560 lines
19 KiB
TypeScript
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_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<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_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<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" },
|
|
],
|
|
};
|