/** * 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', 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(space: string = 'community') { if (!_syncServer) return; // If this space already has proposals, skip if (listProposalDocs(space).length > 0) return; // Ensure space config exists ensureSpaceConfigDoc(space); _syncServer!.changeDoc(spaceConfigDocId(space), 'seed space config', (d) => { if (d.spaceConfig!.name) return; // already configured 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(space, pid); let doc = Automerge.change(Automerge.init(), 'seed proposal', (d) => { const init = proposalSchema.init(); Object.assign(d, init); d.meta.spaceSlug = space; d.proposal.id = pid; d.proposal.spaceSlug = space; 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 for "${space}": 1 space config, 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" } = 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 }); }); // ── Demo page body (reused by / when space=demo, and /demo fallback) ── function renderDemoBody(): string { return `
Interactive Demo

Live Community Polls

These polls are synced in real-time across the entire r* ecosystem via rSpace. Vote on options and watch tallies update live for everyone.

How rVote Works

Quadratic

Voting more costs exponentially. 1 vote = 1 credit, 2 = 4, 3 = 9. No single voice can dominate.

Reddit-style

Upvote or downvote proposals. Scores aggregate from all votes. Best ideas rise to the top.

Vote Decay

Votes fade after 30–60 days. Old support expires naturally. Rankings stay fresh.

Try It

Conviction Voting Simulator

Your credits: 50 / 50

Upvote proposals to rank them. Each additional vote costs quadratically more credits (1, 4, 9, 16...). Proposals re-rank by score in real-time.

Live

Community Polls

Connecting

These polls sync across all r* apps in real-time via WebSocket.

Connecting to rSpace...

Ready to try it for real?

Create a Space for your community to start ranking and voting on proposals. Invite members and allot credits to get started.

`; } // ── Page routes ── // Main route — serves demo page when space=demo, dashboard otherwise routes.get("/", (c) => { const space = c.req.param("space") || "demo"; if (space === "demo") { return c.html(renderShell({ title: "rVote Demo — Interactive Polls | rSpace", moduleId: "rvote", spaceSlug: "demo", modules: getModuleInfoList(), theme: "dark", body: renderDemoBody(), scripts: ``, styles: ``, })); } 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, seedTemplate: seedDemoIfEmpty, 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" }, ], onboardingActions: [ { label: "Create a Proposal", icon: "🗳️", description: "Start a governance vote", type: 'create', href: '/{space}/rvote' }, ], };