/** * 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 } 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); } } initDB(); // ── 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: unknown[] = []; 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(renderShell({ title: `${space} — Vote | rSpace`, moduleId: "vote", spaceSlug: space, modules: getModuleInfoList(), theme: "light", styles: ``, body: ``, scripts: ``, })); }); export const voteModule: RSpaceModule = { id: "vote", name: "rVote", icon: "\u{1F5F3}", description: "Conviction voting engine for collaborative governance", routes, standaloneDomain: "rvote.online", };