/** * Vote module — conviction voting engine. * * Credit-weighted conviction voting for collaborative governance. * Spaces run ranked proposals with configurable parameters. * * All state stored in Automerge documents via SyncServer. * Doc layout: * {space}:vote:config → SpaceConfig (stored on a ProposalDoc) * {space}:vote:proposals:{proposalId} → ProposalDoc with votes/finalVotes */ import { Hono } from "hono"; import * as Automerge from '@automerge/automerge'; import { renderShell } from "../../server/shell"; import { getModuleInfoList } from "../../shared/module"; import type { RSpaceModule } from "../../shared/module"; import { verifyEncryptIDToken, extractToken } from "@encryptid/sdk/server"; import { renderLanding } from "./landing"; import type { SyncServer } from '../../server/local-first/sync-server'; import { proposalSchema, proposalDocId } from './schemas'; import type { ProposalDoc, SpaceConfig } from './schemas'; const routes = new Hono(); // ── SyncServer ref (set during onInit) ── let _syncServer: SyncServer | null = null; // ── DocId helpers ── function spaceConfigDocId(space: string) { return `${space}:vote:config` as const; } // ── Automerge helpers ── /** Ensure a proposal doc exists, creating it if needed. */ function ensureProposalDoc(space: string, proposalId: string): ProposalDoc { const docId = proposalDocId(space, proposalId); let doc = _syncServer!.getDoc(docId); if (!doc) { doc = Automerge.change(Automerge.init(), 'init proposal', (d) => { const init = proposalSchema.init(); Object.assign(d, init); d.meta.spaceSlug = space; d.proposal.id = proposalId; d.proposal.spaceSlug = space; }); _syncServer!.setDoc(docId, doc); } return doc; } /** Ensure a space config doc exists, creating it if needed. Returns the doc. */ function ensureSpaceConfigDoc(space: string): ProposalDoc { const docId = spaceConfigDocId(space); let doc = _syncServer!.getDoc(docId); if (!doc) { doc = Automerge.change(Automerge.init(), 'init space config', (d) => { const init = proposalSchema.init(); Object.assign(d, init); d.meta.spaceSlug = space; d.spaceConfig = { slug: space, name: '', description: '', ownerDid: '', visibility: 'public_read', promotionThreshold: 100, votingPeriodDays: 7, creditsPerDay: 10, maxCredits: 500, startingCredits: 50, createdAt: Date.now(), updatedAt: Date.now(), }; }); _syncServer!.setDoc(docId, doc); } return doc; } /** Get all space config docs across all spaces. */ function listSpaceConfigDocs(): { docId: string; doc: ProposalDoc }[] { if (!_syncServer) return []; const results: { docId: string; doc: ProposalDoc }[] = []; for (const docId of _syncServer.listDocs()) { if (docId.endsWith(':vote:config')) { const doc = _syncServer.getDoc(docId); if (doc?.spaceConfig) results.push({ docId, doc }); } } return results; } /** Get all proposal docs for a space. */ function listProposalDocs(space: string): { docId: string; doc: ProposalDoc }[] { if (!_syncServer) return []; const results: { docId: string; doc: ProposalDoc }[] = []; const prefix = `${space}:vote:proposals:`; for (const docId of _syncServer.listDocs()) { if (docId.startsWith(prefix)) { const doc = _syncServer.getDoc(docId); if (doc) results.push({ docId, doc }); } } return results; } /** Get all proposal docs across all spaces. */ function listAllProposalDocs(): { docId: string; doc: ProposalDoc }[] { if (!_syncServer) return []; const results: { docId: string; doc: ProposalDoc }[] = []; for (const docId of _syncServer.listDocs()) { if (docId.includes(':vote:proposals:')) { const doc = _syncServer.getDoc(docId); if (doc) results.push({ docId, doc }); } } return results; } // ── Conversion helpers (Automerge → REST format) ── function spaceConfigToRest(cfg: SpaceConfig) { return { slug: cfg.slug, name: cfg.name, description: cfg.description, owner_did: cfg.ownerDid, visibility: cfg.visibility, promotion_threshold: cfg.promotionThreshold, voting_period_days: cfg.votingPeriodDays, credits_per_day: cfg.creditsPerDay, max_credits: cfg.maxCredits, starting_credits: cfg.startingCredits, created_at: new Date(cfg.createdAt).toISOString(), updated_at: new Date(cfg.updatedAt).toISOString(), }; } function proposalToRest(doc: ProposalDoc) { const p = doc.proposal; const voteCount = Object.keys(doc.votes).length; return { id: p.id, space_slug: p.spaceSlug, author_id: p.authorId, title: p.title, description: p.description, status: p.status, score: p.score, voting_ends_at: p.votingEndsAt ? new Date(p.votingEndsAt).toISOString() : null, final_yes: p.finalYes, final_no: p.finalNo, final_abstain: p.finalAbstain, vote_count: String(voteCount), created_at: new Date(p.createdAt).toISOString(), updated_at: new Date(p.updatedAt).toISOString(), }; } // ── Helper: calculate effective weight with decay ── function getEffectiveWeight(weight: number, createdAt: number): number { const ageMs = Date.now() - createdAt; const ageDays = ageMs / (1000 * 60 * 60 * 24); if (ageDays < 30) return weight; if (ageDays >= 60) return 0; const decayProgress = (ageDays - 30) / 30; return Math.round(weight * (1 - decayProgress)); } // ── Helper: recalculate proposal score from votes ── function recalcScore(doc: ProposalDoc): number { let score = 0; for (const v of Object.values(doc.votes)) { score += getEffectiveWeight(v.weight, v.createdAt); } return score; } // ── Helper: generate unique ID ── function newId(): string { return crypto.randomUUID(); } // ── Seed demo data into Automerge ── function seedDemoIfEmpty() { const existingSpaces = listSpaceConfigDocs(); if (existingSpaces.length > 0) return; // Create demo space const spaceDoc = ensureSpaceConfigDoc('community'); _syncServer!.changeDoc(spaceConfigDocId('community'), 'seed space config', (d) => { d.spaceConfig!.name = 'Community Governance'; d.spaceConfig!.description = 'Proposals for the rSpace ecosystem'; d.spaceConfig!.ownerDid = 'did:demo:seed'; d.spaceConfig!.promotionThreshold = 100; d.spaceConfig!.votingPeriodDays = 7; d.spaceConfig!.creditsPerDay = 10; d.spaceConfig!.maxCredits = 500; d.spaceConfig!.startingCredits = 50; }); const demoUserId = 'did:demo:seed'; const now = Date.now(); const proposals = [ { title: "Add dark mode across all r* modules", desc: "Implement a consistent dark theme with a toggle in shell.css. Use CSS custom properties for theming so each module inherits automatically.", status: "RANKING", score: 45 }, { title: "Implement real-time collaboration in rNotes", desc: "Use Automerge CRDTs (already in the stack) to enable simultaneous editing of notes, similar to how rSpace canvas works.", status: "RANKING", score: 72 }, { title: "Adopt cosmolocal print-on-demand for all merch", desc: "Route all merchandise orders through the provider registry to find the closest printer. Reduces shipping emissions and supports local economies.", status: "VOTING", score: 105 }, { title: "Use EncryptID passkeys for all authentication", desc: "Standardize on WebAuthn passkeys via EncryptID across the entire r* ecosystem. One passkey, all apps.", status: "PASSED", score: 150 }, { title: "Switch from PostgreSQL to SQLite for simpler deployment", desc: "Evaluate replacing PostgreSQL with SQLite for modules that don't need concurrent writes.", status: "FAILED", score: 30 }, ]; for (const p of proposals) { const pid = newId(); const docId = proposalDocId('community', pid); let doc = Automerge.change(Automerge.init(), 'seed proposal', (d) => { const init = proposalSchema.init(); Object.assign(d, init); d.meta.spaceSlug = 'community'; d.proposal.id = pid; d.proposal.spaceSlug = 'community'; d.proposal.authorId = demoUserId; d.proposal.title = p.title; d.proposal.description = p.desc; d.proposal.status = p.status; d.proposal.score = p.score; d.proposal.createdAt = now; d.proposal.updatedAt = now; }); if (p.status === "VOTING") { doc = Automerge.change(doc, 'set voting tally', (d) => { d.proposal.votingEndsAt = now + 5 * 24 * 60 * 60 * 1000; d.proposal.finalYes = 5; d.proposal.finalNo = 2; }); } else if (p.status === "PASSED") { doc = Automerge.change(doc, 'set passed tally', (d) => { d.proposal.finalYes = 12; d.proposal.finalNo = 3; d.proposal.finalAbstain = 2; }); } else if (p.status === "FAILED") { doc = Automerge.change(doc, 'set failed tally', (d) => { d.proposal.finalYes = 2; d.proposal.finalNo = 8; d.proposal.finalAbstain = 1; }); } _syncServer!.setDoc(docId, doc); } console.log("[Vote] Demo data seeded: 1 space, 5 proposals"); } // ── Spaces API ── // GET /api/spaces — list spaces routes.get("/api/spaces", (c) => { const spaceDocs = listSpaceConfigDocs(); const spaces = spaceDocs .filter((s) => s.doc.spaceConfig !== null) .map((s) => spaceConfigToRest(s.doc.spaceConfig!)) .sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()) .slice(0, 50); return c.json({ spaces }); }); // POST /api/spaces — create space routes.post("/api/spaces", async (c) => { const token = extractToken(c.req.raw.headers); if (!token) return c.json({ error: "Authentication required" }, 401); let claims; try { claims = await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } const body = await c.req.json(); const { name, slug, description, visibility = "public_read" } = body; if (!name || !slug) return c.json({ error: "name and slug required" }, 400); if (!/^[a-z0-9-]+$/.test(slug)) return c.json({ error: "Invalid slug" }, 400); // Check if space already exists const existing = _syncServer!.getDoc(spaceConfigDocId(slug)); if (existing?.spaceConfig?.name) return c.json({ error: "Space already exists" }, 409); const now = Date.now(); const doc = ensureSpaceConfigDoc(slug); _syncServer!.changeDoc(spaceConfigDocId(slug), 'create space', (d) => { d.spaceConfig!.slug = slug; d.spaceConfig!.name = name; d.spaceConfig!.description = description || ''; d.spaceConfig!.ownerDid = claims.sub; d.spaceConfig!.visibility = visibility; d.spaceConfig!.createdAt = now; d.spaceConfig!.updatedAt = now; }); const updated = _syncServer!.getDoc(spaceConfigDocId(slug)); return c.json(spaceConfigToRest(updated!.spaceConfig!), 201); }); // GET /api/spaces/:slug — space detail routes.get("/api/spaces/:slug", (c) => { const slug = c.req.param("slug"); const doc = _syncServer!.getDoc(spaceConfigDocId(slug)); if (!doc?.spaceConfig?.name) return c.json({ error: "Space not found" }, 404); return c.json(spaceConfigToRest(doc.spaceConfig)); }); // ── Proposals API ── // GET /api/proposals — list proposals (query: space_slug, status) routes.get("/api/proposals", (c) => { const { space_slug, status, limit = "50", offset = "0" } = c.req.query(); const maxLimit = Math.min(parseInt(limit) || 50, 100); const startOffset = parseInt(offset) || 0; let docs: { docId: string; doc: ProposalDoc }[]; if (space_slug) { docs = listProposalDocs(space_slug); } else { docs = listAllProposalDocs(); } let proposals = docs .filter((d) => d.doc.proposal.title) // exclude empty/config docs .map((d) => proposalToRest(d.doc)); if (status) { proposals = proposals.filter((p) => p.status === status); } // Sort by score descending, then created_at descending proposals.sort((a, b) => { if (b.score !== a.score) return b.score - a.score; return new Date(b.created_at).getTime() - new Date(a.created_at).getTime(); }); proposals = proposals.slice(startOffset, startOffset + maxLimit); return c.json({ proposals }); }); // POST /api/proposals — create proposal routes.post("/api/proposals", async (c) => { const token = extractToken(c.req.raw.headers); if (!token) return c.json({ error: "Authentication required" }, 401); let claims; try { claims = await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } const body = await c.req.json(); const { space_slug, title, description } = body; if (!space_slug || !title) return c.json({ error: "space_slug and title required" }, 400); // Verify space exists const spaceDoc = _syncServer!.getDoc(spaceConfigDocId(space_slug)); if (!spaceDoc?.spaceConfig?.name) return c.json({ error: "Space not found" }, 404); const pid = newId(); const now = Date.now(); const docId = proposalDocId(space_slug, pid); const doc = Automerge.change(Automerge.init(), 'create proposal', (d) => { const init = proposalSchema.init(); Object.assign(d, init); d.meta.spaceSlug = space_slug; d.proposal.id = pid; d.proposal.spaceSlug = space_slug; d.proposal.authorId = claims.sub; d.proposal.title = title; d.proposal.description = description || ''; d.proposal.createdAt = now; d.proposal.updatedAt = now; }); _syncServer!.setDoc(docId, doc); return c.json(proposalToRest(doc), 201); }); // GET /api/proposals/:id — proposal detail routes.get("/api/proposals/:id", (c) => { const id = c.req.param("id"); // Search across all spaces for this proposal const allDocs = listAllProposalDocs(); const match = allDocs.find((d) => d.doc.proposal.id === id); if (!match) return c.json({ error: "Proposal not found" }, 404); return c.json(proposalToRest(match.doc)); }); // POST /api/proposals/:id/vote — cast conviction vote routes.post("/api/proposals/:id/vote", async (c) => { const token = extractToken(c.req.raw.headers); if (!token) return c.json({ error: "Authentication required" }, 401); let claims; try { claims = await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } const id = c.req.param("id"); const body = await c.req.json(); const { weight = 1 } = body; // Find proposal const allDocs = listAllProposalDocs(); const match = allDocs.find((d) => d.doc.proposal.id === id); if (!match) return c.json({ error: "Proposal not found" }, 404); if (match.doc.proposal.status !== "RANKING") return c.json({ error: "Proposal not in ranking phase" }, 400); const userId = claims.sub; const creditCost = weight * weight; // quadratic cost const now = Date.now(); const thirtyDaysMs = 30 * 24 * 60 * 60 * 1000; // Upsert vote (keyed by userId) _syncServer!.changeDoc(match.docId, 'cast conviction vote', (d) => { d.votes[userId] = { id: d.votes[userId]?.id || newId(), userId, proposalId: id, weight, creditCost, createdAt: now, decaysAt: now + thirtyDaysMs, }; }); // Re-read doc, recalculate score const updatedDoc = _syncServer!.getDoc(match.docId)!; const score = recalcScore(updatedDoc); // Update score on the doc _syncServer!.changeDoc(match.docId, 'update score', (d) => { d.proposal.score = score; d.proposal.updatedAt = Date.now(); }); // Check for promotion to VOTING phase const spaceSlug = updatedDoc.proposal.spaceSlug; const spaceDoc = _syncServer!.getDoc(spaceConfigDocId(spaceSlug)); const threshold = spaceDoc?.spaceConfig?.promotionThreshold || 100; if (score >= threshold && updatedDoc.proposal.status === "RANKING") { const votingDays = spaceDoc?.spaceConfig?.votingPeriodDays || 7; const votingEndsAt = Date.now() + votingDays * 24 * 60 * 60 * 1000; _syncServer!.changeDoc(match.docId, 'promote to voting', (d) => { d.proposal.status = 'VOTING'; d.proposal.votingEndsAt = votingEndsAt; d.proposal.updatedAt = Date.now(); }); } return c.json({ ok: true, score, creditCost }); }); // POST /api/proposals/:id/final-vote — cast binary vote routes.post("/api/proposals/:id/final-vote", async (c) => { const token = extractToken(c.req.raw.headers); if (!token) return c.json({ error: "Authentication required" }, 401); let claims; try { claims = await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } const id = c.req.param("id"); const body = await c.req.json(); const { vote } = body; if (!["YES", "NO", "ABSTAIN"].includes(vote)) return c.json({ error: "Invalid vote" }, 400); // Find proposal const allDocs = listAllProposalDocs(); const match = allDocs.find((d) => d.doc.proposal.id === id); if (!match) return c.json({ error: "Proposal not found" }, 404); if (match.doc.proposal.status !== "VOTING") return c.json({ error: "Proposal not in voting phase" }, 400); const userId = claims.sub; const now = Date.now(); // Upsert final vote (keyed by userId) _syncServer!.changeDoc(match.docId, 'cast final vote', (d) => { d.finalVotes[userId] = { id: d.finalVotes[userId]?.id || newId(), userId, proposalId: id, vote: vote as 'YES' | 'NO' | 'ABSTAIN', createdAt: now, }; }); // Tally final votes const updatedDoc = _syncServer!.getDoc(match.docId)!; const tally: Record = { YES: 0, NO: 0, ABSTAIN: 0 }; for (const fv of Object.values(updatedDoc.finalVotes)) { tally[fv.vote] = (tally[fv.vote] || 0) + 1; } _syncServer!.changeDoc(match.docId, 'update final tally', (d) => { d.proposal.finalYes = tally.YES; d.proposal.finalNo = tally.NO; d.proposal.finalAbstain = tally.ABSTAIN; d.proposal.updatedAt = Date.now(); }); return c.json({ ok: true, tally }); }); // ── Page route ── routes.get("/", (c) => { const space = c.req.param("space") || "demo"; return c.html(renderShell({ title: `${space} — Vote | rSpace`, moduleId: "rvote", spaceSlug: space, modules: getModuleInfoList(), theme: "dark", body: ``, scripts: ``, styles: ``, })); }); export const voteModule: RSpaceModule = { id: "rvote", name: "rVote", icon: "🗳", description: "Conviction voting engine for collaborative governance", scoping: { defaultScope: 'space', userConfigurable: false }, docSchemas: [{ pattern: '{space}:vote:proposals:{proposalId}', description: 'Proposal with votes', init: proposalSchema.init }], routes, standaloneDomain: "rvote.online", landingPage: renderLanding, async onInit(ctx) { _syncServer = ctx.syncServer; seedDemoIfEmpty(); }, feeds: [ { id: "proposals", name: "Proposals", kind: "governance", description: "Active proposals with conviction scores and vote tallies", filterable: true, }, { id: "decisions", name: "Decision Outcomes", kind: "governance", description: "Passed and failed proposals — governance decisions made", }, ], acceptsFeeds: ["economic", "data"], outputPaths: [ { path: "proposals", name: "Proposals", icon: "📜", description: "Governance proposals for conviction voting" }, { path: "ballots", name: "Ballots", icon: "🗳️", description: "Voting ballots and results" }, ], };