/** * Vote module — conviction voting engine. * * Credit-weighted conviction voting for collaborative governance. * Spaces run ranked proposals with configurable parameters. */ import { Hono } from "hono"; import { readFileSync } from "node:fs"; import { resolve } from "node:path"; import { sql } from "../../shared/db/pool"; import { renderShell, renderIframeShell } from "../../server/shell"; import { getModuleInfoList } from "../../shared/module"; import type { RSpaceModule } from "../../shared/module"; import { verifyEncryptIDToken, extractToken } from "@encryptid/sdk/server"; const routes = new Hono(); // ── DB initialization ── const SCHEMA_SQL = readFileSync(resolve(import.meta.dir, "db/schema.sql"), "utf-8"); async function initDB() { try { await sql.unsafe(SCHEMA_SQL); console.log("[Vote] DB schema initialized"); } catch (e) { console.error("[Vote] DB init error:", e); } } async function seedDemoIfEmpty() { try { const count = await sql.unsafe("SELECT count(*)::int as cnt FROM rvote.spaces"); if (parseInt(count[0].cnt) > 0) return; // Create demo user const user = await sql.unsafe( `INSERT INTO rvote.users (did, username) VALUES ('did:demo:seed', 'demo') ON CONFLICT (did) DO UPDATE SET username = 'demo' RETURNING id` ); const userId = user[0].id; // Create voting space await sql.unsafe( `INSERT INTO rvote.spaces (slug, name, description, owner_did, visibility, promotion_threshold) VALUES ('community', 'Community Governance', 'Proposals for the rSpace ecosystem', 'did:demo:seed', 'public', 100)` ); // Seed proposals in various states 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 row = await sql.unsafe( `INSERT INTO rvote.proposals (space_slug, author_id, title, description, status, score) VALUES ('community', $1, $2, $3, $4, $5) RETURNING id`, [userId, p.title, p.desc, p.status, p.score] ); if (p.status === "VOTING") { await sql.unsafe( `UPDATE rvote.proposals SET voting_ends_at = NOW() + INTERVAL '5 days', final_yes = 5, final_no = 2 WHERE id = $1`, [row[0].id] ); } else if (p.status === "PASSED") { await sql.unsafe( `UPDATE rvote.proposals SET final_yes = 12, final_no = 3, final_abstain = 2 WHERE id = $1`, [row[0].id] ); } else if (p.status === "FAILED") { await sql.unsafe( `UPDATE rvote.proposals SET final_yes = 2, final_no = 8, final_abstain = 1 WHERE id = $1`, [row[0].id] ); } } console.log("[Vote] Demo data seeded: 1 space, 5 proposals"); } catch (e) { console.error("[Vote] Seed error:", e); } } initDB().then(seedDemoIfEmpty); // ── Helper: get or create user by DID ── async function getOrCreateUser(did: string, username?: string) { const rows = await sql.unsafe( `INSERT INTO rvote.users (did, username) VALUES ($1, $2) ON CONFLICT (did) DO UPDATE SET username = COALESCE($2, rvote.users.username) RETURNING *`, [did, username || null] ); return rows[0]; } // ── Helper: calculate effective weight with decay ── function getEffectiveWeight(weight: number, createdAt: Date): number { const ageMs = Date.now() - createdAt.getTime(); 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 ── async function recalcScore(proposalId: string) { const votes = await sql.unsafe( "SELECT weight, created_at FROM rvote.votes WHERE proposal_id = $1", [proposalId] ); let score = 0; for (const v of votes) { score += getEffectiveWeight(v.weight, new Date(v.created_at)); } await sql.unsafe( "UPDATE rvote.proposals SET score = $1, updated_at = NOW() WHERE id = $2", [score, proposalId] ); return score; } // ── Spaces API ── // GET /api/spaces — list spaces routes.get("/api/spaces", async (c) => { const rows = await sql.unsafe( "SELECT * FROM rvote.spaces ORDER BY created_at DESC LIMIT 50" ); return c.json({ spaces: rows }); }); // 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); try { const rows = await sql.unsafe( `INSERT INTO rvote.spaces (slug, name, description, owner_did, visibility) VALUES ($1, $2, $3, $4, $5) RETURNING *`, [slug, name, description || null, claims.sub, visibility] ); return c.json(rows[0], 201); } catch (e: any) { if (e.code === "23505") return c.json({ error: "Space already exists" }, 409); throw e; } }); // GET /api/spaces/:slug — space detail routes.get("/api/spaces/:slug", async (c) => { const slug = c.req.param("slug"); const rows = await sql.unsafe("SELECT * FROM rvote.spaces WHERE slug = $1", [slug]); if (rows.length === 0) return c.json({ error: "Space not found" }, 404); return c.json(rows[0]); }); // ── Proposals API ── // GET /api/proposals — list proposals (query: space_slug, status) routes.get("/api/proposals", async (c) => { const { space_slug, status, limit = "50", offset = "0" } = c.req.query(); const conditions: string[] = []; const params: any[] = []; let idx = 1; if (space_slug) { conditions.push(`space_slug = $${idx}`); params.push(space_slug); idx++; } if (status) { conditions.push(`status = $${idx}`); params.push(status); idx++; } const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : ""; const rows = await sql.unsafe( `SELECT * FROM rvote.proposals ${where} ORDER BY score DESC, created_at DESC LIMIT ${Math.min(parseInt(limit), 100)} OFFSET ${parseInt(offset) || 0}`, params ); return c.json({ proposals: rows }); }); // 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 space = await sql.unsafe("SELECT slug FROM rvote.spaces WHERE slug = $1", [space_slug]); if (space.length === 0) return c.json({ error: "Space not found" }, 404); const user = await getOrCreateUser(claims.sub, claims.username); const rows = await sql.unsafe( `INSERT INTO rvote.proposals (space_slug, author_id, title, description) VALUES ($1, $2, $3, $4) RETURNING *`, [space_slug, user.id, title, description || null] ); return c.json(rows[0], 201); }); // GET /api/proposals/:id — proposal detail routes.get("/api/proposals/:id", async (c) => { const id = c.req.param("id"); const rows = await sql.unsafe( `SELECT p.*, (SELECT count(*) FROM rvote.votes WHERE proposal_id = p.id) as vote_count FROM rvote.proposals p WHERE p.id = $1`, [id] ); if (rows.length === 0) return c.json({ error: "Proposal not found" }, 404); return c.json(rows[0]); }); // 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; // Verify proposal is in RANKING const proposal = await sql.unsafe( "SELECT * FROM rvote.proposals WHERE id = $1", [id] ); if (proposal.length === 0) return c.json({ error: "Proposal not found" }, 404); if (proposal[0].status !== "RANKING") return c.json({ error: "Proposal not in ranking phase" }, 400); const user = await getOrCreateUser(claims.sub, claims.username); const creditCost = weight * weight; // quadratic cost // Upsert vote await sql.unsafe( `INSERT INTO rvote.votes (user_id, proposal_id, weight, credit_cost, decays_at) VALUES ($1, $2, $3, $4, NOW() + INTERVAL '30 days') ON CONFLICT (user_id, proposal_id) DO UPDATE SET weight = $3, credit_cost = $4, created_at = NOW(), decays_at = NOW() + INTERVAL '30 days'`, [user.id, id, weight, creditCost] ); // Recalculate score and check for promotion const score = await recalcScore(id); const space = await sql.unsafe( "SELECT * FROM rvote.spaces WHERE slug = $1", [proposal[0].space_slug] ); const threshold = space[0]?.promotion_threshold || 100; if (score >= threshold && proposal[0].status === "RANKING") { const votingDays = space[0]?.voting_period_days || 7; await sql.unsafe( `UPDATE rvote.proposals SET status = 'VOTING', voting_ends_at = NOW() + ($1 || ' days')::INTERVAL, updated_at = NOW() WHERE id = $2`, [votingDays, id] ); } 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); const proposal = await sql.unsafe("SELECT * FROM rvote.proposals WHERE id = $1", [id]); if (proposal.length === 0) return c.json({ error: "Proposal not found" }, 404); if (proposal[0].status !== "VOTING") return c.json({ error: "Proposal not in voting phase" }, 400); const user = await getOrCreateUser(claims.sub, claims.username); await sql.unsafe( `INSERT INTO rvote.final_votes (user_id, proposal_id, vote) VALUES ($1, $2, $3) ON CONFLICT (user_id, proposal_id) DO UPDATE SET vote = $3`, [user.id, id, vote] ); // Update counts const counts = await sql.unsafe( `SELECT vote, count(*) as cnt FROM rvote.final_votes WHERE proposal_id = $1 GROUP BY vote`, [id] ); const tally: Record = { YES: 0, NO: 0, ABSTAIN: 0 }; for (const row of counts) tally[row.vote] = parseInt(row.cnt); await sql.unsafe( "UPDATE rvote.proposals SET final_yes = $1, final_no = $2, final_abstain = $3, updated_at = NOW() WHERE id = $4", [tally.YES, tally.NO, tally.ABSTAIN, id] ); return c.json({ ok: true, tally }); }); // ── Page route ── routes.get("/", (c) => { const space = c.req.param("space") || "demo"; return c.html(renderIframeShell({ title: `${space} — Vote | rSpace`, moduleId: "vote", spaceSlug: space, modules: getModuleInfoList(), theme: "dark", standaloneDomain: "rvote.online", })); }); export const voteModule: RSpaceModule = { id: "vote", name: "rVote", icon: "\u{1F5F3}", description: "Conviction voting engine for collaborative governance", routes, standaloneDomain: "rvote.online", 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"], };