rspace-online/modules/rvote/mod.ts

560 lines
19 KiB
TypeScript

/**
* 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<ProposalDoc>(docId);
if (!doc) {
doc = Automerge.change(Automerge.init<ProposalDoc>(), '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<ProposalDoc>(docId);
if (!doc) {
doc = Automerge.change(Automerge.init<ProposalDoc>(), '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<ProposalDoc>(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<ProposalDoc>(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<ProposalDoc>(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<ProposalDoc>(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<ProposalDoc>(), '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<ProposalDoc>(spaceConfigDocId(slug));
if (existing?.spaceConfig?.name) return c.json({ error: "Space already exists" }, 409);
const now = Date.now();
const doc = ensureSpaceConfigDoc(slug);
_syncServer!.changeDoc<ProposalDoc>(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<ProposalDoc>(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<ProposalDoc>(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<ProposalDoc>(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<ProposalDoc>(), '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<ProposalDoc>(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<ProposalDoc>(match.docId)!;
const score = recalcScore(updatedDoc);
// Update score on the doc
_syncServer!.changeDoc<ProposalDoc>(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<ProposalDoc>(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<ProposalDoc>(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<ProposalDoc>(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<ProposalDoc>(match.docId)!;
const tally: Record<string, number> = { YES: 0, NO: 0, ABSTAIN: 0 };
for (const fv of Object.values(updatedDoc.finalVotes)) {
tally[fv.vote] = (tally[fv.vote] || 0) + 1;
}
_syncServer!.changeDoc<ProposalDoc>(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: `<folk-vote-dashboard space="${space}"></folk-vote-dashboard>`,
scripts: `<script type="module" src="/modules/rvote/folk-vote-dashboard.js"></script>`,
styles: `<link rel="stylesheet" href="/modules/rvote/vote.css">`,
}));
});
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" },
],
};