diff --git a/api/_lib/actionLocksStore.ts b/api/_lib/actionLocksStore.ts deleted file mode 100644 index 993731c..0000000 --- a/api/_lib/actionLocksStore.ts +++ /dev/null @@ -1,158 +0,0 @@ -import { eq } from "drizzle-orm"; - -import { userActionLocks } from "../../db/schema.ts"; -import { createDb } from "./db.ts"; - -type Env = Record; - -export type ActionLock = { - address: string; - reason: string | null; - lockedUntil: string; -}; - -export type ActionLocksStore = { - getActiveLock: (address: string) => Promise; - listActiveLocks: () => Promise; - setLock: (input: { - address: string; - lockedUntil: Date; - reason?: string | null; - }) => Promise; - clearLock: (address: string) => Promise; -}; - -const memoryLocks = new Map< - string, - { lockedUntilMs: number; reason: string | null } ->(); - -function normalizeAddress(address: string): string { - return address.trim(); -} - -function nowMs(): number { - return Date.now(); -} - -export function createActionLocksStore(env: Env): ActionLocksStore { - if (!env.DATABASE_URL || env.READ_MODELS_INLINE === "true") { - return { - getActiveLock: async (address) => { - const normalized = normalizeAddress(address); - const lock = memoryLocks.get(normalized); - if (!lock) return null; - if (lock.lockedUntilMs <= nowMs()) return null; - return { - address: normalized, - reason: lock.reason, - lockedUntil: new Date(lock.lockedUntilMs).toISOString(), - }; - }, - listActiveLocks: async () => { - const now = nowMs(); - const result: ActionLock[] = []; - for (const [address, lock] of memoryLocks.entries()) { - if (lock.lockedUntilMs <= now) continue; - result.push({ - address, - reason: lock.reason, - lockedUntil: new Date(lock.lockedUntilMs).toISOString(), - }); - } - result.sort((a, b) => a.address.localeCompare(b.address)); - return result; - }, - setLock: async ({ address, lockedUntil, reason }) => { - const normalized = normalizeAddress(address); - memoryLocks.set(normalized, { - lockedUntilMs: lockedUntil.getTime(), - reason: reason ?? null, - }); - }, - clearLock: async (address) => { - const normalized = normalizeAddress(address); - memoryLocks.delete(normalized); - }, - }; - } - - const db = createDb(env); - - return { - getActiveLock: async (address) => { - const normalized = normalizeAddress(address); - const rows = await db - .select({ - address: userActionLocks.address, - reason: userActionLocks.reason, - lockedUntil: userActionLocks.lockedUntil, - }) - .from(userActionLocks) - .where(eq(userActionLocks.address, normalized)) - .limit(1); - const row = rows[0]; - if (!row) return null; - if (row.lockedUntil.getTime() <= nowMs()) return null; - return { - address: row.address, - reason: row.reason ?? null, - lockedUntil: row.lockedUntil.toISOString(), - }; - }, - listActiveLocks: async () => { - const now = new Date(); - const rows = await db - .select({ - address: userActionLocks.address, - reason: userActionLocks.reason, - lockedUntil: userActionLocks.lockedUntil, - }) - .from(userActionLocks); - const filtered = rows - .filter((r) => r.lockedUntil.getTime() > now.getTime()) - .map((r) => ({ - address: r.address, - reason: r.reason ?? null, - lockedUntil: r.lockedUntil.toISOString(), - })); - filtered.sort((a, b) => a.address.localeCompare(b.address)); - return filtered; - }, - setLock: async ({ address, lockedUntil, reason }) => { - const normalized = normalizeAddress(address); - const now = new Date(); - await db - .insert(userActionLocks) - .values({ - address: normalized, - lockedUntil, - reason: reason ?? null, - updatedAt: now, - }) - .onConflictDoUpdate({ - target: userActionLocks.address, - set: { lockedUntil, reason: reason ?? null, updatedAt: now }, - }); - }, - clearLock: async (address) => { - const normalized = normalizeAddress(address); - await db - .delete(userActionLocks) - .where(eq(userActionLocks.address, normalized)); - }, - }; -} - -export function clearActionLocksForTests(): void { - memoryLocks.clear(); -} - -export async function setActionLockForTests(input: { - env: Env; - address: string; - lockedUntil: Date; - reason?: string | null; -}): Promise { - await createActionLocksStore(input.env).setLock(input); -} diff --git a/api/_lib/address.ts b/api/_lib/address.ts deleted file mode 100644 index 1e2fdd0..0000000 --- a/api/_lib/address.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { cryptoWaitReady, decodeAddress } from "@polkadot/util-crypto"; -import { u8aToHex } from "@polkadot/util"; -import { encodeAddress } from "@polkadot/util-crypto"; - -// Humanode mainnet SS58 format (produces `hm...`-prefixed addresses). -export const HUMANODE_SS58_FORMAT = 5234; - -export async function addressToPublicKeyHex( - address: string, -): Promise { - const trimmed = address.trim(); - if (!trimmed) return null; - await cryptoWaitReady(); - try { - return u8aToHex(decodeAddress(trimmed)); - } catch { - return null; - } -} - -export async function canonicalizeHmndAddress( - address: string, -): Promise { - const trimmed = address.trim(); - if (!trimmed) return null; - await cryptoWaitReady(); - try { - // decodeAddress accepts any SS58 format; re-encode into the Humanode prefix. - return encodeAddress(trimmed, HUMANODE_SS58_FORMAT); - } catch { - return null; - } -} - -export async function addressesReferToSameKey( - a: string, - b: string, -): Promise { - const left = a.trim(); - const right = b.trim(); - if (!left || !right) return false; - if (left === right) return true; - const [pkA, pkB] = await Promise.all([ - addressToPublicKeyHex(left), - addressToPublicKeyHex(right), - ]); - return Boolean(pkA && pkB && pkA === pkB); -} diff --git a/api/_lib/adminAuditStore.ts b/api/_lib/adminAuditStore.ts deleted file mode 100644 index 74d1926..0000000 --- a/api/_lib/adminAuditStore.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { and, desc, eq, lt } from "drizzle-orm"; - -import { events } from "../../db/schema.ts"; -import { createDb } from "./db.ts"; - -type Env = Record; - -export type AdminAuditAction = - | "user.lock" - | "user.unlock" - | "writes.freeze" - | "writes.unfreeze"; - -export type AdminAuditItem = { - id: string; - action: AdminAuditAction; - targetAddress: string; - lockedUntil?: string; - reason?: string | null; - timestamp: string; -}; - -type MemoryEntry = AdminAuditItem & { createdAtMs: number }; - -const memoryAudit: MemoryEntry[] = []; - -function normalizeAddress(address: string): string { - return address.trim(); -} - -export async function appendAdminAudit( - env: Env, - input: Omit & { - timestamp?: string; - }, -): Promise { - const timestamp = input.timestamp ?? new Date().toISOString(); - const targetAddress = normalizeAddress(input.targetAddress); - const id = `admin:${input.action}:${targetAddress}:${timestamp}`; - const item: AdminAuditItem = { - id, - action: input.action, - targetAddress, - ...(input.lockedUntil ? { lockedUntil: input.lockedUntil } : {}), - ...(input.reason !== undefined ? { reason: input.reason } : {}), - timestamp, - }; - - if (!env.DATABASE_URL) { - memoryAudit.push({ ...item, createdAtMs: Date.now() }); - return item; - } - - const db = createDb(env); - await db.insert(events).values({ - type: "admin.action.v1", - stage: null, - actorAddress: null, - entityType: "admin", - entityId: id, - payload: item, - createdAt: new Date(timestamp), - }); - - return item; -} - -export async function listAdminAudit( - env: Env, - input: { beforeSeq?: number | null; limit: number }, -): Promise<{ items: AdminAuditItem[]; nextSeq?: number }> { - if (!env.DATABASE_URL) { - const sorted = [...memoryAudit].sort( - (a, b) => b.createdAtMs - a.createdAtMs, - ); - const page = sorted - .slice(0, input.limit) - .map(({ createdAtMs: _ms, ...rest }) => rest); - return { items: page }; - } - - const db = createDb(env); - const beforeSeq = input.beforeSeq; - const hasBeforeSeq = beforeSeq !== undefined && beforeSeq !== null; - const whereClause = hasBeforeSeq - ? and( - eq(events.type, "admin.action.v1"), - lt(events.seq, Math.max(0, beforeSeq)), - ) - : eq(events.type, "admin.action.v1"); - - const ordered = await db - .select({ seq: events.seq, payload: events.payload }) - .from(events) - .where(whereClause) - .orderBy(desc(events.seq)) - .limit(input.limit + 1); - const slice = ordered.slice(0, input.limit); - const items = slice.map((r) => r.payload as AdminAuditItem); - const nextSeq = - ordered.length > input.limit ? ordered[input.limit]?.seq : undefined; - - return nextSeq !== undefined ? { items, nextSeq } : { items }; -} - -export function clearAdminAuditForTests(): void { - memoryAudit.length = 0; -} diff --git a/api/_lib/adminStateStore.ts b/api/_lib/adminStateStore.ts deleted file mode 100644 index 7eaeb5d..0000000 --- a/api/_lib/adminStateStore.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { eq } from "drizzle-orm"; - -import { adminState } from "../../db/schema.ts"; -import { createDb } from "./db.ts"; - -type Env = Record; - -export type AdminStateSnapshot = { - writesFrozen: boolean; -}; - -export type AdminStateStore = { - get: () => Promise; - setWritesFrozen: (writesFrozen: boolean) => Promise; -}; - -let memoryWritesFrozen = false; - -export function createAdminStateStore(env: Env): AdminStateStore { - if (!env.DATABASE_URL || env.READ_MODELS_INLINE === "true") { - return { - get: async () => ({ writesFrozen: memoryWritesFrozen }), - setWritesFrozen: async (writesFrozen) => { - memoryWritesFrozen = writesFrozen; - }, - }; - } - - const db = createDb(env); - const rowId = 1; - - async function ensureRow(): Promise { - await db - .insert(adminState) - .values({ id: rowId, writesFrozen: false }) - .onConflictDoNothing(); - } - - return { - get: async () => { - await ensureRow(); - const rows = await db - .select({ writesFrozen: adminState.writesFrozen }) - .from(adminState) - .where(eq(adminState.id, rowId)) - .limit(1); - return { writesFrozen: rows[0]?.writesFrozen ?? false }; - }, - setWritesFrozen: async (writesFrozen) => { - await ensureRow(); - await db - .insert(adminState) - .values({ id: rowId, writesFrozen, updatedAt: new Date() }) - .onConflictDoUpdate({ - target: adminState.id, - set: { writesFrozen, updatedAt: new Date() }, - }); - }, - }; -} - -export function clearAdminStateForTests(): void { - memoryWritesFrozen = false; -} diff --git a/api/_lib/apiRateLimitStore.ts b/api/_lib/apiRateLimitStore.ts deleted file mode 100644 index e32f039..0000000 --- a/api/_lib/apiRateLimitStore.ts +++ /dev/null @@ -1,124 +0,0 @@ -import { eq } from "drizzle-orm"; - -import { apiRateLimits } from "../../db/schema.ts"; -import { createDb } from "./db.ts"; -import { envInt } from "./env.ts"; - -type Env = Record; - -type ConsumeInput = { - bucket: string; - limit: number; - windowSeconds: number; -}; - -type ConsumeResult = - | { ok: true; remaining: number; resetAt: string } - | { ok: false; retryAfterSeconds: number; resetAt: string }; - -export type ApiRateLimitStore = { - consume: (input: ConsumeInput) => Promise; -}; - -const memoryBuckets = new Map(); - -function nowMs(): number { - return Date.now(); -} - -function consumeFromMemory(input: ConsumeInput): ConsumeResult { - const key = input.bucket; - const now = nowMs(); - const windowMs = input.windowSeconds * 1000; - const current = memoryBuckets.get(key); - const resetAtMs = - current && current.resetAtMs > now ? current.resetAtMs : now + windowMs; - const count = current && current.resetAtMs > now ? current.count : 0; - const nextCount = count + 1; - - memoryBuckets.set(key, { count: nextCount, resetAtMs }); - - const resetAt = new Date(resetAtMs).toISOString(); - if (nextCount > input.limit) { - const retryAfterSeconds = Math.max(1, Math.ceil((resetAtMs - now) / 1000)); - return { ok: false, retryAfterSeconds, resetAt }; - } - - return { ok: true, remaining: Math.max(0, input.limit - nextCount), resetAt }; -} - -export function createApiRateLimitStore(env: Env): ApiRateLimitStore { - if (!env.DATABASE_URL || env.READ_MODELS_INLINE === "true") { - return { - consume: async (input) => consumeFromMemory(input), - }; - } - - const db = createDb(env); - - return { - consume: async (input) => { - const now = new Date(); - const windowMs = input.windowSeconds * 1000; - - const rows = await db - .select({ - bucket: apiRateLimits.bucket, - count: apiRateLimits.count, - resetAt: apiRateLimits.resetAt, - }) - .from(apiRateLimits) - .where(eq(apiRateLimits.bucket, input.bucket)) - .limit(1); - const row = rows[0]; - - const active = row && row.resetAt.getTime() > now.getTime(); - const nextResetAt = active - ? row.resetAt - : new Date(now.getTime() + windowMs); - const nextCount = (active ? row.count : 0) + 1; - - await db - .insert(apiRateLimits) - .values({ - bucket: input.bucket, - count: nextCount, - resetAt: nextResetAt, - updatedAt: now, - }) - .onConflictDoUpdate({ - target: apiRateLimits.bucket, - set: { count: nextCount, resetAt: nextResetAt, updatedAt: now }, - }); - - const resetAtIso = nextResetAt.toISOString(); - if (nextCount > input.limit) { - const retryAfterSeconds = Math.max( - 1, - Math.ceil((nextResetAt.getTime() - now.getTime()) / 1000), - ); - return { ok: false, retryAfterSeconds, resetAt: resetAtIso }; - } - return { - ok: true, - remaining: Math.max(0, input.limit - nextCount), - resetAt: resetAtIso, - }; - }, - }; -} - -export function getCommandRateLimitConfig(env: Env): { - perIpPerMinute: number; - perAddressPerMinute: number; -} { - return { - perIpPerMinute: envInt(env, "SIM_COMMAND_RATE_LIMIT_PER_MINUTE_IP") ?? 180, - perAddressPerMinute: - envInt(env, "SIM_COMMAND_RATE_LIMIT_PER_MINUTE_ADDRESS") ?? 60, - }; -} - -export function clearApiRateLimitsForTests(): void { - memoryBuckets.clear(); -} diff --git a/api/_lib/appendEvents.ts b/api/_lib/appendEvents.ts deleted file mode 100644 index fffb978..0000000 --- a/api/_lib/appendEvents.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { and, eq } from "drizzle-orm"; - -import { events } from "../../db/schema.ts"; -import { createDb } from "./db.ts"; -import { feedItemSchema, type FeedItemEventPayload } from "./eventSchemas.ts"; -import { - appendMemoryFeedEvent, - hasMemoryFeedEvent, -} from "./feedEventsMemory.ts"; - -type Env = Record; - -export async function feedItemEventExists( - env: Env, - input: { entityType: string; entityId: string }, -): Promise { - if (!env.DATABASE_URL) { - return hasMemoryFeedEvent(input); - } - - const db = createDb(env); - const rows = await db - .select({ seq: events.seq }) - .from(events) - .where( - and( - eq(events.type, "feed.item.v1"), - eq(events.entityType, input.entityType), - eq(events.entityId, input.entityId), - ), - ) - .limit(1); - return rows.length > 0; -} - -export async function appendFeedItemEvent( - env: Env, - input: { - stage: FeedItemEventPayload["stage"]; - actorAddress?: string; - entityType: string; - entityId: string; - payload: FeedItemEventPayload; - }, -): Promise { - const payload = feedItemSchema.parse(input.payload); - - if (!env.DATABASE_URL) { - appendMemoryFeedEvent({ - stage: input.stage, - actorAddress: input.actorAddress ?? null, - entityType: input.entityType, - entityId: input.entityId, - payload, - }); - return; - } - - const db = createDb(env); - await db.insert(events).values({ - type: "feed.item.v1", - stage: input.stage, - actorAddress: input.actorAddress ?? null, - entityType: input.entityType, - entityId: input.entityId, - payload, - createdAt: new Date(payload.timestamp), - }); -} - -export async function appendFeedItemEventOnce( - env: Env, - input: Parameters[1], -): Promise { - const exists = await feedItemEventExists(env, { - entityType: input.entityType, - entityId: input.entityId, - }); - if (exists) return false; - await appendFeedItemEvent(env, input); - return true; -} diff --git a/api/_lib/auth.ts b/api/_lib/auth.ts deleted file mode 100644 index a522674..0000000 --- a/api/_lib/auth.ts +++ /dev/null @@ -1,165 +0,0 @@ -import { parseCookieHeader, serializeCookie } from "./cookies.ts"; -import { envBoolean, envString } from "./env.ts"; -import { randomHex } from "./random.ts"; -import { signToken, verifyToken } from "./tokens.ts"; - -export type Env = Record; - -export type Session = { - address: string; - issuedAt: number; - expiresAt: number; -}; - -export type NonceToken = { - address: string; - nonce: string; - issuedAt: number; - expiresAt: number; -}; - -const SESSION_COOKIE = "vortex_session"; -const NONCE_COOKIE = "vortex_nonce"; - -function shouldUseSecureCookies(env: Env, requestUrl: string): boolean { - if (envBoolean(env, "DEV_INSECURE_COOKIES")) return false; - return requestUrl.startsWith("https://"); -} - -export function getSessionCookieName(): string { - return SESSION_COOKIE; -} - -export function getNonceCookieName(): string { - return NONCE_COOKIE; -} - -export function getSessionSecret(env: Env): string | undefined { - return envString(env, "SESSION_SECRET"); -} - -export async function issueNonce( - headers: Headers, - env: Env, - requestUrl: string, - address: string, -): Promise<{ nonce: string; expiresAt: number }> { - const secret = getSessionSecret(env); - const nonce = randomHex(16); - const issuedAt = Date.now(); - const expiresAt = issuedAt + 10 * 60_000; - - if (!secret) throw new Error("SESSION_SECRET is required to issue nonces"); - const token = await signToken( - { address, nonce, issuedAt, expiresAt } satisfies NonceToken, - secret, - ); - - const secure = shouldUseSecureCookies(env, requestUrl); - headers.append( - "set-cookie", - serializeCookie(NONCE_COOKIE, token, { - httpOnly: true, - secure, - sameSite: "Lax", - maxAgeSeconds: Math.floor((expiresAt - issuedAt) / 1000), - }), - ); - - return { nonce, expiresAt }; -} - -export async function verifyNonceCookie( - request: Request, - env: Env, -): Promise { - const secret = getSessionSecret(env); - if (!secret) return null; - const cookies = parseCookieHeader(request.headers.get("cookie")); - const token = cookies.get(NONCE_COOKIE); - if (!token) return null; - const payload = await verifyToken(token, secret); - if (!payload) return null; - if (typeof payload.address !== "string") return null; - if (typeof payload.nonce !== "string") return null; - if (typeof payload.issuedAt !== "number") return null; - if (typeof payload.expiresAt !== "number") return null; - if (Date.now() > payload.expiresAt) return null; - return { - address: payload.address, - nonce: payload.nonce, - issuedAt: payload.issuedAt, - expiresAt: payload.expiresAt, - }; -} - -export async function issueSession( - headers: Headers, - env: Env, - requestUrl: string, - address: string, -): Promise { - const secret = getSessionSecret(env); - if (!secret) throw new Error("SESSION_SECRET is required to create sessions"); - const issuedAt = Date.now(); - const expiresAt = issuedAt + 14 * 24 * 60 * 60_000; - const token = await signToken( - { address, issuedAt, expiresAt } satisfies Session, - secret, - ); - const secure = shouldUseSecureCookies(env, requestUrl); - headers.append( - "set-cookie", - serializeCookie(SESSION_COOKIE, token, { - httpOnly: true, - secure, - sameSite: "Lax", - maxAgeSeconds: Math.floor((expiresAt - issuedAt) / 1000), - }), - ); -} - -export async function clearSession( - headers: Headers, - env: Env, - requestUrl: string, -): Promise { - const secure = shouldUseSecureCookies(env, requestUrl); - headers.append( - "set-cookie", - serializeCookie(SESSION_COOKIE, "", { - httpOnly: true, - secure, - sameSite: "Lax", - maxAgeSeconds: 0, - }), - ); -} - -export async function readSession( - request: Request, - env: Env, -): Promise { - const secret = getSessionSecret(env); - if (!secret) return null; - const cookies = parseCookieHeader(request.headers.get("cookie")); - const token = cookies.get(SESSION_COOKIE); - if (!token) return null; - const payload = await verifyToken(token, secret); - if (!payload) return null; - if (typeof payload.address !== "string") return null; - if (typeof payload.issuedAt !== "number") return null; - if (typeof payload.expiresAt !== "number") return null; - if (Date.now() > payload.expiresAt) return null; - return { - address: payload.address, - issuedAt: payload.issuedAt, - expiresAt: payload.expiresAt, - }; -} - -export type GateResult = { - eligible: boolean; - reason?: string; - expiresAt: string; -}; diff --git a/api/_lib/base64url.ts b/api/_lib/base64url.ts deleted file mode 100644 index 8ebc2cf..0000000 --- a/api/_lib/base64url.ts +++ /dev/null @@ -1,17 +0,0 @@ -export function base64UrlEncode(input: Uint8Array): string { - let binary = ""; - for (const byte of input) binary += String.fromCharCode(byte); - const base64 = btoa(binary); - return base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, ""); -} - -export function base64UrlDecode(input: string): Uint8Array { - const base64 = input - .replace(/-/g, "+") - .replace(/_/g, "/") - .padEnd(input.length + ((4 - (input.length % 4)) % 4), "="); - const binary = atob(base64); - const bytes = new Uint8Array(binary.length); - for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); - return bytes; -} diff --git a/api/_lib/chamberActiveDenominators.ts b/api/_lib/chamberActiveDenominators.ts deleted file mode 100644 index a0b778b..0000000 --- a/api/_lib/chamberActiveDenominators.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { and, eq } from "drizzle-orm"; - -import { eraRollups, eraUserStatus } from "../../db/schema.ts"; -import { createDb } from "./db.ts"; -import { createClockStore } from "./clockStore.ts"; -import { - listAllChamberMembers, - listChamberMembers, -} from "./chamberMembershipsStore.ts"; -import { getActiveAddressesForNextEraFromRollup } from "./eraRollupStore.ts"; - -type Env = Record; - -export async function getEligibleGovernorAddressesForChamber( - env: Env, - input: { chamberId: string; genesisMembers?: string[] | null }, -): Promise> { - const chamberId = input.chamberId.trim().toLowerCase(); - const out = new Set(); - - const members = - chamberId === "general" - ? await listAllChamberMembers(env) - : await listChamberMembers(env, chamberId); - for (const address of members) out.add(address.trim()); - - for (const address of input.genesisMembers ?? []) out.add(address.trim()); - - out.delete(""); - return out; -} - -export async function getActiveGovernorAddressesForCurrentEra( - env: Env, -): Promise | null> { - const clock = createClockStore(env); - const { currentEra } = await clock.get(); - const priorEra = currentEra - 1; - if (priorEra < 0) return null; - - if (!env.DATABASE_URL) { - return getActiveAddressesForNextEraFromRollup(env, { era: priorEra }); - } - - const db = createDb(env); - const rollupExists = await db - .select({ era: eraRollups.era }) - .from(eraRollups) - .where(eq(eraRollups.era, priorEra)) - .limit(1); - if (!rollupExists[0]) return null; - - const rows = await db - .select({ address: eraUserStatus.address }) - .from(eraUserStatus) - .where( - and( - eq(eraUserStatus.era, priorEra), - eq(eraUserStatus.isActiveNextEra, true), - ), - ); - - return new Set(rows.map((r) => r.address.trim()).filter(Boolean)); -} - -export async function getActiveGovernorsDenominatorForChamberCurrentEra( - env: Env, - input: { - chamberId: string; - fallbackActiveGovernors: number; - genesisMembers?: string[] | null; - }, -): Promise { - const chamberId = input.chamberId.trim().toLowerCase(); - - const eligible = await getEligibleGovernorAddressesForChamber(env, { - chamberId, - genesisMembers: input.genesisMembers ?? null, - }); - - const eligibleCount = eligible.size; - if (eligibleCount === 0) return 0; - - const activeSet = await getActiveGovernorAddressesForCurrentEra(env); - if (!activeSet) { - return Math.max(0, Math.min(input.fallbackActiveGovernors, eligibleCount)); - } - - let activeInChamber = 0; - for (const address of activeSet) { - if (eligible.has(address.trim())) activeInChamber += 1; - } - - return Math.max(0, Math.min(activeInChamber, eligibleCount)); -} diff --git a/api/_lib/chamberMembershipsStore.ts b/api/_lib/chamberMembershipsStore.ts deleted file mode 100644 index 39666ef..0000000 --- a/api/_lib/chamberMembershipsStore.ts +++ /dev/null @@ -1,174 +0,0 @@ -import { and, eq, sql } from "drizzle-orm"; - -import { chamberMemberships } from "../../db/schema.ts"; -import { createDb } from "./db.ts"; - -type Env = Record; - -const memoryByAddress = new Map>(); - -function normalizeChamberId(value: string): string { - return value.trim().toLowerCase(); -} - -function normalizeAddress(value: string): string { - return value.trim(); -} - -export async function hasChamberMembership( - env: Env, - input: { address: string; chamberId: string }, -): Promise { - const address = normalizeAddress(input.address); - const chamberId = normalizeChamberId(input.chamberId); - if (!env.DATABASE_URL) { - const chambers = memoryByAddress.get(address); - if (!chambers) return false; - return chambers.has(chamberId); - } - - const db = createDb(env); - const rows = await db - .select({ chamberId: chamberMemberships.chamberId }) - .from(chamberMemberships) - .where( - and( - eq(chamberMemberships.address, address), - eq(chamberMemberships.chamberId, chamberId), - ), - ) - .limit(1); - return rows.length > 0; -} - -export async function hasAnyChamberMembership( - env: Env, - addressInput: string, -): Promise { - const address = normalizeAddress(addressInput); - if (!env.DATABASE_URL) { - const chambers = memoryByAddress.get(address); - return Boolean(chambers && chambers.size > 0); - } - - const db = createDb(env); - const rows = await db - .select({ n: sql`count(*)` }) - .from(chamberMemberships) - .where(eq(chamberMemberships.address, address)) - .limit(1); - return Number(rows[0]?.n ?? 0) > 0; -} - -export async function listChamberMemberships( - env: Env, - addressInput: string, -): Promise { - const address = normalizeAddress(addressInput); - if (!env.DATABASE_URL) { - return Array.from(memoryByAddress.get(address) ?? []).sort(); - } - - const db = createDb(env); - const rows = await db - .select({ chamberId: chamberMemberships.chamberId }) - .from(chamberMemberships) - .where(eq(chamberMemberships.address, address)); - return rows.map((r) => r.chamberId).sort(); -} - -export async function listChamberMembers( - env: Env, - chamberIdInput: string, -): Promise { - const chamberId = normalizeChamberId(chamberIdInput); - if (!env.DATABASE_URL) { - const members: string[] = []; - for (const [address, chambers] of memoryByAddress.entries()) { - if (chambers.has(chamberId)) members.push(address); - } - return members.sort(); - } - - const db = createDb(env); - const rows = await db - .select({ address: chamberMemberships.address }) - .from(chamberMemberships) - .where(eq(chamberMemberships.chamberId, chamberId)); - return rows.map((r) => r.address).sort(); -} - -export async function listAllChamberMembers(env: Env): Promise { - if (!env.DATABASE_URL) { - return Array.from(memoryByAddress.keys()).sort(); - } - - const db = createDb(env); - const rows = await db - .select({ address: chamberMemberships.address }) - .from(chamberMemberships) - .groupBy(chamberMemberships.address); - return rows.map((r) => r.address).sort(); -} - -export async function ensureChamberMembership( - env: Env, - input: { - address: string; - chamberId: string; - grantedByProposalId?: string | null; - source?: string; - }, -): Promise { - const address = normalizeAddress(input.address); - const chamberId = normalizeChamberId(input.chamberId); - const source = - (input.source ?? "accepted_proposal").trim() || "accepted_proposal"; - - if (!env.DATABASE_URL) { - const chambers = memoryByAddress.get(address) ?? new Set(); - chambers.add(chamberId); - memoryByAddress.set(address, chambers); - return; - } - - const db = createDb(env); - await db - .insert(chamberMemberships) - .values({ - address, - chamberId, - grantedByProposalId: input.grantedByProposalId ?? null, - source, - createdAt: new Date(), - }) - .onConflictDoNothing({ - target: [chamberMemberships.chamberId, chamberMemberships.address], - }); -} - -export async function grantVotingEligibilityForAcceptedProposal( - env: Env, - input: { address: string; chamberId: string | null; proposalId: string }, -): Promise { - await ensureChamberMembership(env, { - address: input.address, - chamberId: "general", - grantedByProposalId: input.proposalId, - source: "accepted_proposal", - }); - - const chamberId = normalizeChamberId(input.chamberId ?? ""); - if (chamberId && chamberId !== "general") { - await ensureChamberMembership(env, { - address: input.address, - chamberId, - grantedByProposalId: input.proposalId, - source: "accepted_proposal", - }); - } -} - -export function clearChamberMembershipsForTests(): void { - memoryByAddress.clear(); -} diff --git a/api/_lib/chamberMultiplierSubmissionsStore.ts b/api/_lib/chamberMultiplierSubmissionsStore.ts deleted file mode 100644 index 27370ea..0000000 --- a/api/_lib/chamberMultiplierSubmissionsStore.ts +++ /dev/null @@ -1,142 +0,0 @@ -import { and, eq, sql } from "drizzle-orm"; - -import { chamberMultiplierSubmissions } from "../../db/schema.ts"; -import { createDb } from "./db.ts"; - -type Env = Record; - -export type ChamberMultiplierSubmission = { - chamberId: string; - voterAddress: string; - multiplierTimes10: number; - createdAt: Date; - updatedAt: Date; -}; - -const memory = new Map(); - -function keyFor(chamberId: string, voterAddress: string): string { - return `${chamberId.trim().toLowerCase()}:${voterAddress.trim()}`; -} - -export async function upsertChamberMultiplierSubmission( - env: Env, - input: { - chamberId: string; - voterAddress: string; - multiplierTimes10: number; - }, -): Promise<{ submission: ChamberMultiplierSubmission; created: boolean }> { - const chamberId = input.chamberId.trim().toLowerCase(); - const voterAddress = input.voterAddress.trim(); - const multiplierTimes10 = Math.floor(input.multiplierTimes10); - - const now = new Date(); - - if (!env.DATABASE_URL) { - const k = keyFor(chamberId, voterAddress); - const existing = memory.get(k); - if (existing) { - const next: ChamberMultiplierSubmission = { - ...existing, - multiplierTimes10, - updatedAt: now, - }; - memory.set(k, next); - return { submission: next, created: false }; - } - const created: ChamberMultiplierSubmission = { - chamberId, - voterAddress, - multiplierTimes10, - createdAt: now, - updatedAt: now, - }; - memory.set(k, created); - return { submission: created, created: true }; - } - - const db = createDb(env); - - const existingRows = await db - .select({ - chamberId: chamberMultiplierSubmissions.chamberId, - voterAddress: chamberMultiplierSubmissions.voterAddress, - multiplierTimes10: chamberMultiplierSubmissions.multiplierTimes10, - createdAt: chamberMultiplierSubmissions.createdAt, - updatedAt: chamberMultiplierSubmissions.updatedAt, - }) - .from(chamberMultiplierSubmissions) - .where( - and( - eq(chamberMultiplierSubmissions.chamberId, chamberId), - eq(chamberMultiplierSubmissions.voterAddress, voterAddress), - ), - ) - .limit(1); - - await db - .insert(chamberMultiplierSubmissions) - .values({ - chamberId, - voterAddress, - multiplierTimes10, - createdAt: now, - updatedAt: now, - }) - .onConflictDoUpdate({ - target: [ - chamberMultiplierSubmissions.chamberId, - chamberMultiplierSubmissions.voterAddress, - ], - set: { multiplierTimes10, updatedAt: now }, - }); - - const existing = existingRows[0] ?? null; - const submission: ChamberMultiplierSubmission = { - chamberId, - voterAddress, - multiplierTimes10, - createdAt: existing?.createdAt ?? now, - updatedAt: now, - }; - - return { submission, created: existing === null }; -} - -export async function getChamberMultiplierAggregate( - env: Env, - input: { chamberId: string }, -): Promise<{ submissions: number; avgTimes10: number | null }> { - const chamberId = input.chamberId.trim().toLowerCase(); - - if (!env.DATABASE_URL) { - const rows = Array.from(memory.values()).filter( - (row) => row.chamberId === chamberId, - ); - if (rows.length === 0) return { submissions: 0, avgTimes10: null }; - const sum = rows.reduce((acc, row) => acc + row.multiplierTimes10, 0); - const avg = Math.round(sum / rows.length); - return { submissions: rows.length, avgTimes10: avg }; - } - - const db = createDb(env); - const rows = await db - .select({ - count: sql`count(*)`, - avg: sql`avg(${chamberMultiplierSubmissions.multiplierTimes10})`, - }) - .from(chamberMultiplierSubmissions) - .where(eq(chamberMultiplierSubmissions.chamberId, chamberId)) - .limit(1); - - const count = Number(rows[0]?.count ?? 0); - if (count === 0) return { submissions: 0, avgTimes10: null }; - const avg = rows[0]?.avg; - const rounded = Number.isFinite(Number(avg)) ? Math.round(Number(avg)) : null; - return { submissions: count, avgTimes10: rounded }; -} - -export function clearChamberMultiplierSubmissionsForTests(): void { - memory.clear(); -} diff --git a/api/_lib/chamberQuorum.ts b/api/_lib/chamberQuorum.ts deleted file mode 100644 index dfe281a..0000000 --- a/api/_lib/chamberQuorum.ts +++ /dev/null @@ -1,55 +0,0 @@ -export type ChamberQuorumInputs = { - quorumFraction: number; // fraction, e.g. 0.33 - activeGovernors: number; // denominator - passingFraction: number; // fraction, e.g. 2/3 - minQuorum?: number; // absolute minimum engaged governors (optional) -}; - -export type ChamberCounts = { yes: number; no: number; abstain: number }; - -export type ChamberQuorumResult = { - engaged: number; - quorumNeeded: number; - quorumMet: boolean; - yesFraction: number; - passMet: boolean; - shouldAdvance: boolean; -}; - -export function evaluateChamberQuorum( - inputs: ChamberQuorumInputs, - counts: ChamberCounts, -): ChamberQuorumResult { - const active = Math.max(0, Math.floor(inputs.activeGovernors)); - const quorumFraction = Math.max(0, Math.min(1, inputs.quorumFraction)); - const passingFraction = Math.max(0, Math.min(1, inputs.passingFraction)); - const minQuorum = Math.min( - active, - Math.max(0, Math.floor(inputs.minQuorum ?? 0)), - ); - - const yes = Math.max(0, counts.yes); - const no = Math.max(0, counts.no); - const abstain = Math.max(0, counts.abstain); - const engaged = yes + no + abstain; - - const quorumNeeded = - active > 0 ? Math.max(minQuorum, Math.ceil(active * quorumFraction)) : 0; - const quorumMet = active > 0 ? engaged >= quorumNeeded : false; - - const yesFraction = engaged > 0 ? yes / engaged : 0; - // Passing rule: strict supermajority (e.g. 66.6% + 1 yes vote within quorum). - // In discrete votes this means: yes > passingFraction * engaged. - const passNeeded = - engaged > 0 ? Math.floor(engaged * passingFraction) + 1 : 0; - const passMet = engaged > 0 ? yes >= passNeeded : false; - - return { - engaged, - quorumNeeded, - quorumMet, - yesFraction, - passMet, - shouldAdvance: quorumMet && passMet, - }; -} diff --git a/api/_lib/chamberVotesStore.ts b/api/_lib/chamberVotesStore.ts deleted file mode 100644 index 68fff6f..0000000 --- a/api/_lib/chamberVotesStore.ts +++ /dev/null @@ -1,255 +0,0 @@ -import { and, eq, sql } from "drizzle-orm"; - -import { chamberVotes } from "../../db/schema.ts"; -import { createDb } from "./db.ts"; -import { getDelegationWeightsForChamber } from "./delegationsStore.ts"; - -type Env = Record; - -export type ChamberVoteChoice = 1 | 0 | -1; - -export type ChamberVoteCounts = { - yes: number; - no: number; - abstain: number; -}; - -type StoredChamberVote = { choice: ChamberVoteChoice; score?: number | null }; -const memoryVotes = new Map>(); - -export async function hasChamberVote( - env: Env, - input: { proposalId: string; voterAddress: string }, -): Promise { - const voterAddress = input.voterAddress.trim(); - if (!env.DATABASE_URL) { - const byVoter = memoryVotes.get(input.proposalId); - if (!byVoter) return false; - return byVoter.has(voterAddress); - } - const db = createDb(env); - const existing = await db - .select({ choice: chamberVotes.choice }) - .from(chamberVotes) - .where( - and( - eq(chamberVotes.proposalId, input.proposalId), - eq(chamberVotes.voterAddress, voterAddress), - ), - ) - .limit(1); - return existing.length > 0; -} - -export async function castChamberVote( - env: Env, - input: { - proposalId: string; - voterAddress: string; - choice: ChamberVoteChoice; - score?: number | null; - chamberId?: string; - }, -): Promise<{ counts: ChamberVoteCounts; created: boolean }> { - if (!env.DATABASE_URL) { - const byVoter = - memoryVotes.get(input.proposalId) ?? new Map(); - const voterKey = input.voterAddress.trim(); - const created = !byVoter.has(voterKey); - byVoter.set(voterKey, { - choice: input.choice, - score: input.score ?? null, - }); - memoryVotes.set(input.proposalId, byVoter); - return { - counts: await getChamberVoteCounts(env, input.proposalId, { - chamberId: input.chamberId, - }), - created, - }; - } - - const db = createDb(env); - const voterAddress = input.voterAddress.trim(); - const existing = await db - .select({ choice: chamberVotes.choice }) - .from(chamberVotes) - .where( - and( - eq(chamberVotes.proposalId, input.proposalId), - eq(chamberVotes.voterAddress, voterAddress), - ), - ) - .limit(1); - const created = existing.length === 0; - const now = new Date(); - await db - .insert(chamberVotes) - .values({ - proposalId: input.proposalId, - voterAddress, - choice: input.choice, - score: input.score ?? null, - createdAt: now, - updatedAt: now, - }) - .onConflictDoUpdate({ - target: [chamberVotes.proposalId, chamberVotes.voterAddress], - set: { choice: input.choice, score: input.score ?? null, updatedAt: now }, - }); - - return { - counts: await getChamberVoteCounts(env, input.proposalId, { - chamberId: input.chamberId, - }), - created, - }; -} - -export async function getChamberVoteCounts( - env: Env, - proposalId: string, - input?: { chamberId?: string }, -): Promise { - const chamberId = input?.chamberId?.trim().toLowerCase(); - if (!env.DATABASE_URL) { - return chamberId - ? countWeightedFromMemory(env, proposalId, chamberId) - : countMemory(proposalId); - } - - const db = createDb(env); - if (!chamberId) { - const rows = await db - .select({ - yes: sql`sum(case when ${chamberVotes.choice} = 1 then 1 else 0 end)`, - no: sql`sum(case when ${chamberVotes.choice} = -1 then 1 else 0 end)`, - abstain: sql`sum(case when ${chamberVotes.choice} = 0 then 1 else 0 end)`, - }) - .from(chamberVotes) - .where(eq(chamberVotes.proposalId, proposalId)); - - const row = rows[0]; - return { - yes: Number(row?.yes ?? 0), - no: Number(row?.no ?? 0), - abstain: Number(row?.abstain ?? 0), - }; - } - - const voteRows = await db - .select({ - voterAddress: chamberVotes.voterAddress, - choice: chamberVotes.choice, - }) - .from(chamberVotes) - .where(eq(chamberVotes.proposalId, proposalId)); - - const voters = new Set(voteRows.map((r) => r.voterAddress)); - const weights = await getDelegationWeightsForChamber(env, { - chamberId, - excludedDelegators: voters, - }); - - let yes = 0; - let no = 0; - let abstain = 0; - for (const row of voteRows) { - const w = 1 + (weights.get(row.voterAddress) ?? 0); - if (row.choice === 1) yes += w; - if (row.choice === -1) no += w; - if (row.choice === 0) abstain += w; - } - return { yes, no, abstain }; -} - -export async function clearChamberVotesForTests() { - memoryVotes.clear(); -} - -export async function clearChamberVotesForProposal( - env: Env, - proposalId: string, -): Promise { - if (!env.DATABASE_URL) { - memoryVotes.delete(proposalId); - return; - } - - const db = createDb(env); - await db.delete(chamberVotes).where(eq(chamberVotes.proposalId, proposalId)); -} - -function countMemory(proposalId: string): ChamberVoteCounts { - const byVoter = memoryVotes.get(proposalId); - if (!byVoter) return { yes: 0, no: 0, abstain: 0 }; - let yes = 0; - let no = 0; - let abstain = 0; - for (const vote of byVoter.values()) { - if (vote.choice === 1) yes += 1; - if (vote.choice === -1) no += 1; - if (vote.choice === 0) abstain += 1; - } - return { yes, no, abstain }; -} - -async function countWeightedFromMemory( - env: Env, - proposalId: string, - chamberId: string, -): Promise { - const byVoter = memoryVotes.get(proposalId); - if (!byVoter) return { yes: 0, no: 0, abstain: 0 }; - - const voters = new Set(byVoter.keys()); - const weights = await getDelegationWeightsForChamber(env, { - chamberId, - excludedDelegators: voters, - }); - - let yes = 0; - let no = 0; - let abstain = 0; - for (const [voter, vote] of byVoter.entries()) { - const w = 1 + (weights.get(voter) ?? 0); - if (vote.choice === 1) yes += w; - if (vote.choice === -1) no += w; - if (vote.choice === 0) abstain += w; - } - return { yes, no, abstain }; -} - -export async function getChamberYesScoreAverage( - env: Env, - proposalId: string, -): Promise { - if (!env.DATABASE_URL) return getYesScoreAverageFromMemory(proposalId); - - const db = createDb(env); - const rows = await db - .select({ - avg: sql`avg(${chamberVotes.score})`, - }) - .from(chamberVotes) - .where( - sql`${chamberVotes.proposalId} = ${proposalId} and ${chamberVotes.choice} = 1`, - ); - const avg = rows[0]?.avg ?? null; - return avg === null ? null : Number(avg); -} - -function getYesScoreAverageFromMemory(proposalId: string): number | null { - const byVoter = memoryVotes.get(proposalId); - if (!byVoter) return null; - let sum = 0; - let n = 0; - for (const vote of byVoter.values()) { - if (vote.choice !== 1) continue; - if (typeof vote.score !== "number") continue; - sum += vote.score; - n += 1; - } - if (n === 0) return null; - return sum / n; -} diff --git a/api/_lib/chambersStore.ts b/api/_lib/chambersStore.ts deleted file mode 100644 index 4a51cea..0000000 --- a/api/_lib/chambersStore.ts +++ /dev/null @@ -1,616 +0,0 @@ -import { and, eq, inArray, isNull, sql } from "drizzle-orm"; - -import { - chambers as chambersTable, - chambers, - chamberMemberships, - cmAwards, - proposals, -} from "../../db/schema.ts"; -import { createDb } from "./db.ts"; -import { getSimConfig } from "./simConfig.ts"; -import { createReadModelsStore } from "./readModelsStore.ts"; -import { - listAllChamberMembers, - listChamberMembers, -} from "./chamberMembershipsStore.ts"; -import { listCmAwards } from "./cmAwardsStore.ts"; -import { listProposals } from "./proposalsStore.ts"; - -type Env = Record; - -export type ChamberStatus = "active" | "dissolved"; - -export type ChamberRecord = { - id: string; - title: string; - status: ChamberStatus; - multiplierTimes10: number; - createdAt: Date; - updatedAt: Date; - dissolvedAt: Date | null; -}; - -const memory = new Map(); - -const DEFAULT_GENESIS_CHAMBERS: { - id: string; - title: string; - multiplier: number; -}[] = [{ id: "general", title: "General", multiplier: 1.2 }]; - -function normalizeId(value: string): string { - return value.trim().toLowerCase(); -} - -async function upsertChambersReadModel( - env: Env, - input: { - action: "create" | "dissolve"; - id: string; - title?: string; - multiplier?: number; - }, -): Promise { - if ( - env.READ_MODELS_INLINE !== "true" && - env.READ_MODELS_INLINE_EMPTY !== "true" - ) { - return; - } - const store = await createReadModelsStore(env).catch(() => null); - if (!store?.set) return; - - const payload = await store.get("chambers:list"); - const existing = - payload && - typeof payload === "object" && - !Array.isArray(payload) && - Array.isArray((payload as { items?: unknown[] }).items) - ? (payload as { items: unknown[] }).items - : []; - - const normalizedId = normalizeId(input.id); - const nextItems = existing.filter((item) => { - if (!item || typeof item !== "object" || Array.isArray(item)) return true; - return ( - String((item as { id?: string }).id ?? "").toLowerCase() !== normalizedId - ); - }); - - if (input.action === "create") { - const multiplier = - typeof input.multiplier === "number" && Number.isFinite(input.multiplier) - ? input.multiplier - : 1; - nextItems.push({ - id: normalizedId, - name: input.title?.trim() || normalizedId, - multiplier, - stats: { governors: "0", acm: "0", mcm: "0", lcm: "0" }, - pipeline: { pool: 0, vote: 0, build: 0 }, - status: "active", - }); - - await store.set(`chambers:${normalizedId}`, { - proposals: [], - governors: [], - threads: [], - chatLog: [], - stageOptions: [ - { value: "upcoming", label: "Upcoming" }, - { value: "live", label: "Live" }, - { value: "ended", label: "Ended" }, - ], - }); - } - - await store.set("chambers:list", { - ...(payload && typeof payload === "object" && !Array.isArray(payload) - ? payload - : {}), - items: nextItems, - }); -} - -function getGenesisChambersFromConfig( - cfg: unknown, -): typeof DEFAULT_GENESIS_CHAMBERS { - const config = cfg as { - genesisChambers?: { id: string; title: string; multiplier: number }[]; - } | null; - return config?.genesisChambers && config.genesisChambers.length > 0 - ? config.genesisChambers - : DEFAULT_GENESIS_CHAMBERS; -} - -export async function ensureGenesisChambers( - env: Env, - requestUrl: string, -): Promise { - const cfg = await getSimConfig(env, requestUrl); - const genesis = getGenesisChambersFromConfig(cfg); - const now = new Date(); - - if (!env.DATABASE_URL) { - if (memory.size > 0) return; - for (const chamber of genesis) { - const id = normalizeId(chamber.id); - if (!id) continue; - memory.set(id, { - id, - title: chamber.title.trim() || id, - status: "active", - multiplierTimes10: Math.round((chamber.multiplier || 1) * 10), - createdAt: now, - updatedAt: now, - dissolvedAt: null, - }); - } - return; - } - - const db = createDb(env); - const rows = await db - .select({ n: sql`count(*)` }) - .from(chambers) - .limit(1); - if (Number(rows[0]?.n ?? 0) > 0) return; - - await db.insert(chambers).values( - genesis.map((chamber) => ({ - id: normalizeId(chamber.id), - title: chamber.title.trim() || chamber.id, - status: "active", - multiplierTimes10: Math.round((chamber.multiplier || 1) * 10), - createdByProposalId: null, - dissolvedByProposalId: null, - metadata: {}, - createdAt: now, - updatedAt: now, - dissolvedAt: null, - })), - ); -} - -export async function getChamber( - env: Env, - requestUrl: string, - chamberId: string, -): Promise { - await ensureGenesisChambers(env, requestUrl); - const id = normalizeId(chamberId); - - if (!env.DATABASE_URL) return memory.get(id) ?? null; - - const db = createDb(env); - const rows = await db - .select({ - id: chambers.id, - title: chambers.title, - status: chambers.status, - multiplierTimes10: chambers.multiplierTimes10, - createdAt: chambers.createdAt, - updatedAt: chambers.updatedAt, - dissolvedAt: chambers.dissolvedAt, - }) - .from(chambers) - .where(eq(chambers.id, id)) - .limit(1); - const row = rows[0]; - if (!row) return null; - return { - id: row.id, - title: row.title, - status: row.status as ChamberStatus, - multiplierTimes10: row.multiplierTimes10, - createdAt: row.createdAt, - updatedAt: row.updatedAt, - dissolvedAt: row.dissolvedAt ?? null, - }; -} - -export async function listChambers( - env: Env, - requestUrl: string, - input?: { includeDissolved?: boolean }, -): Promise { - await ensureGenesisChambers(env, requestUrl); - const includeDissolved = Boolean(input?.includeDissolved); - - if (!env.DATABASE_URL) { - const rows = Array.from(memory.values()); - return rows - .filter((c) => includeDissolved || c.status === "active") - .sort((a, b) => a.title.localeCompare(b.title)); - } - - const db = createDb(env); - const base = db - .select({ - id: chambers.id, - title: chambers.title, - status: chambers.status, - multiplierTimes10: chambers.multiplierTimes10, - createdAt: chambers.createdAt, - updatedAt: chambers.updatedAt, - dissolvedAt: chambers.dissolvedAt, - }) - .from(chambers); - const rows = includeDissolved - ? await base - : await base.where(eq(chambers.status, "active")); - return rows - .map((row) => ({ - id: row.id, - title: row.title, - status: row.status as ChamberStatus, - multiplierTimes10: row.multiplierTimes10, - createdAt: row.createdAt, - updatedAt: row.updatedAt, - dissolvedAt: row.dissolvedAt ?? null, - })) - .sort((a, b) => a.title.localeCompare(b.title)); -} - -export async function createChamberFromAcceptedGeneralProposal( - env: Env, - requestUrl: string, - input: { - id: string; - title: string; - multiplier?: number; - proposalId: string; - }, -): Promise { - await ensureGenesisChambers(env, requestUrl); - const id = normalizeId(input.id); - if (!id || id === "general") return; - - const now = new Date(); - const multiplierTimes10 = Math.round(((input.multiplier ?? 1) || 1) * 10); - - if (!env.DATABASE_URL) { - if (memory.has(id)) return; - memory.set(id, { - id, - title: input.title.trim() || id, - status: "active", - multiplierTimes10, - createdAt: now, - updatedAt: now, - dissolvedAt: null, - }); - await upsertChambersReadModel(env, { - action: "create", - id, - title: input.title, - multiplier: input.multiplier, - }); - return; - } - - const db = createDb(env); - await db - .insert(chambers) - .values({ - id, - title: input.title.trim() || id, - status: "active", - multiplierTimes10, - createdByProposalId: input.proposalId, - dissolvedByProposalId: null, - metadata: {}, - createdAt: now, - updatedAt: now, - dissolvedAt: null, - }) - .onConflictDoNothing({ target: chambers.id }); - - await upsertChambersReadModel(env, { - action: "create", - id, - title: input.title, - multiplier: input.multiplier, - }); -} - -export async function dissolveChamberFromAcceptedGeneralProposal( - env: Env, - requestUrl: string, - input: { id: string; proposalId: string }, -): Promise { - await ensureGenesisChambers(env, requestUrl); - const id = normalizeId(input.id); - if (!id || id === "general") return; - - const now = new Date(); - - if (!env.DATABASE_URL) { - const existing = memory.get(id); - if (!existing || existing.status === "dissolved") return; - memory.set(id, { - ...existing, - status: "dissolved", - dissolvedAt: now, - updatedAt: now, - }); - await upsertChambersReadModel(env, { action: "dissolve", id }); - return; - } - - const db = createDb(env); - await db - .update(chambers) - .set({ - status: "dissolved", - dissolvedAt: now, - dissolvedByProposalId: input.proposalId, - updatedAt: now, - }) - .where(and(eq(chambers.id, id), isNull(chambers.dissolvedAt))); - - await upsertChambersReadModel(env, { action: "dissolve", id }); -} - -export function parseChamberGovernanceFromPayload(payload: unknown): { - action: "chamber.create" | "chamber.dissolve"; - id: string; - title?: string; - multiplier?: number; -} | null { - if (!payload || typeof payload !== "object" || Array.isArray(payload)) - return null; - const record = payload as Record; - const mg = record.metaGovernance; - if (!mg || typeof mg !== "object" || Array.isArray(mg)) return null; - const meta = mg as Record; - const action = typeof meta.action === "string" ? meta.action : ""; - if (action !== "chamber.create" && action !== "chamber.dissolve") return null; - const id = - typeof meta.chamberId === "string" - ? meta.chamberId - : typeof meta.id === "string" - ? meta.id - : ""; - const title = - typeof meta.title === "string" - ? meta.title - : typeof meta.name === "string" - ? meta.name - : undefined; - const multiplier = - typeof meta.multiplier === "number" ? meta.multiplier : undefined; - return { action, id, title, multiplier }; -} - -export async function getChamberMultiplierTimes10( - env: Env, - requestUrl: string, - chamberIdInput: string, -): Promise { - const id = normalizeId(chamberIdInput); - const chamber = await getChamber(env, requestUrl, id); - return chamber?.multiplierTimes10 ?? 10; -} - -export async function projectChamberPipeline( - env: Env, - input: { chamberId: string }, -): Promise<{ pool: number; vote: number; build: number }> { - const chamberId = normalizeId(input.chamberId); - - if (!env.DATABASE_URL) { - const items = await listProposals(env); - let pool = 0; - let vote = 0; - let build = 0; - for (const proposal of items) { - const proposalChamberId = normalizeId(proposal.chamberId ?? "general"); - if (proposalChamberId !== chamberId) continue; - if (proposal.stage === "pool") pool += 1; - else if (proposal.stage === "vote") vote += 1; - else if (proposal.stage === "build") build += 1; - } - return { pool, vote, build }; - } - const db = createDb(env); - - const rows = await db - .select({ - stage: proposals.stage, - count: sql`count(*)`, - }) - .from(proposals) - .where(eq(proposals.chamberId, chamberId)) - .groupBy(proposals.stage); - - let pool = 0; - let vote = 0; - let build = 0; - for (const row of rows) { - const stage = String(row.stage); - if (stage === "pool") pool += Number(row.count); - else if (stage === "vote") vote += Number(row.count); - else if (stage === "build") build += Number(row.count); - } - return { pool, vote, build }; -} - -export async function projectChamberStats( - env: Env, - requestUrl: string, - input: { chamberId: string }, -): Promise<{ governors: number; acm: number; lcm: number; mcm: number }> { - const chamberId = normalizeId(input.chamberId); - const cfg = await getSimConfig(env, requestUrl); - const genesisMembers = cfg?.genesisChamberMembers ?? undefined; - - if (!env.DATABASE_URL) { - const memberAddresses = new Set(); - if (chamberId === "general") { - if (genesisMembers) { - for (const list of Object.values(genesisMembers)) { - for (const addr of list) memberAddresses.add(addr.trim()); - } - } - for (const addr of await listAllChamberMembers(env)) { - memberAddresses.add(addr.trim()); - } - } else { - if (genesisMembers) { - for (const addr of genesisMembers[chamberId] ?? []) - memberAddresses.add(addr.trim()); - } - for (const addr of await listChamberMembers(env, chamberId)) { - memberAddresses.add(addr.trim()); - } - } - - const members = Array.from(memberAddresses); - const governors = members.length; - if (governors === 0) return { governors: 0, acm: 0, lcm: 0, mcm: 0 }; - - const allAwards = await listCmAwards(env, { proposerIds: members }); - const multiplierByChamberId = new Map(); - for (const chamber of await listChambers(env, requestUrl, { - includeDissolved: true, - })) { - multiplierByChamberId.set(chamber.id, chamber.multiplierTimes10); - } - const acmPoints = allAwards.reduce((sum, award) => { - const times10 = multiplierByChamberId.get(award.chamberId) ?? 10; - return sum + Math.round((award.lcmPoints * times10) / 10); - }, 0); - - const chamberAwards = await listCmAwards(env, { - proposerIds: members, - chamberId, - }); - const lcmPoints = chamberAwards.reduce( - (sum, award) => sum + award.lcmPoints, - 0, - ); - const chamberTimes10 = multiplierByChamberId.get(chamberId) ?? 10; - const mcmPoints = chamberAwards.reduce( - (sum, award) => sum + Math.round((award.lcmPoints * chamberTimes10) / 10), - 0, - ); - - const acm = Math.round(acmPoints / 10); - const lcm = Math.round(lcmPoints / 10); - const mcm = Math.round(mcmPoints / 10); - return { governors, acm, lcm, mcm }; - } - - const db = createDb(env); - - const memberAddresses = new Set(); - if (chamberId === "general") { - const rows = await db - .selectDistinct({ address: chamberMemberships.address }) - .from(chamberMemberships); - for (const row of rows) memberAddresses.add(row.address); - if (genesisMembers) { - for (const list of Object.values(genesisMembers)) { - for (const addr of list) memberAddresses.add(addr); - } - } - } else { - const rows = await db - .selectDistinct({ address: chamberMemberships.address }) - .from(chamberMemberships) - .where(eq(chamberMemberships.chamberId, chamberId)); - for (const row of rows) memberAddresses.add(row.address); - if (genesisMembers) { - for (const addr of genesisMembers[chamberId] ?? []) - memberAddresses.add(addr); - } - } - - const members = Array.from(memberAddresses); - const governors = members.length; - if (members.length === 0) return { governors: 0, acm: 0, lcm: 0, mcm: 0 }; - - const acmRows = await db - .select({ - sum: sql`coalesce(sum(round(${cmAwards.lcmPoints} * coalesce(${chambersTable.multiplierTimes10}, ${cmAwards.chamberMultiplierTimes10}, 10) / 10.0)), 0)`, - }) - .from(cmAwards) - .leftJoin(chambersTable, eq(chambersTable.id, cmAwards.chamberId)) - .where(inArray(cmAwards.proposerId, members)); - const chamberRows = await db - .select({ - lcmSum: sql`coalesce(sum(${cmAwards.lcmPoints}), 0)`, - mcmSum: sql`coalesce(sum(round(${cmAwards.lcmPoints} * coalesce(${chambersTable.multiplierTimes10}, ${cmAwards.chamberMultiplierTimes10}, 10) / 10.0)), 0)`, - }) - .from(cmAwards) - .leftJoin(chambersTable, eq(chambersTable.id, cmAwards.chamberId)) - .where( - and( - eq(cmAwards.chamberId, chamberId), - inArray(cmAwards.proposerId, members), - ), - ); - - const acm = Math.round(Number(acmRows[0]?.sum ?? 0) / 10); - const lcm = Math.round(Number(chamberRows[0]?.lcmSum ?? 0) / 10); - const mcm = Math.round(Number(chamberRows[0]?.mcmSum ?? 0) / 10); - - return { governors, acm, lcm, mcm }; -} - -export async function setChamberMultiplierTimes10( - env: Env, - requestUrl: string, - input: { id: string; multiplierTimes10: number }, -): Promise<{ updated: boolean; prevTimes10: number; nextTimes10: number }> { - await ensureGenesisChambers(env, requestUrl); - const id = normalizeId(input.id); - const nextTimes10 = Math.floor(input.multiplierTimes10); - if (!id) return { updated: false, prevTimes10: 10, nextTimes10 }; - - if (!env.DATABASE_URL) { - const existing = memory.get(id); - const prevTimes10 = existing?.multiplierTimes10 ?? 10; - if (!existing || existing.status !== "active") { - return { updated: false, prevTimes10, nextTimes10 }; - } - if (prevTimes10 === nextTimes10) { - return { updated: false, prevTimes10, nextTimes10 }; - } - const now = new Date(); - memory.set(id, { - ...existing, - multiplierTimes10: nextTimes10, - updatedAt: now, - }); - return { updated: true, prevTimes10, nextTimes10 }; - } - - const db = createDb(env); - const row = await db - .select({ - multiplierTimes10: chambers.multiplierTimes10, - status: chambers.status, - }) - .from(chambers) - .where(eq(chambers.id, id)) - .limit(1); - const prevTimes10 = row[0]?.multiplierTimes10 ?? 10; - const status = row[0]?.status ?? null; - if (status !== "active") return { updated: false, prevTimes10, nextTimes10 }; - if (prevTimes10 === nextTimes10) { - return { updated: false, prevTimes10, nextTimes10 }; - } - const now = new Date(); - await db - .update(chambers) - .set({ multiplierTimes10: nextTimes10, updatedAt: now }) - .where(eq(chambers.id, id)); - return { updated: true, prevTimes10, nextTimes10 }; -} - -export function clearChambersForTests(): void { - memory.clear(); -} diff --git a/api/_lib/clockStore.ts b/api/_lib/clockStore.ts deleted file mode 100644 index 3d6c932..0000000 --- a/api/_lib/clockStore.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { eq } from "drizzle-orm"; - -import { clockState } from "../../db/schema.ts"; -import { envBoolean, envString } from "./env.ts"; -import { createDb } from "./db.ts"; - -type Env = Record; - -export type ClockSnapshot = { - currentEra: number; - updatedAt: string; -}; - -export type ClockStore = { - get: () => Promise; - advanceEra: () => Promise; -}; - -let inlineEra = 0; -let inlineUpdatedAt = new Date().toISOString(); - -async function ensureClockRow(): Promise { - return { currentEra: inlineEra, updatedAt: inlineUpdatedAt }; -} - -export function createClockStore(env: Env): ClockStore { - if ( - env.READ_MODELS_INLINE === "true" || - env.READ_MODELS_INLINE_EMPTY === "true" || - !env.DATABASE_URL - ) { - return { - get: async () => ensureClockRow(), - advanceEra: async () => { - inlineEra += 1; - inlineUpdatedAt = new Date().toISOString(); - return ensureClockRow(); - }, - }; - } - - const db = createDb(env); - const clockRowId = 1; - - async function upsertDefaultRow(): Promise { - await db - .insert(clockState) - .values({ id: clockRowId, currentEra: 0 }) - .onConflictDoNothing(); - } - - async function readRow(): Promise { - await upsertDefaultRow(); - const rows = await db - .select({ - currentEra: clockState.currentEra, - updatedAt: clockState.updatedAt, - }) - .from(clockState) - .where(eq(clockState.id, clockRowId)) - .limit(1); - const updatedAt = rows[0]?.updatedAt - ? rows[0].updatedAt.toISOString() - : new Date(0).toISOString(); - return { currentEra: rows[0]?.currentEra ?? 0, updatedAt }; - } - - async function bumpEra(): Promise { - const rows = await db - .select({ currentEra: clockState.currentEra }) - .from(clockState) - .where(eq(clockState.id, clockRowId)) - .limit(1); - const currentEra = rows[0]?.currentEra ?? 0; - const nextEra = currentEra + 1; - const now = new Date(); - await db - .insert(clockState) - .values({ id: clockRowId, currentEra: nextEra, updatedAt: now }) - .onConflictDoUpdate({ - target: clockState.id, - set: { currentEra: nextEra, updatedAt: now }, - }); - return { currentEra: nextEra, updatedAt: now.toISOString() }; - } - - return { - get: async () => readRow(), - advanceEra: async () => bumpEra(), - }; -} - -export function clearClockForTests(): void { - inlineEra = 0; - inlineUpdatedAt = new Date().toISOString(); -} - -export function assertAdmin(context: { request: Request; env: Env }): void { - if (envBoolean(context.env, "DEV_BYPASS_ADMIN")) return; - const secret = envString(context.env, "ADMIN_SECRET"); - if (!secret) throw new Error("ADMIN_SECRET is required"); - const provided = context.request.headers.get("x-admin-secret") ?? ""; - if (!provided || provided !== secret) { - const err = new Error("Unauthorized"); - (err as Error & { status?: number }).status = 401; - throw err; - } -} diff --git a/api/_lib/cmAwardsStore.ts b/api/_lib/cmAwardsStore.ts deleted file mode 100644 index fedf6d1..0000000 --- a/api/_lib/cmAwardsStore.ts +++ /dev/null @@ -1,158 +0,0 @@ -import { eq, sql } from "drizzle-orm"; - -import { chambers, cmAwards } from "../../db/schema.ts"; -import { createDb } from "./db.ts"; - -type Env = Record; - -export type CmAwardInput = { - proposalId: string; - proposerId: string; - chamberId: string; - avgScore: number | null; - lcmPoints: number; - chamberMultiplierTimes10: number; - mcmPoints: number; -}; - -export type CmAcmTotals = { acmPoints: number }; - -const memoryAwardsByProposal = new Map(); -const memoryAcmByProposer = new Map(); - -export async function listCmAwards( - env: Env, - input?: { chamberId?: string | null; proposerIds?: string[] | null }, -): Promise { - const chamberId = (input?.chamberId ?? null)?.trim().toLowerCase() ?? null; - const proposerIds = input?.proposerIds ?? null; - const proposerSet = proposerIds ? new Set(proposerIds) : null; - - if (!env.DATABASE_URL) { - return Array.from(memoryAwardsByProposal.values()).filter((award) => { - if (chamberId && award.chamberId !== chamberId) return false; - if (proposerSet && !proposerSet.has(award.proposerId)) return false; - return true; - }); - } - - const db = createDb(env); - const rows = await db - .select({ - proposalId: cmAwards.proposalId, - proposerId: cmAwards.proposerId, - chamberId: cmAwards.chamberId, - avgScore: cmAwards.avgScore, - lcmPoints: cmAwards.lcmPoints, - chamberMultiplierTimes10: cmAwards.chamberMultiplierTimes10, - mcmPoints: cmAwards.mcmPoints, - }) - .from(cmAwards); - - return rows - .map((row) => ({ - proposalId: row.proposalId, - proposerId: row.proposerId, - chamberId: String(row.chamberId).trim().toLowerCase(), - avgScore: row.avgScore === null ? null : Number(row.avgScore), - lcmPoints: row.lcmPoints, - chamberMultiplierTimes10: row.chamberMultiplierTimes10, - mcmPoints: row.mcmPoints, - })) - .filter((award) => { - if (chamberId && award.chamberId !== chamberId) return false; - if (proposerSet && !proposerSet.has(award.proposerId)) return false; - return true; - }); -} - -export async function awardCmOnce( - env: Env, - input: CmAwardInput, -): Promise { - if (!env.DATABASE_URL) { - if (memoryAwardsByProposal.has(input.proposalId)) return; - memoryAwardsByProposal.set(input.proposalId, input); - const prev = memoryAcmByProposer.get(input.proposerId) ?? 0; - memoryAcmByProposer.set(input.proposerId, prev + input.mcmPoints); - return; - } - - const db = createDb(env); - await db - .insert(cmAwards) - .values({ - proposalId: input.proposalId, - proposerId: input.proposerId, - chamberId: input.chamberId, - avgScore: input.avgScore === null ? null : Math.round(input.avgScore), - lcmPoints: input.lcmPoints, - chamberMultiplierTimes10: input.chamberMultiplierTimes10, - mcmPoints: input.mcmPoints, - createdAt: new Date(), - }) - .onConflictDoNothing({ target: cmAwards.proposalId }); -} - -export async function hasLcmHistoryInChamber( - env: Env, - input: { proposerId: string; chamberId: string }, -): Promise { - const proposerId = input.proposerId.trim(); - const chamberId = input.chamberId.trim().toLowerCase(); - if (!proposerId || !chamberId) return false; - - if (!env.DATABASE_URL) { - for (const award of memoryAwardsByProposal.values()) { - if (award.proposerId !== proposerId) continue; - if (award.chamberId !== chamberId) continue; - return true; - } - return false; - } - - const db = createDb(env); - const rows = await db - .select({ proposalId: cmAwards.proposalId }) - .from(cmAwards) - .where( - sql`${cmAwards.proposerId} = ${proposerId} and ${cmAwards.chamberId} = ${chamberId}`, - ) - .limit(1); - return Boolean(rows[0]); -} - -export async function getAcmDelta( - env: Env, - proposerId: string, -): Promise { - if (!env.DATABASE_URL) { - const { getChamberMultiplierTimes10 } = await import("./chambersStore.ts"); - let sum = 0; - for (const award of memoryAwardsByProposal.values()) { - if (award.proposerId !== proposerId) continue; - const times10 = await getChamberMultiplierTimes10( - env, - "https://local.test/api/internal", - award.chamberId, - ); - sum += Math.round((award.lcmPoints * times10) / 10); - } - return sum; - } - - const db = createDb(env); - const rows = await db - .select({ - sum: sql`coalesce(sum(round(${cmAwards.lcmPoints} * coalesce(${chambers.multiplierTimes10}, ${cmAwards.chamberMultiplierTimes10}, 10) / 10.0)), 0)`, - }) - .from(cmAwards) - .leftJoin(chambers, eq(chambers.id, cmAwards.chamberId)) - .where(eq(cmAwards.proposerId, proposerId)); - return Number(rows[0]?.sum ?? 0); -} - -export async function clearCmAwardsForTests() { - memoryAwardsByProposal.clear(); - memoryAcmByProposer.clear(); -} diff --git a/api/_lib/cookies.ts b/api/_lib/cookies.ts deleted file mode 100644 index ddda802..0000000 --- a/api/_lib/cookies.ts +++ /dev/null @@ -1,35 +0,0 @@ -type SameSite = "Lax" | "Strict" | "None"; - -export function parseCookieHeader( - headerValue: string | null, -): Map { - const map = new Map(); - if (!headerValue) return map; - for (const part of headerValue.split(";")) { - const [rawKey, ...rawRest] = part.trim().split("="); - if (!rawKey) continue; - map.set(rawKey, decodeURIComponent(rawRest.join("="))); - } - return map; -} - -export function serializeCookie( - name: string, - value: string, - options: { - httpOnly?: boolean; - secure?: boolean; - sameSite?: SameSite; - maxAgeSeconds?: number; - path?: string; - } = {}, -): string { - const parts = [`${name}=${encodeURIComponent(value)}`]; - parts.push(`Path=${options.path ?? "/"}`); - parts.push(`SameSite=${options.sameSite ?? "Lax"}`); - if (options.httpOnly) parts.push("HttpOnly"); - if (options.secure) parts.push("Secure"); - if (typeof options.maxAgeSeconds === "number") - parts.push(`Max-Age=${options.maxAgeSeconds}`); - return parts.join("; "); -} diff --git a/api/_lib/courtsStore.ts b/api/_lib/courtsStore.ts deleted file mode 100644 index daf0b96..0000000 --- a/api/_lib/courtsStore.ts +++ /dev/null @@ -1,321 +0,0 @@ -import { and, eq, sql } from "drizzle-orm"; - -import { courtCases, courtReports, courtVerdicts } from "../../db/schema.ts"; -import type { ReadModelsStore } from "./readModelsStore.ts"; -import { createDb } from "./db.ts"; - -type Env = Record; - -export type CourtStatus = "jury" | "live" | "ended"; -export type CourtVerdict = "guilty" | "not_guilty"; - -type CourtCaseSeed = { - status: CourtStatus; - baseReports: number; - opened: string | null; -}; - -export type CourtOverlay = { - status: CourtStatus; - reports: number; - verdicts: { guilty: number; notGuilty: number }; -}; - -const REPORTS_TO_START_LIVE = 12; -const VERDICTS_TO_END = 12; - -const memoryCases = new Map(); -const memoryReports = new Map>(); -const memoryVerdicts = new Map>(); - -export async function hasCourtReport( - env: Env, - input: { caseId: string; reporterAddress: string }, -): Promise { - const reporter = input.reporterAddress.trim(); - if (!env.DATABASE_URL) { - const set = memoryReports.get(input.caseId); - if (!set) return false; - return set.has(reporter); - } - const db = createDb(env); - const existing = await db - .select({ reporterAddress: courtReports.reporterAddress }) - .from(courtReports) - .where( - and( - eq(courtReports.caseId, input.caseId), - eq(courtReports.reporterAddress, reporter), - ), - ) - .limit(1); - return existing.length > 0; -} - -export async function hasCourtVerdict( - env: Env, - input: { caseId: string; voterAddress: string }, -): Promise { - const voter = input.voterAddress.trim(); - if (!env.DATABASE_URL) { - const map = memoryVerdicts.get(input.caseId); - if (!map) return false; - return map.has(voter); - } - const db = createDb(env); - const existing = await db - .select({ voterAddress: courtVerdicts.voterAddress }) - .from(courtVerdicts) - .where( - and( - eq(courtVerdicts.caseId, input.caseId), - eq(courtVerdicts.voterAddress, voter), - ), - ) - .limit(1); - return existing.length > 0; -} - -export async function ensureCourtCaseSeed( - env: Env, - readModels: ReadModelsStore, - caseId: string, -): Promise { - if (!env.DATABASE_URL) { - const existing = memoryCases.get(caseId); - if (existing) return existing; - const seed = await seedFromReadModel(readModels, caseId); - memoryCases.set(caseId, seed); - if (!memoryReports.has(caseId)) memoryReports.set(caseId, new Set()); - if (!memoryVerdicts.has(caseId)) memoryVerdicts.set(caseId, new Map()); - return seed; - } - - const db = createDb(env); - const existing = await db - .select() - .from(courtCases) - .where(eq(courtCases.id, caseId)) - .limit(1); - if (existing[0]) { - return { - status: normalizeStatus(existing[0].status), - baseReports: existing[0].baseReports, - opened: existing[0].opened ?? null, - }; - } - - const seed = await seedFromReadModel(readModels, caseId); - const now = new Date(); - await db.insert(courtCases).values({ - id: caseId, - status: seed.status, - baseReports: seed.baseReports, - opened: seed.opened, - createdAt: now, - updatedAt: now, - }); - return seed; -} - -export async function getCourtOverlay( - env: Env, - readModels: ReadModelsStore, - caseId: string, -): Promise { - const seed = await ensureCourtCaseSeed(env, readModels, caseId); - - if (!env.DATABASE_URL) { - const reports = (memoryReports.get(caseId)?.size ?? 0) + seed.baseReports; - const verdictMap = memoryVerdicts.get(caseId) ?? new Map(); - const guilty = Array.from(verdictMap.values()).filter( - (v) => v === "guilty", - ).length; - const notGuilty = Array.from(verdictMap.values()).filter( - (v) => v === "not_guilty", - ).length; - const status = computeStatus(seed.status, reports, guilty + notGuilty); - return { - status, - reports, - verdicts: { guilty, notGuilty }, - }; - } - - const db = createDb(env); - const [reportAgg] = await db - .select({ n: sql`count(*)` }) - .from(courtReports) - .where(eq(courtReports.caseId, caseId)); - const [guiltyAgg] = await db - .select({ - n: sql`sum(case when ${courtVerdicts.verdict} = 'guilty' then 1 else 0 end)`, - }) - .from(courtVerdicts) - .where(eq(courtVerdicts.caseId, caseId)); - const [notGuiltyAgg] = await db - .select({ - n: sql`sum(case when ${courtVerdicts.verdict} = 'not_guilty' then 1 else 0 end)`, - }) - .from(courtVerdicts) - .where(eq(courtVerdicts.caseId, caseId)); - - const reports = seed.baseReports + Number(reportAgg?.n ?? 0); - const guilty = Number(guiltyAgg?.n ?? 0); - const notGuilty = Number(notGuiltyAgg?.n ?? 0); - const status = computeStatus(seed.status, reports, guilty + notGuilty); - - if (status !== seed.status) { - await db - .update(courtCases) - .set({ status, updatedAt: new Date() }) - .where(eq(courtCases.id, caseId)); - } - - return { - status, - reports, - verdicts: { guilty, notGuilty }, - }; -} - -export async function reportCourtCase( - env: Env, - readModels: ReadModelsStore, - input: { caseId: string; reporterAddress: string }, -): Promise<{ overlay: CourtOverlay; created: boolean }> { - await ensureCourtCaseSeed(env, readModels, input.caseId); - - const reporter = input.reporterAddress.trim(); - if (!env.DATABASE_URL) { - const set = memoryReports.get(input.caseId) ?? new Set(); - const created = !set.has(reporter); - set.add(reporter); - memoryReports.set(input.caseId, set); - return { - overlay: await getCourtOverlay(env, readModels, input.caseId), - created, - }; - } - - const db = createDb(env); - const existing = await db - .select({ reporterAddress: courtReports.reporterAddress }) - .from(courtReports) - .where( - and( - eq(courtReports.caseId, input.caseId), - eq(courtReports.reporterAddress, reporter), - ), - ) - .limit(1); - const created = existing.length === 0; - await db - .insert(courtReports) - .values({ - caseId: input.caseId, - reporterAddress: reporter, - createdAt: new Date(), - }) - .onConflictDoNothing({ - target: [courtReports.caseId, courtReports.reporterAddress], - }); - - return { - overlay: await getCourtOverlay(env, readModels, input.caseId), - created, - }; -} - -export async function castCourtVerdict( - env: Env, - readModels: ReadModelsStore, - input: { caseId: string; voterAddress: string; verdict: CourtVerdict }, -): Promise<{ overlay: CourtOverlay; created: boolean }> { - const overlay = await getCourtOverlay(env, readModels, input.caseId); - if (overlay.status !== "live") throw new Error("case_not_live"); - - const voter = input.voterAddress.trim(); - if (!env.DATABASE_URL) { - const map = - memoryVerdicts.get(input.caseId) ?? new Map(); - const created = !map.has(voter); - map.set(voter, input.verdict); - memoryVerdicts.set(input.caseId, map); - return { - overlay: await getCourtOverlay(env, readModels, input.caseId), - created, - }; - } - - const db = createDb(env); - const existing = await db - .select({ voterAddress: courtVerdicts.voterAddress }) - .from(courtVerdicts) - .where( - and( - eq(courtVerdicts.caseId, input.caseId), - eq(courtVerdicts.voterAddress, voter), - ), - ) - .limit(1); - const created = existing.length === 0; - const now = new Date(); - await db - .insert(courtVerdicts) - .values({ - caseId: input.caseId, - voterAddress: voter, - verdict: input.verdict, - createdAt: now, - updatedAt: now, - }) - .onConflictDoUpdate({ - target: [courtVerdicts.caseId, courtVerdicts.voterAddress], - set: { verdict: input.verdict, updatedAt: now }, - }); - - return { - overlay: await getCourtOverlay(env, readModels, input.caseId), - created, - }; -} - -export function clearCourtsForTests() { - memoryCases.clear(); - memoryReports.clear(); - memoryVerdicts.clear(); -} - -async function seedFromReadModel( - readModels: ReadModelsStore, - caseId: string, -): Promise { - const payload = await readModels.get(`courts:${caseId}`); - if (!payload || typeof payload !== "object" || Array.isArray(payload)) { - throw new Error("court_case_missing"); - } - const record = payload as Record; - const status = normalizeStatus(record.status); - const reports = typeof record.reports === "number" ? record.reports : 0; - const opened = typeof record.opened === "string" ? record.opened : null; - return { status, baseReports: reports, opened }; -} - -function normalizeStatus(value: unknown): CourtStatus { - if (value === "jury" || value === "live" || value === "ended") return value; - return "jury"; -} - -function computeStatus( - base: CourtStatus, - reports: number, - verdicts: number, -): CourtStatus { - if (base === "ended") return "ended"; - const effectiveStage: "jury" | "live" = - base === "live" || reports >= REPORTS_TO_START_LIVE ? "live" : "jury"; - if (effectiveStage === "jury") return "jury"; - if (verdicts >= VERDICTS_TO_END) return "ended"; - return "live"; -} diff --git a/api/_lib/db.ts b/api/_lib/db.ts deleted file mode 100644 index 9f1819f..0000000 --- a/api/_lib/db.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { neon } from "@neondatabase/serverless"; -import { drizzle } from "drizzle-orm/neon-http"; - -type Env = Record; - -export type Db = ReturnType; - -export function createDb(env: Env): Db { - const url = env.DATABASE_URL; - if (!url) { - throw new Error("DATABASE_URL is required"); - } - - const sql = neon(url); - return drizzle(sql); -} diff --git a/api/_lib/delegationsStore.ts b/api/_lib/delegationsStore.ts deleted file mode 100644 index e14c7a3..0000000 --- a/api/_lib/delegationsStore.ts +++ /dev/null @@ -1,296 +0,0 @@ -import { and, eq, sql } from "drizzle-orm"; - -import { delegationEvents, delegations } from "../../db/schema.ts"; -import { createDb } from "./db.ts"; - -type Env = Record; - -export type Delegation = { - chamberId: string; - delegatorAddress: string; - delegateeAddress: string; - createdAt: string; - updatedAt: string; -}; - -type StoredDelegation = { - delegateeAddress: string; - createdAt: string; - updatedAt: string; -}; -const memory = new Map>(); // chamberId -> delegator -> record - -function normalizeChamberId(value: string): string { - return value.trim().toLowerCase(); -} - -function normalizeAddress(value: string): string { - return value.trim(); -} - -export async function getDelegation( - env: Env, - input: { chamberId: string; delegatorAddress: string }, -): Promise { - const chamberId = normalizeChamberId(input.chamberId); - const delegatorAddress = normalizeAddress(input.delegatorAddress); - - if (!env.DATABASE_URL) { - const byDelegator = memory.get(chamberId); - const row = byDelegator?.get(delegatorAddress); - if (!row) return null; - return { - chamberId, - delegatorAddress, - delegateeAddress: row.delegateeAddress, - createdAt: row.createdAt, - updatedAt: row.updatedAt, - }; - } - - const db = createDb(env); - const rows = await db - .select({ - chamberId: delegations.chamberId, - delegatorAddress: delegations.delegatorAddress, - delegateeAddress: delegations.delegateeAddress, - createdAt: delegations.createdAt, - updatedAt: delegations.updatedAt, - }) - .from(delegations) - .where( - and( - eq(delegations.chamberId, chamberId), - eq(delegations.delegatorAddress, delegatorAddress), - ), - ) - .limit(1); - const row = rows[0]; - if (!row) return null; - return { - chamberId: row.chamberId, - delegatorAddress: row.delegatorAddress, - delegateeAddress: row.delegateeAddress, - createdAt: row.createdAt.toISOString(), - updatedAt: row.updatedAt.toISOString(), - }; -} - -export async function setDelegation( - env: Env, - input: { - chamberId: string; - delegatorAddress: string; - delegateeAddress: string; - }, -): Promise { - const chamberId = normalizeChamberId(input.chamberId); - const delegatorAddress = normalizeAddress(input.delegatorAddress); - const delegateeAddress = normalizeAddress(input.delegateeAddress); - - if (!chamberId) throw new Error("delegation_chamber_missing"); - if (!delegatorAddress) throw new Error("delegation_delegator_missing"); - if (!delegateeAddress) throw new Error("delegation_delegatee_missing"); - if (delegatorAddress === delegateeAddress) throw new Error("delegation_self"); - - await assertNoDelegationCycle(env, { - chamberId, - delegatorAddress, - delegateeAddress, - }); - - const now = new Date(); - - if (!env.DATABASE_URL) { - const byDelegator = - memory.get(chamberId) ?? new Map(); - const existing = byDelegator.get(delegatorAddress); - const createdAt = existing?.createdAt ?? now.toISOString(); - const updatedAt = now.toISOString(); - byDelegator.set(delegatorAddress, { - delegateeAddress, - createdAt, - updatedAt, - }); - memory.set(chamberId, byDelegator); - return { - chamberId, - delegatorAddress, - delegateeAddress, - createdAt, - updatedAt, - }; - } - - const db = createDb(env); - const existing = await db - .select({ - createdAt: delegations.createdAt, - }) - .from(delegations) - .where( - and( - eq(delegations.chamberId, chamberId), - eq(delegations.delegatorAddress, delegatorAddress), - ), - ) - .limit(1); - const createdAt = existing[0]?.createdAt ?? now; - - await db - .insert(delegations) - .values({ - chamberId, - delegatorAddress, - delegateeAddress, - createdAt, - updatedAt: now, - }) - .onConflictDoUpdate({ - target: [delegations.chamberId, delegations.delegatorAddress], - set: { delegateeAddress, updatedAt: now }, - }); - - await db.insert(delegationEvents).values({ - chamberId, - delegatorAddress, - delegateeAddress, - type: "set", - createdAt: now, - }); - - return { - chamberId, - delegatorAddress, - delegateeAddress, - createdAt: createdAt.toISOString(), - updatedAt: now.toISOString(), - }; -} - -export async function clearDelegation( - env: Env, - input: { chamberId: string; delegatorAddress: string }, -): Promise<{ cleared: boolean }> { - const chamberId = normalizeChamberId(input.chamberId); - const delegatorAddress = normalizeAddress(input.delegatorAddress); - if (!chamberId) throw new Error("delegation_chamber_missing"); - if (!delegatorAddress) throw new Error("delegation_delegator_missing"); - - if (!env.DATABASE_URL) { - const byDelegator = memory.get(chamberId); - if (!byDelegator) return { cleared: false }; - const cleared = byDelegator.delete(delegatorAddress); - return { cleared }; - } - - const db = createDb(env); - const existing = await db - .select({ n: sql`count(*)` }) - .from(delegations) - .where( - and( - eq(delegations.chamberId, chamberId), - eq(delegations.delegatorAddress, delegatorAddress), - ), - ) - .limit(1); - const cleared = Number(existing[0]?.n ?? 0) > 0; - if (!cleared) return { cleared: false }; - - await db - .delete(delegations) - .where( - and( - eq(delegations.chamberId, chamberId), - eq(delegations.delegatorAddress, delegatorAddress), - ), - ); - - await db.insert(delegationEvents).values({ - chamberId, - delegatorAddress, - delegateeAddress: null, - type: "clear", - createdAt: new Date(), - }); - - return { cleared: true }; -} - -export async function getDelegationMapForChamber( - env: Env, - chamberIdInput: string, -): Promise> { - const chamberId = normalizeChamberId(chamberIdInput); - const map = new Map(); // delegator -> delegatee - - if (!env.DATABASE_URL) { - const byDelegator = memory.get(chamberId); - if (!byDelegator) return map; - for (const [delegator, record] of byDelegator.entries()) { - map.set(delegator, record.delegateeAddress); - } - return map; - } - - const db = createDb(env); - const rows = await db - .select({ - delegatorAddress: delegations.delegatorAddress, - delegateeAddress: delegations.delegateeAddress, - }) - .from(delegations) - .where(eq(delegations.chamberId, chamberId)); - for (const row of rows) { - map.set(row.delegatorAddress, row.delegateeAddress); - } - return map; -} - -export async function getDelegationWeightsForChamber( - env: Env, - input: { chamberId: string; excludedDelegators?: Set }, -): Promise> { - const chamberId = normalizeChamberId(input.chamberId); - const excluded = input.excludedDelegators ?? new Set(); - const weights = new Map(); // delegatee -> count - - const map = await getDelegationMapForChamber(env, chamberId); - for (const [delegator, delegatee] of map.entries()) { - if (excluded.has(delegator)) continue; - weights.set(delegatee, (weights.get(delegatee) ?? 0) + 1); - } - return weights; -} - -async function assertNoDelegationCycle( - env: Env, - input: { - chamberId: string; - delegatorAddress: string; - delegateeAddress: string; - }, -): Promise { - const chamberId = input.chamberId; - const delegatorAddress = input.delegatorAddress; - const delegateeAddress = input.delegateeAddress; - - const map = await getDelegationMapForChamber(env, chamberId); - map.set(delegatorAddress, delegateeAddress); - - const seen = new Set(); - let current = delegateeAddress; - while (true) { - if (current === delegatorAddress) throw new Error("delegation_cycle"); - if (seen.has(current)) return; - seen.add(current); - const next = map.get(current); - if (!next) return; - current = next; - } -} - -export function clearDelegationsForTests(): void { - memory.clear(); -} diff --git a/api/_lib/env.ts b/api/_lib/env.ts deleted file mode 100644 index bd11a82..0000000 --- a/api/_lib/env.ts +++ /dev/null @@ -1,43 +0,0 @@ -export function envBoolean( - env: Record, - key: string, -): boolean { - const raw = env[key]; - if (!raw) return false; - return ( - raw === "1" || raw.toLowerCase() === "true" || raw.toLowerCase() === "yes" - ); -} - -export function envString( - env: Record, - key: string, -): string | undefined { - const raw = env[key]; - if (!raw) return undefined; - const trimmed = raw.trim(); - return trimmed.length ? trimmed : undefined; -} - -export function envCsv( - env: Record, - key: string, -): string[] { - const raw = envString(env, key); - if (!raw) return []; - return raw - .split(",") - .map((s) => s.trim()) - .filter(Boolean); -} - -export function envInt( - env: Record, - key: string, -): number | undefined { - const raw = envString(env, key); - if (!raw) return undefined; - const parsed = Number.parseInt(raw, 10); - if (!Number.isFinite(parsed)) return undefined; - return parsed; -} diff --git a/api/_lib/eraQuotas.ts b/api/_lib/eraQuotas.ts deleted file mode 100644 index fced3b2..0000000 --- a/api/_lib/eraQuotas.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { envInt } from "./env.ts"; - -type Env = Record; - -export type EraQuotaConfig = { - maxPoolVotes: number | null; - maxChamberVotes: number | null; - maxCourtActions: number | null; - maxFormationActions: number | null; -}; - -function normalizeLimit(value: number | undefined): number | null { - if (value === undefined) return null; - if (!Number.isFinite(value) || value <= 0) return null; - return value; -} - -export function getEraQuotaConfig(env: Env): EraQuotaConfig { - return { - maxPoolVotes: normalizeLimit(envInt(env, "SIM_MAX_POOL_VOTES_PER_ERA")), - maxChamberVotes: normalizeLimit( - envInt(env, "SIM_MAX_CHAMBER_VOTES_PER_ERA"), - ), - maxCourtActions: normalizeLimit( - envInt(env, "SIM_MAX_COURT_ACTIONS_PER_ERA"), - ), - maxFormationActions: normalizeLimit( - envInt(env, "SIM_MAX_FORMATION_ACTIONS_PER_ERA"), - ), - }; -} diff --git a/api/_lib/eraRollupStore.ts b/api/_lib/eraRollupStore.ts deleted file mode 100644 index 595cae5..0000000 --- a/api/_lib/eraRollupStore.ts +++ /dev/null @@ -1,507 +0,0 @@ -import { and, eq, sql } from "drizzle-orm"; - -import { eraRollups, eraUserStatus } from "../../db/schema.ts"; -import { createDb } from "./db.ts"; -import { envBoolean, envCsv } from "./env.ts"; -import { listEraUserActivity } from "./eraStore.ts"; -import { - fetchSessionValidatorsViaRpc, - isSs58OrHexAddressInSet, -} from "./humanodeRpc.ts"; -import { getSimConfig } from "./simConfig.ts"; - -type Env = Record; - -export type GoverningStatus = - | "Ahead" - | "Stable" - | "Falling behind" - | "At risk" - | "Losing status"; - -export type EraRequirements = { - poolVotes: number; - chamberVotes: number; - courtActions: number; - formationActions: number; -}; - -export type EraRollupResult = { - era: number; - rolledAt: string; - requirements: EraRequirements; - requiredTotal: number; - activeGovernorsNextEra: number; - usersRolled: number; - statusCounts: Record; -}; - -type StoredRollup = { - era: number; - requirements: EraRequirements; - requiredTotal: number; - activeGovernorsNextEra: number; - rolledAt: string; -}; - -const memoryRollups = new Map(); -const memoryUserStatuses = new Map< - string, - { - status: GoverningStatus; - requiredTotal: number; - completedTotal: number; - isActiveNextEra: boolean; - } ->(); // key: `${era}:${address}` - -export async function getActiveAddressesForNextEraFromRollup( - env: Env, - input: { era: number }, -): Promise | null> { - if (!env.DATABASE_URL) { - if (!memoryRollups.has(input.era)) return null; - const out = new Set(); - for (const [key, status] of memoryUserStatuses.entries()) { - if (!key.startsWith(`${input.era}:`)) continue; - if (!status.isActiveNextEra) continue; - const address = key.split(":").slice(1).join(":").trim(); - if (address) out.add(address); - } - return out; - } - - const db = createDb(env); - const rollupExists = await db - .select({ era: eraRollups.era }) - .from(eraRollups) - .where(eq(eraRollups.era, input.era)) - .limit(1); - if (!rollupExists[0]) return null; - - const rows = await db - .select({ address: eraUserStatus.address }) - .from(eraUserStatus) - .where( - and( - eq(eraUserStatus.era, input.era), - eq(eraUserStatus.isActiveNextEra, true), - ), - ); - return new Set(rows.map((r) => r.address.trim()).filter(Boolean)); -} - -export async function rollupEra( - env: Env, - input: { era: number; requestUrl?: string }, -): Promise { - const existing = await getEraRollup(env, input.era); - if (existing) { - const statusCounts = await getEraStatusCounts(env, input.era); - const usersRolled = await getEraUsersRolled(env, input.era); - return { - era: existing.era, - rolledAt: existing.rolledAt, - requirements: existing.requirements, - requiredTotal: existing.requiredTotal, - activeGovernorsNextEra: existing.activeGovernorsNextEra, - usersRolled, - statusCounts, - }; - } - - const requirements = getRequirements(env); - const requiredTotal = sumRequirements(requirements); - - const activityRows = await listEraUserActivity(env, { era: input.era }); - - const eligibleAddresses = new Set( - envCsv(env, "DEV_ELIGIBLE_ADDRESSES").map((a) => a.trim()), - ); - const bypassGate = envBoolean(env, "DEV_BYPASS_GATE"); - - let validatorSet: Set | null = null; - if (!bypassGate) { - let envWithRpc: Env = env; - if (!env.HUMANODE_RPC_URL && input.requestUrl) { - const cfg = await getSimConfig(env, input.requestUrl); - const fromCfg = (cfg?.humanodeRpcUrl ?? "").trim(); - if (fromCfg) envWithRpc = { ...env, HUMANODE_RPC_URL: fromCfg }; - } - - const validators = await fetchSessionValidatorsViaRpc(envWithRpc); - validatorSet = new Set(validators); - } - - const userStatuses = activityRows.map((row) => { - const completedTotal = - row.poolVotes + - row.chamberVotes + - row.courtActions + - row.formationActions; - const status = computeGoverningStatus(completedTotal, requiredTotal); - const meetsRequirements = isActiveByRequirements(row, requirements); - const isActiveHumanNode = - bypassGate || - eligibleAddresses.has(row.address.trim()) || - (validatorSet - ? isSs58OrHexAddressInSet(row.address, validatorSet) - : false); - const isActiveNextEra = meetsRequirements && isActiveHumanNode; - return { - ...row, - status, - requiredTotal, - completedTotal, - isActiveNextEra, - }; - }); - - const activeGovernorsNextEra = userStatuses.filter( - (u) => u.isActiveNextEra, - ).length; - - const rolledAt = new Date().toISOString(); - await storeEraRollup(env, { - era: input.era, - requirements, - requiredTotal, - activeGovernorsNextEra, - rolledAt, - userStatuses, - }); - - const statusCounts = userStatuses.reduce( - (acc, u) => { - acc[u.status] += 1; - return acc; - }, - { - Ahead: 0, - Stable: 0, - "Falling behind": 0, - "At risk": 0, - "Losing status": 0, - } as Record, - ); - - return { - era: input.era, - rolledAt, - requirements, - requiredTotal, - activeGovernorsNextEra, - usersRolled: userStatuses.length, - statusCounts, - }; -} - -export async function getEraRollupMeta( - env: Env, - input: { era: number }, -): Promise { - const rollup = await getEraRollup(env, input.era); - if (!rollup) return null; - return { - era: rollup.era, - rolledAt: rollup.rolledAt, - requiredTotal: rollup.requiredTotal, - requirements: rollup.requirements, - activeGovernorsNextEra: rollup.activeGovernorsNextEra, - }; -} - -export async function getEraUserStatus( - env: Env, - input: { era: number; address: string }, -): Promise { - const address = input.address.trim(); - - if (!env.DATABASE_URL) { - const rollup = memoryRollups.get(input.era); - if (!rollup) return null; - const entry = memoryUserStatuses.get(`${input.era}:${address}`); - if (!entry) return null; - return { - era: input.era, - address, - status: entry.status, - requiredTotal: entry.requiredTotal, - completedTotal: entry.completedTotal, - isActiveNextEra: entry.isActiveNextEra, - }; - } - - const db = createDb(env); - const rows = await db - .select({ - status: eraUserStatus.status, - requiredTotal: eraUserStatus.requiredTotal, - completedTotal: eraUserStatus.completedTotal, - isActiveNextEra: eraUserStatus.isActiveNextEra, - }) - .from(eraUserStatus) - .where( - and(eq(eraUserStatus.era, input.era), eq(eraUserStatus.address, address)), - ) - .limit(1); - const row = rows[0]; - if (!row) return null; - const status = String(row.status) as GoverningStatus; - if ( - status !== "Ahead" && - status !== "Stable" && - status !== "Falling behind" && - status !== "At risk" && - status !== "Losing status" - ) { - return null; - } - return { - era: input.era, - address, - status, - requiredTotal: row.requiredTotal, - completedTotal: row.completedTotal, - isActiveNextEra: Boolean(row.isActiveNextEra), - }; -} - -export function clearEraRollupsForTests() { - memoryRollups.clear(); - memoryUserStatuses.clear(); -} - -function computeGoverningStatus( - completed: number, - required: number, -): GoverningStatus { - if (required <= 0) return "Stable"; - if (completed >= required + 2) return "Ahead"; - if (completed >= required) return "Stable"; - const ratio = completed / required; - if (ratio >= 0.75) return "Falling behind"; - if (ratio >= 0.55) return "At risk"; - return "Losing status"; -} - -function isActiveByRequirements( - counts: EraRequirements, - required: EraRequirements, -): boolean { - if (required.poolVotes > 0 && counts.poolVotes < required.poolVotes) - return false; - if (required.chamberVotes > 0 && counts.chamberVotes < required.chamberVotes) - return false; - if (required.courtActions > 0 && counts.courtActions < required.courtActions) - return false; - if ( - required.formationActions > 0 && - counts.formationActions < required.formationActions - ) - return false; - return true; -} - -async function getEraRollup( - env: Env, - era: number, -): Promise { - if (!env.DATABASE_URL) { - return memoryRollups.get(era) ?? null; - } - const db = createDb(env); - const rows = await db - .select({ - era: eraRollups.era, - requiredPoolVotes: eraRollups.requiredPoolVotes, - requiredChamberVotes: eraRollups.requiredChamberVotes, - requiredCourtActions: eraRollups.requiredCourtActions, - requiredFormationActions: eraRollups.requiredFormationActions, - requiredTotal: eraRollups.requiredTotal, - activeGovernorsNextEra: eraRollups.activeGovernorsNextEra, - rolledAt: eraRollups.rolledAt, - }) - .from(eraRollups) - .where(eq(eraRollups.era, era)) - .limit(1); - const row = rows[0]; - if (!row) return null; - return { - era: row.era, - requirements: { - poolVotes: row.requiredPoolVotes, - chamberVotes: row.requiredChamberVotes, - courtActions: row.requiredCourtActions, - formationActions: row.requiredFormationActions, - }, - requiredTotal: row.requiredTotal, - activeGovernorsNextEra: row.activeGovernorsNextEra, - rolledAt: row.rolledAt.toISOString(), - }; -} - -async function getEraStatusCounts( - env: Env, - era: number, -): Promise> { - const base: Record = { - Ahead: 0, - Stable: 0, - "Falling behind": 0, - "At risk": 0, - "Losing status": 0, - }; - if (!env.DATABASE_URL) { - for (const [key, value] of memoryUserStatuses.entries()) { - if (!key.startsWith(`${era}:`)) continue; - base[value.status] += 1; - } - return base; - } - - const db = createDb(env); - const rows = await db - .select({ - status: eraUserStatus.status, - n: sql`count(*)`, - }) - .from(eraUserStatus) - .where(eq(eraUserStatus.era, era)) - .groupBy(eraUserStatus.status); - for (const row of rows) { - const status = String(row.status) as GoverningStatus; - if (status in base) base[status] = Number(row.n ?? 0); - } - return base; -} - -async function getEraUsersRolled(env: Env, era: number): Promise { - if (!env.DATABASE_URL) { - let n = 0; - for (const key of memoryUserStatuses.keys()) { - if (key.startsWith(`${era}:`)) n += 1; - } - return n; - } - const db = createDb(env); - const rows = await db - .select({ n: sql`count(*)` }) - .from(eraUserStatus) - .where(eq(eraUserStatus.era, era)); - return Number(rows[0]?.n ?? 0); -} - -async function storeEraRollup( - env: Env, - input: { - era: number; - requirements: EraRequirements; - requiredTotal: number; - activeGovernorsNextEra: number; - rolledAt: string; - userStatuses: Array< - EraRequirements & { - address: string; - status: GoverningStatus; - requiredTotal: number; - completedTotal: number; - isActiveNextEra: boolean; - } - >; - }, -): Promise { - if (!env.DATABASE_URL) { - memoryRollups.set(input.era, { - era: input.era, - requirements: input.requirements, - requiredTotal: input.requiredTotal, - activeGovernorsNextEra: input.activeGovernorsNextEra, - rolledAt: input.rolledAt, - }); - for (const u of input.userStatuses) { - memoryUserStatuses.set(`${input.era}:${u.address.trim()}`, { - status: u.status, - requiredTotal: input.requiredTotal, - completedTotal: u.completedTotal, - isActiveNextEra: u.isActiveNextEra, - }); - } - return; - } - - const db = createDb(env); - const now = new Date(input.rolledAt); - await db - .insert(eraRollups) - .values({ - era: input.era, - requiredPoolVotes: input.requirements.poolVotes, - requiredChamberVotes: input.requirements.chamberVotes, - requiredCourtActions: input.requirements.courtActions, - requiredFormationActions: input.requirements.formationActions, - requiredTotal: input.requiredTotal, - activeGovernorsNextEra: input.activeGovernorsNextEra, - rolledAt: now, - }) - .onConflictDoNothing({ target: eraRollups.era }); - - if (input.userStatuses.length === 0) return; - await db - .insert(eraUserStatus) - .values( - input.userStatuses.map((u) => ({ - era: input.era, - address: u.address.trim(), - status: u.status, - requiredTotal: input.requiredTotal, - completedTotal: u.completedTotal, - isActiveNextEra: u.isActiveNextEra, - poolVotes: u.poolVotes, - chamberVotes: u.chamberVotes, - courtActions: u.courtActions, - formationActions: u.formationActions, - createdAt: now, - })), - ) - .onConflictDoNothing({ - target: [eraUserStatus.era, eraUserStatus.address], - }); -} - -function sumRequirements(req: EraRequirements): number { - return ( - req.poolVotes + req.chamberVotes + req.courtActions + req.formationActions - ); -} - -function getRequirements(env: Env): EraRequirements { - return { - poolVotes: envInt(env, "SIM_REQUIRED_POOL_VOTES", 1), - chamberVotes: envInt(env, "SIM_REQUIRED_CHAMBER_VOTES", 1), - courtActions: envInt(env, "SIM_REQUIRED_COURT_ACTIONS", 0), - formationActions: envInt(env, "SIM_REQUIRED_FORMATION_ACTIONS", 0), - }; -} - -function envInt(env: Env, key: string, fallback: number): number { - const raw = env[key]; - if (!raw) return fallback; - const n = Number(raw); - if (!Number.isFinite(n)) return fallback; - if (n < 0) return fallback; - return Math.floor(n); -} diff --git a/api/_lib/eraStore.ts b/api/_lib/eraStore.ts deleted file mode 100644 index 455848d..0000000 --- a/api/_lib/eraStore.ts +++ /dev/null @@ -1,239 +0,0 @@ -import { and, eq, sql } from "drizzle-orm"; - -import { eraSnapshots, eraUserActivity } from "../../db/schema.ts"; -import { createDb } from "./db.ts"; -import { V1_ACTIVE_GOVERNORS_FALLBACK } from "./v1Constants.ts"; -import { createClockStore } from "./clockStore.ts"; - -type Env = Record; - -type UserEraCounts = { - poolVotes: number; - chamberVotes: number; - courtActions: number; - formationActions: number; -}; - -type Snapshot = { era: number; activeGovernors: number }; - -const memoryEraSnapshots = new Map(); -const memoryEraActivity = new Map(); // key: `${era}:${address}` - -export async function listEraUserActivity( - env: Env, - input: { era: number }, -): Promise> { - if (!env.DATABASE_URL) { - const rows: Array<{ address: string } & UserEraCounts> = []; - for (const [key, counts] of memoryEraActivity.entries()) { - if (!key.startsWith(`${input.era}:`)) continue; - const address = key.split(":").slice(1).join(":"); - rows.push({ address, ...counts }); - } - return rows; - } - - const db = createDb(env); - const rows = await db - .select({ - address: eraUserActivity.address, - poolVotes: eraUserActivity.poolVotes, - chamberVotes: eraUserActivity.chamberVotes, - courtActions: eraUserActivity.courtActions, - formationActions: eraUserActivity.formationActions, - }) - .from(eraUserActivity) - .where(eq(eraUserActivity.era, input.era)); - return rows.map((r) => ({ - address: r.address, - poolVotes: r.poolVotes, - chamberVotes: r.chamberVotes, - courtActions: r.courtActions, - formationActions: r.formationActions, - })); -} - -export async function ensureEraSnapshot( - env: Env, - era: number, -): Promise { - if (!env.DATABASE_URL) { - const existing = memoryEraSnapshots.get(era); - if (existing) return existing; - const snap = { era, activeGovernors: getActiveGovernorsBaseline(env) }; - memoryEraSnapshots.set(era, snap); - return snap; - } - - const db = createDb(env); - const rows = await db - .select({ - era: eraSnapshots.era, - activeGovernors: eraSnapshots.activeGovernors, - }) - .from(eraSnapshots) - .where(eq(eraSnapshots.era, era)) - .limit(1); - if (rows[0]) { - return { - era: rows[0].era ?? era, - activeGovernors: rows[0].activeGovernors, - }; - } - - const snap = { era, activeGovernors: getActiveGovernorsBaseline(env) }; - await db.insert(eraSnapshots).values({ - era: snap.era, - activeGovernors: snap.activeGovernors, - createdAt: new Date(), - }); - return snap; -} - -export async function setEraSnapshotActiveGovernors( - env: Env, - input: { era: number; activeGovernors: number }, -): Promise { - const era = input.era; - const activeGovernors = input.activeGovernors; - await ensureEraSnapshot(env, era); - - if (!env.DATABASE_URL) { - memoryEraSnapshots.set(era, { era, activeGovernors }); - return; - } - - const db = createDb(env); - await db - .insert(eraSnapshots) - .values({ era, activeGovernors, createdAt: new Date() }) - .onConflictDoUpdate({ - target: eraSnapshots.era, - set: { activeGovernors }, - }); -} - -export async function getActiveGovernorsForCurrentEra( - env: Env, -): Promise { - const clock = createClockStore(env); - const { currentEra } = await clock.get(); - const snap = await ensureEraSnapshot(env, currentEra); - return snap.activeGovernors; -} - -export async function incrementEraUserActivity( - env: Env, - input: { address: string; delta: Partial }, -): Promise { - const clock = createClockStore(env); - const { currentEra } = await clock.get(); - const address = input.address.trim(); - await ensureEraSnapshot(env, currentEra); - - const delta: UserEraCounts = { - poolVotes: input.delta.poolVotes ?? 0, - chamberVotes: input.delta.chamberVotes ?? 0, - courtActions: input.delta.courtActions ?? 0, - formationActions: input.delta.formationActions ?? 0, - }; - - if (!env.DATABASE_URL) { - const key = `${currentEra}:${address}`; - const prev = memoryEraActivity.get(key) ?? { - poolVotes: 0, - chamberVotes: 0, - courtActions: 0, - formationActions: 0, - }; - memoryEraActivity.set(key, { - poolVotes: prev.poolVotes + delta.poolVotes, - chamberVotes: prev.chamberVotes + delta.chamberVotes, - courtActions: prev.courtActions + delta.courtActions, - formationActions: prev.formationActions + delta.formationActions, - }); - return; - } - - const db = createDb(env); - const now = new Date(); - await db - .insert(eraUserActivity) - .values({ - era: currentEra, - address, - poolVotes: delta.poolVotes, - chamberVotes: delta.chamberVotes, - courtActions: delta.courtActions, - formationActions: delta.formationActions, - updatedAt: now, - }) - .onConflictDoUpdate({ - target: [eraUserActivity.era, eraUserActivity.address], - set: { - poolVotes: sql`${eraUserActivity.poolVotes} + ${delta.poolVotes}`, - chamberVotes: sql`${eraUserActivity.chamberVotes} + ${delta.chamberVotes}`, - courtActions: sql`${eraUserActivity.courtActions} + ${delta.courtActions}`, - formationActions: sql`${eraUserActivity.formationActions} + ${delta.formationActions}`, - updatedAt: now, - }, - }); -} - -export async function getUserEraActivity( - env: Env, - input: { address: string }, -): Promise<{ era: number; counts: UserEraCounts; activeGovernors: number }> { - const clock = createClockStore(env); - const { currentEra } = await clock.get(); - const snap = await ensureEraSnapshot(env, currentEra); - const address = input.address.trim(); - - if (!env.DATABASE_URL) { - const key = `${currentEra}:${address}`; - const counts = memoryEraActivity.get(key) ?? { - poolVotes: 0, - chamberVotes: 0, - courtActions: 0, - formationActions: 0, - }; - return { era: currentEra, counts, activeGovernors: snap.activeGovernors }; - } - - const db = createDb(env); - const rows = await db - .select({ - poolVotes: eraUserActivity.poolVotes, - chamberVotes: eraUserActivity.chamberVotes, - courtActions: eraUserActivity.courtActions, - formationActions: eraUserActivity.formationActions, - }) - .from(eraUserActivity) - .where( - and( - eq(eraUserActivity.era, currentEra), - eq(eraUserActivity.address, address), - ), - ) - .limit(1); - const row = rows[0]; - const counts: UserEraCounts = { - poolVotes: row?.poolVotes ?? 0, - chamberVotes: row?.chamberVotes ?? 0, - courtActions: row?.courtActions ?? 0, - formationActions: row?.formationActions ?? 0, - }; - return { era: currentEra, counts, activeGovernors: snap.activeGovernors }; -} - -export function clearEraForTests() { - memoryEraSnapshots.clear(); - memoryEraActivity.clear(); -} - -function getActiveGovernorsBaseline(env: Env): number { - const raw = env.SIM_ACTIVE_GOVERNORS ?? env.VORTEX_ACTIVE_GOVERNORS ?? ""; - const parsed = Number(raw); - if (Number.isFinite(parsed) && parsed > 0) return Math.round(parsed); - return V1_ACTIVE_GOVERNORS_FALLBACK; -} diff --git a/api/_lib/eventSchemas.ts b/api/_lib/eventSchemas.ts deleted file mode 100644 index d5f11f7..0000000 --- a/api/_lib/eventSchemas.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { z } from "zod"; - -export const toneSchema = z.enum(["ok", "warn"]); - -export const feedStageSchema = z.enum([ - "pool", - "vote", - "build", - "thread", - "courts", - "faction", -]); - -export const feedStageDatumSchema = z.object({ - title: z.string(), - description: z.string(), - value: z.string(), - tone: toneSchema.optional(), -}); - -export const feedStatSchema = z.object({ - label: z.string(), - value: z.string(), -}); - -export const feedItemSchema = z.object({ - id: z.string(), - title: z.string(), - meta: z.string(), - stage: feedStageSchema, - summaryPill: z.string(), - summary: z.string(), - stageData: z.array(feedStageDatumSchema).optional(), - stats: z.array(feedStatSchema).optional(), - proposer: z.string().optional(), - proposerId: z.string().optional(), - ctaPrimary: z.string().optional(), - ctaSecondary: z.string().optional(), - href: z.string().optional(), - timestamp: z.string(), -}); - -export type FeedItemEventPayload = z.infer; diff --git a/api/_lib/eventsStore.ts b/api/_lib/eventsStore.ts deleted file mode 100644 index 0894b7c..0000000 --- a/api/_lib/eventsStore.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { and, desc, eq, lt } from "drizzle-orm"; - -import { events } from "../../db/schema.ts"; -import { createDb } from "./db.ts"; -import type { FeedItemEventPayload } from "./eventSchemas.ts"; -import { projectFeedPageFromEvents } from "./feedEventProjector.ts"; -import { - clearMemoryFeedEventsForTests, - listMemoryFeedEvents, -} from "./feedEventsMemory.ts"; - -type Env = Record; - -export type FeedEventsPage = { - items: FeedItemEventPayload[]; - nextSeq?: number; -}; - -export async function listFeedEventsPage( - env: Env, - input: { stage?: string | null; beforeSeq?: number | null; limit: number }, -): Promise { - if (!env.DATABASE_URL) { - const rows = listMemoryFeedEvents().map((event) => ({ - seq: event.seq, - stage: event.stage, - payload: event.payload, - })); - return projectFeedPageFromEvents(rows, input); - } - - const db = createDb(env); - - const beforeSeq = input.beforeSeq; - const hasBeforeSeq = beforeSeq !== undefined && beforeSeq !== null; - - const whereClause = and( - eq(events.type, "feed.item.v1"), - ...(input.stage ? [eq(events.stage, input.stage)] : []), - ...(hasBeforeSeq ? [lt(events.seq, Math.max(0, beforeSeq))] : []), - ); - - const rows = await db - .select({ - seq: events.seq, - stage: events.stage, - payload: events.payload, - }) - .from(events) - .where(whereClause) - .orderBy(desc(events.seq)) - .limit(input.limit + 1); - return projectFeedPageFromEvents(rows, input); -} - -export function clearFeedEventsForTests(): void { - clearMemoryFeedEventsForTests(); -} diff --git a/api/_lib/feedEventProjector.ts b/api/_lib/feedEventProjector.ts deleted file mode 100644 index 624797f..0000000 --- a/api/_lib/feedEventProjector.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { feedItemSchema, type FeedItemEventPayload } from "./eventSchemas.ts"; - -export type FeedEventRow = { - seq: number; - stage: string | null; - payload: unknown; -}; - -export type FeedProjectPageInput = { - stage?: string | null; - beforeSeq?: number | null; - limit: number; -}; - -export type FeedProjectPageOutput = { - items: FeedItemEventPayload[]; - nextSeq?: number; -}; - -export function projectFeedPageFromEvents( - rows: FeedEventRow[], - input: FeedProjectPageInput, -): FeedProjectPageOutput { - let filtered = [...rows]; - if (input.stage) - filtered = filtered.filter((row) => row.stage === input.stage); - - const beforeSeq = input.beforeSeq; - const hasBeforeSeq = beforeSeq !== undefined && beforeSeq !== null; - if (hasBeforeSeq) { - filtered = filtered.filter((row) => row.seq < Math.max(0, beforeSeq)); - } - - filtered.sort((a, b) => b.seq - a.seq); - - const pageRows = filtered.slice(0, input.limit + 1); - const slice = pageRows.slice(0, input.limit); - const items = slice.map((row) => feedItemSchema.parse(row.payload)); - const nextSeq = - pageRows.length > input.limit ? pageRows[input.limit]?.seq : undefined; - - return nextSeq !== undefined ? { items, nextSeq } : { items }; -} diff --git a/api/_lib/feedEventsMemory.ts b/api/_lib/feedEventsMemory.ts deleted file mode 100644 index 820af72..0000000 --- a/api/_lib/feedEventsMemory.ts +++ /dev/null @@ -1,39 +0,0 @@ -import type { FeedItemEventPayload } from "./eventSchemas.ts"; - -export type MemoryFeedEvent = { - seq: number; - stage: string | null; - actorAddress: string | null; - entityType: string; - entityId: string; - payload: FeedItemEventPayload; -}; - -let nextSeq = 1; -const memory: MemoryFeedEvent[] = []; - -export function appendMemoryFeedEvent( - input: Omit, -): void { - memory.push({ ...input, seq: nextSeq++ }); -} - -export function listMemoryFeedEvents(): MemoryFeedEvent[] { - return [...memory]; -} - -export function hasMemoryFeedEvent(input: { - entityType: string; - entityId: string; -}): boolean { - return memory.some( - (event) => - event.entityType === input.entityType && - event.entityId === input.entityId, - ); -} - -export function clearMemoryFeedEventsForTests(): void { - memory.length = 0; - nextSeq = 1; -} diff --git a/api/_lib/formationStore.ts b/api/_lib/formationStore.ts deleted file mode 100644 index 513c7c1..0000000 --- a/api/_lib/formationStore.ts +++ /dev/null @@ -1,645 +0,0 @@ -import { and, eq, sql } from "drizzle-orm"; - -import { - formationMilestoneEvents, - formationMilestones, - formationProjects, - formationTeam, -} from "../../db/schema.ts"; -import type { ReadModelsStore } from "./readModelsStore.ts"; -import { createDb } from "./db.ts"; - -type Env = Record; - -type FormationProjectSeed = { - teamSlotsTotal: number; - baseTeamFilled: number; - milestonesTotal: number; - baseMilestonesCompleted: number; - budgetTotalHmnd: number | null; - baseBudgetAllocatedHmnd: number | null; -}; - -type FormationMilestoneStatus = "todo" | "submitted" | "unlocked"; - -type FormationSummary = { - teamFilled: number; - teamTotal: number; - milestonesCompleted: number; - milestonesTotal: number; -}; - -const memoryProjects = new Map(); -const memoryTeam = new Map>(); -const memoryMilestones = new Map< - string, - Map ->(); - -export type FormationSeedInput = FormationProjectSeed; - -export async function isFormationTeamMember( - env: Env, - input: { proposalId: string; memberAddress: string }, -): Promise { - const address = input.memberAddress.trim(); - if (!env.DATABASE_URL) { - const team = memoryTeam.get(input.proposalId); - if (!team) return false; - return team.has(address); - } - const db = createDb(env); - const existing = await db - .select({ memberAddress: formationTeam.memberAddress }) - .from(formationTeam) - .where( - and( - eq(formationTeam.proposalId, input.proposalId), - eq(formationTeam.memberAddress, address), - ), - ) - .limit(1); - return existing.length > 0; -} - -export async function getFormationMilestoneStatus( - env: Env, - readModels: ReadModelsStore, - input: { proposalId: string; milestoneIndex: number }, -): Promise { - const seed = await ensureFormationSeed(env, readModels, input.proposalId); - if (input.milestoneIndex < 1 || input.milestoneIndex > seed.milestonesTotal) { - throw new Error("milestone_out_of_range"); - } - - if (!env.DATABASE_URL) { - const milestones = memoryMilestones.get(input.proposalId); - if (!milestones) throw new Error("milestones_missing"); - return milestones.get(input.milestoneIndex) ?? "todo"; - } - - const db = createDb(env); - const rows = await db - .select({ status: formationMilestones.status }) - .from(formationMilestones) - .where( - and( - eq(formationMilestones.proposalId, input.proposalId), - eq(formationMilestones.milestoneIndex, input.milestoneIndex), - ), - ) - .limit(1); - const current = rows[0]?.status; - if (current === "submitted" || current === "unlocked" || current === "todo") { - return current; - } - return "todo"; -} - -export async function ensureFormationSeed( - env: Env, - readModels: ReadModelsStore, - proposalId: string, -): Promise { - if (!env.DATABASE_URL) { - const existing = memoryProjects.get(proposalId); - if (existing) return existing; - const seed = await buildSeedFromReadModel(readModels, proposalId); - memoryProjects.set(proposalId, seed); - const milestoneMap = new Map(); - for (let i = 1; i <= seed.milestonesTotal; i += 1) { - milestoneMap.set( - i, - i <= seed.baseMilestonesCompleted ? "unlocked" : "todo", - ); - } - memoryMilestones.set(proposalId, milestoneMap); - if (!memoryTeam.has(proposalId)) memoryTeam.set(proposalId, new Map()); - return seed; - } - - const db = createDb(env); - const existing = await db - .select() - .from(formationProjects) - .where(eq(formationProjects.proposalId, proposalId)) - .limit(1); - if (existing[0]) { - return { - teamSlotsTotal: existing[0].teamSlotsTotal, - baseTeamFilled: existing[0].baseTeamFilled, - milestonesTotal: existing[0].milestonesTotal, - baseMilestonesCompleted: existing[0].baseMilestonesCompleted, - budgetTotalHmnd: existing[0].budgetTotalHmnd ?? null, - baseBudgetAllocatedHmnd: existing[0].baseBudgetAllocatedHmnd ?? null, - }; - } - - const seed = await buildSeedFromReadModel(readModels, proposalId); - const now = new Date(); - await db.insert(formationProjects).values({ - proposalId, - teamSlotsTotal: seed.teamSlotsTotal, - baseTeamFilled: seed.baseTeamFilled, - milestonesTotal: seed.milestonesTotal, - baseMilestonesCompleted: seed.baseMilestonesCompleted, - budgetTotalHmnd: seed.budgetTotalHmnd, - baseBudgetAllocatedHmnd: seed.baseBudgetAllocatedHmnd, - createdAt: now, - updatedAt: now, - }); - - if (seed.milestonesTotal > 0) { - await db - .insert(formationMilestones) - .values( - Array.from({ length: seed.milestonesTotal }, (_, idx) => { - const milestoneIndex = idx + 1; - return { - proposalId, - milestoneIndex, - status: - milestoneIndex <= seed.baseMilestonesCompleted - ? "unlocked" - : "todo", - createdAt: now, - updatedAt: now, - }; - }), - ) - .onConflictDoNothing({ - target: [ - formationMilestones.proposalId, - formationMilestones.milestoneIndex, - ], - }); - } - - return seed; -} - -export async function ensureFormationSeedFromInput( - env: Env, - input: { proposalId: string; seed: FormationSeedInput }, -): Promise { - if (!env.DATABASE_URL) { - const existing = memoryProjects.get(input.proposalId); - if (existing) return; - memoryProjects.set(input.proposalId, input.seed); - const milestoneMap = new Map(); - for (let i = 1; i <= input.seed.milestonesTotal; i += 1) { - milestoneMap.set( - i, - i <= input.seed.baseMilestonesCompleted ? "unlocked" : "todo", - ); - } - memoryMilestones.set(input.proposalId, milestoneMap); - if (!memoryTeam.has(input.proposalId)) - memoryTeam.set(input.proposalId, new Map()); - return; - } - - const db = createDb(env); - const existing = await db - .select() - .from(formationProjects) - .where(eq(formationProjects.proposalId, input.proposalId)) - .limit(1); - if (existing[0]) return; - - const now = new Date(); - await db.insert(formationProjects).values({ - proposalId: input.proposalId, - teamSlotsTotal: input.seed.teamSlotsTotal, - baseTeamFilled: input.seed.baseTeamFilled, - milestonesTotal: input.seed.milestonesTotal, - baseMilestonesCompleted: input.seed.baseMilestonesCompleted, - budgetTotalHmnd: input.seed.budgetTotalHmnd, - baseBudgetAllocatedHmnd: input.seed.baseBudgetAllocatedHmnd, - createdAt: now, - updatedAt: now, - }); - - if (input.seed.milestonesTotal > 0) { - await db - .insert(formationMilestones) - .values( - Array.from({ length: input.seed.milestonesTotal }, (_, idx) => { - const milestoneIndex = idx + 1; - return { - proposalId: input.proposalId, - milestoneIndex, - status: - milestoneIndex <= input.seed.baseMilestonesCompleted - ? "unlocked" - : "todo", - createdAt: now, - updatedAt: now, - }; - }), - ) - .onConflictDoNothing({ - target: [ - formationMilestones.proposalId, - formationMilestones.milestoneIndex, - ], - }); - } -} - -export function buildV1FormationSeedFromProposalPayload( - payload: unknown, -): FormationSeedInput { - const record = - payload && typeof payload === "object" && !Array.isArray(payload) - ? (payload as Record) - : null; - - const timeline = Array.isArray(record?.timeline) - ? (record?.timeline as unknown[]) - : []; - const budgetItems = Array.isArray(record?.budgetItems) - ? (record?.budgetItems as Array>) - : []; - - const budgetTotalHmnd = budgetItems.reduce((sum, item) => { - const amountRaw = typeof item.amount === "string" ? item.amount : ""; - const n = Number(amountRaw); - if (!Number.isFinite(n) || n <= 0) return sum; - return sum + n; - }, 0); - - return { - teamSlotsTotal: 3, - baseTeamFilled: 1, - milestonesTotal: timeline.length, - baseMilestonesCompleted: 0, - budgetTotalHmnd: budgetTotalHmnd > 0 ? budgetTotalHmnd : null, - baseBudgetAllocatedHmnd: 0, - }; -} - -export async function getFormationSummary( - env: Env, - readModels: ReadModelsStore, - proposalId: string, -): Promise { - const seed = await ensureFormationSeed(env, readModels, proposalId); - if (!env.DATABASE_URL) { - const teamCount = memoryTeam.get(proposalId)?.size ?? 0; - const milestones = memoryMilestones.get(proposalId); - const completed = milestones - ? Array.from(milestones.values()).filter((s) => s === "unlocked").length - : seed.baseMilestonesCompleted; - return { - teamFilled: seed.baseTeamFilled + teamCount, - teamTotal: seed.teamSlotsTotal, - milestonesCompleted: completed, - milestonesTotal: seed.milestonesTotal, - }; - } - - const db = createDb(env); - const [teamAgg] = await db - .select({ n: sql`count(*)` }) - .from(formationTeam) - .where(eq(formationTeam.proposalId, proposalId)); - const [milestoneAgg] = await db - .select({ - n: sql`sum(case when ${formationMilestones.status} = 'unlocked' then 1 else 0 end)`, - }) - .from(formationMilestones) - .where(eq(formationMilestones.proposalId, proposalId)); - - return { - teamFilled: seed.baseTeamFilled + Number(teamAgg?.n ?? 0), - teamTotal: seed.teamSlotsTotal, - milestonesCompleted: Number( - milestoneAgg?.n ?? seed.baseMilestonesCompleted, - ), - milestonesTotal: seed.milestonesTotal, - }; -} - -export async function listFormationJoiners( - env: Env, - proposalId: string, -): Promise<{ address: string; role?: string | null }[]> { - if (!env.DATABASE_URL) { - const team = memoryTeam.get(proposalId); - if (!team) return []; - return Array.from(team.entries()).map(([address, meta]) => ({ - address, - role: meta.role ?? null, - })); - } - - const db = createDb(env); - const rows = await db - .select({ - address: formationTeam.memberAddress, - role: formationTeam.role, - }) - .from(formationTeam) - .where(eq(formationTeam.proposalId, proposalId)); - return rows.map((r) => ({ address: r.address, role: r.role ?? null })); -} - -export async function joinFormationProject( - env: Env, - readModels: ReadModelsStore, - input: { proposalId: string; memberAddress: string; role?: string | null }, -): Promise<{ summary: FormationSummary; created: boolean }> { - const seed = await ensureFormationSeed(env, readModels, input.proposalId); - const address = input.memberAddress.trim(); - - if (!env.DATABASE_URL) { - const team = memoryTeam.get(input.proposalId) ?? new Map(); - const created = !team.has(address); - if (created) { - const current = seed.baseTeamFilled + team.size; - if (current >= seed.teamSlotsTotal) throw new Error("team_full"); - team.set(address, { role: input.role ?? null }); - memoryTeam.set(input.proposalId, team); - } - return { - summary: await getFormationSummary(env, readModels, input.proposalId), - created, - }; - } - - const db = createDb(env); - const existing = await db - .select({ memberAddress: formationTeam.memberAddress }) - .from(formationTeam) - .where( - and( - eq(formationTeam.proposalId, input.proposalId), - eq(formationTeam.memberAddress, address), - ), - ) - .limit(1); - if (existing.length > 0) { - return { - summary: await getFormationSummary(env, readModels, input.proposalId), - created: false, - }; - } - - const currentSummary = await getFormationSummary( - env, - readModels, - input.proposalId, - ); - if (currentSummary.teamFilled >= currentSummary.teamTotal) - throw new Error("team_full"); - - const now = new Date(); - await db - .insert(formationTeam) - .values({ - proposalId: input.proposalId, - memberAddress: address, - role: input.role ?? null, - createdAt: now, - updatedAt: now, - }) - .onConflictDoNothing({ - target: [formationTeam.proposalId, formationTeam.memberAddress], - }); - - return { - summary: await getFormationSummary(env, readModels, input.proposalId), - created: true, - }; -} - -export async function submitFormationMilestone( - env: Env, - readModels: ReadModelsStore, - input: { - proposalId: string; - milestoneIndex: number; - actorAddress: string; - note?: string | null; - }, -): Promise<{ summary: FormationSummary; created: boolean }> { - const seed = await ensureFormationSeed(env, readModels, input.proposalId); - if (input.milestoneIndex < 1 || input.milestoneIndex > seed.milestonesTotal) { - throw new Error("milestone_out_of_range"); - } - - if (!env.DATABASE_URL) { - const milestones = memoryMilestones.get(input.proposalId); - if (!milestones) throw new Error("milestones_missing"); - const current = milestones.get(input.milestoneIndex) ?? "todo"; - if (current === "unlocked") throw new Error("milestone_already_unlocked"); - const created = current !== "submitted"; - if (created) milestones.set(input.milestoneIndex, "submitted"); - return { - summary: await getFormationSummary(env, readModels, input.proposalId), - created, - }; - } - - const db = createDb(env); - const now = new Date(); - const rows = await db - .select({ status: formationMilestones.status }) - .from(formationMilestones) - .where( - and( - eq(formationMilestones.proposalId, input.proposalId), - eq(formationMilestones.milestoneIndex, input.milestoneIndex), - ), - ) - .limit(1); - const current = rows[0]?.status; - if (current === "unlocked") throw new Error("milestone_already_unlocked"); - const created = current !== "submitted"; - - await db - .insert(formationMilestones) - .values({ - proposalId: input.proposalId, - milestoneIndex: input.milestoneIndex, - status: "submitted", - createdAt: now, - updatedAt: now, - }) - .onConflictDoUpdate({ - target: [ - formationMilestones.proposalId, - formationMilestones.milestoneIndex, - ], - set: { status: "submitted", updatedAt: now }, - }); - - await db.insert(formationMilestoneEvents).values({ - proposalId: input.proposalId, - milestoneIndex: input.milestoneIndex, - type: "submit", - actorAddress: input.actorAddress, - payload: { note: input.note ?? null }, - createdAt: now, - }); - - return { - summary: await getFormationSummary(env, readModels, input.proposalId), - created, - }; -} - -export async function requestFormationMilestoneUnlock( - env: Env, - readModels: ReadModelsStore, - input: { - proposalId: string; - milestoneIndex: number; - actorAddress: string; - }, -): Promise<{ summary: FormationSummary; created: boolean }> { - const seed = await ensureFormationSeed(env, readModels, input.proposalId); - if (input.milestoneIndex < 1 || input.milestoneIndex > seed.milestonesTotal) { - throw new Error("milestone_out_of_range"); - } - - if (!env.DATABASE_URL) { - const milestones = memoryMilestones.get(input.proposalId); - if (!milestones) throw new Error("milestones_missing"); - const current = milestones.get(input.milestoneIndex) ?? "todo"; - if (current === "unlocked") throw new Error("milestone_already_unlocked"); - if (current === "todo") throw new Error("milestone_not_submitted"); - milestones.set(input.milestoneIndex, "unlocked"); - return { - summary: await getFormationSummary(env, readModels, input.proposalId), - created: true, - }; - } - - const db = createDb(env); - const now = new Date(); - const rows = await db - .select({ status: formationMilestones.status }) - .from(formationMilestones) - .where( - and( - eq(formationMilestones.proposalId, input.proposalId), - eq(formationMilestones.milestoneIndex, input.milestoneIndex), - ), - ) - .limit(1); - const current = rows[0]?.status; - if (current === "unlocked") throw new Error("milestone_already_unlocked"); - if (current === "todo" || current === undefined) - throw new Error("milestone_not_submitted"); - - await db - .insert(formationMilestones) - .values({ - proposalId: input.proposalId, - milestoneIndex: input.milestoneIndex, - status: "unlocked", - createdAt: now, - updatedAt: now, - }) - .onConflictDoUpdate({ - target: [ - formationMilestones.proposalId, - formationMilestones.milestoneIndex, - ], - set: { status: "unlocked", updatedAt: now }, - }); - - await db.insert(formationMilestoneEvents).values({ - proposalId: input.proposalId, - milestoneIndex: input.milestoneIndex, - type: "request_unlock", - actorAddress: input.actorAddress, - payload: {}, - createdAt: now, - }); - - return { - summary: await getFormationSummary(env, readModels, input.proposalId), - created: true, - }; -} - -export function clearFormationForTests() { - memoryProjects.clear(); - memoryTeam.clear(); - memoryMilestones.clear(); -} - -async function buildSeedFromReadModel( - readModels: ReadModelsStore, - proposalId: string, -): Promise { - const payload = await readModels.get(`proposals:${proposalId}:formation`); - if (!payload || typeof payload !== "object" || Array.isArray(payload)) { - return { - teamSlotsTotal: 0, - baseTeamFilled: 0, - milestonesTotal: 0, - baseMilestonesCompleted: 0, - budgetTotalHmnd: null, - baseBudgetAllocatedHmnd: null, - }; - } - - const anyPayload = payload as Record; - const team = parseRatio(asString(anyPayload.teamSlots, "")); - const milestones = parseRatio(asString(anyPayload.milestones, "")); - - const stageData = Array.isArray(anyPayload.stageData) - ? anyPayload.stageData - : []; - const budgetEntry = stageData.find( - (entry) => - entry && - typeof entry === "object" && - !Array.isArray(entry) && - String((entry as Record).title ?? "") - .toLowerCase() - .includes("budget"), - ) as Record | undefined; - const budgetPair = parseRatio(asString(budgetEntry?.value, "")); - - return { - teamSlotsTotal: team?.total ?? 0, - baseTeamFilled: team?.filled ?? 0, - milestonesTotal: milestones?.total ?? 0, - baseMilestonesCompleted: milestones?.filled ?? 0, - budgetTotalHmnd: budgetPair ? budgetPair.total : null, - baseBudgetAllocatedHmnd: budgetPair ? budgetPair.filled : null, - }; -} - -function asString(value: unknown, fallback = ""): string { - return typeof value === "string" ? value : fallback; -} - -function parseRatio(input: string): { filled: number; total: number } | null { - const normalized = input.replace(/HMND/gi, "").trim(); - if (!normalized) return null; - const parts = normalized.split("/").map((p) => p.trim()); - if (parts.length !== 2) return null; - const filled = parseHmndNumber(parts[0]); - const total = parseHmndNumber(parts[1]); - if (filled === null || total === null) return null; - return { filled, total }; -} - -function parseHmndNumber(input: string): number | null { - const s = input.trim().replace(/,/g, "").toLowerCase(); - if (!s) return null; - const match = s.match(/^([0-9]+(?:\.[0-9]+)?)\s*([km])?$/); - if (!match) return null; - const n = Number(match[1]); - if (!Number.isFinite(n)) return null; - const suffix = match[2]; - if (suffix === "k") return Math.round(n * 1_000); - if (suffix === "m") return Math.round(n * 1_000_000); - return Math.round(n); -} diff --git a/api/_lib/gate.ts b/api/_lib/gate.ts deleted file mode 100644 index 988b857..0000000 --- a/api/_lib/gate.ts +++ /dev/null @@ -1,132 +0,0 @@ -import { eq } from "drizzle-orm"; - -import { eligibilityCache } from "../../db/schema.ts"; -import { envBoolean, envCsv } from "./env.ts"; -import { createDb } from "./db.ts"; -import { isActiveHumanNodeViaRpc } from "./humanodeRpc.ts"; -import { getSimConfig } from "./simConfig.ts"; - -type Env = Record; - -export type GateResult = { - eligible: boolean; - reason?: string; - expiresAt: string; -}; - -const memory = new Map(); - -export async function checkEligibility( - env: Env, - address: string, - requestUrl?: string, -): Promise { - const eligibleAddresses = new Set( - envCsv(env, "DEV_ELIGIBLE_ADDRESSES").map((a) => a.trim()), - ); - - const ttlMs = 10 * 60_000; - const expiresAt = new Date(Date.now() + ttlMs).toISOString(); - - if (envBoolean(env, "DEV_BYPASS_GATE")) return { eligible: true, expiresAt }; - if (eligibleAddresses.has(address.trim())) - return { eligible: true, expiresAt }; - - let envWithRpc: Env = env; - if (!env.HUMANODE_RPC_URL && requestUrl) { - const cfg = await getSimConfig(env, requestUrl); - const fromCfg = (cfg?.humanodeRpcUrl ?? "").trim(); - if (fromCfg) { - envWithRpc = { ...env, HUMANODE_RPC_URL: fromCfg }; - } - } - - if (env.DATABASE_URL) { - const db = createDb(env); - const now = new Date(); - const rows = await db - .select({ - isActive: eligibilityCache.isActiveHumanNode, - expiresAt: eligibilityCache.expiresAt, - reasonCode: eligibilityCache.reasonCode, - }) - .from(eligibilityCache) - .where(eq(eligibilityCache.address, address)) - .limit(1); - const row = rows[0]; - if (row && row.expiresAt.getTime() > now.getTime()) { - return { - eligible: row.isActive === 1, - reason: - row.isActive === 1 ? undefined : (row.reasonCode ?? "not_eligible"), - expiresAt: row.expiresAt.toISOString(), - }; - } - - let eligible = false; - let reason: string | undefined = undefined; - try { - eligible = await isActiveHumanNodeViaRpc(envWithRpc, address); - if (!eligible) reason = "not_in_validator_set"; - } catch (error) { - eligible = false; - const message = (error as Error | null)?.message ?? ""; - reason = message.includes("HUMANODE_RPC_URL") - ? "rpc_not_configured" - : "rpc_error"; - } - - const nextExpires = new Date(Date.now() + ttlMs); - await db - .insert(eligibilityCache) - .values({ - address, - isActiveHumanNode: eligible ? 1 : 0, - checkedAt: new Date(), - source: "rpc", - expiresAt: nextExpires, - reasonCode: eligible ? null : reason, - }) - .onConflictDoUpdate({ - target: eligibilityCache.address, - set: { - isActiveHumanNode: eligible ? 1 : 0, - checkedAt: new Date(), - source: "rpc", - expiresAt: nextExpires, - reasonCode: eligible ? null : reason, - }, - }); - - return { - eligible, - reason: eligible ? undefined : reason, - expiresAt: nextExpires.toISOString(), - }; - } - - const cached = memory.get(address); - if (cached && new Date(cached.expiresAt).getTime() > Date.now()) - return cached; - - let eligible = false; - let reason: string | undefined = undefined; - try { - eligible = await isActiveHumanNodeViaRpc(envWithRpc, address); - if (!eligible) reason = "not_in_validator_set"; - } catch (error) { - eligible = false; - const message = (error as Error | null)?.message ?? ""; - reason = message.includes("HUMANODE_RPC_URL") - ? "rpc_not_configured" - : "rpc_error"; - } - - const result: GateResult = { - eligible, - reason: eligible ? undefined : reason, - expiresAt, - }; - memory.set(address, result); - return result; -} diff --git a/api/_lib/http.ts b/api/_lib/http.ts deleted file mode 100644 index d3caddb..0000000 --- a/api/_lib/http.ts +++ /dev/null @@ -1,33 +0,0 @@ -export function jsonResponse( - body: unknown, - init: ResponseInit & { headers?: HeadersInit } = {}, -): Response { - const headers = new Headers(init.headers); - if (!headers.has("content-type")) - headers.set("content-type", "application/json; charset=utf-8"); - return new Response(JSON.stringify(body), { ...init, headers }); -} - -export function errorResponse( - status: number, - message: string, - extra?: Record, -): Response { - return jsonResponse( - { - error: { - message, - ...(extra ?? {}), - }, - }, - { status }, - ); -} - -export async function readJson(request: Request): Promise { - const contentType = request.headers.get("content-type") ?? ""; - if (!contentType.toLowerCase().includes("application/json")) { - throw new Error("Expected application/json request body"); - } - return (await request.json()) as T; -} diff --git a/api/_lib/humanodeRpc.ts b/api/_lib/humanodeRpc.ts deleted file mode 100644 index 997820a..0000000 --- a/api/_lib/humanodeRpc.ts +++ /dev/null @@ -1,115 +0,0 @@ -import { xxhashAsHex } from "@polkadot/util-crypto"; -import { cryptoWaitReady, decodeAddress } from "@polkadot/util-crypto"; -import { hexToU8a, u8aToHex } from "@polkadot/util"; - -type Env = Record; - -type JsonRpcResponse = - | { jsonrpc: "2.0"; id: number; result: T } - | { jsonrpc: "2.0"; id: number; error: { code: number; message: string } }; - -function storageKeySessionValidators(): string { - const pallet = xxhashAsHex("Session", 128).slice(2); - const item = xxhashAsHex("Validators", 128).slice(2); - return `0x${pallet}${item}`; -} - -async function rpcCall(rpcUrl: string, method: string, params: unknown[]) { - const res = await fetch(rpcUrl, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params }), - }); - if (!res.ok) throw new Error(`RPC HTTP ${res.status}`); - const json = (await res.json()) as JsonRpcResponse; - if ("error" in json) throw new Error(json.error.message); - return json.result; -} - -function readCompactU32( - bytes: Uint8Array, - offset: number, -): { value: number; offset: number } { - const first = bytes[offset]; - if (first === undefined) throw new Error("SCALE: unexpected EOF"); - const mode = first & 0b11; - if (mode === 0) return { value: first >> 2, offset: offset + 1 }; - if (mode === 1) { - const b1 = bytes[offset + 1]; - if (b1 === undefined) throw new Error("SCALE: unexpected EOF"); - const value = (first >> 2) | (b1 << 6); - return { value, offset: offset + 2 }; - } - if (mode === 2) { - const b1 = bytes[offset + 1]; - const b2 = bytes[offset + 2]; - const b3 = bytes[offset + 3]; - if (b3 === undefined) throw new Error("SCALE: unexpected EOF"); - const value = (first >> 2) | (b1 << 6) | (b2 << 14) | (b3 << 22); - return { value, offset: offset + 4 }; - } - throw new Error("SCALE: compact big-int not supported"); -} - -function decodeVecAccountId32(hex: string | null): Uint8Array[] { - if (!hex || hex === "0x") return []; - const bytes = hexToU8a(hex); - const { value: length, offset } = readCompactU32(bytes, 0); - const accounts: Uint8Array[] = []; - let cursor = offset; - for (let i = 0; i < length; i++) { - const chunk = bytes.slice(cursor, cursor + 32); - if (chunk.length !== 32) throw new Error("SCALE: invalid AccountId"); - accounts.push(chunk); - cursor += 32; - } - return accounts; -} - -function publicKeyHexFromAddress(address: string): string | null { - try { - const subject = decodeAddress(address.trim()); - return u8aToHex(subject); - } catch { - return null; - } -} - -export async function fetchSessionValidatorsViaRpc( - env: Env, -): Promise { - const rpcUrl = env.HUMANODE_RPC_URL; - if (!rpcUrl) throw new Error("HUMANODE_RPC_URL is required"); - - await cryptoWaitReady(); - - const validatorsKey = storageKeySessionValidators(); - const validatorsStorage = await rpcCall( - rpcUrl, - "state_getStorage", - [validatorsKey], - ); - const validators = decodeVecAccountId32(validatorsStorage); - return validators.map((pk) => u8aToHex(pk)); -} - -export function isSs58OrHexAddressInSet( - address: string, - validatorPublicKeysHex: Set, -): boolean { - const pk = publicKeyHexFromAddress(address); - if (!pk) return false; - return validatorPublicKeysHex.has(pk); -} - -export async function isActiveHumanNodeViaRpc( - env: Env, - address: string, -): Promise { - await cryptoWaitReady(); - const subject = decodeAddress(address.trim()); - - const validators = await fetchSessionValidatorsViaRpc(env); - const subjectHex = u8aToHex(subject); - return validators.some((v) => v === subjectHex); -} diff --git a/api/_lib/idempotencyStore.ts b/api/_lib/idempotencyStore.ts deleted file mode 100644 index 20d0f41..0000000 --- a/api/_lib/idempotencyStore.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { eq } from "drizzle-orm"; - -import { idempotencyKeys } from "../../db/schema.ts"; -import { createDb } from "./db.ts"; - -type Env = Record; - -type Stored = { - address: string; - request: unknown; - response: unknown; -}; - -const memory = new Map(); - -function stableStringify(value: unknown): string { - if (value === null || typeof value !== "object") return JSON.stringify(value); - if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`; - const obj = value as Record; - const keys = Object.keys(obj).sort((a, b) => a.localeCompare(b)); - return `{${keys.map((k) => `${JSON.stringify(k)}:${stableStringify(obj[k])}`).join(",")}}`; -} - -export async function getIdempotencyResponse( - env: Env, - input: { key: string; address: string; request: unknown }, -): Promise< - | { hit: true; response: unknown } - | { hit: false } - | { hit: false; conflict: true } -> { - if (!env.DATABASE_URL) { - const existing = memory.get(input.key); - if (!existing) return { hit: false }; - if (existing.address !== input.address) - return { hit: false, conflict: true }; - if (stableStringify(existing.request) !== stableStringify(input.request)) { - return { hit: false, conflict: true }; - } - return { hit: true, response: existing.response }; - } - - const db = createDb(env); - const rows = await db - .select({ - address: idempotencyKeys.address, - request: idempotencyKeys.request, - response: idempotencyKeys.response, - }) - .from(idempotencyKeys) - .where(eq(idempotencyKeys.key, input.key)) - .limit(1); - const row = rows[0]; - if (!row) return { hit: false }; - if (row.address !== input.address) return { hit: false, conflict: true }; - if (stableStringify(row.request) !== stableStringify(input.request)) { - return { hit: false, conflict: true }; - } - return { hit: true, response: row.response }; -} - -export async function storeIdempotencyResponse( - env: Env, - input: { key: string; address: string; request: unknown; response: unknown }, -): Promise { - if (!env.DATABASE_URL) { - memory.set(input.key, { - address: input.address, - request: input.request, - response: input.response, - }); - return; - } - - const db = createDb(env); - await db.insert(idempotencyKeys).values({ - key: input.key, - address: input.address, - request: input.request, - response: input.response, - createdAt: new Date(), - }); -} - -export function clearIdempotencyForTests() { - memory.clear(); -} diff --git a/api/_lib/nonceStore.ts b/api/_lib/nonceStore.ts deleted file mode 100644 index c886967..0000000 --- a/api/_lib/nonceStore.ts +++ /dev/null @@ -1,132 +0,0 @@ -import { and, eq, gt, isNull } from "drizzle-orm"; - -import { authNonces } from "../../db/schema.ts"; -import { createDb } from "./db.ts"; - -type Env = Record; - -export type NonceStore = { - create: (input: { - address: string; - nonce: string; - requestIp?: string; - expiresAt: Date; - }) => Promise; - consume: (input: { - address: string; - nonce: string; - }) => Promise< - | { ok: true } - | { ok: false; reason: "not_found" | "expired" | "used" | "mismatch" } - >; - canIssue: (input: { - address: string; - requestIp?: string; - }) => Promise< - | { ok: true } - | { ok: false; reason: "rate_limited"; retryAfterSeconds: number } - >; -}; - -const memory = new Map< - string, - { address: string; expiresAt: number; usedAt?: number; requestIp?: string } ->(); -const memoryIssuedAt = new Map(); - -function nowMs(): number { - return Date.now(); -} - -export function createNonceStore(env: Env): NonceStore { - if (!env.DATABASE_URL) { - return { - canIssue: async ({ requestIp }) => { - if (!requestIp) return { ok: true }; - const now = nowMs(); - const windowMs = 60_000; - const limit = 20; - const issued = memoryIssuedAt.get(requestIp) ?? []; - const next = issued.filter((t) => now - t < windowMs); - if (next.length >= limit) { - return { ok: false, reason: "rate_limited", retryAfterSeconds: 60 }; - } - next.push(now); - memoryIssuedAt.set(requestIp, next); - return { ok: true }; - }, - create: async ({ address, nonce, expiresAt, requestIp }) => { - memory.set(nonce, { - address, - expiresAt: expiresAt.getTime(), - requestIp, - }); - }, - consume: async ({ address, nonce }) => { - const row = memory.get(nonce); - if (!row) return { ok: false, reason: "not_found" }; - if (row.address !== address) return { ok: false, reason: "mismatch" }; - if (row.usedAt) return { ok: false, reason: "used" }; - if (nowMs() > row.expiresAt) return { ok: false, reason: "expired" }; - row.usedAt = nowMs(); - return { ok: true }; - }, - }; - } - - const db = createDb(env); - - return { - canIssue: async ({ requestIp }) => { - if (!requestIp) return { ok: true }; - const now = new Date(); - const windowStart = new Date(now.getTime() - 60_000); - const limit = 20; - const rows = await db - .select({ nonce: authNonces.nonce }) - .from(authNonces) - .where( - and( - eq(authNonces.requestIp, requestIp), - gt(authNonces.createdAt, windowStart), - ), - ) - .limit(limit + 1); - if (rows.length > limit) { - return { ok: false, reason: "rate_limited", retryAfterSeconds: 60 }; - } - return { ok: true }; - }, - create: async ({ address, nonce, requestIp, expiresAt }) => { - await db.insert(authNonces).values({ - nonce, - address, - requestIp, - expiresAt, - }); - }, - consume: async ({ address, nonce }) => { - const rows = await db - .select({ - address: authNonces.address, - expiresAt: authNonces.expiresAt, - usedAt: authNonces.usedAt, - }) - .from(authNonces) - .where(eq(authNonces.nonce, nonce)) - .limit(1); - const row = rows[0]; - if (!row) return { ok: false, reason: "not_found" }; - if (row.address !== address) return { ok: false, reason: "mismatch" }; - if (row.usedAt) return { ok: false, reason: "used" }; - if (row.expiresAt.getTime() < Date.now()) - return { ok: false, reason: "expired" }; - - await db - .update(authNonces) - .set({ usedAt: new Date() }) - .where(and(eq(authNonces.nonce, nonce), isNull(authNonces.usedAt))); - return { ok: true }; - }, - }; -} diff --git a/api/_lib/pages.d.ts b/api/_lib/pages.d.ts deleted file mode 100644 index 15922df..0000000 --- a/api/_lib/pages.d.ts +++ /dev/null @@ -1,7 +0,0 @@ -// Minimal runtime handler types for editor/typecheck support. - -type ApiHandler> = (context: { - request: Request; - env: Env; - params?: Record; -}) => Response | Promise; diff --git a/api/_lib/poolQuorum.ts b/api/_lib/poolQuorum.ts deleted file mode 100644 index ff802a6..0000000 --- a/api/_lib/poolQuorum.ts +++ /dev/null @@ -1,42 +0,0 @@ -export type PoolQuorumInputs = { - attentionQuorum: number; // fraction, e.g. 0.2 - activeGovernors: number; // denominator - upvoteFloor: number; // absolute number of upvotes required -}; - -export type PoolCounts = { upvotes: number; downvotes: number }; - -export type PoolQuorumResult = { - engaged: number; - engagedNeeded: number; - attentionMet: boolean; - upvoteMet: boolean; - shouldAdvance: boolean; -}; - -export function evaluatePoolQuorum( - inputs: PoolQuorumInputs, - counts: PoolCounts, -): PoolQuorumResult { - const active = Math.max(0, Math.floor(inputs.activeGovernors)); - const engaged = Math.max(0, counts.upvotes) + Math.max(0, counts.downvotes); - const quorum = Math.max(0, Math.min(1, inputs.attentionQuorum)); - - // With very small active sets, ceil(active * quorum) can become 1 and cause - // "single-vote advances"; require at least 2 engaged governors whenever there - // is more than 1 active governor. - const minEngaged = active > 1 ? 2 : 1; - const engagedNeeded = - active > 0 ? Math.max(minEngaged, Math.ceil(active * quorum)) : 0; - const attentionMet = active > 0 ? engaged >= engagedNeeded : false; - const upvoteMet = - Math.max(0, counts.upvotes) >= Math.max(0, inputs.upvoteFloor); - - return { - engaged, - engagedNeeded, - attentionMet, - upvoteMet, - shouldAdvance: attentionMet && upvoteMet, - }; -} diff --git a/api/_lib/poolVotesStore.ts b/api/_lib/poolVotesStore.ts deleted file mode 100644 index e287182..0000000 --- a/api/_lib/poolVotesStore.ts +++ /dev/null @@ -1,118 +0,0 @@ -import { and, eq, sql } from "drizzle-orm"; - -import { poolVotes } from "../../db/schema.ts"; -import { createDb } from "./db.ts"; - -type Env = Record; - -type Direction = 1 | -1; - -type Counts = { upvotes: number; downvotes: number }; - -const memoryVotes = new Map>(); - -export async function hasPoolVote( - env: Env, - input: { proposalId: string; voterAddress: string }, -): Promise { - const voterAddress = input.voterAddress.trim(); - if (!env.DATABASE_URL) { - const byVoter = memoryVotes.get(input.proposalId); - if (!byVoter) return false; - return byVoter.has(voterAddress); - } - const db = createDb(env); - const existing = await db - .select({ direction: poolVotes.direction }) - .from(poolVotes) - .where( - and( - eq(poolVotes.proposalId, input.proposalId), - eq(poolVotes.voterAddress, voterAddress), - ), - ) - .limit(1); - return existing.length > 0; -} - -export async function castPoolVote( - env: Env, - input: { proposalId: string; voterAddress: string; direction: Direction }, -): Promise<{ counts: Counts; created: boolean }> { - if (!env.DATABASE_URL) { - const byVoter = - memoryVotes.get(input.proposalId) ?? new Map(); - const key = input.voterAddress.trim(); - const created = !byVoter.has(key); - byVoter.set(key, input.direction); - memoryVotes.set(input.proposalId, byVoter); - return { counts: countMemory(input.proposalId), created }; - } - - const db = createDb(env); - const voterAddress = input.voterAddress.trim(); - const existing = await db - .select({ direction: poolVotes.direction }) - .from(poolVotes) - .where( - and( - eq(poolVotes.proposalId, input.proposalId), - eq(poolVotes.voterAddress, voterAddress), - ), - ) - .limit(1); - const created = existing.length === 0; - const now = new Date(); - await db - .insert(poolVotes) - .values({ - proposalId: input.proposalId, - voterAddress, - direction: input.direction, - createdAt: now, - updatedAt: now, - }) - .onConflictDoUpdate({ - target: [poolVotes.proposalId, poolVotes.voterAddress], - set: { direction: input.direction, updatedAt: now }, - }); - - return { counts: await getPoolVoteCounts(env, input.proposalId), created }; -} - -export async function getPoolVoteCounts( - env: Env, - proposalId: string, -): Promise { - if (!env.DATABASE_URL) return countMemory(proposalId); - const db = createDb(env); - const rows = await db - .select({ - upvotes: sql`sum(case when ${poolVotes.direction} = 1 then 1 else 0 end)`, - downvotes: sql`sum(case when ${poolVotes.direction} = -1 then 1 else 0 end)`, - }) - .from(poolVotes) - .where(eq(poolVotes.proposalId, proposalId)); - - const row = rows[0]; - return { - upvotes: Number(row?.upvotes ?? 0), - downvotes: Number(row?.downvotes ?? 0), - }; -} - -export async function clearPoolVotesForTests() { - memoryVotes.clear(); -} - -function countMemory(proposalId: string): Counts { - const byVoter = memoryVotes.get(proposalId); - if (!byVoter) return { upvotes: 0, downvotes: 0 }; - let upvotes = 0; - let downvotes = 0; - for (const direction of byVoter.values()) { - if (direction === 1) upvotes += 1; - if (direction === -1) downvotes += 1; - } - return { upvotes, downvotes }; -} diff --git a/api/_lib/proposalDraftsStore.ts b/api/_lib/proposalDraftsStore.ts deleted file mode 100644 index 0c1bacb..0000000 --- a/api/_lib/proposalDraftsStore.ts +++ /dev/null @@ -1,542 +0,0 @@ -import { and, desc, eq, isNull } from "drizzle-orm"; -import { z } from "zod"; - -import { proposalDrafts } from "../../db/schema.ts"; -import { randomHex } from "./random.ts"; -import { createDb } from "./db.ts"; - -type Env = Record; - -const timelineItemSchema = z.object({ - id: z.string(), - title: z.string(), - timeframe: z.string(), -}); - -const outputItemSchema = z.object({ - id: z.string(), - label: z.string(), - url: z.string(), -}); - -const budgetItemSchema = z.object({ - id: z.string(), - description: z.string(), - amount: z.string(), -}); - -const attachmentItemSchema = z.object({ - id: z.string(), - label: z.string(), - url: z.string(), -}); - -const metaGovernanceSchema = z.object({ - action: z.enum(["chamber.create", "chamber.dissolve"]), - chamberId: z.string(), - title: z.string().optional(), - multiplier: z.number().optional(), - genesisMembers: z.array(z.string()).optional(), -}); - -const optionalString = z.string().optional().default(""); -const optionalTimeline = z.array(timelineItemSchema).optional().default([]); -const optionalOutputs = z.array(outputItemSchema).optional().default([]); -const optionalBudgetItems = z.array(budgetItemSchema).optional().default([]); -const optionalAttachments = z - .array(attachmentItemSchema) - .optional() - .default([]); - -const projectDraftSchema = z.object({ - templateId: z.literal("project"), - title: z.string(), - chamberId: z.string(), - summary: z.string(), - what: z.string(), - why: z.string(), - how: z.string(), - metaGovernance: z.undefined().optional(), - timeline: z.array(timelineItemSchema), - outputs: z.array(outputItemSchema), - budgetItems: z.array(budgetItemSchema), - aboutMe: z.string(), - attachments: z.array(attachmentItemSchema), - agreeRules: z.boolean(), - confirmBudget: z.boolean(), -}); - -const systemDraftSchema = z.object({ - templateId: z.literal("system"), - title: z.string(), - chamberId: z.string(), - summary: optionalString, - what: optionalString, - why: optionalString, - how: optionalString, - metaGovernance: metaGovernanceSchema, - timeline: optionalTimeline, - outputs: optionalOutputs, - budgetItems: optionalBudgetItems, - aboutMe: optionalString, - attachments: optionalAttachments, - agreeRules: z.boolean(), - confirmBudget: z.boolean(), -}); - -export const proposalDraftFormSchema = z.preprocess( - (input) => { - if (!input || typeof input !== "object" || Array.isArray(input)) - return input; - const record = { ...(input as Record) }; - if (!("templateId" in record)) { - record.templateId = record.metaGovernance ? "system" : "project"; - } - return record; - }, - z.discriminatedUnion("templateId", [projectDraftSchema, systemDraftSchema]), -); - -export type ProposalDraftForm = z.infer; - -export type ProposalDraftRecord = { - id: string; - authorAddress: string; - title: string; - chamberId: string | null; - summary: string; - payload: ProposalDraftForm; - createdAt: Date; - updatedAt: Date; - submittedAt: Date | null; - submittedProposalId: string | null; -}; - -const memoryDraftsByAuthor = new Map< - string, - Map ->(); - -export function clearProposalDraftsForTests() { - memoryDraftsByAuthor.clear(); -} - -export function seedLegacyDraftForTests(input: { - authorAddress: string; - draftId: string; - title: string; - chamberId?: string | null; - summary?: string; - payload: unknown; - createdAt?: Date; - updatedAt?: Date; - submittedAt?: Date | null; - submittedProposalId?: string | null; -}) { - const address = input.authorAddress.trim(); - const now = new Date(); - const byId = - memoryDraftsByAuthor.get(address) ?? new Map(); - const record: ProposalDraftRecord = { - id: input.draftId, - authorAddress: address, - title: input.title, - chamberId: input.chamberId ?? null, - summary: input.summary ?? "", - payload: input.payload as ProposalDraftForm, - createdAt: input.createdAt ?? now, - updatedAt: input.updatedAt ?? now, - submittedAt: input.submittedAt ?? null, - submittedProposalId: input.submittedProposalId ?? null, - }; - byId.set(record.id, record); - memoryDraftsByAuthor.set(address, byId); -} - -function hasTemplateId(payload: unknown): boolean { - if (!payload || typeof payload !== "object" || Array.isArray(payload)) - return false; - return typeof (payload as { templateId?: unknown }).templateId === "string"; -} - -function normalizeDraftPayload(payload: unknown): ProposalDraftForm { - return proposalDraftFormSchema.parse(payload); -} - -function slugify(value: string): string { - return value - .trim() - .toLowerCase() - .replace(/[^a-z0-9]+/g, "-") - .replace(/^-+|-+$/g, "") - .slice(0, 48); -} - -function computeBudgetTotalHmnd(form: ProposalDraftForm): number { - return (form.budgetItems ?? []).reduce((sum, item) => { - const n = Number(item.amount); - if (!Number.isFinite(n) || n <= 0) return sum; - return sum + n; - }, 0); -} - -function resolveTemplateId(form: ProposalDraftForm): "project" | "system" { - return form.templateId; -} - -export function draftIsSubmittable(form: ProposalDraftForm): boolean { - const templateId = resolveTemplateId(form); - const isSystem = templateId === "system"; - const budgetTotal = computeBudgetTotalHmnd(form); - const title = (form.title ?? "").trim(); - const what = (form.what ?? "").trim(); - const why = (form.why ?? "").trim(); - const how = (form.how ?? "").trim(); - const essentialsValid = - title.length > 0 && (isSystem ? true : what.length > 0 && why.length > 0); - const planValid = isSystem ? true : how.length > 0; - const budgetItems = form.budgetItems ?? []; - const budgetValid = isSystem - ? true - : budgetItems.some( - (item) => - item.description.trim().length > 0 && - Number.isFinite(Number(item.amount)) && - Number(item.amount) > 0, - ) && budgetTotal > 0; - const meta = form.metaGovernance; - const systemValid = isSystem - ? Boolean( - meta && - (form.chamberId ?? "").trim().toLowerCase() === "general" && - meta.chamberId.trim().length > 0 && - (meta.action === "chamber.dissolve" - ? true - : (meta.title ?? "").trim().length > 0), - ) - : true; - const rulesValid = form.agreeRules && form.confirmBudget; - return ( - essentialsValid && planValid && budgetValid && systemValid && rulesValid - ); -} - -export function formatChamberLabel(chamberId: string | null): string { - const id = (chamberId ?? "").trim(); - if (!id) return "General chamber"; - const title = id - .split(/[-_\s]+/g) - .filter(Boolean) - .map((part) => part.slice(0, 1).toUpperCase() + part.slice(1)) - .join(" "); - return `${title} chamber`; -} - -export function formatDraftId(input: { title: string }): string { - const slug = slugify(input.title); - const suffix = randomHex(2); - return `draft-${slug || "untitled"}-${suffix}`; -} - -export async function upsertDraft( - env: Env, - input: { authorAddress: string; draftId?: string; form: ProposalDraftForm }, -): Promise { - const address = input.authorAddress.trim(); - const now = new Date(); - const form = proposalDraftFormSchema.parse(input.form); - - const id = - typeof input.draftId === "string" && input.draftId.trim().length > 0 - ? input.draftId.trim() - : formatDraftId({ title: form.title }); - - if (!env.DATABASE_URL) { - const byId = - memoryDraftsByAuthor.get(address) ?? - new Map(); - const existing = byId.get(id); - const record: ProposalDraftRecord = { - id, - authorAddress: address, - title: form.title, - chamberId: form.chamberId || null, - summary: form.summary, - payload: form, - createdAt: existing?.createdAt ?? now, - updatedAt: now, - submittedAt: existing?.submittedAt ?? null, - submittedProposalId: existing?.submittedProposalId ?? null, - }; - byId.set(id, record); - memoryDraftsByAuthor.set(address, byId); - return record; - } - - const db = createDb(env); - const existing = await db - .select({ - id: proposalDrafts.id, - createdAt: proposalDrafts.createdAt, - submittedAt: proposalDrafts.submittedAt, - submittedProposalId: proposalDrafts.submittedProposalId, - }) - .from(proposalDrafts) - .where( - and(eq(proposalDrafts.id, id), eq(proposalDrafts.authorAddress, address)), - ) - .limit(1); - - const createdAt = existing[0]?.createdAt ?? now; - const submittedAt = existing[0]?.submittedAt ?? null; - const submittedProposalId = existing[0]?.submittedProposalId ?? null; - - await db - .insert(proposalDrafts) - .values({ - id, - authorAddress: address, - title: form.title, - chamberId: form.chamberId || null, - summary: form.summary, - payload: form, - submittedAt, - submittedProposalId, - createdAt, - updatedAt: now, - }) - .onConflictDoUpdate({ - target: proposalDrafts.id, - set: { - title: form.title, - chamberId: form.chamberId || null, - summary: form.summary, - payload: form, - updatedAt: now, - }, - }); - - return { - id, - authorAddress: address, - title: form.title, - chamberId: form.chamberId || null, - summary: form.summary, - payload: form, - createdAt, - updatedAt: now, - submittedAt, - submittedProposalId, - }; -} - -export async function deleteDraft( - env: Env, - input: { authorAddress: string; draftId: string }, -): Promise { - const address = input.authorAddress.trim(); - const id = input.draftId.trim(); - if (!env.DATABASE_URL) { - const byId = memoryDraftsByAuthor.get(address); - if (!byId) return false; - return byId.delete(id); - } - - const db = createDb(env); - const res = await db - .delete(proposalDrafts) - .where( - and(eq(proposalDrafts.id, id), eq(proposalDrafts.authorAddress, address)), - ); - return res.rowCount > 0; -} - -export async function listDrafts( - env: Env, - input: { authorAddress: string; includeSubmitted?: boolean }, -): Promise { - const address = input.authorAddress.trim(); - const includeSubmitted = Boolean(input.includeSubmitted); - - if (!env.DATABASE_URL) { - const byId = memoryDraftsByAuthor.get(address); - const list = byId ? Array.from(byId.values()) : []; - const normalized = list - .filter((d) => includeSubmitted || !d.submittedAt) - .map((draft) => { - if (!hasTemplateId(draft.payload)) { - const payload = normalizeDraftPayload(draft.payload); - const next = { ...draft, payload }; - byId?.set(draft.id, next); - return next; - } - return draft; - }) - .sort((a, b) => b.updatedAt.getTime() - a.updatedAt.getTime()); - return normalized; - } - - const db = createDb(env); - const where = includeSubmitted - ? and(eq(proposalDrafts.authorAddress, address)) - : and( - eq(proposalDrafts.authorAddress, address), - isNull(proposalDrafts.submittedAt), - ); - - const rows = await db - .select({ - id: proposalDrafts.id, - authorAddress: proposalDrafts.authorAddress, - title: proposalDrafts.title, - chamberId: proposalDrafts.chamberId, - summary: proposalDrafts.summary, - payload: proposalDrafts.payload, - createdAt: proposalDrafts.createdAt, - updatedAt: proposalDrafts.updatedAt, - submittedAt: proposalDrafts.submittedAt, - submittedProposalId: proposalDrafts.submittedProposalId, - }) - .from(proposalDrafts) - .where(where) - .orderBy(desc(proposalDrafts.updatedAt)); - - const migrations: Promise[] = []; - const result = rows.map((row) => { - const needsMigration = !hasTemplateId(row.payload); - const payload = normalizeDraftPayload(row.payload); - if (needsMigration) { - migrations.push( - db - .update(proposalDrafts) - .set({ payload, updatedAt: row.updatedAt }) - .where( - and( - eq(proposalDrafts.id, row.id), - eq(proposalDrafts.authorAddress, row.authorAddress), - ), - ), - ); - } - return { - id: row.id, - authorAddress: row.authorAddress, - title: row.title, - chamberId: row.chamberId ?? null, - summary: row.summary, - payload, - createdAt: row.createdAt, - updatedAt: row.updatedAt, - submittedAt: row.submittedAt ?? null, - submittedProposalId: row.submittedProposalId ?? null, - }; - }); - if (migrations.length > 0) { - await Promise.all(migrations); - } - return result; -} - -export async function getDraft( - env: Env, - input: { authorAddress: string; draftId: string }, -): Promise { - const address = input.authorAddress.trim(); - const id = input.draftId.trim(); - if (!env.DATABASE_URL) { - const byId = memoryDraftsByAuthor.get(address); - const record = byId?.get(id) ?? null; - if (!record) return null; - if (!hasTemplateId(record.payload)) { - const payload = normalizeDraftPayload(record.payload); - const next = { ...record, payload }; - byId?.set(id, next); - return next; - } - return record; - } - - const db = createDb(env); - const rows = await db - .select({ - id: proposalDrafts.id, - authorAddress: proposalDrafts.authorAddress, - title: proposalDrafts.title, - chamberId: proposalDrafts.chamberId, - summary: proposalDrafts.summary, - payload: proposalDrafts.payload, - createdAt: proposalDrafts.createdAt, - updatedAt: proposalDrafts.updatedAt, - submittedAt: proposalDrafts.submittedAt, - submittedProposalId: proposalDrafts.submittedProposalId, - }) - .from(proposalDrafts) - .where( - and(eq(proposalDrafts.id, id), eq(proposalDrafts.authorAddress, address)), - ) - .limit(1); - const row = rows[0]; - if (!row) return null; - const needsMigration = !hasTemplateId(row.payload); - const payload = normalizeDraftPayload(row.payload); - if (needsMigration) { - await db - .update(proposalDrafts) - .set({ payload, updatedAt: row.updatedAt }) - .where( - and( - eq(proposalDrafts.id, row.id), - eq(proposalDrafts.authorAddress, row.authorAddress), - ), - ); - } - return { - id: row.id, - authorAddress: row.authorAddress, - title: row.title, - chamberId: row.chamberId ?? null, - summary: row.summary, - payload, - createdAt: row.createdAt, - updatedAt: row.updatedAt, - submittedAt: row.submittedAt ?? null, - submittedProposalId: row.submittedProposalId ?? null, - }; -} - -export async function markDraftSubmitted( - env: Env, - input: { authorAddress: string; draftId: string; proposalId: string }, -): Promise { - const address = input.authorAddress.trim(); - const draftId = input.draftId.trim(); - const now = new Date(); - - if (!env.DATABASE_URL) { - const byId = memoryDraftsByAuthor.get(address); - const existing = byId?.get(draftId); - if (!existing) throw new Error("draft_missing"); - byId?.set(draftId, { - ...existing, - submittedAt: existing.submittedAt ?? now, - submittedProposalId: existing.submittedProposalId ?? input.proposalId, - updatedAt: now, - }); - return; - } - - const db = createDb(env); - await db - .update(proposalDrafts) - .set({ - submittedAt: now, - submittedProposalId: input.proposalId, - updatedAt: now, - }) - .where( - and( - eq(proposalDrafts.id, draftId), - eq(proposalDrafts.authorAddress, address), - ), - ); -} diff --git a/api/_lib/proposalFinalizer.ts b/api/_lib/proposalFinalizer.ts deleted file mode 100644 index 19627a2..0000000 --- a/api/_lib/proposalFinalizer.ts +++ /dev/null @@ -1,195 +0,0 @@ -import { getChamberYesScoreAverage } from "./chamberVotesStore.ts"; -import { awardCmOnce } from "./cmAwardsStore.ts"; -import { - createChamberFromAcceptedGeneralProposal, - dissolveChamberFromAcceptedGeneralProposal, - getChamberMultiplierTimes10 as getCanonicalChamberMultiplierTimes10, - parseChamberGovernanceFromPayload, -} from "./chambersStore.ts"; -import { - buildV1FormationSeedFromProposalPayload, - ensureFormationSeedFromInput, -} from "./formationStore.ts"; -import { - grantVotingEligibilityForAcceptedProposal, - ensureChamberMembership, -} from "./chamberMembershipsStore.ts"; -import { appendProposalTimelineItem } from "./proposalTimelineStore.ts"; -import { - clearProposalVotePendingVeto, - getProposal, - transitionProposalStage, -} from "./proposalsStore.ts"; -import { randomHex } from "./random.ts"; - -type Env = Record; - -function getFormationEligibleFromProposalPayload(payload: unknown): boolean { - if (!payload || typeof payload !== "object" || Array.isArray(payload)) - return true; - const record = payload as Record; - if (record.templateId === "system") return false; - if ( - typeof record.metaGovernance === "object" && - record.metaGovernance !== null && - !Array.isArray(record.metaGovernance) - ) - return false; - if (typeof record.formationEligible === "boolean") - return record.formationEligible; - if (typeof record.formation === "boolean") return record.formation; - return true; -} - -export async function finalizeAcceptedProposalFromVote( - env: Env, - input: { proposalId: string; requestUrl: string }, -): Promise< - | { - ok: true; - formationEligible: boolean; - avgScore: number | null; - proposalChamberId: string; - } - | { ok: false; reason: string } -> { - const proposal = await getProposal(env, input.proposalId); - if (!proposal) return { ok: false, reason: "missing_proposal" }; - if (proposal.stage !== "vote") return { ok: false, reason: "stage_invalid" }; - - const transitioned = await transitionProposalStage(env, { - proposalId: input.proposalId, - from: "vote", - to: "build", - }); - if (!transitioned) return { ok: false, reason: "transition_failed" }; - - await clearProposalVotePendingVeto(env, { proposalId: proposal.id }).catch( - () => {}, - ); - - await grantVotingEligibilityForAcceptedProposal(env, { - address: proposal.authorAddress, - chamberId: proposal.chamberId ?? null, - proposalId: proposal.id, - }); - - const proposalChamberId = (() => { - const raw = (proposal.chamberId ?? "general").trim(); - return raw ? raw.toLowerCase() : "general"; - })(); - const meta = parseChamberGovernanceFromPayload(proposal.payload); - const effectiveChamberId = meta ? "general" : proposalChamberId; - - if (effectiveChamberId === "general" && meta) { - if (meta.action === "chamber.create" && meta.title) { - await createChamberFromAcceptedGeneralProposal(env, input.requestUrl, { - id: meta.id, - title: meta.title, - multiplier: meta.multiplier, - proposalId: proposal.id, - }); - - await appendProposalTimelineItem(env, { - proposalId: proposal.id, - stage: "build", - actorAddress: null, - item: { - id: `timeline:chamber-created:${proposal.id}:${randomHex(4)}`, - type: "chamber.created", - title: "Chamber created", - detail: `${meta.id} (${meta.title})`, - actor: "system", - timestamp: new Date().toISOString(), - }, - }); - - const genesisMembers = (() => { - if (!proposal.payload || typeof proposal.payload !== "object") - return []; - const record = proposal.payload as Record; - const mg = record.metaGovernance; - if (!mg || typeof mg !== "object" || Array.isArray(mg)) return []; - const metaRecord = mg as Record; - const raw = metaRecord.genesisMembers; - if (!Array.isArray(raw)) return []; - return raw - .filter((v): v is string => typeof v === "string") - .map((v) => v.trim()) - .filter(Boolean); - })(); - - const memberSet = new Set(genesisMembers); - memberSet.add(proposal.authorAddress.trim()); - for (const address of memberSet) { - await ensureChamberMembership(env, { - address, - chamberId: meta.id, - grantedByProposalId: proposal.id, - source: "chamber_genesis", - }); - } - } - if (meta.action === "chamber.dissolve") { - await dissolveChamberFromAcceptedGeneralProposal(env, input.requestUrl, { - id: meta.id, - proposalId: proposal.id, - }); - - await appendProposalTimelineItem(env, { - proposalId: proposal.id, - stage: "build", - actorAddress: null, - item: { - id: `timeline:chamber-dissolved:${proposal.id}:${randomHex(4)}`, - type: "chamber.dissolved", - title: "Chamber dissolved", - detail: meta.id, - actor: "system", - timestamp: new Date().toISOString(), - }, - }); - } - } - - const formationEligible = getFormationEligibleFromProposalPayload( - proposal.payload, - ); - if (formationEligible) { - const seed = buildV1FormationSeedFromProposalPayload(proposal.payload); - await ensureFormationSeedFromInput(env, { - proposalId: input.proposalId, - seed, - }); - } - - const avgScore = - (await getChamberYesScoreAverage(env, input.proposalId)) ?? null; - const multiplierTimes10 = - (await getCanonicalChamberMultiplierTimes10( - env, - input.requestUrl, - proposalChamberId, - )) ?? 10; - - if (avgScore !== null) { - const lcmPoints = Math.round(avgScore * 10); - const mcmPoints = Math.round((lcmPoints * multiplierTimes10) / 10); - await awardCmOnce(env, { - proposalId: input.proposalId, - proposerId: proposal.authorAddress, - chamberId: proposalChamberId, - avgScore, - lcmPoints, - chamberMultiplierTimes10: multiplierTimes10, - mcmPoints, - }); - } - - return { - ok: true, - formationEligible, - avgScore, - proposalChamberId, - }; -} diff --git a/api/_lib/proposalProjector.ts b/api/_lib/proposalProjector.ts deleted file mode 100644 index 5eadb3b..0000000 --- a/api/_lib/proposalProjector.ts +++ /dev/null @@ -1,561 +0,0 @@ -import { evaluateChamberQuorum } from "./chamberQuorum.ts"; -import { evaluatePoolQuorum } from "./poolQuorum.ts"; -import type { - ProposalListItemDto, - ChamberProposalPageDto, - FormationProposalPageDto, - PoolProposalPageDto, - ProposalStageDatumDto, -} from "../../src/types/api.ts"; -import type { ProposalDraftForm } from "./proposalDraftsStore.ts"; -import { formatChamberLabel } from "./proposalDraftsStore.ts"; -import type { ProposalRecord, ProposalStage } from "./proposalsStore.ts"; -import { - formatTimeLeftDaysHours, - getStageRemainingSeconds, -} from "./stageWindows.ts"; -import { - V1_ACTIVE_GOVERNORS_FALLBACK, - V1_CHAMBER_PASSING_FRACTION, - V1_CHAMBER_QUORUM_FRACTION, - V1_POOL_ATTENTION_QUORUM_FRACTION, - V1_POOL_UPVOTE_FLOOR_FRACTION, -} from "./v1Constants.ts"; -import type { HumanTier } from "./userTier.ts"; - -export function projectProposalListItem( - proposal: ProposalRecord, - input: { - activeGovernors: number; - tier?: HumanTier; - now?: Date; - voteWindowSeconds?: number; - poolCounts?: { upvotes: number; downvotes: number }; - chamberCounts?: { yes: number; no: number; abstain: number }; - formationSummary?: { - teamFilled: number; - teamTotal: number; - milestonesCompleted: number; - milestonesTotal: number; - }; - }, -): ProposalListItemDto { - const chamber = formatChamberLabel(proposal.chamberId); - const date = proposal.createdAt.toISOString().slice(0, 10); - const now = input.now ?? new Date(); - const formationEligible = getFormationEligibleFromPayload(proposal.payload); - const tier: HumanTier = input.tier ?? "Nominee"; - - const stageData = - proposal.stage === "pool" - ? projectPoolListStageData({ - activeGovernors: input.activeGovernors, - counts: input.poolCounts ?? { upvotes: 0, downvotes: 0 }, - }) - : proposal.stage === "vote" - ? projectVoteListStageData({ - activeGovernors: input.activeGovernors, - counts: input.chamberCounts ?? { yes: 0, no: 0, abstain: 0 }, - timeLeft: (() => { - if ( - !( - typeof input.voteWindowSeconds === "number" && - input.voteWindowSeconds > 0 - ) - ) - return "3d 00h"; - const remaining = getStageRemainingSeconds({ - now, - stageStartedAt: proposal.updatedAt, - windowSeconds: input.voteWindowSeconds, - }); - return remaining === 0 - ? "Ended" - : formatTimeLeftDaysHours(remaining); - })(), - }) - : formationEligible - ? projectBuildListStageData({ - summary: input.formationSummary ?? { - teamFilled: 0, - teamTotal: 0, - milestonesCompleted: 0, - milestonesTotal: 0, - }, - }) - : projectPassedListStageData(); - - const budget = formatBudget(getDraftForm(proposal.payload)); - const milestonesCount = getDraftForm(proposal.payload)?.timeline.length ?? 0; - - const ctaPrimary = - proposal.stage === "pool" - ? "Open proposal" - : proposal.stage === "vote" - ? "Open proposal" - : formationEligible - ? "Open project" - : "Open proposal"; - - const ctaSecondary = - proposal.stage === "build" && formationEligible ? "Ping team" : ""; - - const summaryPill = - proposal.stage === "pool" - ? `${milestonesCount} milestones` - : proposal.stage === "vote" - ? "Chamber vote" - : formationEligible - ? "Formation" - : "Passed"; - - return { - id: proposal.id, - title: proposal.title, - meta: `${chamber} · ${tier} tier`, - stage: proposal.stage, - summaryPill, - summary: proposal.summary, - stageData, - stats: [ - { label: "Budget ask", value: budget }, - { label: "Formation", value: formationEligible ? "Yes" : "No" }, - ], - proposer: proposal.authorAddress, - proposerId: proposal.authorAddress, - chamber, - tier, - proofFocus: "pot", - tags: [], - keywords: [], - date, - votes: - proposal.stage === "pool" - ? (input.poolCounts?.upvotes ?? 0) + (input.poolCounts?.downvotes ?? 0) - : proposal.stage === "vote" - ? (input.chamberCounts?.yes ?? 0) + - (input.chamberCounts?.no ?? 0) + - (input.chamberCounts?.abstain ?? 0) - : 0, - activityScore: 0, - ctaPrimary, - ctaSecondary, - }; -} - -export function projectPoolProposalPage( - proposal: ProposalRecord, - input: { - counts: { upvotes: number; downvotes: number }; - activeGovernors: number; - tier?: HumanTier; - }, -): PoolProposalPageDto { - const form = getDraftForm(proposal.payload); - const chamber = formatChamberLabel(proposal.chamberId); - const budget = formatBudget(form); - const formationEligible = getFormationEligibleFromPayload(proposal.payload); - const tier: HumanTier = input.tier ?? "Nominee"; - - const activeGovernors = Math.max( - 0, - Math.floor(input.activeGovernors ?? V1_ACTIVE_GOVERNORS_FALLBACK), - ); - const attentionQuorum = V1_POOL_ATTENTION_QUORUM_FRACTION; - const upvoteFloor = Math.max( - 1, - Math.ceil(activeGovernors * V1_POOL_UPVOTE_FLOOR_FRACTION), - ); - - const rules = [ - `${Math.round(attentionQuorum * 100)}% attention from active governors required.`, - `At least ${Math.round((upvoteFloor / Math.max(1, activeGovernors)) * 100)}% upvotes to move to chamber vote.`, - ]; - - return { - title: proposal.title, - proposer: proposal.authorAddress, - proposerId: proposal.authorAddress, - chamber, - focus: "—", - tier, - budget, - cooldown: "Withdraw cooldown: 12h", - formationEligible, - teamSlots: "1 / 3", - milestones: String(form?.timeline.length ?? 0), - upvotes: input.counts.upvotes, - downvotes: input.counts.downvotes, - attentionQuorum, - activeGovernors, - upvoteFloor, - rules, - attachments: - form?.attachments - .filter((a) => a.label.trim().length > 0) - .map((a) => ({ id: a.id, title: a.label })) ?? [], - teamLocked: [{ name: proposal.authorAddress, role: "Proposer" }], - openSlotNeeds: [], - milestonesDetail: - form?.timeline.map((m, idx) => ({ - title: m.title.trim().length ? m.title : `Milestone ${idx + 1}`, - desc: m.timeframe.trim().length ? m.timeframe : "Timeline TBD", - })) ?? [], - summary: form?.summary ?? proposal.summary, - overview: form?.what ?? "", - executionPlan: - form?.how - .split("\n") - .map((line) => line.trim()) - .filter(Boolean) ?? [], - budgetScope: - form?.budgetItems - .filter((b) => b.description.trim().length > 0) - .map((b) => `${b.description}: ${b.amount} HMND`) - .join("\n") ?? "", - invisionInsight: { - role: "Draft author", - bullets: [ - "Submitted via the simulation backend proposal wizard.", - "This is an off-chain governance simulation (not mainnet).", - ], - }, - }; -} - -export function projectChamberProposalPage( - proposal: ProposalRecord, - input: { - counts: { yes: number; no: number; abstain: number }; - activeGovernors: number; - now?: Date; - voteWindowSeconds?: number; - }, -): ChamberProposalPageDto { - const form = getDraftForm(proposal.payload); - const chamber = formatChamberLabel(proposal.chamberId); - const budget = formatBudget(form); - const formationEligible = getFormationEligibleFromPayload(proposal.payload); - - const activeGovernors = Math.max( - 0, - Math.floor(input.activeGovernors ?? V1_ACTIVE_GOVERNORS_FALLBACK), - ); - const engagedGovernors = - input.counts.yes + input.counts.no + input.counts.abstain; - const now = input.now ?? new Date(); - const timeLeft = (() => { - if ( - !( - typeof input.voteWindowSeconds === "number" && - input.voteWindowSeconds > 0 - ) - ) - return "3d 00h"; - const remaining = getStageRemainingSeconds({ - now, - stageStartedAt: proposal.updatedAt, - windowSeconds: input.voteWindowSeconds, - }); - return remaining === 0 ? "Ended" : formatTimeLeftDaysHours(remaining); - })(); - - return { - title: proposal.title, - proposer: proposal.authorAddress, - proposerId: proposal.authorAddress, - chamber, - budget, - formationEligible, - teamSlots: "1 / 3", - milestones: `${form?.timeline.length ?? 0}`, - timeLeft, - votes: input.counts, - attentionQuorum: V1_CHAMBER_QUORUM_FRACTION, - passingRule: `≥${(V1_CHAMBER_PASSING_FRACTION * 100).toFixed(1)}% + 1 yes within quorum`, - engagedGovernors, - activeGovernors, - attachments: - form?.attachments - .filter((a) => a.label.trim().length > 0) - .map((a) => ({ id: a.id, title: a.label })) ?? [], - teamLocked: [{ name: proposal.authorAddress, role: "Proposer" }], - openSlotNeeds: [], - milestonesDetail: - form?.timeline.map((m, idx) => ({ - title: m.title.trim().length ? m.title : `Milestone ${idx + 1}`, - desc: m.timeframe.trim().length ? m.timeframe : "Timeline TBD", - })) ?? [], - summary: form?.summary ?? proposal.summary, - overview: form?.what ?? "", - executionPlan: - form?.how - .split("\n") - .map((line) => line.trim()) - .filter(Boolean) ?? [], - budgetScope: - form?.budgetItems - .filter((b) => b.description.trim().length > 0) - .map((b) => `${b.description}: ${b.amount} HMND`) - .join("\n") ?? "", - invisionInsight: { - role: "Draft author", - bullets: [ - "Submitted via the simulation backend proposal wizard.", - "This is an off-chain governance simulation (not mainnet).", - ], - }, - }; -} - -export function projectFormationProposalPage( - proposal: ProposalRecord, - input: { - summary: { - teamFilled: number; - teamTotal: number; - milestonesCompleted: number; - milestonesTotal: number; - }; - joiners: { address: string; role?: string | null }[]; - }, -): FormationProposalPageDto { - const form = getDraftForm(proposal.payload); - const chamber = formatChamberLabel(proposal.chamberId); - const budget = formatBudget(form); - - const teamSlots = `${input.summary.teamFilled} / ${input.summary.teamTotal}`; - const milestones = `${input.summary.milestonesCompleted} / ${input.summary.milestonesTotal}`; - const progress = - input.summary.milestonesTotal > 0 - ? `${Math.round((input.summary.milestonesCompleted / input.summary.milestonesTotal) * 100)}%` - : "0%"; - - return { - title: proposal.title, - chamber, - proposer: proposal.authorAddress, - proposerId: proposal.authorAddress, - budget, - timeLeft: "12w", - teamSlots, - milestones, - progress, - stageData: [ - { title: "Budget allocated", description: "HMND", value: "0 / —" }, - { title: "Team slots", description: "Filled / Total", value: teamSlots }, - { - title: "Milestones", - description: "Completed / Total", - value: milestones, - }, - ], - stats: [{ label: "Lead chamber", value: chamber }], - lockedTeam: [ - { name: shortenAddress(proposal.authorAddress), role: "Proposer" }, - ...input.joiners.map((entry) => ({ - name: shortenAddress(entry.address), - role: entry.role ?? "Contributor", - })), - ], - openSlots: [], - milestonesDetail: - form?.timeline.map((m, idx) => ({ - title: m.title.trim().length ? m.title : `Milestone ${idx + 1}`, - desc: m.timeframe.trim().length ? m.timeframe : "Timeline TBD", - })) ?? [], - attachments: - form?.attachments - .filter((a) => a.label.trim().length > 0) - .map((a) => ({ id: a.id, title: a.label })) ?? [], - summary: form?.summary ?? proposal.summary, - overview: form?.what ?? "", - executionPlan: - form?.how - .split("\n") - .map((line) => line.trim()) - .filter(Boolean) ?? [], - budgetScope: - form?.budgetItems - .filter((b) => b.description.trim().length > 0) - .map((b) => `${b.description}: ${b.amount} HMND`) - .join("\n") ?? "", - invisionInsight: { - role: "Draft author", - bullets: [ - "Submitted via the simulation backend proposal wizard.", - "This is an off-chain governance simulation (not mainnet).", - ], - }, - }; -} - -function projectPoolListStageData(input: { - activeGovernors: number; - counts: { upvotes: number; downvotes: number }; -}): ProposalStageDatumDto[] { - const attentionQuorum = V1_POOL_ATTENTION_QUORUM_FRACTION; - const upvoteFloor = Math.max( - 1, - Math.ceil( - Math.max(0, input.activeGovernors) * V1_POOL_UPVOTE_FLOOR_FRACTION, - ), - ); - const quorum = evaluatePoolQuorum( - { attentionQuorum, activeGovernors: input.activeGovernors, upvoteFloor }, - input.counts, - ); - const engagedPct = - input.activeGovernors > 0 - ? (quorum.engaged / input.activeGovernors) * 100 - : 0; - return [ - { - title: "Pool momentum", - description: "Upvotes / Downvotes", - value: `${input.counts.upvotes} / ${input.counts.downvotes}`, - }, - { - title: "Attention quorum", - description: `${Math.round(attentionQuorum * 100)}% active or ≥10% upvotes`, - value: `${quorum.shouldAdvance ? "Met" : "Needs"} · ${Math.round(engagedPct)}% engaged`, - tone: quorum.shouldAdvance ? "ok" : "warn", - }, - { - title: "Upvote floor", - description: `${upvoteFloor} needed`, - value: `${input.counts.upvotes} / ${upvoteFloor}`, - tone: input.counts.upvotes >= upvoteFloor ? "ok" : "warn", - }, - ]; -} - -function projectVoteListStageData(input: { - activeGovernors: number; - counts: { yes: number; no: number; abstain: number }; - timeLeft: string; -}): ProposalStageDatumDto[] { - const quorumFraction = V1_CHAMBER_QUORUM_FRACTION; - const passingFraction = V1_CHAMBER_PASSING_FRACTION; - const result = evaluateChamberQuorum( - { quorumFraction, activeGovernors: input.activeGovernors, passingFraction }, - input.counts, - ); - const quorumPct = - input.activeGovernors > 0 - ? (result.engaged / input.activeGovernors) * 100 - : 0; - return [ - { - title: "Voting quorum", - description: `Strict ${Math.round(quorumFraction * 100)}% active governors`, - value: `${result.quorumMet ? "Met" : "Needs"} · ${Math.round(quorumPct)}%`, - tone: result.quorumMet ? "ok" : "warn", - }, - { - title: "Passing", - description: "≥66.6% yes", - value: `${Math.round(result.yesFraction * 1000) / 10}% yes`, - tone: result.passMet ? "ok" : "warn", - }, - { title: "Time left", description: "Voting window", value: input.timeLeft }, - ]; -} - -function projectBuildListStageData(input: { - summary: { - teamFilled: number; - teamTotal: number; - milestonesCompleted: number; - milestonesTotal: number; - }; -}): ProposalStageDatumDto[] { - const teamValue = `${input.summary.teamFilled} / ${input.summary.teamTotal}`; - const milestonesValue = `${input.summary.milestonesCompleted} / ${input.summary.milestonesTotal}`; - const pct = - input.summary.milestonesTotal > 0 - ? (input.summary.milestonesCompleted / input.summary.milestonesTotal) * - 100 - : 0; - return [ - { title: "Team slots", description: "Filled / Total", value: teamValue }, - { - title: "Milestones", - description: "Completed / Total", - value: milestonesValue, - }, - { - title: "Progress", - description: "Milestones", - value: `${Math.round(pct)}%`, - tone: pct >= 50 ? "ok" : "warn", - }, - ]; -} - -function projectPassedListStageData(): ProposalStageDatumDto[] { - return [ - { - title: "Accepted", - description: "Passed chamber vote", - value: "Yes", - tone: "ok", - }, - { - title: "Formation", - description: "Execution stage", - value: "Not required", - }, - ]; -} - -function getDraftForm(payload: unknown): ProposalDraftForm | null { - if (!payload || typeof payload !== "object" || Array.isArray(payload)) - return null; - const record = payload as Partial; - if (typeof record.title !== "string") return null; - if (!Array.isArray(record.timeline) || !Array.isArray(record.budgetItems)) - return null; - return record as ProposalDraftForm; -} - -function getFormationEligibleFromPayload(payload: unknown): boolean { - if (!payload || typeof payload !== "object" || Array.isArray(payload)) - return true; - const record = payload as Record; - if (record.templateId === "system") return false; - if ( - typeof record.metaGovernance === "object" && - record.metaGovernance !== null && - !Array.isArray(record.metaGovernance) - ) - return false; - if (typeof record.formationEligible === "boolean") - return record.formationEligible; - if (typeof record.formation === "boolean") return record.formation; - return true; -} - -function formatBudget(form: ProposalDraftForm | null): string { - if (!form) return "—"; - const total = form.budgetItems.reduce((sum, item) => { - const n = Number(item.amount); - if (!Number.isFinite(n) || n <= 0) return sum; - return sum + n; - }, 0); - return total > 0 ? `${total.toLocaleString()} HMND` : "—"; -} - -export function parseProposalStageQuery( - value: string | null, -): ProposalStage | null { - if (!value) return null; - if (value === "pool" || value === "vote" || value === "build") return value; - return null; -} - -function shortenAddress(address: string): string { - const normalized = address.trim(); - if (normalized.length <= 12) return normalized; - return `${normalized.slice(0, 6)}…${normalized.slice(-4)}`; -} diff --git a/api/_lib/proposalStageDenominatorsStore.ts b/api/_lib/proposalStageDenominatorsStore.ts deleted file mode 100644 index 0487f71..0000000 --- a/api/_lib/proposalStageDenominatorsStore.ts +++ /dev/null @@ -1,158 +0,0 @@ -import { and, eq, inArray } from "drizzle-orm"; - -import { proposalStageDenominators } from "../../db/schema.ts"; -import { createDb } from "./db.ts"; -import { createClockStore } from "./clockStore.ts"; - -type Env = Record; - -export type ProposalDenominatorStage = "pool" | "vote"; - -export type ProposalStageDenominator = { - proposalId: string; - stage: ProposalDenominatorStage; - era: number; - activeGovernors: number; - capturedAt: string; -}; - -const memory = new Map(); // key: `${proposalId}:${stage}` - -export async function captureProposalStageDenominator( - env: Env, - input: { - proposalId: string; - stage: ProposalDenominatorStage; - activeGovernors: number; - }, -): Promise { - const proposalId = input.proposalId.trim(); - const stage = input.stage; - const activeGovernors = Math.max(0, Math.floor(input.activeGovernors)); - - const clock = createClockStore(env); - const { currentEra } = await clock.get(); - - const capturedAt = new Date().toISOString(); - const row: ProposalStageDenominator = { - proposalId, - stage, - era: currentEra, - activeGovernors, - capturedAt, - }; - - if (!env.DATABASE_URL) { - const key = `${proposalId}:${stage}`; - if (memory.has(key)) return; - memory.set(key, row); - return; - } - - const db = createDb(env); - await db - .insert(proposalStageDenominators) - .values({ - proposalId, - stage, - era: currentEra, - activeGovernors, - capturedAt: new Date(capturedAt), - }) - .onConflictDoNothing({ - target: [ - proposalStageDenominators.proposalId, - proposalStageDenominators.stage, - ], - }); -} - -export async function getProposalStageDenominator( - env: Env, - input: { proposalId: string; stage: ProposalDenominatorStage }, -): Promise { - const proposalId = input.proposalId.trim(); - const stage = input.stage; - - if (!env.DATABASE_URL) { - return memory.get(`${proposalId}:${stage}`) ?? null; - } - - const db = createDb(env); - const rows = await db - .select({ - proposalId: proposalStageDenominators.proposalId, - stage: proposalStageDenominators.stage, - era: proposalStageDenominators.era, - activeGovernors: proposalStageDenominators.activeGovernors, - capturedAt: proposalStageDenominators.capturedAt, - }) - .from(proposalStageDenominators) - .where( - and( - eq(proposalStageDenominators.proposalId, proposalId), - eq(proposalStageDenominators.stage, stage), - ), - ) - .limit(1); - const row = rows[0]; - if (!row) return null; - return { - proposalId: row.proposalId, - stage: row.stage as ProposalDenominatorStage, - era: row.era, - activeGovernors: row.activeGovernors, - capturedAt: row.capturedAt.toISOString(), - }; -} - -export async function getProposalStageDenominatorMap( - env: Env, - input: { stage: ProposalDenominatorStage; proposalIds: string[] }, -): Promise> { - const stage = input.stage; - const proposalIds = input.proposalIds.map((id) => id.trim()).filter(Boolean); - const map = new Map(); - - if (proposalIds.length === 0) return map; - - if (!env.DATABASE_URL) { - for (const proposalId of proposalIds) { - const row = memory.get(`${proposalId}:${stage}`); - if (row) map.set(proposalId, row); - } - return map; - } - - const db = createDb(env); - const rows = await db - .select({ - proposalId: proposalStageDenominators.proposalId, - stage: proposalStageDenominators.stage, - era: proposalStageDenominators.era, - activeGovernors: proposalStageDenominators.activeGovernors, - capturedAt: proposalStageDenominators.capturedAt, - }) - .from(proposalStageDenominators) - .where( - and( - eq(proposalStageDenominators.stage, stage), - inArray(proposalStageDenominators.proposalId, proposalIds), - ), - ); - - for (const row of rows) { - map.set(row.proposalId, { - proposalId: row.proposalId, - stage: row.stage as ProposalDenominatorStage, - era: row.era, - activeGovernors: row.activeGovernors, - capturedAt: row.capturedAt.toISOString(), - }); - } - return map; -} - -export function clearProposalStageDenominatorsForTests(): void { - memory.clear(); -} diff --git a/api/_lib/proposalStateMachine.ts b/api/_lib/proposalStateMachine.ts deleted file mode 100644 index 460a79a..0000000 --- a/api/_lib/proposalStateMachine.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { evaluateChamberQuorum } from "./chamberQuorum.ts"; -import { evaluatePoolQuorum } from "./poolQuorum.ts"; -import type { ProposalStage } from "./proposalsStore.ts"; -import { - V1_CHAMBER_PASSING_FRACTION, - V1_CHAMBER_QUORUM_FRACTION, - V1_POOL_ATTENTION_QUORUM_FRACTION, - V1_POOL_UPVOTE_FLOOR_FRACTION, -} from "./v1Constants.ts"; - -export type ProposalStageTransition = { - from: ProposalStage; - to: ProposalStage; -}; - -export const PROPOSAL_TRANSITIONS: ProposalStageTransition[] = [ - { from: "pool", to: "vote" }, - { from: "vote", to: "build" }, -]; - -export function canTransitionStage( - from: ProposalStage, - to: ProposalStage, -): boolean { - return PROPOSAL_TRANSITIONS.some((t) => t.from === from && t.to === to); -} - -export function computePoolUpvoteFloor(activeGovernors: number): number { - const active = Math.max(0, Math.floor(activeGovernors)); - return Math.max(1, Math.ceil(active * V1_POOL_UPVOTE_FLOOR_FRACTION)); -} - -export function shouldAdvancePoolToVote(input: { - activeGovernors: number; - counts: { upvotes: number; downvotes: number }; -}): boolean { - const upvoteFloor = computePoolUpvoteFloor(input.activeGovernors); - const quorum = evaluatePoolQuorum( - { - attentionQuorum: V1_POOL_ATTENTION_QUORUM_FRACTION, - activeGovernors: input.activeGovernors, - upvoteFloor, - }, - input.counts, - ); - return quorum.shouldAdvance; -} - -export function shouldAdvanceVoteToBuild(input: { - activeGovernors: number; - counts: { yes: number; no: number; abstain: number }; - minQuorum?: number; -}): boolean { - const result = evaluateChamberQuorum( - { - quorumFraction: V1_CHAMBER_QUORUM_FRACTION, - activeGovernors: input.activeGovernors, - passingFraction: V1_CHAMBER_PASSING_FRACTION, - minQuorum: input.minQuorum, - }, - input.counts, - ); - return result.shouldAdvance; -} diff --git a/api/_lib/proposalTimelineStore.ts b/api/_lib/proposalTimelineStore.ts deleted file mode 100644 index ae2f432..0000000 --- a/api/_lib/proposalTimelineStore.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { and, asc, eq } from "drizzle-orm"; - -import { events } from "../../db/schema.ts"; -import type { ProposalTimelineItemDto } from "../../src/types/api.ts"; -import { createDb } from "./db.ts"; - -type Env = Record; - -const EVENT_TYPE = "proposal.timeline.v1"; -const ENTITY_TYPE = "proposal"; - -const memory = new Map(); - -export async function appendProposalTimelineItem( - env: Env, - input: { - proposalId: string; - stage?: string | null; - actorAddress?: string | null; - item: ProposalTimelineItemDto; - }, -): Promise { - if (!env.DATABASE_URL) { - const items = memory.get(input.proposalId) ?? []; - memory.set(input.proposalId, [...items, input.item]); - return; - } - - const db = createDb(env); - await db.insert(events).values({ - type: EVENT_TYPE, - stage: input.stage ?? null, - actorAddress: input.actorAddress ?? null, - entityType: ENTITY_TYPE, - entityId: input.proposalId, - payload: input.item, - createdAt: new Date(input.item.timestamp), - }); -} - -export async function listProposalTimelineItems( - env: Env, - input: { proposalId: string; limit: number }, -): Promise { - if (!env.DATABASE_URL) { - const items = memory.get(input.proposalId) ?? []; - return [...items] - .sort((a, b) => a.timestamp.localeCompare(b.timestamp)) - .slice(-input.limit); - } - - const db = createDb(env); - const rows = await db - .select({ seq: events.seq, payload: events.payload }) - .from(events) - .where( - and( - eq(events.type, EVENT_TYPE), - eq(events.entityType, ENTITY_TYPE), - eq(events.entityId, input.proposalId), - ), - ) - .orderBy(asc(events.seq)) - .limit(Math.max(1, input.limit)); - - return rows.map((row) => row.payload as ProposalTimelineItemDto); -} - -export function clearProposalTimelineForTests(): void { - memory.clear(); -} diff --git a/api/_lib/proposalsStore.ts b/api/_lib/proposalsStore.ts deleted file mode 100644 index c071145..0000000 --- a/api/_lib/proposalsStore.ts +++ /dev/null @@ -1,363 +0,0 @@ -import { and, eq, sql } from "drizzle-orm"; - -import { proposals } from "../../db/schema.ts"; -import { createDb } from "./db.ts"; -type Env = Record; - -export type ProposalStage = "pool" | "vote" | "build"; - -export type ProposalRecord = { - id: string; - stage: ProposalStage; - authorAddress: string; - title: string; - chamberId: string | null; - summary: string; - payload: unknown; - vetoCount: number; - votePassedAt: Date | null; - voteFinalizesAt: Date | null; - vetoCouncil: string[] | null; - vetoThreshold: number | null; - createdAt: Date; - updatedAt: Date; -}; - -const memory = new Map(); - -function normalizeVetoCouncil(value: unknown): string[] | null { - if (value === null || value === undefined) return null; - if (!Array.isArray(value)) return null; - const members = value - .filter((v): v is string => typeof v === "string") - .map((v) => v.trim()) - .filter(Boolean); - return members.length > 0 ? members : null; -} - -export async function createProposal( - env: Env, - input: { - id: string; - stage: ProposalStage; - authorAddress: string; - title: string; - chamberId: string | null; - summary: string; - payload: unknown; - vetoCount?: number; - votePassedAt?: Date | null; - voteFinalizesAt?: Date | null; - vetoCouncil?: string[] | null; - vetoThreshold?: number | null; - }, -): Promise { - const now = new Date(); - const record: ProposalRecord = { - id: input.id, - stage: input.stage, - authorAddress: input.authorAddress, - title: input.title, - chamberId: input.chamberId ?? null, - summary: input.summary, - payload: input.payload, - vetoCount: input.vetoCount ?? 0, - votePassedAt: input.votePassedAt ?? null, - voteFinalizesAt: input.voteFinalizesAt ?? null, - vetoCouncil: input.vetoCouncil ?? null, - vetoThreshold: input.vetoThreshold ?? null, - createdAt: now, - updatedAt: now, - }; - - if (env.DATABASE_URL) { - const db = createDb(env); - await db.insert(proposals).values({ - id: record.id, - stage: record.stage, - authorAddress: record.authorAddress, - title: record.title, - chamberId: record.chamberId, - summary: record.summary, - payload: record.payload, - vetoCount: record.vetoCount, - votePassedAt: record.votePassedAt, - voteFinalizesAt: record.voteFinalizesAt, - vetoCouncil: record.vetoCouncil, - vetoThreshold: record.vetoThreshold, - createdAt: record.createdAt, - updatedAt: record.updatedAt, - }); - return record; - } - - memory.set(record.id, record); - return record; -} - -export async function updateProposalStage( - env: Env, - input: { proposalId: string; stage: ProposalStage }, -): Promise { - const now = new Date(); - if (env.DATABASE_URL) { - const db = createDb(env); - await db - .update(proposals) - .set({ stage: input.stage, updatedAt: now }) - .where(eq(proposals.id, input.proposalId)); - return; - } - - const existing = memory.get(input.proposalId); - if (!existing) return; - memory.set(input.proposalId, { - ...existing, - stage: input.stage, - updatedAt: now, - }); -} - -export async function setProposalVotePendingVeto( - env: Env, - input: { - proposalId: string; - passedAt: Date; - finalizesAt: Date; - vetoCouncil: string[]; - vetoThreshold: number; - }, -): Promise { - if (env.DATABASE_URL) { - const db = createDb(env); - await db - .update(proposals) - .set({ - votePassedAt: input.passedAt, - voteFinalizesAt: input.finalizesAt, - vetoCouncil: input.vetoCouncil, - vetoThreshold: input.vetoThreshold, - }) - .where(eq(proposals.id, input.proposalId)); - return; - } - - const existing = memory.get(input.proposalId); - if (!existing) return; - memory.set(input.proposalId, { - ...existing, - votePassedAt: input.passedAt, - voteFinalizesAt: input.finalizesAt, - vetoCouncil: input.vetoCouncil, - vetoThreshold: input.vetoThreshold, - }); -} - -export async function clearProposalVotePendingVeto( - env: Env, - input: { proposalId: string }, -): Promise { - if (env.DATABASE_URL) { - const db = createDb(env); - await db - .update(proposals) - .set({ - votePassedAt: null, - voteFinalizesAt: null, - vetoCouncil: null, - vetoThreshold: null, - }) - .where(eq(proposals.id, input.proposalId)); - return; - } - - const existing = memory.get(input.proposalId); - if (!existing) return; - memory.set(input.proposalId, { - ...existing, - votePassedAt: null, - voteFinalizesAt: null, - vetoCouncil: null, - vetoThreshold: null, - }); -} - -export async function applyProposalVeto( - env: Env, - input: { proposalId: string; nextVoteStartsAt: Date }, -): Promise { - if (env.DATABASE_URL) { - const db = createDb(env); - await db - .update(proposals) - .set({ - vetoCount: sql`${proposals.vetoCount} + 1`, - votePassedAt: null, - voteFinalizesAt: null, - vetoCouncil: null, - vetoThreshold: null, - updatedAt: input.nextVoteStartsAt, - }) - .where(eq(proposals.id, input.proposalId)); - return; - } - - const existing = memory.get(input.proposalId); - if (!existing) return; - memory.set(input.proposalId, { - ...existing, - vetoCount: existing.vetoCount + 1, - votePassedAt: null, - voteFinalizesAt: null, - vetoCouncil: null, - vetoThreshold: null, - updatedAt: input.nextVoteStartsAt, - }); -} - -export async function transitionProposalStage( - env: Env, - input: { proposalId: string; from: ProposalStage; to: ProposalStage }, -): Promise { - if ( - !( - (input.from === "pool" && input.to === "vote") || - (input.from === "vote" && input.to === "build") - ) - ) { - throw new Error("invalid_transition"); - } - - const now = new Date(); - if (env.DATABASE_URL) { - const db = createDb(env); - const res = await db - .update(proposals) - .set({ stage: input.to, updatedAt: now }) - .where( - and( - eq(proposals.id, input.proposalId), - eq(proposals.stage, input.from), - ), - ); - return res.rowCount > 0; - } - - const existing = memory.get(input.proposalId); - if (!existing) return false; - if (existing.stage !== input.from) return false; - memory.set(input.proposalId, { - ...existing, - stage: input.to, - updatedAt: now, - }); - return true; -} - -export async function getProposal( - env: Env, - proposalId: string, -): Promise { - if (env.DATABASE_URL) { - const db = createDb(env); - const rows = await db - .select({ - id: proposals.id, - stage: proposals.stage, - authorAddress: proposals.authorAddress, - title: proposals.title, - chamberId: proposals.chamberId, - summary: proposals.summary, - payload: proposals.payload, - vetoCount: proposals.vetoCount, - votePassedAt: proposals.votePassedAt, - voteFinalizesAt: proposals.voteFinalizesAt, - vetoCouncil: proposals.vetoCouncil, - vetoThreshold: proposals.vetoThreshold, - createdAt: proposals.createdAt, - updatedAt: proposals.updatedAt, - }) - .from(proposals) - .where(eq(proposals.id, proposalId)) - .limit(1); - const row = rows[0]; - if (!row) return null; - return { - id: row.id, - stage: row.stage as ProposalStage, - authorAddress: row.authorAddress, - title: row.title, - chamberId: row.chamberId ?? null, - summary: row.summary, - payload: row.payload, - vetoCount: row.vetoCount ?? 0, - votePassedAt: row.votePassedAt ?? null, - voteFinalizesAt: row.voteFinalizesAt ?? null, - vetoCouncil: normalizeVetoCouncil(row.vetoCouncil), - vetoThreshold: - typeof row.vetoThreshold === "number" ? row.vetoThreshold : null, - createdAt: row.createdAt, - updatedAt: row.updatedAt, - }; - } - - return memory.get(proposalId) ?? null; -} - -export async function listProposals( - env: Env, - input?: { stage?: ProposalStage | null }, -): Promise { - const stage = input?.stage ?? null; - if (env.DATABASE_URL) { - const db = createDb(env); - const base = db - .select({ - id: proposals.id, - stage: proposals.stage, - authorAddress: proposals.authorAddress, - title: proposals.title, - chamberId: proposals.chamberId, - summary: proposals.summary, - payload: proposals.payload, - vetoCount: proposals.vetoCount, - votePassedAt: proposals.votePassedAt, - voteFinalizesAt: proposals.voteFinalizesAt, - vetoCouncil: proposals.vetoCouncil, - vetoThreshold: proposals.vetoThreshold, - createdAt: proposals.createdAt, - updatedAt: proposals.updatedAt, - }) - .from(proposals); - const rows = stage - ? await base.where(eq(proposals.stage, stage)) - : await base; - return rows - .map((row) => ({ - id: row.id, - stage: row.stage as ProposalStage, - authorAddress: row.authorAddress, - title: row.title, - chamberId: row.chamberId ?? null, - summary: row.summary, - payload: row.payload, - vetoCount: row.vetoCount ?? 0, - votePassedAt: row.votePassedAt ?? null, - voteFinalizesAt: row.voteFinalizesAt ?? null, - vetoCouncil: normalizeVetoCouncil(row.vetoCouncil), - vetoThreshold: - typeof row.vetoThreshold === "number" ? row.vetoThreshold : null, - createdAt: row.createdAt, - updatedAt: row.updatedAt, - })) - .sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()); - } - - const items = Array.from(memory.values()); - const filtered = stage ? items.filter((p) => p.stage === stage) : items; - return filtered.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()); -} - -export function clearProposalsForTests(): void { - memory.clear(); -} diff --git a/api/_lib/random.ts b/api/_lib/random.ts deleted file mode 100644 index bff964e..0000000 --- a/api/_lib/random.ts +++ /dev/null @@ -1,5 +0,0 @@ -export function randomHex(bytes: number): string { - const out = new Uint8Array(bytes); - crypto.getRandomValues(out); - return [...out].map((b) => b.toString(16).padStart(2, "0")).join(""); -} diff --git a/api/_lib/readModelsStore.ts b/api/_lib/readModelsStore.ts deleted file mode 100644 index 4dbfc4c..0000000 --- a/api/_lib/readModelsStore.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { eq } from "drizzle-orm"; - -import { readModels } from "../../db/schema.ts"; -import { createDb } from "./db.ts"; - -type Env = Record; - -export type ReadModelsStore = { - get: (key: string) => Promise; - set?: (key: string, payload: unknown) => Promise; -}; - -export async function createReadModelsStore( - env: Env, -): Promise { - if (env.READ_MODELS_INLINE_EMPTY === "true") { - const map = await getEmptyReadModelsMap(); - return { - get: async (key) => map.get(key) ?? null, - set: async (key, payload) => { - map.set(key, payload); - }, - }; - } - if (env.READ_MODELS_INLINE === "true") { - const map = await getInlineReadModelsMap(); - return { - get: async (key) => map.get(key) ?? null, - set: async (key, payload) => { - map.set(key, payload); - }, - }; - } - - // Default to an in-memory empty store when DATABASE_URL is not configured. - // This keeps deployments functional (ephemeral persistence) while still - // allowing full persistence when DATABASE_URL is present. - if (!env.DATABASE_URL) { - const map = await getEmptyReadModelsMap(); - return { - get: async (key) => map.get(key) ?? null, - set: async (key, payload) => { - map.set(key, payload); - }, - }; - } - - const db = createDb(env); - return { - get: async (key) => { - const rows = await db - .select() - .from(readModels) - .where(eq(readModels.key, key)) - .limit(1); - return rows[0]?.payload ?? null; - }, - set: async (key, payload) => { - const now = new Date(); - await db - .insert(readModels) - .values({ key, payload, updatedAt: now }) - .onConflictDoUpdate({ - target: readModels.key, - set: { payload, updatedAt: now }, - }); - }, - }; -} - -let inlineReadModelsMap: Map | null = null; -let emptyReadModelsMap: Map | null = null; - -export function clearInlineReadModelsForTests() { - inlineReadModelsMap = null; - emptyReadModelsMap = null; -} - -async function getInlineReadModelsMap(): Promise> { - if (inlineReadModelsMap) return inlineReadModelsMap; - const { buildReadModelSeed } = await import("../../db/seed/readModels.ts"); - inlineReadModelsMap = new Map( - buildReadModelSeed().map((entry) => [entry.key, entry.payload]), - ); - return inlineReadModelsMap; -} - -async function getEmptyReadModelsMap(): Promise> { - if (emptyReadModelsMap) return emptyReadModelsMap; - emptyReadModelsMap = new Map(); - return emptyReadModelsMap; -} diff --git a/api/_lib/requestIp.ts b/api/_lib/requestIp.ts deleted file mode 100644 index 38e2f19..0000000 --- a/api/_lib/requestIp.ts +++ /dev/null @@ -1,7 +0,0 @@ -export function getRequestIp(request: Request): string | undefined { - const cf = request.headers.get("cf-connecting-ip"); - if (cf) return cf; - const xff = request.headers.get("x-forwarded-for"); - if (!xff) return undefined; - return xff.split(",")[0]?.trim() || undefined; -} diff --git a/api/_lib/signatures.ts b/api/_lib/signatures.ts deleted file mode 100644 index 3901492..0000000 --- a/api/_lib/signatures.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { cryptoWaitReady, signatureVerify } from "@polkadot/util-crypto"; - -export async function verifySubstrateSignature(input: { - address: string; - message: string; - signature: string; -}): Promise { - await cryptoWaitReady(); - try { - const result = signatureVerify( - input.message, - input.signature, - input.address, - ); - return result.isValid; - } catch { - return false; - } -} diff --git a/api/_lib/simConfig.ts b/api/_lib/simConfig.ts deleted file mode 100644 index 7b1389b..0000000 --- a/api/_lib/simConfig.ts +++ /dev/null @@ -1,137 +0,0 @@ -type SimConfig = { - humanodeRpcUrl?: string; - genesisChamberMembers?: Record; - genesisChambers?: { id: string; title: string; multiplier: number }[]; - genesisUserTiers?: Record< - string, - "Nominee" | "Ecclesiast" | "Legate" | "Consul" | "Citizen" - >; -}; - -let cached: - | { - value: SimConfig | null; - expiresAtMs: number; - } - | undefined; - -function asUserTiers( - value: unknown, -): SimConfig["genesisUserTiers"] | undefined { - if (!value || typeof value !== "object") return undefined; - const record = value as Record; - const out: NonNullable = {}; - for (const [rawKey, rawValue] of Object.entries(record)) { - const address = rawKey.trim(); - if (!address) continue; - if (typeof rawValue !== "string") continue; - const tier = rawValue.trim(); - if ( - tier !== "Nominee" && - tier !== "Ecclesiast" && - tier !== "Legate" && - tier !== "Consul" && - tier !== "Citizen" - ) - continue; - out[address] = tier; - } - return Object.keys(out).length > 0 ? out : undefined; -} - -function asGenesisMembers( - value: unknown, -): Record | undefined { - if (!value || typeof value !== "object") return undefined; - const record = value as Record; - - const out: Record = {}; - for (const [key, raw] of Object.entries(record)) { - if (!Array.isArray(raw)) continue; - const list = raw - .filter((v): v is string => typeof v === "string") - .map((v) => v.trim()) - .filter(Boolean); - if (list.length > 0) out[key.trim().toLowerCase()] = list; - } - return Object.keys(out).length > 0 ? out : undefined; -} - -function parseSimConfig(json: unknown): SimConfig | null { - if (!json || typeof json !== "object") return null; - const value = json as Record; - const genesisChambersRaw = value.genesisChambers; - const genesisChambers = Array.isArray(genesisChambersRaw) - ? genesisChambersRaw - .filter((row): row is Record => { - return Boolean(row && typeof row === "object" && !Array.isArray(row)); - }) - .map((row) => ({ - id: typeof row.id === "string" ? row.id.trim().toLowerCase() : "", - title: - typeof row.title === "string" - ? row.title.trim() - : typeof row.name === "string" - ? row.name.trim() - : "", - multiplier: - typeof row.multiplier === "number" - ? row.multiplier - : typeof row.multiplierTimes10 === "number" - ? row.multiplierTimes10 / 10 - : 1, - })) - .filter((row) => row.id && row.title) - : undefined; - return { - humanodeRpcUrl: - typeof value.humanodeRpcUrl === "string" - ? value.humanodeRpcUrl - : undefined, - genesisChamberMembers: asGenesisMembers(value.genesisChamberMembers), - genesisUserTiers: asUserTiers(value.genesisUserTiers), - genesisChambers: - genesisChambers && genesisChambers.length > 0 - ? genesisChambers - : undefined, - }; -} - -export async function getSimConfig( - env: Record, - requestUrl: string, -): Promise { - const rawOverride = (env.SIM_CONFIG_JSON ?? "").trim(); - if (rawOverride) { - try { - const json = JSON.parse(rawOverride) as unknown; - return parseSimConfig(json); - } catch { - return null; - } - } - return getSimConfigFromOrigin(requestUrl); -} - -export async function getSimConfigFromOrigin( - requestUrl: string, -): Promise { - const now = Date.now(); - if (cached && cached.expiresAtMs > now) return cached.value; - - const origin = new URL(requestUrl).origin; - try { - const res = await fetch(`${origin}/sim-config.json`, { method: "GET" }); - if (!res.ok) { - cached = { value: null, expiresAtMs: now + 60_000 }; - return null; - } - const json = (await res.json()) as unknown; - const value = parseSimConfig(json); - cached = { value, expiresAtMs: now + 60_000 }; - return value; - } catch { - cached = { value: null, expiresAtMs: now + 10_000 }; - return null; - } -} diff --git a/api/_lib/stageWindows.ts b/api/_lib/stageWindows.ts deleted file mode 100644 index 31cd40d..0000000 --- a/api/_lib/stageWindows.ts +++ /dev/null @@ -1,77 +0,0 @@ -import type { ProposalStage } from "./proposalsStore.ts"; -import { - V1_POOL_STAGE_SECONDS_DEFAULT, - V1_VOTE_STAGE_SECONDS_DEFAULT, -} from "./v1Constants.ts"; - -type Env = Record; - -export function getSimNow(env: Env): Date { - const raw = env.SIM_NOW_ISO ?? ""; - if (raw.trim().length > 0) { - const parsed = new Date(raw); - if (!Number.isNaN(parsed.getTime())) return parsed; - } - return new Date(); -} - -export function stageWindowsEnabled(env: Env): boolean { - return env.SIM_ENABLE_STAGE_WINDOWS === "true"; -} - -export function getStageWindowSeconds(env: Env, stage: ProposalStage): number { - const raw = - stage === "pool" - ? env.SIM_POOL_WINDOW_SECONDS - : stage === "vote" - ? env.SIM_VOTE_WINDOW_SECONDS - : null; - const parsed = raw ? Number(raw) : NaN; - if (Number.isFinite(parsed) && parsed > 0) return Math.floor(parsed); - - return stage === "pool" - ? V1_POOL_STAGE_SECONDS_DEFAULT - : stage === "vote" - ? V1_VOTE_STAGE_SECONDS_DEFAULT - : 0; -} - -export function getStageDeadlineIso(input: { - stageStartedAt: Date; - windowSeconds: number; -}): string { - const ms = - input.stageStartedAt.getTime() + Math.max(0, input.windowSeconds) * 1000; - return new Date(ms).toISOString(); -} - -export function getStageRemainingSeconds(input: { - now: Date; - stageStartedAt: Date; - windowSeconds: number; -}): number { - const msRemaining = - input.stageStartedAt.getTime() + - Math.max(0, input.windowSeconds) * 1000 - - input.now.getTime(); - return Math.max(0, Math.floor(msRemaining / 1000)); -} - -export function formatTimeLeftDaysHours(remainingSeconds: number): string { - const seconds = Math.max(0, Math.floor(remainingSeconds)); - const days = Math.floor(seconds / (24 * 60 * 60)); - const hours = Math.floor((seconds % (24 * 60 * 60)) / (60 * 60)); - return `${days}d ${String(hours).padStart(2, "0")}h`; -} - -export function isStageOpen(input: { - now: Date; - stageStartedAt: Date; - windowSeconds: number; -}): boolean { - return ( - input.now.getTime() >= input.stageStartedAt.getTime() && - input.now.getTime() < - input.stageStartedAt.getTime() + Math.max(0, input.windowSeconds) * 1000 - ); -} diff --git a/api/_lib/tokens.ts b/api/_lib/tokens.ts deleted file mode 100644 index 107426b..0000000 --- a/api/_lib/tokens.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { base64UrlDecode, base64UrlEncode } from "./base64url.ts"; - -const encoder = new TextEncoder(); - -export type SignedTokenPayload = Record; - -export async function signToken( - payload: SignedTokenPayload, - secret: string, -): Promise { - const header = { alg: "HS256", typ: "JWT" }; - const headerB64 = base64UrlEncode(encoder.encode(JSON.stringify(header))); - const payloadB64 = base64UrlEncode(encoder.encode(JSON.stringify(payload))); - const data = `${headerB64}.${payloadB64}`; - const sig = await hmacSha256(data, secret); - return `${data}.${base64UrlEncode(sig)}`; -} - -export async function verifyToken( - token: string, - secret: string, -): Promise { - const parts = token.split("."); - if (parts.length !== 3) return null; - const [headerB64, payloadB64, sigB64] = parts; - const data = `${headerB64}.${payloadB64}`; - const expectedSig = await hmacSha256(data, secret); - const actualSig = base64UrlDecode(sigB64); - if (!timingSafeEqual(expectedSig, actualSig)) return null; - - try { - const payloadRaw = new TextDecoder().decode(base64UrlDecode(payloadB64)); - return JSON.parse(payloadRaw) as SignedTokenPayload; - } catch { - return null; - } -} - -async function hmacSha256(data: string, secret: string): Promise { - const key = await crypto.subtle.importKey( - "raw", - encoder.encode(secret), - { name: "HMAC", hash: "SHA-256" }, - false, - ["sign"], - ); - const sig = await crypto.subtle.sign("HMAC", key, encoder.encode(data)); - return new Uint8Array(sig); -} - -function timingSafeEqual(a: Uint8Array, b: Uint8Array): boolean { - if (a.length !== b.length) return false; - let diff = 0; - for (let i = 0; i < a.length; i++) diff |= a[i] ^ b[i]; - return diff === 0; -} diff --git a/api/_lib/userStore.ts b/api/_lib/userStore.ts deleted file mode 100644 index 3976284..0000000 --- a/api/_lib/userStore.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { users } from "../../db/schema.ts"; -import { createDb } from "./db.ts"; - -type Env = Record; - -export async function upsertUser(env: Env, input: { address: string }) { - if (!env.DATABASE_URL) return; - const db = createDb(env); - await db - .insert(users) - .values({ address: input.address }) - .onConflictDoNothing(); -} diff --git a/api/_lib/userTier.ts b/api/_lib/userTier.ts deleted file mode 100644 index bff0d21..0000000 --- a/api/_lib/userTier.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { getSimConfig } from "./simConfig.ts"; -import { addressesReferToSameKey } from "./address.ts"; - -export type HumanTier = - | "Nominee" - | "Ecclesiast" - | "Legate" - | "Consul" - | "Citizen"; - -export async function resolveUserTierFromSimConfig( - simConfig: Awaited> | null, - address: string, -): Promise { - const tiers = simConfig?.genesisUserTiers; - if (tiers) { - const key = address.trim(); - const exact = tiers[key]; - if (exact) return exact; - for (const [candidate, tier] of Object.entries(tiers)) { - if (await addressesReferToSameKey(candidate, key)) return tier; - } - } - return "Nominee"; -} - -export async function getUserTier( - env: Record, - requestUrl: string, - address: string, -): Promise { - const simConfig = await getSimConfig(env, requestUrl).catch(() => null); - return await resolveUserTierFromSimConfig(simConfig, address); -} diff --git a/api/_lib/v1Constants.ts b/api/_lib/v1Constants.ts deleted file mode 100644 index f6829c3..0000000 --- a/api/_lib/v1Constants.ts +++ /dev/null @@ -1,22 +0,0 @@ -export const V1_ACTIVE_GOVERNORS_FALLBACK = 150; - -// The simulation's default era length (used by Phase 16 automation). -// This does not come from chain state; it's an off-chain simulation constant. -export const V1_ERA_SECONDS_DEFAULT = 7 * 24 * 60 * 60; // 7 days - -export const V1_POOL_STAGE_SECONDS_DEFAULT = 7 * 24 * 60 * 60; // 7 days -// Paper: "Any proposal that is pulled out of the proposal pool gets a week to be voted upon". -export const V1_VOTE_STAGE_SECONDS_DEFAULT = 7 * 24 * 60 * 60; // 7 days - -// Paper: 22% of active governors engaged (upvote + downvote) in the proposal pool. -export const V1_POOL_ATTENTION_QUORUM_FRACTION = 0.22; -export const V1_POOL_UPVOTE_FLOOR_FRACTION = 0.1; - -export const V1_CHAMBER_QUORUM_FRACTION = 0.33; -export const V1_CHAMBER_PASSING_FRACTION = 2 / 3; // 66.6% - -// Veto (temporary slow-down) (Phase 30). -// If the veto threshold is met, the proposal returns to chamber voting after a delay. -export const V1_VETO_PASSING_FRACTION = 2 / 3; // 66.6% + 1 (rounded per council size) -export const V1_VETO_DELAY_SECONDS_DEFAULT = 14 * 24 * 60 * 60; // 2 weeks -export const V1_VETO_MAX_APPLIES = 2; diff --git a/api/_lib/vetoCouncilStore.ts b/api/_lib/vetoCouncilStore.ts deleted file mode 100644 index 4ba1f23..0000000 --- a/api/_lib/vetoCouncilStore.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { desc, eq, sql } from "drizzle-orm"; - -import { cmAwards } from "../../db/schema.ts"; -import { listChambers } from "./chambersStore.ts"; -import { createDb } from "./db.ts"; -import { listCmAwards } from "./cmAwardsStore.ts"; -import { V1_VETO_PASSING_FRACTION } from "./v1Constants.ts"; - -type Env = Record; - -export type VetoCouncilSnapshot = { - members: string[]; - threshold: number; -}; - -function computeThreshold(memberCount: number): number { - const n = Math.max(0, Math.floor(memberCount)); - if (n === 0) return 0; - return Math.floor(n * V1_VETO_PASSING_FRACTION) + 1; -} - -async function getTopLcmHolderForChamber( - env: Env, - chamberId: string, -): Promise { - const id = chamberId.trim().toLowerCase(); - if (!id) return null; - - if (!env.DATABASE_URL) { - const awards = await listCmAwards(env, { chamberId: id }); - const totals = new Map(); - for (const award of awards) { - const proposer = award.proposerId.trim(); - if (!proposer) continue; - totals.set(proposer, (totals.get(proposer) ?? 0) + award.lcmPoints); - } - let best: string | null = null; - let bestPoints = -1; - for (const [proposer, points] of totals.entries()) { - if (points > bestPoints) { - best = proposer; - bestPoints = points; - } - } - return best; - } - - const db = createDb(env); - const rows = await db - .select({ - proposerId: cmAwards.proposerId, - lcm: sql`sum(${cmAwards.lcmPoints})`, - }) - .from(cmAwards) - .where(eq(cmAwards.chamberId, id)) - .groupBy(cmAwards.proposerId) - .orderBy(desc(sql`sum(${cmAwards.lcmPoints})`)) - .limit(1); - const top = rows[0]?.proposerId?.trim(); - return top ? top : null; -} - -export async function computeVetoCouncilSnapshot( - env: Env, - requestUrl: string, -): Promise { - const chambers = await listChambers(env, requestUrl, { - includeDissolved: false, - }); - - const members = new Set(); - for (const chamber of chambers) { - const top = await getTopLcmHolderForChamber(env, chamber.id); - if (top) members.add(top); - } - - const list = Array.from(members); - return { members: list, threshold: computeThreshold(list.length) }; -} diff --git a/api/_lib/vetoVotesStore.ts b/api/_lib/vetoVotesStore.ts deleted file mode 100644 index a193015..0000000 --- a/api/_lib/vetoVotesStore.ts +++ /dev/null @@ -1,137 +0,0 @@ -import { and, eq, sql } from "drizzle-orm"; - -import { vetoVotes } from "../../db/schema.ts"; -import { createDb } from "./db.ts"; - -type Env = Record; - -export type VetoVoteChoice = "veto" | "keep"; - -export type VetoVoteCounts = { - veto: number; - keep: number; -}; - -type StoredVetoVote = { choice: VetoVoteChoice }; -const memoryVotes = new Map>(); - -export async function hasVetoVote( - env: Env, - input: { proposalId: string; voterAddress: string }, -): Promise { - const voter = input.voterAddress.trim(); - if (!env.DATABASE_URL) { - const byVoter = memoryVotes.get(input.proposalId); - if (!byVoter) return false; - return byVoter.has(voter); - } - - const db = createDb(env); - const existing = await db - .select({ choice: vetoVotes.choice }) - .from(vetoVotes) - .where( - and( - eq(vetoVotes.proposalId, input.proposalId), - eq(vetoVotes.voterAddress, voter), - ), - ) - .limit(1); - return existing.length > 0; -} - -export async function castVetoVote( - env: Env, - input: { - proposalId: string; - voterAddress: string; - choice: VetoVoteChoice; - }, -): Promise<{ counts: VetoVoteCounts; created: boolean }> { - const voter = input.voterAddress.trim(); - const now = new Date(); - - if (!env.DATABASE_URL) { - const byVoter = - memoryVotes.get(input.proposalId) ?? new Map(); - const created = !byVoter.has(voter); - byVoter.set(voter, { choice: input.choice }); - memoryVotes.set(input.proposalId, byVoter); - return { counts: countMemory(input.proposalId), created }; - } - - const db = createDb(env); - const existing = await db - .select({ choice: vetoVotes.choice }) - .from(vetoVotes) - .where( - and( - eq(vetoVotes.proposalId, input.proposalId), - eq(vetoVotes.voterAddress, voter), - ), - ) - .limit(1); - const created = existing.length === 0; - await db - .insert(vetoVotes) - .values({ - proposalId: input.proposalId, - voterAddress: voter, - choice: input.choice, - createdAt: now, - updatedAt: now, - }) - .onConflictDoUpdate({ - target: [vetoVotes.proposalId, vetoVotes.voterAddress], - set: { choice: input.choice, updatedAt: now }, - }); - - return { counts: await getVetoVoteCounts(env, input.proposalId), created }; -} - -export async function getVetoVoteCounts( - env: Env, - proposalId: string, -): Promise { - if (!env.DATABASE_URL) return countMemory(proposalId); - - const db = createDb(env); - const rows = await db - .select({ - veto: sql`sum(case when ${vetoVotes.choice} = 'veto' then 1 else 0 end)`, - keep: sql`sum(case when ${vetoVotes.choice} = 'keep' then 1 else 0 end)`, - }) - .from(vetoVotes) - .where(eq(vetoVotes.proposalId, proposalId)); - const row = rows[0]; - return { veto: Number(row?.veto ?? 0), keep: Number(row?.keep ?? 0) }; -} - -export async function clearVetoVotesForProposal( - env: Env, - proposalId: string, -): Promise { - if (!env.DATABASE_URL) { - memoryVotes.delete(proposalId); - return; - } - - const db = createDb(env); - await db.delete(vetoVotes).where(eq(vetoVotes.proposalId, proposalId)); -} - -export function clearVetoVotesForTests(): void { - memoryVotes.clear(); -} - -function countMemory(proposalId: string): VetoVoteCounts { - const byVoter = memoryVotes.get(proposalId); - if (!byVoter) return { veto: 0, keep: 0 }; - let veto = 0; - let keep = 0; - for (const v of byVoter.values()) { - if (v.choice === "veto") veto += 1; - if (v.choice === "keep") keep += 1; - } - return { veto, keep }; -} diff --git a/api/routes/admin/audit/index.ts b/api/routes/admin/audit/index.ts deleted file mode 100644 index 661316f..0000000 --- a/api/routes/admin/audit/index.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { assertAdmin } from "../../../_lib/clockStore.ts"; -import { listAdminAudit } from "../../../_lib/adminAuditStore.ts"; -import { errorResponse, jsonResponse } from "../../../_lib/http.ts"; - -const DEFAULT_LIMIT = 50; - -export const onRequestGet: ApiHandler = async (context) => { - try { - assertAdmin(context); - } catch (error) { - const status = (error as Error & { status?: number }).status ?? 500; - return errorResponse(status, (error as Error).message); - } - - const url = new URL(context.request.url); - const cursor = url.searchParams.get("cursor"); - let beforeSeq: number | undefined; - if (cursor !== null) { - const parsed = Number.parseInt(cursor, 10); - if (!Number.isFinite(parsed) || parsed < 0) { - return errorResponse(400, "Invalid cursor"); - } - beforeSeq = parsed; - } - - const page = await listAdminAudit(context.env, { - beforeSeq, - limit: DEFAULT_LIMIT, - }); - const response = - page.nextSeq !== undefined - ? { items: page.items, nextCursor: String(page.nextSeq) } - : { items: page.items }; - return jsonResponse(response); -}; diff --git a/api/routes/admin/stats.ts b/api/routes/admin/stats.ts deleted file mode 100644 index 955febf..0000000 --- a/api/routes/admin/stats.ts +++ /dev/null @@ -1,199 +0,0 @@ -import { eq, sql } from "drizzle-orm"; - -import { - adminState, - apiRateLimits, - chamberVotes, - cmAwards, - courtCases, - courtReports, - courtVerdicts, - eraRollups, - eraUserActivity, - events, - formationMilestoneEvents, - formationTeam, - poolVotes, - userActionLocks, - users, -} from "../../../db/schema.ts"; -import { createDb } from "../../_lib/db.ts"; -import { getCommandRateLimitConfig } from "../../_lib/apiRateLimitStore.ts"; -import { assertAdmin, createClockStore } from "../../_lib/clockStore.ts"; -import { getEraQuotaConfig } from "../../_lib/eraQuotas.ts"; -import { listEraUserActivity } from "../../_lib/eraStore.ts"; -import { errorResponse, jsonResponse } from "../../_lib/http.ts"; - -type Env = Record; - -function sum( - rows: Array<{ - poolVotes: number; - chamberVotes: number; - courtActions: number; - formationActions: number; - }>, -) { - return rows.reduce( - (acc, r) => ({ - poolVotes: acc.poolVotes + r.poolVotes, - chamberVotes: acc.chamberVotes + r.chamberVotes, - courtActions: acc.courtActions + r.courtActions, - formationActions: acc.formationActions + r.formationActions, - }), - { poolVotes: 0, chamberVotes: 0, courtActions: 0, formationActions: 0 }, - ); -} - -async function getWritesFrozen(env: Env): Promise { - if (env.SIM_WRITE_FREEZE === "true") return true; - if (env.READ_MODELS_INLINE === "true") return false; - if (!env.DATABASE_URL) return false; - const db = createDb(env); - await db.insert(adminState).values({ id: 1 }).onConflictDoNothing(); - const rows = await db - .select({ writesFrozen: adminState.writesFrozen }) - .from(adminState) - .where(eq(adminState.id, 1)) - .limit(1); - return rows[0]?.writesFrozen ?? false; -} - -export const onRequestGet: ApiHandler = async (context) => { - try { - assertAdmin(context); - } catch (error) { - const status = (error as Error & { status?: number }).status ?? 500; - return errorResponse(status, (error as Error).message); - } - - const clock = createClockStore(context.env); - const { currentEra } = await clock.get(); - const rate = getCommandRateLimitConfig(context.env); - const quotas = getEraQuotaConfig(context.env); - const writesFrozen = await getWritesFrozen(context.env); - - if (!context.env.DATABASE_URL) { - const rows = await listEraUserActivity(context.env, { era: currentEra }); - const totals = sum(rows); - return jsonResponse({ - currentEra, - writesFrozen, - config: { - rateLimitsPerMinute: rate, - eraQuotas: quotas, - }, - currentEraActivity: { - rows: rows.length, - totals, - }, - db: null, - }); - } - - const db = createDb(context.env); - const now = new Date(); - - const [ - usersCount, - eventsCount, - adminAuditCount, - feedEventCount, - poolVotesCount, - chamberVotesCount, - cmAwardsCount, - formationTeamCount, - formationMilestoneEventsCount, - courtCasesCount, - courtReportsCount, - courtVerdictsCount, - rateLimitBucketsCount, - activeLocksCount, - currentEraActivityRowsCount, - currentEraActivityTotals, - rollupsCount, - ] = await Promise.all([ - db.select({ n: sql`count(*)` }).from(users), - db.select({ n: sql`count(*)` }).from(events), - db - .select({ n: sql`count(*)` }) - .from(events) - .where(sql`${events.type} = 'admin.action.v1'`), - db - .select({ n: sql`count(*)` }) - .from(events) - .where(sql`${events.type} = 'feed.item.v1'`), - db.select({ n: sql`count(*)` }).from(poolVotes), - db.select({ n: sql`count(*)` }).from(chamberVotes), - db.select({ n: sql`count(*)` }).from(cmAwards), - db.select({ n: sql`count(*)` }).from(formationTeam), - db.select({ n: sql`count(*)` }).from(formationMilestoneEvents), - db.select({ n: sql`count(*)` }).from(courtCases), - db.select({ n: sql`count(*)` }).from(courtReports), - db.select({ n: sql`count(*)` }).from(courtVerdicts), - db.select({ n: sql`count(*)` }).from(apiRateLimits), - db - .select({ n: sql`count(*)` }) - .from(userActionLocks) - .where(sql`${userActionLocks.lockedUntil} > ${now}`), - db - .select({ n: sql`count(*)` }) - .from(eraUserActivity) - .where(sql`${eraUserActivity.era} = ${currentEra}`), - db - .select({ - poolVotes: sql`sum(${eraUserActivity.poolVotes})`, - chamberVotes: sql`sum(${eraUserActivity.chamberVotes})`, - courtActions: sql`sum(${eraUserActivity.courtActions})`, - formationActions: sql`sum(${eraUserActivity.formationActions})`, - }) - .from(eraUserActivity) - .where(sql`${eraUserActivity.era} = ${currentEra}`), - db.select({ n: sql`count(*)` }).from(eraRollups), - ]); - - return jsonResponse({ - currentEra, - writesFrozen, - config: { - rateLimitsPerMinute: rate, - eraQuotas: quotas, - }, - db: { - users: Number(usersCount[0]?.n ?? 0), - events: { - total: Number(eventsCount[0]?.n ?? 0), - feedItems: Number(feedEventCount[0]?.n ?? 0), - adminAudit: Number(adminAuditCount[0]?.n ?? 0), - }, - actions: { - poolVotes: Number(poolVotesCount[0]?.n ?? 0), - chamberVotes: Number(chamberVotesCount[0]?.n ?? 0), - cmAwards: Number(cmAwardsCount[0]?.n ?? 0), - formationTeam: Number(formationTeamCount[0]?.n ?? 0), - formationMilestoneEvents: Number( - formationMilestoneEventsCount[0]?.n ?? 0, - ), - courtCases: Number(courtCasesCount[0]?.n ?? 0), - courtReports: Number(courtReportsCount[0]?.n ?? 0), - courtVerdicts: Number(courtVerdictsCount[0]?.n ?? 0), - }, - hardening: { - rateLimitBuckets: Number(rateLimitBucketsCount[0]?.n ?? 0), - activeLocks: Number(activeLocksCount[0]?.n ?? 0), - }, - eras: { - rollups: Number(rollupsCount[0]?.n ?? 0), - currentEraActivityRows: Number(currentEraActivityRowsCount[0]?.n ?? 0), - currentEraTotals: { - poolVotes: Number(currentEraActivityTotals[0]?.poolVotes ?? 0), - chamberVotes: Number(currentEraActivityTotals[0]?.chamberVotes ?? 0), - courtActions: Number(currentEraActivityTotals[0]?.courtActions ?? 0), - formationActions: Number( - currentEraActivityTotals[0]?.formationActions ?? 0, - ), - }, - }, - }, - }); -}; diff --git a/api/routes/admin/users/[address].ts b/api/routes/admin/users/[address].ts deleted file mode 100644 index 387c567..0000000 --- a/api/routes/admin/users/[address].ts +++ /dev/null @@ -1,54 +0,0 @@ -import { assertAdmin } from "../../../_lib/clockStore.ts"; -import { createActionLocksStore } from "../../../_lib/actionLocksStore.ts"; -import { getEraQuotaConfig } from "../../../_lib/eraQuotas.ts"; -import { getUserEraActivity } from "../../../_lib/eraStore.ts"; -import { errorResponse, jsonResponse } from "../../../_lib/http.ts"; - -export const onRequestGet: ApiHandler<{ address: string }> = async ( - context, -) => { - try { - assertAdmin(context); - } catch (error) { - const status = (error as Error & { status?: number }).status ?? 500; - return errorResponse(status, (error as Error).message); - } - - const address = (context.params.address ?? "").trim(); - if (!address) return errorResponse(400, "Missing address"); - - const activity = await getUserEraActivity(context.env, { address }); - const quotas = getEraQuotaConfig(context.env); - const lock = await createActionLocksStore(context.env).getActiveLock(address); - - const remaining = { - poolVotes: - quotas.maxPoolVotes === null - ? null - : Math.max(0, quotas.maxPoolVotes - activity.counts.poolVotes), - chamberVotes: - quotas.maxChamberVotes === null - ? null - : Math.max(0, quotas.maxChamberVotes - activity.counts.chamberVotes), - courtActions: - quotas.maxCourtActions === null - ? null - : Math.max(0, quotas.maxCourtActions - activity.counts.courtActions), - formationActions: - quotas.maxFormationActions === null - ? null - : Math.max( - 0, - quotas.maxFormationActions - activity.counts.formationActions, - ), - }; - - return jsonResponse({ - address, - era: activity.era, - counts: activity.counts, - quotas, - remaining, - lock, - }); -}; diff --git a/api/routes/admin/users/lock.ts b/api/routes/admin/users/lock.ts deleted file mode 100644 index 21b4f8d..0000000 --- a/api/routes/admin/users/lock.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { z } from "zod"; - -import { assertAdmin } from "../../../_lib/clockStore.ts"; -import { createActionLocksStore } from "../../../_lib/actionLocksStore.ts"; -import { appendAdminAudit } from "../../../_lib/adminAuditStore.ts"; -import { errorResponse, jsonResponse, readJson } from "../../../_lib/http.ts"; - -const schema = z.object({ - address: z.string().min(1), - lockedUntil: z.string().min(1), - reason: z.string().min(1).optional(), -}); - -export const onRequestPost: ApiHandler = async (context) => { - try { - assertAdmin(context); - } catch (error) { - const status = (error as Error & { status?: number }).status ?? 500; - return errorResponse(status, (error as Error).message); - } - - let body: unknown; - try { - body = await readJson(context.request); - } catch (error) { - return errorResponse(400, (error as Error).message); - } - - const parsed = schema.safeParse(body); - if (!parsed.success) { - return errorResponse(400, "Invalid body", { issues: parsed.error.issues }); - } - - const lockedUntil = new Date(parsed.data.lockedUntil); - if (Number.isNaN(lockedUntil.getTime())) { - return errorResponse(400, "Invalid lockedUntil"); - } - - await createActionLocksStore(context.env).setLock({ - address: parsed.data.address, - lockedUntil, - reason: parsed.data.reason ?? null, - }); - - await appendAdminAudit(context.env, { - action: "user.lock", - targetAddress: parsed.data.address, - lockedUntil: lockedUntil.toISOString(), - reason: parsed.data.reason ?? null, - }); - - return jsonResponse({ ok: true }); -}; diff --git a/api/routes/admin/users/locks.ts b/api/routes/admin/users/locks.ts deleted file mode 100644 index 73437e7..0000000 --- a/api/routes/admin/users/locks.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { assertAdmin } from "../../../_lib/clockStore.ts"; -import { createActionLocksStore } from "../../../_lib/actionLocksStore.ts"; -import { errorResponse, jsonResponse } from "../../../_lib/http.ts"; - -export const onRequestGet: ApiHandler = async (context) => { - try { - assertAdmin(context); - } catch (error) { - const status = (error as Error & { status?: number }).status ?? 500; - return errorResponse(status, (error as Error).message); - } - - const locks = await createActionLocksStore(context.env).listActiveLocks(); - return jsonResponse({ items: locks }); -}; diff --git a/api/routes/admin/users/unlock.ts b/api/routes/admin/users/unlock.ts deleted file mode 100644 index df64197..0000000 --- a/api/routes/admin/users/unlock.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { z } from "zod"; - -import { assertAdmin } from "../../../_lib/clockStore.ts"; -import { createActionLocksStore } from "../../../_lib/actionLocksStore.ts"; -import { appendAdminAudit } from "../../../_lib/adminAuditStore.ts"; -import { errorResponse, jsonResponse, readJson } from "../../../_lib/http.ts"; - -const schema = z.object({ - address: z.string().min(1), -}); - -export const onRequestPost: ApiHandler = async (context) => { - try { - assertAdmin(context); - } catch (error) { - const status = (error as Error & { status?: number }).status ?? 500; - return errorResponse(status, (error as Error).message); - } - - let body: unknown; - try { - body = await readJson(context.request); - } catch (error) { - return errorResponse(400, (error as Error).message); - } - - const parsed = schema.safeParse(body); - if (!parsed.success) { - return errorResponse(400, "Invalid body", { issues: parsed.error.issues }); - } - - await createActionLocksStore(context.env).clearLock(parsed.data.address); - await appendAdminAudit(context.env, { - action: "user.unlock", - targetAddress: parsed.data.address, - }); - return jsonResponse({ ok: true }); -}; diff --git a/api/routes/admin/writes/freeze.ts b/api/routes/admin/writes/freeze.ts deleted file mode 100644 index 4328da5..0000000 --- a/api/routes/admin/writes/freeze.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { z } from "zod"; - -import { assertAdmin } from "../../../_lib/clockStore.ts"; -import { appendAdminAudit } from "../../../_lib/adminAuditStore.ts"; -import { createAdminStateStore } from "../../../_lib/adminStateStore.ts"; -import { errorResponse, jsonResponse, readJson } from "../../../_lib/http.ts"; - -const schema = z.object({ - enabled: z.boolean(), -}); - -export const onRequestPost: ApiHandler = async (context) => { - try { - assertAdmin(context); - } catch (error) { - const status = (error as Error & { status?: number }).status ?? 500; - return errorResponse(status, (error as Error).message); - } - - let body: unknown; - try { - body = await readJson(context.request); - } catch (error) { - return errorResponse(400, (error as Error).message); - } - - const parsed = schema.safeParse(body); - if (!parsed.success) { - return errorResponse(400, "Invalid body", { issues: parsed.error.issues }); - } - - const enabled = parsed.data.enabled; - await createAdminStateStore(context.env).setWritesFrozen(enabled); - - await appendAdminAudit(context.env, { - action: enabled ? "writes.freeze" : "writes.unfreeze", - targetAddress: "global", - }); - - return jsonResponse({ ok: true, writesFrozen: enabled }); -}; diff --git a/api/routes/auth/logout.ts b/api/routes/auth/logout.ts deleted file mode 100644 index e783681..0000000 --- a/api/routes/auth/logout.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { clearSession } from "../../_lib/auth.ts"; -import { jsonResponse } from "../../_lib/http.ts"; - -export const onRequestPost: ApiHandler = async (context) => { - const headers = new Headers(); - await clearSession(headers, context.env, context.request.url); - return jsonResponse({ ok: true }, { headers }); -}; diff --git a/api/routes/auth/nonce.ts b/api/routes/auth/nonce.ts deleted file mode 100644 index 217f3d3..0000000 --- a/api/routes/auth/nonce.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { issueNonce } from "../../_lib/auth.ts"; -import { createNonceStore } from "../../_lib/nonceStore.ts"; -import { errorResponse, jsonResponse, readJson } from "../../_lib/http.ts"; -import { getRequestIp } from "../../_lib/requestIp.ts"; -import { canonicalizeHmndAddress } from "../../_lib/address.ts"; - -type Body = { address?: string }; - -export const onRequestPost: ApiHandler = async (context) => { - let body: Body; - try { - body = await readJson(context.request); - } catch (error) { - return errorResponse(400, (error as Error).message); - } - - const address = (body.address ?? "").trim(); - if (!address) return errorResponse(400, "Missing address"); - const canonical = (await canonicalizeHmndAddress(address)) ?? address; - - const headers = new Headers(); - try { - const nonceStore = createNonceStore(context.env); - const requestIp = getRequestIp(context.request); - const rate = await nonceStore.canIssue({ address: canonical, requestIp }); - if (!rate.ok) - return errorResponse(429, "Rate limited", { - retryAfterSeconds: rate.retryAfterSeconds, - }); - - const { nonce, expiresAt } = await issueNonce( - headers, - context.env, - context.request.url, - canonical, - ); - - await nonceStore.create({ - address: canonical, - nonce, - requestIp, - expiresAt: new Date(expiresAt), - }); - return jsonResponse({ nonce }, { headers }); - } catch (error) { - return errorResponse(500, (error as Error).message); - } -}; diff --git a/api/routes/auth/verify.ts b/api/routes/auth/verify.ts deleted file mode 100644 index 986cd6f..0000000 --- a/api/routes/auth/verify.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { issueSession, verifyNonceCookie } from "../../_lib/auth.ts"; -import { envBoolean } from "../../_lib/env.ts"; -import { createNonceStore } from "../../_lib/nonceStore.ts"; -import { verifySubstrateSignature } from "../../_lib/signatures.ts"; -import { errorResponse, jsonResponse, readJson } from "../../_lib/http.ts"; -import { upsertUser } from "../../_lib/userStore.ts"; -import { - canonicalizeHmndAddress, - addressesReferToSameKey, -} from "../../_lib/address.ts"; - -type Body = { - address?: string; - nonce?: string; - signature?: string; -}; - -export const onRequestPost: ApiHandler = async (context) => { - let body: Body; - try { - body = await readJson(context.request); - } catch (error) { - return errorResponse(400, (error as Error).message); - } - - const address = (body.address ?? "").trim(); - const nonce = (body.nonce ?? "").trim(); - const signature = (body.signature ?? "").trim(); - if (!address) return errorResponse(400, "Missing address"); - if (!nonce) return errorResponse(400, "Missing nonce"); - if (!signature) return errorResponse(400, "Missing signature"); - const canonical = (await canonicalizeHmndAddress(address)) ?? address; - - const nonceToken = await verifyNonceCookie(context.request, context.env); - if (!nonceToken) - return errorResponse( - 401, - "Nonce expired or missing; call /api/auth/nonce again", - ); - if (!(await addressesReferToSameKey(nonceToken.address, canonical))) - return errorResponse(401, "Nonce was issued for a different address"); - if (nonceToken.nonce !== nonce) return errorResponse(401, "Nonce mismatch"); - - const nonceStore = createNonceStore(context.env); - const consume = await nonceStore.consume({ address: canonical, nonce }); - if (!consume.ok) { - const message = - consume.reason === "expired" - ? "Nonce expired; call /api/auth/nonce again" - : consume.reason === "used" - ? "Nonce already used; call /api/auth/nonce again" - : "Nonce invalid; call /api/auth/nonce again"; - return errorResponse(401, message, { reason: consume.reason }); - } - - if (!envBoolean(context.env, "DEV_BYPASS_SIGNATURE")) { - const ok = await verifySubstrateSignature({ - address: canonical, - message: nonce, - signature, - }); - if (!ok) return errorResponse(401, "Invalid signature"); - } - - const headers = new Headers(); - await issueSession(headers, context.env, context.request.url, canonical); - await upsertUser(context.env, { address: canonical }); - return jsonResponse({ ok: true, address: canonical }, { headers }); -}; diff --git a/api/routes/chambers/[id].ts b/api/routes/chambers/[id].ts deleted file mode 100644 index a94a361..0000000 --- a/api/routes/chambers/[id].ts +++ /dev/null @@ -1,187 +0,0 @@ -import { errorResponse, jsonResponse } from "../../_lib/http.ts"; -import { getChamber } from "../../_lib/chambersStore.ts"; -import { listProposals } from "../../_lib/proposalsStore.ts"; -import { createReadModelsStore } from "../../_lib/readModelsStore.ts"; -import { - listAllChamberMembers, - listChamberMembers, -} from "../../_lib/chamberMembershipsStore.ts"; -import { getSimConfig } from "../../_lib/simConfig.ts"; -import { resolveUserTierFromSimConfig } from "../../_lib/userTier.ts"; - -export const onRequestGet: ApiHandler = async (context) => { - try { - const id = context.params?.id; - if (!id) return errorResponse(400, "Missing chamber id"); - if (context.env.READ_MODELS_INLINE_EMPTY === "true") { - const store = await createReadModelsStore(context.env).catch(() => null); - const fallback = store ? await store.get(`chambers:${id}`) : null; - if (!fallback) return errorResponse(404, "Chamber not found"); - } - - let chamber = await getChamber(context.env, context.request.url, id); - if (!chamber) { - const store = await createReadModelsStore(context.env).catch(() => null); - const listPayload = store ? await store.get("chambers:list") : null; - const items = - listPayload && - typeof listPayload === "object" && - !Array.isArray(listPayload) && - Array.isArray((listPayload as { items?: unknown[] }).items) - ? (listPayload as { items: unknown[] }).items - : []; - const entry = items.find( - (item) => - item && - typeof item === "object" && - !Array.isArray(item) && - String((item as { id?: string }).id ?? "").toLowerCase() === - id.toLowerCase(), - ) as - | { - id?: string; - name?: string; - multiplier?: number; - status?: string; - } - | undefined; - if (entry) { - const multiplier = - typeof entry.multiplier === "number" ? entry.multiplier : 1; - const now = new Date(); - chamber = { - id: String(entry.id ?? id).toLowerCase(), - title: entry.name ?? entry.id ?? id, - status: - entry.status === "dissolved" ? "dissolved" : ("active" as const), - multiplierTimes10: Math.round(multiplier * 10), - createdAt: now, - updatedAt: now, - dissolvedAt: null, - }; - } - } - if (!chamber) return errorResponse(404, "Chamber not found"); - - const stageOptions = [ - { value: "upcoming", label: "Upcoming" }, - { value: "live", label: "Live" }, - { value: "ended", label: "Ended" }, - ] as const; - - const proposalsList: Array<{ - id: string; - title: string; - meta: string; - summary: string; - lead: string; - nextStep: string; - timing: string; - stage: "upcoming" | "live" | "ended"; - }> = []; - - const proposalRows = await listProposals(context.env); - for (const proposal of proposalRows) { - if ((proposal.chamberId ?? "general").toLowerCase() !== id.toLowerCase()) - continue; - const stage = - proposal.stage === "pool" - ? "upcoming" - : proposal.stage === "vote" - ? "live" - : "ended"; - const formationEligible = (() => { - const payload = proposal.payload as Record | null; - if (!payload || typeof payload !== "object" || Array.isArray(payload)) - return true; - if (payload.templateId === "system") return false; - if ( - typeof payload.metaGovernance === "object" && - payload.metaGovernance !== null && - !Array.isArray(payload.metaGovernance) - ) - return false; - if (typeof payload.formationEligible === "boolean") - return payload.formationEligible; - if (typeof payload.formation === "boolean") return payload.formation; - return true; - })(); - - const meta = - stage === "upcoming" - ? "Proposal pool" - : stage === "live" - ? "Chamber vote" - : formationEligible - ? "Formation" - : "Passed"; - - proposalsList.push({ - id: proposal.id, - title: proposal.title, - meta, - summary: proposal.summary, - lead: chamber.title, - nextStep: - stage === "upcoming" - ? "Cast attention vote" - : stage === "live" - ? "Cast chamber vote" - : formationEligible - ? "Open Formation" - : "Read outcome", - timing: proposal.createdAt.toISOString().slice(0, 10), - stage, - }); - } - - const cfg = await getSimConfig(context.env, context.request.url); - const genesisMembers = cfg?.genesisChamberMembers ?? undefined; - const memberAddresses = new Set(); - const normalizeAddress = (value: string) => value.trim(); - const chamberId = id.toLowerCase(); - - if (chamberId === "general") { - if (genesisMembers) { - for (const list of Object.values(genesisMembers)) { - for (const addr of list) memberAddresses.add(normalizeAddress(addr)); - } - } - // In v1, the roster for General is the set of anyone with any membership. - // This will be refined once canonical human profiles and era activity are in place. - const seeded = await listAllChamberMembers(context.env); - for (const addr of seeded) memberAddresses.add(normalizeAddress(addr)); - } else { - if (genesisMembers) { - for (const addr of genesisMembers[chamberId] ?? []) - memberAddresses.add(normalizeAddress(addr)); - } - const seeded = await listChamberMembers(context.env, id); - for (const addr of seeded) memberAddresses.add(normalizeAddress(addr)); - } - - const governors = await Promise.all( - Array.from(memberAddresses) - .sort() - .map(async (address) => ({ - id: address, - name: - address.length > 12 - ? `${address.slice(0, 6)}…${address.slice(-4)}` - : address, - tier: await resolveUserTierFromSimConfig(cfg, address), - focus: chamber.title, - })), - ); - - return jsonResponse({ - proposals: proposalsList.sort((a, b) => a.title.localeCompare(b.title)), - governors, - threads: [], - chatLog: [], - stageOptions, - }); - } catch (error) { - return errorResponse(500, (error as Error).message); - } -}; diff --git a/api/routes/chambers/index.ts b/api/routes/chambers/index.ts deleted file mode 100644 index 261dd66..0000000 --- a/api/routes/chambers/index.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { errorResponse, jsonResponse } from "../../_lib/http.ts"; -import { - listChambers, - projectChamberPipeline, - projectChamberStats, -} from "../../_lib/chambersStore.ts"; - -export const onRequestGet: ApiHandler = async (context) => { - try { - if (context.env.READ_MODELS_INLINE_EMPTY === "true") { - return jsonResponse({ items: [] }); - } - const url = new URL(context.request.url); - const includeDissolved = - url.searchParams.get("includeDissolved") === "true"; - const chambers = await listChambers(context.env, context.request.url, { - includeDissolved, - }); - const items = await Promise.all( - chambers.map(async (chamber) => { - const pipeline = await projectChamberPipeline(context.env, { - chamberId: chamber.id, - }); - const stats = await projectChamberStats( - context.env, - context.request.url, - { chamberId: chamber.id }, - ); - return { - id: chamber.id, - name: chamber.title, - multiplier: Math.round((chamber.multiplierTimes10 / 10) * 10) / 10, - stats: { - governors: stats.governors.toLocaleString(), - acm: stats.acm.toLocaleString(), - lcm: stats.lcm.toLocaleString(), - mcm: stats.mcm.toLocaleString(), - }, - pipeline, - }; - }), - ); - return jsonResponse({ items }); - } catch (error) { - return errorResponse(500, (error as Error).message); - } -}; diff --git a/api/routes/clock/advance-era.ts b/api/routes/clock/advance-era.ts deleted file mode 100644 index d79a965..0000000 --- a/api/routes/clock/advance-era.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { assertAdmin, createClockStore } from "../../_lib/clockStore.ts"; -import { ensureEraSnapshot } from "../../_lib/eraStore.ts"; -import { errorResponse, jsonResponse } from "../../_lib/http.ts"; - -export const onRequestPost: ApiHandler = async (context) => { - try { - assertAdmin(context); - const clock = createClockStore(context.env); - const next = await clock.advanceEra(); - await ensureEraSnapshot(context.env, next.currentEra).catch(() => {}); - return jsonResponse(next); - } catch (error) { - const err = error as Error & { status?: number }; - if (err.status) return errorResponse(err.status, err.message); - return errorResponse(500, err.message); - } -}; diff --git a/api/routes/clock/index.ts b/api/routes/clock/index.ts deleted file mode 100644 index f91fda5..0000000 --- a/api/routes/clock/index.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { createClockStore } from "../../_lib/clockStore.ts"; -import { getActiveGovernorsForCurrentEra } from "../../_lib/eraStore.ts"; -import { getEraRollupMeta } from "../../_lib/eraRollupStore.ts"; -import { errorResponse, jsonResponse } from "../../_lib/http.ts"; - -export const onRequestGet: ApiHandler = async (context) => { - try { - const clock = createClockStore(context.env); - const snapshot = await clock.get(); - const activeGovernors = await getActiveGovernorsForCurrentEra(context.env); - const rollup = await getEraRollupMeta(context.env, { - era: snapshot.currentEra, - }).catch(() => null); - return jsonResponse({ - ...snapshot, - activeGovernors, - ...(rollup ? { currentEraRollup: rollup } : {}), - }); - } catch (error) { - return errorResponse(500, (error as Error).message); - } -}; diff --git a/api/routes/clock/rollup-era.ts b/api/routes/clock/rollup-era.ts deleted file mode 100644 index 1d9de51..0000000 --- a/api/routes/clock/rollup-era.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { assertAdmin, createClockStore } from "../../_lib/clockStore.ts"; -import { rollupEra } from "../../_lib/eraRollupStore.ts"; -import { setEraSnapshotActiveGovernors } from "../../_lib/eraStore.ts"; -import { errorResponse, jsonResponse } from "../../_lib/http.ts"; - -export const onRequestPost: ApiHandler = async (context) => { - try { - assertAdmin(context); - - const clock = createClockStore(context.env); - const { currentEra } = await clock.get(); - - let era = currentEra; - const contentType = context.request.headers.get("content-type") ?? ""; - if (contentType.toLowerCase().includes("application/json")) { - const body = (await context.request.json().catch(() => null)) as { - era?: number; - } | null; - if (body && typeof body.era === "number") era = Math.floor(body.era); - } - - const result = await rollupEra(context.env, { - era, - requestUrl: context.request.url, - }); - - await setEraSnapshotActiveGovernors(context.env, { - era: era + 1, - activeGovernors: result.activeGovernorsNextEra, - }).catch(() => {}); - - return jsonResponse({ ok: true as const, ...result }); - } catch (error) { - const err = error as Error & { status?: number }; - if (err.status) return errorResponse(err.status, err.message); - return errorResponse(500, err.message); - } -}; diff --git a/api/routes/clock/tick.ts b/api/routes/clock/tick.ts deleted file mode 100644 index e0b9032..0000000 --- a/api/routes/clock/tick.ts +++ /dev/null @@ -1,226 +0,0 @@ -import { assertAdmin, createClockStore } from "../../_lib/clockStore.ts"; -import { errorResponse, jsonResponse } from "../../_lib/http.ts"; -import { appendFeedItemEventOnce } from "../../_lib/appendEvents.ts"; -import { rollupEra } from "../../_lib/eraRollupStore.ts"; -import { - ensureEraSnapshot, - setEraSnapshotActiveGovernors, -} from "../../_lib/eraStore.ts"; -import { formatChamberLabel } from "../../_lib/proposalDraftsStore.ts"; -import { listProposals } from "../../_lib/proposalsStore.ts"; -import { - getSimNow, - getStageDeadlineIso, - getStageWindowSeconds, - isStageOpen, - stageWindowsEnabled, -} from "../../_lib/stageWindows.ts"; -import { V1_ERA_SECONDS_DEFAULT } from "../../_lib/v1Constants.ts"; -import { finalizeAcceptedProposalFromVote } from "../../_lib/proposalFinalizer.ts"; -import { appendProposalTimelineItem } from "../../_lib/proposalTimelineStore.ts"; -import { randomHex } from "../../_lib/random.ts"; - -type Env = Record; - -function getEraSeconds(env: Env): number { - const raw = env.SIM_ERA_SECONDS ?? ""; - const parsed = Number(raw); - if (Number.isFinite(parsed) && parsed > 0) return Math.floor(parsed); - return V1_ERA_SECONDS_DEFAULT; -} - -export const onRequestPost: ApiHandler = async (context) => { - try { - assertAdmin(context); - - const contentType = context.request.headers.get("content-type") ?? ""; - const body = contentType.toLowerCase().includes("application/json") - ? ((await context.request.json().catch(() => null)) as { - forceAdvance?: boolean; - rollup?: boolean; - } | null) - : null; - - const forceAdvance = body?.forceAdvance === true; - const shouldRollup = body?.rollup !== false; - - const clock = createClockStore(context.env); - const snapshot = await clock.get(); - await ensureEraSnapshot(context.env, snapshot.currentEra).catch(() => {}); - - const now = getSimNow(context.env); - const eraSeconds = getEraSeconds(context.env); - const updatedAt = new Date(snapshot.updatedAt); - const dueByTime = - Number.isFinite(updatedAt.getTime()) && - now.getTime() - updatedAt.getTime() >= eraSeconds * 1000; - - const due = forceAdvance || dueByTime; - - const rollup = shouldRollup - ? await rollupEra(context.env, { - era: snapshot.currentEra, - requestUrl: context.request.url, - }) - : null; - - if (rollup) { - await setEraSnapshotActiveGovernors(context.env, { - era: snapshot.currentEra + 1, - activeGovernors: rollup.activeGovernorsNextEra, - }).catch(() => {}); - } - - let advancedTo = snapshot.currentEra; - let advanced = false; - if (due) { - const next = await clock.advanceEra(); - advancedTo = next.currentEra; - advanced = advancedTo !== snapshot.currentEra; - await ensureEraSnapshot(context.env, next.currentEra).catch(() => {}); - } - - const endedWindows: Array<{ - proposalId: string; - stage: "pool" | "vote"; - endedAt: string; - emitted: boolean; - }> = []; - - if (stageWindowsEnabled(context.env)) { - const proposals = await listProposals(context.env).catch(() => []); - for (const proposal of proposals) { - if (proposal.stage !== "pool" && proposal.stage !== "vote") continue; - if (proposal.stage === "vote" && proposal.votePassedAt) continue; - const windowSeconds = getStageWindowSeconds( - context.env, - proposal.stage, - ); - if (windowSeconds <= 0) continue; - - const stageStartedAt = proposal.updatedAt; - if (now.getTime() < stageStartedAt.getTime()) continue; - const endedAt = getStageDeadlineIso({ stageStartedAt, windowSeconds }); - const open = isStageOpen({ now, stageStartedAt, windowSeconds }); - if (open) continue; - - const entityType = "proposal.stage_window_ended.v1"; - const entityId = `${proposal.id}:${proposal.stage}:${endedAt}`; - - const chamberLabel = proposal.chamberId - ? formatChamberLabel(proposal.chamberId) - : "General chamber"; - - const emitted = await appendFeedItemEventOnce(context.env, { - stage: proposal.stage, - entityType, - entityId, - payload: { - id: `proposal-window-ended:${proposal.id}:${proposal.stage}:${endedAt}`, - title: - proposal.stage === "pool" - ? "Proposal pool window ended" - : "Chamber voting window ended", - meta: `${chamberLabel} · System`, - stage: proposal.stage, - summaryPill: - proposal.stage === "pool" ? "Proposal pool" : "Chamber vote", - summary: - proposal.stage === "pool" - ? "Pool voting is now closed for this proposal." - : "Voting is now closed for this proposal.", - ctaPrimary: "Open proposal", - href: - proposal.stage === "pool" - ? `/app/proposals/${proposal.id}/pp` - : `/app/proposals/${proposal.id}/chamber`, - timestamp: endedAt, - }, - }); - - endedWindows.push({ - proposalId: proposal.id, - stage: proposal.stage, - endedAt, - emitted, - }); - } - } - - const finalized: Array<{ proposalId: string; ok: boolean }> = []; - { - const proposals = await listProposals(context.env, { - stage: "vote", - }).catch(() => []); - for (const proposal of proposals) { - const finalizesAt = proposal.voteFinalizesAt; - if (!finalizesAt) continue; - if (now.getTime() < finalizesAt.getTime()) continue; - if (!proposal.votePassedAt) continue; - - const result = await finalizeAcceptedProposalFromVote(context.env, { - proposalId: proposal.id, - requestUrl: context.request.url, - }); - finalized.push({ proposalId: proposal.id, ok: result.ok }); - if (!result.ok) continue; - - await appendFeedItemEventOnce(context.env, { - stage: "build", - entityType: "proposal", - entityId: `vote-finalized:${proposal.id}:${finalizesAt.toISOString()}`, - payload: { - id: `vote-finalized:${proposal.id}:${finalizesAt.toISOString()}`, - title: "Proposal accepted", - meta: "Chamber vote", - stage: "build", - summaryPill: "Accepted", - summary: - "Veto window ended; chamber vote is finalized and the proposal is now accepted.", - stats: [ - ...(result.avgScore !== null - ? [{ label: "Avg CM", value: result.avgScore.toFixed(1) }] - : []), - ], - ctaPrimary: "Open proposal", - href: result.formationEligible - ? `/app/proposals/${proposal.id}/formation` - : `/app/proposals/${proposal.id}/chamber`, - timestamp: now.toISOString(), - }, - }); - - await appendProposalTimelineItem(context.env, { - proposalId: proposal.id, - stage: "build", - actorAddress: null, - item: { - id: `timeline:vote-finalized:${proposal.id}:${randomHex(4)}`, - type: "proposal.vote.finalized", - title: "Proposal accepted", - detail: "Veto window ended", - actor: "system", - timestamp: now.toISOString(), - }, - }); - } - } - - return jsonResponse({ - ok: true as const, - now: now.toISOString(), - eraSeconds, - due, - advanced, - fromEra: snapshot.currentEra, - toEra: advancedTo, - ...(endedWindows.length > 0 ? { endedWindows } : {}), - ...(finalized.length > 0 ? { finalized } : {}), - ...(rollup ? { rollup } : {}), - }); - } catch (error) { - const err = error as Error & { status?: number }; - if (err.status) return errorResponse(err.status, err.message); - return errorResponse(500, err.message); - } -}; diff --git a/api/routes/command.ts b/api/routes/command.ts deleted file mode 100644 index 6727f33..0000000 --- a/api/routes/command.ts +++ /dev/null @@ -1,3159 +0,0 @@ -import { z } from "zod"; - -import { readSession } from "../_lib/auth.ts"; -import { checkEligibility } from "../_lib/gate.ts"; -import { errorResponse, jsonResponse, readJson } from "../_lib/http.ts"; -import { - getIdempotencyResponse, - storeIdempotencyResponse, -} from "../_lib/idempotencyStore.ts"; -import { castPoolVote } from "../_lib/poolVotesStore.ts"; -import { appendFeedItemEvent } from "../_lib/appendEvents.ts"; -import { appendProposalTimelineItem } from "../_lib/proposalTimelineStore.ts"; -import { createReadModelsStore } from "../_lib/readModelsStore.ts"; -import { evaluatePoolQuorum } from "../_lib/poolQuorum.ts"; -import { - castChamberVote, - getChamberYesScoreAverage, - clearChamberVotesForProposal, -} from "../_lib/chamberVotesStore.ts"; -import { evaluateChamberQuorum } from "../_lib/chamberQuorum.ts"; -import { awardCmOnce, hasLcmHistoryInChamber } from "../_lib/cmAwardsStore.ts"; -import { - joinFormationProject, - ensureFormationSeed, - getFormationMilestoneStatus, - isFormationTeamMember, - requestFormationMilestoneUnlock, - submitFormationMilestone, -} from "../_lib/formationStore.ts"; -import { - castCourtVerdict, - hasCourtReport, - hasCourtVerdict, - reportCourtCase, -} from "../_lib/courtsStore.ts"; -import { - getActiveGovernorsForCurrentEra, - incrementEraUserActivity, - getUserEraActivity, -} from "../_lib/eraStore.ts"; -import { - createApiRateLimitStore, - getCommandRateLimitConfig, -} from "../_lib/apiRateLimitStore.ts"; -import { getRequestIp } from "../_lib/requestIp.ts"; -import { createActionLocksStore } from "../_lib/actionLocksStore.ts"; -import { getEraQuotaConfig } from "../_lib/eraQuotas.ts"; -import { hasPoolVote } from "../_lib/poolVotesStore.ts"; -import { hasChamberVote } from "../_lib/chamberVotesStore.ts"; -import { createAdminStateStore } from "../_lib/adminStateStore.ts"; -import { - deleteDraft, - draftIsSubmittable, - formatChamberLabel, - getDraft, - markDraftSubmitted, - proposalDraftFormSchema, - upsertDraft, -} from "../_lib/proposalDraftsStore.ts"; -import { - createProposal, - setProposalVotePendingVeto, - getProposal, - transitionProposalStage, - applyProposalVeto, -} from "../_lib/proposalsStore.ts"; -import { - captureProposalStageDenominator, - getProposalStageDenominator, -} from "../_lib/proposalStageDenominatorsStore.ts"; -import { clearDelegation, setDelegation } from "../_lib/delegationsStore.ts"; -import { - ensureChamberMembership, - hasAnyChamberMembership, - hasChamberMembership, -} from "../_lib/chamberMembershipsStore.ts"; -import { getActiveGovernorsDenominatorForChamberCurrentEra } from "../_lib/chamberActiveDenominators.ts"; -import { randomHex } from "../_lib/random.ts"; -import { - computePoolUpvoteFloor, - shouldAdvancePoolToVote, - shouldAdvanceVoteToBuild, -} from "../_lib/proposalStateMachine.ts"; -import { - V1_ACTIVE_GOVERNORS_FALLBACK, - V1_CHAMBER_PASSING_FRACTION, - V1_CHAMBER_QUORUM_FRACTION, - V1_POOL_ATTENTION_QUORUM_FRACTION, - V1_VETO_DELAY_SECONDS_DEFAULT, - V1_VETO_MAX_APPLIES, -} from "../_lib/v1Constants.ts"; -import { computeVetoCouncilSnapshot } from "../_lib/vetoCouncilStore.ts"; -import { - castVetoVote, - clearVetoVotesForProposal, -} from "../_lib/vetoVotesStore.ts"; -import { finalizeAcceptedProposalFromVote } from "../_lib/proposalFinalizer.ts"; -import { - formatTimeLeftDaysHours, - getSimNow, - getStageDeadlineIso, - getStageRemainingSeconds, - getStageWindowSeconds, - isStageOpen, - stageWindowsEnabled, -} from "../_lib/stageWindows.ts"; -import { envBoolean } from "../_lib/env.ts"; -import { getSimConfig } from "../_lib/simConfig.ts"; -import { resolveUserTierFromSimConfig } from "../_lib/userTier.ts"; -import { addressesReferToSameKey } from "../_lib/address.ts"; -import { - createChamberFromAcceptedGeneralProposal, - dissolveChamberFromAcceptedGeneralProposal, - getChamber, - parseChamberGovernanceFromPayload, - setChamberMultiplierTimes10, -} from "../_lib/chambersStore.ts"; -import { - getChamberMultiplierAggregate, - upsertChamberMultiplierSubmission, -} from "../_lib/chamberMultiplierSubmissionsStore.ts"; - -function getGenesisMembersForDenominators( - simConfig: Awaited> | null, - chamberId: string, -): string[] | null { - const genesis = simConfig?.genesisChamberMembers; - if (!genesis) return null; - const normalized = chamberId.trim().toLowerCase(); - if (normalized === "general") return Object.values(genesis).flat(); - return genesis[normalized] ?? null; -} - -const poolVoteSchema = z.object({ - type: z.literal("pool.vote"), - payload: z.object({ - proposalId: z.string().min(1), - direction: z.enum(["up", "down"]), - }), - idempotencyKey: z.string().min(8).optional(), -}); - -const chamberVoteSchema = z.object({ - type: z.literal("chamber.vote"), - payload: z.object({ - proposalId: z.string().min(1), - choice: z.enum(["yes", "no", "abstain"]), - score: z.number().int().min(1).max(10).optional(), - }), - idempotencyKey: z.string().min(8).optional(), -}); - -const formationJoinSchema = z.object({ - type: z.literal("formation.join"), - payload: z.object({ - proposalId: z.string().min(1), - role: z.string().min(1).optional(), - }), - idempotencyKey: z.string().min(8).optional(), -}); - -const formationMilestoneSubmitSchema = z.object({ - type: z.literal("formation.milestone.submit"), - payload: z.object({ - proposalId: z.string().min(1), - milestoneIndex: z.number().int().min(1), - note: z.string().min(1).optional(), - }), - idempotencyKey: z.string().min(8).optional(), -}); - -const formationMilestoneUnlockSchema = z.object({ - type: z.literal("formation.milestone.requestUnlock"), - payload: z.object({ - proposalId: z.string().min(1), - milestoneIndex: z.number().int().min(1), - }), - idempotencyKey: z.string().min(8).optional(), -}); - -const courtReportSchema = z.object({ - type: z.literal("court.case.report"), - payload: z.object({ - caseId: z.string().min(1), - }), - idempotencyKey: z.string().min(8).optional(), -}); - -const courtVerdictSchema = z.object({ - type: z.literal("court.case.verdict"), - payload: z.object({ - caseId: z.string().min(1), - verdict: z.enum(["guilty", "not_guilty"]), - }), - idempotencyKey: z.string().min(8).optional(), -}); - -const proposalDraftSaveSchema = z.object({ - type: z.literal("proposal.draft.save"), - payload: z.object({ - draftId: z.string().min(1).optional(), - form: proposalDraftFormSchema, - }), - idempotencyKey: z.string().min(8).optional(), -}); - -const proposalDraftDeleteSchema = z.object({ - type: z.literal("proposal.draft.delete"), - payload: z.object({ - draftId: z.string().min(1), - }), - idempotencyKey: z.string().min(8).optional(), -}); - -const proposalSubmitToPoolSchema = z.object({ - type: z.literal("proposal.submitToPool"), - payload: z.object({ - draftId: z.string().min(1), - }), - idempotencyKey: z.string().min(8).optional(), -}); - -const delegationSetSchema = z.object({ - type: z.literal("delegation.set"), - payload: z.object({ - chamberId: z.string().min(1), - delegateeAddress: z.string().min(1), - }), - idempotencyKey: z.string().min(8).optional(), -}); - -const delegationClearSchema = z.object({ - type: z.literal("delegation.clear"), - payload: z.object({ - chamberId: z.string().min(1), - }), - idempotencyKey: z.string().min(8).optional(), -}); - -const vetoVoteSchema = z.object({ - type: z.literal("veto.vote"), - payload: z.object({ - proposalId: z.string().min(1), - choice: z.enum(["veto", "keep"]), - }), - idempotencyKey: z.string().min(8).optional(), -}); - -const chamberMultiplierSubmitSchema = z.object({ - type: z.literal("chamber.multiplier.submit"), - payload: z.object({ - chamberId: z.string().min(1), - multiplierTimes10: z.number().int().min(1).max(100), - }), - idempotencyKey: z.string().min(8).optional(), -}); - -const commandSchema = z.discriminatedUnion("type", [ - poolVoteSchema, - chamberVoteSchema, - formationJoinSchema, - formationMilestoneSubmitSchema, - formationMilestoneUnlockSchema, - courtReportSchema, - courtVerdictSchema, - proposalDraftSaveSchema, - proposalDraftDeleteSchema, - proposalSubmitToPoolSchema, - delegationSetSchema, - delegationClearSchema, - vetoVoteSchema, - chamberMultiplierSubmitSchema, -]); - -type CommandInput = z.infer; - -export const onRequestPost: ApiHandler = async (context) => { - let body: unknown; - try { - body = await readJson(context.request); - } catch (error) { - return errorResponse(400, (error as Error).message); - } - - const parsed = commandSchema.safeParse(body); - if (!parsed.success) { - return errorResponse(400, "Invalid command", { - issues: parsed.error.issues, - }); - } - - const session = await readSession(context.request, context.env); - if (!session) return errorResponse(401, "Not authenticated"); - const sessionAddress = session.address; - - const gate = await checkEligibility( - context.env, - sessionAddress, - context.request.url, - ); - if (!gate.eligible) { - return errorResponse(403, gate.reason ?? "not_eligible", { gate }); - } - - if (context.env.SIM_WRITE_FREEZE === "true") { - return errorResponse(503, "Writes are temporarily disabled", { - code: "writes_frozen", - }); - } - const adminState = await createAdminStateStore(context.env) - .get() - .catch(() => ({ writesFrozen: false })); - if (adminState.writesFrozen) { - return errorResponse(503, "Writes are temporarily disabled", { - code: "writes_frozen", - }); - } - - const locks = createActionLocksStore(context.env); - const activeLock = await locks.getActiveLock(sessionAddress); - if (activeLock) { - return errorResponse(403, "Action locked", { - code: "action_locked", - lock: activeLock, - }); - } - - const rateLimits = createApiRateLimitStore(context.env); - const rateConfig = getCommandRateLimitConfig(context.env); - const requestIp = getRequestIp(context.request); - - if (requestIp) { - const ipLimit = await rateLimits.consume({ - bucket: `command:ip:${requestIp}`, - limit: rateConfig.perIpPerMinute, - windowSeconds: 60, - }); - if (!ipLimit.ok) { - return errorResponse(429, "Rate limited", { - scope: "ip", - retryAfterSeconds: ipLimit.retryAfterSeconds, - resetAt: ipLimit.resetAt, - }); - } - } - - const addressLimit = await rateLimits.consume({ - bucket: `command:address:${session.address}`, - limit: rateConfig.perAddressPerMinute, - windowSeconds: 60, - }); - if (!addressLimit.ok) { - return errorResponse(429, "Rate limited", { - scope: "address", - retryAfterSeconds: addressLimit.retryAfterSeconds, - resetAt: addressLimit.resetAt, - }); - } - - const input: CommandInput = parsed.data; - const headerKey = - context.request.headers.get("idempotency-key") ?? - context.request.headers.get("x-idempotency-key") ?? - undefined; - const idempotencyKey = headerKey ?? input.idempotencyKey; - const requestForIdem = { type: input.type, payload: input.payload }; - - if (idempotencyKey) { - const hit = await getIdempotencyResponse(context.env, { - key: idempotencyKey, - address: session.address, - request: requestForIdem, - }); - if ("conflict" in hit && hit.conflict) { - return errorResponse(409, "Idempotency key conflict"); - } - if (hit.hit) return jsonResponse(hit.response); - } - - const readModels = await createReadModelsStore(context.env).catch(() => null); - const activeGovernorsBaseline = await getActiveGovernorsForCurrentEra( - context.env, - ).catch(() => null); - - const quotas = getEraQuotaConfig(context.env); - - async function enforceEraQuota(input: { - kind: "poolVotes" | "chamberVotes" | "courtActions" | "formationActions"; - wouldCount: boolean; - }): Promise { - if (!input.wouldCount) return null; - const limit = - input.kind === "poolVotes" - ? quotas.maxPoolVotes - : input.kind === "chamberVotes" - ? quotas.maxChamberVotes - : input.kind === "courtActions" - ? quotas.maxCourtActions - : quotas.maxFormationActions; - if (limit === null) return null; - - const activity = await getUserEraActivity(context.env, { - address: sessionAddress, - }); - const used = activity.counts[input.kind] ?? 0; - if (used >= limit) { - return errorResponse(429, "Era quota exceeded", { - code: "era_quota_exceeded", - era: activity.era, - kind: input.kind, - limit, - used, - }); - } - return null; - } - if ( - input.type === "pool.vote" || - input.type === "chamber.vote" || - input.type === "formation.join" || - input.type === "formation.milestone.submit" || - input.type === "formation.milestone.requestUnlock" - ) { - const requiredStage = - input.type === "pool.vote" - ? "pool" - : input.type === "chamber.vote" - ? "vote" - : "build"; - const stage = - (await getProposal(context.env, input.payload.proposalId))?.stage ?? - (readModels - ? await getProposalStage(readModels, input.payload.proposalId) - : null); - if (!stage) return errorResponse(404, "Unknown proposal"); - if (stage !== requiredStage) { - return errorResponse(409, "Proposal is not in the required stage", { - stage, - requiredStage, - }); - } - } - - if (input.type === "proposal.draft.save") { - const record = await upsertDraft(context.env, { - authorAddress: sessionAddress, - draftId: input.payload.draftId, - form: input.payload.form, - }); - - const response = { - ok: true as const, - type: input.type, - draftId: record.id, - updatedAt: record.updatedAt.toISOString(), - }; - - if (idempotencyKey) { - await storeIdempotencyResponse(context.env, { - key: idempotencyKey, - address: sessionAddress, - request: requestForIdem, - response, - }); - } - - return jsonResponse(response); - } - - if (input.type === "proposal.draft.delete") { - const deleted = await deleteDraft(context.env, { - authorAddress: sessionAddress, - draftId: input.payload.draftId, - }); - - const response = { - ok: true as const, - type: input.type, - draftId: input.payload.draftId, - deleted, - }; - - if (idempotencyKey) { - await storeIdempotencyResponse(context.env, { - key: idempotencyKey, - address: sessionAddress, - request: requestForIdem, - response, - }); - } - - return jsonResponse(response); - } - - if (input.type === "proposal.submitToPool") { - const draft = await getDraft(context.env, { - authorAddress: sessionAddress, - draftId: input.payload.draftId, - }); - if (!draft) return errorResponse(404, "Draft not found"); - if (draft.submittedAt || draft.submittedProposalId) { - return errorResponse(409, "Draft already submitted"); - } - if (!draftIsSubmittable(draft.payload)) { - return errorResponse(400, "Draft is not ready for submission", { - code: "draft_not_submittable", - }); - } - - const now = new Date(); - const baseSlug = draft.title - .trim() - .toLowerCase() - .replace(/[^a-z0-9]+/g, "-") - .replace(/^-+|-+$/g, "") - .slice(0, 48); - const proposalId = `${baseSlug || "proposal"}-${randomHex(2)}`; - - const chamberId = (draft.chamberId ?? "").trim().toLowerCase(); - if (chamberId && chamberId !== "general") { - const chamber = await getChamber( - context.env, - context.request.url, - chamberId, - ); - if (!chamber) { - return errorResponse(400, "Unknown chamber", { - code: "invalid_chamber", - chamberId, - }); - } - if (chamber.status !== "active") { - return errorResponse(409, "Chamber is dissolved", { - code: "chamber_dissolved", - chamberId, - status: chamber.status, - dissolvedAt: chamber.dissolvedAt?.toISOString() ?? null, - }); - } - } - - const meta = (() => { - const payload = draft.payload as Record | null; - if (!payload || typeof payload !== "object" || Array.isArray(payload)) - return null; - const mg = payload.metaGovernance; - if (!mg || typeof mg !== "object" || Array.isArray(mg)) return null; - const record = mg as Record; - const action = typeof record.action === "string" ? record.action : ""; - if (action !== "chamber.create" && action !== "chamber.dissolve") - return { invalid: true as const }; - - const id = typeof record.chamberId === "string" ? record.chamberId : ""; - const title = typeof record.title === "string" ? record.title : ""; - const multiplier = - typeof record.multiplier === "number" ? record.multiplier : null; - const genesisMembersRaw = record.genesisMembers; - const genesisMembers = Array.isArray(genesisMembersRaw) - ? genesisMembersRaw - .filter((v): v is string => typeof v === "string") - .map((v) => v.trim()) - .filter(Boolean) - : []; - return { - action, - id, - title, - multiplier, - genesisMembers, - } as const; - })(); - - if (meta?.invalid) { - return errorResponse(400, "Invalid meta-governance payload", { - code: "invalid_meta_governance", - }); - } - - if (meta && meta.action) { - if (chamberId !== "general") { - return errorResponse( - 400, - "Meta-governance proposals must use General chamber", - { - code: "meta_governance_requires_general", - }, - ); - } - - const targetId = meta.id.trim().toLowerCase(); - if (!targetId || targetId === "general") { - return errorResponse(400, "Invalid target chamber", { - code: "invalid_meta_chamber", - }); - } - - const existing = await getChamber( - context.env, - context.request.url, - targetId, - ); - - if (meta.action === "chamber.create") { - if (existing) { - return errorResponse(409, "Chamber already exists", { - code: "chamber_exists", - chamberId: targetId, - status: existing.status, - }); - } - if (!meta.title.trim()) { - return errorResponse(400, "Chamber title is required", { - code: "invalid_meta_chamber", - }); - } - if (meta.multiplier !== null && !(meta.multiplier > 0)) { - return errorResponse(400, "Multiplier must be > 0", { - code: "invalid_meta_chamber", - }); - } - } else { - if (!existing) { - return errorResponse(400, "Unknown chamber", { - code: "invalid_chamber", - chamberId: targetId, - }); - } - if (existing.status !== "active") { - return errorResponse(409, "Chamber is already dissolved", { - code: "chamber_dissolved", - chamberId: targetId, - status: existing.status, - dissolvedAt: existing.dissolvedAt?.toISOString() ?? null, - }); - } - } - } - - const normalizedChamberId = meta ? "general" : draft.chamberId; - - await createProposal(context.env, { - id: proposalId, - stage: "pool", - authorAddress: sessionAddress, - title: draft.title, - chamberId: normalizedChamberId ?? null, - summary: draft.summary, - payload: draft.payload, - }); - - const chamber = formatChamberLabel(normalizedChamberId ?? null); - const budgetTotal = draft.payload.budgetItems.reduce((sum, item) => { - const n = Number(item.amount); - if (!Number.isFinite(n) || n <= 0) return sum; - return sum + n; - }, 0); - const budget = - budgetTotal > 0 ? `${budgetTotal.toLocaleString()} HMND` : "—"; - - const poolChamberId = (normalizedChamberId ?? "general") - .trim() - .toLowerCase(); - const simConfig = await getSimConfig( - context.env, - context.request.url, - ).catch(() => null); - const authorTier = await resolveUserTierFromSimConfig( - simConfig, - sessionAddress, - ); - const genesisMembers = getGenesisMembersForDenominators( - simConfig, - poolChamberId, - ); - const poolActiveGovernors = - await getActiveGovernorsDenominatorForChamberCurrentEra(context.env, { - chamberId: poolChamberId || "general", - fallbackActiveGovernors: - typeof activeGovernorsBaseline === "number" - ? activeGovernorsBaseline - : V1_ACTIVE_GOVERNORS_FALLBACK, - genesisMembers, - }); - const attentionQuorum = V1_POOL_ATTENTION_QUORUM_FRACTION; - const upvoteFloor = computePoolUpvoteFloor(poolActiveGovernors); - - const formationEligible = getFormationEligibleFromProposalPayload( - draft.payload, - ); - - const poolPagePayload = { - title: draft.title, - proposer: sessionAddress, - proposerId: sessionAddress, - chamber, - focus: "—", - tier: authorTier, - budget, - cooldown: "Withdraw cooldown: 12h", - formationEligible, - templateId: isRecord(draft.payload) - ? draft.payload.templateId - : undefined, - metaGovernance: isRecord(draft.payload) - ? draft.payload.metaGovernance - : undefined, - teamSlots: "1 / 3", - milestones: String(draft.payload.timeline.length), - upvotes: 0, - downvotes: 0, - attentionQuorum, - activeGovernors: poolActiveGovernors, - upvoteFloor, - rules: [ - `${Math.round(attentionQuorum * 100)}% attention from active governors required.`, - poolActiveGovernors > 0 - ? `At least ${Math.round((upvoteFloor / poolActiveGovernors) * 100)}% upvotes to move to chamber vote.` - : "At least 0% upvotes to move to chamber vote.", - ], - attachments: draft.payload.attachments - .filter((a) => a.label.trim().length > 0) - .map((a) => ({ id: a.id, title: a.label })), - teamLocked: [{ name: sessionAddress, role: "Proposer" }], - openSlotNeeds: [], - milestonesDetail: draft.payload.timeline.map((m, idx) => ({ - title: m.title.trim().length ? m.title : `Milestone ${idx + 1}`, - desc: m.timeframe.trim().length ? m.timeframe : "Timeline TBD", - })), - summary: draft.payload.summary, - overview: draft.payload.what, - executionPlan: draft.payload.how - .split("\n") - .map((line) => line.trim()) - .filter(Boolean), - budgetScope: draft.payload.budgetItems - .filter((b) => b.description.trim().length > 0) - .map((b) => `${b.description}: ${b.amount} HMND`) - .join("\n"), - invisionInsight: { - role: "Draft author", - bullets: [ - "Submitted via the simulation backend proposal wizard.", - "This is an off-chain governance simulation (not mainnet).", - ], - }, - }; - - const listPayload = readModels?.set - ? await readModels.get("proposals:list") - : null; - const existingItems = - readModels?.set && - isRecord(listPayload) && - Array.isArray(listPayload.items) - ? listPayload.items - : []; - - const listItem = { - id: proposalId, - title: draft.title, - meta: `${chamber} · ${authorTier} tier`, - stage: "pool", - summaryPill: `${draft.payload.timeline.length} milestones`, - summary: draft.payload.summary, - stageData: [ - { - title: "Pool momentum", - description: "Upvotes / Downvotes", - value: "0 / 0", - }, - { - title: "Attention quorum", - description: "20% active or ≥10% upvotes", - value: "Needs · 0% engaged", - tone: "warn", - }, - { title: "Votes casted", description: "Backing seats", value: "0" }, - ], - stats: [ - { label: "Budget ask", value: budget }, - { label: "Formation", value: formationEligible ? "Yes" : "No" }, - ], - proposer: sessionAddress, - proposerId: sessionAddress, - chamber, - tier: authorTier, - proofFocus: "pot", - tags: [], - keywords: [], - date: now.toISOString().slice(0, 10), - votes: 0, - activityScore: 0, - ctaPrimary: "Open proposal", - ctaSecondary: "", - }; - - if (readModels?.set) { - await readModels.set("proposals:list", { - ...(isRecord(listPayload) ? listPayload : {}), - items: [...existingItems, listItem], - }); - await readModels.set(`proposals:${proposalId}:pool`, poolPagePayload); - } - - await captureProposalStageDenominator(context.env, { - proposalId, - stage: "pool", - activeGovernors: poolActiveGovernors, - }).catch(() => {}); - - await markDraftSubmitted(context.env, { - authorAddress: sessionAddress, - draftId: input.payload.draftId, - proposalId, - }); - - const response = { - ok: true as const, - type: input.type, - draftId: input.payload.draftId, - proposalId, - }; - - if (idempotencyKey) { - await storeIdempotencyResponse(context.env, { - key: idempotencyKey, - address: sessionAddress, - request: requestForIdem, - response, - }); - } - - await appendFeedItemEvent(context.env, { - stage: "pool", - actorAddress: sessionAddress, - entityType: "proposal", - entityId: proposalId, - payload: { - id: `proposal-submitted:${proposalId}:${Date.now()}`, - title: "Proposal submitted", - meta: "Proposal pool · New", - stage: "pool", - summaryPill: "Submitted", - summary: `Submitted "${draft.title}" to the proposal pool.`, - stats: [{ label: "Budget ask", value: budget }], - ctaPrimary: "Open proposal", - href: `/app/proposals/${proposalId}/pp`, - timestamp: new Date().toISOString(), - }, - }); - - await appendProposalTimelineItem(context.env, { - proposalId, - stage: "pool", - actorAddress: sessionAddress, - item: { - id: `timeline:proposal-submitted:${proposalId}:${randomHex(4)}`, - type: "proposal.submitted", - title: "Proposal submitted", - detail: `Submitted to ${chamber}`, - actor: sessionAddress, - timestamp: new Date().toISOString(), - }, - }); - - return jsonResponse(response); - } - - if (input.type === "delegation.set") { - const chamberId = input.payload.chamberId.trim().toLowerCase(); - const delegateeAddress = input.payload.delegateeAddress.trim(); - - const isDelegatorEligible = - chamberId === "general" - ? await hasAnyChamberMembership(context.env, sessionAddress) - : await hasChamberMembership(context.env, { - address: sessionAddress, - chamberId, - }); - if (!isDelegatorEligible) { - return errorResponse(400, "Delegator is not eligible for delegation", { - code: "delegator_not_eligible", - chamberId, - }); - } - - const isDelegateeEligible = - chamberId === "general" - ? await hasAnyChamberMembership(context.env, delegateeAddress) - : await hasChamberMembership(context.env, { - address: delegateeAddress, - chamberId, - }); - if (!isDelegateeEligible) { - return errorResponse(400, "Delegatee is not eligible for delegation", { - code: "delegatee_not_eligible", - chamberId, - }); - } - - let record; - try { - record = await setDelegation(context.env, { - chamberId, - delegatorAddress: sessionAddress, - delegateeAddress, - }); - } catch (error) { - const code = (error as Error).message; - return errorResponse(400, "Unable to set delegation", { code }); - } - - const response = { - ok: true as const, - type: input.type, - chamberId: record.chamberId, - delegatorAddress: record.delegatorAddress, - delegateeAddress: record.delegateeAddress, - updatedAt: record.updatedAt, - }; - - if (idempotencyKey) { - await storeIdempotencyResponse(context.env, { - key: idempotencyKey, - address: session.address, - request: requestForIdem, - response, - }); - } - - await appendFeedItemEvent(context.env, { - stage: "vote", - actorAddress: session.address, - entityType: "delegation", - entityId: `${record.chamberId}:${record.delegatorAddress}`, - payload: { - id: `delegation-set:${record.chamberId}:${record.delegatorAddress}:${Date.now()}`, - title: "Delegation set", - meta: "Delegation", - stage: "vote", - summaryPill: "Delegated", - summary: `Delegated voting power in ${record.chamberId} chamber.`, - stats: [{ label: "Delegatee", value: record.delegateeAddress }], - ctaPrimary: "Open My Governance", - href: "/app/my-governance", - timestamp: new Date().toISOString(), - }, - }); - - return jsonResponse(response); - } - - if (input.type === "delegation.clear") { - const chamberId = input.payload.chamberId.trim().toLowerCase(); - - let cleared; - try { - cleared = await clearDelegation(context.env, { - chamberId, - delegatorAddress: sessionAddress, - }); - } catch (error) { - const code = (error as Error).message; - return errorResponse(400, "Unable to clear delegation", { code }); - } - - const response = { - ok: true as const, - type: input.type, - chamberId, - delegatorAddress: sessionAddress, - cleared: cleared.cleared, - }; - - if (idempotencyKey) { - await storeIdempotencyResponse(context.env, { - key: idempotencyKey, - address: session.address, - request: requestForIdem, - response, - }); - } - - return jsonResponse(response); - } - - if (input.type === "chamber.multiplier.submit") { - const chamberId = input.payload.chamberId.trim().toLowerCase(); - const multiplierTimes10 = input.payload.multiplierTimes10; - - const chamber = await getChamber( - context.env, - context.request.url, - chamberId, - ); - if (!chamber) { - return errorResponse(400, "Unknown chamber", { - code: "invalid_chamber", - chamberId, - }); - } - if (chamber.status !== "active") { - return errorResponse(409, "Chamber is dissolved", { - code: "chamber_dissolved", - chamberId, - }); - } - - const simConfig = await getSimConfig(context.env, context.request.url); - const genesis = simConfig?.genesisChamberMembers ?? null; - const hasGenesisMembership = (() => { - if (!genesis) return false; - for (const list of Object.values(genesis)) { - if (list.some((addr) => addr.trim() === sessionAddress)) return true; - } - return false; - })(); - - const isGovernor = - hasGenesisMembership || - (await hasAnyChamberMembership(context.env, sessionAddress)); - if (!isGovernor) { - return errorResponse(403, "Only governors can set chamber multipliers", { - code: "not_governor", - }); - } - - const hasLcmHere = await hasLcmHistoryInChamber(context.env, { - proposerId: sessionAddress, - chamberId, - }); - if (hasLcmHere) { - return errorResponse(400, "Multiplier voting is outsiders-only", { - code: "multiplier_outsider_required", - chamberId, - }); - } - - const { submission } = await upsertChamberMultiplierSubmission( - context.env, - { - chamberId, - voterAddress: sessionAddress, - multiplierTimes10, - }, - ); - - const aggregate = await getChamberMultiplierAggregate(context.env, { - chamberId, - }); - - const applied = - typeof aggregate.avgTimes10 === "number" - ? await setChamberMultiplierTimes10(context.env, context.request.url, { - id: chamberId, - multiplierTimes10: aggregate.avgTimes10, - }) - : null; - - const response = { - ok: true as const, - type: input.type, - chamberId, - submission: { - multiplierTimes10: submission.multiplierTimes10, - }, - aggregate, - applied: applied - ? { - updated: applied.updated, - prevMultiplierTimes10: applied.prevTimes10, - nextMultiplierTimes10: applied.nextTimes10, - } - : null, - }; - - if (idempotencyKey) { - await storeIdempotencyResponse(context.env, { - key: idempotencyKey, - address: session.address, - request: requestForIdem, - response, - }); - } - - await appendFeedItemEvent(context.env, { - stage: "vote", - actorAddress: sessionAddress, - entityType: "chamber", - entityId: `multiplier:${chamberId}`, - payload: { - id: `chamber-multiplier-submit:${chamberId}:${sessionAddress}:${Date.now()}`, - title: "Multiplier submitted", - meta: "Chambers · CM", - stage: "vote", - summaryPill: "Multiplier", - summary: `Submitted a chamber multiplier for ${chamberId}.`, - stats: [ - { label: "Submitted", value: String(submission.multiplierTimes10) }, - ...(typeof aggregate.avgTimes10 === "number" - ? [{ label: "Avg", value: String(aggregate.avgTimes10) }] - : []), - ], - ctaPrimary: "Open chambers", - href: "/app/chambers", - timestamp: new Date().toISOString(), - }, - }); - - return jsonResponse(response); - } - - if (input.type === "veto.vote") { - const proposalId = input.payload.proposalId; - const proposal = await getProposal(context.env, proposalId); - if (!proposal) return errorResponse(404, "Unknown proposal"); - if (proposal.stage !== "vote") { - return errorResponse(409, "Proposal is not in chamber vote stage", { - code: "stage_invalid", - stage: proposal.stage, - }); - } - - const now = getSimNow(context.env); - if (!proposal.votePassedAt || !proposal.voteFinalizesAt) { - return errorResponse(409, "No veto window is open for this proposal", { - code: "veto_not_open", - }); - } - if (now.getTime() >= proposal.voteFinalizesAt.getTime()) { - return errorResponse(409, "Veto window ended", { - code: "veto_window_ended", - finalizesAt: proposal.voteFinalizesAt.toISOString(), - }); - } - - const council = proposal.vetoCouncil ?? []; - const threshold = proposal.vetoThreshold ?? 0; - if (council.length === 0 || threshold <= 0) { - return errorResponse(409, "Veto is not enabled for this proposal", { - code: "veto_disabled", - }); - } - if (!council.includes(sessionAddress)) { - return errorResponse(403, "Not eligible to cast a veto vote", { - code: "not_veto_holder", - }); - } - - const { counts, created } = await castVetoVote(context.env, { - proposalId, - voterAddress: sessionAddress, - choice: input.payload.choice, - }); - - const response = { - ok: true as const, - type: input.type, - proposalId, - choice: input.payload.choice, - counts, - threshold, - }; - - if (idempotencyKey) { - await storeIdempotencyResponse(context.env, { - key: idempotencyKey, - address: session.address, - request: requestForIdem, - response, - }); - } - - await appendProposalTimelineItem(context.env, { - proposalId, - stage: "vote", - actorAddress: sessionAddress, - item: { - id: `timeline:veto-vote:${proposalId}:${sessionAddress}:${randomHex(4)}`, - type: "veto.vote", - title: "Veto vote cast", - detail: input.payload.choice === "veto" ? "Veto" : "Keep", - actor: sessionAddress, - timestamp: now.toISOString(), - }, - }); - - if (counts.veto >= threshold) { - await clearVetoVotesForProposal(context.env, proposalId).catch(() => {}); - await clearChamberVotesForProposal(context.env, proposalId).catch( - () => {}, - ); - - const nextVoteStartsAt = new Date( - now.getTime() + V1_VETO_DELAY_SECONDS_DEFAULT * 1000, - ); - await applyProposalVeto(context.env, { proposalId, nextVoteStartsAt }); - - await appendFeedItemEvent(context.env, { - stage: "vote", - actorAddress: session.address, - entityType: "proposal", - entityId: proposalId, - payload: { - id: `veto-applied:${proposalId}:${Date.now()}`, - title: "Veto applied", - meta: "Veto", - stage: "vote", - summaryPill: "Vetoed", - summary: - "Veto threshold met; chamber vote is reset and voting is paused.", - stats: [ - { label: "Veto votes", value: `${counts.veto} / ${threshold}` }, - ], - ctaPrimary: "Open proposal", - href: `/app/proposals/${proposalId}/chamber`, - timestamp: now.toISOString(), - }, - }); - - await appendProposalTimelineItem(context.env, { - proposalId, - stage: "vote", - actorAddress: null, - item: { - id: `timeline:veto-applied:${proposalId}:${randomHex(4)}`, - type: "veto.applied", - title: "Veto applied", - detail: `Voting resumes at ${nextVoteStartsAt.toISOString()}`, - actor: "system", - timestamp: now.toISOString(), - }, - }); - } - - if (created) { - await incrementEraUserActivity(context.env, { - address: session.address, - delta: { chamberVotes: 1 }, - }).catch(() => {}); - } - - return jsonResponse(response); - } - - if (input.type === "pool.vote") { - const poolEligibilityError = await enforcePoolVoteEligibility( - context.env, - readModels, - { - proposalId: input.payload.proposalId, - voterAddress: sessionAddress, - }, - context.request.url, - ); - if (poolEligibilityError) return poolEligibilityError; - - const proposal = await getProposal(context.env, input.payload.proposalId); - if ( - proposal && - stageWindowsEnabled(context.env) && - proposal.stage === "pool" - ) { - const now = getSimNow(context.env); - const windowSeconds = getStageWindowSeconds(context.env, "pool"); - if ( - !isStageOpen({ - now, - stageStartedAt: proposal.updatedAt, - windowSeconds, - }) - ) { - return errorResponse(409, "Pool window ended", { - code: "stage_closed", - stage: "pool", - endedAt: getStageDeadlineIso({ - stageStartedAt: proposal.updatedAt, - windowSeconds, - }), - timeLeft: (() => { - const remaining = getStageRemainingSeconds({ - now, - stageStartedAt: proposal.updatedAt, - windowSeconds, - }); - return remaining === 0 - ? "Ended" - : formatTimeLeftDaysHours(remaining); - })(), - }); - } - } - - const wouldCount = !(await hasPoolVote(context.env, { - proposalId: input.payload.proposalId, - voterAddress: sessionAddress, - })); - const quotaError = await enforceEraQuota({ - kind: "poolVotes", - wouldCount, - }); - if (quotaError) return quotaError; - - const direction = input.payload.direction === "up" ? 1 : -1; - const { counts, created } = await castPoolVote(context.env, { - proposalId: input.payload.proposalId, - voterAddress: session.address, - direction, - }); - - const response = { - ok: true as const, - type: input.type, - proposalId: input.payload.proposalId, - direction: input.payload.direction, - counts, - }; - - if (idempotencyKey) { - await storeIdempotencyResponse(context.env, { - key: idempotencyKey, - address: session.address, - request: requestForIdem, - response, - }); - } - - await appendFeedItemEvent(context.env, { - stage: "pool", - actorAddress: session.address, - entityType: "proposal", - entityId: input.payload.proposalId, - payload: { - id: `pool-vote:${input.payload.proposalId}:${session.address}:${Date.now()}`, - title: "Pool vote cast", - meta: "Proposal pool · Vote", - stage: "pool", - summaryPill: input.payload.direction === "up" ? "Upvote" : "Downvote", - summary: `Recorded a ${input.payload.direction}vote in the proposal pool.`, - stats: [ - { label: "Upvotes", value: String(counts.upvotes) }, - { label: "Downvotes", value: String(counts.downvotes) }, - ], - ctaPrimary: "Open proposal", - href: `/app/proposals/${input.payload.proposalId}/pp`, - timestamp: new Date().toISOString(), - }, - }); - - await appendProposalTimelineItem(context.env, { - proposalId: input.payload.proposalId, - stage: "pool", - actorAddress: session.address, - item: { - id: `timeline:pool-vote:${input.payload.proposalId}:${session.address}:${randomHex(4)}`, - type: "pool.vote", - title: "Pool vote cast", - detail: input.payload.direction === "up" ? "Upvote" : "Downvote", - actor: session.address, - timestamp: new Date().toISOString(), - }, - }); - - const storedPoolDenominator = await getProposalStageDenominator( - context.env, - { - proposalId: input.payload.proposalId, - stage: "pool", - }, - ).catch(() => null); - const poolChamberId = await getProposalChamberIdForPool( - context.env, - readModels, - { proposalId: input.payload.proposalId }, - ); - const simConfig = await getSimConfig( - context.env, - context.request.url, - ).catch(() => null); - const genesisMembers = getGenesisMembersForDenominators( - simConfig, - poolChamberId, - ); - const poolDenominator = - storedPoolDenominator?.activeGovernors ?? - (await getActiveGovernorsDenominatorForChamberCurrentEra(context.env, { - chamberId: poolChamberId, - fallbackActiveGovernors: - typeof activeGovernorsBaseline === "number" - ? activeGovernorsBaseline - : V1_ACTIVE_GOVERNORS_FALLBACK, - genesisMembers, - })); - if (!storedPoolDenominator) { - await captureProposalStageDenominator(context.env, { - proposalId: input.payload.proposalId, - stage: "pool", - activeGovernors: poolDenominator, - }).catch(() => {}); - } - - const canonicalAdvanced = await maybeAdvancePoolProposalToVoteCanonical( - context.env, - { - proposalId: input.payload.proposalId, - counts, - activeGovernors: poolDenominator, - }, - ); - const readModelAdvanced = - !canonicalAdvanced && - readModels && - (await maybeAdvancePoolProposalToVote(readModels, { - proposalId: input.payload.proposalId, - counts, - activeGovernors: poolDenominator, - })); - const advanced = canonicalAdvanced || Boolean(readModelAdvanced); - - if (advanced) { - const voteDenominator = - await getActiveGovernorsDenominatorForChamberCurrentEra(context.env, { - chamberId: poolChamberId, - fallbackActiveGovernors: - typeof activeGovernorsBaseline === "number" - ? activeGovernorsBaseline - : V1_ACTIVE_GOVERNORS_FALLBACK, - genesisMembers, - }); - await captureProposalStageDenominator(context.env, { - proposalId: input.payload.proposalId, - stage: "vote", - activeGovernors: voteDenominator, - }).catch(() => {}); - - await appendFeedItemEvent(context.env, { - stage: "vote", - actorAddress: session.address, - entityType: "proposal", - entityId: input.payload.proposalId, - payload: { - id: `pool-advance:${input.payload.proposalId}:${Date.now()}`, - title: "Proposal advanced", - meta: "Chamber vote", - stage: "vote", - summaryPill: "Advanced", - summary: "Attention quorum met; proposal moved to chamber vote.", - stats: [ - { label: "Upvotes", value: String(counts.upvotes) }, - { - label: "Engaged", - value: String(counts.upvotes + counts.downvotes), - }, - ], - ctaPrimary: "Open proposal", - href: `/app/proposals/${input.payload.proposalId}/chamber`, - timestamp: new Date().toISOString(), - }, - }); - - await appendProposalTimelineItem(context.env, { - proposalId: input.payload.proposalId, - stage: "vote", - actorAddress: session.address, - item: { - id: `timeline:pool-advance:${input.payload.proposalId}:${randomHex(4)}`, - type: "proposal.stage.advanced", - title: "Advanced to chamber vote", - detail: "Attention quorum met", - actor: "system", - timestamp: new Date().toISOString(), - }, - }); - } - - if (created) { - await incrementEraUserActivity(context.env, { - address: session.address, - delta: { poolVotes: 1 }, - }).catch(() => {}); - } - - return jsonResponse(response); - } - - if (input.type === "formation.join") { - if (!readModels) return errorResponse(500, "Read models store unavailable"); - const formationGate = await requireFormationEnabled(context.env, { - proposalId: input.payload.proposalId, - }); - if (!formationGate.ok) return formationGate.error; - const wouldCount = !(await isFormationTeamMember(context.env, { - proposalId: input.payload.proposalId, - memberAddress: session.address, - })); - const quotaError = await enforceEraQuota({ - kind: "formationActions", - wouldCount, - }); - if (quotaError) return quotaError; - - let summary; - let created = false; - try { - const result = await joinFormationProject(context.env, readModels, { - proposalId: input.payload.proposalId, - memberAddress: session.address, - role: input.payload.role ?? null, - }); - summary = result.summary; - created = result.created; - } catch (error) { - const message = (error as Error).message; - if (message === "team_full") - return errorResponse(409, "Formation team is full"); - return errorResponse(400, "Unable to join formation project", { - code: message, - }); - } - - const response = { - ok: true as const, - type: input.type, - proposalId: input.payload.proposalId, - teamSlots: { filled: summary.teamFilled, total: summary.teamTotal }, - }; - - if (idempotencyKey) { - await storeIdempotencyResponse(context.env, { - key: idempotencyKey, - address: session.address, - request: requestForIdem, - response, - }); - } - - await appendFeedItemEvent(context.env, { - stage: "build", - actorAddress: session.address, - entityType: "proposal", - entityId: input.payload.proposalId, - payload: { - id: `formation-join:${input.payload.proposalId}:${session.address}:${Date.now()}`, - title: "Joined formation project", - meta: "Formation", - stage: "build", - summaryPill: "Joined", - summary: "Joined the formation project team (mock).", - stats: [ - { - label: "Team slots", - value: `${summary.teamFilled} / ${summary.teamTotal}`, - }, - ], - ctaPrimary: "Open proposal", - href: `/app/proposals/${input.payload.proposalId}/formation`, - timestamp: new Date().toISOString(), - }, - }); - - await appendProposalTimelineItem(context.env, { - proposalId: input.payload.proposalId, - stage: "build", - actorAddress: session.address, - item: { - id: `timeline:formation-join:${input.payload.proposalId}:${session.address}:${randomHex(4)}`, - type: "formation.join", - title: "Joined formation project", - detail: input.payload.role - ? `Role: ${input.payload.role}` - : "Joined as contributor", - actor: session.address, - timestamp: new Date().toISOString(), - }, - }); - - if (created) { - await incrementEraUserActivity(context.env, { - address: session.address, - delta: { formationActions: 1 }, - }).catch(() => {}); - } - - return jsonResponse(response); - } - - if (input.type === "formation.milestone.submit") { - if (!readModels) return errorResponse(500, "Read models store unavailable"); - const formationGate = await requireFormationEnabled(context.env, { - proposalId: input.payload.proposalId, - }); - if (!formationGate.ok) return formationGate.error; - const status = await getFormationMilestoneStatus(context.env, readModels, { - proposalId: input.payload.proposalId, - milestoneIndex: input.payload.milestoneIndex, - }).catch(() => null); - const wouldCount = - status !== null && status !== "submitted" && status !== "unlocked"; - const quotaError = await enforceEraQuota({ - kind: "formationActions", - wouldCount, - }); - if (quotaError) return quotaError; - - let summary; - let created = false; - try { - const result = await submitFormationMilestone(context.env, readModels, { - proposalId: input.payload.proposalId, - milestoneIndex: input.payload.milestoneIndex, - actorAddress: session.address, - note: input.payload.note ?? null, - }); - summary = result.summary; - created = result.created; - } catch (error) { - const message = (error as Error).message; - if (message === "milestone_out_of_range") - return errorResponse(400, "Milestone index is out of range"); - if (message === "milestone_already_unlocked") - return errorResponse(409, "Milestone is already unlocked"); - return errorResponse(400, "Unable to submit milestone", { - code: message, - }); - } - - const response = { - ok: true as const, - type: input.type, - proposalId: input.payload.proposalId, - milestoneIndex: input.payload.milestoneIndex, - milestones: { - completed: summary.milestonesCompleted, - total: summary.milestonesTotal, - }, - }; - - if (idempotencyKey) { - await storeIdempotencyResponse(context.env, { - key: idempotencyKey, - address: session.address, - request: requestForIdem, - response, - }); - } - - await appendFeedItemEvent(context.env, { - stage: "build", - actorAddress: session.address, - entityType: "proposal", - entityId: input.payload.proposalId, - payload: { - id: `formation-milestone-submit:${input.payload.proposalId}:${input.payload.milestoneIndex}:${Date.now()}`, - title: "Milestone submitted", - meta: "Formation", - stage: "build", - summaryPill: `M${input.payload.milestoneIndex}`, - summary: "Submitted a milestone deliverable for review (mock).", - stats: [ - { - label: "Milestones", - value: `${summary.milestonesCompleted} / ${summary.milestonesTotal}`, - }, - ], - ctaPrimary: "Open proposal", - href: `/app/proposals/${input.payload.proposalId}/formation`, - timestamp: new Date().toISOString(), - }, - }); - - await appendProposalTimelineItem(context.env, { - proposalId: input.payload.proposalId, - stage: "build", - actorAddress: session.address, - item: { - id: `timeline:formation-milestone-unlock:${input.payload.proposalId}:${input.payload.milestoneIndex}:${randomHex(4)}`, - type: "formation.milestone.unlockRequested", - title: `Unlock requested (M${input.payload.milestoneIndex})`, - detail: "Requested unlock for milestone payout (mock)", - actor: session.address, - timestamp: new Date().toISOString(), - }, - }); - - if (created) { - await incrementEraUserActivity(context.env, { - address: session.address, - delta: { formationActions: 1 }, - }).catch(() => {}); - } - - return jsonResponse(response); - } - - if (input.type === "formation.milestone.requestUnlock") { - if (!readModels) return errorResponse(500, "Read models store unavailable"); - const formationGate = await requireFormationEnabled(context.env, { - proposalId: input.payload.proposalId, - }); - if (!formationGate.ok) return formationGate.error; - const quotaError = await enforceEraQuota({ - kind: "formationActions", - wouldCount: true, - }); - if (quotaError) return quotaError; - - let summary; - let created = false; - try { - const result = await requestFormationMilestoneUnlock( - context.env, - readModels, - { - proposalId: input.payload.proposalId, - milestoneIndex: input.payload.milestoneIndex, - actorAddress: session.address, - }, - ); - summary = result.summary; - created = result.created; - } catch (error) { - const message = (error as Error).message; - if (message === "milestone_out_of_range") - return errorResponse(400, "Milestone index is out of range"); - if (message === "milestone_not_submitted") - return errorResponse(409, "Milestone must be submitted first"); - if (message === "milestone_already_unlocked") - return errorResponse(409, "Milestone is already unlocked"); - return errorResponse(400, "Unable to request unlock", { code: message }); - } - - const response = { - ok: true as const, - type: input.type, - proposalId: input.payload.proposalId, - milestoneIndex: input.payload.milestoneIndex, - milestones: { - completed: summary.milestonesCompleted, - total: summary.milestonesTotal, - }, - }; - - if (idempotencyKey) { - await storeIdempotencyResponse(context.env, { - key: idempotencyKey, - address: session.address, - request: requestForIdem, - response, - }); - } - - await appendFeedItemEvent(context.env, { - stage: "build", - actorAddress: session.address, - entityType: "proposal", - entityId: input.payload.proposalId, - payload: { - id: `formation-milestone-unlock:${input.payload.proposalId}:${input.payload.milestoneIndex}:${Date.now()}`, - title: "Milestone unlocked", - meta: "Formation", - stage: "build", - summaryPill: `M${input.payload.milestoneIndex}`, - summary: "Milestone marked as unlocked (mock).", - stats: [ - { - label: "Milestones", - value: `${summary.milestonesCompleted} / ${summary.milestonesTotal}`, - }, - ], - ctaPrimary: "Open proposal", - href: `/app/proposals/${input.payload.proposalId}/formation`, - timestamp: new Date().toISOString(), - }, - }); - - if (created) { - await incrementEraUserActivity(context.env, { - address: session.address, - delta: { formationActions: 1 }, - }).catch(() => {}); - } - - return jsonResponse(response); - } - - if (input.type === "court.case.report") { - if (!readModels) return errorResponse(500, "Read models store unavailable"); - const wouldCount = !(await hasCourtReport(context.env, { - caseId: input.payload.caseId, - reporterAddress: session.address, - })); - const quotaError = await enforceEraQuota({ - kind: "courtActions", - wouldCount, - }); - if (quotaError) return quotaError; - - let overlay; - let created = false; - try { - const result = await reportCourtCase(context.env, readModels, { - caseId: input.payload.caseId, - reporterAddress: session.address, - }); - overlay = result.overlay; - created = result.created; - } catch (error) { - const code = (error as Error).message; - if (code === "court_case_missing") - return errorResponse(404, "Unknown case"); - return errorResponse(400, "Unable to report case", { code }); - } - - const response = { - ok: true as const, - type: input.type, - caseId: input.payload.caseId, - reports: overlay.reports, - status: overlay.status, - }; - - if (idempotencyKey) { - await storeIdempotencyResponse(context.env, { - key: idempotencyKey, - address: session.address, - request: requestForIdem, - response, - }); - } - - await appendFeedItemEvent(context.env, { - stage: "courts", - actorAddress: session.address, - entityType: "court_case", - entityId: input.payload.caseId, - payload: { - id: `court-report:${input.payload.caseId}:${session.address}:${Date.now()}`, - title: "Court case reported", - meta: "Courts", - stage: "courts", - summaryPill: "Report", - summary: "Filed a report for a court case (mock).", - stats: [{ label: "Reports", value: String(overlay.reports) }], - ctaPrimary: "Open courtroom", - href: `/app/courts/${input.payload.caseId}`, - timestamp: new Date().toISOString(), - }, - }); - - if (created) { - await incrementEraUserActivity(context.env, { - address: session.address, - delta: { courtActions: 1 }, - }).catch(() => {}); - } - - return jsonResponse(response); - } - - if (input.type === "court.case.verdict") { - if (!readModels) return errorResponse(500, "Read models store unavailable"); - const wouldCount = !(await hasCourtVerdict(context.env, { - caseId: input.payload.caseId, - voterAddress: session.address, - })); - const quotaError = await enforceEraQuota({ - kind: "courtActions", - wouldCount, - }); - if (quotaError) return quotaError; - - let overlay; - let created = false; - try { - const result = await castCourtVerdict(context.env, readModels, { - caseId: input.payload.caseId, - voterAddress: session.address, - verdict: input.payload.verdict, - }); - overlay = result.overlay; - created = result.created; - } catch (error) { - const code = (error as Error).message; - if (code === "court_case_missing") - return errorResponse(404, "Unknown case"); - if (code === "case_not_live") - return errorResponse(409, "Case is not live"); - return errorResponse(400, "Unable to cast verdict", { code }); - } - - const response = { - ok: true as const, - type: input.type, - caseId: input.payload.caseId, - verdict: input.payload.verdict, - status: overlay.status, - totals: { - guilty: overlay.verdicts.guilty, - notGuilty: overlay.verdicts.notGuilty, - }, - }; - - if (idempotencyKey) { - await storeIdempotencyResponse(context.env, { - key: idempotencyKey, - address: session.address, - request: requestForIdem, - response, - }); - } - - await appendFeedItemEvent(context.env, { - stage: "courts", - actorAddress: session.address, - entityType: "court_case", - entityId: input.payload.caseId, - payload: { - id: `court-verdict:${input.payload.caseId}:${session.address}:${Date.now()}`, - title: "Verdict cast", - meta: "Courtroom", - stage: "courts", - summaryPill: - input.payload.verdict === "guilty" ? "Guilty" : "Not guilty", - summary: "Cast a verdict in a courtroom session (mock).", - stats: [ - { label: "Guilty", value: String(overlay.verdicts.guilty) }, - { label: "Not guilty", value: String(overlay.verdicts.notGuilty) }, - ], - ctaPrimary: "Open courtroom", - href: `/app/courts/${input.payload.caseId}`, - timestamp: new Date().toISOString(), - }, - }); - - if (created) { - await incrementEraUserActivity(context.env, { - address: session.address, - delta: { courtActions: 1 }, - }).catch(() => {}); - } - - return jsonResponse(response); - } - - if (input.type !== "chamber.vote") { - return errorResponse(400, "Unsupported command"); - } - - const proposal = await getProposal(context.env, input.payload.proposalId); - if (proposal && proposal.stage !== "vote") { - return errorResponse(409, "Proposal is not in chamber vote stage", { - code: "stage_invalid", - stage: proposal.stage, - }); - } - - if (proposal && proposal.stage === "vote") { - const now = getSimNow(context.env); - if (proposal.votePassedAt && proposal.voteFinalizesAt) { - if (now.getTime() < proposal.voteFinalizesAt.getTime()) { - return errorResponse(409, "Vote already passed (pending veto)", { - code: "vote_pending_veto", - finalizesAt: proposal.voteFinalizesAt.toISOString(), - }); - } - } - if (now.getTime() < proposal.updatedAt.getTime()) { - return errorResponse(409, "Voting is paused", { - code: "vote_paused", - resumesAt: proposal.updatedAt.toISOString(), - }); - } - } - - if ( - proposal && - stageWindowsEnabled(context.env) && - proposal.stage === "vote" - ) { - const now = getSimNow(context.env); - const windowSeconds = getStageWindowSeconds(context.env, "vote"); - if ( - !isStageOpen({ - now, - stageStartedAt: proposal.updatedAt, - windowSeconds, - }) - ) { - return errorResponse(409, "Voting window ended", { - code: "stage_closed", - stage: "vote", - endedAt: getStageDeadlineIso({ - stageStartedAt: proposal.updatedAt, - windowSeconds, - }), - timeLeft: (() => { - const remaining = getStageRemainingSeconds({ - now, - stageStartedAt: proposal.updatedAt, - windowSeconds, - }); - return remaining === 0 ? "Ended" : formatTimeLeftDaysHours(remaining); - })(), - }); - } - } - - if (proposal) { - const chamberId = (proposal.chamberId ?? "general").toLowerCase(); - if (chamberId !== "general") { - const chamber = await getChamber( - context.env, - context.request.url, - chamberId, - ); - if (chamber?.status === "dissolved" && chamber.dissolvedAt) { - const proposalCreatedAt = proposal.createdAt.getTime(); - const dissolvedAt = chamber.dissolvedAt.getTime(); - if (proposalCreatedAt > dissolvedAt) { - return errorResponse(409, "Chamber is dissolved", { - code: "chamber_dissolved", - chamberId, - dissolvedAt: chamber.dissolvedAt.toISOString(), - }); - } - } - } - } - - const eligibilityError = await enforceChamberVoteEligibility( - context.env, - readModels, - { - proposalId: input.payload.proposalId, - voterAddress: session.address, - }, - context.request.url, - ); - if (eligibilityError) return eligibilityError; - - if (input.payload.choice !== "yes" && input.payload.score !== undefined) { - return errorResponse(400, "Score is only allowed for yes votes"); - } - - const wouldCount = !(await hasChamberVote(context.env, { - proposalId: input.payload.proposalId, - voterAddress: session.address, - })); - const quotaError = await enforceEraQuota({ - kind: "chamberVotes", - wouldCount, - }); - if (quotaError) return quotaError; - - const chamberIdForVote = await getProposalChamberIdForVote( - context.env, - readModels, - { proposalId: input.payload.proposalId }, - ); - const choice = - input.payload.choice === "yes" ? 1 : input.payload.choice === "no" ? -1 : 0; - const { counts, created } = await castChamberVote(context.env, { - proposalId: input.payload.proposalId, - voterAddress: session.address, - choice, - score: - input.payload.choice === "yes" ? (input.payload.score ?? null) : null, - chamberId: chamberIdForVote, - }); - - const response = { - ok: true as const, - type: input.type, - proposalId: input.payload.proposalId, - choice: input.payload.choice, - counts, - }; - - if (idempotencyKey) { - await storeIdempotencyResponse(context.env, { - key: idempotencyKey, - address: session.address, - request: requestForIdem, - response, - }); - } - - await appendFeedItemEvent(context.env, { - stage: "vote", - actorAddress: session.address, - entityType: "proposal", - entityId: input.payload.proposalId, - payload: { - id: `chamber-vote:${input.payload.proposalId}:${session.address}:${Date.now()}`, - title: "Chamber vote cast", - meta: "Chamber vote", - stage: "vote", - summaryPill: - input.payload.choice === "yes" - ? "Yes" - : input.payload.choice === "no" - ? "No" - : "Abstain", - summary: "Recorded a vote in chamber stage.", - stats: [ - { label: "Yes", value: String(counts.yes) }, - { label: "No", value: String(counts.no) }, - { label: "Abstain", value: String(counts.abstain) }, - ], - ctaPrimary: "Open proposal", - href: `/app/proposals/${input.payload.proposalId}/chamber`, - timestamp: new Date().toISOString(), - }, - }); - - await appendProposalTimelineItem(context.env, { - proposalId: input.payload.proposalId, - stage: "vote", - actorAddress: session.address, - item: { - id: `timeline:chamber-vote:${input.payload.proposalId}:${session.address}:${randomHex(4)}`, - type: "chamber.vote", - title: "Chamber vote cast", - detail: - input.payload.choice === "yes" - ? `Yes${input.payload.score ? ` (score ${input.payload.score})` : ""}` - : input.payload.choice === "no" - ? "No" - : "Abstain", - actor: session.address, - timestamp: new Date().toISOString(), - }, - }); - - const storedVoteDenominator = await getProposalStageDenominator(context.env, { - proposalId: input.payload.proposalId, - stage: "vote", - }).catch(() => null); - const simConfig = await getSimConfig(context.env, context.request.url).catch( - () => null, - ); - const genesisMembers = getGenesisMembersForDenominators( - simConfig, - chamberIdForVote, - ); - const voteDenominator = - storedVoteDenominator?.activeGovernors ?? - (await getActiveGovernorsDenominatorForChamberCurrentEra(context.env, { - chamberId: chamberIdForVote, - fallbackActiveGovernors: - typeof activeGovernorsBaseline === "number" - ? activeGovernorsBaseline - : V1_ACTIVE_GOVERNORS_FALLBACK, - genesisMembers, - })); - if (!storedVoteDenominator) { - await captureProposalStageDenominator(context.env, { - proposalId: input.payload.proposalId, - stage: "vote", - activeGovernors: voteDenominator, - }).catch(() => {}); - } - - const canonicalOutcome = await maybeAdvanceVoteProposalToBuildCanonical( - context.env, - { - proposalId: input.payload.proposalId, - counts, - activeGovernors: voteDenominator, - }, - context.request.url, - ); - - const readModelAdvanced = - canonicalOutcome.status === "none" && - readModels && - (await maybeAdvanceVoteProposalToBuild(context.env, readModels, { - proposalId: input.payload.proposalId, - counts, - activeGovernors: voteDenominator, - requestUrl: context.request.url, - })); - - const advanced = - canonicalOutcome.status === "advanced" || Boolean(readModelAdvanced); - - if (canonicalOutcome.status === "pending_veto") { - await appendFeedItemEvent(context.env, { - stage: "vote", - actorAddress: session.address, - entityType: "proposal", - entityId: input.payload.proposalId, - payload: { - id: `vote-pass-pending-veto:${input.payload.proposalId}:${Date.now()}`, - title: "Proposal passed (pending veto)", - meta: "Chamber vote", - stage: "vote", - summaryPill: "Passed", - summary: - "Chamber vote passed; the proposal is in the veto window before acceptance is finalized.", - stats: [ - { label: "Yes", value: String(counts.yes) }, - { - label: "Engaged", - value: String(counts.yes + counts.no + counts.abstain), - }, - { - label: "Veto", - value: `${canonicalOutcome.vetoCouncilSize} holders · ${canonicalOutcome.vetoThreshold} needed`, - }, - ], - ctaPrimary: "Open proposal", - href: `/app/proposals/${input.payload.proposalId}/chamber`, - timestamp: new Date().toISOString(), - }, - }); - - await appendProposalTimelineItem(context.env, { - proposalId: input.payload.proposalId, - stage: "vote", - actorAddress: session.address, - item: { - id: `timeline:vote-pass-pending-veto:${input.payload.proposalId}:${randomHex(4)}`, - type: "proposal.vote.passed", - title: "Chamber vote passed", - detail: `Pending veto until ${canonicalOutcome.finalizesAt}`, - actor: "system", - timestamp: new Date().toISOString(), - }, - }); - } - - if (advanced) { - const avgScore = - canonicalOutcome.status === "advanced" - ? canonicalOutcome.avgScore - : ((await getChamberYesScoreAverage( - context.env, - input.payload.proposalId, - )) ?? null); - const formationEligible = - canonicalOutcome.status === "advanced" - ? canonicalOutcome.formationEligible - : await (async () => { - if (!readModels) return true; - const chamberPayload = await readModels.get( - `proposals:${input.payload.proposalId}:chamber`, - ); - if (isRecord(chamberPayload)) { - const meta = parseChamberGovernanceFromPayload(chamberPayload); - if (meta) return false; - if (typeof chamberPayload.formationEligible === "boolean") { - return chamberPayload.formationEligible; - } - } - const poolPayload = await readModels.get( - `proposals:${input.payload.proposalId}:pool`, - ); - if (isRecord(poolPayload)) { - const meta = parseChamberGovernanceFromPayload(poolPayload); - if (meta) return false; - if (typeof poolPayload.formationEligible === "boolean") { - return poolPayload.formationEligible; - } - } - return true; - })(); - - await appendFeedItemEvent(context.env, { - stage: "build", - actorAddress: session.address, - entityType: "proposal", - entityId: input.payload.proposalId, - payload: { - id: `vote-pass:${input.payload.proposalId}:${Date.now()}`, - title: "Proposal accepted", - meta: "Chamber vote", - stage: "build", - summaryPill: "Accepted", - summary: "Chamber vote finalized; proposal is now accepted.", - stats: [ - ...(avgScore !== null - ? [{ label: "Avg CM", value: avgScore.toFixed(1) }] - : []), - { label: "Yes", value: String(counts.yes) }, - { - label: "Engaged", - value: String(counts.yes + counts.no + counts.abstain), - }, - ], - ctaPrimary: "Open proposal", - href: formationEligible - ? `/app/proposals/${input.payload.proposalId}/formation` - : `/app/proposals/${input.payload.proposalId}/chamber`, - timestamp: new Date().toISOString(), - }, - }); - - await appendProposalTimelineItem(context.env, { - proposalId: input.payload.proposalId, - stage: "build", - actorAddress: session.address, - item: { - id: `timeline:vote-pass:${input.payload.proposalId}:${randomHex(4)}`, - type: "proposal.stage.advanced", - title: "Advanced to accepted", - detail: "Chamber vote finalized", - actor: "system", - timestamp: new Date().toISOString(), - }, - }); - } - - if (created) { - await incrementEraUserActivity(context.env, { - address: session.address, - delta: { chamberVotes: 1 }, - }).catch(() => {}); - } - - return jsonResponse(response); -}; - -function isRecord(value: unknown): value is Record { - return Boolean(value) && typeof value === "object" && !Array.isArray(value); -} - -async function getProposalStage( - store: Awaited>, - proposalId: string, -): Promise { - const listPayload = await store.get("proposals:list"); - if (!isRecord(listPayload)) return null; - const items = listPayload.items; - if (!Array.isArray(items)) return null; - const item = items.find( - (entry) => isRecord(entry) && entry.id === proposalId, - ); - if (!item || !isRecord(item)) return null; - return typeof item.stage === "string" ? item.stage : null; -} - -async function maybeAdvancePoolProposalToVote( - store: Awaited>, - input: { - proposalId: string; - counts: { upvotes: number; downvotes: number }; - activeGovernors: number; - }, -): Promise { - if (!store.set) return false; - - const poolPayload = await store.get(`proposals:${input.proposalId}:pool`); - if (!isRecord(poolPayload)) return false; - const attentionQuorum = poolPayload.attentionQuorum; - const activeGovernors = input.activeGovernors; - const upvoteFloor = computePoolUpvoteFloor(activeGovernors); - if ( - typeof attentionQuorum !== "number" || - typeof activeGovernors !== "number" || - typeof upvoteFloor !== "number" - ) { - return false; - } - - const quorum = evaluatePoolQuorum( - { attentionQuorum, activeGovernors, upvoteFloor }, - input.counts, - ); - if (!quorum.shouldAdvance) return false; - - const listPayload = await store.get("proposals:list"); - if (!isRecord(listPayload)) return false; - const items = listPayload.items; - if (!Array.isArray(items)) return false; - - const chamberPayload = await ensureChamberProposalPage( - store, - input.proposalId, - poolPayload, - { - activeGovernors, - }, - ); - const voteStageData = buildVoteStageData(chamberPayload); - - let changed = false; - const nextItems = items.map((item) => { - if (!isRecord(item) || item.id !== input.proposalId) return item; - if (item.stage !== "pool") return item; - changed = true; - return { - ...item, - stage: "vote", - summaryPill: "Chamber vote", - stageData: voteStageData ?? item.stageData, - }; - }); - if (!changed) return false; - - await store.set("proposals:list", { ...listPayload, items: nextItems }); - return true; -} - -async function maybeAdvancePoolProposalToVoteCanonical( - env: Record, - input: { - proposalId: string; - counts: { upvotes: number; downvotes: number }; - activeGovernors: number; - }, -): Promise { - const proposal = await getProposal(env, input.proposalId); - if (!proposal) return false; - if (proposal.stage !== "pool") return false; - - const shouldAdvance = shouldAdvancePoolToVote({ - activeGovernors: input.activeGovernors, - counts: input.counts, - }); - if (!shouldAdvance) return false; - - return transitionProposalStage(env, { - proposalId: input.proposalId, - from: "pool", - to: "vote", - }); -} - -async function ensureChamberProposalPage( - store: Awaited>, - proposalId: string, - poolPayload: Record, - input: { activeGovernors: number }, -): Promise { - const existing = await store.get(`proposals:${proposalId}:chamber`); - if (existing) return existing; - if (!store.set) return existing; - - const generated = buildChamberProposalPageFromPool(poolPayload); - (generated as Record).activeGovernors = - input.activeGovernors; - await store.set(`proposals:${proposalId}:chamber`, generated); - return generated; -} - -function asString(value: unknown, fallback = ""): string { - return typeof value === "string" ? value : fallback; -} - -function asNumber(value: unknown, fallback = 0): number { - const n = typeof value === "number" ? value : Number(value); - return Number.isFinite(n) ? n : fallback; -} - -function asBoolean(value: unknown, fallback = false): boolean { - return typeof value === "boolean" ? value : fallback; -} - -function asArray(value: unknown): T[] { - return Array.isArray(value) ? (value as T[]) : []; -} - -function buildChamberProposalPageFromPool( - poolPayload: Record, -): Record { - const activeGovernors = asNumber(poolPayload.activeGovernors, 0); - return { - title: asString(poolPayload.title, "Proposal"), - proposer: asString(poolPayload.proposer, "Unknown"), - proposerId: asString(poolPayload.proposerId, "unknown"), - chamber: asString(poolPayload.chamber, "General chamber"), - budget: asString(poolPayload.budget, "—"), - formationEligible: asBoolean(poolPayload.formationEligible, false), - templateId: poolPayload.templateId, - metaGovernance: poolPayload.metaGovernance, - teamSlots: asString(poolPayload.teamSlots, "—"), - milestones: asString(poolPayload.milestones, "—"), - timeLeft: "3d 00h", - votes: { yes: 0, no: 0, abstain: 0 }, - attentionQuorum: V1_CHAMBER_QUORUM_FRACTION, - passingRule: `≥${(V1_CHAMBER_PASSING_FRACTION * 100).toFixed(1)}% + 1 yes within quorum`, - engagedGovernors: 0, - activeGovernors, - attachments: asArray(poolPayload.attachments), - teamLocked: asArray(poolPayload.teamLocked), - openSlotNeeds: asArray(poolPayload.openSlotNeeds), - milestonesDetail: asArray(poolPayload.milestonesDetail), - summary: asString(poolPayload.summary, ""), - overview: asString(poolPayload.overview, ""), - executionPlan: asArray(poolPayload.executionPlan), - budgetScope: asString(poolPayload.budgetScope, ""), - invisionInsight: isRecord(poolPayload.invisionInsight) - ? poolPayload.invisionInsight - : { role: "—", bullets: [] }, - }; -} - -function buildVoteStageData(payload: unknown): Array<{ - title: string; - description: string; - value: string; - tone?: "ok" | "warn"; -}> | null { - if (!isRecord(payload)) return null; - const attentionQuorum = payload.attentionQuorum; - const activeGovernors = payload.activeGovernors; - const engagedGovernors = payload.engagedGovernors; - const passingRule = payload.passingRule; - const timeLeft = payload.timeLeft; - const votes = payload.votes; - if ( - typeof attentionQuorum !== "number" || - typeof activeGovernors !== "number" || - typeof engagedGovernors !== "number" || - typeof passingRule !== "string" || - typeof timeLeft !== "string" || - !isRecord(votes) - ) { - return null; - } - - const yes = Number(votes.yes ?? 0); - const no = Number(votes.no ?? 0); - const abstain = Number(votes.abstain ?? 0); - const total = Math.max(0, yes) + Math.max(0, no) + Math.max(0, abstain); - const yesPct = total > 0 ? (yes / total) * 100 : 0; - - const quorumNeeded = Math.ceil( - Math.max(0, activeGovernors) * attentionQuorum, - ); - const quorumPct = - activeGovernors > 0 ? (engagedGovernors / activeGovernors) * 100 : 0; - const quorumMet = engagedGovernors >= quorumNeeded; - - return [ - { - title: "Voting quorum", - description: `Strict ${Math.round(attentionQuorum * 100)}% active governors`, - value: `${quorumMet ? "Met" : "Needs"} · ${Math.round(quorumPct)}%`, - tone: quorumMet ? "ok" : "warn", - }, - { - title: "Passing rule", - description: passingRule, - value: `Current ${Math.round(yesPct)}%`, - tone: yesPct >= V1_CHAMBER_PASSING_FRACTION * 100 ? "ok" : "warn", - }, - { title: "Time left", description: "Voting window", value: timeLeft }, - ]; -} - -async function upsertChamberReadModel( - store: Awaited>, - input: { - action: "create" | "dissolve"; - id: string; - title?: string; - multiplier?: number; - }, -): Promise { - if (!store.set) return; - const listPayload = await store.get("chambers:list"); - const existing = - isRecord(listPayload) && Array.isArray(listPayload.items) - ? listPayload.items - : []; - - const normalizedId = input.id.trim().toLowerCase(); - const nextItems = existing.filter( - (item) => !isRecord(item) || String(item.id).toLowerCase() !== normalizedId, - ); - - if (input.action === "create") { - const multiplier = - typeof input.multiplier === "number" && Number.isFinite(input.multiplier) - ? input.multiplier - : 1; - nextItems.push({ - id: normalizedId, - name: input.title?.trim() || normalizedId, - multiplier, - stats: { governors: "0", acm: "0", mcm: "0", lcm: "0" }, - pipeline: { pool: 0, vote: 0, build: 0 }, - status: "active", - }); - - await store.set(`chambers:${normalizedId}`, { - proposals: [], - governors: [], - threads: [], - chatLog: [], - stageOptions: [ - { value: "upcoming", label: "Upcoming" }, - { value: "live", label: "Live" }, - { value: "ended", label: "Ended" }, - ], - }); - } - - await store.set("chambers:list", { - ...(isRecord(listPayload) ? listPayload : {}), - items: nextItems, - }); -} - -async function maybeAdvanceVoteProposalToBuild( - env: Record, - store: Awaited>, - input: { - proposalId: string; - counts: { yes: number; no: number; abstain: number }; - activeGovernors: number; - requestUrl: string; - }, -): Promise { - if (!store.set) return false; - - const chamberPayload = await store.get( - `proposals:${input.proposalId}:chamber`, - ); - if (!isRecord(chamberPayload)) return false; - - const attentionQuorum = chamberPayload.attentionQuorum; - const activeGovernors = input.activeGovernors; - let meta = parseChamberGovernanceFromPayload(chamberPayload); - let poolPayload: Record | null = null; - if (!meta) { - const candidate = await store.get(`proposals:${input.proposalId}:pool`); - if (isRecord(candidate)) { - poolPayload = candidate; - meta = parseChamberGovernanceFromPayload(candidate); - } - } - const formationEligible = meta - ? false - : typeof chamberPayload.formationEligible === "boolean" - ? chamberPayload.formationEligible - : poolPayload && typeof poolPayload.formationEligible === "boolean" - ? poolPayload.formationEligible - : true; - if ( - typeof attentionQuorum !== "number" || - typeof activeGovernors !== "number" || - typeof formationEligible !== "boolean" - ) { - return false; - } - - const minQuorum = - env.SIM_ACTIVE_GOVERNORS || env.VORTEX_ACTIVE_GOVERNORS - ? undefined - : activeGovernors > 1 - ? 2 - : undefined; - - const quorum = evaluateChamberQuorum( - { - quorumFraction: attentionQuorum, - activeGovernors, - passingFraction: V1_CHAMBER_PASSING_FRACTION, - minQuorum, - }, - input.counts, - ); - if (!quorum.shouldAdvance) return false; - - const listPayload = await store.get("proposals:list"); - if (!isRecord(listPayload)) return false; - const items = listPayload.items; - if (!Array.isArray(items)) return false; - - if (meta?.action === "chamber.create" && meta.title && meta.id) { - await createChamberFromAcceptedGeneralProposal(env, input.requestUrl, { - id: meta.id, - title: meta.title, - multiplier: meta.multiplier, - proposalId: input.proposalId, - }); - - await upsertChamberReadModel(store, { - action: "create", - id: meta.id, - title: meta.title, - multiplier: meta.multiplier, - }); - - const genesisMembers = (() => { - const source = - (chamberPayload.metaGovernance as { genesisMembers?: unknown }) ?? - (poolPayload?.metaGovernance as { genesisMembers?: unknown }); - const raw = source?.genesisMembers; - if (!Array.isArray(raw)) return []; - return raw - .filter((v): v is string => typeof v === "string") - .map((v) => v.trim()) - .filter(Boolean); - })(); - - const proposerId = asString(chamberPayload.proposerId, "").trim(); - const memberSet = new Set(genesisMembers); - if (proposerId) memberSet.add(proposerId); - for (const address of memberSet) { - await ensureChamberMembership(env, { - address, - chamberId: meta.id, - grantedByProposalId: input.proposalId, - source: "chamber_genesis", - }); - } - } - - if (meta?.action === "chamber.dissolve" && meta.id) { - await dissolveChamberFromAcceptedGeneralProposal(env, input.requestUrl, { - id: meta.id, - proposalId: input.proposalId, - }); - - await upsertChamberReadModel(store, { - action: "dissolve", - id: meta.id, - }); - } - - if (formationEligible) { - await ensureFormationProposalPage(store, input.proposalId, chamberPayload); - await ensureFormationSeed(env, store, input.proposalId); - } - - let changed = false; - const nextItems = items.map((item) => { - if (!isRecord(item) || item.id !== input.proposalId) return item; - if (item.stage !== "vote") return item; - changed = true; - return { - ...item, - stage: "build", - summaryPill: formationEligible ? "Formation" : "Passed", - }; - }); - if (!changed) return false; - - await store.set("proposals:list", { ...listPayload, items: nextItems }); - - const proposerId = asString(chamberPayload.proposerId, ""); - const chamberLabel = asString(chamberPayload.chamber, ""); - const chamberId = normalizeChamberId(chamberLabel); - const multiplierTimes10 = await getChamberMultiplierTimes10(store, chamberId); - const avgScore = - (await getChamberYesScoreAverage(env, input.proposalId)) ?? null; - - if (proposerId && avgScore !== null) { - const lcmPoints = Math.round(avgScore * 10); - const mcmPoints = Math.round((lcmPoints * multiplierTimes10) / 10); - await awardCmOnce(env, { - proposalId: input.proposalId, - proposerId, - chamberId, - avgScore, - lcmPoints, - chamberMultiplierTimes10: multiplierTimes10, - mcmPoints, - }); - } - - return true; -} - -async function maybeAdvanceVoteProposalToBuildCanonical( - env: Record, - input: { - proposalId: string; - counts: { yes: number; no: number; abstain: number }; - activeGovernors: number; - }, - requestUrl: string, -): Promise< - | { status: "none" } - | { - status: "pending_veto"; - finalizesAt: string; - vetoCouncilSize: number; - vetoThreshold: number; - } - | { status: "advanced"; formationEligible: boolean; avgScore: number | null } -> { - const proposal = await getProposal(env, input.proposalId); - if (!proposal) return { status: "none" }; - if (proposal.stage !== "vote") return { status: "none" }; - if (proposal.votePassedAt && proposal.voteFinalizesAt) { - const now = getSimNow(env); - if (now.getTime() < proposal.voteFinalizesAt.getTime()) { - return { status: "none" }; - } - } - - const minQuorum = - env.SIM_ACTIVE_GOVERNORS || env.VORTEX_ACTIVE_GOVERNORS - ? undefined - : input.activeGovernors > 1 - ? 2 - : undefined; - - const shouldAdvance = shouldAdvanceVoteToBuild({ - activeGovernors: input.activeGovernors, - counts: input.counts, - minQuorum, - }); - if (!shouldAdvance) return { status: "none" }; - - const vetoCount = proposal.vetoCount ?? 0; - if (vetoCount < V1_VETO_MAX_APPLIES) { - const snapshot = await computeVetoCouncilSnapshot(env, requestUrl); - if (snapshot.members.length > 0 && snapshot.threshold > 0) { - const now = getSimNow(env); - const finalizesAt = new Date( - now.getTime() + V1_VETO_DELAY_SECONDS_DEFAULT * 1000, - ); - await clearVetoVotesForProposal(env, proposal.id).catch(() => {}); - await setProposalVotePendingVeto(env, { - proposalId: proposal.id, - passedAt: now, - finalizesAt, - vetoCouncil: snapshot.members, - vetoThreshold: snapshot.threshold, - }); - return { - status: "pending_veto", - finalizesAt: finalizesAt.toISOString(), - vetoCouncilSize: snapshot.members.length, - vetoThreshold: snapshot.threshold, - }; - } - } - - const finalized = await finalizeAcceptedProposalFromVote(env, { - proposalId: proposal.id, - requestUrl, - }); - if (!finalized.ok) return { status: "none" }; - - return { - status: "advanced", - formationEligible: finalized.formationEligible, - avgScore: finalized.avgScore, - }; -} - -function getFormationEligibleFromProposalPayload(payload: unknown): boolean { - if (!isRecord(payload)) return true; - if (payload.templateId === "system") return false; - if ( - typeof payload.metaGovernance === "object" && - payload.metaGovernance !== null && - !Array.isArray(payload.metaGovernance) - ) - return false; - if (typeof payload.formationEligible === "boolean") - return payload.formationEligible; - if (typeof payload.formation === "boolean") return payload.formation; - return true; -} - -async function requireFormationEnabled( - env: Record, - input: { proposalId: string }, -): Promise< - | { ok: true } - | { - ok: false; - error: Response; - } -> { - const proposal = await getProposal(env, input.proposalId); - if (!proposal) return { ok: true }; - if (proposal.stage !== "build") { - return { - ok: false, - error: errorResponse(409, "Proposal is not in formation stage", { - code: "stage_invalid", - stage: proposal.stage, - }), - }; - } - if (!getFormationEligibleFromProposalPayload(proposal.payload)) { - return { - ok: false, - error: errorResponse(409, "Formation is not required for this proposal", { - code: "formation_not_required", - }), - }; - } - return { ok: true }; -} - -async function ensureFormationProposalPage( - store: Awaited>, - proposalId: string, - chamberPayload: Record, -): Promise { - const existing = await store.get(`proposals:${proposalId}:formation`); - if (existing) return; - if (!store.set) return; - await store.set( - `proposals:${proposalId}:formation`, - buildFormationProposalPageFromChamber(chamberPayload), - ); -} - -function buildFormationProposalPageFromChamber( - chamberPayload: Record, -): Record { - return { - title: asString(chamberPayload.title, "Proposal"), - chamber: asString(chamberPayload.chamber, "General chamber"), - proposer: asString(chamberPayload.proposer, "Unknown"), - proposerId: asString(chamberPayload.proposerId, "unknown"), - budget: asString(chamberPayload.budget, "—"), - timeLeft: "12w", - teamSlots: asString(chamberPayload.teamSlots, "0 / 0"), - milestones: asString(chamberPayload.milestones, "0 / 0"), - progress: "0%", - stageData: [ - { title: "Budget allocated", description: "HMND", value: "0 / —" }, - { title: "Team slots", description: "Filled / Total", value: "0 / —" }, - { title: "Milestones", description: "Completed / Total", value: "0 / —" }, - ], - stats: [{ label: "Lead chamber", value: asString(chamberPayload.chamber) }], - lockedTeam: asArray(chamberPayload.teamLocked), - openSlots: asArray(chamberPayload.openSlotNeeds), - milestonesDetail: asArray(chamberPayload.milestonesDetail), - attachments: asArray(chamberPayload.attachments), - summary: asString(chamberPayload.summary, ""), - overview: asString(chamberPayload.overview, ""), - executionPlan: asArray(chamberPayload.executionPlan), - budgetScope: asString(chamberPayload.budgetScope, ""), - invisionInsight: isRecord(chamberPayload.invisionInsight) - ? chamberPayload.invisionInsight - : { role: "—", bullets: [] }, - }; -} - -function normalizeChamberId(chamberLabel: string): string { - const match = chamberLabel.trim().match(/^([A-Za-z]+)/); - return (match?.[1] ?? chamberLabel).toLowerCase(); -} - -async function enforceChamberVoteEligibility( - env: Record, - readModels: Awaited> | null, - input: { proposalId: string; voterAddress: string }, - requestUrl: string, -): Promise { - if (envBoolean(env, "DEV_BYPASS_CHAMBER_ELIGIBILITY")) return null; - - const simConfig = await getSimConfig(env, requestUrl); - const genesis = simConfig?.genesisChamberMembers; - - const chamberId = await getProposalChamberIdForVote(env, readModels, { - proposalId: input.proposalId, - }); - const voterAddress = input.voterAddress.trim(); - - const hasGenesisMembership = async ( - targetChamberId: string, - ): Promise => { - if (!genesis) return false; - const members = genesis[targetChamberId.toLowerCase()] ?? []; - for (const member of members) { - if (await addressesReferToSameKey(member, voterAddress)) return true; - } - return false; - }; - const hasAnyGenesisMembership = async (): Promise => { - if (!genesis) return false; - for (const members of Object.values(genesis)) { - for (const member of members) { - if (await addressesReferToSameKey(member, voterAddress)) return true; - } - } - return false; - }; - - if (chamberId === "general") { - // Bootstrap: if the user is explicitly configured with a tier, treat them as eligible in General. - const tier = await resolveUserTierFromSimConfig(simConfig, voterAddress); - if (tier !== "Nominee") return null; - - return null; - } - - const eligible = await hasChamberMembership(env, { - address: voterAddress, - chamberId, - }); - if (!eligible && !(await hasGenesisMembership(chamberId))) { - return errorResponse(403, "Not eligible to vote in this chamber", { - code: "chamber_vote_ineligible", - chamberId, - }); - } - return null; -} - -async function enforcePoolVoteEligibility( - env: Record, - readModels: Awaited> | null, - input: { proposalId: string; voterAddress: string }, - requestUrl: string, -): Promise { - if (envBoolean(env, "DEV_BYPASS_CHAMBER_ELIGIBILITY")) return null; - - const simConfig = await getSimConfig(env, requestUrl); - const genesis = simConfig?.genesisChamberMembers; - - const chamberId = await getProposalChamberIdForPool(env, readModels, { - proposalId: input.proposalId, - }); - const voterAddress = input.voterAddress.trim(); - - const hasAnyGenesisMembership = async (): Promise => { - if (!genesis) return false; - for (const members of Object.values(genesis)) { - for (const member of members) { - if (await addressesReferToSameKey(member, voterAddress)) return true; - } - } - return false; - }; - - const hasGenesisMembership = async (target: string): Promise => { - const members = genesis?.[target]?.map((m) => m.trim()) ?? []; - for (const member of members) { - if (await addressesReferToSameKey(member, voterAddress)) return true; - } - return false; - }; - - if (chamberId === "general") { - // Bootstrap: if the user is explicitly configured with a tier, treat them as eligible in General. - const tier = await resolveUserTierFromSimConfig(simConfig, voterAddress); - if (tier !== "Nominee") return null; - - const eligible = - (await hasAnyChamberMembership(env, voterAddress)) || - (await hasChamberMembership(env, { - address: voterAddress, - chamberId: "general", - })) || - (await hasAnyGenesisMembership()); - if (!eligible) { - return errorResponse(403, "Not eligible to vote in the proposal pool", { - code: "pool_vote_ineligible", - chamberId, - }); - } - return null; - } - - const eligible = - (await hasChamberMembership(env, { address: voterAddress, chamberId })) || - (await hasGenesisMembership(chamberId)); - if (!eligible) { - return errorResponse(403, "Not eligible to vote in the proposal pool", { - code: "pool_vote_ineligible", - chamberId, - }); - } - return null; -} - -async function getProposalChamberIdForVote( - env: Record, - readModels: Awaited> | null, - input: { proposalId: string }, -): Promise { - const proposal = await getProposal(env, input.proposalId); - if (proposal) return (proposal.chamberId ?? "general").toLowerCase(); - - if (!readModels) return "general"; - - const chamberPayload = await readModels.get( - `proposals:${input.proposalId}:chamber`, - ); - if (isRecord(chamberPayload)) { - const label = asString(chamberPayload.chamber, ""); - const normalized = normalizeChamberId(label); - return normalized || "general"; - } - - const listPayload = await readModels.get("proposals:list"); - if (isRecord(listPayload) && Array.isArray(listPayload.items)) { - const entry = listPayload.items.find( - (item) => isRecord(item) && item.id === input.proposalId, - ); - if (isRecord(entry)) { - const label = asString(entry.chamber, asString(entry.meta, "")); - const normalized = normalizeChamberId(label); - return normalized || "general"; - } - } - - return "general"; -} - -async function getProposalChamberIdForPool( - env: Record, - readModels: Awaited> | null, - input: { proposalId: string }, -): Promise { - const proposal = await getProposal(env, input.proposalId); - if (proposal) return (proposal.chamberId ?? "general").toLowerCase(); - - if (!readModels) return "general"; - - const poolPayload = await readModels.get( - `proposals:${input.proposalId}:pool`, - ); - if (isRecord(poolPayload)) { - const label = asString(poolPayload.chamber, ""); - const normalized = normalizeChamberId(label); - return normalized || "general"; - } - - const listPayload = await readModels.get("proposals:list"); - if (isRecord(listPayload) && Array.isArray(listPayload.items)) { - const entry = listPayload.items.find( - (item) => isRecord(item) && item.id === input.proposalId, - ); - if (isRecord(entry)) { - const label = asString(entry.chamber, asString(entry.meta, "")); - const normalized = normalizeChamberId(label); - return normalized || "general"; - } - } - - return "general"; -} - -async function getChamberMultiplierTimes10( - store: Awaited>, - chamberId: string, -): Promise { - const payload = await store.get("chambers:list"); - if (!isRecord(payload)) return 10; - const items = payload.items; - if (!Array.isArray(items)) return 10; - const entry = items.find( - (item) => - isRecord(item) && - (item.id === chamberId || - (typeof item.name === "string" && - item.name.toLowerCase() === chamberId)), - ); - if (!isRecord(entry)) return 10; - const mult = entry.multiplier; - if (typeof mult !== "number") return 10; - return Math.round(mult * 10); -} diff --git a/api/routes/courts/[id].ts b/api/routes/courts/[id].ts deleted file mode 100644 index 1930b6c..0000000 --- a/api/routes/courts/[id].ts +++ /dev/null @@ -1,32 +0,0 @@ -import { createReadModelsStore } from "../../_lib/readModelsStore.ts"; -import { errorResponse, jsonResponse } from "../../_lib/http.ts"; -import { getCourtOverlay } from "../../_lib/courtsStore.ts"; - -export const onRequestGet: ApiHandler = async (context) => { - try { - const id = context.params?.id; - if (!id) return errorResponse(400, "Missing case id"); - const store = await createReadModelsStore(context.env); - const payload = await store.get(`courts:${id}`); - if (!payload) return errorResponse(404, `Missing read model: courts:${id}`); - - let overlay; - try { - overlay = await getCourtOverlay(context.env, store, id); - } catch { - overlay = null; - } - - if (!overlay) return jsonResponse(payload); - if (!payload || typeof payload !== "object" || Array.isArray(payload)) - return jsonResponse(payload); - const record = payload as Record; - return jsonResponse({ - ...record, - status: overlay.status, - reports: overlay.reports, - }); - } catch (error) { - return errorResponse(500, (error as Error).message); - } -}; diff --git a/api/routes/courts/index.ts b/api/routes/courts/index.ts deleted file mode 100644 index fe7c8d9..0000000 --- a/api/routes/courts/index.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { createReadModelsStore } from "../../_lib/readModelsStore.ts"; -import { errorResponse, jsonResponse } from "../../_lib/http.ts"; -import { getCourtOverlay } from "../../_lib/courtsStore.ts"; - -export const onRequestGet: ApiHandler = async (context) => { - try { - const store = await createReadModelsStore(context.env); - const payload = await store.get("courts:list"); - if (!payload) return jsonResponse({ items: [] }); - if ( - typeof payload !== "object" || - payload === null || - Array.isArray(payload) - ) - return jsonResponse({ items: [] }); - - const record = payload as Record; - const items = Array.isArray(record.items) ? record.items : []; - - const nextItems = await Promise.all( - items.map(async (item) => { - if (!item || typeof item !== "object" || Array.isArray(item)) - return item; - const row = item as Record; - const id = typeof row.id === "string" ? row.id : null; - if (!id) return item; - try { - const overlay = await getCourtOverlay(context.env, store, id); - return { ...row, status: overlay.status, reports: overlay.reports }; - } catch { - return item; - } - }), - ); - - return jsonResponse({ ...record, items: nextItems }); - } catch (error) { - return errorResponse(500, (error as Error).message); - } -}; diff --git a/api/routes/factions/[id].ts b/api/routes/factions/[id].ts deleted file mode 100644 index fcfcacf..0000000 --- a/api/routes/factions/[id].ts +++ /dev/null @@ -1,16 +0,0 @@ -import { createReadModelsStore } from "../../_lib/readModelsStore.ts"; -import { errorResponse, jsonResponse } from "../../_lib/http.ts"; - -export const onRequestGet: ApiHandler = async (context) => { - try { - const id = context.params?.id; - if (!id) return errorResponse(400, "Missing faction id"); - const store = await createReadModelsStore(context.env); - const payload = await store.get(`factions:${id}`); - if (!payload) - return errorResponse(404, `Missing read model: factions:${id}`); - return jsonResponse(payload); - } catch (error) { - return errorResponse(500, (error as Error).message); - } -}; diff --git a/api/routes/factions/index.ts b/api/routes/factions/index.ts deleted file mode 100644 index d476e0f..0000000 --- a/api/routes/factions/index.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { createReadModelsStore } from "../../_lib/readModelsStore.ts"; -import { errorResponse, jsonResponse } from "../../_lib/http.ts"; - -export const onRequestGet: ApiHandler = async (context) => { - try { - const store = await createReadModelsStore(context.env); - const payload = await store.get("factions:list"); - return jsonResponse(payload ?? { items: [] }); - } catch (error) { - return errorResponse(500, (error as Error).message); - } -}; diff --git a/api/routes/feed/index.ts b/api/routes/feed/index.ts deleted file mode 100644 index 4ef6f51..0000000 --- a/api/routes/feed/index.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { createReadModelsStore } from "../../_lib/readModelsStore.ts"; -import { errorResponse, jsonResponse } from "../../_lib/http.ts"; -import { base64UrlDecode, base64UrlEncode } from "../../_lib/base64url.ts"; -import { listFeedEventsPage } from "../../_lib/eventsStore.ts"; - -const DEFAULT_PAGE_SIZE = 25; - -type Cursor = - | { kind: "read_models"; ts: string; id: string } - | { kind: "events"; seq: number }; - -function decodeCursor(input: string): Cursor | null { - try { - const bytes = base64UrlDecode(input); - const raw = new TextDecoder().decode(bytes); - const parsed = JSON.parse(raw) as { - ts?: unknown; - id?: unknown; - seq?: unknown; - }; - if (typeof parsed.seq === "number" && Number.isFinite(parsed.seq)) { - return { kind: "events", seq: parsed.seq }; - } - if (typeof parsed.ts === "string" && typeof parsed.id === "string") { - return { kind: "read_models", ts: parsed.ts, id: parsed.id }; - } - return null; - } catch { - return null; - } -} - -function encodeCursor(input: { ts: string; id: string } | { seq: number }) { - const raw = JSON.stringify(input); - const bytes = new TextEncoder().encode(raw); - return base64UrlEncode(bytes); -} - -export const onRequestGet: ApiHandler = async (context) => { - try { - const url = new URL(context.request.url); - const stage = url.searchParams.get("stage"); - const cursor = url.searchParams.get("cursor"); - const decoded = cursor ? decodeCursor(cursor) : null; - if (cursor && !decoded) return errorResponse(400, "Invalid cursor"); - - const wantsInlineReadModels = context.env.READ_MODELS_INLINE === "true"; - const hasDatabase = Boolean(context.env.DATABASE_URL); - - if (hasDatabase && !wantsInlineReadModels) { - if (decoded && decoded.kind !== "events") { - return errorResponse(400, "Invalid cursor"); - } - const beforeSeq = decoded?.seq ?? null; - const page = await listFeedEventsPage(context.env, { - stage, - beforeSeq, - limit: DEFAULT_PAGE_SIZE, - }); - const nextCursor = - page.nextSeq !== undefined - ? encodeCursor({ seq: page.nextSeq }) - : undefined; - return jsonResponse( - nextCursor ? { items: page.items, nextCursor } : { items: page.items }, - ); - } - - const store = await createReadModelsStore(context.env); - const payload = await store.get("feed:list"); - if (!payload) return jsonResponse({ items: [] }); - if (decoded && decoded.kind !== "read_models") { - return errorResponse(400, "Invalid cursor"); - } - - const typed = payload as { - items?: { id: string; stage: string; timestamp: string }[]; - }; - let items = [...(typed.items ?? [])]; - - if (stage) items = items.filter((item) => item.stage === stage); - - items.sort( - (a, b) => - new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime(), - ); - - if (decoded?.kind === "read_models") { - const idx = items.findIndex( - (item) => item.timestamp === decoded.ts && item.id === decoded.id, - ); - if (idx >= 0) items = items.slice(idx + 1); - } - - const page = items.slice(0, DEFAULT_PAGE_SIZE); - const next = - items.length > DEFAULT_PAGE_SIZE - ? encodeCursor({ - ts: page[page.length - 1]?.timestamp ?? "", - id: page[page.length - 1]?.id ?? "", - }) - : undefined; - - const response = next ? { items: page, nextCursor: next } : { items: page }; - return jsonResponse(response); - } catch (error) { - return errorResponse(500, (error as Error).message); - } -}; diff --git a/api/routes/formation/index.ts b/api/routes/formation/index.ts deleted file mode 100644 index f2897dc..0000000 --- a/api/routes/formation/index.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { createReadModelsStore } from "../../_lib/readModelsStore.ts"; -import { errorResponse, jsonResponse } from "../../_lib/http.ts"; - -export const onRequestGet: ApiHandler = async (context) => { - try { - const store = await createReadModelsStore(context.env); - const payload = await store.get("formation:directory"); - return jsonResponse(payload ?? { metrics: [], projects: [] }); - } catch (error) { - return errorResponse(500, (error as Error).message); - } -}; diff --git a/api/routes/gate/status.ts b/api/routes/gate/status.ts deleted file mode 100644 index b63e099..0000000 --- a/api/routes/gate/status.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { readSession } from "../../_lib/auth.ts"; -import { checkEligibility } from "../../_lib/gate.ts"; -import { jsonResponse } from "../../_lib/http.ts"; - -export const onRequestGet: ApiHandler = async (context) => { - const session = await readSession(context.request, context.env); - if (!session) { - return jsonResponse({ - eligible: false, - reason: "not_authenticated", - expiresAt: new Date().toISOString(), - }); - } - return jsonResponse( - await checkEligibility(context.env, session.address, context.request.url), - ); -}; diff --git a/api/routes/health.ts b/api/routes/health.ts deleted file mode 100644 index b4b8972..0000000 --- a/api/routes/health.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { jsonResponse } from "../_lib/http.ts"; - -export const onRequest: ApiHandler = async () => { - return jsonResponse({ - ok: true, - service: "vortex-simulation-api", - time: new Date().toISOString(), - }); -}; diff --git a/api/routes/humans/[id].ts b/api/routes/humans/[id].ts deleted file mode 100644 index 3d9cd26..0000000 --- a/api/routes/humans/[id].ts +++ /dev/null @@ -1,31 +0,0 @@ -import { createReadModelsStore } from "../../_lib/readModelsStore.ts"; -import { errorResponse, jsonResponse } from "../../_lib/http.ts"; -import { getAcmDelta } from "../../_lib/cmAwardsStore.ts"; - -export const onRequestGet: ApiHandler = async (context) => { - try { - const id = context.params?.id; - if (!id) return errorResponse(400, "Missing human id"); - const store = await createReadModelsStore(context.env); - const payload = await store.get(`humans:${id}`); - if (!payload) return errorResponse(404, `Missing read model: humans:${id}`); - - const delta = await getAcmDelta(context.env, id); - if (!delta) return jsonResponse(payload); - - const typed = payload as Record; - const heroStats = Array.isArray(typed.heroStats) - ? (typed.heroStats as Array>) - : []; - const nextHeroStats = heroStats.map((stat) => { - if (stat.label !== "ACM") return stat; - const raw = typeof stat.value === "string" ? stat.value : "0"; - const base = Number(raw.replace(/,/g, "")) || 0; - return { ...stat, value: String(base + delta) }; - }); - - return jsonResponse({ ...typed, heroStats: nextHeroStats }); - } catch (error) { - return errorResponse(500, (error as Error).message); - } -}; diff --git a/api/routes/humans/index.ts b/api/routes/humans/index.ts deleted file mode 100644 index 2b3d085..0000000 --- a/api/routes/humans/index.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { createReadModelsStore } from "../../_lib/readModelsStore.ts"; -import { errorResponse, jsonResponse } from "../../_lib/http.ts"; -import { getAcmDelta } from "../../_lib/cmAwardsStore.ts"; - -export const onRequestGet: ApiHandler = async (context) => { - try { - const store = await createReadModelsStore(context.env); - const payload = await store.get("humans:list"); - if (!payload) return jsonResponse({ items: [] }); - const typed = payload as { items?: Array> }; - const items = Array.isArray(typed.items) ? typed.items : []; - - const nextItems = await Promise.all( - items.map(async (item) => { - const id = typeof item.id === "string" ? item.id : null; - if (!id) return item; - const delta = await getAcmDelta(context.env, id); - const base = typeof item.acm === "number" ? item.acm : 0; - return { ...item, acm: base + delta }; - }), - ); - - return jsonResponse({ ...typed, items: nextItems }); - } catch (error) { - return errorResponse(500, (error as Error).message); - } -}; diff --git a/api/routes/invision/index.ts b/api/routes/invision/index.ts deleted file mode 100644 index 847c634..0000000 --- a/api/routes/invision/index.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { createReadModelsStore } from "../../_lib/readModelsStore.ts"; -import { errorResponse, jsonResponse } from "../../_lib/http.ts"; - -export const onRequestGet: ApiHandler = async (context) => { - try { - const store = await createReadModelsStore(context.env); - const payload = await store.get("invision:dashboard"); - return jsonResponse( - payload ?? { - governanceState: { label: "—", metrics: [] }, - economicIndicators: [], - riskSignals: [], - chamberProposals: [], - }, - ); - } catch (error) { - return errorResponse(500, (error as Error).message); - } -}; diff --git a/api/routes/me.ts b/api/routes/me.ts deleted file mode 100644 index 3f1a144..0000000 --- a/api/routes/me.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { readSession } from "../_lib/auth.ts"; -import { checkEligibility } from "../_lib/gate.ts"; -import { jsonResponse } from "../_lib/http.ts"; - -export const onRequestGet: ApiHandler = async (context) => { - const session = await readSession(context.request, context.env); - if (!session) return jsonResponse({ authenticated: false }); - const gate = await checkEligibility( - context.env, - session.address, - context.request.url, - ); - return jsonResponse({ - authenticated: true, - address: session.address, - gate, - }); -}; diff --git a/api/routes/my-governance/index.ts b/api/routes/my-governance/index.ts deleted file mode 100644 index 76b32cf..0000000 --- a/api/routes/my-governance/index.ts +++ /dev/null @@ -1,181 +0,0 @@ -import { createReadModelsStore } from "../../_lib/readModelsStore.ts"; -import { readSession } from "../../_lib/auth.ts"; -import { getUserEraActivity } from "../../_lib/eraStore.ts"; -import { - getEraRollupMeta, - getEraUserStatus, -} from "../../_lib/eraRollupStore.ts"; -import { errorResponse, jsonResponse } from "../../_lib/http.ts"; - -function envInt( - env: Record, - key: string, - fallback: number, -): number { - const raw = env[key]; - if (!raw) return fallback; - const n = Number(raw); - if (!Number.isFinite(n)) return fallback; - if (n < 0) return fallback; - return Math.floor(n); -} - -export const onRequestGet: ApiHandler = async (context) => { - try { - const store = await createReadModelsStore(context.env); - const payload = await store.get("my-governance:summary"); - const base = - payload && typeof payload === "object" && !Array.isArray(payload) - ? (payload as Record) - : { - eraActivity: { - era: "Era 0", - required: 0, - completed: 0, - actions: [], - timeLeft: "—", - }, - myChamberIds: [], - }; - - const requiredByLabel: Record = { - "Pool votes": envInt(context.env, "SIM_REQUIRED_POOL_VOTES", 1), - "Chamber votes": envInt(context.env, "SIM_REQUIRED_CHAMBER_VOTES", 1), - "Court actions": envInt(context.env, "SIM_REQUIRED_COURT_ACTIONS", 0), - "Formation actions": envInt( - context.env, - "SIM_REQUIRED_FORMATION_ACTIONS", - 0, - ), - }; - - const session = await readSession(context.request, context.env); - if (!session) { - // Normalize the base model to the configured requirements (even for anon users). - const baseEraActivity = - base && typeof base === "object" && !Array.isArray(base) - ? (base as Record).eraActivity - : null; - const actions = - baseEraActivity && - typeof baseEraActivity === "object" && - baseEraActivity !== null && - !Array.isArray(baseEraActivity) && - Array.isArray((baseEraActivity as Record).actions) - ? ((baseEraActivity as Record).actions as Array< - Record - >) - : []; - - const normalizedActions = actions - .map((action) => { - const label = String(action.label ?? ""); - if (!(label in requiredByLabel)) return null; - return { - ...action, - label, - required: requiredByLabel[label], - }; - }) - .filter(Boolean) as Array>; - - const requiredTotal = normalizedActions.reduce((sum, action) => { - return ( - sum + (typeof action.required === "number" ? action.required : 0) - ); - }, 0); - - return jsonResponse({ - ...base, - eraActivity: { - ...(baseEraActivity as Record), - required: requiredTotal, - actions: normalizedActions, - }, - }); - } - - const era = await getUserEraActivity(context.env, { - address: session.address, - }).catch(() => null); - if (!era) return jsonResponse(base); - - const baseEraActivity = - base && typeof base === "object" && !Array.isArray(base) - ? (base as Record).eraActivity - : null; - const actions = - baseEraActivity && - typeof baseEraActivity === "object" && - baseEraActivity !== null && - !Array.isArray(baseEraActivity) && - Array.isArray((baseEraActivity as Record).actions) - ? ((baseEraActivity as Record).actions as Array< - Record - >) - : []; - - const nextActions = actions - .map((action) => { - const label = String(action.label ?? ""); - if (!(label in requiredByLabel)) return null; - const done = - label === "Pool votes" - ? era.counts.poolVotes - : label === "Chamber votes" - ? era.counts.chamberVotes - : label === "Court actions" - ? era.counts.courtActions - : label === "Formation actions" - ? era.counts.formationActions - : 0; - return { ...action, label, required: requiredByLabel[label], done }; - }) - .filter(Boolean) as Array>; - - const requiredTotal = nextActions.reduce((sum, action) => { - return sum + (typeof action.required === "number" ? action.required : 0); - }, 0); - const completedTotal = nextActions.reduce( - (sum, action) => - sum + (typeof action.done === "number" ? action.done : 0), - 0, - ); - - const rollupMeta = await getEraRollupMeta(context.env, { - era: era.era, - }).catch(() => null); - const rollupUser = rollupMeta - ? await getEraUserStatus(context.env, { - era: rollupMeta.era, - address: session.address, - }).catch(() => null) - : null; - - return jsonResponse({ - ...base, - eraActivity: { - ...(baseEraActivity as Record), - era: String(era.era), - required: requiredTotal, - completed: completedTotal, - actions: nextActions, - }, - ...(rollupMeta - ? { - rollup: { - era: rollupMeta.era, - rolledAt: rollupMeta.rolledAt, - status: rollupUser?.status ?? "Losing status", - requiredTotal: rollupMeta.requiredTotal, - completedTotal: rollupUser?.completedTotal ?? 0, - isActiveNextEra: rollupUser?.isActiveNextEra ?? false, - activeGovernorsNextEra: rollupMeta.activeGovernorsNextEra, - }, - } - : {}), - }); - } catch (error) { - return errorResponse(500, (error as Error).message); - } -}; diff --git a/api/routes/proposals/[id]/chamber.ts b/api/routes/proposals/[id]/chamber.ts deleted file mode 100644 index cf9be38..0000000 --- a/api/routes/proposals/[id]/chamber.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { createReadModelsStore } from "../../../_lib/readModelsStore.ts"; -import { errorResponse, jsonResponse } from "../../../_lib/http.ts"; -import { getChamberVoteCounts } from "../../../_lib/chamberVotesStore.ts"; -import { getActiveGovernorsForCurrentEra } from "../../../_lib/eraStore.ts"; -import { getProposal } from "../../../_lib/proposalsStore.ts"; -import { projectChamberProposalPage } from "../../../_lib/proposalProjector.ts"; -import { getProposalStageDenominator } from "../../../_lib/proposalStageDenominatorsStore.ts"; -import { V1_ACTIVE_GOVERNORS_FALLBACK } from "../../../_lib/v1Constants.ts"; -import { - getSimNow, - getStageWindowSeconds, - stageWindowsEnabled, -} from "../../../_lib/stageWindows.ts"; - -function normalizeChamberId(chamberLabel: string): string { - const match = chamberLabel.trim().match(/^([A-Za-z]+)/); - return (match?.[1] ?? chamberLabel).toLowerCase(); -} - -export const onRequestGet: ApiHandler = async (context) => { - try { - const id = context.params?.id; - if (!id) return errorResponse(400, "Missing proposal id"); - - const baseline = - (await getActiveGovernorsForCurrentEra(context.env).catch(() => null)) ?? - V1_ACTIVE_GOVERNORS_FALLBACK; - const activeGovernors = - ( - await getProposalStageDenominator(context.env, { - proposalId: id, - stage: "vote", - }).catch(() => null) - )?.activeGovernors ?? baseline; - - const proposal = await getProposal(context.env, id); - if (proposal) { - const counts = await getChamberVoteCounts(context.env, id, { - chamberId: (proposal.chamberId ?? "general").toLowerCase(), - }); - const now = getSimNow(context.env); - return jsonResponse( - projectChamberProposalPage(proposal, { - counts, - activeGovernors, - now, - voteWindowSeconds: stageWindowsEnabled(context.env) - ? getStageWindowSeconds(context.env, "vote") - : undefined, - }), - ); - } - - const store = await createReadModelsStore(context.env); - const payload = await store.get(`proposals:${id}:chamber`); - if (!payload) - return errorResponse(404, `Missing read model: proposals:${id}:chamber`); - - const typed = payload as Record; - const chamberId = - normalizeChamberId(String(typed.chamber ?? "general")) || "general"; - const counts = await getChamberVoteCounts(context.env, id, { chamberId }); - const engagedGovernors = counts.yes + counts.no + counts.abstain; - return jsonResponse({ - ...typed, - votes: counts, - engagedGovernors, - activeGovernors, - }); - } catch (error) { - return errorResponse(500, (error as Error).message); - } -}; diff --git a/api/routes/proposals/[id]/formation.ts b/api/routes/proposals/[id]/formation.ts deleted file mode 100644 index 93ab478..0000000 --- a/api/routes/proposals/[id]/formation.ts +++ /dev/null @@ -1,149 +0,0 @@ -import { createReadModelsStore } from "../../../_lib/readModelsStore.ts"; -import { errorResponse, jsonResponse } from "../../../_lib/http.ts"; -import { - getFormationSummary, - listFormationJoiners, - ensureFormationSeedFromInput, - buildV1FormationSeedFromProposalPayload, -} from "../../../_lib/formationStore.ts"; -import { getProposal } from "../../../_lib/proposalsStore.ts"; -import type { ReadModelsStore } from "../../../_lib/readModelsStore.ts"; -import { projectFormationProposalPage } from "../../../_lib/proposalProjector.ts"; - -export const onRequestGet: ApiHandler = async (context) => { - try { - const id = context.params?.id; - if (!id) return errorResponse(400, "Missing proposal id"); - - const proposal = await getProposal(context.env, id); - if (proposal) { - const store: ReadModelsStore = (await createReadModelsStore( - context.env, - ).catch(() => null)) ?? { - get: async () => null, - }; - - const formationEligible = (() => { - const payload = proposal.payload; - if (!payload || typeof payload !== "object" || Array.isArray(payload)) - return true; - const record = payload as Record; - if (record.templateId === "system") return false; - if ( - typeof record.metaGovernance === "object" && - record.metaGovernance !== null && - !Array.isArray(record.metaGovernance) - ) - return false; - if (typeof record.formationEligible === "boolean") - return record.formationEligible; - if (typeof record.formation === "boolean") return record.formation; - return true; - })(); - - if (!formationEligible) { - return jsonResponse( - projectFormationProposalPage(proposal, { - summary: { - teamFilled: 0, - teamTotal: 0, - milestonesCompleted: 0, - milestonesTotal: 0, - }, - joiners: [], - }), - ); - } - - if (formationEligible) { - const seed = buildV1FormationSeedFromProposalPayload(proposal.payload); - await ensureFormationSeedFromInput(context.env, { - proposalId: id, - seed, - }); - } - - const summary = await getFormationSummary(context.env, store, id); - const joiners = await listFormationJoiners(context.env, id); - return jsonResponse( - projectFormationProposalPage(proposal, { summary, joiners }), - ); - } - - const store = await createReadModelsStore(context.env); - const readModelKey = `proposals:${id}:formation`; - const payload = await store.get(readModelKey); - if (!payload) - return errorResponse(404, `Missing read model: ${readModelKey}`); - - const summary = await getFormationSummary(context.env, store, id); - const joiners = await listFormationJoiners(context.env, id); - - const next = patchFormationReadModel(payload, { - teamFilled: summary.teamFilled, - teamTotal: summary.teamTotal, - milestonesCompleted: summary.milestonesCompleted, - milestonesTotal: summary.milestonesTotal, - joiners, - }); - - return jsonResponse(next); - } catch (error) { - return errorResponse(500, (error as Error).message); - } -}; - -function patchFormationReadModel( - payload: unknown, - input: { - teamFilled: number; - teamTotal: number; - milestonesCompleted: number; - milestonesTotal: number; - joiners: { address: string; role?: string | null }[]; - }, -): unknown { - if (!payload || typeof payload !== "object" || Array.isArray(payload)) { - return payload; - } - - const record = payload as Record; - const teamSlots = `${input.teamFilled} / ${input.teamTotal}`; - const milestones = `${input.milestonesCompleted} / ${input.milestonesTotal}`; - const progress = - input.milestonesTotal > 0 - ? `${Math.round((input.milestonesCompleted / input.milestonesTotal) * 100)}%` - : "0%"; - - const baseTeam = Array.isArray(record.lockedTeam) ? record.lockedTeam : []; - const joinerItems = input.joiners.map((entry) => ({ - name: shortenAddress(entry.address), - role: entry.role ?? "Contributor", - })); - - const stageData = Array.isArray(record.stageData) ? record.stageData : []; - const nextStageData = stageData.map((entry) => { - if (!entry || typeof entry !== "object" || Array.isArray(entry)) - return entry; - const row = entry as Record; - const title = String(row.title ?? "").toLowerCase(); - if (title.includes("team slots")) return { ...row, value: teamSlots }; - if (title.includes("milestones")) return { ...row, value: milestones }; - return entry; - }); - - return { - ...record, - teamSlots, - milestones, - progress, - stageData: nextStageData, - lockedTeam: [...baseTeam, ...joinerItems], - }; -} - -function shortenAddress(address: string): string { - const normalized = address.trim(); - if (normalized.length <= 12) return normalized; - return `${normalized.slice(0, 6)}…${normalized.slice(-4)}`; -} diff --git a/api/routes/proposals/[id]/pool.ts b/api/routes/proposals/[id]/pool.ts deleted file mode 100644 index ab000c9..0000000 --- a/api/routes/proposals/[id]/pool.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { createReadModelsStore } from "../../../_lib/readModelsStore.ts"; -import { errorResponse, jsonResponse } from "../../../_lib/http.ts"; -import { getPoolVoteCounts } from "../../../_lib/poolVotesStore.ts"; -import { getActiveGovernorsForCurrentEra } from "../../../_lib/eraStore.ts"; -import { getProposal } from "../../../_lib/proposalsStore.ts"; -import { projectPoolProposalPage } from "../../../_lib/proposalProjector.ts"; -import { getProposalStageDenominator } from "../../../_lib/proposalStageDenominatorsStore.ts"; -import { V1_ACTIVE_GOVERNORS_FALLBACK } from "../../../_lib/v1Constants.ts"; -import { getSimConfig } from "../../../_lib/simConfig.ts"; -import { resolveUserTierFromSimConfig } from "../../../_lib/userTier.ts"; - -export const onRequestGet: ApiHandler = async (context) => { - try { - const id = context.params?.id; - if (!id) return errorResponse(400, "Missing proposal id"); - - const counts = await getPoolVoteCounts(context.env, id); - const baseline = - (await getActiveGovernorsForCurrentEra(context.env).catch(() => null)) ?? - V1_ACTIVE_GOVERNORS_FALLBACK; - const activeGovernors = - ( - await getProposalStageDenominator(context.env, { - proposalId: id, - stage: "pool", - }).catch(() => null) - )?.activeGovernors ?? baseline; - - const proposal = await getProposal(context.env, id); - if (proposal) { - const simConfig = await getSimConfig( - context.env, - context.request.url, - ).catch(() => null); - return jsonResponse( - projectPoolProposalPage(proposal, { - counts, - activeGovernors, - tier: await resolveUserTierFromSimConfig( - simConfig, - proposal.authorAddress, - ), - }), - ); - } - - const store = await createReadModelsStore(context.env); - const payload = await store.get(`proposals:${id}:pool`); - if (!payload) - return errorResponse(404, `Missing read model: proposals:${id}:pool`); - const patched = { - ...(payload as Record), - upvotes: counts.upvotes, - downvotes: counts.downvotes, - activeGovernors, - }; - return jsonResponse(patched); - } catch (error) { - return errorResponse(500, (error as Error).message); - } -}; diff --git a/api/routes/proposals/[id]/timeline.ts b/api/routes/proposals/[id]/timeline.ts deleted file mode 100644 index 847d48b..0000000 --- a/api/routes/proposals/[id]/timeline.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { errorResponse, jsonResponse } from "../../../_lib/http.ts"; -import { listProposalTimelineItems } from "../../../_lib/proposalTimelineStore.ts"; - -export const onRequestGet: ApiHandler = async (context) => { - try { - const id = context.params?.id; - if (!id) return errorResponse(400, "Missing proposal id"); - - const url = new URL(context.request.url); - const limitParam = url.searchParams.get("limit"); - const limitRaw = limitParam ? Number.parseInt(limitParam, 10) : 100; - const limit = Number.isFinite(limitRaw) - ? Math.max(1, Math.min(500, limitRaw)) - : 100; - - const items = await listProposalTimelineItems(context.env, { - proposalId: id, - limit, - }); - return jsonResponse({ items }); - } catch (error) { - return errorResponse(500, (error as Error).message); - } -}; diff --git a/api/routes/proposals/drafts/[id].ts b/api/routes/proposals/drafts/[id].ts deleted file mode 100644 index 294896c..0000000 --- a/api/routes/proposals/drafts/[id].ts +++ /dev/null @@ -1,170 +0,0 @@ -import { createReadModelsStore } from "../../../_lib/readModelsStore.ts"; -import { readSession } from "../../../_lib/auth.ts"; -import { errorResponse, jsonResponse } from "../../../_lib/http.ts"; -import { - getDraft, - formatChamberLabel, -} from "../../../_lib/proposalDraftsStore.ts"; -import { getUserTier } from "../../../_lib/userTier.ts"; - -export const onRequestGet: ApiHandler = async (context) => { - try { - const id = context.params?.id; - if (!id) return errorResponse(400, "Missing draft id"); - const session = await readSession(context.request, context.env); - - if (!context.env.DATABASE_URL) { - if (session) { - const tier = await getUserTier( - context.env, - context.request.url, - session.address, - ); - const draft = await getDraft(context.env, { - authorAddress: session.address, - draftId: id, - }); - if (draft) { - const budgetTotal = draft.payload.budgetItems.reduce((sum, item) => { - const n = Number(item.amount); - if (!Number.isFinite(n) || n <= 0) return sum; - return sum + n; - }, 0); - - return jsonResponse({ - title: draft.title, - proposer: session.address, - chamber: formatChamberLabel(draft.chamberId), - focus: draft.payload.chamberId - ? "Chamber-scoped proposal" - : "General proposal", - tier, - budget: - budgetTotal > 0 ? `${budgetTotal.toLocaleString()} HMND` : "—", - formationEligible: !draft.payload.metaGovernance, - teamSlots: "1 / 3", - milestonesPlanned: `${draft.payload.timeline.length} milestones`, - summary: draft.payload.summary, - rationale: draft.payload.why, - budgetScope: draft.payload.budgetItems - .filter((b) => b.description.trim().length > 0) - .map((b) => `${b.description}: ${b.amount} HMND`) - .join("\n"), - invisionInsight: { - role: "Draft author", - bullets: [ - "This is a saved draft in the off-chain simulation backend.", - "Submission to the pool is gated by active Humanode status.", - ], - }, - checklist: draft.payload.how - .split("\n") - .map((line) => line.trim()) - .filter(Boolean) - .slice(0, 12), - milestones: draft.payload.timeline - .map((m) => m.title) - .filter(Boolean), - teamLocked: [ - { - name: session.address, - role: "Proposer", - }, - ], - openSlotNeeds: [ - { - title: "Contributor (open slot)", - desc: "Join the taskforce if the proposal reaches Formation.", - }, - ], - milestonesDetail: draft.payload.timeline.map((m, idx) => ({ - title: m.title.trim().length ? m.title : `Milestone ${idx + 1}`, - desc: m.timeframe.trim().length ? m.timeframe : "Timeline TBD", - })), - attachments: draft.payload.attachments - .filter((a) => a.label.trim().length > 0) - .map((a) => ({ title: a.label, href: a.url || "#" })), - }); - } - } - - const store = await createReadModelsStore(context.env); - const payload = await store.get(`proposals:drafts:${id}`); - if (!payload) - return errorResponse(404, `Missing read model: proposals:drafts:${id}`); - return jsonResponse(payload); - } - - if (!session) return errorResponse(404, "Draft not found"); - const tier = await getUserTier( - context.env, - context.request.url, - session.address, - ); - const draft = await getDraft(context.env, { - authorAddress: session.address, - draftId: id, - }); - if (!draft) return errorResponse(404, "Draft not found"); - - const budgetTotal = draft.payload.budgetItems.reduce((sum, item) => { - const n = Number(item.amount); - if (!Number.isFinite(n) || n <= 0) return sum; - return sum + n; - }, 0); - - return jsonResponse({ - title: draft.title, - proposer: session.address, - chamber: formatChamberLabel(draft.chamberId), - focus: draft.payload.chamberId - ? "Chamber-scoped proposal" - : "General proposal", - tier, - budget: budgetTotal > 0 ? `${budgetTotal.toLocaleString()} HMND` : "—", - formationEligible: !draft.payload.metaGovernance, - teamSlots: "1 / 3", - milestonesPlanned: `${draft.payload.timeline.length} milestones`, - summary: draft.payload.summary, - rationale: draft.payload.why, - budgetScope: draft.payload.budgetItems - .filter((b) => b.description.trim().length > 0) - .map((b) => `${b.description}: ${b.amount} HMND`) - .join("\n"), - invisionInsight: { - role: "Draft author", - bullets: [ - "This is a saved draft in the off-chain simulation backend.", - "Submission to the pool is gated by active Humanode status.", - ], - }, - checklist: draft.payload.how - .split("\n") - .map((line) => line.trim()) - .filter(Boolean) - .slice(0, 12), - milestones: draft.payload.timeline.map((m) => m.title).filter(Boolean), - teamLocked: [ - { - name: session.address, - role: "Proposer", - }, - ], - openSlotNeeds: [ - { - title: "Contributor (open slot)", - desc: "Join the taskforce if the proposal reaches Formation.", - }, - ], - milestonesDetail: draft.payload.timeline.map((m, idx) => ({ - title: m.title.trim().length ? m.title : `Milestone ${idx + 1}`, - desc: m.timeframe.trim().length ? m.timeframe : "Timeline TBD", - })), - attachments: draft.payload.attachments - .filter((a) => a.label.trim().length > 0) - .map((a) => ({ title: a.label, href: a.url || "#" })), - }); - } catch (error) { - return errorResponse(500, (error as Error).message); - } -}; diff --git a/api/routes/proposals/drafts/index.ts b/api/routes/proposals/drafts/index.ts deleted file mode 100644 index a101f56..0000000 --- a/api/routes/proposals/drafts/index.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { createReadModelsStore } from "../../../_lib/readModelsStore.ts"; -import { readSession } from "../../../_lib/auth.ts"; -import { errorResponse, jsonResponse } from "../../../_lib/http.ts"; -import { - listDrafts, - formatChamberLabel, -} from "../../../_lib/proposalDraftsStore.ts"; -import { getUserTier } from "../../../_lib/userTier.ts"; - -export const onRequestGet: ApiHandler = async (context) => { - try { - const session = await readSession(context.request, context.env); - - if (!context.env.DATABASE_URL) { - if (session) { - const tier = await getUserTier( - context.env, - context.request.url, - session.address, - ); - const drafts = await listDrafts(context.env, { - authorAddress: session.address, - }); - return jsonResponse({ - items: drafts.map((d) => ({ - id: d.id, - title: d.title, - chamber: formatChamberLabel(d.chamberId), - tier, - summary: d.summary, - updated: d.updatedAt.toISOString().slice(0, 10), - })), - }); - } - - const store = await createReadModelsStore(context.env); - const payload = await store.get("proposals:drafts:list"); - return jsonResponse(payload ?? { items: [] }); - } - - if (!session) return jsonResponse({ items: [] }); - const tier = await getUserTier( - context.env, - context.request.url, - session.address, - ); - const drafts = await listDrafts(context.env, { - authorAddress: session.address, - }); - return jsonResponse({ - items: drafts.map((d) => ({ - id: d.id, - title: d.title, - chamber: formatChamberLabel(d.chamberId), - tier, - summary: d.summary, - updated: d.updatedAt.toISOString().slice(0, 10), - })), - }); - } catch (error) { - return errorResponse(500, (error as Error).message); - } -}; diff --git a/api/routes/proposals/index.ts b/api/routes/proposals/index.ts deleted file mode 100644 index d44bb1a..0000000 --- a/api/routes/proposals/index.ts +++ /dev/null @@ -1,139 +0,0 @@ -import { createReadModelsStore } from "../../_lib/readModelsStore.ts"; -import { errorResponse, jsonResponse } from "../../_lib/http.ts"; -import { listProposals } from "../../_lib/proposalsStore.ts"; -import { getActiveGovernorsForCurrentEra } from "../../_lib/eraStore.ts"; -import { getPoolVoteCounts } from "../../_lib/poolVotesStore.ts"; -import { getChamberVoteCounts } from "../../_lib/chamberVotesStore.ts"; -import { getFormationSummary } from "../../_lib/formationStore.ts"; -import { getProposalStageDenominatorMap } from "../../_lib/proposalStageDenominatorsStore.ts"; -import { - parseProposalStageQuery, - projectProposalListItem, -} from "../../_lib/proposalProjector.ts"; -import { V1_ACTIVE_GOVERNORS_FALLBACK } from "../../_lib/v1Constants.ts"; -import { getSimConfig } from "../../_lib/simConfig.ts"; -import { resolveUserTierFromSimConfig } from "../../_lib/userTier.ts"; -import { - getSimNow, - getStageWindowSeconds, - stageWindowsEnabled, -} from "../../_lib/stageWindows.ts"; - -export const onRequestGet: ApiHandler = async (context) => { - try { - const store = await createReadModelsStore(context.env); - const now = getSimNow(context.env); - const voteWindowSeconds = stageWindowsEnabled(context.env) - ? getStageWindowSeconds(context.env, "vote") - : undefined; - const url = new URL(context.request.url); - const stage = url.searchParams.get("stage"); - - const listPayload = await store.get("proposals:list"); - const readModelItems = - listPayload && - typeof listPayload === "object" && - !Array.isArray(listPayload) && - Array.isArray((listPayload as { items?: unknown[] }).items) - ? ((listPayload as { items: unknown[] }).items.filter( - (entry) => - entry && typeof entry === "object" && !Array.isArray(entry), - ) as Array>) - : []; - - const activeGovernors = - (await getActiveGovernorsForCurrentEra(context.env).catch(() => null)) ?? - V1_ACTIVE_GOVERNORS_FALLBACK; - - const stageQuery = - stage === "draft" ? null : parseProposalStageQuery(stage ?? null); - const proposals = - stage === "draft" - ? [] - : await listProposals(context.env, { stage: stageQuery }); - const simConfig = await getSimConfig( - context.env, - context.request.url, - ).catch(() => null); - - const poolDenominators = await getProposalStageDenominatorMap(context.env, { - stage: "pool", - proposalIds: proposals.filter((p) => p.stage === "pool").map((p) => p.id), - }); - const voteDenominators = await getProposalStageDenominatorMap(context.env, { - stage: "vote", - proposalIds: proposals.filter((p) => p.stage === "vote").map((p) => p.id), - }); - - const projected = await Promise.all( - proposals.map(async (proposal) => { - const formationEligible = (() => { - const payload = proposal.payload; - if (!payload || typeof payload !== "object" || Array.isArray(payload)) - return true; - const record = payload as Record; - if (record.templateId === "system") return false; - if ( - typeof record.metaGovernance === "object" && - record.metaGovernance !== null && - !Array.isArray(record.metaGovernance) - ) - return false; - if (typeof record.formationEligible === "boolean") - return record.formationEligible; - if (typeof record.formation === "boolean") return record.formation; - return true; - })(); - - const poolCounts = - proposal.stage === "pool" - ? await getPoolVoteCounts(context.env, proposal.id) - : undefined; - const chamberCounts = - proposal.stage === "vote" - ? await getChamberVoteCounts(context.env, proposal.id, { - chamberId: (proposal.chamberId ?? "general").toLowerCase(), - }) - : undefined; - const formationSummary = - proposal.stage === "build" && formationEligible - ? await getFormationSummary(context.env, store, proposal.id).catch( - () => null, - ) - : null; - const stageDenominator = - proposal.stage === "pool" - ? poolDenominators.get(proposal.id)?.activeGovernors - : proposal.stage === "vote" - ? voteDenominators.get(proposal.id)?.activeGovernors - : undefined; - return projectProposalListItem(proposal, { - activeGovernors: stageDenominator ?? activeGovernors, - tier: await resolveUserTierFromSimConfig( - simConfig, - proposal.authorAddress, - ), - now, - voteWindowSeconds, - poolCounts, - chamberCounts, - formationSummary: formationSummary ?? undefined, - }); - }), - ); - - const projectedIds = new Set(projected.map((item) => item.id)); - const merged = [ - ...readModelItems.filter((item) => !projectedIds.has(String(item.id))), - ...projected, - ]; - - const filtered = stage - ? merged.filter((item) => String(item.stage) === stage) - : merged; - - return jsonResponse({ items: filtered }); - } catch (error) { - return errorResponse(500, (error as Error).message); - } -}; diff --git a/api/tsconfig.json b/api/tsconfig.json deleted file mode 100644 index 6080cef..0000000 --- a/api/tsconfig.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "extends": "../tsconfig.json", - "compilerOptions": { - "composite": false, - "noEmit": true, - "lib": ["DOM", "ES2020"] - }, - "include": ["**/*.ts", "**/*.d.ts"], - "exclude": ["../node_modules", "../dist"] -} diff --git a/db/migrations/0000_nosy_mastermind.sql b/db/migrations/0000_nosy_mastermind.sql deleted file mode 100644 index 0231d5a..0000000 --- a/db/migrations/0000_nosy_mastermind.sql +++ /dev/null @@ -1,26 +0,0 @@ -CREATE TABLE "clock_state" ( - "id" integer PRIMARY KEY NOT NULL, - "current_era" integer DEFAULT 0 NOT NULL, - "updated_at" timestamp with time zone DEFAULT now() NOT NULL -); ---> statement-breakpoint -CREATE TABLE "eligibility_cache" ( - "address" text PRIMARY KEY NOT NULL, - "is_active_human_node" integer NOT NULL, - "checked_at" timestamp with time zone DEFAULT now() NOT NULL, - "source" text DEFAULT 'rpc' NOT NULL, - "expires_at" timestamp with time zone NOT NULL, - "reason_code" text -); ---> statement-breakpoint -CREATE TABLE "read_models" ( - "key" text PRIMARY KEY NOT NULL, - "payload" jsonb NOT NULL, - "updated_at" timestamp with time zone DEFAULT now() NOT NULL -); ---> statement-breakpoint -CREATE TABLE "users" ( - "address" text PRIMARY KEY NOT NULL, - "display_name" text, - "created_at" timestamp with time zone DEFAULT now() NOT NULL -); diff --git a/db/migrations/0001_bitter_oracle.sql b/db/migrations/0001_bitter_oracle.sql deleted file mode 100644 index 55fcf80..0000000 --- a/db/migrations/0001_bitter_oracle.sql +++ /dev/null @@ -1,8 +0,0 @@ -CREATE TABLE "auth_nonces" ( - "nonce" text PRIMARY KEY NOT NULL, - "address" text NOT NULL, - "request_ip" text, - "created_at" timestamp with time zone DEFAULT now() NOT NULL, - "expires_at" timestamp with time zone NOT NULL, - "used_at" timestamp with time zone -); diff --git a/db/migrations/0002_dear_betty_ross.sql b/db/migrations/0002_dear_betty_ross.sql deleted file mode 100644 index 12d9b33..0000000 --- a/db/migrations/0002_dear_betty_ross.sql +++ /dev/null @@ -1,10 +0,0 @@ -CREATE TABLE "events" ( - "seq" bigserial PRIMARY KEY NOT NULL, - "type" text NOT NULL, - "stage" text, - "actor_address" text, - "entity_type" text NOT NULL, - "entity_id" text NOT NULL, - "payload" jsonb NOT NULL, - "created_at" timestamp with time zone DEFAULT now() NOT NULL -); diff --git a/db/migrations/0003_cultured_supreme_intelligence.sql b/db/migrations/0003_cultured_supreme_intelligence.sql deleted file mode 100644 index aa74596..0000000 --- a/db/migrations/0003_cultured_supreme_intelligence.sql +++ /dev/null @@ -1,16 +0,0 @@ -CREATE TABLE "idempotency_keys" ( - "key" text PRIMARY KEY NOT NULL, - "address" text NOT NULL, - "request" jsonb NOT NULL, - "response" jsonb NOT NULL, - "created_at" timestamp with time zone DEFAULT now() NOT NULL -); ---> statement-breakpoint -CREATE TABLE "pool_votes" ( - "proposal_id" text NOT NULL, - "voter_address" text NOT NULL, - "direction" integer NOT NULL, - "created_at" timestamp with time zone DEFAULT now() NOT NULL, - "updated_at" timestamp with time zone DEFAULT now() NOT NULL, - CONSTRAINT "pool_votes_proposal_id_voter_address_pk" PRIMARY KEY("proposal_id","voter_address") -); diff --git a/db/migrations/0004_smiling_iron_man.sql b/db/migrations/0004_smiling_iron_man.sql deleted file mode 100644 index 374cecf..0000000 --- a/db/migrations/0004_smiling_iron_man.sql +++ /dev/null @@ -1,9 +0,0 @@ -CREATE TABLE "chamber_votes" ( - "proposal_id" text NOT NULL, - "voter_address" text NOT NULL, - "choice" integer NOT NULL, - "created_at" timestamp with time zone DEFAULT now() NOT NULL, - "updated_at" timestamp with time zone DEFAULT now() NOT NULL, - CONSTRAINT "chamber_votes_proposal_id_voter_address_pk" PRIMARY KEY("proposal_id","voter_address") -); - diff --git a/db/migrations/0005_warm_alex_wilder.sql b/db/migrations/0005_warm_alex_wilder.sql deleted file mode 100644 index 623e557..0000000 --- a/db/migrations/0005_warm_alex_wilder.sql +++ /dev/null @@ -1,2 +0,0 @@ -ALTER TABLE "chamber_votes" ADD COLUMN "score" integer; - diff --git a/db/migrations/0006_heavy_hidden_dream.sql b/db/migrations/0006_heavy_hidden_dream.sql deleted file mode 100644 index 60a8829..0000000 --- a/db/migrations/0006_heavy_hidden_dream.sql +++ /dev/null @@ -1,13 +0,0 @@ -CREATE TABLE "cm_awards" ( - "id" bigserial PRIMARY KEY NOT NULL, - "proposal_id" text NOT NULL, - "proposer_id" text NOT NULL, - "chamber_id" text NOT NULL, - "avg_score" integer, - "lcm_points" integer NOT NULL, - "chamber_multiplier_times10" integer NOT NULL, - "mcm_points" integer NOT NULL, - "created_at" timestamp with time zone DEFAULT now() NOT NULL, - CONSTRAINT "cm_awards_proposal_id_unique" UNIQUE("proposal_id") -); - diff --git a/db/migrations/0007_ancient_formation.sql b/db/migrations/0007_ancient_formation.sql deleted file mode 100644 index 6f128e2..0000000 --- a/db/migrations/0007_ancient_formation.sql +++ /dev/null @@ -1,39 +0,0 @@ -CREATE TABLE "formation_projects" ( - "proposal_id" text PRIMARY KEY NOT NULL, - "team_slots_total" integer NOT NULL, - "base_team_filled" integer DEFAULT 0 NOT NULL, - "milestones_total" integer NOT NULL, - "base_milestones_completed" integer DEFAULT 0 NOT NULL, - "budget_total_hmnd" integer, - "base_budget_allocated_hmnd" integer, - "created_at" timestamp with time zone DEFAULT now() NOT NULL, - "updated_at" timestamp with time zone DEFAULT now() NOT NULL -); - -CREATE TABLE "formation_team" ( - "proposal_id" text NOT NULL, - "member_address" text NOT NULL, - "role" text, - "created_at" timestamp with time zone DEFAULT now() NOT NULL, - "updated_at" timestamp with time zone DEFAULT now() NOT NULL, - CONSTRAINT "formation_team_pk" PRIMARY KEY("proposal_id","member_address") -); - -CREATE TABLE "formation_milestones" ( - "proposal_id" text NOT NULL, - "milestone_index" integer NOT NULL, - "status" text NOT NULL, - "created_at" timestamp with time zone DEFAULT now() NOT NULL, - "updated_at" timestamp with time zone DEFAULT now() NOT NULL, - CONSTRAINT "formation_milestones_pk" PRIMARY KEY("proposal_id","milestone_index") -); - -CREATE TABLE "formation_milestone_events" ( - "id" bigserial PRIMARY KEY NOT NULL, - "proposal_id" text NOT NULL, - "milestone_index" integer NOT NULL, - "type" text NOT NULL, - "actor_address" text, - "payload" jsonb NOT NULL, - "created_at" timestamp with time zone DEFAULT now() NOT NULL -); diff --git a/db/migrations/0008_courts_v1.sql b/db/migrations/0008_courts_v1.sql deleted file mode 100644 index ffa0e1d..0000000 --- a/db/migrations/0008_courts_v1.sql +++ /dev/null @@ -1,24 +0,0 @@ -CREATE TABLE "court_cases" ( - "id" text PRIMARY KEY NOT NULL, - "status" text NOT NULL, - "base_reports" integer DEFAULT 0 NOT NULL, - "opened" text, - "created_at" timestamp with time zone DEFAULT now() NOT NULL, - "updated_at" timestamp with time zone DEFAULT now() NOT NULL -); - -CREATE TABLE "court_reports" ( - "case_id" text NOT NULL, - "reporter_address" text NOT NULL, - "created_at" timestamp with time zone DEFAULT now() NOT NULL, - CONSTRAINT "court_reports_pk" PRIMARY KEY("case_id","reporter_address") -); - -CREATE TABLE "court_verdicts" ( - "case_id" text NOT NULL, - "voter_address" text NOT NULL, - "verdict" text NOT NULL, - "created_at" timestamp with time zone DEFAULT now() NOT NULL, - "updated_at" timestamp with time zone DEFAULT now() NOT NULL, - CONSTRAINT "court_verdicts_pk" PRIMARY KEY("case_id","voter_address") -); diff --git a/db/migrations/0009_era_rollups.sql b/db/migrations/0009_era_rollups.sql deleted file mode 100644 index 6e5d3c8..0000000 --- a/db/migrations/0009_era_rollups.sql +++ /dev/null @@ -1,16 +0,0 @@ -CREATE TABLE "era_snapshots" ( - "era" integer PRIMARY KEY NOT NULL, - "active_governors" integer NOT NULL, - "created_at" timestamp with time zone DEFAULT now() NOT NULL -); - -CREATE TABLE "era_user_activity" ( - "era" integer NOT NULL, - "address" text NOT NULL, - "pool_votes" integer DEFAULT 0 NOT NULL, - "chamber_votes" integer DEFAULT 0 NOT NULL, - "court_actions" integer DEFAULT 0 NOT NULL, - "formation_actions" integer DEFAULT 0 NOT NULL, - "updated_at" timestamp with time zone DEFAULT now() NOT NULL, - CONSTRAINT "era_user_activity_pk" PRIMARY KEY("era","address") -); diff --git a/db/migrations/0010_era_rollup_status.sql b/db/migrations/0010_era_rollup_status.sql deleted file mode 100644 index c2cb0dc..0000000 --- a/db/migrations/0010_era_rollup_status.sql +++ /dev/null @@ -1,26 +0,0 @@ -CREATE TABLE "era_rollups" ( - "era" integer PRIMARY KEY NOT NULL, - "required_pool_votes" integer DEFAULT 0 NOT NULL, - "required_chamber_votes" integer DEFAULT 0 NOT NULL, - "required_court_actions" integer DEFAULT 0 NOT NULL, - "required_formation_actions" integer DEFAULT 0 NOT NULL, - "required_total" integer DEFAULT 0 NOT NULL, - "active_governors_next_era" integer DEFAULT 0 NOT NULL, - "rolled_at" timestamp with time zone DEFAULT now() NOT NULL -); - -CREATE TABLE "era_user_status" ( - "era" integer NOT NULL, - "address" text NOT NULL, - "status" text NOT NULL, - "required_total" integer DEFAULT 0 NOT NULL, - "completed_total" integer DEFAULT 0 NOT NULL, - "is_active_next_era" boolean DEFAULT false NOT NULL, - "pool_votes" integer DEFAULT 0 NOT NULL, - "chamber_votes" integer DEFAULT 0 NOT NULL, - "court_actions" integer DEFAULT 0 NOT NULL, - "formation_actions" integer DEFAULT 0 NOT NULL, - "created_at" timestamp with time zone DEFAULT now() NOT NULL, - CONSTRAINT "era_user_status_pk" PRIMARY KEY("era","address") -); - diff --git a/db/migrations/0011_api_rate_limits.sql b/db/migrations/0011_api_rate_limits.sql deleted file mode 100644 index d44adc9..0000000 --- a/db/migrations/0011_api_rate_limits.sql +++ /dev/null @@ -1,7 +0,0 @@ -CREATE TABLE "api_rate_limits" ( - "bucket" text PRIMARY KEY NOT NULL, - "count" integer DEFAULT 0 NOT NULL, - "reset_at" timestamp with time zone NOT NULL, - "updated_at" timestamp with time zone DEFAULT now() NOT NULL -); - diff --git a/db/migrations/0012_user_action_locks.sql b/db/migrations/0012_user_action_locks.sql deleted file mode 100644 index 87dc74e..0000000 --- a/db/migrations/0012_user_action_locks.sql +++ /dev/null @@ -1,7 +0,0 @@ -CREATE TABLE "user_action_locks" ( - "address" text PRIMARY KEY NOT NULL, - "locked_until" timestamp with time zone NOT NULL, - "reason" text, - "updated_at" timestamp with time zone DEFAULT now() NOT NULL -); - diff --git a/db/migrations/0013_admin_state.sql b/db/migrations/0013_admin_state.sql deleted file mode 100644 index e086839..0000000 --- a/db/migrations/0013_admin_state.sql +++ /dev/null @@ -1,6 +0,0 @@ -CREATE TABLE "admin_state" ( - "id" integer PRIMARY KEY NOT NULL, - "writes_frozen" boolean DEFAULT false NOT NULL, - "updated_at" timestamp with time zone DEFAULT now() NOT NULL -); - diff --git a/db/migrations/0014_proposal_drafts.sql b/db/migrations/0014_proposal_drafts.sql deleted file mode 100644 index 6017be2..0000000 --- a/db/migrations/0014_proposal_drafts.sql +++ /dev/null @@ -1,12 +0,0 @@ -CREATE TABLE "proposal_drafts" ( - "id" text PRIMARY KEY NOT NULL, - "author_address" text NOT NULL, - "title" text NOT NULL, - "chamber_id" text, - "summary" text NOT NULL, - "payload" jsonb NOT NULL, - "submitted_at" timestamp with time zone, - "submitted_proposal_id" text, - "created_at" timestamp with time zone DEFAULT now() NOT NULL, - "updated_at" timestamp with time zone DEFAULT now() NOT NULL -); diff --git a/db/migrations/0015_proposals.sql b/db/migrations/0015_proposals.sql deleted file mode 100644 index 8c2f9de..0000000 --- a/db/migrations/0015_proposals.sql +++ /dev/null @@ -1,12 +0,0 @@ -CREATE TABLE "proposals" ( - "id" text PRIMARY KEY NOT NULL, - "stage" text NOT NULL, - "author_address" text NOT NULL, - "title" text NOT NULL, - "chamber_id" text, - "summary" text NOT NULL, - "payload" jsonb NOT NULL, - "created_at" timestamp with time zone DEFAULT now() NOT NULL, - "updated_at" timestamp with time zone DEFAULT now() NOT NULL -); - diff --git a/db/migrations/0016_chamber_memberships.sql b/db/migrations/0016_chamber_memberships.sql deleted file mode 100644 index 3898f67..0000000 --- a/db/migrations/0016_chamber_memberships.sql +++ /dev/null @@ -1,9 +0,0 @@ -CREATE TABLE "chamber_memberships" ( - "chamber_id" text NOT NULL, - "address" text NOT NULL, - "granted_by_proposal_id" text, - "source" text DEFAULT 'accepted_proposal' NOT NULL, - "created_at" timestamp with time zone DEFAULT now() NOT NULL, - CONSTRAINT "chamber_memberships_chamber_id_address_pk" PRIMARY KEY("chamber_id","address") -); - diff --git a/db/migrations/0017_chambers.sql b/db/migrations/0017_chambers.sql deleted file mode 100644 index 80f414d..0000000 --- a/db/migrations/0017_chambers.sql +++ /dev/null @@ -1,12 +0,0 @@ -CREATE TABLE "chambers" ( - "id" text PRIMARY KEY NOT NULL, - "title" text NOT NULL, - "status" text DEFAULT 'active' NOT NULL, - "multiplier_times10" integer DEFAULT 10 NOT NULL, - "created_by_proposal_id" text, - "dissolved_by_proposal_id" text, - "metadata" jsonb DEFAULT '{}'::jsonb NOT NULL, - "created_at" timestamp with time zone DEFAULT now() NOT NULL, - "updated_at" timestamp with time zone DEFAULT now() NOT NULL, - "dissolved_at" timestamp with time zone -); diff --git a/db/migrations/0018_proposal_stage_denominators.sql b/db/migrations/0018_proposal_stage_denominators.sql deleted file mode 100644 index b69d00a..0000000 --- a/db/migrations/0018_proposal_stage_denominators.sql +++ /dev/null @@ -1,8 +0,0 @@ -CREATE TABLE "proposal_stage_denominators" ( - "proposal_id" text NOT NULL, - "stage" text NOT NULL, - "era" integer NOT NULL, - "active_governors" integer NOT NULL, - "captured_at" timestamp with time zone DEFAULT now() NOT NULL, - CONSTRAINT "proposal_stage_denominators_proposal_id_stage_pk" PRIMARY KEY("proposal_id","stage") -); diff --git a/db/migrations/0019_delegations.sql b/db/migrations/0019_delegations.sql deleted file mode 100644 index 072c44c..0000000 --- a/db/migrations/0019_delegations.sql +++ /dev/null @@ -1,17 +0,0 @@ -CREATE TABLE "delegations" ( - "chamber_id" text NOT NULL, - "delegator_address" text NOT NULL, - "delegatee_address" text NOT NULL, - "created_at" timestamp with time zone DEFAULT now() NOT NULL, - "updated_at" timestamp with time zone DEFAULT now() NOT NULL, - CONSTRAINT "delegations_chamber_id_delegator_address_pk" PRIMARY KEY("chamber_id","delegator_address") -); ---> statement-breakpoint -CREATE TABLE "delegation_events" ( - "seq" bigserial PRIMARY KEY NOT NULL, - "chamber_id" text NOT NULL, - "delegator_address" text NOT NULL, - "delegatee_address" text, - "type" text NOT NULL, - "created_at" timestamp with time zone DEFAULT now() NOT NULL -); diff --git a/db/migrations/0020_veto.sql b/db/migrations/0020_veto.sql deleted file mode 100644 index 507b1ae..0000000 --- a/db/migrations/0020_veto.sql +++ /dev/null @@ -1,14 +0,0 @@ -ALTER TABLE "proposals" ADD COLUMN "veto_count" integer DEFAULT 0 NOT NULL; -ALTER TABLE "proposals" ADD COLUMN "vote_passed_at" timestamp with time zone; -ALTER TABLE "proposals" ADD COLUMN "vote_finalizes_at" timestamp with time zone; -ALTER TABLE "proposals" ADD COLUMN "veto_council" jsonb; -ALTER TABLE "proposals" ADD COLUMN "veto_threshold" integer; - -CREATE TABLE "veto_votes" ( - "proposal_id" text NOT NULL, - "voter_address" text NOT NULL, - "choice" text NOT NULL, - "created_at" timestamp with time zone DEFAULT now() NOT NULL, - "updated_at" timestamp with time zone DEFAULT now() NOT NULL, - CONSTRAINT "veto_votes_proposal_id_voter_address_pk" PRIMARY KEY("proposal_id","voter_address") -); diff --git a/db/migrations/0021_chamber_multiplier_submissions.sql b/db/migrations/0021_chamber_multiplier_submissions.sql deleted file mode 100644 index aa89997..0000000 --- a/db/migrations/0021_chamber_multiplier_submissions.sql +++ /dev/null @@ -1,11 +0,0 @@ --- v1: chamber multiplier voting submissions (outsiders-only aggregation) - -CREATE TABLE "chamber_multiplier_submissions" ( - "chamber_id" text NOT NULL, - "voter_address" text NOT NULL, - "multiplier_times10" integer NOT NULL, - "created_at" timestamp with time zone DEFAULT now() NOT NULL, - "updated_at" timestamp with time zone DEFAULT now() NOT NULL, - CONSTRAINT "chamber_multiplier_submissions_chamber_id_voter_address_pk" PRIMARY KEY("chamber_id","voter_address"), - CONSTRAINT "chamber_multiplier_submissions_multiplier_range" CHECK ("multiplier_times10" >= 1 AND "multiplier_times10" <= 100) -); diff --git a/db/migrations/meta/0000_snapshot.json b/db/migrations/meta/0000_snapshot.json deleted file mode 100644 index 29bd2cc..0000000 --- a/db/migrations/meta/0000_snapshot.json +++ /dev/null @@ -1,167 +0,0 @@ -{ - "id": "1416fd76-c86c-4384-90bd-43856c9d4db3", - "prevId": "00000000-0000-0000-0000-000000000000", - "version": "7", - "dialect": "postgresql", - "tables": { - "public.clock_state": { - "name": "clock_state", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "integer", - "primaryKey": true, - "notNull": true - }, - "current_era": { - "name": "current_era", - "type": "integer", - "primaryKey": false, - "notNull": true, - "default": 0 - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.eligibility_cache": { - "name": "eligibility_cache", - "schema": "", - "columns": { - "address": { - "name": "address", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "is_active_human_node": { - "name": "is_active_human_node", - "type": "integer", - "primaryKey": false, - "notNull": true - }, - "checked_at": { - "name": "checked_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "source": { - "name": "source", - "type": "text", - "primaryKey": false, - "notNull": true, - "default": "'rpc'" - }, - "expires_at": { - "name": "expires_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true - }, - "reason_code": { - "name": "reason_code", - "type": "text", - "primaryKey": false, - "notNull": false - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.read_models": { - "name": "read_models", - "schema": "", - "columns": { - "key": { - "name": "key", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "payload": { - "name": "payload", - "type": "jsonb", - "primaryKey": false, - "notNull": true - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.users": { - "name": "users", - "schema": "", - "columns": { - "address": { - "name": "address", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "display_name": { - "name": "display_name", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - } - }, - "enums": {}, - "schemas": {}, - "sequences": {}, - "roles": {}, - "policies": {}, - "views": {}, - "_meta": { - "columns": {}, - "schemas": {}, - "tables": {} - } -} diff --git a/db/migrations/meta/0001_snapshot.json b/db/migrations/meta/0001_snapshot.json deleted file mode 100644 index 990c022..0000000 --- a/db/migrations/meta/0001_snapshot.json +++ /dev/null @@ -1,217 +0,0 @@ -{ - "id": "b35347c6-133d-469b-a78b-62653ba59142", - "prevId": "1416fd76-c86c-4384-90bd-43856c9d4db3", - "version": "7", - "dialect": "postgresql", - "tables": { - "public.auth_nonces": { - "name": "auth_nonces", - "schema": "", - "columns": { - "nonce": { - "name": "nonce", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "address": { - "name": "address", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "request_ip": { - "name": "request_ip", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "expires_at": { - "name": "expires_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true - }, - "used_at": { - "name": "used_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.clock_state": { - "name": "clock_state", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "integer", - "primaryKey": true, - "notNull": true - }, - "current_era": { - "name": "current_era", - "type": "integer", - "primaryKey": false, - "notNull": true, - "default": 0 - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.eligibility_cache": { - "name": "eligibility_cache", - "schema": "", - "columns": { - "address": { - "name": "address", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "is_active_human_node": { - "name": "is_active_human_node", - "type": "integer", - "primaryKey": false, - "notNull": true - }, - "checked_at": { - "name": "checked_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "source": { - "name": "source", - "type": "text", - "primaryKey": false, - "notNull": true, - "default": "'rpc'" - }, - "expires_at": { - "name": "expires_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true - }, - "reason_code": { - "name": "reason_code", - "type": "text", - "primaryKey": false, - "notNull": false - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.read_models": { - "name": "read_models", - "schema": "", - "columns": { - "key": { - "name": "key", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "payload": { - "name": "payload", - "type": "jsonb", - "primaryKey": false, - "notNull": true - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.users": { - "name": "users", - "schema": "", - "columns": { - "address": { - "name": "address", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "display_name": { - "name": "display_name", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - } - }, - "enums": {}, - "schemas": {}, - "sequences": {}, - "roles": {}, - "policies": {}, - "views": {}, - "_meta": { - "columns": {}, - "schemas": {}, - "tables": {} - } -} diff --git a/db/migrations/meta/0002_snapshot.json b/db/migrations/meta/0002_snapshot.json deleted file mode 100644 index 2a02ec1..0000000 --- a/db/migrations/meta/0002_snapshot.json +++ /dev/null @@ -1,279 +0,0 @@ -{ - "id": "100019a4-96ff-414a-94ef-d99b7496527f", - "prevId": "b35347c6-133d-469b-a78b-62653ba59142", - "version": "7", - "dialect": "postgresql", - "tables": { - "public.auth_nonces": { - "name": "auth_nonces", - "schema": "", - "columns": { - "nonce": { - "name": "nonce", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "address": { - "name": "address", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "request_ip": { - "name": "request_ip", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "expires_at": { - "name": "expires_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true - }, - "used_at": { - "name": "used_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.clock_state": { - "name": "clock_state", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "integer", - "primaryKey": true, - "notNull": true - }, - "current_era": { - "name": "current_era", - "type": "integer", - "primaryKey": false, - "notNull": true, - "default": 0 - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.eligibility_cache": { - "name": "eligibility_cache", - "schema": "", - "columns": { - "address": { - "name": "address", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "is_active_human_node": { - "name": "is_active_human_node", - "type": "integer", - "primaryKey": false, - "notNull": true - }, - "checked_at": { - "name": "checked_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "source": { - "name": "source", - "type": "text", - "primaryKey": false, - "notNull": true, - "default": "'rpc'" - }, - "expires_at": { - "name": "expires_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true - }, - "reason_code": { - "name": "reason_code", - "type": "text", - "primaryKey": false, - "notNull": false - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.events": { - "name": "events", - "schema": "", - "columns": { - "seq": { - "name": "seq", - "type": "bigserial", - "primaryKey": true, - "notNull": true - }, - "type": { - "name": "type", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "stage": { - "name": "stage", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "actor_address": { - "name": "actor_address", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "entity_type": { - "name": "entity_type", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "entity_id": { - "name": "entity_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "payload": { - "name": "payload", - "type": "jsonb", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.read_models": { - "name": "read_models", - "schema": "", - "columns": { - "key": { - "name": "key", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "payload": { - "name": "payload", - "type": "jsonb", - "primaryKey": false, - "notNull": true - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.users": { - "name": "users", - "schema": "", - "columns": { - "address": { - "name": "address", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "display_name": { - "name": "display_name", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - } - }, - "enums": {}, - "schemas": {}, - "sequences": {}, - "roles": {}, - "policies": {}, - "views": {}, - "_meta": { - "columns": {}, - "schemas": {}, - "tables": {} - } -} diff --git a/db/migrations/meta/0003_snapshot.json b/db/migrations/meta/0003_snapshot.json deleted file mode 100644 index a23cc58..0000000 --- a/db/migrations/meta/0003_snapshot.json +++ /dev/null @@ -1,373 +0,0 @@ -{ - "id": "c5b8f5c7-1d23-4738-8a05-7bfb2203266f", - "prevId": "100019a4-96ff-414a-94ef-d99b7496527f", - "version": "7", - "dialect": "postgresql", - "tables": { - "public.auth_nonces": { - "name": "auth_nonces", - "schema": "", - "columns": { - "nonce": { - "name": "nonce", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "address": { - "name": "address", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "request_ip": { - "name": "request_ip", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "expires_at": { - "name": "expires_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true - }, - "used_at": { - "name": "used_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.clock_state": { - "name": "clock_state", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "integer", - "primaryKey": true, - "notNull": true - }, - "current_era": { - "name": "current_era", - "type": "integer", - "primaryKey": false, - "notNull": true, - "default": 0 - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.eligibility_cache": { - "name": "eligibility_cache", - "schema": "", - "columns": { - "address": { - "name": "address", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "is_active_human_node": { - "name": "is_active_human_node", - "type": "integer", - "primaryKey": false, - "notNull": true - }, - "checked_at": { - "name": "checked_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "source": { - "name": "source", - "type": "text", - "primaryKey": false, - "notNull": true, - "default": "'rpc'" - }, - "expires_at": { - "name": "expires_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true - }, - "reason_code": { - "name": "reason_code", - "type": "text", - "primaryKey": false, - "notNull": false - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.events": { - "name": "events", - "schema": "", - "columns": { - "seq": { - "name": "seq", - "type": "bigserial", - "primaryKey": true, - "notNull": true - }, - "type": { - "name": "type", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "stage": { - "name": "stage", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "actor_address": { - "name": "actor_address", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "entity_type": { - "name": "entity_type", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "entity_id": { - "name": "entity_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "payload": { - "name": "payload", - "type": "jsonb", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.idempotency_keys": { - "name": "idempotency_keys", - "schema": "", - "columns": { - "key": { - "name": "key", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "address": { - "name": "address", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "request": { - "name": "request", - "type": "jsonb", - "primaryKey": false, - "notNull": true - }, - "response": { - "name": "response", - "type": "jsonb", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.pool_votes": { - "name": "pool_votes", - "schema": "", - "columns": { - "proposal_id": { - "name": "proposal_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "voter_address": { - "name": "voter_address", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "direction": { - "name": "direction", - "type": "integer", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": { - "pool_votes_proposal_id_voter_address_pk": { - "name": "pool_votes_proposal_id_voter_address_pk", - "columns": ["proposal_id", "voter_address"] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.read_models": { - "name": "read_models", - "schema": "", - "columns": { - "key": { - "name": "key", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "payload": { - "name": "payload", - "type": "jsonb", - "primaryKey": false, - "notNull": true - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.users": { - "name": "users", - "schema": "", - "columns": { - "address": { - "name": "address", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "display_name": { - "name": "display_name", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - } - }, - "enums": {}, - "schemas": {}, - "sequences": {}, - "roles": {}, - "policies": {}, - "views": {}, - "_meta": { - "columns": {}, - "schemas": {}, - "tables": {} - } -} diff --git a/db/migrations/meta/0004_snapshot.json b/db/migrations/meta/0004_snapshot.json deleted file mode 100644 index e80e107..0000000 --- a/db/migrations/meta/0004_snapshot.json +++ /dev/null @@ -1,1305 +0,0 @@ -{ - "id": "8bc8a52d-7433-4a5e-a9d5-284a19c00c25", - "prevId": "c5b8f5c7-1d23-4738-8a05-7bfb2203266f", - "version": "7", - "dialect": "postgresql", - "tables": { - "public.admin_state": { - "name": "admin_state", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "integer", - "primaryKey": true, - "notNull": true - }, - "writes_frozen": { - "name": "writes_frozen", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": false - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.api_rate_limits": { - "name": "api_rate_limits", - "schema": "", - "columns": { - "bucket": { - "name": "bucket", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "count": { - "name": "count", - "type": "integer", - "primaryKey": false, - "notNull": true, - "default": 0 - }, - "reset_at": { - "name": "reset_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.auth_nonces": { - "name": "auth_nonces", - "schema": "", - "columns": { - "nonce": { - "name": "nonce", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "address": { - "name": "address", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "request_ip": { - "name": "request_ip", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "expires_at": { - "name": "expires_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true - }, - "used_at": { - "name": "used_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.chamber_votes": { - "name": "chamber_votes", - "schema": "", - "columns": { - "proposal_id": { - "name": "proposal_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "voter_address": { - "name": "voter_address", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "choice": { - "name": "choice", - "type": "integer", - "primaryKey": false, - "notNull": true - }, - "score": { - "name": "score", - "type": "integer", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": { - "chamber_votes_proposal_id_voter_address_pk": { - "name": "chamber_votes_proposal_id_voter_address_pk", - "columns": ["proposal_id", "voter_address"] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.clock_state": { - "name": "clock_state", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "integer", - "primaryKey": true, - "notNull": true - }, - "current_era": { - "name": "current_era", - "type": "integer", - "primaryKey": false, - "notNull": true, - "default": 0 - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.cm_awards": { - "name": "cm_awards", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "bigserial", - "primaryKey": true, - "notNull": true - }, - "proposal_id": { - "name": "proposal_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "proposer_id": { - "name": "proposer_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "chamber_id": { - "name": "chamber_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "avg_score": { - "name": "avg_score", - "type": "integer", - "primaryKey": false, - "notNull": false - }, - "lcm_points": { - "name": "lcm_points", - "type": "integer", - "primaryKey": false, - "notNull": true - }, - "chamber_multiplier_times10": { - "name": "chamber_multiplier_times10", - "type": "integer", - "primaryKey": false, - "notNull": true - }, - "mcm_points": { - "name": "mcm_points", - "type": "integer", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.court_cases": { - "name": "court_cases", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "status": { - "name": "status", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "base_reports": { - "name": "base_reports", - "type": "integer", - "primaryKey": false, - "notNull": true, - "default": 0 - }, - "opened": { - "name": "opened", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.court_reports": { - "name": "court_reports", - "schema": "", - "columns": { - "case_id": { - "name": "case_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "reporter_address": { - "name": "reporter_address", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": { - "court_reports_case_id_reporter_address_pk": { - "name": "court_reports_case_id_reporter_address_pk", - "columns": ["case_id", "reporter_address"] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.court_verdicts": { - "name": "court_verdicts", - "schema": "", - "columns": { - "case_id": { - "name": "case_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "voter_address": { - "name": "voter_address", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "verdict": { - "name": "verdict", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": { - "court_verdicts_case_id_voter_address_pk": { - "name": "court_verdicts_case_id_voter_address_pk", - "columns": ["case_id", "voter_address"] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.eligibility_cache": { - "name": "eligibility_cache", - "schema": "", - "columns": { - "address": { - "name": "address", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "is_active_human_node": { - "name": "is_active_human_node", - "type": "integer", - "primaryKey": false, - "notNull": true - }, - "checked_at": { - "name": "checked_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "source": { - "name": "source", - "type": "text", - "primaryKey": false, - "notNull": true, - "default": "'rpc'" - }, - "expires_at": { - "name": "expires_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true - }, - "reason_code": { - "name": "reason_code", - "type": "text", - "primaryKey": false, - "notNull": false - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.era_rollups": { - "name": "era_rollups", - "schema": "", - "columns": { - "era": { - "name": "era", - "type": "integer", - "primaryKey": true, - "notNull": true - }, - "required_pool_votes": { - "name": "required_pool_votes", - "type": "integer", - "primaryKey": false, - "notNull": true, - "default": 0 - }, - "required_chamber_votes": { - "name": "required_chamber_votes", - "type": "integer", - "primaryKey": false, - "notNull": true, - "default": 0 - }, - "required_court_actions": { - "name": "required_court_actions", - "type": "integer", - "primaryKey": false, - "notNull": true, - "default": 0 - }, - "required_formation_actions": { - "name": "required_formation_actions", - "type": "integer", - "primaryKey": false, - "notNull": true, - "default": 0 - }, - "required_total": { - "name": "required_total", - "type": "integer", - "primaryKey": false, - "notNull": true, - "default": 0 - }, - "active_governors_next_era": { - "name": "active_governors_next_era", - "type": "integer", - "primaryKey": false, - "notNull": true, - "default": 0 - }, - "rolled_at": { - "name": "rolled_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.era_snapshots": { - "name": "era_snapshots", - "schema": "", - "columns": { - "era": { - "name": "era", - "type": "integer", - "primaryKey": true, - "notNull": true - }, - "active_governors": { - "name": "active_governors", - "type": "integer", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.era_user_activity": { - "name": "era_user_activity", - "schema": "", - "columns": { - "era": { - "name": "era", - "type": "integer", - "primaryKey": false, - "notNull": true - }, - "address": { - "name": "address", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "pool_votes": { - "name": "pool_votes", - "type": "integer", - "primaryKey": false, - "notNull": true, - "default": 0 - }, - "chamber_votes": { - "name": "chamber_votes", - "type": "integer", - "primaryKey": false, - "notNull": true, - "default": 0 - }, - "court_actions": { - "name": "court_actions", - "type": "integer", - "primaryKey": false, - "notNull": true, - "default": 0 - }, - "formation_actions": { - "name": "formation_actions", - "type": "integer", - "primaryKey": false, - "notNull": true, - "default": 0 - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": { - "era_user_activity_era_address_pk": { - "name": "era_user_activity_era_address_pk", - "columns": ["era", "address"] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.era_user_status": { - "name": "era_user_status", - "schema": "", - "columns": { - "era": { - "name": "era", - "type": "integer", - "primaryKey": false, - "notNull": true - }, - "address": { - "name": "address", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "status": { - "name": "status", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "required_total": { - "name": "required_total", - "type": "integer", - "primaryKey": false, - "notNull": true, - "default": 0 - }, - "completed_total": { - "name": "completed_total", - "type": "integer", - "primaryKey": false, - "notNull": true, - "default": 0 - }, - "is_active_next_era": { - "name": "is_active_next_era", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": false - }, - "pool_votes": { - "name": "pool_votes", - "type": "integer", - "primaryKey": false, - "notNull": true, - "default": 0 - }, - "chamber_votes": { - "name": "chamber_votes", - "type": "integer", - "primaryKey": false, - "notNull": true, - "default": 0 - }, - "court_actions": { - "name": "court_actions", - "type": "integer", - "primaryKey": false, - "notNull": true, - "default": 0 - }, - "formation_actions": { - "name": "formation_actions", - "type": "integer", - "primaryKey": false, - "notNull": true, - "default": 0 - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": { - "era_user_status_era_address_pk": { - "name": "era_user_status_era_address_pk", - "columns": ["era", "address"] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.events": { - "name": "events", - "schema": "", - "columns": { - "seq": { - "name": "seq", - "type": "bigserial", - "primaryKey": true, - "notNull": true - }, - "type": { - "name": "type", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "stage": { - "name": "stage", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "actor_address": { - "name": "actor_address", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "entity_type": { - "name": "entity_type", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "entity_id": { - "name": "entity_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "payload": { - "name": "payload", - "type": "jsonb", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.formation_milestone_events": { - "name": "formation_milestone_events", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "bigserial", - "primaryKey": true, - "notNull": true - }, - "proposal_id": { - "name": "proposal_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "milestone_index": { - "name": "milestone_index", - "type": "integer", - "primaryKey": false, - "notNull": true - }, - "type": { - "name": "type", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "actor_address": { - "name": "actor_address", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "payload": { - "name": "payload", - "type": "jsonb", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.formation_milestones": { - "name": "formation_milestones", - "schema": "", - "columns": { - "proposal_id": { - "name": "proposal_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "milestone_index": { - "name": "milestone_index", - "type": "integer", - "primaryKey": false, - "notNull": true - }, - "status": { - "name": "status", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": { - "formation_milestones_proposal_id_milestone_index_pk": { - "name": "formation_milestones_proposal_id_milestone_index_pk", - "columns": ["proposal_id", "milestone_index"] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.formation_projects": { - "name": "formation_projects", - "schema": "", - "columns": { - "proposal_id": { - "name": "proposal_id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "team_slots_total": { - "name": "team_slots_total", - "type": "integer", - "primaryKey": false, - "notNull": true - }, - "base_team_filled": { - "name": "base_team_filled", - "type": "integer", - "primaryKey": false, - "notNull": true, - "default": 0 - }, - "milestones_total": { - "name": "milestones_total", - "type": "integer", - "primaryKey": false, - "notNull": true - }, - "base_milestones_completed": { - "name": "base_milestones_completed", - "type": "integer", - "primaryKey": false, - "notNull": true, - "default": 0 - }, - "budget_total_hmnd": { - "name": "budget_total_hmnd", - "type": "integer", - "primaryKey": false, - "notNull": false - }, - "base_budget_allocated_hmnd": { - "name": "base_budget_allocated_hmnd", - "type": "integer", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.formation_team": { - "name": "formation_team", - "schema": "", - "columns": { - "proposal_id": { - "name": "proposal_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "member_address": { - "name": "member_address", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "role": { - "name": "role", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": { - "formation_team_proposal_id_member_address_pk": { - "name": "formation_team_proposal_id_member_address_pk", - "columns": ["proposal_id", "member_address"] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.idempotency_keys": { - "name": "idempotency_keys", - "schema": "", - "columns": { - "key": { - "name": "key", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "address": { - "name": "address", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "request": { - "name": "request", - "type": "jsonb", - "primaryKey": false, - "notNull": true - }, - "response": { - "name": "response", - "type": "jsonb", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.pool_votes": { - "name": "pool_votes", - "schema": "", - "columns": { - "proposal_id": { - "name": "proposal_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "voter_address": { - "name": "voter_address", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "direction": { - "name": "direction", - "type": "integer", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": { - "pool_votes_proposal_id_voter_address_pk": { - "name": "pool_votes_proposal_id_voter_address_pk", - "columns": ["proposal_id", "voter_address"] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.proposal_drafts": { - "name": "proposal_drafts", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "author_address": { - "name": "author_address", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "title": { - "name": "title", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "chamber_id": { - "name": "chamber_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "summary": { - "name": "summary", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "payload": { - "name": "payload", - "type": "jsonb", - "primaryKey": false, - "notNull": true - }, - "submitted_at": { - "name": "submitted_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false - }, - "submitted_proposal_id": { - "name": "submitted_proposal_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.read_models": { - "name": "read_models", - "schema": "", - "columns": { - "key": { - "name": "key", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "payload": { - "name": "payload", - "type": "jsonb", - "primaryKey": false, - "notNull": true - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.user_action_locks": { - "name": "user_action_locks", - "schema": "", - "columns": { - "address": { - "name": "address", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "locked_until": { - "name": "locked_until", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true - }, - "reason": { - "name": "reason", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.users": { - "name": "users", - "schema": "", - "columns": { - "address": { - "name": "address", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "display_name": { - "name": "display_name", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - } - }, - "enums": {}, - "schemas": {}, - "sequences": {}, - "roles": {}, - "policies": {}, - "views": {}, - "_meta": { - "columns": {}, - "schemas": {}, - "tables": {} - } -} diff --git a/db/migrations/meta/_journal.json b/db/migrations/meta/_journal.json deleted file mode 100644 index 7cf85d6..0000000 --- a/db/migrations/meta/_journal.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "version": "7", - "dialect": "postgresql", - "entries": [ - { - "idx": 0, - "version": "7", - "when": 1766509637223, - "tag": "0000_nosy_mastermind", - "breakpoints": true - }, - { - "idx": 1, - "version": "7", - "when": 1766533489857, - "tag": "0001_bitter_oracle", - "breakpoints": true - }, - { - "idx": 2, - "version": "7", - "when": 1766670704887, - "tag": "0002_dear_betty_ross", - "breakpoints": true - }, - { - "idx": 3, - "version": "7", - "when": 1766674457787, - "tag": "0003_cultured_supreme_intelligence", - "breakpoints": true - } - ] -} diff --git a/db/schema.ts b/db/schema.ts deleted file mode 100644 index e2fc286..0000000 --- a/db/schema.ts +++ /dev/null @@ -1,499 +0,0 @@ -import { - bigserial, - boolean, - integer, - jsonb, - primaryKey, - pgTable, - text, - timestamp, -} from "drizzle-orm/pg-core"; - -export const adminState = pgTable("admin_state", { - id: integer("id").primaryKey(), - writesFrozen: boolean("writes_frozen").notNull().default(false), - updatedAt: timestamp("updated_at", { withTimezone: true }) - .notNull() - .defaultNow(), -}); - -export const users = pgTable("users", { - address: text("address").primaryKey(), - displayName: text("display_name"), - createdAt: timestamp("created_at", { withTimezone: true }) - .notNull() - .defaultNow(), -}); - -export const authNonces = pgTable("auth_nonces", { - nonce: text("nonce").primaryKey(), - address: text("address").notNull(), - requestIp: text("request_ip"), - createdAt: timestamp("created_at", { withTimezone: true }) - .notNull() - .defaultNow(), - expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(), - usedAt: timestamp("used_at", { withTimezone: true }), -}); - -export const eligibilityCache = pgTable("eligibility_cache", { - address: text("address").primaryKey(), - isActiveHumanNode: integer("is_active_human_node").notNull(), // 0/1 for portability - checkedAt: timestamp("checked_at", { withTimezone: true }) - .notNull() - .defaultNow(), - source: text("source").notNull().default("rpc"), - expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(), - reasonCode: text("reason_code"), -}); - -export const clockState = pgTable("clock_state", { - id: integer("id").primaryKey(), - currentEra: integer("current_era").notNull().default(0), - updatedAt: timestamp("updated_at", { withTimezone: true }) - .notNull() - .defaultNow(), -}); - -// Temporary storage for page read models during Phase 4 migration. -// Seed fixtures live in `db/seed/fixtures/*` while normalized tables + event log are built out. -export const readModels = pgTable("read_models", { - key: text("key").primaryKey(), - payload: jsonb("payload").notNull(), - updatedAt: timestamp("updated_at", { withTimezone: true }) - .notNull() - .defaultNow(), -}); - -// Proposal drafts (Phase 12). -// Drafts are author-owned and are the source of truth for the proposal creation wizard. -export const proposalDrafts = pgTable("proposal_drafts", { - id: text("id").primaryKey(), - authorAddress: text("author_address").notNull(), - title: text("title").notNull(), - chamberId: text("chamber_id"), - summary: text("summary").notNull(), - payload: jsonb("payload").notNull(), - submittedAt: timestamp("submitted_at", { withTimezone: true }), - submittedProposalId: text("submitted_proposal_id"), - createdAt: timestamp("created_at", { withTimezone: true }) - .notNull() - .defaultNow(), - updatedAt: timestamp("updated_at", { withTimezone: true }) - .notNull() - .defaultNow(), -}); - -// Canonical proposals (Phase 14). -// This starts the migration away from `read_models` as source of truth. -export const proposals = pgTable("proposals", { - id: text("id").primaryKey(), - stage: text("stage").notNull(), // pool | vote | build (v1) - authorAddress: text("author_address").notNull(), - title: text("title").notNull(), - chamberId: text("chamber_id"), - summary: text("summary").notNull(), - payload: jsonb("payload").notNull(), // stage-agnostic proposal content (v1: derived from draft) - vetoCount: integer("veto_count").notNull().default(0), - votePassedAt: timestamp("vote_passed_at", { withTimezone: true }), - voteFinalizesAt: timestamp("vote_finalizes_at", { withTimezone: true }), - vetoCouncil: jsonb("veto_council"), - vetoThreshold: integer("veto_threshold"), - createdAt: timestamp("created_at", { withTimezone: true }) - .notNull() - .defaultNow(), - updatedAt: timestamp("updated_at", { withTimezone: true }) - .notNull() - .defaultNow(), -}); - -export const vetoVotes = pgTable( - "veto_votes", - { - proposalId: text("proposal_id").notNull(), - voterAddress: text("voter_address").notNull(), - choice: text("choice").notNull(), // veto | keep - createdAt: timestamp("created_at", { withTimezone: true }) - .notNull() - .defaultNow(), - updatedAt: timestamp("updated_at", { withTimezone: true }) - .notNull() - .defaultNow(), - }, - (t) => ({ - pk: primaryKey({ columns: [t.proposalId, t.voterAddress] }), - }), -); - -export const chamberMultiplierSubmissions = pgTable( - "chamber_multiplier_submissions", - { - chamberId: text("chamber_id").notNull(), - voterAddress: text("voter_address").notNull(), - multiplierTimes10: integer("multiplier_times10").notNull(), - createdAt: timestamp("created_at", { withTimezone: true }) - .notNull() - .defaultNow(), - updatedAt: timestamp("updated_at", { withTimezone: true }) - .notNull() - .defaultNow(), - }, - (t) => ({ - pk: primaryKey({ columns: [t.chamberId, t.voterAddress] }), - }), -); - -// Captures the active-governor denominator at proposal stage entry. -// This prevents quorum math from drifting when eras advance mid-stage. -export const proposalStageDenominators = pgTable( - "proposal_stage_denominators", - { - proposalId: text("proposal_id").notNull(), - stage: text("stage").notNull(), // pool | vote (v1) - era: integer("era").notNull(), - activeGovernors: integer("active_governors").notNull(), - capturedAt: timestamp("captured_at", { withTimezone: true }) - .notNull() - .defaultNow(), - }, - (t) => ({ - pk: primaryKey({ columns: [t.proposalId, t.stage] }), - }), -); - -// Delegation graph (Phase 29). -// Delegation affects chamber vote weight but not proposal-pool attention. -export const delegations = pgTable( - "delegations", - { - chamberId: text("chamber_id").notNull(), - delegatorAddress: text("delegator_address").notNull(), - delegateeAddress: text("delegatee_address").notNull(), - createdAt: timestamp("created_at", { withTimezone: true }) - .notNull() - .defaultNow(), - updatedAt: timestamp("updated_at", { withTimezone: true }) - .notNull() - .defaultNow(), - }, - (t) => ({ - pk: primaryKey({ columns: [t.chamberId, t.delegatorAddress] }), - }), -); - -export const delegationEvents = pgTable("delegation_events", { - seq: bigserial("seq", { mode: "number" }).primaryKey(), - chamberId: text("chamber_id").notNull(), - delegatorAddress: text("delegator_address").notNull(), - delegateeAddress: text("delegatee_address"), - type: text("type").notNull(), // set | clear - createdAt: timestamp("created_at", { withTimezone: true }) - .notNull() - .defaultNow(), -}); - -// Chamber voting eligibility (Phase 17). -// Membership is granted when a proposal is accepted in a chamber. -// General chamber membership is granted when any proposal is accepted anywhere. -export const chamberMemberships = pgTable( - "chamber_memberships", - { - chamberId: text("chamber_id").notNull(), - address: text("address").notNull(), - grantedByProposalId: text("granted_by_proposal_id"), - source: text("source").notNull().default("accepted_proposal"), - createdAt: timestamp("created_at", { withTimezone: true }) - .notNull() - .defaultNow(), - }, - (t) => ({ - pk: primaryKey({ columns: [t.chamberId, t.address] }), - }), -); - -// Canonical chambers (Phase 18). -export const chambers = pgTable("chambers", { - id: text("id").primaryKey(), - title: text("title").notNull(), - status: text("status").notNull().default("active"), // active | dissolved (v1) - multiplierTimes10: integer("multiplier_times10").notNull().default(10), - createdByProposalId: text("created_by_proposal_id"), - dissolvedByProposalId: text("dissolved_by_proposal_id"), - metadata: jsonb("metadata").notNull(), - createdAt: timestamp("created_at", { withTimezone: true }) - .notNull() - .defaultNow(), - updatedAt: timestamp("updated_at", { withTimezone: true }) - .notNull() - .defaultNow(), - dissolvedAt: timestamp("dissolved_at", { withTimezone: true }), -}); - -// Append-only event log backbone (Phase 5). -export const events = pgTable("events", { - seq: bigserial("seq", { mode: "number" }).primaryKey(), - type: text("type").notNull(), - stage: text("stage"), - actorAddress: text("actor_address"), - entityType: text("entity_type").notNull(), - entityId: text("entity_id").notNull(), - payload: jsonb("payload").notNull(), - createdAt: timestamp("created_at", { withTimezone: true }) - .notNull() - .defaultNow(), -}); - -export const poolVotes = pgTable( - "pool_votes", - { - proposalId: text("proposal_id").notNull(), - voterAddress: text("voter_address").notNull(), - direction: integer("direction").notNull(), // 1 (upvote) or -1 (downvote) - createdAt: timestamp("created_at", { withTimezone: true }) - .notNull() - .defaultNow(), - updatedAt: timestamp("updated_at", { withTimezone: true }) - .notNull() - .defaultNow(), - }, - (t) => ({ - pk: primaryKey({ columns: [t.proposalId, t.voterAddress] }), - }), -); - -export const chamberVotes = pgTable( - "chamber_votes", - { - proposalId: text("proposal_id").notNull(), - voterAddress: text("voter_address").notNull(), - choice: integer("choice").notNull(), // 1 (yes), -1 (no), 0 (abstain) - score: integer("score"), // optional 1..10 CM input (v1) - createdAt: timestamp("created_at", { withTimezone: true }) - .notNull() - .defaultNow(), - updatedAt: timestamp("updated_at", { withTimezone: true }) - .notNull() - .defaultNow(), - }, - (t) => ({ - pk: primaryKey({ columns: [t.proposalId, t.voterAddress] }), - }), -); - -export const idempotencyKeys = pgTable("idempotency_keys", { - key: text("key").primaryKey(), - address: text("address").notNull(), - request: jsonb("request").notNull(), - response: jsonb("response").notNull(), - createdAt: timestamp("created_at", { withTimezone: true }) - .notNull() - .defaultNow(), -}); - -export const apiRateLimits = pgTable("api_rate_limits", { - bucket: text("bucket").primaryKey(), - count: integer("count").notNull().default(0), - resetAt: timestamp("reset_at", { withTimezone: true }).notNull(), - updatedAt: timestamp("updated_at", { withTimezone: true }) - .notNull() - .defaultNow(), -}); - -export const userActionLocks = pgTable("user_action_locks", { - address: text("address").primaryKey(), - lockedUntil: timestamp("locked_until", { withTimezone: true }).notNull(), - reason: text("reason"), - updatedAt: timestamp("updated_at", { withTimezone: true }) - .notNull() - .defaultNow(), -}); - -export const cmAwards = pgTable("cm_awards", { - id: bigserial("id", { mode: "number" }).primaryKey(), - proposalId: text("proposal_id").notNull(), - proposerId: text("proposer_id").notNull(), - chamberId: text("chamber_id").notNull(), - avgScore: integer("avg_score"), // 1..10 scale (rounded) - lcmPoints: integer("lcm_points").notNull(), - chamberMultiplierTimes10: integer("chamber_multiplier_times10").notNull(), - mcmPoints: integer("mcm_points").notNull(), - createdAt: timestamp("created_at", { withTimezone: true }) - .notNull() - .defaultNow(), -}); - -export const formationProjects = pgTable("formation_projects", { - proposalId: text("proposal_id").primaryKey(), - teamSlotsTotal: integer("team_slots_total").notNull(), - baseTeamFilled: integer("base_team_filled").notNull().default(0), - milestonesTotal: integer("milestones_total").notNull(), - baseMilestonesCompleted: integer("base_milestones_completed") - .notNull() - .default(0), - budgetTotalHmnd: integer("budget_total_hmnd"), - baseBudgetAllocatedHmnd: integer("base_budget_allocated_hmnd"), - createdAt: timestamp("created_at", { withTimezone: true }) - .notNull() - .defaultNow(), - updatedAt: timestamp("updated_at", { withTimezone: true }) - .notNull() - .defaultNow(), -}); - -export const formationTeam = pgTable( - "formation_team", - { - proposalId: text("proposal_id").notNull(), - memberAddress: text("member_address").notNull(), - role: text("role"), - createdAt: timestamp("created_at", { withTimezone: true }) - .notNull() - .defaultNow(), - updatedAt: timestamp("updated_at", { withTimezone: true }) - .notNull() - .defaultNow(), - }, - (t) => ({ - pk: primaryKey({ columns: [t.proposalId, t.memberAddress] }), - }), -); - -export const formationMilestones = pgTable( - "formation_milestones", - { - proposalId: text("proposal_id").notNull(), - milestoneIndex: integer("milestone_index").notNull(), - status: text("status").notNull(), - createdAt: timestamp("created_at", { withTimezone: true }) - .notNull() - .defaultNow(), - updatedAt: timestamp("updated_at", { withTimezone: true }) - .notNull() - .defaultNow(), - }, - (t) => ({ - pk: primaryKey({ columns: [t.proposalId, t.milestoneIndex] }), - }), -); - -export const formationMilestoneEvents = pgTable("formation_milestone_events", { - id: bigserial("id", { mode: "number" }).primaryKey(), - proposalId: text("proposal_id").notNull(), - milestoneIndex: integer("milestone_index").notNull(), - type: text("type").notNull(), - actorAddress: text("actor_address"), - payload: jsonb("payload").notNull(), - createdAt: timestamp("created_at", { withTimezone: true }) - .notNull() - .defaultNow(), -}); - -export const courtCases = pgTable("court_cases", { - id: text("id").primaryKey(), - status: text("status").notNull(), - baseReports: integer("base_reports").notNull().default(0), - opened: text("opened"), - createdAt: timestamp("created_at", { withTimezone: true }) - .notNull() - .defaultNow(), - updatedAt: timestamp("updated_at", { withTimezone: true }) - .notNull() - .defaultNow(), -}); - -export const courtReports = pgTable( - "court_reports", - { - caseId: text("case_id").notNull(), - reporterAddress: text("reporter_address").notNull(), - createdAt: timestamp("created_at", { withTimezone: true }) - .notNull() - .defaultNow(), - }, - (t) => ({ - pk: primaryKey({ columns: [t.caseId, t.reporterAddress] }), - }), -); - -export const courtVerdicts = pgTable( - "court_verdicts", - { - caseId: text("case_id").notNull(), - voterAddress: text("voter_address").notNull(), - verdict: text("verdict").notNull(), // guilty|not_guilty - createdAt: timestamp("created_at", { withTimezone: true }) - .notNull() - .defaultNow(), - updatedAt: timestamp("updated_at", { withTimezone: true }) - .notNull() - .defaultNow(), - }, - (t) => ({ - pk: primaryKey({ columns: [t.caseId, t.voterAddress] }), - }), -); - -export const eraSnapshots = pgTable("era_snapshots", { - era: integer("era").primaryKey(), - activeGovernors: integer("active_governors").notNull(), - createdAt: timestamp("created_at", { withTimezone: true }) - .notNull() - .defaultNow(), -}); - -export const eraUserActivity = pgTable( - "era_user_activity", - { - era: integer("era").notNull(), - address: text("address").notNull(), - poolVotes: integer("pool_votes").notNull().default(0), - chamberVotes: integer("chamber_votes").notNull().default(0), - courtActions: integer("court_actions").notNull().default(0), - formationActions: integer("formation_actions").notNull().default(0), - updatedAt: timestamp("updated_at", { withTimezone: true }) - .notNull() - .defaultNow(), - }, - (t) => ({ - pk: primaryKey({ columns: [t.era, t.address] }), - }), -); - -export const eraRollups = pgTable("era_rollups", { - era: integer("era").primaryKey(), - requiredPoolVotes: integer("required_pool_votes").notNull().default(0), - requiredChamberVotes: integer("required_chamber_votes").notNull().default(0), - requiredCourtActions: integer("required_court_actions").notNull().default(0), - requiredFormationActions: integer("required_formation_actions") - .notNull() - .default(0), - requiredTotal: integer("required_total").notNull().default(0), - activeGovernorsNextEra: integer("active_governors_next_era") - .notNull() - .default(0), - rolledAt: timestamp("rolled_at", { withTimezone: true }) - .notNull() - .defaultNow(), -}); - -export const eraUserStatus = pgTable( - "era_user_status", - { - era: integer("era").notNull(), - address: text("address").notNull(), - status: text("status").notNull(), - requiredTotal: integer("required_total").notNull().default(0), - completedTotal: integer("completed_total").notNull().default(0), - isActiveNextEra: boolean("is_active_next_era").notNull().default(false), - poolVotes: integer("pool_votes").notNull().default(0), - chamberVotes: integer("chamber_votes").notNull().default(0), - courtActions: integer("court_actions").notNull().default(0), - formationActions: integer("formation_actions").notNull().default(0), - createdAt: timestamp("created_at", { withTimezone: true }) - .notNull() - .defaultNow(), - }, - (t) => ({ - pk: primaryKey({ columns: [t.era, t.address] }), - }), -); diff --git a/db/seed/events.ts b/db/seed/events.ts deleted file mode 100644 index 027e0bb..0000000 --- a/db/seed/events.ts +++ /dev/null @@ -1,30 +0,0 @@ -import type { FeedItemDto } from "@/types/api"; - -import { feedItemsApi } from "./fixtures/feedApi.ts"; - -export type EventSeedEntry = { - type: "feed.item.v1"; - stage: FeedItemDto["stage"]; - actorAddress: string | null; - entityType: "feed"; - entityId: string; - payload: FeedItemDto; - createdAt: Date; -}; - -export function buildEventSeed(): EventSeedEntry[] { - return [...feedItemsApi] - .sort( - (a, b) => - new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime(), - ) - .map((item) => ({ - type: "feed.item.v1" as const, - stage: item.stage, - actorAddress: null, - entityType: "feed" as const, - entityId: item.id, - payload: item, - createdAt: new Date(item.timestamp), - })); -} diff --git a/db/seed/fixtures/chamberDetail.ts b/db/seed/fixtures/chamberDetail.ts deleted file mode 100644 index 4c4a820..0000000 --- a/db/seed/fixtures/chamberDetail.ts +++ /dev/null @@ -1,109 +0,0 @@ -export type ProposalStage = "upcoming" | "live" | "ended"; - -export type ChamberProposal = { - id: string; - title: string; - meta: string; - summary: string; - lead: string; - nextStep: string; - timing: string; - stage: ProposalStage; -}; - -export type Governor = { - id: string; - name: string; - tier: string; - focus: string; -}; - -export type Thread = { - id: string; - title: string; - author: string; - replies: number; - updated: string; -}; - -export type ChatMessage = { - id: string; - author: string; - message: string; -}; - -export const proposalStageOptions: { value: ProposalStage; label: string }[] = [ - { value: "upcoming", label: "Upcoming" }, - { value: "live", label: "Live" }, - { value: "ended", label: "Ended" }, -]; - -export const chamberProposals: ChamberProposal[] = [ - { - id: "evm-dev-starter-kit", - title: "Humanode EVM Dev Starter Kit & Testing Sandbox", - meta: "Legate · Engineering chamber", - summary: - "Starter kit + sandbox so developers can deploy EVM dApps on Humanode in under 30 minutes.", - lead: "Sesh", - nextStep: "Formation · Milestone 1 in progress", - timing: "Live · Week 3/12", - stage: "live", - }, - { - id: "voluntary-commitment-staking", - title: "Voluntary Governor Commitment Staking", - meta: "Legate · General chamber", - summary: - "Optional commitment staking + opt-in self-slashing without changing governance access or voting power.", - lead: "Victor", - nextStep: "Chamber vote", - timing: "Live · 3d 12h", - stage: "live", - }, -]; - -export const chamberGovernors: Governor[] = [ - { id: "shahmeer", name: "Shahmeer", tier: "Citizen", focus: "Engineering" }, - { id: "dato", name: "Dato", tier: "Consul", focus: "Infra" }, - { id: "andrei", name: "Andrei", tier: "Consul", focus: "Observability" }, - { id: "victor", name: "Victor", tier: "Legate", focus: "Policy" }, - { id: "fares", name: "Fares", tier: "Legate", focus: "Economics" }, - { id: "sesh", name: "Sesh", tier: "Legate", focus: "Security" }, -]; - -export const chamberThreads: Thread[] = [ - { - id: "thread-1", - title: "EVM Dev Starter Kit — scope & milestone review", - author: "Sesh", - replies: 7, - updated: "1h ago", - }, - { - id: "thread-2", - title: "Commitment staking — UX + slashing conditions", - author: "Victor", - replies: 12, - updated: "3h ago", - }, -]; - -export const chamberChatLog: ChatMessage[] = [ - { - id: "chat-1", - author: "Sesh", - message: "SDK API surface draft is ready — looking for review on naming.", - }, - { - id: "chat-2", - author: "Victor", - message: "Added notes on voluntary vs mandatory stake framing for the UI.", - }, - { - id: "chat-3", - author: "Sesh", - message: - "Sandbox + faucet flow: what’s the simplest onboarding UX we want?", - }, -]; diff --git a/db/seed/fixtures/chambers.ts b/db/seed/fixtures/chambers.ts deleted file mode 100644 index fc0417d..0000000 --- a/db/seed/fixtures/chambers.ts +++ /dev/null @@ -1,68 +0,0 @@ -export type ChamberPipeline = { - pool: number; - vote: number; - build: number; -}; - -export type ChamberStats = { - governors: string; - acm: string; - mcm: string; - lcm: string; -}; - -export type Chamber = { - id: string; - name: string; - multiplier: number; - stats: ChamberStats; - pipeline: ChamberPipeline; -}; - -export const chambers: Chamber[] = [ - { - id: "engineering", - name: "Engineering", - multiplier: 1.5, - stats: { governors: "22", acm: "3,400", mcm: "1,600", lcm: "1,800" }, - pipeline: { pool: 2, vote: 2, build: 1 }, - }, - { - id: "economics", - name: "Economics", - multiplier: 1.3, - stats: { governors: "18", acm: "2,950", mcm: "1,400", lcm: "1,550" }, - pipeline: { pool: 2, vote: 2, build: 1 }, - }, - { - id: "product", - name: "Product", - multiplier: 1.2, - stats: { governors: "12", acm: "1,900", mcm: "900", lcm: "1,000" }, - pipeline: { pool: 1, vote: 0, build: 3 }, - }, - { - id: "marketing", - name: "Marketing", - multiplier: 1.1, - stats: { governors: "10", acm: "1,480", mcm: "700", lcm: "780" }, - pipeline: { pool: 1, vote: 1, build: 0 }, - }, - { - id: "general", - name: "General", - multiplier: 1.2, - stats: { governors: "15", acm: "2,600", mcm: "1,200", lcm: "1,400" }, - pipeline: { pool: 2, vote: 1, build: 0 }, - }, - { - id: "design", - name: "Design", - multiplier: 1.4, - stats: { governors: "23", acm: "3,800", mcm: "1,800", lcm: "2,000" }, - pipeline: { pool: 1, vote: 2, build: 1 }, - }, -]; - -export const getChamberById = (id: string | undefined): Chamber | undefined => - (id ? chambers.find((chamber) => chamber.id === id) : undefined) ?? undefined; diff --git a/db/seed/fixtures/courts.ts b/db/seed/fixtures/courts.ts deleted file mode 100644 index dc68b7f..0000000 --- a/db/seed/fixtures/courts.ts +++ /dev/null @@ -1,228 +0,0 @@ -export type CourtCase = { - id: string; - title: string; - subject: string; - triggeredBy: string; - status: "jury" | "live" | "ended"; - reports: number; - juryIds: string[]; - opened: string; // dd/mm/yyyy - parties: { role: string; humanId: string; note?: string }[]; - proceedings: { - claim: string; - evidence: string[]; - nextSteps: string[]; - }; -}; - -const DEFAULT_JURY_IDS = [ - "dato", - "victor", - "temo", - "dima", - "tony", - "sesh", - "petr", - "shannon", - "shahmeer", - "fiona", - "silis", - "ekko", -]; - -export const courtCases: CourtCase[] = [ - { - id: "delegation-reroute-keeper-nyx", - title: "Delegation dispute", - subject: "Delegation dispute: unexpected reroute away from Dato", - triggeredBy: "14 reports · Delegation shift", - status: "live", - reports: 14, - juryIds: DEFAULT_JURY_IDS, - opened: "12/12/2025", - parties: [ - { role: "Previous delegatee", humanId: "dato" }, - { role: "Recipient delegatee", humanId: "shahmeer" }, - { - role: "Requester", - humanId: "fiona", - note: "Filed on behalf of affected delegators", - }, - ], - proceedings: { - claim: - "Multiple governors report that their delegations were switched from delegatee Dato to delegatee Shahmeer without their clear consent. The requester asks the court to (1) determine whether the shift was authorized, (2) require a public explanation from the involved parties, and (3) recommend remediation steps (re-delegation guidance, UI fixes, or sanctions if abuse is proven).", - evidence: [ - "On-chain delegation events: epochs 318–320 showing repeated changes to the same delegatee in a short time window", - "Signed statements from 6 delegators claiming they did not intentionally reassign delegation", - "Screenshot exports from the delegation UI showing an ambiguous confirmation step (no explicit “You are delegating to X” final screen)", - "Device/IP session logs (where available) showing multiple delegation actions executed within minutes", - "Comparison table: “expected delegatee per user statement” vs “actual delegatee after shift”", - ], - nextSteps: [ - "Collect jury statements (24h window) focusing on consent clarity, UI ambiguity, and plausibility of mistaken delegation", - "Request response statements from the recipient delegatee and previous delegatee", - "Ask Vortex engineering for a technical note: whether delegation can be triggered by any mechanism other than the delegator’s signed action", - "Determine likely cause category: user error / confusing UX, compromised wallet/session, coordinated manipulation campaign, or false reporting", - "Publish a short ruling with recommended actions (UI patch proposal, warnings, or sanctions if applicable)", - ], - }, - }, - { - id: "delegation-farming-forum-whale", - title: "Delegation dispute", - subject: "Delegation dispute: alleged “delegation farming” by Fares", - triggeredBy: "9 reports · Delegation shift", - status: "jury", - reports: 9, - juryIds: [ - "dato", - "victor", - "temo", - "dima", - "tony", - "sesh", - "petr", - "shannon", - "shahmeer", - "fiona", - "andrei", - "fares", - ], - opened: "12/12/2025", - parties: [ - { role: "Accused delegatee", humanId: "fares" }, - { - role: "Requester", - humanId: "petr", - note: "Filed after delegator reports", - }, - ], - proceedings: { - claim: - "Multiple governors allege that Fares obtained delegations through misleading communication: promising “vote your way” representation, then voting contrary to delegators’ stated intent. The requester asks the court to determine whether this constitutes delegation abuse / misrepresentation and to recommend remedies (public disclosure requirements, formal warning, or temporary delegation visibility restrictions).", - evidence: [ - "Delegation events: epochs 321–324 showing a rapid increase in delegations following a public thread", - "Screenshots of DMs and forum posts where the delegatee allegedly stated they would “mirror delegators’ votes”", - "Voting record comparison: proposals where delegators expected “mirror votes” vs the delegatee’s actual votes", - "Statements from 5 delegators requesting reversal and claiming “I would not have delegated under full information”", - "Timeline: communication posts → delegation inflow → voting divergence", - ], - nextSteps: [ - "Collect jury statements (48h) on whether “vote mirroring” claims are enforceable or merely political speech", - "Request a response statement from the delegatee (promises made, how voting decisions were determined, whether terms were documented)", - "Ask Vortex engineering if the UI should support optional delegation terms metadata (“advisory”, “mirror”, “topic-based”)", - "Determine remedy category: no fault, warning for misleading representation, or sanction for systematic misrepresentation (if proven)", - "Schedule deliberation and publish verdict summary in the Vortex feed", - ], - }, - }, - { - id: "milestone-dispute-dev-starter-kit", - title: "Milestone dispute", - subject: "Milestone dispute: “Dev Starter Kit” delivered but not usable", - triggeredBy: "11 reports · Milestone completion contested", - status: "live", - reports: 11, - juryIds: [ - "dato", - "victor", - "temo", - "dima", - "sesh", - "petr", - "shannon", - "shahmeer", - "fiona", - "silis", - "ekko", - "andrei", - ], - opened: "12/12/2025", - parties: [ - { - role: "Proposer", - humanId: "andrei", - note: "Marked Milestone 2 as done", - }, - { - role: "Requester", - humanId: "shannon", - note: "Filed after reproducible setup failures", - }, - ], - proceedings: { - claim: - "Multiple governors contest Milestone 2 completion for the “Humanode EVM Dev Starter Kit & Testing Sandbox” proposal. The proposer marked the milestone as “done” and requested the unlock, but reporters claim the deliverable is not usable for a new developer (broken install path, missing docs, unstable sandbox RPC). The requester asks the court to determine whether the milestone meets the acceptance criteria and whether payment should be released, partially released, or withheld pending fixes.", - evidence: [ - "Milestone definition and acceptance criteria (Milestone 2: “Sandbox Online” including faucet + one-command setup + docs page)", - "Public repo links showing release tag vs current branch differences", - "Issue tracker: 18 opened issues in 48 hours tagged “blocking” (install failures, RPC timeouts, faucet errors)", - "Reproduction logs from 6 independent testers (OS, Node version, steps, error outputs)", - "Sandbox uptime snapshots showing intermittent endpoint failures over 24h", - "Screenshots of docs page missing key steps (faucet link, RPC URL, chain ID)", - ], - nextSteps: [ - "Collect jury statements (72h) focused on one question: does this meet the milestone definition as written?", - "Request a response from the proposer (exact setup steps, known issues list, proposed fix timeline)", - "Ask Vortex engineering or independent testers to run a standardized “new dev” checklist: clone → install → run local → deploy sample → use faucet → verify on explorer", - "Decide remedy: release (met), conditional release (partial), or withhold until acceptance criteria are satisfied", - "Publish a short verdict with explicit pass/fail criteria and deadlines", - ], - }, - }, - { - id: "identity-integrity-multi-human", - title: "Identity integrity dispute", - subject: - "Identity integrity dispute: suspected “multi-human” enrolment attempt", - triggeredBy: "8 reports · Verification anomaly", - status: "ended", - reports: 8, - juryIds: [ - "dato", - "victor", - "temo", - "dima", - "tony", - "sesh", - "petr", - "shannon", - "shahmeer", - "fiona", - "silis", - "fares", - ], - opened: "12/12/2025", - parties: [ - { - role: "Requester", - humanId: "victor", - note: "Filed for integrity review", - }, - { - role: "Investigated", - humanId: "tony", - note: "Flagged by anomaly pattern", - }, - ], - proceedings: { - claim: - "A cluster of verification attempts appears to indicate that one operator may be trying to enroll multiple identities using coordinated tactics (similar device fingerprints, repeated timing patterns, and shared network characteristics). Reporters request a court ruling on whether this meets the threshold for a PoBU integrity violation and whether temporary restrictions should be applied while the Legal team investigates.", - evidence: [ - "Verification metadata summary (no raw biometrics): repeated session timing patterns within short windows, high similarity of device fingerprints (model/OS/browser), repeated IP / ASN overlaps across attempts", - "Biomapper anomaly flags showing elevated risk score for the cluster", - "Node-side logs (validator / verification relayers) indicating repeated retries with similar parameters", - "Statements from 3 human nodes reporting unusually high failure-retry loops tied to the same region/time window", - "Prior incident notes (if any) describing similar patterns and how they were handled", - ], - nextSteps: [ - "Collect jury statements (48h) focusing on proportionality: what can be done now without exposing sensitive data?", - "Request a technical note from Legal on whether the observed metadata is sufficient to suspect coordinated abuse and what additional non-sensitive signals can be reviewed", - "Ask Vortex engineering to confirm existing controls: rate limits, cooldown windows, session caps, temporary blocks", - "Determine interim remedy: no action, soft mitigation (rate limits/cooldowns), or temporary restriction pending deeper review", - "Publish verdict summary: what was decided, why, and what follow-up investigation will occur (without leaking sensitive details)", - ], - }, - }, -]; diff --git a/db/seed/fixtures/factions.ts b/db/seed/fixtures/factions.ts deleted file mode 100644 index 517fe18..0000000 --- a/db/seed/fixtures/factions.ts +++ /dev/null @@ -1,241 +0,0 @@ -export type Faction = { - id: string; - name: string; - description: string; - members: number; - votes: string; - acm: string; - focus: string; - goals: string[]; - initiatives: string[]; - roster: { - humanNodeId: string; - role: string; - tag: - | { kind: "acm"; value: number } - | { kind: "mm"; value: number } - | { kind: "text"; value: string }; - }[]; -}; - -export const factions: Faction[] = [ - { - id: "delegation-removal-supporters", - name: "Delegation removal supporters", - description: - "End delegation. Direct votes only. No proxy power, no silent capture.", - members: 19, - votes: "19", - acm: "1,420", - focus: "Governance design", - goals: [ - "Remove proxy power and enforce direct participation as the default.", - "Reduce governance capture vectors and make outcomes legible.", - ], - initiatives: [ - "Tier Decay v1", - "Delegation removal v1", - "Quorum transparency checklist", - ], - roster: [ - { - humanNodeId: "andrei", - role: "Governance systems", - tag: { kind: "acm", value: 176 }, - }, - { - humanNodeId: "petr", - role: "Policy ops", - tag: { kind: "mm", value: 74 }, - }, - { - humanNodeId: "victor", - role: "Rules & definitions", - tag: { kind: "text", value: "Votes 46" }, - }, - ], - }, - { - id: "validator-subsidies", - name: "Validator subsidies", - description: - "Subsidize validators to improve uptime, geographic spread, and operator diversity.", - members: 22, - votes: "22", - acm: "1,610", - focus: "Network reliability", - goals: [ - "Improve uptime and reduce single-region concentration.", - "Make validator operations sustainable for smaller operators.", - ], - initiatives: [ - "Uptime subsidy model v1", - "Geographic diversity grants", - "Operator onboarding playbook", - ], - roster: [ - { - humanNodeId: "dato", - role: "Reliability & ops", - tag: { kind: "acm", value: 188 }, - }, - { - humanNodeId: "shannon", - role: "Operator programs", - tag: { kind: "mm", value: 82 }, - }, - { - humanNodeId: "ekko", - role: "Telemetry & dashboards", - tag: { kind: "text", value: "Votes 39" }, - }, - ], - }, - { - id: "formal-verification-maxis", - name: "Formal verification maxis", - description: - "Specs and proofs before upgrades. Verified safety over “ship now, patch later.”", - members: 17, - votes: "17", - acm: "1,780", - focus: "Engineering & security", - goals: [ - "Require clear specs and review before upgrades.", - "Prefer verified safety over “ship now, patch later.”", - ], - initiatives: [ - "Biometric Account Recovery & Key Rotation Pallet", - "Audit playbook v1", - "Formal spec templates", - ], - roster: [ - { - humanNodeId: "sesh", - role: "Security reviews", - tag: { kind: "acm", value: 194 }, - }, - { - humanNodeId: "shahmeer", - role: "Protocol engineering", - tag: { kind: "mm", value: 91 }, - }, - { - humanNodeId: "fares", - role: "Risk modeling", - tag: { kind: "text", value: "Votes 44" }, - }, - ], - }, - { - id: "anti-ai-slop-conglomerate", - name: "Anti-AI-slop conglomerate", - description: - "Enforce a quality bar: human-made outputs, citations, review, and zero spam.", - members: 14, - votes: "14", - acm: "980", - focus: "Quality & culture", - goals: [ - "Reduce low-effort spam and improve signal in proposals and threads.", - "Normalize review, citations, and clear acceptance criteria.", - ], - initiatives: [ - "Fixed Governor Stake & Spam Slashing Rule for Vortex", - "Proposal quality checklist", - "Review culture sprint", - ], - roster: [ - { - humanNodeId: "silis", - role: "Quality bar", - tag: { kind: "acm", value: 152 }, - }, - { - humanNodeId: "temo", - role: "UX clarity", - tag: { kind: "mm", value: 68 }, - }, - { - humanNodeId: "victor", - role: "Policy language", - tag: { kind: "text", value: "Votes 31" }, - }, - ], - }, - { - id: "social-media-awareness", - name: "Social media awareness", - description: - "Turn governance into signal: distribution, creators, campaigns, and measurable reach.", - members: 16, - votes: "16", - acm: "1,120", - focus: "Growth & distribution", - goals: [ - "Make governance legible to outsiders without dumbing it down.", - "Run repeatable campaigns and track measurable outcomes.", - ], - initiatives: [ - "Vortex Field Experiments: Season 1", - "Humanode AI Video Series: 3 Viral-Quality Shorts", - "AI Video Launch & Distribution Sprint", - ], - roster: [ - { - humanNodeId: "tony", - role: "Community ops", - tag: { kind: "acm", value: 139 }, - }, - { - humanNodeId: "dima", - role: "Campaign execution", - tag: { kind: "mm", value: 63 }, - }, - { - humanNodeId: "petr", - role: "Narrative & copy", - tag: { kind: "text", value: "Votes 22" }, - }, - ], - }, - { - id: "local-voting-adoption-movement", - name: "Local voting adoption movement", - description: - "Run local pilots and onboard communities into on-chain voting with real turnout.", - members: 20, - votes: "20", - acm: "1,260", - focus: "Adoption", - goals: [ - "Bootstrap real participation with local pilots and clear onboarding.", - "Turn “read-only spectators” into active governors over time.", - ], - initiatives: [ - "Local pilot playbook v1", - "Onboarding cohorts", - "Voluntary Governor Commitment Staking", - ], - roster: [ - { - humanNodeId: "fiona", - role: "Onboarding", - tag: { kind: "acm", value: 148 }, - }, - { - humanNodeId: "victor", - role: "Local governance policy", - tag: { kind: "mm", value: 79 }, - }, - { - humanNodeId: "shannon", - role: "Field ops", - tag: { kind: "text", value: "Votes 27" }, - }, - ], - }, -]; - -export const getFactionById = (id: string | undefined): Faction | undefined => - (id ? factions.find((faction) => faction.id === id) : undefined) ?? undefined; diff --git a/db/seed/fixtures/feedApi.ts b/db/seed/fixtures/feedApi.ts deleted file mode 100644 index 2739536..0000000 --- a/db/seed/fixtures/feedApi.ts +++ /dev/null @@ -1,124 +0,0 @@ -import type { FeedItemDto } from "@/types/api"; - -export const feedItemsApi: FeedItemDto[] = [ - { - id: "voluntary-commitment-staking", - title: "Voluntary Governor Commitment Staking", - meta: "General chamber · Legate tier", - stage: "vote", - summaryPill: "No mandatory stake", - summary: - "Optional HMND commitment staking with opt-in self-slashing, without changing voting power (1 human = 1 vote).", - stageData: [ - { - title: "Voting quorum", - description: "Strict 33% active governors", - value: "Met · 35%", - tone: "ok", - }, - { - title: "Passing rule", - description: "≥66.6% + 1 vote yes", - value: "Current 86%", - tone: "ok", - }, - { title: "Time left", description: "Voting window", value: "3d 12h" }, - ], - stats: [ - { label: "Budget ask", value: "16k HMND" }, - { label: "Votes casted", value: "52" }, - ], - proposer: "Victor", - proposerId: "victor", - ctaPrimary: "Open proposal", - ctaSecondary: "Add to agenda", - href: "/app/proposals/voluntary-commitment-staking/chamber", - timestamp: "2026-01-06T12:10:00Z", - }, - { - id: "evm-dev-starter-kit", - title: "Humanode EVM Dev Starter Kit & Testing Sandbox", - meta: "Engineering chamber · Legate tier", - stage: "build", - summaryPill: "Milestone 1 / 3", - summary: - "EVM dev starter kit + public testing sandbox so developers can deploy dApps on Humanode in under 30 minutes.", - stageData: [ - { - title: "Budget allocated", - description: "HMND", - value: "18k / 180k", - }, - { - title: "Team slots", - description: "Taken / Total", - value: "1 / 3", - }, - { - title: "Progress", - description: "Reported completion", - value: "24%", - }, - ], - stats: [ - { label: "Budget ask", value: "180k HMND" }, - { label: "Duration", value: "12 weeks" }, - ], - proposer: "Sesh", - proposerId: "sesh", - ctaPrimary: "Open proposal", - ctaSecondary: "Watch", - href: "/app/proposals/evm-dev-starter-kit/formation", - timestamp: "2026-01-05T09:00:00Z", - }, - { - id: "delegation-dispute", - title: "Court: Delegation reroute dispute", - meta: "Courts · Jury", - stage: "courts", - summaryPill: "Courts", - summary: - "Multiple governors report delegations rerouted away from Dato to Shahmeer without clear consent.", - ctaPrimary: "Open case", - ctaSecondary: "Track", - href: "/app/courts/delegation-reroute-keeper-nyx", - timestamp: "2025-12-12T06:45:00Z", - }, - { - id: "protocol-council-thread", - title: "Engineering thread", - meta: "Engineering chamber · Thread", - stage: "thread", - summaryPill: "Engineering chamber", - summary: "Incident review for redundant checkpoints · new replies.", - ctaPrimary: "Open thread", - ctaSecondary: "Mark read", - href: "/app/chambers/engineering", - timestamp: "2025-03-30T05:10:00Z", - }, - { - id: "formal-verification-thread", - title: "Formal verification thread", - meta: "Faction · Formal verification maxis", - stage: "thread", - summaryPill: "Faction thread", - summary: "Audit checklist proposal · new replies.", - ctaPrimary: "Open thread", - ctaSecondary: "Mark read", - href: "/app/factions/formal-verification-maxis", - timestamp: "2025-03-26T04:00:00Z", - }, - { - id: "social-media-awareness", - title: "Social media awareness update", - meta: "Faction · Social media awareness", - stage: "faction", - summaryPill: "Slots open", - summary: - "Votes: 16 · ACM: 1,120 · Creator and ops slots open for upcoming campaigns.", - ctaPrimary: "Open faction", - ctaSecondary: "Follow", - href: "/app/factions/social-media-awareness", - timestamp: "2025-03-20T20:00:00Z", - }, -]; diff --git a/db/seed/fixtures/formation.ts b/db/seed/fixtures/formation.ts deleted file mode 100644 index f378471..0000000 --- a/db/seed/fixtures/formation.ts +++ /dev/null @@ -1,69 +0,0 @@ -export type FormationMetric = { - label: string; - value: string; - dataAttr: string; -}; - -export type FormationCategory = "all" | "research" | "development" | "social"; -export type FormationStage = "live" | "gathering" | "completed"; - -export type FormationProject = { - id: string; - title: string; - focus: string; - proposer: string; - summary: string; - category: FormationCategory; - stage: FormationStage; - budget: string; - milestones: string; - teamSlots: string; -}; - -export const formationMetrics: FormationMetric[] = [ - { label: "Total funded HMND", value: "425k", dataAttr: "metric-hmnd" }, - { label: "Active projects", value: "2", dataAttr: "metric-active" }, - { label: "Open team slots", value: "4", dataAttr: "metric-slots" }, - { label: "Milestones delivered", value: "3", dataAttr: "metric-milestones" }, -]; - -export const formationProjects: FormationProject[] = [ - { - id: "evm-dev-starter-kit", - title: "Humanode EVM Dev Starter Kit & Testing Sandbox", - focus: "Engineering chamber · Developer tooling", - proposer: "Sesh", - summary: - "Starter kit + public sandbox so developers can deploy EVM dApps on Humanode in under 30 minutes.", - category: "development", - stage: "live", - budget: "180k HMND", - milestones: "1 / 3", - teamSlots: "2 open", - }, - { - id: "mev-safe-dex-v1-launch-sprint", - title: "Humanode MEV-Safe DEX v1 + Launch Sprint", - focus: "Engineering chamber · Protected swaps", - proposer: "Dato", - summary: - "MEV-protected DEX + Biostaker/getHMND integrations and fees to Human Nodes, with an audited mainnet launch.", - category: "development", - stage: "gathering", - budget: "245k HMND", - milestones: "2 / 4", - teamSlots: "2 open", - }, -]; - -export const formationStageLabel = (stage: FormationStage): string => { - if (stage === "live") return "Live"; - if (stage === "gathering") return "Gathering"; - return "Completed"; -}; - -export const getFormationProjectById = ( - id: string | undefined, -): FormationProject | undefined => - (id ? formationProjects.find((project) => project.id === id) : undefined) ?? - undefined; diff --git a/db/seed/fixtures/humanNodeProfiles.ts b/db/seed/fixtures/humanNodeProfiles.ts deleted file mode 100644 index 1c2e52c..0000000 --- a/db/seed/fixtures/humanNodeProfiles.ts +++ /dev/null @@ -1,663 +0,0 @@ -import type { HumanNode } from "./humanNodes.ts"; -import { humanNodes } from "./humanNodes.ts"; -import { - formationStageLabel, - getFormationProjectById, - type FormationProject, -} from "./formation.ts"; -import { getFactionById } from "./factions.ts"; - -export type ProofKey = "time" | "devotion" | "governance"; - -export type ProofSection = { - title: string; - items: { label: string; value: string }[]; -}; - -export type GovernanceAction = { - title: string; - action: string; - context: string; - detail: string; -}; - -export type HistoryItem = { - title: string; - action: string; - context: string; - detail: string; - date: string; -}; - -export type ProjectCard = { - title: string; - status: string; - summary: string; - chips: string[]; -}; - -export type HeroStat = { label: string; value: string }; - -export type QuickDetail = - | { label: "Tier"; value: string } - | { label: string; value: string }; - -export type HumanNodeProfile = { - id: string; - name: string; - governorActive: boolean; - humanNodeActive: boolean; - governanceSummary: string; - heroStats: HeroStat[]; - quickDetails: QuickDetail[]; - proofSections: Record; - governanceActions: GovernanceAction[]; - projects: ProjectCard[]; - activity: HistoryItem[]; - history: string[]; -}; - -export const proofToggleOptions: { key: ProofKey; label: string }[] = [ - { key: "time", label: "PoT" }, - { key: "devotion", label: "PoD" }, - { key: "governance", label: "PoG" }, -]; - -const titleCaseTier = (tier: HumanNode["tier"]): string => - tier.charAt(0).toUpperCase() + tier.slice(1); - -const nodeById = (id: string): HumanNode | undefined => - humanNodes.find((node) => node.id === id); - -const defaultGovernanceActions: GovernanceAction[] = [ - { - title: "EVM Dev Starter Kit", - action: "Reviewed scope", - context: "Engineering chamber", - detail: "Left notes on SDK ergonomics and sandbox onboarding flow.", - }, - { - title: "Commitment staking", - action: "Casted vote", - context: "General chamber", - detail: "Suggested UX framing for voluntary vs mandatory stake.", - }, - { - title: "Chamber policy refresh", - action: "Commented", - context: "General chamber", - detail: "Proposed a short checklist for proposal compliance and clarity.", - }, - { - title: "Formation milestone sync", - action: "Joined call", - context: "Formation", - detail: "Reviewed deliverables and helped unblock milestone planning.", - }, - { - title: "Spam mitigation debate", - action: "Published note", - context: "Economics chamber", - detail: "Summarized trade-offs of fixed stakes vs voluntary commitments.", - }, - { - title: "Governance onboarding", - action: "Hosted session", - context: "Marketing", - detail: "Walked new governors through pools, chambers, and Formation.", - }, -]; - -const defaultActivity: HistoryItem[] = [ - { - title: "EVM Dev Starter Kit", - action: "Reviewed scope", - context: "Engineering chamber", - detail: "Left notes on SDK ergonomics and sandbox onboarding flow.", - date: "Epoch 214", - }, - { - title: "Commitment staking", - action: "Casted vote", - context: "General chamber", - detail: "Suggested UX framing for voluntary vs mandatory stake.", - date: "Epoch 209", - }, - { - title: "Chamber policy refresh", - action: "Commented", - context: "General chamber", - detail: "Proposed a short checklist for proposal compliance and clarity.", - date: "Epoch 205", - }, - { - title: "Spam mitigation debate", - action: "Published note", - context: "Economics chamber", - detail: "Summarized trade-offs of fixed stakes vs voluntary commitments.", - date: "Epoch 202", - }, - { - title: "Governance onboarding", - action: "Hosted session", - context: "Marketing", - detail: "Walked new governors through pools, chambers, and Formation.", - date: "Epoch 198", - }, -]; - -const projectToCard = (project: FormationProject): ProjectCard => ({ - title: project.title, - status: `${project.focus} · ${formationStageLabel(project.stage)}`, - summary: project.summary, - chips: [ - `Budget: ${project.budget}`, - `Milestones: ${project.milestones}`, - `Team slots: ${project.teamSlots}`, - ], -}); - -const getProjectsForNode = (node: HumanNode | undefined): ProjectCard[] => - (node?.formationProjectIds ?? []) - .map((projectId) => getFormationProjectById(projectId)) - .filter((project): project is FormationProject => Boolean(project)) - .map(projectToCard); - -const createProfile = (input: { - id: string; - invisionScore: number; - delegationShare: string; - proposalsCreated: string; - governanceSummary: string; - proofSections: Record; - history?: string[]; - activity?: HistoryItem[]; -}): HumanNodeProfile => { - const node = nodeById(input.id); - const name = node?.name ?? input.id; - const acm = node?.acm ?? 0; - const mm = node?.mm ?? 0; - const tier = node?.tier ?? "nominee"; - const memberSince = node?.memberSince ?? "—"; - const active = node?.active ?? true; - const factionName = - (node?.factionId ? getFactionById(node.factionId)?.name : undefined) ?? "—"; - const projects = getProjectsForNode(node); - - return { - id: input.id, - name, - governorActive: active, - humanNodeActive: active, - governanceSummary: input.governanceSummary, - heroStats: [ - { label: "ACM", value: acm.toString() }, - { label: "MM", value: mm.toString() }, - { label: "Invision score", value: `${input.invisionScore} / 100` }, - { label: "Member since", value: memberSince }, - ], - quickDetails: [ - { label: "Tier", value: titleCaseTier(tier) }, - { label: "Faction", value: factionName }, - { label: "Delegation share", value: input.delegationShare }, - { label: "Proposals created", value: input.proposalsCreated }, - ], - proofSections: input.proofSections, - governanceActions: defaultGovernanceActions, - projects, - activity: input.activity ?? defaultActivity, - history: - input.history ?? - (input.activity ?? defaultActivity) - .slice(0, 3) - .map((item) => `${item.date} · ${item.action} ${item.title}`), - }; -}; - -export const humanNodeProfilesById: Record = { - dato: createProfile({ - id: "dato", - invisionScore: 78, - delegationShare: "2.4%", - proposalsCreated: "7", - governanceSummary: - "Operator-minded governor focused on protocol readiness, observability, and keeping milestone execution predictable across chambers.", - proofSections: { - time: { - title: "Proof-of-Time", - items: [ - { label: "Human node for", value: "3 Y · 3 M" }, - { label: "Governor for", value: "2 Y · 7 M" }, - ], - }, - devotion: { - title: "Proof-of-Devotion", - items: [ - { label: "Proposal accepted?", value: "Yes" }, - { label: "Participated in formation?", value: "Yes" }, - ], - }, - governance: { - title: "Proof-of-Governance", - items: [ - { label: "Actively governed", value: "2 Y · 4 M" }, - { label: "Active governor?", value: "Yes" }, - ], - }, - }, - history: [ - "Epoch 214 · Reviewed EVM sandbox milestone scope", - "Epoch 209 · Voted on commitment staking proposal", - "Epoch 205 · Hosted governance onboarding session", - ], - }), - victor: createProfile({ - id: "victor", - invisionScore: 81, - delegationShare: "1.9%", - proposalsCreated: "5", - governanceSummary: - "Legal-focused governor translating protocol changes into clear rules, summaries, and policies that chambers can enforce consistently.", - proofSections: { - time: { - title: "Proof-of-Time", - items: [ - { label: "Human node for", value: "2 Y · 10 M" }, - { label: "Governor for", value: "2 Y · 1 M" }, - ], - }, - devotion: { - title: "Proof-of-Devotion", - items: [ - { label: "Proposal accepted?", value: "Yes" }, - { label: "Participated in formation?", value: "No" }, - ], - }, - governance: { - title: "Proof-of-Governance", - items: [ - { label: "Actively governed", value: "1 Y · 9 M" }, - { label: "Active governor?", value: "Yes" }, - ], - }, - }, - }), - temo: createProfile({ - id: "temo", - invisionScore: 69, - delegationShare: "0.4%", - proposalsCreated: "0", - governanceSummary: - "Early-stage governor with a product/UX lens, focused on making proposals and chambers easier to scan and understand.", - proofSections: { - time: { - title: "Proof-of-Time", - items: [ - { label: "Human node for", value: "0 Y · 9 M" }, - { label: "Governor for", value: "0 Y · 6 M" }, - ], - }, - devotion: { - title: "Proof-of-Devotion", - items: [ - { label: "Proposal accepted?", value: "No" }, - { label: "Participated in formation?", value: "No" }, - ], - }, - governance: { - title: "Proof-of-Governance", - items: [ - { label: "Actively governed", value: "0 Y · 5 M" }, - { label: "Active governor?", value: "Yes" }, - ], - }, - }, - }), - dima: createProfile({ - id: "dima", - invisionScore: 65, - delegationShare: "0.6%", - proposalsCreated: "1", - governanceSummary: - "Security apprentice governor contributing audits, incident playbooks, and review notes for high-risk proposals.", - proofSections: { - time: { - title: "Proof-of-Time", - items: [ - { label: "Human node for", value: "2 Y · 7 M" }, - { label: "Governor for", value: "1 Y · 2 M" }, - ], - }, - devotion: { - title: "Proof-of-Devotion", - items: [ - { label: "Proposal accepted?", value: "No" }, - { label: "Participated in formation?", value: "No" }, - ], - }, - governance: { - title: "Proof-of-Governance", - items: [ - { label: "Actively governed", value: "1 Y · 0 M" }, - { label: "Active governor?", value: "Yes" }, - ], - }, - }, - }), - tony: createProfile({ - id: "tony", - invisionScore: 71, - delegationShare: "0.8%", - proposalsCreated: "2", - governanceSummary: - "Community-facing governor focused on onboarding, comms clarity, and getting more humans from reading to voting.", - proofSections: { - time: { - title: "Proof-of-Time", - items: [ - { label: "Human node for", value: "3 Y · 1 M" }, - { label: "Governor for", value: "1 Y · 6 M" }, - ], - }, - devotion: { - title: "Proof-of-Devotion", - items: [ - { label: "Proposal accepted?", value: "Yes" }, - { label: "Participated in formation?", value: "Yes" }, - ], - }, - governance: { - title: "Proof-of-Governance", - items: [ - { label: "Actively governed", value: "0 Y · 10 M" }, - { label: "Active governor?", value: "No" }, - ], - }, - }, - }), - sesh: createProfile({ - id: "sesh", - invisionScore: 90, - delegationShare: "3.1%", - proposalsCreated: "9", - governanceSummary: - "Security council lead with a bias for hardening: threat models, incident response, and ruthless clarity in proposal scope.", - proofSections: { - time: { - title: "Proof-of-Time", - items: [ - { label: "Human node for", value: "4 Y · 6 M" }, - { label: "Governor for", value: "3 Y · 2 M" }, - ], - }, - devotion: { - title: "Proof-of-Devotion", - items: [ - { label: "Proposal accepted?", value: "Yes" }, - { label: "Participated in formation?", value: "Yes" }, - ], - }, - governance: { - title: "Proof-of-Governance", - items: [ - { label: "Actively governed", value: "3 Y · 0 M" }, - { label: "Active governor?", value: "Yes" }, - ], - }, - }, - }), - petr: createProfile({ - id: "petr", - invisionScore: 76, - delegationShare: "1.1%", - proposalsCreated: "4", - governanceSummary: - "Treasury operations governor focused on budget readability, reporting cadence, and keeping Formation tranches accountable.", - proofSections: { - time: { - title: "Proof-of-Time", - items: [ - { label: "Human node for", value: "1 Y · 9 M" }, - { label: "Governor for", value: "1 Y · 2 M" }, - ], - }, - devotion: { - title: "Proof-of-Devotion", - items: [ - { label: "Proposal accepted?", value: "Yes" }, - { label: "Participated in formation?", value: "Yes" }, - ], - }, - governance: { - title: "Proof-of-Governance", - items: [ - { label: "Actively governed", value: "0 Y · 9 M" }, - { label: "Active governor?", value: "No" }, - ], - }, - }, - }), - shannon: createProfile({ - id: "shannon", - invisionScore: 88, - delegationShare: "2.2%", - proposalsCreated: "6", - governanceSummary: - "Formation logistics consul keeping squads aligned, milestones realistic, and the execution layer moving without drama.", - proofSections: { - time: { - title: "Proof-of-Time", - items: [ - { label: "Human node for", value: "0 Y · 11 M" }, - { label: "Governor for", value: "0 Y · 11 M" }, - ], - }, - devotion: { - title: "Proof-of-Devotion", - items: [ - { label: "Proposal accepted?", value: "Yes" }, - { label: "Participated in formation?", value: "Yes" }, - ], - }, - governance: { - title: "Proof-of-Governance", - items: [ - { label: "Actively governed", value: "0 Y · 10 M" }, - { label: "Active governor?", value: "Yes" }, - ], - }, - }, - }), - shahmeer: createProfile({ - id: "shahmeer", - invisionScore: 94, - delegationShare: "3.8%", - proposalsCreated: "12", - governanceSummary: - "Protocol steward with long-range perspective, focused on stability, upgrades, and keeping Vortex rules legible as the network scales.", - proofSections: { - time: { - title: "Proof-of-Time", - items: [ - { label: "Human node for", value: "4 Y · 2 M" }, - { label: "Governor for", value: "3 Y · 6 M" }, - ], - }, - devotion: { - title: "Proof-of-Devotion", - items: [ - { label: "Proposal accepted?", value: "Yes" }, - { label: "Participated in formation?", value: "Yes" }, - ], - }, - governance: { - title: "Proof-of-Governance", - items: [ - { label: "Actively governed", value: "3 Y · 2 M" }, - { label: "Active governor?", value: "Yes" }, - ], - }, - }, - }), - fiona: createProfile({ - id: "fiona", - invisionScore: 79, - delegationShare: "1.4%", - proposalsCreated: "3", - governanceSummary: - "Community builder pushing clear guides, better onboarding, and practical rituals that keep governors active across eras.", - proofSections: { - time: { - title: "Proof-of-Time", - items: [ - { label: "Human node for", value: "2 Y · 4 M" }, - { label: "Governor for", value: "1 Y · 8 M" }, - ], - }, - devotion: { - title: "Proof-of-Devotion", - items: [ - { label: "Proposal accepted?", value: "Yes" }, - { label: "Participated in formation?", value: "Yes" }, - ], - }, - governance: { - title: "Proof-of-Governance", - items: [ - { label: "Actively governed", value: "1 Y · 6 M" }, - { label: "Active governor?", value: "Yes" }, - ], - }, - }, - }), - silis: createProfile({ - id: "silis", - invisionScore: 80, - delegationShare: "1.0%", - proposalsCreated: "4", - governanceSummary: - "Legal ops legate focused on risk framing, clear proposal requirements, and reducing ambiguity that causes chamber churn.", - proofSections: { - time: { - title: "Proof-of-Time", - items: [ - { label: "Human node for", value: "2 Y · 5 M" }, - { label: "Governor for", value: "1 Y · 11 M" }, - ], - }, - devotion: { - title: "Proof-of-Devotion", - items: [ - { label: "Proposal accepted?", value: "Yes" }, - { label: "Participated in formation?", value: "No" }, - ], - }, - governance: { - title: "Proof-of-Governance", - items: [ - { label: "Actively governed", value: "0 Y · 10 M" }, - { label: "Active governor?", value: "No" }, - ], - }, - }, - }), - ekko: createProfile({ - id: "ekko", - invisionScore: 75, - delegationShare: "0.9%", - proposalsCreated: "2", - governanceSummary: - "Formation coordinator keeping projects on track and translating chamber decisions into clean milestone checklists.", - proofSections: { - time: { - title: "Proof-of-Time", - items: [ - { label: "Human node for", value: "2 Y · 11 M" }, - { label: "Governor for", value: "1 Y · 5 M" }, - ], - }, - devotion: { - title: "Proof-of-Devotion", - items: [ - { label: "Proposal accepted?", value: "Yes" }, - { label: "Participated in formation?", value: "Yes" }, - ], - }, - governance: { - title: "Proof-of-Governance", - items: [ - { label: "Actively governed", value: "1 Y · 1 M" }, - { label: "Active governor?", value: "Yes" }, - ], - }, - }, - }), - andrei: createProfile({ - id: "andrei", - invisionScore: 86, - delegationShare: "2.7%", - proposalsCreated: "8", - governanceSummary: - "Infra-first consul focused on monitoring, reliability, and turning protocol goals into measurable operating standards.", - proofSections: { - time: { - title: "Proof-of-Time", - items: [ - { label: "Human node for", value: "3 Y · 7 M" }, - { label: "Governor for", value: "2 Y · 5 M" }, - ], - }, - devotion: { - title: "Proof-of-Devotion", - items: [ - { label: "Proposal accepted?", value: "Yes" }, - { label: "Participated in formation?", value: "Yes" }, - ], - }, - governance: { - title: "Proof-of-Governance", - items: [ - { label: "Actively governed", value: "2 Y · 2 M" }, - { label: "Active governor?", value: "Yes" }, - ], - }, - }, - }), - fares: createProfile({ - id: "fares", - invisionScore: 84, - delegationShare: "2.0%", - proposalsCreated: "6", - governanceSummary: - "Economics legate focused on budgets, incentives, and keeping proposal asks aligned with measurable outcomes.", - proofSections: { - time: { - title: "Proof-of-Time", - items: [ - { label: "Human node for", value: "3 Y · 9 M" }, - { label: "Governor for", value: "2 Y · 0 M" }, - ], - }, - devotion: { - title: "Proof-of-Devotion", - items: [ - { label: "Proposal accepted?", value: "Yes" }, - { label: "Participated in formation?", value: "Yes" }, - ], - }, - governance: { - title: "Proof-of-Governance", - items: [ - { label: "Actively governed", value: "1 Y · 10 M" }, - { label: "Active governor?", value: "Yes" }, - ], - }, - }, - }), -}; - -export const myProfileId = "dato"; -export const myProfile = humanNodeProfilesById[myProfileId]; - -export const getHumanNodeProfile = (id: string | undefined): HumanNodeProfile => - (id ? humanNodeProfilesById[id] : undefined) ?? myProfile; diff --git a/db/seed/fixtures/humanNodes.ts b/db/seed/fixtures/humanNodes.ts deleted file mode 100644 index 5a18c4e..0000000 --- a/db/seed/fixtures/humanNodes.ts +++ /dev/null @@ -1,227 +0,0 @@ -export type HumanNode = { - id: string; - name: string; - role: string; - chamber: string; - factionId: string; - tier: "nominee" | "ecclesiast" | "legate" | "consul" | "citizen"; - acm: number; - mm: number; - memberSince: string; - formationCapable?: boolean; - active: boolean; - formationProjectIds?: string[]; - tags: string[]; -}; - -export const humanNodes: HumanNode[] = [ - { - id: "dato", - name: "Dato", - role: "Consul · Engineering", - chamber: "engineering", - factionId: "validator-subsidies", - tier: "consul", - acm: 176, - mm: 88, - memberSince: "14.09.2021", - formationCapable: true, - active: true, - formationProjectIds: ["mev-safe-dex-v1-launch-sprint"], - tags: ["engineering", "infra", "ops"], - }, - { - id: "victor", - name: "Victor", - role: "Legate · General", - chamber: "general", - factionId: "local-voting-adoption-movement", - tier: "legate", - acm: 160, - mm: 82, - memberSince: "02.02.2022", - formationCapable: false, - active: true, - tags: ["general", "governance", "policy"], - }, - { - id: "temo", - name: "Temo", - role: "Nominee · Product", - chamber: "product", - factionId: "anti-ai-slop-conglomerate", - tier: "nominee", - acm: 124, - mm: 66, - memberSince: "21.03.2024", - formationCapable: false, - active: true, - tags: ["product", "ux", "community"], - }, - { - id: "dima", - name: "Dima", - role: "Nominee · Engineering Apprentice", - chamber: "engineering", - factionId: "formal-verification-maxis", - tier: "nominee", - acm: 131, - mm: 64, - memberSince: "21.05.2022", - formationCapable: false, - active: true, - tags: ["engineering", "audits"], - }, - { - id: "tony", - name: "Tony", - role: "Ecclesiast · Marketing", - chamber: "marketing", - factionId: "social-media-awareness", - tier: "ecclesiast", - acm: 143, - mm: 72, - memberSince: "08.11.2021", - formationCapable: true, - active: false, - formationProjectIds: ["mev-safe-dex-v1-launch-sprint"], - tags: ["marketing", "community", "product"], - }, - { - id: "sesh", - name: "Sesh", - role: "Legate · Engineering", - chamber: "engineering", - factionId: "formal-verification-maxis", - tier: "legate", - acm: 168, - mm: 86, - memberSince: "17.07.2020", - formationCapable: true, - active: true, - formationProjectIds: ["evm-dev-starter-kit"], - tags: ["engineering", "infra", "audits"], - }, - { - id: "petr", - name: "Petr", - role: "Ecclesiast · Treasury Ops", - chamber: "economics", - factionId: "delegation-removal-supporters", - tier: "ecclesiast", - acm: 152, - mm: 77, - memberSince: "05.06.2023", - formationCapable: true, - active: false, - formationProjectIds: ["mev-safe-dex-v1-launch-sprint"], - tags: ["treasury", "economics", "operations"], - }, - { - id: "shannon", - name: "Shannon", - role: "Consul · Product Ops", - chamber: "product", - factionId: "validator-subsidies", - tier: "consul", - acm: 173, - mm: 89, - memberSince: "21.06.2024", - formationCapable: true, - active: true, - formationProjectIds: ["mev-safe-dex-v1-launch-sprint"], - tags: ["product", "operations", "logistics"], - }, - { - id: "shahmeer", - name: "Shahmeer", - role: "Citizen · Engineering Steward", - chamber: "engineering", - factionId: "formal-verification-maxis", - tier: "citizen", - acm: 184, - mm: 93, - memberSince: "18.10.2020", - formationCapable: true, - active: true, - formationProjectIds: ["mev-safe-dex-v1-launch-sprint"], - tags: ["engineering", "governance", "infra"], - }, - { - id: "fiona", - name: "Fiona", - role: "Ecclesiast · Community Builder", - chamber: "marketing", - factionId: "local-voting-adoption-movement", - tier: "ecclesiast", - acm: 148, - mm: 79, - memberSince: "09.01.2023", - formationCapable: true, - active: true, - formationProjectIds: ["evm-dev-starter-kit"], - tags: ["community", "marketing", "education"], - }, - { - id: "silis", - name: "Silis", - role: "Legate · General Ops", - chamber: "general", - factionId: "anti-ai-slop-conglomerate", - tier: "legate", - acm: 157, - mm: 81, - memberSince: "12.12.2022", - formationCapable: false, - active: false, - tags: ["general", "policy", "risk"], - }, - { - id: "ekko", - name: "Ekko", - role: "Ecclesiast · Product Coordinator", - chamber: "product", - factionId: "validator-subsidies", - tier: "ecclesiast", - acm: 146, - mm: 75, - memberSince: "27.04.2022", - formationCapable: true, - active: true, - formationProjectIds: ["evm-dev-starter-kit"], - tags: ["product", "operations", "delivery"], - }, - { - id: "andrei", - name: "Andrei", - role: "Consul · Infrastructure", - chamber: "engineering", - factionId: "delegation-removal-supporters", - tier: "consul", - acm: 170, - mm: 87, - memberSince: "30.08.2021", - formationCapable: true, - active: true, - formationProjectIds: ["mev-safe-dex-v1-launch-sprint"], - tags: ["infra", "engineering", "observability"], - }, - { - id: "fares", - name: "Fares", - role: "Legate · Economics", - chamber: "economics", - factionId: "formal-verification-maxis", - tier: "legate", - acm: 163, - mm: 84, - memberSince: "11.05.2021", - formationCapable: true, - active: true, - formationProjectIds: ["mev-safe-dex-v1-launch-sprint"], - tags: ["economics", "treasury", "modeling"], - }, -]; - -export const getHumanNode = (id: string): HumanNode | undefined => - humanNodes.find((node) => node.id === id); diff --git a/db/seed/fixtures/invision.ts b/db/seed/fixtures/invision.ts deleted file mode 100644 index 779c606..0000000 --- a/db/seed/fixtures/invision.ts +++ /dev/null @@ -1,70 +0,0 @@ -export const invisionGovernanceState = { - label: "Egalitarian Republic", - metrics: [ - { label: "Legitimacy", value: "78%" }, - { label: "Stability", value: "72%" }, - { label: "Centralization", value: "44%" }, - ], -} as const; - -export const invisionEconomicIndicators = [ - { - label: "Treasury reserves", - value: "412M HMND", - detail: "52 weeks of runway", - }, - { label: "Burn rate", value: "7.8M HMND / epoch", detail: "Up 4% vs prior" }, - { - label: "Civic budget", - value: "112M HMND", - detail: "Infrastructure & grants", - }, - { - label: "Trade routes", - value: "9 active", - detail: "Formation & faction deals", - }, -] as const; - -export const invisionRiskSignals = [ - { - title: "Faction cohesion", - status: "Low risk", - detail: "Blocs aligned on governance reforms", - }, - { - title: "External deterrence", - status: "Moderate risk", - detail: "Neighboring factions probing markets", - }, - { - title: "Treasury liquidity", - status: "Low risk", - detail: "Healthy reserves & inflows", - }, - { - title: "Formation morale", - status: "Watch", - detail: "Two squads reported burnout", - }, -] as const; - -export const invisionChamberProposals = [ - { - title: "Humanode EVM Dev Starter Kit & Testing Sandbox", - effect: - "Starter kit + sandbox to cut time-to-first-dApp to under 30 minutes", - sponsors: "Validator subsidies · Formal verification maxis", - }, - { - title: "Voluntary Governor Commitment Staking", - effect: - "Optional commitment staking with opt-in self-slashing (no voting power impact)", - sponsors: "Local voting adoption movement · Delegation removal supporters", - }, - { - title: "Biometric Account Recovery & Key Rotation Pallet", - effect: "Key rotation + recovery via biometric identity (audited pallet)", - sponsors: "Formal verification maxis · Anti-AI-slop conglomerate", - }, -] as const; diff --git a/db/seed/fixtures/myGovernance.ts b/db/seed/fixtures/myGovernance.ts deleted file mode 100644 index ec96be9..0000000 --- a/db/seed/fixtures/myGovernance.ts +++ /dev/null @@ -1,14 +0,0 @@ -export const eraActivity = { - era: "142", - required: 18, - completed: 11, - actions: [ - { label: "Pool votes", done: 5, required: 6 }, - { label: "Chamber votes", done: 3, required: 6 }, - { label: "Court actions", done: 1, required: 3 }, - { label: "Proposals", done: 2, required: 3 }, - ], - timeLeft: "22d 14h", -} as const; - -export const myChamberIds = ["engineering", "product"] as const; diff --git a/db/seed/fixtures/proposalDraft.ts b/db/seed/fixtures/proposalDraft.ts deleted file mode 100644 index 5ff148d..0000000 --- a/db/seed/fixtures/proposalDraft.ts +++ /dev/null @@ -1,62 +0,0 @@ -export const proposalDraftDetails = { - title: "Vortex Governance Hub UX Refresh & Design System v1", - proposer: "Temo", - chamber: "Design chamber", - focus: "Clarity, hierarchy, and mobile UX", - tier: "Nominee", - budget: "20k HMND", - formationEligible: true, - teamSlots: "1 / 2", - milestonesPlanned: "3 milestones · 6 weeks", - summary: - "Audit Vortex UX, create a lightweight design system, redesign key flows, and deliver dev-ready Figma + basic design tokens.", - rationale: - "Vortex has strong concepts but feels dense and unclear for many users, especially on mobile. A coherent design system + clear hierarchy reduces confusion and makes governance usable for new humans.", - budgetScope: - "Covers the UX audit + flow mapping, Design System v1 (tokens + components), redesigned core screens (proposals/chambers/Invision/proposal creation), and a dev-ready handoff bundle. Out of scope: implementing the redesign in the frontend.", - invisionInsight: { - role: "Product & Web UX", - bullets: [ - "Improves scanability of proposals, chambers, and Invision insights across devices.", - "Reduces onboarding confusion (“where do I vote?” / “where do I start?”) with clearer hierarchy and flows.", - "Delivers dev-ready Figma + token notes so implementation is predictable and consistent.", - ], - }, - checklist: [ - "Map current flows and pain points; produce a short problem map.", - "Deliver wireframes for proposal list/detail, creation flow, chambers, and Invision.", - "Build a lightweight design system (tokens + components) and apply to key screens.", - "Prepare dev handoff bundle with guidelines and token mapping notes.", - ], - milestones: [ - "M1 — UX Flows & Wireframes", - "M2 — Design System & Key Screens", - "M3 — Handoff Ready", - ], - teamLocked: [{ name: "Temo", role: "Lead product & UX designer" }], - openSlotNeeds: [ - { - title: "Frontend dev (part-time, optional)", - desc: "Help translate the design system into tokens, review feasibility, and support the dev handoff.", - }, - ], - milestonesDetail: [ - { - title: "M1 — UX Flows & Wireframes", - desc: "Desktop + mobile wireframes for main flows with one iteration cycle after review.", - }, - { - title: "M2 — Design System & Key Screens", - desc: "Tokens (colors/type/spacing/shadows) + key components, applied to proposal list/detail, creation flow, chambers, and Invision.", - }, - { - title: "M3 — Handoff Ready", - desc: "Polished Figma bundle + guidelines + token notes, plus optional walkthrough calls with devs.", - }, - ], - attachments: [ - { title: "Portfolio: https://pixel-node.design", href: "#" }, - { title: "UX audit + problem map (draft)", href: "#" }, - { title: "Design system tokens (draft)", href: "#" }, - ], -} as const; diff --git a/db/seed/fixtures/proposalPages.ts b/db/seed/fixtures/proposalPages.ts deleted file mode 100644 index e3b7a6a..0000000 --- a/db/seed/fixtures/proposalPages.ts +++ /dev/null @@ -1,807 +0,0 @@ -import { proposals as proposalList } from "./proposals.ts"; - -export type InvisionInsight = { - role: string; - bullets: string[]; -}; - -const proposalSummaryById: Record = Object.fromEntries( - proposalList.map((proposal) => [proposal.id, proposal.summary]), -); - -const summaryFor = (id: string) => proposalSummaryById[id] ?? ""; - -export type PoolProposalPage = { - title: string; - proposer: string; - proposerId: string; - chamber: string; - focus: string; - tier: string; - budget: string; - cooldown: string; - formationEligible: boolean; - teamSlots: string; - milestones: string; - upvotes: number; - downvotes: number; - attentionQuorum: number; - activeGovernors: number; - upvoteFloor: number; - rules: string[]; - attachments: { id: string; title: string }[]; - teamLocked: { name: string; role: string }[]; - openSlotNeeds: { title: string; desc: string }[]; - milestonesDetail: { title: string; desc: string }[]; - summary: string; - overview: string; - executionPlan: string[]; - budgetScope: string; - invisionInsight: InvisionInsight; -}; - -export type ChamberProposalPage = { - title: string; - proposer: string; - proposerId: string; - chamber: string; - budget: string; - formationEligible: boolean; - teamSlots: string; - milestones: string; - timeLeft: string; - votes: { yes: number; no: number; abstain: number }; - attentionQuorum: number; - passingRule: string; - engagedGovernors: number; - activeGovernors: number; - attachments: { id: string; title: string }[]; - teamLocked: { name: string; role: string }[]; - openSlotNeeds: { title: string; desc: string }[]; - milestonesDetail: { title: string; desc: string }[]; - summary: string; - overview: string; - executionPlan: string[]; - budgetScope: string; - invisionInsight: InvisionInsight; -}; - -export type FormationProposalPage = { - title: string; - chamber: string; - proposer: string; - proposerId: string; - budget: string; - timeLeft: string; - teamSlots: string; - milestones: string; - progress: string; - stageData: { title: string; description: string; value: string }[]; - stats: { label: string; value: string }[]; - lockedTeam: { name: string; role: string }[]; - openSlots: { title: string; desc: string }[]; - milestonesDetail: { title: string; desc: string }[]; - attachments: { id: string; title: string }[]; - summary: string; - overview: string; - executionPlan: string[]; - budgetScope: string; - invisionInsight: InvisionInsight; -}; - -const poolProposals: Record = { - "humanode-dreamscapes-visual-lore": { - title: "Humanode Dreamscapes: Visual Lore Series", - proposer: "Fiona", - proposerId: "fiona", - chamber: "Design chamber", - focus: "Visual lore & culture", - tier: "Ecclesiast", - budget: "9k HMND", - cooldown: "Withdraw cooldown: 12h", - formationEligible: false, - teamSlots: "1 / 1", - milestones: "3", - upvotes: 18, - downvotes: 6, - attentionQuorum: 0.2, - activeGovernors: 150, - upvoteFloor: 15, - rules: [ - "20% attention from active governors required.", - "At least 10% upvotes to move to chamber vote.", - "Deliverables must be high-res and usable across formats (print + social crops).", - ], - attachments: [ - { id: "portfolio", title: "Portfolio: https://…/void-artist" }, - ], - teamLocked: [{ name: "Fiona", role: "Visual artist & art director" }], - openSlotNeeds: [], - milestonesDetail: [ - { - title: "M1 — Concepts & Sketches", - desc: "Concept sheet + roughs for 8–10 pieces, with short titles and one-sentence intent per artwork.", - }, - { - title: "M2 — First Batch of Finals", - desc: "4–6 finished high-res artworks delivered, plus social crops and light feedback iteration.", - }, - { - title: "M3 — Full Set & Asset Pack", - desc: "Complete 8–10 artwork set delivered with an asset pack and simple usage notes.", - }, - ], - summary: summaryFor("humanode-dreamscapes-visual-lore"), - overview: - "Humanode visuals are often product screenshots or generic graphics. Dreamscapes creates a coherent aesthetic world: symbolic pieces (Vortex engine room, Tier Decay, Proof of Biometric Uniqueness) usable for posters, banners, and community identity.", - executionPlan: [ - "Week 0: brief alignment + moodboard and 3–4 style probes to lock direction.", - "Weeks 1–2: concepts and roughs for all pieces; pick the final list and priorities.", - "Weeks 3–4: finish 4–6 artworks; export social crops; incorporate light feedback.", - "Week 5: finish the full set and deliver the asset pack + usage notes.", - ], - budgetScope: - "Total ask: 9,000 HMND (2k concept/art direction, 6k creation, 1k exports/docs). Out of scope: motion/video, UI/UX work, and paid campaigns.", - invisionInsight: { - role: "Visual Artist / Culture", - bullets: [ - "Proposals: 1 total · 1 approved (community-funded experiment)", - "Milestones: 2 / 2 completed · on time", - "Delegations: 0.3% active voting power from 3 Humanodes", - "Confidence: Medium delivery risk · 4.4 / 5", - ], - }, - }, - "biometric-account-recovery": { - title: "Biometric Account Recovery & Key Rotation Pallet", - proposer: "Shahmeer", - proposerId: "shahmeer", - chamber: "Engineering chamber", - focus: "Account safety & identity UX", - tier: "Citizen", - budget: "34k HMND", - cooldown: "Withdraw cooldown: 12h", - formationEligible: true, - teamSlots: "1 / 2", - milestones: "3", - upvotes: 44, - downvotes: 7, - attentionQuorum: 0.2, - activeGovernors: 150, - upvoteFloor: 15, - rules: [ - "20% attention from active governors required.", - "At least 10% upvotes to move to chamber vote.", - "Delegated votes are ignored in the pool.", - ], - attachments: [ - { id: "github", title: "GitHub: https://github.com/cto-node" }, - { id: "security-notes", title: "Security invariants (draft)" }, - { id: "audit-scope", title: "Audit scope & shortlist (draft)" }, - ], - teamLocked: [{ name: "Shahmeer", role: "Architect & reviewer" }], - openSlotNeeds: [ - { - title: "Rust / Substrate engineer", - desc: "Implement pallet + tests; integrate with identity registry; respond to audit findings.", - }, - ], - milestonesDetail: [ - { - title: "M1 — Pallet implemented & tested", - desc: "Core extrinsics (link/rotate/retire) + tests + integration hooks with identity registry.", - }, - { - title: "M2 — Audit completed", - desc: "External audit focused on takeover/replay paths; fixes applied; report published.", - }, - { - title: "M3 — Mainnet activation & docs", - desc: "Testnet rollout, staged tests, runtime upgrade scheduled, docs published on docs.humanode.io.", - }, - ], - summary: summaryFor("biometric-account-recovery"), - overview: - "If a user loses or compromises their key today, their account is effectively dead. Humanode can confirm the same human via biometric uniqueness, so keys should be replaceable without breaking “one human” semantics.", - executionPlan: [ - "Week 0: confirm engineer and auditor; lock invariants and integration boundaries.", - "Weeks 1–3: implement pallet + tests; design HumanID ↔ account mapping and cooldowns.", - "Weeks 4–6: external audit + fixes; publish report and merge final code.", - "Weeks 7–8: testnet → mainnet runtime upgrade; publish user/dev docs and link from frontends.", - ], - budgetScope: - "Total ask: 34,000 HMND for ~8 weeks (6k architect/review, 18k engineer, 10k external audit). Out of scope: changes to biometric verification flows, advanced social recovery, or broad wallet UI redesign.", - invisionInsight: { - role: "CTO / Protocol Architect", - bullets: [ - "Proposals: 5 total · 5 approved · 0 abandoned", - "Milestones: 12 / 12 completed · avg delay +2 days", - "Budget: 210k HMND requested · 15k HMND returned", - "Governance: active in 100% of protocol-related proposals", - "Delegations: 4.3% active voting power from 31 Humanodes", - "Confidence: Very low delivery risk · 4.9 / 5", - ], - }, - }, - "humanode-ai-video-shorts": { - title: "Humanode AI Video Series: 3 Viral-Quality Shorts for Mass Reach", - proposer: "Tony", - proposerId: "tony", - chamber: "Design chamber", - focus: "High-quality shorts for mass reach", - tier: "Ecclesiast", - budget: "15k HMND", - cooldown: "Withdraw cooldown: 12h", - formationEligible: false, - teamSlots: "1 / 1", - milestones: "3", - upvotes: 22, - downvotes: 8, - attentionQuorum: 0.2, - activeGovernors: 150, - upvoteFloor: 15, - rules: [ - "20% attention from active governors required.", - "At least 10% upvotes to move to chamber vote.", - "Deliverables include editable project files and an asset pack for reuse.", - ], - attachments: [ - { id: "portfolio", title: "Portfolio: https://…/visual-node" }, - { id: "tools", title: "Tooling list + licensing plan (draft)" }, - { id: "style", title: "Style test snippets (draft)" }, - ], - teamLocked: [{ name: "Tony", role: "AI motion designer & producer" }], - openSlotNeeds: [], - milestonesDetail: [ - { - title: "M1 — Scripts & Style Tests", - desc: "Scripts, storyboards, and AI style test snippets for all 3 videos delivered.", - }, - { - title: "M2 — Video 1 & 2 Final", - desc: "Two final videos delivered in vertical + horizontal formats, with source project files.", - }, - { - title: "M3 — Video 3 + Asset Pack", - desc: "Third video delivered plus asset pack and short usage guide for the ecosystem.", - }, - ], - summary: summaryFor("humanode-ai-video-shorts"), - overview: - "We need visuals one level above typical AI spam. This proposal funds a cohesive 3-video series with strong hooks, consistent style, proper sound design, and reusable assets for official and community channels.", - executionPlan: [ - "Week 0: lock brief + messaging; purchase/upgrade required AI tooling and SFX/music libraries.", - "Weeks 1–2: scripts + storyboards + style tests for all 3 videos.", - "Weeks 3–4: produce Video 1 and Video 2; iterate once on feedback; deliver exports + project files.", - "Weeks 5–6: produce Video 3; compile asset pack + templates; deliver usage guide + handoff.", - ], - budgetScope: - "Total ask: 15,000 HMND (3k tools/assets, 10k production, 2k VO/freelance contingency). Out of scope: long-term content operations beyond the 3-video series.", - invisionInsight: { - role: "Visual & Content Creator", - bullets: [ - "Track record: Humanode AI Video Contest winner", - "Focus: high-quality, emotionally engaging visual content", - "Confidence: Low delivery risk · 4.7 / 5", - ], - }, - }, - "ai-video-launch-distribution-sprint": { - title: - "AI Video Launch & Distribution Sprint: Turn Visual Assets into Reach", - proposer: "Petr", - proposerId: "petr", - chamber: "Marketing chamber", - focus: "Distribution execution + playbook", - tier: "Ecclesiast", - budget: "18k HMND", - cooldown: "Withdraw cooldown: 12h", - formationEligible: true, - teamSlots: "1 / 3", - milestones: "3", - upvotes: 21, - downvotes: 7, - attentionQuorum: 0.2, - activeGovernors: 150, - upvoteFloor: 15, - rules: [ - "20% attention from active governors required.", - "At least 10% upvotes to move to chamber vote.", - "No paid ads in v1; focus on coordinated organic distribution and experiments.", - ], - attachments: [ - { id: "github", title: "GitHub: https://github.com/clip-captain" }, - ], - teamLocked: [{ name: "Petr", role: "Campaign lead & strategist" }], - openSlotNeeds: [ - { - title: "shorts-wizard", - desc: "Editor / repurposer for vertical clips, subtitles, hooks, and variants.", - }, - { - title: "comms-anchor", - desc: "Community + posting ops (scheduling, replies, coordination with core comms).", - }, - ], - milestonesDetail: [ - { - title: "M1 — Content Kits & Calendar", - desc: "Content kits (clips, thumbnails, captions, CTAs) + 6-week posting calendar delivered for up to 3 core videos.", - }, - { - title: "M2 — Campaign Live", - desc: "Multi-channel distribution executed with experiments and weekly tuning updates.", - }, - { - title: "M3 — Report & Playbook", - desc: "Campaign results summarized and a reusable AI video launch playbook produced.", - }, - ], - summary: summaryFor("ai-video-launch-distribution-sprint"), - overview: - "The failure mode is “publish once, then forget”. This proposal funds hands-on distribution: content kits, calendars, coordinated posting, community engagement, and measurement so videos turn into followers, community members, and potential Vortex participants.", - executionPlan: [ - "Week 0: recruit team; sync with core comms on access, timing, and approval process; inventory assets.", - "Weeks 1–2: produce kits and calendar; align messaging and CTAs per channel.", - "Weeks 3–5: run the campaign; iterate based on performance; publish weekly mini-updates.", - "Week 6: wrap-up report + playbook for future launches.", - ], - budgetScope: - "Total ask: 18,000 HMND (7k lead, 6k editor, 5k comms ops). Out of scope: producing new videos, paid ads, and influencer deals.", - invisionInsight: { - role: "Content & Growth Operations", - bullets: [ - "Proposals: 2 total · 2 approved", - "Milestones: 4 / 4 completed · avg delay +2 days", - "Budget: 22k HMND requested · 2k HMND returned", - "Delegations: 1.0% active voting power from 8 Humanodes", - "Confidence: Low delivery risk · 4.6 / 5", - ], - }, - }, - "vortex-field-experiments-s1": { - title: "Vortex Field Experiments: Season 1 (Find the True Believers)", - proposer: "Ekko", - proposerId: "ekko", - chamber: "Marketing chamber", - focus: "High-signal onboarding", - tier: "Ecclesiast", - budget: "24k HMND", - cooldown: "Withdraw cooldown: 12h", - formationEligible: true, - teamSlots: "1 / 3", - milestones: "3", - upvotes: 28, - downvotes: 9, - attentionQuorum: 0.2, - activeGovernors: 150, - upvoteFloor: 15, - rules: [ - "20% attention from active governors required.", - "At least 10% upvotes to move to chamber vote.", - "No paid ads / airdrop farming incentives in this proposal scope.", - ], - attachments: [ - { id: "github", title: "GitHub: https://github.com/signal-hacker" }, - { id: "hub", title: "Season 1 hub page (draft)" }, - { id: "briefs", title: "Experiment briefs & KPI targets (draft)" }, - ], - teamLocked: [{ name: "Ekko", role: "Campaign architect / growth" }], - openSlotNeeds: [ - { - title: "Content & design generalist", - desc: "Threads, visuals, simple edits, and recaps for each experiment.", - }, - { - title: "Community producer / mod", - desc: "Host clinics, run calls, onboard newcomers, and keep discussions healthy.", - }, - ], - milestonesDetail: [ - { - title: "M1 — Governance Puzzle Drop", - desc: "3–5 puzzles + debrief calls + recap thread + list of high-signal participants.", - }, - { - title: "M2 — Vortex Problem Clinics", - desc: "3 live clinics + recordings/summaries + follow-up write-ups + recurring participant list.", - }, - { - title: "M3 — Micro-Bounty Lab & Debrief", - desc: "10–15 thinking bounties + rewards + Season 1 report + onboarding map into Vortex.", - }, - ], - summary: summaryFor("vortex-field-experiments-s1"), - overview: - "Instead of ads or low-signal campaigns, Season 1 uses interactive governance experiments that require thinking and creation, filtering out farmers and pulling in true believers.", - executionPlan: [ - "Week 0: finalize team, channels, and minimal hub page + visual kit.", - "Weeks 1–2: run Puzzle Drop, debrief calls, and publish recap + high-signal list.", - "Weeks 3–4: run three Problem Clinics with external guests; publish summaries/clips.", - "Weeks 5–6: run Micro-Bounty Lab, reward best submissions, publish Season 1 debrief.", - ], - budgetScope: - "Total ask: 24,000 HMND for ~6 weeks (9k architect, 7k content/design, 5k community producer, 3k prizes/ops). Out of scope: paid ad campaigns and KOL packages.", - invisionInsight: { - role: "Growth & Community Experiments", - bullets: [ - "Proposals: 3 total · 3 approved · 0 abandoned", - "Milestones: 6 / 6 completed · avg delay +2 days", - "Budget: 60k HMND requested · 4k HMND returned", - "Governance: voted in 80% last year; comments on distribution & narrative", - "Delegations: 1.2% active voting power from 9 Humanodes", - "Confidence: Low delivery risk · 4.5 / 5", - ], - }, - }, -}; - -const chamberProposals: Record = { - "fixed-governor-stake-spam-slashing": { - title: "Fixed Governor Stake & Spam Slashing Rule for Vortex", - proposer: "Fares", - proposerId: "fares", - chamber: "Economics chamber", - budget: "18k HMND", - formationEligible: true, - teamSlots: "1 / 2", - milestones: "3", - timeLeft: "11h 05m", - votes: { yes: 36, no: 22, abstain: 4 }, - attentionQuorum: 0.33, - passingRule: "≥66.6% + 1 yes within quorum", - engagedGovernors: 62, - activeGovernors: 150, - attachments: [ - { id: "policy", title: "Spam definition & governance process (draft)" }, - { id: "params", title: "Parameter sheet: stake, slash curve, cooldowns" }, - ], - teamLocked: [{ name: "Fares", role: "Economic design lead / proposer" }], - openSlotNeeds: [ - { - title: "Rust / Substrate engineer", - desc: "Implement stake gate + lock/unlock + spam-slash hook; write tests; deploy to testnet/mainnet.", - }, - ], - milestonesDetail: [ - { - title: "M1 — Spec & Policy Ready", - desc: "Stake size, slashing curve, spam definition, and cooldowns documented and approved at policy level.", - }, - { - title: "M2 — Implementation & Testnet", - desc: "Runtime logic implemented + tests + scenario runs (normal, spam incidents, revoke/re-entry) on testnet.", - }, - { - title: "M3 — Mainnet Activation", - desc: "Mainnet activation + minimal monitoring + public post-deploy note.", - }, - ], - summary: summaryFor("fixed-governor-stake-spam-slashing"), - overview: - "Adds a single gate (fixed governor stake) and a slashing hook for repeated spam proposals. Voting power remains equal; stake only enforces eligibility and discipline.", - executionPlan: [ - "Week 0–1: confirm Substrate engineer; align spam definition and parameters with General chamber.", - "Weeks 2–3: publish spec (stake size, slash curve, cooldowns, tagging process).", - "Weeks 4–6: runtime implementation + tests + testnet scenario runs.", - "Weeks 7–8: mainnet activation + minimal monitoring + public post-deploy note.", - ], - budgetScope: - "Total ask: 18,000 HMND for ~8 weeks (7k economic/policy, 11k Substrate engineer). Out of scope: L1 tokenomics changes, voting power changes, and extensive UI work.", - invisionInsight: { - role: "Governance & Economics", - bullets: [ - "Proposals: 4 total · 3 approved · 0 abandoned", - "Milestones: 8 / 9 completed · avg delay +3 days · 0 slashing", - "Budget: 160k HMND requested · 7k HMND returned", - "Governance: voted in 95% last year · 24 economic comments", - "Delegations: 2.4% active voting power from 19 Humanodes", - "Confidence: Low delivery risk · 4.6 / 5", - ], - }, - }, - "tier-decay-v1": { - title: "Tier Decay v1: Nominee → Ecclesiast → Legate → Consul → Citizen", - proposer: "Andrei", - proposerId: "andrei", - chamber: "General chamber", - budget: "13k HMND", - formationEligible: true, - teamSlots: "2 / 2", - milestones: "3", - timeLeft: "2d 18h", - votes: { yes: 41, no: 13, abstain: 4 }, - attentionQuorum: 0.33, - passingRule: "≥66.6% + 1 yes within quorum", - engagedGovernors: 58, - activeGovernors: 150, - attachments: [ - { id: "params", title: "Decay thresholds + warnings table (draft)" }, - { id: "shadow", title: "Shadow-mode report template (draft)" }, - ], - teamLocked: [ - { name: "Andrei", role: "Governance design lead / proposer" }, - { name: "Engineer (TBD)", role: "Backend / data engineer (part-time)" }, - ], - openSlotNeeds: [], - milestonesDetail: [ - { - title: "M1 — Tier Decay Spec v1", - desc: "Finalize thresholds per tier, warning rules, and re-tiering conditions; publish and incorporate feedback.", - }, - { - title: "M2 — Implementation & Shadow Mode", - desc: "Implement tracking and run shadow mode for a few eras to validate expected decay outcomes.", - }, - { - title: "M3 — Activation & UX", - desc: "Enable decay, show tier + decay status in profiles/Invision, and publish a clear explainer.", - }, - ], - summary: summaryFor("tier-decay-v1"), - overview: - "Tier Decay steps down proposition rights over consecutive inactive eras (Citizen → Consul → Legate → Ecclesiast → Nominee → Inactive), while preserving 1 human = 1 vote.", - executionPlan: [ - "Week 0: inspect active-governor data and era history; lock v1 parameters.", - "Weeks 1–2: publish policy spec and incorporate feedback.", - "Weeks 3–4: implement tracking and run shadow mode for a few eras.", - "Weeks 5–6: enable decay and ship basic UX: tier + status + warnings.", - ], - budgetScope: - "Total ask: 13,000 HMND (5k spec/design + 8k part-time backend/data engineer). Out of scope: runtime changes and advanced notification integrations.", - invisionInsight: { - role: "Governance systems designer", - bullets: [ - "Focus: anti-ossification and aligning tiers with real activity", - "Confidence: Low delivery risk", - ], - }, - }, - "voluntary-commitment-staking": { - title: - "Voluntary Governor Commitment Staking (No Mandatory Stake, No Plutocracy)", - proposer: "Victor", - proposerId: "victor", - chamber: "General chamber", - budget: "16k HMND", - formationEligible: true, - teamSlots: "1 / 2", - milestones: "3", - timeLeft: "3d 12h", - votes: { yes: 44, no: 6, abstain: 2 }, - attentionQuorum: 0.33, - passingRule: "≥66.6% + 1 yes within quorum", - engagedGovernors: 52, - activeGovernors: 150, - attachments: [ - { id: "spec-v1", title: "Voluntary Commitment Staking Spec v1" }, - ], - teamLocked: [{ name: "Victor", role: "Economic design lead / proposer" }], - openSlotNeeds: [ - { - title: "Rust / Substrate engineer", - desc: "Implement optional commitment stake module + tests; add integration hooks for linking stake to pledges.", - }, - ], - milestonesDetail: [ - { - title: "M1 — Spec & UX", - desc: "Commitment stake spec + UX notes published (amount, pledge linking, self-slash conditions).", - }, - { - title: "M2 — Code & Testnet", - desc: "Implementation live on testnet with basic tests and an internal trial.", - }, - { - title: "M3 — Mainnet & Guidelines", - desc: "Mainnet activation + guidelines; insights updated to show stake + self-slash history.", - }, - ], - summary: summaryFor("voluntary-commitment-staking"), - overview: - "Introduce a voluntary “Commitment Stake” module: humans may stake any amount as a public signal and optionally attach self-slashing conditions tied to milestones or behavior, without gating governance or affecting voting power.", - executionPlan: [ - "Week 0–1: confirm Substrate engineer, define constraints (optional, no voting-power impact).", - "Weeks 2–3: publish spec + UX notes (linking stake to proposal/milestone pledges).", - "Weeks 4–6: implement module + tests, deploy to testnet and run an internal trial.", - "Weeks 7–8: mainnet activation + guidelines, update insights to show stake + self-slash history.", - ], - budgetScope: - "Total ask: 16,000 HMND for ~8 weeks. Breakdown: 6k economic design/spec + 10k Substrate engineer. Out of scope: any mandatory stake requirements, voting power changes, or complex reputation scoring beyond simple display.", - invisionInsight: { - role: "Governance & Public Goods Economics", - bullets: [ - "Proposals: 3 total · 3 approved · 0 abandoned", - "Milestones: 7 / 7 completed · avg delay +2 days · 0 slashing", - "Budget: 95k HMND requested · 5k HMND returned", - "Governance: voted in 90% last year · 20 comments on inclusivity/incentives", - "Delegations: 1.6% active voting power from 14 Humanodes", - "Confidence: Low delivery risk · 4.5 / 5", - ], - }, - }, -}; - -const formationProposals: Record = { - "evm-dev-starter-kit": { - title: "Humanode EVM Dev Starter Kit & Testing Sandbox", - chamber: "Engineering chamber", - proposer: "Sesh", - proposerId: "sesh", - budget: "180k HMND", - timeLeft: "12w", - teamSlots: "1 / 3", - milestones: "1 / 3", - progress: "24%", - stageData: [ - { title: "Budget allocated", description: "HMND", value: "18k / 180k" }, - { title: "Team slots", description: "Taken / Total", value: "1 / 3" }, - { title: "Milestones", description: "Completed / Total", value: "1 / 3" }, - ], - stats: [ - { label: "Lead chamber", value: "Engineering chamber" }, - { label: "Duration", value: "12 weeks" }, - ], - lockedTeam: [{ name: "Sesh", role: "Lead engineer" }], - openSlots: [ - { - title: "EVM full-stack developer", - desc: "Example dApps, sandbox UI, local setup + integration with Humanode EVM endpoints.", - }, - { - title: "Technical writer / DevRel", - desc: "Docs + tutorials, quickstarts, walkthrough videos, and early builder feedback loop.", - }, - ], - milestonesDetail: [ - { - title: "M1 — SDK & Template Ready", - desc: "TypeScript SDK + base dApp template implemented, tested, and released (v0.1.0).", - }, - { - title: "M2 — Sandbox Online", - desc: "Public testnet sandbox + faucet + one-command local dev setup and “Getting started” docs.", - }, - { - title: "M3 — Docs & Beta Launch", - desc: "Full docs + short walkthrough videos; closed beta with 3–5 teams and critical fixes merged.", - }, - ], - attachments: [ - { id: "tech-spec", title: "Technical spec (Notion / gist)" }, - { - id: "repo-draft", - title: "Draft repo: humanode-network/evm-dev-starter-kit", - }, - { id: "docs-outline", title: "Rough docs outline" }, - ], - summary: summaryFor("evm-dev-starter-kit"), - overview: - "Deliver a TypeScript SDK, templates, sandbox + faucet, and full docs so developers can deploy dApps on Humanode in under 30 minutes.", - executionPlan: [ - "Week 0–1: finalize requirements, recruit remaining roles, set up repos and CI skeleton.", - "Weeks 2–4: ship SDK + base template (v0.1.0) with minimal tests and quickstart.", - "Weeks 5–8: deploy public sandbox + faucet, ship one-command local dev setup.", - "Weeks 9–12: publish full docs + videos, run a beta with 3–5 teams, integrate feedback.", - ], - budgetScope: - "Total ask: 180,000 HMND for 12 weeks, unlocked by milestones (plus small upfront Formation tranche).", - invisionInsight: { - role: "Core Builder – Engineering", - bullets: [ - "Proposals: 6 total · 5 approved · 0 abandoned", - "Milestones: 13 / 14 completed · avg delay +4 days · 0 slashing", - "Confidence: Low delivery risk · 4.7 / 5", - ], - }, - }, - "mev-safe-dex-v1-launch-sprint": { - title: "Humanode MEV-Safe DEX v1 + Launch Sprint", - chamber: "Engineering chamber", - proposer: "Dato", - proposerId: "dato", - budget: "245k HMND", - timeLeft: "16w", - teamSlots: "3 / 5", - milestones: "2 / 4", - progress: "46%", - stageData: [ - { title: "Budget allocated", description: "HMND", value: "98k / 245k" }, - { title: "Team slots", description: "Filled / Total", value: "3 / 5" }, - { title: "Milestones", description: "Completed / Total", value: "2 / 4" }, - ], - stats: [ - { label: "Lead chamber", value: "Engineering chamber" }, - { label: "Audit", value: "In progress" }, - ], - lockedTeam: [ - { name: "Dato", role: "Protocol lead" }, - { name: "mev-ops", role: "MEV / relayer engineer (recruiting)" }, - { name: "frontend-loop", role: "Frontend dApp dev (recruiting)" }, - ], - openSlots: [ - { - title: "liq-pilot", - desc: "Liquidity onboarding, pool setup, and partner coordination (part-time).", - }, - { - title: "launch-captain", - desc: "Marketing lead for distribution + launch execution + reporting.", - }, - ], - milestonesDetail: [ - { - title: "M1 — Contracts MVP", - desc: "Spec + tested contracts + fee-to-nodes module; Biostaker/getHMND compatibility at contract layer.", - }, - { - title: "M2 — Protected swaps + UI", - desc: "Protected swap path on testnet + frontend alpha + bridge panel.", - }, - { - title: "M3 — Audit + Mainnet", - desc: "External audit, fixes, and mainnet deployment with core pools.", - }, - { - title: "M4 — Launch sprint", - desc: "Liquidity onboarding + coordinated launch sprint + playbook + first-month summary.", - }, - ], - attachments: [ - { id: "github", title: "GitHub: https://github.com/defi-synth" }, - { id: "audit", title: "Audit vendor scope (draft)" }, - { id: "launch", title: "Launch kit outline (draft)" }, - ], - summary: summaryFor("mev-safe-dex-v1-launch-sprint"), - overview: - "A DEX launch needs liquidity, trust, and repeated distribution. This project covers contracts + MEV protection + frontend + audit, plus a launch pod to drive adoption.", - executionPlan: [ - "Weeks 1–4: contracts MVP + tests/fuzzing.", - "Weeks 5–8: protected swaps on testnet + frontend alpha.", - "Weeks 9–12: audit + fixes + mainnet deploy.", - "Weeks 13–16: liquidity onboarding + marketing launch sprint + reporting.", - ], - budgetScope: - "Total ask: 245,000 HMND including audit and launch pod. Out of scope: paid influencer packages and any unparameterized tokenomics changes.", - invisionInsight: { - role: "DeFi engineer (shipping + adoption)", - bullets: [ - "Focus: AMMs/routers/incentives and production launches", - "Confidence: Low delivery risk (subject to audit)", - ], - }, - }, -}; - -export function getPoolProposalPage(id?: string) { - const first = Object.values(poolProposals)[0]; - return (id ? poolProposals[id] : undefined) ?? first; -} - -export function poolProposalPageById(id: string): PoolProposalPage | undefined { - return poolProposals[id]; -} - -export function getChamberProposalPage(id?: string) { - return ( - (id ? chamberProposals[id] : undefined) ?? - chamberProposals["voluntary-commitment-staking"] - ); -} - -export function chamberProposalPageById( - id: string, -): ChamberProposalPage | undefined { - return chamberProposals[id]; -} - -export function getFormationProposalPage(id?: string) { - const first = Object.values(formationProposals)[0]; - return (id ? formationProposals[id] : undefined) ?? first; -} - -export function formationProposalPageById( - id: string, -): FormationProposalPage | undefined { - return formationProposals[id]; -} diff --git a/db/seed/fixtures/proposals.ts b/db/seed/fixtures/proposals.ts deleted file mode 100644 index e742113..0000000 --- a/db/seed/fixtures/proposals.ts +++ /dev/null @@ -1,531 +0,0 @@ -import type { ReactNode } from "react"; - -import type { ProposalStage } from "@/types/stages"; - -export type ProposalStageDatum = { - title: string; - description: string; - value: ReactNode; - tone?: "ok" | "warn"; -}; - -export type ProposalStat = { - label: string; - value: ReactNode; -}; - -export type ProposalListItem = { - id: string; - title: string; - meta: string; - stage: ProposalStage; - summaryPill: string; - summary: string; - stageData: ProposalStageDatum[]; - stats: ProposalStat[]; - proposer: string; - proposerId: string; - chamber: string; - tier: "Nominee" | "Ecclesiast" | "Legate" | "Consul" | "Citizen"; - proofFocus: "pot" | "pod" | "pog"; - tags: string[]; - keywords: string[]; - date: string; - votes: number; - activityScore: number; - ctaPrimary: string; - ctaSecondary: string; -}; - -export const proposals: ProposalListItem[] = [ - { - id: "humanode-dreamscapes-visual-lore", - title: "Humanode Dreamscapes: Visual Lore Series", - meta: "Design chamber · Ecclesiast tier", - stage: "pool", - summaryPill: "8–10 artworks · 5 weeks", - summary: - "Commission a small series of high-quality surreal artworks that build Humanode/Vortex visual lore (culture fertilizer, not infographics).", - stageData: [ - { - title: "Pool momentum", - description: "Upvotes / Downvotes", - value: "18 / 6", - }, - { - title: "Attention quorum", - description: "20% active or ≥10% upvotes", - value: "Met · 16% engaged", - }, - { title: "Votes casted", description: "Backing seats", value: "18" }, - ], - stats: [ - { label: "Budget ask", value: "9k HMND" }, - { label: "Formation", value: "No" }, - ], - proposer: "Fiona", - proposerId: "fiona", - chamber: "Design chamber", - tier: "Ecclesiast", - proofFocus: "pod", - tags: ["Art", "Lore", "Culture"], - keywords: [ - "dreamscapes", - "lore", - "art", - "culture", - "visual", - "identity", - "vortex", - "humanode", - ], - date: "2026-01-10", - votes: 24, - activityScore: 70, - ctaPrimary: "Open proposal", - ctaSecondary: "Watch", - }, - { - id: "biometric-account-recovery", - title: "Biometric Account Recovery & Key Rotation Pallet", - meta: "Engineering chamber · Citizen tier", - stage: "pool", - summaryPill: "8 weeks · audited pallet", - summary: - "Substrate pallet to let a verified human rotate keys / recover accounts via biometric identity, with strict rules and an external audit.", - stageData: [ - { - title: "Pool momentum", - description: "Upvotes / Downvotes", - value: "44 / 7", - }, - { - title: "Attention quorum", - description: "20% active or ≥10% upvotes", - value: "Met · 34% engaged", - tone: "ok", - }, - { title: "Votes casted", description: "Backing seats", value: "44" }, - ], - stats: [ - { label: "Budget ask", value: "34k HMND" }, - { label: "Formation", value: "Yes" }, - ], - proposer: "Shahmeer", - proposerId: "shahmeer", - chamber: "Engineering chamber", - tier: "Citizen", - proofFocus: "pog", - tags: ["Protocol", "Security", "UX"], - keywords: [ - "biometric", - "account", - "recovery", - "key", - "rotation", - "pallet", - "substrate", - "audit", - "identity", - ], - date: "2026-01-08", - votes: 51, - activityScore: 93, - ctaPrimary: "Open proposal", - ctaSecondary: "Watch", - }, - { - id: "tier-decay-v1", - title: "Tier Decay v1", - meta: "General chamber · Consul tier", - stage: "vote", - summaryPill: "Chamber vote", - summary: "Keep high tiers reserved for active governors.", - stageData: [ - { - title: "Voting quorum", - description: "Strict 33% active governors", - value: "Met · 39%", - tone: "ok", - }, - { - title: "Passing rule", - description: "≥66.6% + 1 vote yes", - value: "Current 71%", - tone: "ok", - }, - { title: "Time left", description: "Voting window", value: "2d 18h" }, - ], - stats: [ - { label: "Budget ask", value: "13k HMND" }, - { label: "Formation", value: "Yes" }, - ], - proposer: "Andrei", - proposerId: "andrei", - chamber: "General chamber", - tier: "Consul", - proofFocus: "pog", - tags: ["Governance", "Tiers", "Policy"], - keywords: [ - "tier", - "decay", - "inactive", - "governor", - "eras", - "warnings", - "rights", - "vortex-1.0", - ], - date: "2026-01-07", - votes: 58, - activityScore: 84, - ctaPrimary: "Open proposal", - ctaSecondary: "Watch", - }, - { - id: "evm-dev-starter-kit", - title: "Humanode EVM Dev Starter Kit & Testing Sandbox", - meta: "Engineering chamber · Legate tier", - stage: "build", - summaryPill: "Milestone 1 / 3", - summary: - "EVM dev starter kit + public testing sandbox so developers can deploy dApps on Humanode in under 30 minutes.", - stageData: [ - { - title: "Budget allocated", - description: "HMND", - value: "18k / 180k", - }, - { - title: "Team slots", - description: "Taken / Total", - value: "1 / 3", - }, - { - title: "Progress", - description: "Reported completion", - value: "24%", - }, - ], - stats: [ - { label: "Budget ask", value: "180k HMND" }, - { label: "Duration", value: "12 weeks" }, - ], - proposer: "Sesh", - proposerId: "sesh", - chamber: "Engineering chamber", - tier: "Legate", - proofFocus: "pod", - tags: ["Dev tooling", "EVM", "Docs"], - keywords: [ - "humanode", - "evm", - "dev", - "starter", - "kit", - "sandbox", - "faucet", - "sdk", - "template", - "docs", - ], - date: "2026-01-06", - votes: 35, - activityScore: 91, - ctaPrimary: "Open proposal", - ctaSecondary: "Watch", - }, - { - id: "humanode-ai-video-shorts", - title: "Humanode AI Video Series: 3 Viral-Quality Shorts", - meta: "Design chamber · Ecclesiast tier", - stage: "pool", - summaryPill: "3 videos · 6 weeks", - summary: - "Produce three premium-quality AI-powered short videos (30–90s) explaining Humanode and Vortex for X/TikTok/Shorts, including project files + asset pack.", - stageData: [ - { - title: "Pool momentum", - description: "Upvotes / Downvotes", - value: "22 / 8", - }, - { - title: "Attention quorum", - description: "20% active or ≥10% upvotes", - value: "Met · 20% engaged", - tone: "ok", - }, - { title: "Votes casted", description: "Backing seats", value: "22" }, - ], - stats: [ - { label: "Budget ask", value: "15k HMND" }, - { label: "Formation", value: "No" }, - ], - proposer: "Tony", - proposerId: "tony", - chamber: "Design chamber", - tier: "Ecclesiast", - proofFocus: "pod", - tags: ["Video", "Growth", "Design"], - keywords: [ - "video", - "ai", - "shorts", - "tiktok", - "x", - "sound", - "storyboard", - "assets", - "vortex", - "humanode", - ], - date: "2026-01-01", - votes: 30, - activityScore: 72, - ctaPrimary: "Open proposal", - ctaSecondary: "Watch", - }, - { - id: "ai-video-launch-distribution-sprint", - title: "AI Video Launch & Distribution Sprint", - meta: "Marketing chamber · Ecclesiast tier", - stage: "pool", - summaryPill: "6 weeks · distribution playbook", - summary: - "Run a coordinated 6-week sprint to distribute Humanode AI videos across X/TikTok/Shorts/Telegram, with content kits, experiments, and a reusable launch playbook.", - stageData: [ - { - title: "Pool momentum", - description: "Upvotes / Downvotes", - value: "21 / 7", - }, - { - title: "Attention quorum", - description: "20% active or ≥10% upvotes", - value: "Met · 19% engaged", - tone: "ok", - }, - { title: "Votes casted", description: "Backing seats", value: "21" }, - ], - stats: [ - { label: "Budget ask", value: "18k HMND" }, - { label: "Formation", value: "Yes" }, - ], - proposer: "Petr", - proposerId: "petr", - chamber: "Marketing chamber", - tier: "Ecclesiast", - proofFocus: "pod", - tags: ["Growth", "Distribution", "Content ops"], - keywords: [ - "distribution", - "shorts", - "tiktok", - "x", - "youtube", - "telegram", - "calendar", - "playbook", - "funnels", - ], - date: "2026-01-02", - votes: 28, - activityScore: 73, - ctaPrimary: "Open proposal", - ctaSecondary: "Watch", - }, - { - id: "mev-safe-dex-v1-launch-sprint", - title: "Humanode MEV-Safe DEX v1 + Launch Sprint", - meta: "Engineering chamber · Consul tier", - stage: "build", - summaryPill: "Milestone 2 / 4", - summary: - "Ship a Humanode-native DEX with MEV protection, Biostaker + getHMND integrations, fees routed to Human Nodes, plus a 6-week launch sprint for real adoption.", - stageData: [ - { - title: "Budget allocated", - description: "HMND", - value: "98k / 245k", - }, - { - title: "Team slots", - description: "Filled / Total", - value: "3 / 5", - }, - { - title: "Progress", - description: "Reported completion", - value: "46%", - }, - ], - stats: [ - { label: "Budget ask", value: "245k HMND" }, - { label: "Audit", value: "In progress" }, - ], - proposer: "Dato", - proposerId: "dato", - chamber: "Engineering chamber", - tier: "Consul", - proofFocus: "pog", - tags: ["DeFi", "MEV", "Fees to nodes"], - keywords: [ - "dex", - "mev", - "protected-swaps", - "biostaker", - "gethmnd", - "fees", - "audit", - "liquidity", - "launch", - ], - date: "2025-12-29", - votes: 60, - activityScore: 95, - ctaPrimary: "Open proposal", - ctaSecondary: "Watch", - }, - { - id: "vortex-field-experiments-s1", - title: "Vortex Field Experiments: Season 1", - meta: "Marketing chamber · Ecclesiast tier", - stage: "pool", - summaryPill: "6 weeks · 3 experiments", - summary: - "Run a 6-week series of interactive governance experiments (puzzles, clinics, micro-bounties) to attract high-signal contributors into Vortex.", - stageData: [ - { - title: "Pool momentum", - description: "Upvotes / Downvotes", - value: "28 / 9", - }, - { - title: "Attention quorum", - description: "20% active or ≥10% upvotes", - value: "Met · 25% engaged", - tone: "ok", - }, - { title: "Votes casted", description: "Backing seats", value: "28" }, - ], - stats: [ - { label: "Budget ask", value: "24k HMND" }, - { label: "Formation", value: "Yes" }, - ], - proposer: "Ekko", - proposerId: "ekko", - chamber: "Marketing chamber", - tier: "Ecclesiast", - proofFocus: "pod", - tags: ["Growth", "Community", "Experiments"], - keywords: [ - "marketing", - "growth", - "experiments", - "puzzles", - "clinics", - "micro-bounties", - "onboarding", - "governance", - ], - date: "2026-01-05", - votes: 37, - activityScore: 76, - ctaPrimary: "Open proposal", - ctaSecondary: "Watch", - }, - { - id: "fixed-governor-stake-spam-slashing", - title: "Fixed Governor Stake & Spam Slashing Rule for Vortex", - meta: "Economics chamber · Legate tier", - stage: "vote", - summaryPill: "Chamber vote", - summary: - "Introduce a fixed HMND stake to remain a governor plus a simple spam slashing curve, without changing voting power (still 1 human = 1 vote).", - stageData: [ - { - title: "Voting quorum", - description: "Strict 33% active governors", - value: "Met · 41%", - tone: "ok", - }, - { - title: "Passing rule", - description: "≥66.6% + 1 vote yes", - value: "Current 58%", - tone: "warn", - }, - { title: "Time left", description: "Voting window", value: "11h 05m" }, - ], - stats: [ - { label: "Budget ask", value: "18k HMND" }, - { label: "Formation", value: "Yes" }, - ], - proposer: "Fares", - proposerId: "fares", - chamber: "Economics chamber", - tier: "Legate", - proofFocus: "pog", - tags: ["Economics", "Governance", "Spam resistance"], - keywords: [ - "stake", - "slashing", - "spam", - "governor", - "economics", - "parameters", - "incentives", - "non-plutocratic", - ], - date: "2026-01-03", - votes: 62, - activityScore: 89, - ctaPrimary: "Open proposal", - ctaSecondary: "Watch", - }, - { - id: "voluntary-commitment-staking", - title: "Voluntary Governor Commitment Staking", - meta: "General chamber · Legate tier", - stage: "vote", - summaryPill: "No mandatory stake", - summary: - "Optional HMND commitment staking with opt-in self-slashing, without changing voting power (1 human = 1 vote).", - stageData: [ - { - title: "Voting quorum", - description: "Strict 33% active governors", - value: "Met · 35%", - tone: "ok", - }, - { - title: "Passing rule", - description: "≥66.6% + 1 vote yes", - value: "Current 86%", - tone: "ok", - }, - { title: "Time left", description: "Voting window", value: "3d 12h" }, - ], - stats: [{ label: "Budget ask", value: "16k HMND" }], - proposer: "Victor", - proposerId: "victor", - chamber: "General chamber", - tier: "Legate", - proofFocus: "pog", - tags: ["Governance", "Economics", "Reputation"], - keywords: [ - "governance", - "commitment", - "staking", - "optional", - "self-slash", - "reputation", - "non-plutocratic", - ], - date: "2026-01-04", - votes: 52, - activityScore: 88, - ctaPrimary: "Open proposal", - ctaSecondary: "Add to agenda", - }, -]; diff --git a/db/seed/readModels.ts b/db/seed/readModels.ts deleted file mode 100644 index 129051b..0000000 --- a/db/seed/readModels.ts +++ /dev/null @@ -1,140 +0,0 @@ -import { chambers } from "./fixtures/chambers.ts"; -import { - chamberChatLog, - chamberGovernors, - chamberProposals, - chamberThreads, - proposalStageOptions, -} from "./fixtures/chamberDetail.ts"; -import { courtCases } from "./fixtures/courts.ts"; -import { factions } from "./fixtures/factions.ts"; -import { formationMetrics, formationProjects } from "./fixtures/formation.ts"; -import { humanNodes } from "./fixtures/humanNodes.ts"; -import { humanNodeProfilesById } from "./fixtures/humanNodeProfiles.ts"; -import { - invisionChamberProposals, - invisionEconomicIndicators, - invisionGovernanceState, - invisionRiskSignals, -} from "./fixtures/invision.ts"; -import { eraActivity, myChamberIds } from "./fixtures/myGovernance.ts"; -import { proposalDraftDetails } from "./fixtures/proposalDraft.ts"; -import { proposals } from "./fixtures/proposals.ts"; -import { feedItemsApi } from "./fixtures/feedApi.ts"; -import { - chamberProposalPageById, - formationProposalPageById, - poolProposalPageById, -} from "./fixtures/proposalPages.ts"; - -export type ReadModelSeedEntry = { key: string; payload: unknown }; - -export function buildReadModelSeed(): ReadModelSeedEntry[] { - const entries: ReadModelSeedEntry[] = []; - - entries.push({ key: "chambers:list", payload: { items: chambers } }); - - entries.push({ - key: "chambers:engineering", - payload: { - proposals: chamberProposals, - governors: chamberGovernors, - threads: chamberThreads, - chatLog: chamberChatLog, - stageOptions: proposalStageOptions, - }, - }); - - entries.push({ key: "proposals:list", payload: { items: proposals } }); - - entries.push({ key: "feed:list", payload: { items: feedItemsApi } }); - - entries.push({ key: "factions:list", payload: { items: factions } }); - for (const faction of factions) { - entries.push({ key: `factions:${faction.id}`, payload: faction }); - } - - entries.push({ - key: "formation:directory", - payload: { metrics: formationMetrics, projects: formationProjects }, - }); - - entries.push({ - key: "invision:dashboard", - payload: { - governanceState: invisionGovernanceState, - economicIndicators: invisionEconomicIndicators, - riskSignals: invisionRiskSignals, - chamberProposals: invisionChamberProposals, - }, - }); - - entries.push({ - key: "my-governance:summary", - payload: { eraActivity, myChamberIds }, - }); - - entries.push({ - key: "proposals:drafts:list", - payload: { - items: [ - { - id: "draft-vortex-ux-v1", - title: proposalDraftDetails.title, - chamber: proposalDraftDetails.chamber, - tier: proposalDraftDetails.tier, - summary: proposalDraftDetails.summary, - updated: "2026-01-09", - }, - ], - }, - }); - entries.push({ - key: "proposals:drafts:draft-vortex-ux-v1", - payload: proposalDraftDetails, - }); - - entries.push({ - key: "courts:list", - payload: { - items: courtCases.map((c) => ({ - id: c.id, - title: c.title, - subject: c.subject, - triggeredBy: c.triggeredBy, - status: c.status, - reports: c.reports, - juryIds: c.juryIds, - opened: c.opened, - })), - }, - }); - - for (const c of courtCases) - entries.push({ key: `courts:${c.id}`, payload: c }); - - entries.push({ key: "humans:list", payload: { items: humanNodes } }); - - for (const [id, profile] of Object.entries(humanNodeProfilesById)) { - entries.push({ key: `humans:${id}`, payload: profile }); - } - - for (const proposal of proposals) { - const id = proposal.id; - const poolPage = poolProposalPageById(id); - const chamberPage = chamberProposalPageById(id); - const formationPage = formationProposalPageById(id); - - if (poolPage) - entries.push({ key: `proposals:${id}:pool`, payload: poolPage }); - if (chamberPage) - entries.push({ key: `proposals:${id}:chamber`, payload: chamberPage }); - if (formationPage) - entries.push({ - key: `proposals:${id}:formation`, - payload: formationPage, - }); - } - - return entries; -} diff --git a/docs/README.md b/docs/README.md deleted file mode 100644 index c42b0df..0000000 --- a/docs/README.md +++ /dev/null @@ -1,111 +0,0 @@ -# Docs - -This folder documents the **Vortex simulation backend** that powers the UI in this repo. - -Docs are grouped by intent: - -- `docs/simulation/` — core simulation specs, architecture, and implementation plan -- `docs/ops/` — operational runbook for the backend -- `docs/paper/` — working reference copy of the Vortex 1.0 paper - -## Overview - -This repo ships two pieces together: - -1. A React UI mockup of Vortex (the “governance hub”) -2. An off-chain simulation backend served from `/api/*` - -The simulation is **not** an on-chain implementation. Humanode mainnet is used only as an **eligibility gate** (read-only). All governance state (proposals, votes, courts, formation, reputation/CM, feed/history) is off-chain in Postgres and advanced with deterministic rules. - -### On-chain vs off-chain boundary (v1) - -- On-chain (read-only): determine whether an address is an **active Human Node** -- Off-chain (authoritative): everything else - -### How the backend fits the UI - -The UI reads from `/api/*`. The contract is kept stable so the backend can evolve without forcing UI churn: - -- Contract source of truth: `docs/simulation/vortex-simulation-api-contract.md` -- TS DTO types used by the UI: `src/types/api.ts` - -In v1, reads are backed by a transitional `read_models` table (and optional overlays from normalized tables), so pages can render without requiring the full normalized domain schema on day one. - -### Write path (commands) - -State-changing actions go through: - -- `POST /api/command` - -Guards applied to every command: - -- signature-authenticated session -- active Human Node eligibility (cached with TTL) -- idempotency (optional `Idempotency-Key`) -- rate limiting (per IP and per address) -- per-era action quotas (optional) -- admin action locks (optional) -- global write freeze (optional) - -### Local dev modes - -- Recommended: `yarn dev:full` (UI + API + `/api/*` proxy) -- DB mode: `DATABASE_URL` + `yarn db:migrate && yarn db:seed` -- Inline seeded mode: `READ_MODELS_INLINE=true` -- Clean/empty mode: `READ_MODELS_INLINE_EMPTY=true` (pages show “No … yet”) - -### TypeScript projects - -The repo intentionally uses two TS projects: - -- UI + shared client types: `tsconfig.json` (covers `src/` + `tests/`) -- API handlers: `api/tsconfig.json` (covers `api/` + local helper `.d.ts` typing) - -This keeps editor tooling for API handlers isolated while leaving the frontend TS config lean. - -Goal: keep a tight, professional set of docs that answers: - -- What is being built (scope, assumptions, non-goals) -- How it works (architecture, data model, state machines) -- How the UI talks to it (API contract) -- How to run and operate it (local dev, admin/ops runbook) -- What is implemented now vs intentionally deferred - -## Reading order - -1. `docs/simulation/vortex-simulation-scope-v1.md` — v1 scope, explicit non-goals, and what “done” means -2. `docs/simulation/vortex-simulation-modules.md` — module map (paper → docs → code) -3. `docs/simulation/vortex-simulation-processes.md` — domain processes to model (product-level) -4. `docs/simulation/vortex-simulation-proposal-wizard-architecture.md` — proposal wizard template architecture (project vs system flows) -5. `docs/simulation/vortex-simulation-state-machines.md` — formal state machines, invariants, and derived metrics -6. `docs/simulation/vortex-simulation-tech-architecture.md` — technical architecture (runtime + DB + API shape) -7. `docs/simulation/vortex-simulation-data-model.md` — DB tables and how reads/writes/events map to them -8. `docs/simulation/vortex-simulation-api-contract.md` — frozen DTO contracts consumed by the UI -9. `docs/simulation/vortex-simulation-local-dev.md` — local dev commands and env vars -10. `docs/ops/vortex-simulation-ops-runbook.md` — admin endpoints, safety controls, and operational workflows -11. `docs/simulation/vortex-simulation-implementation-plan.md` — phased plan + current status -12. `docs/simulation/vortex-simulation-v1-constants.md` — v1 constants shared by code and tests -13. `docs/paper/vortex-1.0-paper.md` — working, adapted reference copy of the Vortex 1.0 paper (used for audits) -14. `docs/simulation/vortex-simulation-paper-alignment.md` — paper vs simulation audit notes (what matches, what’s deferred) - -## Doc conventions - -### Voice and tone - -- Write as “we / our system”, not “you should…”. -- Prefer precise language over persuasive language. -- Keep “why” in the doc where it matters (scope/ADR), not sprinkled everywhere. - -### Truth hierarchy - -- **API truth:** `docs/simulation/vortex-simulation-api-contract.md` + `src/types/api.ts` -- **Scope truth:** `docs/simulation/vortex-simulation-scope-v1.md` -- **Behavior truth:** `docs/simulation/vortex-simulation-state-machines.md` (rules + invariants) -- **Operational truth:** `docs/ops/vortex-simulation-ops-runbook.md` - -### Status tags - -When a section mixes implemented + planned behavior, label it explicitly: - -- `Implemented (v1)` -- `Planned (v2+)` diff --git a/docs/ops/vortex-simulation-ops-runbook.md b/docs/ops/vortex-simulation-ops-runbook.md deleted file mode 100644 index 187b000..0000000 --- a/docs/ops/vortex-simulation-ops-runbook.md +++ /dev/null @@ -1,131 +0,0 @@ -# Vortex Simulation Backend — Ops Runbook (v1) - -This document is the operational reference for running the simulation backend as a public demo: safety controls, admin endpoints, and data reset workflows. - -## Local vs production runtime - -- Production: API handlers (`api/`) -- Local: Node runner (`yarn dev:api`) with the UI proxy (`yarn dev` / `yarn dev:full`) - -Local dev details: `docs/simulation/vortex-simulation-local-dev.md`. - -## Persistence vs ephemeral mode - -- With `DATABASE_URL` configured, the simulation persists state in Postgres (recommended for public demos). -- Without `DATABASE_URL`, the API runs in an in-memory fallback mode: - - reads return clean defaults - - writes are accepted but are not durable across deploys/worker restarts - -## Admin auth - -Admin endpoints require an `x-admin-secret` header with `ADMIN_SECRET`, unless `DEV_BYPASS_ADMIN=true` is set for local dev. - -## Safety controls (writes) - -Write commands run through `POST /api/command`. The system supports four layers of write blocking: - -1. **Deploy-time kill switch**: `SIM_WRITE_FREEZE=true` -2. **Admin global freeze**: `POST /api/admin/writes/freeze` -3. **Address action locks**: `POST /api/admin/users/lock` / `unlock` -4. **Rate limiting / quotas**: - - per-minute command rate limits (IP + address) - - optional per-era action quotas - -## Admin endpoints (v1) - -### Time (simulation clock) - -- `GET /api/clock` -- `POST /api/clock/advance-era` -- `POST /api/clock/rollup-era` - -### Moderation / ops - -- `GET /api/admin/stats` -- `POST /api/admin/writes/freeze` -- `POST /api/admin/users/lock` -- `POST /api/admin/users/unlock` -- `GET /api/admin/users/locks` -- `GET /api/admin/users/:address` -- `GET /api/admin/audit` - -Details of request/response DTOs: `docs/simulation/vortex-simulation-api-contract.md`. - -## Incident playbooks - -### Stop all writes immediately - -Options, from strongest to weakest: - -1. Set `SIM_WRITE_FREEZE=true` in the deployment environment and redeploy. -2. Call `POST /api/admin/writes/freeze` with `{ freeze: true }`. -3. Lock a specific address via `POST /api/admin/users/lock`. - -### Rate-limit abuse / spam - -Adjust: - -- `SIM_COMMAND_RATE_LIMIT_PER_MINUTE_IP` -- `SIM_COMMAND_RATE_LIMIT_PER_MINUTE_ADDRESS` -- optional per-era quotas: - - `SIM_MAX_POOL_VOTES_PER_ERA` - - `SIM_MAX_CHAMBER_VOTES_PER_ERA` - - `SIM_MAX_COURT_ACTIONS_PER_ERA` - - `SIM_MAX_FORMATION_ACTIONS_PER_ERA` - -### Inspect a suspicious user - -Use: - -- `GET /api/admin/users/:address` to view: - - gate status cache (if present) - - action locks - - per-era counters - - recent audit events (DB mode) - -### Audit what happened - -- `GET /api/admin/audit` - -In DB mode, admin actions are also logged to the `events` table as `admin.action.v1`. - -## Data reset workflows - -### Clean UI (no content) without touching the DB - -Run with: - -- `READ_MODELS_INLINE_EMPTY=true` - -List endpoints return `{ items: [] }` and singleton endpoints return minimal defaults. - -### Wipe simulation data in Postgres (keep schema) - -Run: - -- `yarn db:clear` - -This truncates simulation tables and keeps migrations/schema intact. - -### Seed deterministic demo content - -Run: - -- `yarn db:seed` - -This populates `read_models` and seeds the initial event stream. - -## Operational metrics - -`GET /api/admin/stats` returns a small snapshot intended for dashboards: - -- current era -- active governors baseline -- configured rate limits / quotas -- write-freeze state -- basic counts (events, votes, cases) - -## Known v1 limitations - -- Many list/detail reads are served from `read_models` and overlaid with live counters. -- This is deliberate: it keeps the UI contract stable while normalized domain tables and event-driven projections evolve. diff --git a/docs/paper/vortex-1.0-paper.md b/docs/paper/vortex-1.0-paper.md deleted file mode 100644 index 83317e8..0000000 --- a/docs/paper/vortex-1.0-paper.md +++ /dev/null @@ -1,427 +0,0 @@ -# Vortex 1.0 - -Note: this is a working reference copy imported into the repo for ongoing audits against the simulation backend and UI mockups. It contains small, explicitly requested adaptations (e.g., no sub-chambers) and may include duplicated sections if they were pasted twice upstream. - -"In the sciences, the authority of thousands of opinions is not worth as much as one tiny spark of reason in an individual man." - -**- Galileo Galilei** - -## **Synopsis** - -Vortex is the main governing body (GB) of Humanode. As stated in the Humanode whitepaper v0.9.5 in 4 years time after the mainnet launch all the authority of Humanode Core will be transferred to Vortex and dispersed among its governing entities. Vortex aims to be an egalitarian GB where the voting power is equally distributed among its participants. In contrast with many other chains that heavily rely on PoS or PoW in voting power distribution, Humanode is reliant on its cryptobiometric infrastructure to determine that its governors are living and unique human beings thus enabling equal power distribution among them. - -# Basis of Vortex - -Vortex is based on five major principles through which the main organizational branches are structured: - -1. **Cognitocracy** -2. **Meritocracy** -3. **Local determinism** -4. **Constant deterrence** -5. **Power detachment resilience** - -**Cognitocracy** (from Latin verb _cognoscere_ ‘to know’ and from Ancient Greek _kratos_ 'rule') - a legislative model based on granting the voting rights only to those who are able to bring constructive and deployable innovation (Cognitocrats). It is a system that tries to concentrate decision-making capabilities in the hands of those who have proven to be professional and creative enough to receive the rights to vote on matters of a certain specialization. Throughout this paper words “Cognitocrat” and “Governor” are used interchangeably, as one can’t become a governor without being a cognitocrat. - -Vortex aims to be a cognitocratric meritocracy where merit of innovation and optimization is separately evaluated from the merit of functional work. Both cognitocracy and meritocracy aim to concentrate power in the hands of those who have proof of proficiency in a specialization. Vortex aims to take into account the quantitative and qualitative data of contribution of governors to the Humanode network, through various forms of Proof-of-Time and Proof-of-Devotion that will act as cornerstones of a governor's emancipation from the Nominee to the Citizen status. - -Ideology is usually a system of ideas or ideals that aims to regulate the economy and political processes of human society through a certain prism of principles and instruments. Local determinism denies ideology, as ideology is more a means of gaining political power rather than a viable approach to good decision-making. Local determinism acknowledges that any field of expertise has a tremendous amount of details and intricacies and that solutions that actually work efficiently on the ground don't usually align with the ideology that governs it. As long as a solution works and does so efficiently it doesn’t matter which side of the political spectrum it might be associated with. - -One of the underlying principles of Vortex is the principle of constant deterrence. As any governing system, Vortex is a dynamic one, where balances and narratives constantly shift. The game changes faster than any checks and balances structure can evolve to deter the constantly changing meta. The principle of constant deterrence implies that individuals that comprise the governing body must actively seek centralization threats and mitigate them, refrain from delegating their vote and maximize their direct participation in governing processes. To carry out this deterrence efficiently there must be a transparent way of telling in which state the governing body is. Vortex will present the governors with a dedicated app allowing them not only to participate in governance, but also to clearly see based on data what state the system is in. By combining it with the principle of an active quorum, where only those governors who are actively participating are counted in the quorum, we hypothetically get a dynamic, flexible, transparent and active system of constant deterrence that would be aimed to hold at bay any force such as populists, oligarchs, collusionist, etc that might be trying to centralize efforts of the system for their own personal advantage. - -Power detachment is a serious issue of most blockchain governing entities out there. The fact is that the real power is usually distributed among top validators, as an inherent principle from capital-based Sybil-resistant protocols, and core teams of projects, while governance is misrepresented by active community members and some stakeholders. In an effort to make governance seem egalitarian there are even cases where chains introduced 1 human = 1 vote chambers on top of the existing PoS but this led to even more misrepresentation. Humanode aims to minimize detachment by making sure that every node is an individual, by providing the underlying principle of equality of all the nodes in terms of validation power, by making sure that governors are only composed of Humanode validators and by maximizing validator participation in governance. The detachment will still be there as there is almost zero chance that all validators partake in the governance, so the amount of governors will be less than the amount of validators. At the same time it will not be a democratic facade, behind which top stakeholders and core team members can rule as they see fit due to real power being concentrated in their hands. - -## Vortex structure - -Vortex consists of three major parts: - -1. **Proposal pools** -2. **Vortex** -3. **Formation** - -Proposal pools and voting chambers belong to the legislative branch. Formation belongs to the executive branch. - -

Figure 1. Vortex structure

- -## Vortex Roles - -Vortex consists of Human nodes, Delegators, and Governors-cognitocrats. - -1. **Human node** — a person who has gone through proper biometric processing, participates in blockchain consensus and receives network transaction fees but does not participate in governance. -2. **Governor** — a human node who participates in voting procedures according to governing requirements. If governing requirements are not met, the protocol converts him back to a non-governing human node automatically. -3. **Delegator** — a Governor who decides to delegate his voting power to another Governor. - -# Cognitocracy - -There are several primary objectives of cognitocracy. First, it is to form an elitogenesis sequence where the elite of the society is formed through demonstrating creative brilliance that evolves into real-world benefits. Second, to create a just competitive ecosystem for specialists that would constantly drive creativity and innovation to higher levels. Third, to make sure that there is a separation of power based on proof of knowledge and specialization, rather than a popularity contest. Fourth, to foster a cooperative environment that drives optimization and creativity to its highest potential. Fifth, to create an evolving intellectual barrier for obtaining voting rights and legislative mandate. - -This will be an ongoing research for quite some time, so please consider everything laid out in this paper to be hypothetical and highly experimental as there is an absolute lack of any empirical evidence or data to back up most of the hypotheses. This paper will go through a tremendous update after a real-life cognitocratic system will have been built and functioned for quite some time to gather data. - -Delving into the philosophical base we can outline five major principles that molded into the approach behind the design of cognitocracy. - -1. Technocracy -2. Meritocracy -3. Intellectual qualifications for obtaining voting rights -4. Hybrid of direct and representative democracies -5. Liquid democracy - -Undeniably technological development is one of the most influential processes that affects human species. Arguably, the most pivotal moments in our history were determined by our technological evolution. Technocracy implies that the decision-makers are selected based on their technological knowledge and technology-oriented methods of solving issues. It is often criticized for being elitist in nature as technocracy is closely associated with technological oligarchs that concentrate capital and emerging tech to get a grasp on the future development of their systems and thus have an undisputed rule over critical governing instruments or bodies. Cognitocracy inherits the principles of technological innovation and concentration of power in the hands of those who strive for innovation and technological development but puts aside the plutocratic trait of a common technocracy. - -Meritocracy has always been described as an ultimate form of governance. From Aristotle and Plato to Oliver Cromwell and Tomas Jefferson, hundreds of philosophers and key historical figures throughout history have claimed that merit must be the decisive factor in determining who is fit to rule. Unfortunately, as with any other idealistic philosophy, the world has never seen a real meritocracy in action. Favoritism, nepotism, blood-based capital inheritance and some other factors have been obstructing meritocracy from being enacted for a prolonged period of time. Cognitocracy cannot function without meritocracy. Cognitocracy derives from the very principle of merit but with a strong inclination towards creativity and innovation rather than functionality and working experience. The relationship between the two models isof the utmost importance for Humanode governance and will shape how the future governing apparatus is formed and is regulated. - -Introducing intellectual qualifications for obtaining voting rights has been a long-standing theme of debates all over the democratic world since the widespread adoption of democracy. There are two different cases here. First, the baseline intellectual tests for admittance of voters to broader elections. Second, the proof of expertise in a certain field to be eligible for a legislative position. The first case doesn’t concern cognitocracy, as there are no elections and no voting rights for those who haven’t proven their creative merit. The second one is where the challenge stands. Usually, this problem is addressed through obtaining diplomas in universities that satisfy unwritten requirements to enroll into a legislative body, but on paper not a single leading democracy of the modern world requires proof of one's intellectual capabilities in any form, while an overwhelming quantity of MP’s have some kind of higher education. Due to how most of the educational systems function, in today's world, ownership of a diploma of a higher education is in no way evidence of possession of innovative brilliance or decision-making capabilities which are necessary for effective legislature. Cognitocracy aims to establish intellectual barriers for obtaining voting rights in legislation with the difference that the test to understand one’s merit is conducted on the spot through proposals and not through a third-party institution of any kind. More about the enrollment procedure can be read below. - -Cognitocracy aims to maximize the direct participation of eligible voters in the governing process while acknowledging the delegation of vote as a necessary instrument to express political will without constant participation. Delegation is a critical variable, implementing a cognitocratic system without this instrument makes the system explicitly elitist. This question is addressed in more detail in a separate section below. - -In the case of cognitocracy, liquid democracy is applied to voice delegation, as there are no elections. Cognitocracts can only delegate their power to other cognitocrats. Such an approach makes the voting system more dynamic and reactive. The voice can be retracted at any given moment. Compared to elective mandate one's voice is not burnt away if their candidate loses the race. Utilization of liquid democracy brings other benefits such as reduced polarization as it allows voters to support representatives based on specific issues rather than aligning with a particular party or ideology. This can lead to more professional and issue-specific decision-making. The ability to change delegation at any time allows voters to respond dynamically to changing circumstances or evolving opinions. This adaptability ensures that representation remains aligned with the cognitocrats' current preferences. - -Cognitocracy blends various underlying principles of the abovementioned postulates but puts the prevalence of an inventive, decisive, rational and intelligent mind above all else. - -## Chambers of Vortex - -### Specialization Chambers - -Cognitocracy functions through Specialization Chambers (SC) each representing a distinct field of expertise. These Chambers are governed by specialists - human nodes who had their innovative proposal accepted by a qualified majority of voters within a specific chamber. The fundamental principle of the Chambers is that only those who have convincingly demonstrated their creative merit to the majority of specialists in their respective field are granted the right to vote on matters related to that field. SCs aim to shard the legislative process to make it more professional and efficient giving the decision-making capabilities only to those who have undisputedly proven their intellectual properties to the majority of specialists. - -An egalitarian cognitocracy that follows the original postulates of Humanode would have a strict invariant of 1 governor-cognitocract = 1 vote so that scales of power remain balanced and so that the voting power is distributed and decentralized as much as possible. 1 human = 1 node = 1 cognitocrat = 1 vote. - -Let’s go over an example. Consider, for instance, the Programming chamber. While various subdivisions exist such as protocol, front-end, cryptobiometrics, etc., their consideration is presently omitted. To attain the status of a legitimate governor in the Programming chamber, one must present a proposal characterized by innovativeness, practicality, and realistic achievability for approval by the governors. The governors involved in the voting procedure have previously demonstrated their intellectual capabilities and field expertise through the approval of their own proposals and thus are cognitocrats. Consequently, only engineers with established specialization are granted the privilege to vote, thereby notably mitigating the probability of endorsing flawed or non-professional proposals. - -This approach sets a system where only those Humanode validators who can pass the intellectual barrier get the right to vote and become governors, but at the same time all governors are always equal in terms of voting power. - -The design of the SC suggests that their main purposes are: - -- Parallelisation of consensus without losing the quality of decision-making -- Egalitarian distribution of decision-making power among the cognitocrats -- Maintenance of an intellectual barrier for emancipation of voters -- Dynamic distribution of vote delegation - -This structure in some way might resemble ministries but has three major differences. First, usually ministries are a part of an executive branch while chambers are a legislative one. Second, ministries are formed from the outside, either a parliament or a president or a governing body sets their structure, while branches in cognitocracy are self-formed and self-regulated. Third, every ministry might have its own unique structure while chambers in cognitocracy maintain a uniform hierarchy structure. - -The voting thresholds for accepting a proposal will be set at a qualified majority of 66.6% + 1 vote. Meaning that 66.6% + 1 casted vote including delegated ones are in favor of a proposal. The quorum will be considered assembled if 33% of active governors of the chamber partake in the voting. - -The voting procedure, various thresholds and quorum mechanics are described in more detail below. - -Cognitocracy strives to be a dynamic system. With chambers arising only as a necessity to address a rising throughput of challenges and proposals from the same field. - -### General Chamber - -Besides SCs there exists a General Chamber (GC) that incorporates all cognitocrat-governors that reside in a system regardless of their field of expertise. A GC acts as a venue for proposal submissions that affect a system as a whole. Any ruling of a GC is absolute and must surpass SCs in its legislative power as it would represent a democratic consensus of the whole system. - -In rare cases, a GC can also be used as a method for forced admittance of a proposal to a SC. For example, if a SC proposal was locally declined several times then a proposer could submit it to a GC and if the cognitocrats vote to accept it then it will be enforced upon the SC and this proposer would still become a cognitocrat. The difficulty in this is that getting the quorum in a GC is much harder than in a SC and cognitocrats will be reluctant to contradict the opinion of experts in the respective SCs to enforce a proposal that was declined by the experts locally. - -### Chamber structure - -Vortex uses a General Chamber (GC) and Specialization Chambers (SC). There are no sub-chambers. - -### Chamber inception - -SC creation follows the same steps as any other voting process - a proposal must be made and voted upon. There are several small differences though. First, only an established governor-cognitocrat can propose to form an SC. Second, a number of cognitocrats must be nominated to become the first cognitocrats in that chamber. Third, an approach to receive Cognitocratic Measure must be chosen. Read in detail below. After the chamber is created the newly established cognitocracts can admit new members as usual. - -### Chamber dissolution - -There are two ways to dissolve a chamber. First, through a proposal inside the SC. Second, through a proposal in the GC. Same as with inception, only cognitocrats can make such a proposal. The dissolution can be purely functional, when cognitocrats decide that an SC has become irrelevant, or it can be a vote of censure. If there is suspicion about a chamber being corrupt or some other malicious reason, a GC vote can decide to implement a motion of non-confidence. - -For all types of dissolution the procedure follows the guidelines of the ordinary voting procedure. With a small exception that during a GC vote of censure the cognitocrats who are a part of the targeted SC can’t partake in the voting and are not counted in the quorum. - -The penalties for residing in the dissolved SC are contextual and should take into consideration not only the properties of the system itself but also the actual case of why the chamber is dissolved. - -### Proposal Pools - -Proposal pools act as means to filter out crucial proposals and implement the quorum of attention. As mentioned before, every chamber has a proposal pool attached to it. The General Chamber also has its own proposal pool. - -Proposals are submitted in a free form to the proposal pool of a respective SC. Active governors act as scouts that go through the submitted proposals and either upvote or downvote those proposals that they consider to be either useful or harmful. As soon as a proposal receives attention from 22% of active governors in a chamber, but not less than 10% of upvotes from governors, it gets propelled to a chamber where the voting procedure begins. - -Unless a proposal passes the necessary threshold to move onto the SC it remains in the proposal pool. The proposer is able to withdraw the proposal from the pool at any time, but receives a certain cooldown before being able to cast the same proposal once again. Every voting chamber is able to determine the cooldown necessary for various types of proposals submitted into its proposal pool. - -The biggest distinction between the Voting Chambers and the proposal pool is the fact that delegated votes are not counted in the proposal pool. Thus 22% quorum of attention casted would come from real human active governors only. Such an approach curtails the ability of popular governors with a big following and a lot of delegated voices to drive the narrative and the agenda of a chamber. - -Proposal pools act as drivers of the attention of the ecosystem participants. They allow for the community of governors to decide which proposals must be voted upon urgently and which proposals should be revised. - -# Voting, Delegation and Quorum - -### Quorum of attention - -This quorum is applied in every proposal pool in the system. Proposals that receive upvotes or downvotes from 22% of active governors in a SC, but not less than 10% of upvotes, are immediately conveyed to a Chamber for voting. - -### Quorum of vote - -This quorum is applied in the chambers. A quorum is reached if at least 33% of the Governors vote on a proposal. If 66.6% + 1 of the Governors, within the quorum, vote to approve a proposal, then Vortex will consider it approved. This means that 22% of the governors in the chamber will be the necessary minimum to approve a proposal. Human nodes that do not participate in governance are not counted in reaching a quorum. The voting power of each governor is equal to 1 + the votes of his Delegators. - -Any proposal that is pulled out of the proposal pool gets a week to be voted upon in the respective chamber. - -## Vote delegation and quorum - -Cognitocratic vote delegation is one of the most controversial topics laid out in this paper as permittance of vote delegation opens up a pandora's box of power concentration behind an individual cognitocrat. Conditioning here is intertwined with how a system approaches to gather a quorum. - -If there is an absolute quorum where all the governing agents are considered during the count then not implementing a vote delegation entails risk of voters apathy - not being able to gather a necessary quorum to make any decision. On the other hand, if it is enabled then it makes the risk of centralization and corruption much higher as it is much easier to affect inert and apathetic voters to centralize around certain individuals. “Delegate and forget”.\ -\ -If there is an active quorum where only active governing members are included into the quorum count then not implementing the vote delegation makes the whole system very elitist as only those cognitocrats who have time and resources to dedicate themselves fully to governance will have the right to vote. In this case, it doesn’t even make sense to enable vote delegation only from active participants as if one is an active governor why delegate your vote in the first place if one is actively governing? - -The most interesting case, and the one Humanode is going to utilize, is counting only active governors in the quorum but allowing non-active cognitocracts to delegate their voice to any active cognitocrat. This type of a system tries to balance the elitism of cognitocrats with irrationality of a broader audience as delegation is allowed but only for those who have proven their merit. What is also unique about this delegation is that one can only delegate his voice to a fellow cognitocrat from the same chamber. Thus even delegation of voice becomes specialized. - -### Veto rights - -There should be a Veto system in place to prevent cases where the majority is wrong. This Veto should be temporary or still breakable with several attempts. It should be done so that there is no mechanism to stop the accepted democratic consensus, but slow one down to reconsider. - -In Vortex the veto power is distributed among the cognitocrats with the highest Local Cognitocractic Measure (LCM) in their respective chambers. The LCM is described in more detail below. For example, if there are 12 specialized chambers the veto power would be distributed among 12 individuals who have obtained the highest LCM. If 66.6% + 1 person decides that some proposal should be vetoed, then the proposal will be sent back to vortex for two weeks. The temporary veto can be set twice. If the proposal gets approved for the third time then no veto can be implemented on it anymore. - -This Veto mechanism is a necessary tool of deterrence not only in the cases of minority vs majority, but also from direct attacks on a governing system from various vectors. - -### Voting procedure - -

Figure 3. Voting procedure

- -# Cognitocratic measure - -Cognitocratic measure (CM) is an attempt to objectify the contribution of each cognitocrat to the system as a whole. It’s a numerical score that is received by a cognitocrat every time a proposition is accepted by a chamber. Instead of just voting “Yes”, voters must also input a number (for the simplicity of the example let's assume that it is on a scale from 1 to 10). The average number that is derived from all the inputs converts into the CM received by the proposer. While CM tries to objectify contribution it is still a subjective measure that wouldn’t and shouldn’t in any way directly empower the mandate of any particular cognitocrat. Instead, it subjectively demonstrates to others the magnitude of contribution of a particular cognitocrat. It would be logical to state that the bigger the CM the more contribution was committed. - -### Cognitocratic measure multiplier - -As a cognitocratic system consists of multiple chambers depending on the specialization, cognitocractic measures received from different chambers can’t have the same value. A CM of 5 received in the Chambers of Consensus and Cryptobiometrics can’t be the same as a CM of 5 received in the chamber of Social Relations and Marketing. The relation between these two measures is severely contextual as a cognitocractic system can value Nuclear Physics more than Marketing or vice-versa. For this reason, a multiplier set for each chamber would help define the proportions between contributions to different chambers. - -**Local Cognitocractic Measure (LCM)** subjectively demonstrates the amount of contribution from a cognitocrat in a specific chamber. - -**Multiplied Cognitocratic Measure (MCM)** is LCM multiplied by the chamber multiplier. - -**Absolute Cognitocratic Measure (ACM)** represents the sum of all MCM’s received by a cognitocrat in various chambers. - -Absolute Cognitocratic Measure formula is as follows, - -$$ -ACM = \sum\_{i = 1}^{n}{LCM\_{Chamber(i)} \cdot M\_{Chamber(i)}} -$$ - -Where **i** is a specific chamber and **M** is a chamber’s multiplier. - -Let’s give a specific example.\ -\ -Bob has 5 LCM in a Philosophy SC and 10 LCM in SC of Finance.\ -\ -Multiplier for Philosophy SC is 3. Multiplier for Finance SC is 5.\ -\ -Thus, - -$$ -ACM = (3 \cdot 5) + (10 \cdot 5) = 65 -$$ - -Bob’s ACM is equal to 65. - -### Setting the Multiplier - -As CM plays an important role in projecting the contribution of a cognitocrat to other cognitocrats, the process of setting the multiplier for the chambers becomes crucial as it changes the balance of contribution projection. There could be various forms of such a procedure but as it was set in the beginning of this paper I will concentrate on describing the most egalitarian form conceivable. - -Every single cognitocrat can set a multiplicator on a scale from 1 to 100 for any chamber other than those where he received LCM. The average multiplier calculated from submissions becomes the Chamber Multiplier. In other words, it's the collective perception of the value brought to a system by a chamber that is represented by the average multiplier that is set by all cognitocrats but those who reside in the chamber itself.\ -\ -If a cognitocrat has received LCMs in multiple chambers then he is locked out from setting multipliers in all of those chambers. - -

Figure 4. Setting the multiplier

- -As can be seen on this Figure every cognitocrat sets a multiplier to every other SC they are not a part of. Eve and Mallory cast multipliers of 2 and 6 to SC1 which on average sets it to 4. Alice, Bob and Mallory cast multipliers of 2, 2 and 5 to SC2 which on average sets it to 3. Alice and Eve cast multipliers of 8 and 4 to SC3 which on average sets it to 6. - -### Cognitocratic measure in cases of SC inception or dissolution - -During an inception there are three outcomes that might be put in place by a proposer upon an approval of creation of a new SC : - -- A proposer (and nominees) receives ACM in a GC and both the proposer and nominees become cognitocrats in a created chamber. As in the case of any other proposition in a GC an average number submitted by approving cognitocrats becomes a CM received by the proposer. -- A proposer (and nominees) receives LCM in the created SC with the difference that the number is aggregated from a GC vote. -- A proposer doesn’t receive any CM but still becomes a cognitocrat in a newly created SC. - -Dissolution comes with a separate set of outcomes that are also very contextual. There are two major cases: - -- Cognitocrats from a dissolved chamber retain their LCMs. Even if the SC doesn’t exist anymore the CM retains some legacy value that is either adjustable as others or frozen. -- Cognitocrats from a dissolved chamber lose all of their CM associated with that certain SC. This case is highly associated with a vote of censure. - -
- -# Formation - -Vortex governs the Humanode by deciding on key parameters through the voting power of human nodes. Formation is a part of the Humanode. It is a special grant-based development system providing grants, investments, service agreements, and projects to build. It is dedicated to supporting the Humanode network and all related technologies. It will be available for interaction through the Vortex DAO app. - -Any human node can join Formation to make a grant proposal or apply to become a part of a team that already develops an approved proposal. Proposals by non-human nodes can only reach Formation if one of the governing human nodes decides to nominate them. Such limitations allow us to protect devoted followers and contributors to the Humanode network. - -Human nodes create proposals, allocate funds for their implementation that are sourced from the community and the treasury, and take coordinated action to see the proposals implemented properly. Governors upvote and downvote them. We assume that 2% of fees go to Formation as the network begins to function. Then, the proposers, i.e., Vortex, will regularly determine the percentage of the fees going to Formation. - -It is worth noting that there is no need for participation in governance to partake in Formation. Any bioauthorized human node is allowed to join any project. - -The Humanode network’s DAO supports a number of different proposal directions. - -Generally, Formation funds: - -- **Research:** Advancing basic and applied research in cryptobiometrics, cryptographic primitives, distributed systems, consensus mechanisms, smart-contract layers, biometric modalities, liveness detection, encrypted search, and matching operations.
-- **Development and Product:** Development turns research into software, while Product turns it into user experiences. Formation is primarily interested in technologies that expand the Humanode network, its potential, capabilities, and security, as well as the ecosystem, from decentralized finance and non-fungible tokens to decentralized courts. -- **Social Good & Community:** Formation supports community members to bring awareness to open-source, decentralized networks, and biometric technologies, and scale community outreach for the Humanode network. The Formation funds are mainly used to maintain the network. - -### Assembling a team - -We understand how crucial it is to find and coordinate people that are willing to support the - -proliferation of the Humanode network. That is why we are developing a special team-assembly - -procedure in the Vortex DAO app that will allow those whose proposals were approved by Vortex to find passionate professionals to assemble their team from the members of the international Humanode community. All the proposer has to do is send a digital offer to any other human node that he thinks is a good fit for his projects. Their proposal must have the public address of the potential member, and it should state working objectives and conditions and have a smart contract that locks some part of the grant for that person in particular and saves the data onchain. - -There can be: - -- **Full team projects**, with predetermined participants; -- **The team is partially assembled**, with spots that can be filled later from the community; -- **No one in the team yet**, with all the slots available for the community. - -It is worth noting that there is no need for participation in governance to partake in Formation. Any bioauthorized human node is allowed to join any project. - -
- -# Conclusion - -Vortex is a cognitocratic-meritocratic governing system with proposition rights emancipation based on various proofs of dedication towards the ecosystem. The parallelization of consensus through specialized chambers, that work through proposals voted upon only by proven specialists, is intertwined with the proposal right emancipation system that would allow gradual decentralization of power. - -The more actively governor-cognitocrats participate in the governance - the more robust, democratic and decentralized the chain becomes. Each and every human node is safeguarding the system from centralization and Sybils by maintaining cryptobiometric Sybil-defense. Only by active participation and direct expression of your will can the network become and remain decentralized. Potential governors must approach their duties with utmost efficiency and do everything in their capabilities to actively deter centralization efforts for personal or purely mercantile reasons. - -
- -# Discussion - -### Gradual decentralization - -Obviously, the Humanode network will rely heavily on the activity of its Governors. Besides building the technological solutions stated in this paper, the Humanode core will promote full transparency of governing processes and transactions, design and deploy decentralized governing processes, participate heavily in the Humanode community, and make development proposals. The Proposal Pool System/Vortex–Formation governance stack was designed by the Humanode core to create a hybrid Proof-of-Time/Proof-of-Devotion/Proof-of-Human-Existence safeguarded network. This implementation allows us to lower the influence of the problems that affect any system that tries to integrate democratic procedures: - -1. Voter apathy is a very widespread problem that entangles every single voting system. The biggest part of this problem is the inability to reach a quorum. The Humanode network demands governance participation in proposals and voting from Governors and proof of existence from all human nodes. Those Governors who do not fulfill monthly governing conditions (either they did not make proposals or did not vote on any proposal) are automatically converted to non-governing. Quorum is reached if 33% of Governors vote upon a proposal, so it means that only voices of those who actively participate in governance are calculated to reach a quorum. -2. Masses are often mistaken. It is common sense that a small, dedicated group of professionals with years of experience would be able to give a more precise and correct opinion on a particular voting matter than a mass of people with different backgrounds and education. To balance the democratic approach with professional education and experience, Humanode core came up with a hybrid Proof-of-Time/Proof-of-Dedication governance system named “Vortex”, in which Governors have different tiers. They can be promoted in tiers if certain requirements are met. This way the protocol gives more tools and proposal rights to those who have more experience and have proven their devotion through Formation. The necessity to have your proposal approved before becoming a Governor acts as a Proof-of-Devotion step that uplifts the quality of Governors and acts as an important layer of defense against Sybil attacks. -3. Inability to directly delegate your vote to any other voter in a system creates many different forms of how the voting procedures take place. The very systems of how electoral delegates are chosen have loopholes that allow political tricks such as gerrymandering and filibustering. Governing human nodes are designed to be equal in voting power; at the same time, the voting mechanisms allow you to delegate your vote to any other human node without boundaries. A Governor’s voting power equals 1+ the number of delegations he has. - -### The iron law of oligarchy - -“Who says organization, says oligarchy.”\ -“Historical evolution mocks all the prophylactic measures that have been adopted for the prevention of oligarchy.”\ -\ -\&#xNAN;**_- Robert Michels_** - -This hypothesis was developed by the German sociologist Robert Michels in his 1911 book, ’Political Parties.’ It states that any organizational form inevitably leads to oligarchy as an ’iron law’. Michels researched the fact that large and complex organizations cannot function efficiently if they are governed through direct democracy. Because of this, power within such organizations is always delegated to a group of individuals. - -In Michels’s understanding, any organization eventually is run by a class of leaders regardless of their morals or political stance. Monarchies and republics, democracies and autocracies, political parties, labor unions, and corporations, etc. have a nobility class, administrators, executives, spokespersons, or political strategists. Michels stated that only rarely do representatives of these classes truly act as servants of the people. In most cases, people become pawns in never-ending games of power balancing, networking, and survival. Regardless of the inception principles, the ruling class will always emerge and in time it will inevitably grow to dominate the organization’s power structures. The consolidation of power occurs for many different reasons, but one of the most common ways is through controlling access to information. - -Michels argues that any decentralized attempts to verify the credibility of leadership are predetermined to fail, as power gives different tools to control and corrupt any process of verification. Many different mechanisms allow serious influence on the outcome of democratically made decisions like the media. Michels stated that the official goal of representative democracy of eliminating elite rule was impossible, that representative democracy is a facade legitimizing the rule of a particular elite, and that elite rule, which he refers to as oligarchy, is inevitable. - -This law is directly applied to modern elites. The financial network is always a complex multilayer construct that requires a great deal of administrative and organizational power. According to Michels, such a system would inevitably become oligarchic. While designing the basic principles of the Humanode network and Vortex, the Humanode core was faced with a challenge to find a delicate balance between organizational efficiency and the democratic involvement of the masses. We believe that a combination of voting power equality, unbiased intellectual barriers, direct delegation, Proof-Of-Time, Proof-of-Devotion, and proof-of-human existence would make a very balanced and just system, but it will not solve the problem of ‘Iron Oligarchy,’ as a leadership class will definitely emerge. - -Fiat credit-cycle systems have large financial entities, PoW networks are faced with miner cartels, PoS systems have validator oligopolies, and Humanode has Citizens and research groups. Governors have different proposal rights based on different tiers. Citizens have absolute freedom in proposal creation as they can put forth an idea of any type and some even wield a right to veto any decision that is approved by Vortex twice. Legate and Citizen freedom of authority is balanced out by the voting mechanism that requires a quorum and an absolute majority of those voting for a proposal to be approved. As the absolute majority of Governors is required for a decision to be approved, it negates the ability of Legates and Consuls to approve something against the will of the majority of voters. - -In a perfect world where all participants of the network actively govern, this balancing effort should be just enough to minimize the influence of any type of oligopoly that might emerge in the Humanode network, but we do not live in a perfect world. The apathy of voters is a scourge to most of the voting systems that exist and creates the necessity of vote delegation, which has its own advantages and disadvantages. - -### Vote delegation - -Problems of vote delegation have always accompanied any large democratic system. The core problem of democracies in their purest form is that they are very vulnerable to the Byzantine Generals Problem (BGP). Any system has a critical point of failure. Large systems tend to have several or dozens. Because of this, any democratic system requires institutions built on top to protect those critical points. These institutions limit the direct voting of the masses on crucial matters. There are four main reasons why these limitations are a necessity. - -1. Strategic resources, critical points, and stability. Any system has a sensitive part. For example, some countries wield nuclear arsenals and have democratic political systems. The vote on the deployment of nuclear weaponry is commonly restricted to a very small group of individuals. It makes sense that such an important spectrum would be heavily guarded against any angle of attack, especially the BGP. That is why this part of the system requires consolidation of power and an autocratic approach in decision making. Besides weapons of mass destruction, there are financial, energetic, military, trading, diplomatic, intelligence, etc. chokepoints that unless safeguarded can be used by the enemies of that system to cause catastrophic events and lead to destabilization. Natural autocracy rises in the chokepoints of strategic value. -2. Apathy of voters and effectiveness. Lack of caring among voters in voting procedures can lead to a halt in governance, as most voting requires some kind of a quorum. If apathy is strong enough to stop a quorum from being raised then the governance process stops until a quorum is reached. Some operations and decisions require the constant active involvement of voters, which is where delegation comes in hand. Ordinary people do not want or have time to participate in governance, which is why in representative democracies citizens can cast their vote to elect representatives that are actively involved in decision making. The fewer people participate in voting, the easier it is to coordinate. -3. Technological limitations. Before the digital era, there was no effective way to conduct voting procedures, as communications were not as developed as they are now. Without proper confirmation of identity and support of modern tech, it was hard to imagine a way to conduct large direct voting without putting strain on administrative resources. Delegating to a politically active person negates the necessity of using sophisticated technologies to conduct legislative procedures. -4. Misrepresentation. In most democracies your vote is restricted by the region you are geographically located in, meaning that you can cast a vote for a nominee tied to your constituency, but he might not get elected, meaning that your vote was practically burned and a person that you did not vote for might be representing you. Most governing systems lack the freedom of vote delegation, as you cannot directly delegate your voice to a particular person. - -While devising the voting procedures for Vortex, the Humanode core has kept in mind the principles mentioned above. The Governor tier system safeguards critical points by limiting the abilities of the electorate to create proposals but at the same time, the autocratic chokepoint is balanced out by requiring a quorum of Governors to approve created proposals. The influence of apathy of voters is limited by demanding voting activity from human nodes to be counted as Governors. This way only active participants of the network are counted in reaching a quorum. The technological progress in DAO deployment and biometric processing in the last decade has brought forward a way to overcome the obstacles of the past connected to direct voting procedures and the uniqueness of voters. Delegation of voting power is chamber-scoped: a governor can delegate their vote to another eligible governor within the same chamber. We acknowledge that even with modern approaches to voting and technological breakthroughs, a delegation mechanism in the Humanode network is a natural necessity. - -The digital revolution has paved the way for technologies that allow us to create systems with liquid representative democracies. Compared to traditional representative democracies, a voter can re-cast his vote any time he wants, without the necessity to wait for years to do it again. Vote delegation can be changed anytime. Delegated PoS (DPoS) protocols implemented liquid democracy for delegating transaction validation operations to professional entities. As the validators are safeguarding the protocol and receive a commission for their operation, the voter’s choice is usually driven by economic incentives: how the commission size, uptime, and security of the delegate’s server might reflect on the voter’s earnings. Is that enough to choose an opinion representative in a decentralized network? Most DPoS networks have a strict unbounding period that can last up to two weeks or even months. This measure is a necessity to safeguard the system from manipulated panic-based market crashes where Delegators undelegate their tokens and sell them in fear of losing value. In the Humanode network, voting power is not entangled with a token, which is why there is no need for unbounding periods. Any time a human node wishes to re-cast or simply retrace its delegation it can be done instantly. - -### Populist tide and professional backslide - -It is commonly acknowledged that any voting system faces the problem of too much populism. Hypothetically there are two major approaches to how populism is perceived: - -- Populism poses a threat to democratic stability. According to recent studies, conducted by Jordan Kyle and Yascha Mounk of the Tony Blair Institute for Global Change, one of the key findings they have had is that populists are far more likely to damage democracy. Overall, 23 percent of populists cause significant democratic backsliding, compared with 6 percent of nonpopulist democratically elected leaders (J. Kyle & Y. Mounk, 2018). In other words, populist governments are about four times more likely than non-populist ones to harm democratic institutions. -- Populism is a necessary corrective mechanism that addresses popular problems and limits the power of elites. - -Regardless of which view is more accurate, populism is acknowledged to be a very powerful tool to gather the support of the masses in democratic systems. The main danger perceived by the Humanode core is the rise of populists. Individuals that know how to be popular do not necessarily have the intelligence, professional qualities, experience, or profound knowledge on the subjects they have to make decisions upon on a regular basis. - -In the Humanode network, every human node has a voting power of 1. Voting delegation in Humanode allows governors to delegate their voting power to another governor in the same chamber. Governor power equals 1+ the number of delegations from other governors. Such a system allows crowdsourcing possibilities as delegation is liquid and not regionally bound. As in any other democratic system, individuals that possess oratory, diplomatic skills and are backed by influential media sources have an advantage in the Humanode network. An introvert with sociopathic tendencies possessing a very professional skill set for decision-making operations will most likely receive less support than a good negotiator, orator, and crowd controller that possesses a mediocre skill set. This is slightly balanced out by the fact that human nodes must have an accepted proposal before they become Governors. Thus Governors should be less affected by populist media, as they have a confirmed intellectual skill set that allowed them to create a useful proposal accepted by the Governors of Humanode. - -In Vortex voting procedures, Governors have disproportionate voting power and those Governors that have more delegations have more power. The professional backslide in our understanding poses a threat to the effectiveness, progressiveness, and constant optimization of governance. We fear that without Proof-of-Devotion, which is in a way a proof of having some kind of professional skill set, any democratic system faces becoming a plutocracy, where the wealthiest members control influential and credible media sources to direct the opinion of masses and drive support to candidates of their choosing. - -Proof-of-Devotion might bring a small balance to populism upheaval, as it demands participation in Formation to receive proposal rights on critical matters. Nevertheless, Consuls wielding huge delegations will inevitably emerge and their stance in decision-making mechanisms will be very strong. The only way to limit their influence is the direct and active participation of human nodes in governing processes. The more Governors that do not delegate their vote and actively participate in governance the less authority can be accumulated in the hands of those that seek it. - -### Attack vectors on Cognitocratic core - -#### Plutocracy - -Plutocracy is a state of a governing system where most of the decision-making capabilities are affected or concentrated in the hands of large capital owners. The bigger the plutocratic effect on the system the more power and effort are diverted from continuous optimization, innovation and professional development to supporting and lobbying the interest of capital holders with an intention of maximizing profits through legislative interference. - -Plutocracy is not rooted in any established political philosophy because every single political system on the planet is scourged by it. It’s less of a political system rather than a constant pressure from the elites whose power is derived from the wealth they own. Without transparency and proper deterrence any political system can become a plutocracy by being corrupted from the inside. Traditionally, the most vulnerable spot is elections. Political actors provide lobbying in exchange for electoral support and donations to a warchest. Little by little plutocrats infiltrate all branches of the government, including the legislative. In the end, instead of having a professional system “of the people, by the people, for the people” composed of specialists promoted based on their merit, modern political systems end up with political actors that struggle for power by pleasing their plutocratic overlords - -Cognitocracy is also vulnerable to plutocracy like any other system, but compared to other models it has some benefits that help to keep the plutocrats at bay and deter their influence. - -- There are no elections in a cognitocracy. No position that is elected through a popularity contest. All Cognitocracts have the same voting power so there is no point of direct exchange of electoral support for lobbying. -- If plutocrats want to help out a cognitocrat for him to lobby their interest in the future they have to divert resources to foster an actual creative innovation so that it is accepted by specialists. So instead of just spending capital on marketing. -- Media campaign doesn’t play such a crucial role in voting in a cognitocracy. As a proposer is addressing a professional minority, it is more crucial to prepare and deliver a good paper than to have a massive public image. This is achievable without any external help, thus lowering the influence of plutocrats. - -#### Cognitocractic populism - -Populism is a political approach that seeks to appeal to the interests and sentiments of the general population, often by presenting itself as a champion of the common people against a perceived elite or establishment. Notoriously, to gain power populists might support a popular resolution that satisfies the needs of the masses, but in reality is harmful and counterproductive. - -The very essence of cognitocracy addresses the issue of populism as its effects are limited by making sure that only specialists get to vote. It becomes way harder for populists to amass power as instead of appealing to the masses they would have to appeal to cognitocrats. Although one could argue that potential populists would still find a way by appealing to the most popular problems faced by cognitocrats. - -Promoting education and media literacy is the most long-term effective way of combating populist power grab. They both foster critical thinking, help potential voters distinguish between reliable and unreliable sources of information. Encourage fact-checking and critical evaluation of news and information. - -There is a hypothetical assumption (as there is no data yet) that a cognitocrat who was able to come up with a specialized creative innovation or optimization and was accepted by other cognitocrats will be less susceptible to false media pretenses and much more empirical in critical thinking than a broader population voter. An intellectual barrier that potential cognitocrats have to pass through also acts as an anti-populism barrier as, hypothetically, cognitocrats must personally reach some level of critical thinking and evaluation to actually come up with an innovation. - -#### Cognitocratic drain - -Cognitocratic drain is a potential state in which a certain field has so much innovation and optimization implemented that it becomes very hard to come up with something creative to accept new cognitocrats. This state can lead to several critical changes that might affect the efficiency of a SC. - -It can lead to: - -1. Lowering the barrier for admittance. Proposals become not as innovative or professional as they were before thus lowering the quality of cognitocrats admitted. -2. Emergence of innovative but non-practical proposals and their admittance. -3. Cartelization of a chamber without admittance of new cognitocrats and an emergent hierarchy. - -If such tendencies arise in a SC it might be viable to either dissolve this chamber or merge it with another. This problem represents the very root of how cognitocracy functions. As mentioned above cognitocracy aims to be a dynamic system that creates and dissolves chambers according to the prevalent needs and throughput of proposals of a certain specialization. If the size of a chamber becomes disproportionately larger than the actual decision-making needs and potential innovation that this chamber might bring then it will inevitably fall into the state of cognitocratic drain which will certainly lead to above-mentioned consequences and pose a threat to a cognitocratic system as a whole. - -[_That's all for now. If you read everything till the end then you are now my personal hero! Thank you so much!_](#user-content-fn-1)[^1] - -[^1]: - -
- -# Meritocratic measure - -Meritocratic Measure (MM) is an attempt to represent the merit of functional work and delivery in the Humanode ecosystem. While Cognitocratic Measure (CM) is received for having a proposal accepted in a chamber (i.e., demonstrating innovation and decision-making), MM is received through participation in Formation (i.e., executing work and delivering outcomes). - -MM is a numerical score that is received by a contributor after completing a Formation task or milestone. After a delivery is submitted, governors (or reviewers designated by the relevant chamber) provide a rating on a scale (for the simplicity of the example, from 1 to 10). The average rating converts into MM received by the contributor. - -MM does not grant extra voting power. It acts as a reputation and merit signal that helps: - -- Demonstrate execution ability and reliability over time. -- Inform proposition rights emancipation (Proof-of-Devotion) alongside Proof-of-Time and Proof-of-Governance. -- Provide context in Invision insights when evaluating proposers and teams. - -
- -# Courts and disputes - -Any governance system operating in an adversarial environment needs a structured way to handle disputes. Courts in Vortex exist to process conflicts, ambiguity, and suspected abuse in a transparent, procedural way, without reducing the entire system to informal social pressure. - -Courts handle disputes such as: - -- Delegation disputes and alleged delegation abuse. -- Milestone disputes in Formation (e.g., “delivered but not usable”, “unlock contested”). -- Identity integrity disputes (e.g., PoBU anomalies and suspected coordinated enrolment attempts). -- Governance process disputes (e.g., procedural issues, ambiguous rules, repeated abuse patterns). - -Courts operate through cases with a clear lifecycle: - -1. Case filing (reports + subject + trigger). -2. Evidence and proceedings (claims, evidence list, and planned actions). -3. A dedicated jury session and deliberation window. -4. A verdict and recommended actions (remediation steps, governance follow-ups, warnings, or escalation to a chamber proposal when needed). - -Court outcomes must be auditable and should not silently mutate history. Court actions and verdicts should be recorded as events that can be reviewed later by governors and by the community. - -
- -# Invision - -Invision is the reputation and system-state lens of Vortex. Its goal is to support the principle of constant deterrence by making the system measurable and legible: governors should be able to see what is happening, who is performing, where risks exist, and how governance is trending over time. - -Invision provides an “Insight” layer that aggregates signals such as: - -- Proposal history (submitted, accepted, rejected, abandoned). -- Delivery history (milestones completed, delays, contested milestones, returned budget). -- Governance participation (vote participation over time, comments and review activity). -- Delegation signals (delegations held, delegation changes, concentration indicators). - -Invision is informational: it does not change the 1 human = 1 vote invariant. It exists to improve decision-making quality, reduce uncertainty, and help governors reason about trust, risk, and centralization pressure with concrete data rather than narratives alone. diff --git a/docs/simulation/vortex-simulation-api-contract.md b/docs/simulation/vortex-simulation-api-contract.md deleted file mode 100644 index b2e69eb..0000000 --- a/docs/simulation/vortex-simulation-api-contract.md +++ /dev/null @@ -1,1424 +0,0 @@ -# Vortex Simulation Backend — API Contract v1 - -This document freezes the **JSON contracts** the backend serves so the UI can render from `/api/*` responses consistently. - -Notes: - -- These are **DTOs** (network-safe JSON), not React UI models. -- All DTOs are JSON-safe (no `ReactNode`, no `Date`, no functions). -- Read endpoints are served in two modes: - - DB mode: reads from Postgres `read_models` (seeded by `scripts/db-seed.ts`) plus overlays from normalized tables (votes/formation/courts/era) and canonical domain tables where applicable. - - Inline mode: `READ_MODELS_INLINE=true` serves the same payloads from the in-repo seed builder (`db/seed/readModels.ts`) for local dev/tests without a DB. - - Empty mode: `READ_MODELS_INLINE_EMPTY=true` forces empty/default payloads (used for clean local dev and “no content yet” UX). - -## Conventions - -- IDs are stable slugs (e.g. `engineering`, `evm-dev-starter-kit`, `dato`). -- Timestamps are ISO strings. -- List endpoints return `{ items: [...] }` and may add cursors later. -- When the backing read-model entry does not exist, list endpoints return `{ items: [] }` (HTTP 200). Some singleton endpoints return a minimal empty object (documented below). -- Cursors are opaque and may be backed by different underlying stores (read models vs event log). Clients should treat `nextCursor` as an opaque string and pass it back unchanged. - -## Auth + gating - -Already implemented in `api/routes/*`: - -- `GET /api/health` → `{ ok: true, service: string, time: string }` -- `POST /api/auth/nonce` → `{ address }` → `{ nonce }` (+ `vortex_nonce` cookie) -- `POST /api/auth/verify` → `{ address, nonce, signature }` (+ `vortex_session` cookie) -- `POST /api/auth/logout` -- `GET /api/me` -- `GET /api/gate/status` - -Eligibility (v1): - -- The backend checks Humanode mainnet RPC and considers an address eligible if it is in the current validator set (`Session::Validators`). -- The Humanode RPC URL is resolved in this order: - 1. `HUMANODE_RPC_URL` (API handlers runtime env) - 2. `/sim-config.json` → `humanodeRpcUrl` (repo-configured runtime config served from `public/`) -- If neither is configured, the gate returns `eligible: false` with `reason: "rpc_not_configured"`. - -Chamber voting eligibility (v1): - -- `chamber.vote` is additionally restricted by **chamber membership**: - - specialization chamber `X`: eligible if the human has at least one accepted proposal in `X` - - General chamber: eligible if the human has at least one accepted proposal in any chamber -- Genesis bootstrap is configured via `/sim-config.json` → `genesisChamberMembers` (a mapping of `chamberId -> [addresses]` treated as eligible from day one). - -Chambers (v1): - -- Chambers are canonical (`chambers` table). -- Genesis chambers are configured via `/sim-config.json` → `genesisChambers` and are auto-seeded when the table is empty. - -## Write endpoints (Phase 6+) - -### `POST /api/command` - -All state-changing operations are routed through a single command endpoint. Each command requires: - -- a valid session cookie (`vortex_session`) -- eligibility (active human node via RPC gating), unless dev bypass is enabled - -Idempotency: - -- Clients may pass an `Idempotency-Key` (or `idempotency-key`) header. -- If the same key is sent again with the same request body, the stored response is returned. -- If the same key is re-used with a different request body, the API returns HTTP `409`. - -Rate limiting: - -- `POST /api/command` is rate limited: - - per IP: `SIM_COMMAND_RATE_LIMIT_PER_MINUTE_IP` (default `180`) - - per address: `SIM_COMMAND_RATE_LIMIT_PER_MINUTE_ADDRESS` (default `60`) -- When rate limited, the API returns HTTP `429`: - -```json -{ - "error": { - "message": "Rate limited", - "scope": "ip | address", - "retryAfterSeconds": 30, - "resetAt": "2026-01-01T00:00:00.000Z" - } -} -``` - -Action locks: - -- Writes can be temporarily disabled for an address via admin action locks (`user_action_locks`). -- When locked, the API returns HTTP `403`: - -```json -{ - "error": { - "message": "Action locked", - "code": "action_locked", - "lock": { - "address": "5f... (lowercased)", - "reason": "optional", - "lockedUntil": "2026-01-01T00:00:00.000Z" - } - } -} -``` - -Era quotas: - -- Writes can be capped per era per address (to prevent spam while the community tests the simulation). -- Limits are configured via env vars: - - `SIM_MAX_POOL_VOTES_PER_ERA` - - `SIM_MAX_CHAMBER_VOTES_PER_ERA` - - `SIM_MAX_COURT_ACTIONS_PER_ERA` - - `SIM_MAX_FORMATION_ACTIONS_PER_ERA` -- When a quota is exceeded, the API returns HTTP `429`: - -```json -{ - "error": { - "message": "Era quota exceeded", - "code": "era_quota_exceeded", - "era": 0, - "kind": "poolVotes | chamberVotes | courtActions | formationActions", - "limit": 1, - "used": 1 - } -} -``` - -Additional implemented commands (Phase 12): - -- `proposal.draft.save` -- `proposal.draft.delete` -- `proposal.submitToPool` - These are gated the same way as other writes (session + eligibility). - -#### Command: `proposal.draft.save` - -Request: - -```ts -type ProposalDraftFormPayload = { - templateId?: "project" | "system"; - title: string; - chamberId: string; - summary: string; - what: string; - why: string; - how: string; - metaGovernance?: { - action: "chamber.create" | "chamber.dissolve"; - chamberId: string; - title?: string; - multiplier?: number; - genesisMembers?: string[]; - }; - timeline: { id: string; title: string; timeframe: string }[]; - outputs: { id: string; label: string; url: string }[]; - budgetItems: { id: string; description: string; amount: string }[]; - aboutMe: string; - attachments: { id: string; label: string; url: string }[]; - agreeRules: boolean; - confirmBudget: boolean; -}; - -Notes: - -- This is the v1 “single big form” draft payload used by the current wizard implementation. -- `templateId` is optional; if omitted the backend infers `"system"` when `metaGovernance` is present, otherwise `"project"`. -- The backend now validates drafts using a template-aware discriminant (project vs system) so system proposals can omit project-only fields; missing fields are normalized to defaults for storage. -- Planned (v2+): drafts will continue to evolve into a template-driven discriminated union (project vs system-change flows), with full backend/schema separation. The target architecture and rollout phases are documented in: - - `docs/simulation/vortex-simulation-proposal-wizard-architecture.md` - -type ProposalDraftSaveCommand = { - type: "proposal.draft.save"; - payload: { draftId?: string; form: ProposalDraftFormPayload }; - idempotencyKey?: string; -}; -``` - -Response: - -```ts -type ProposalDraftSaveResponse = { - ok: true; - type: "proposal.draft.save"; - draftId: string; - updatedAt: string; -}; -``` - -Notes: - -- If `draftId` is omitted, the backend generates a new draft ID. - -#### Command: `proposal.draft.delete` - -Request: - -```ts -type ProposalDraftDeleteCommand = { - type: "proposal.draft.delete"; - payload: { draftId: string }; - idempotencyKey?: string; -}; -``` - -Response: - -```ts -type ProposalDraftDeleteResponse = { - ok: true; - type: "proposal.draft.delete"; - draftId: string; - deleted: boolean; -}; -``` - -#### Command: `proposal.submitToPool` - -Request: - -```ts -type ProposalSubmitToPoolCommand = { - type: "proposal.submitToPool"; - payload: { draftId: string }; - idempotencyKey?: string; -}; -``` - -Response: - -```ts -type ProposalSubmitToPoolResponse = { - ok: true; - type: "proposal.submitToPool"; - draftId: string; - proposalId: string; -}; -``` - -Notes: - -- Submission validates that required fields are present (same constraints as the UI wizard). -- The chamber must be valid and active: - - if `draft.chamberId` is unknown, the API returns HTTP `400` with `code: "invalid_chamber"`. - - if `draft.chamberId` points to a dissolved chamber, the API returns HTTP `409` with `code: "chamber_dissolved"`. -- On success, the backend: - - creates a new proposal in the proposal pool by writing `proposals:list` and `proposals:${proposalId}:pool` read models, - - marks the draft as submitted so it no longer appears under drafts. - -#### Command: `pool.vote` - -Request: - -```ts -type PoolVoteDirection = "up" | "down"; -type PoolVoteCommand = { - type: "pool.vote"; - payload: { proposalId: string; direction: PoolVoteDirection }; - idempotencyKey?: string; -}; -``` - -Response: - -```ts -type PoolVoteResponse = { - ok: true; - type: "pool.vote"; - proposalId: string; - direction: PoolVoteDirection; - counts: { upvotes: number; downvotes: number }; -}; -``` - -Notes: - -- If the proposal is not currently in the pool stage, the API returns HTTP `409` (the pool phase is closed once the proposal advances). -- Pool eligibility is enforced (paper-aligned): - - only governors can upvote/downvote in proposal pools - - specialization pools are additionally chamber-scoped: - - voting requires eligibility for that chamber (accepted proposal in that chamber) or genesis membership - - General pool voting requires eligibility in any chamber (accepted proposal in any chamber) or genesis membership - - when ineligible, the API returns HTTP `403` with `code: "pool_vote_ineligible"` and the target `chamberId` -- When pool quorum thresholds are met, the backend auto-advances the proposal from **pool → vote** by updating the `proposals:list` read model. - - If `proposals:${proposalId}:chamber` does not exist yet, it is created from the pool page payload as a minimal placeholder so the UI can render the chamber vote view. - -#### Command: `chamber.vote` - -Request: - -```ts -type ChamberVoteChoice = "yes" | "no" | "abstain"; -type ChamberVoteCommand = { - type: "chamber.vote"; - payload: { proposalId: string; choice: ChamberVoteChoice; score?: number }; - idempotencyKey?: string; -}; -``` - -Response: - -```ts -type ChamberVoteResponse = { - ok: true; - type: "chamber.vote"; - proposalId: string; - choice: ChamberVoteChoice; - counts: { yes: number; no: number; abstain: number }; -}; -``` - -Notes: - -- If the proposal is not currently in the vote stage, the API returns HTTP `409`. -- If the proposal is assigned to a dissolved chamber and was created after the chamber was dissolved, the API returns HTTP `409` with `code: "chamber_dissolved"`. -- Chamber eligibility is enforced (paper-aligned): - - voting in a specialization chamber requires an accepted proposal in that chamber - - voting in General requires an accepted proposal in any chamber - - when ineligible, the API returns HTTP `403` with `code: "chamber_vote_ineligible"` and the target `chamberId` -- `score` is optional and only allowed when `choice === "yes"` (HTTP `400` otherwise). This is the v1 CM input. -- The chamber page read endpoint overlays live vote totals from stored votes (so `votes` and `engagedGovernors` update immediately). -- When quorum + passing are met, the backend either: - - advances immediately to **build**, or - - opens a bounded **veto window** and marks the proposal as “passed (pending veto)”. - - If a veto window is opened, the proposal is finalized to **build** by `POST /api/clock/tick` once the window ends (unless veto is applied). -- When a proposal passes, CM is awarded off-chain: - - the average `score` across yes votes is converted into points - - a CM award record is stored in `cm_awards` (unique per proposal) - - `/api/humans` and `/api/humans/:id` overlay the derived ACM delta from awards - -#### Command: `veto.vote` - -Veto exists as a bounded, temporary slow-down window after a proposal passes chamber vote. - -Request: - -```ts -type VetoVoteChoice = "veto" | "keep"; -type VetoVoteCommand = { - type: "veto.vote"; - payload: { proposalId: string; choice: VetoVoteChoice }; - idempotencyKey?: string; -}; -``` - -Response: - -```ts -type VetoVoteResponse = { - ok: true; - type: "veto.vote"; - proposalId: string; - choice: VetoVoteChoice; - counts: { veto: number; keep: number }; - threshold: number; -}; -``` - -Notes: - -- This command is only valid when a proposal is in `vote` stage and a veto window is open (HTTP `409` otherwise). -- Only veto holders can cast this vote (HTTP `403` otherwise). -- If `counts.veto >= threshold`, the backend: - - clears chamber votes and veto votes for the proposal - - increments `veto_count` - - pauses voting for the veto delay window (then the vote stage re-opens automatically) - - emits feed + timeline events for auditability - -#### Command: `chamber.multiplier.submit` - -Multiplier voting is used to set chamber multipliers based on outsider submissions. - -Request: - -```ts -type ChamberMultiplierSubmitCommand = { - type: "chamber.multiplier.submit"; - payload: { chamberId: string; multiplierTimes10: number }; // 1..100 (represents 0.1..10.0) - idempotencyKey?: string; -}; -``` - -Response: - -```ts -type ChamberMultiplierSubmitResponse = { - ok: true; - type: "chamber.multiplier.submit"; - chamberId: string; - submission: { multiplierTimes10: number }; - aggregate: { submissions: number; avgTimes10: number | null }; - applied: null | { - updated: boolean; - prevMultiplierTimes10: number; - nextMultiplierTimes10: number; - }; -}; -``` - -Notes: - -- Only governors can submit multipliers (HTTP `403` otherwise). -- Submissions are outsiders-only: - - if an address has LCM history in the target chamber, submission is rejected (HTTP `400`). -- Aggregation (v1): average of all submissions for the chamber, rounded to an integer. -- The canonical chamber multiplier (`chambers.multiplier_times10`) is updated to the aggregate average. - -#### Command: `delegation.set` - -Request: - -```ts -type DelegationSetCommand = { - type: "delegation.set"; - payload: { chamberId: string; delegateeAddress: string }; - idempotencyKey?: string; -}; -``` - -Response: - -```ts -type DelegationSetResponse = { - ok: true; - type: "delegation.set"; - chamberId: string; - delegatorAddress: string; - delegateeAddress: string; - updatedAt: string; -}; -``` - -Notes: - -- Delegation is chamber-scoped (v1): a user can set one delegatee per chamber. -- Cycles and self-delegation are rejected (HTTP `400`). -- Delegator eligibility is enforced: - - for General: delegator must be a governor (has an accepted proposal in any chamber) - - for a specialization chamber: delegator must be eligible in that chamber -- Delegatee eligibility is enforced: - - for General: delegatee must be a governor (has an accepted proposal in any chamber) - - for a specialization chamber: delegatee must be eligible in that chamber - -#### Command: `delegation.clear` - -Request: - -```ts -type DelegationClearCommand = { - type: "delegation.clear"; - payload: { chamberId: string }; - idempotencyKey?: string; -}; -``` - -Response: - -```ts -type DelegationClearResponse = { - ok: true; - type: "delegation.clear"; - chamberId: string; - delegatorAddress: string; - cleared: boolean; -}; -``` - -#### Command: `formation.join` - -Request: - -```ts -type FormationJoinCommand = { - type: "formation.join"; - payload: { proposalId: string; role?: string }; - idempotencyKey?: string; -}; -``` - -Response: - -```ts -type FormationJoinResponse = { - ok: true; - type: "formation.join"; - proposalId: string; - teamSlots: { filled: number; total: number }; -}; -``` - -Notes: - -- If the proposal is not currently in the build stage, the API returns HTTP `409`. -- If the proposal does not require Formation (`formationEligible === false`), the API returns HTTP `409` with `code: "formation_not_required"`. -- If team slots are full, the API returns HTTP `409`. -- This command emits a feed event (stage: `build`). - -#### Command: `formation.milestone.submit` - -Request: - -```ts -type FormationMilestoneSubmitCommand = { - type: "formation.milestone.submit"; - payload: { proposalId: string; milestoneIndex: number; note?: string }; - idempotencyKey?: string; -}; -``` - -Response: - -```ts -type FormationMilestoneSubmitResponse = { - ok: true; - type: "formation.milestone.submit"; - proposalId: string; - milestoneIndex: number; - milestones: { completed: number; total: number }; -}; -``` - -Notes: - -- If the proposal is not currently in the build stage, the API returns HTTP `409`. -- If the proposal does not require Formation (`formationEligible === false`), the API returns HTTP `409` with `code: "formation_not_required"`. -- `milestoneIndex` is 1-based. -- Submitting does not automatically increase `completed` until it is unlocked. -- This command emits a feed event (stage: `build`). - -#### Command: `formation.milestone.requestUnlock` - -Request: - -```ts -type FormationMilestoneRequestUnlockCommand = { - type: "formation.milestone.requestUnlock"; - payload: { proposalId: string; milestoneIndex: number }; - idempotencyKey?: string; -}; -``` - -Response: - -```ts -type FormationMilestoneRequestUnlockResponse = { - ok: true; - type: "formation.milestone.requestUnlock"; - proposalId: string; - milestoneIndex: number; - milestones: { completed: number; total: number }; -}; -``` - -Notes: - -- If the proposal is not currently in the build stage, the API returns HTTP `409`. -- If the proposal does not require Formation (`formationEligible === false`), the API returns HTTP `409` with `code: "formation_not_required"`. -- Unlocking requires a prior submit (HTTP `409` if not submitted). -- Double-unlock is rejected (HTTP `409`). -- This command emits a feed event (stage: `build`). - -## Read endpoints - -These endpoints are implemented under `api/routes/*`. - -In v1, most reads start from `read_models` (DB mode) or the inline seed (inline mode), then apply overlays from normalized state (votes, formation, courts, era) and canonical tables where needed. - -Proposals note: - -- Proposal endpoints may prefer canonical proposals (Phase 14+) and fall back to `read_models` for seeded legacy payloads: - - `GET /api/proposals` - - `GET /api/proposals/:id/pool` - - `GET /api/proposals/:id/chamber` - - `GET /api/proposals/:id/formation` - -## Admin/simulation endpoints - -These endpoints are intended for simulation control (local dev, cron jobs, and admin tools). - -- All admin endpoints require `x-admin-secret: $ADMIN_SECRET` unless `DEV_BYPASS_ADMIN=true`. - -- `GET /api/clock` -- `POST /api/clock/advance-era` -- `POST /api/clock/rollup-era` (computes per-era statuses and next-era active governor set) -- `POST /api/clock/tick` (automation hook: rollup + optional era auto-advance) -- `POST /api/admin/users/lock` (temporarily disables writes for an address) -- `POST /api/admin/users/unlock` -- `GET /api/admin/users/locks` (lists active locks) -- `GET /api/admin/users/:address` (inspection: era counters, quotas, remaining, lock) -- `GET /api/admin/audit` (admin actions audit log) -- `GET /api/admin/stats` (admin operational stats) -- `POST /api/admin/writes/freeze` (toggle write freeze) - -### `POST /api/clock/tick` - -This is the simulation “cron” entrypoint. It is safe to call repeatedly; rollups are idempotent per-era and era advancement is guarded by a “due” check unless forced. - -Request: - -```ts -type PostClockTickRequest = { - forceAdvance?: boolean; // advance even if the era is not due - rollup?: boolean; // default true -}; -``` - -Response: - -```ts -type PostClockTickResponse = { - ok: true; - now: string; - eraSeconds: number; - due: boolean; - advanced: boolean; - fromEra: number; - toEra: number; - endedWindows?: Array<{ - proposalId: string; - stage: "pool" | "vote"; - endedAt: string; - emitted: boolean; // true only once per (proposalId, stage, endedAt) - }>; - rollup?: { - era: number; - rolledAt: string; - requirements: { - poolVotes: number; - chamberVotes: number; - courtActions: number; - formationActions: number; - }; - requiredTotal: number; - activeGovernorsNextEra: number; - usersRolled: number; - statusCounts: Record< - "Ahead" | "Stable" | "Falling behind" | "At risk" | "Losing status", - number - >; - }; -}; -``` - -Notes: - -- When `SIM_ENABLE_STAGE_WINDOWS=true`, `POST /api/clock/tick` can also emit (deduped) feed events when a proposal’s pool/vote window has ended, and returns those in `endedWindows` for visibility/debugging. -- When a proposal passes chamber vote and enters a veto window, `POST /api/clock/tick` finalizes it to `build` once the veto window ends (unless veto has been applied). - -### `POST /api/admin/users/lock` - -```ts -type PostAdminUserLockRequest = { - address: string; - lockedUntil: string; // ISO timestamp - reason?: string; -}; - -type PostAdminUserLockResponse = { ok: true }; -``` - -### `POST /api/admin/users/unlock` - -```ts -type PostAdminUserUnlockRequest = { address: string }; -type PostAdminUserUnlockResponse = { ok: true }; -``` - -### `GET /api/admin/users/locks` - -```ts -type GetAdminUserLocksResponse = { - items: Array<{ address: string; lockedUntil: string; reason: string | null }>; -}; -``` - -### `GET /api/admin/users/:address` - -```ts -type EraQuotaConfigDto = { - maxPoolVotes: number | null; - maxChamberVotes: number | null; - maxCourtActions: number | null; - maxFormationActions: number | null; -}; - -type GetAdminUserResponse = { - address: string; - era: number; - counts: { - poolVotes: number; - chamberVotes: number; - courtActions: number; - formationActions: number; - }; - quotas: EraQuotaConfigDto; - remaining: { - poolVotes: number | null; - chamberVotes: number | null; - courtActions: number | null; - formationActions: number | null; - }; - lock: { address: string; lockedUntil: string; reason: string | null } | null; -}; -``` - -### `GET /api/admin/audit` - -```ts -type AdminAuditActionDto = "user.lock" | "user.unlock"; -type AdminAuditItemDto = { - id: string; - action: AdminAuditActionDto; - targetAddress: string; - lockedUntil?: string; - reason?: string | null; - timestamp: string; -}; - -type GetAdminAuditResponse = { - items: AdminAuditItemDto[]; - nextCursor?: string; // DB mode uses event seq -}; -``` - -### `POST /api/admin/writes/freeze` - -```ts -type PostAdminWritesFreezeRequest = { enabled: boolean }; -type PostAdminWritesFreezeResponse = { ok: true; writesFrozen: boolean }; -``` - -### `GET /api/admin/stats` - -The response is intended for ops/debugging. It can evolve, but it stays JSON-safe and stable enough for manual inspection. - -```ts -type GetAdminStatsResponse = { - currentEra: number; - writesFrozen: boolean; - config: { - rateLimitsPerMinute: { - perIpPerMinute: number; - perAddressPerMinute: number; - }; - eraQuotas: { - maxPoolVotes: number | null; - maxChamberVotes: number | null; - maxCourtActions: number | null; - maxFormationActions: number | null; - }; - dynamicActiveGovernors: boolean; - }; -}; -``` - -### `GET /api/clock` - -```ts -type GoverningStatusDto = - | "Ahead" - | "Stable" - | "Falling behind" - | "At risk" - | "Losing status"; - -type EraRollupMetaDto = { - era: number; - rolledAt: string; - requiredTotal: number; - requirements: { - poolVotes: number; - chamberVotes: number; - courtActions: number; - formationActions: number; - }; - activeGovernorsNextEra: number; -}; - -type GetClockResponse = { - currentEra: number; - activeGovernors: number; - currentEraRollup?: EraRollupMetaDto; -}; -``` - -### Chambers - -#### `GET /api/chambers` - -Returns the chambers directory cards. - -```ts -type ChamberPipelineDto = { pool: number; vote: number; build: number }; -type ChamberStatsDto = { - governors: string; - acm: string; - mcm: string; - lcm: string; -}; -type ChamberDto = { - id: string; - name: string; - multiplier: number; - stats: ChamberStatsDto; - pipeline: ChamberPipelineDto; -}; - -type GetChambersResponse = { items: ChamberDto[] }; -``` - -Query params: - -- `includeDissolved=true` (optional): include dissolved chambers in the list (default is active-only). - -#### `GET /api/chambers/:id` - -Returns the chamber detail model. - -```ts -type ChamberProposalStageDto = "upcoming" | "live" | "ended"; -type ChamberProposalDto = { - id: string; - title: string; - meta: string; - summary: string; - lead: string; - nextStep: string; - timing: string; - stage: ChamberProposalStageDto; -}; - -type ChamberGovernorDto = { - id: string; - name: string; - tier: string; - focus: string; -}; -type ChamberThreadDto = { - id: string; - title: string; - author: string; - replies: number; - updated: string; -}; -type ChamberChatMessageDto = { id: string; author: string; message: string }; -type ChamberStageOptionDto = { value: ChamberProposalStageDto; label: string }; - -type GetChamberResponse = { - proposals: ChamberProposalDto[]; - governors: ChamberGovernorDto[]; - threads: ChamberThreadDto[]; - chatLog: ChamberChatMessageDto[]; - stageOptions: ChamberStageOptionDto[]; -}; -``` - -Notes: - -- In v1, `governors` is projected from canonical membership (`chamber_memberships`) plus `/sim-config.json` → `genesisChamberMembers`. -- In v1, `proposals` is projected from canonical proposals: - - pool → `upcoming` - - vote → `live` - - build → `ended` (meta may render as “Formation” or “Passed” depending on `formationEligible`). - -### Factions - -#### `GET /api/factions` - -```ts -type FactionRosterTagDto = - | { kind: "acm"; value: number } - | { kind: "mm"; value: number } - | { kind: "text"; value: string }; - -type FactionRosterMemberDto = { - humanNodeId: string; - role: string; - tag: FactionRosterTagDto; -}; - -type FactionDto = { - id: string; - name: string; - description: string; - members: number; - votes: string; - acm: string; - focus: string; - goals: string[]; - initiatives: string[]; - roster: FactionRosterMemberDto[]; -}; - -type GetFactionsResponse = { items: FactionDto[] }; -``` - -#### `GET /api/factions/:id` - -Returns `FactionDto`. - -### Formation - -#### `GET /api/formation` - -```ts -type FormationMetricDto = { label: string; value: string; dataAttr: string }; -type FormationCategoryDto = "all" | "research" | "development" | "social"; -type FormationStageDto = "live" | "gathering" | "completed"; - -type FormationProjectDto = { - id: string; - title: string; - focus: string; - proposer: string; - summary: string; - category: FormationCategoryDto; - stage: FormationStageDto; - budget: string; - milestones: string; - teamSlots: string; -}; - -type GetFormationResponse = { - metrics: FormationMetricDto[]; - projects: FormationProjectDto[]; -}; -``` - -### Invision - -#### `GET /api/invision` - -```ts -type InvisionGovernanceMetricDto = { label: string; value: string }; -type InvisionGovernanceStateDto = { - label: string; - metrics: InvisionGovernanceMetricDto[]; -}; -type InvisionEconomicIndicatorDto = { - label: string; - value: string; - detail: string; -}; -type InvisionRiskSignalDto = { title: string; status: string; detail: string }; -type InvisionChamberProposalDto = { - title: string; - effect: string; - sponsors: string; -}; - -type GetInvisionResponse = { - governanceState: InvisionGovernanceStateDto; - economicIndicators: InvisionEconomicIndicatorDto[]; - riskSignals: InvisionRiskSignalDto[]; - chamberProposals: InvisionChamberProposalDto[]; -}; -``` - -### My governance - -#### `GET /api/my-governance` - -```ts -type MyGovernanceEraActionDto = { - label: string; - done: number; - required: number; -}; -type MyGovernanceEraActivityDto = { - era: string; - required: number; - completed: number; - actions: MyGovernanceEraActionDto[]; - timeLeft: string; -}; - -type GetMyGovernanceResponse = { - eraActivity: MyGovernanceEraActivityDto; - myChamberIds: string[]; - rollup?: { - era: number; - rolledAt: string; - status: GoverningStatusDto; - requiredTotal: number; - completedTotal: number; - isActiveNextEra: boolean; - activeGovernorsNextEra: number; - }; -}; -``` - -Notes: - -- Anonymous users get the base `read_models` payload. -- When authenticated, the backend overlays `eraActivity.era` and each action’s `done` count from `era_user_activity` for the current era. -- Per-era action counters are incremented only on first-time actions per entity (e.g. changing a vote does not count as another action). -- If the current era has been rolled up, the response includes a `rollup` object derived from `era_rollups` and `era_user_status`. - -### Proposals (list) - -#### `GET /api/proposals?stage=pool|vote|build|draft` - -Returns the proposals page cards (collapsed/expanded content comes from this DTO). - -```ts -type ProposalStageDto = "draft" | "pool" | "vote" | "build"; -type ProposalToneDto = "ok" | "warn"; - -type ProposalStageDatumDto = { - title: string; - description: string; - value: string; - tone?: ProposalToneDto; -}; -type ProposalStatDto = { label: string; value: string }; - -type ProposalListItemDto = { - id: string; - title: string; - meta: string; - stage: ProposalStageDto; - summaryPill: string; - summary: string; - stageData: ProposalStageDatumDto[]; - stats: ProposalStatDto[]; - proposer: string; - proposerId: string; - chamber: string; - tier: "Nominee" | "Ecclesiast" | "Legate" | "Consul" | "Citizen"; - proofFocus: "pot" | "pod" | "pog"; - tags: string[]; - keywords: string[]; - date: string; - votes: number; - activityScore: number; - ctaPrimary: string; - ctaSecondary: string; -}; - -type GetProposalsResponse = { items: ProposalListItemDto[] }; -``` - -### Proposal pages - -These endpoints map 1:1 to the current stage pages in the UI. - -#### `GET /api/proposals/:id/pool` - -```ts -type InvisionInsightDto = { role: string; bullets: string[] }; - -type PoolProposalPageDto = { - title: string; - proposer: string; - proposerId: string; - chamber: string; - focus: string; - tier: string; - budget: string; - cooldown: string; - formationEligible: boolean; - teamSlots: string; - milestones: string; - upvotes: number; - downvotes: number; - attentionQuorum: number; // e.g. 0.22 - activeGovernors: number; // era baseline - upvoteFloor: number; - rules: string[]; - attachments: { id: string; title: string }[]; - teamLocked: { name: string; role: string }[]; - openSlotNeeds: { title: string; desc: string }[]; - milestonesDetail: { title: string; desc: string }[]; - summary: string; - overview: string; - executionPlan: string[]; - budgetScope: string; - invisionInsight: InvisionInsightDto; -}; -``` - -#### `GET /api/proposals/:id/chamber` - -```ts -type ChamberProposalPageDto = { - title: string; - proposer: string; - proposerId: string; - chamber: string; - budget: string; - formationEligible: boolean; - teamSlots: string; - milestones: string; - timeLeft: string; - votes: { yes: number; no: number; abstain: number }; - attentionQuorum: number; - passingRule: string; - engagedGovernors: number; - activeGovernors: number; - attachments: { id: string; title: string }[]; - teamLocked: { name: string; role: string }[]; - openSlotNeeds: { title: string; desc: string }[]; - milestonesDetail: { title: string; desc: string }[]; - summary: string; - overview: string; - executionPlan: string[]; - budgetScope: string; - invisionInsight: InvisionInsightDto; -}; -``` - -#### `GET /api/proposals/:id/formation` - -```ts -type FormationProposalPageDto = { - title: string; - chamber: string; - proposer: string; - proposerId: string; - budget: string; - timeLeft: string; - teamSlots: string; - milestones: string; - progress: string; - stageData: { title: string; description: string; value: string }[]; - stats: { label: string; value: string }[]; - lockedTeam: { name: string; role: string }[]; - openSlots: { title: string; desc: string }[]; - milestonesDetail: { title: string; desc: string }[]; - attachments: { id: string; title: string }[]; - summary: string; - overview: string; - executionPlan: string[]; - budgetScope: string; - invisionInsight: InvisionInsightDto; -}; -``` - -#### `GET /api/proposals/:id/timeline` - -Returns the event-backed “what happened” timeline for a proposal. - -```ts -type ProposalTimelineEventTypeDto = - | "proposal.submitted" - | "proposal.stage.advanced" - | "pool.vote" - | "chamber.vote" - | "formation.join" - | "formation.milestone.submitted" - | "formation.milestone.unlockRequested" - | "chamber.created" - | "chamber.dissolved"; - -type ProposalTimelineItemDto = { - id: string; - type: ProposalTimelineEventTypeDto; - title: string; - detail?: string; - actor?: string; - timestamp: string; // ISO -}; - -type GetProposalTimelineResponse = { items: ProposalTimelineItemDto[] }; -``` - -Notes: - -- Optional query string: `?limit=...` (default `100`, max `500`). -- Backed by the append-only `events` table: - - `events.type = "proposal.timeline.v1"` - - `events.entityType = "proposal"` - - `events.entityId = proposalId` - -Notes: - -- The payload is overlaid with Formation state: - - `teamSlots`, `milestones`, and `progress` are computed from stored Formation state. - - joined team members are appended to `lockedTeam` (as short addresses). - -#### Command: `court.case.report` - -Request: - -```ts -type CourtCaseReportCommand = { - type: "court.case.report"; - payload: { caseId: string }; - idempotencyKey?: string; -}; -``` - -Response: - -```ts -type CourtCaseReportResponse = { - ok: true; - type: "court.case.report"; - caseId: string; - reports: number; - status: "jury" | "live" | "ended"; -}; -``` - -Notes: - -- If the case ID is unknown, the API returns HTTP `404`. -- Reports are per-user (reporting twice does not increment the count). -- Status can transition **jury → live** once enough reports are collected (v1 threshold). -- Emits a feed event (stage: `courts`). - -#### Command: `court.case.verdict` - -Request: - -```ts -type CourtCaseVerdictCommand = { - type: "court.case.verdict"; - payload: { caseId: string; verdict: "guilty" | "not_guilty" }; - idempotencyKey?: string; -}; -``` - -Response: - -```ts -type CourtCaseVerdictResponse = { - ok: true; - type: "court.case.verdict"; - caseId: string; - verdict: "guilty" | "not_guilty"; - status: "jury" | "live" | "ended"; - totals: { guilty: number; notGuilty: number }; -}; -``` - -Notes: - -- Verdicts are only allowed when the case is **live** (HTTP `409` otherwise). -- Verdicts are one-per-user (re-voting updates the voter’s verdict). -- Status can transition **live → ended** once enough distinct verdicts are collected (v1 threshold). -- Emits a feed event (stage: `courts`). - -### Proposal drafts - -#### `GET /api/proposals/drafts` - -```ts -type ProposalDraftListItemDto = { - id: string; - title: string; - chamber: string; - tier: string; - summary: string; - updated: string; -}; - -type GetProposalDraftsResponse = { items: ProposalDraftListItemDto[] }; -``` - -#### `GET /api/proposals/drafts/:id` - -```ts -type ProposalDraftDetailDto = { - title: string; - proposer: string; - chamber: string; - focus: string; - tier: string; - budget: string; - formationEligible: boolean; - teamSlots: string; - milestonesPlanned: string; - summary: string; - rationale: string; - budgetScope: string; - invisionInsight: InvisionInsightDto; - checklist: string[]; - milestones: string[]; - teamLocked: { name: string; role: string }[]; - openSlotNeeds: { title: string; desc: string }[]; - milestonesDetail: { title: string; desc: string }[]; - attachments: { title: string; href: string }[]; -}; -``` - -### Courts - -#### `GET /api/courts` - -```ts -type CourtCaseStatusDto = "jury" | "live" | "ended"; -type CourtCaseDto = { - id: string; - title: string; - subject: string; - triggeredBy: string; - status: CourtCaseStatusDto; - reports: number; - juryIds: string[]; - opened: string; // dd/mm/yyyy -}; - -type GetCourtsResponse = { items: CourtCaseDto[] }; -``` - -#### `GET /api/courts/:id` - -```ts -type CourtCaseDetailDto = CourtCaseDto & { - parties: { role: string; humanId: string; note?: string }[]; - proceedings: { claim: string; evidence: string[]; nextSteps: string[] }; -}; -``` - -### Human nodes - -#### `GET /api/humans` - -```ts -type HumanTierDto = "nominee" | "ecclesiast" | "legate" | "consul" | "citizen"; -type HumanNodeDto = { - id: string; - name: string; - role: string; - chamber: string; - factionId: string; - tier: HumanTierDto; - acm: number; - mm: number; - memberSince: string; - formationCapable?: boolean; - active: boolean; - formationProjectIds?: string[]; - tags: string[]; -}; - -type GetHumansResponse = { items: HumanNodeDto[] }; -``` - -#### `GET /api/humans/:id` - -Mirrors `db/seed/fixtures/humanNodeProfiles.ts` but remains JSON-safe. - -```ts -type ProofKeyDto = "time" | "devotion" | "governance"; -type ProofSectionDto = { - title: string; - items: { label: string; value: string }[]; -}; -type HeroStatDto = { label: string; value: string }; -type QuickDetailDto = { label: string; value: string }; -type GovernanceActionDto = { - title: string; - action: string; - context: string; - detail: string; -}; -type HistoryItemDto = { - title: string; - action: string; - context: string; - detail: string; - date: string; -}; -type ProjectCardDto = { - title: string; - status: string; - summary: string; - chips: string[]; -}; - -type HumanNodeProfileDto = { - id: string; - name: string; - governorActive: boolean; - humanNodeActive: boolean; - governanceSummary: string; - heroStats: HeroStatDto[]; - quickDetails: QuickDetailDto[]; - proofSections: Record; - governanceActions: GovernanceActionDto[]; - projects: ProjectCardDto[]; - activity: HistoryItemDto[]; - history: string[]; -}; -``` - -### Feed - -#### `GET /api/feed?cursor=...&stage=...` - -```ts -type FeedStageDto = "pool" | "vote" | "build" | "courts" | "thread" | "faction"; -type FeedToneDto = "ok" | "warn"; - -type FeedStageDatumDto = { - title: string; - description: string; - value: string; - tone?: FeedToneDto; -}; - -type FeedStatDto = { label: string; value: string }; - -type FeedItemDto = { - id: string; - title: string; - meta: string; - stage: FeedStageDto; - summaryPill: string; - summary: string; // plain text or Markdown - stageData?: FeedStageDatumDto[]; - stats?: FeedStatDto[]; - proposer?: string; - proposerId?: string; - ctaPrimary?: string; - ctaSecondary?: string; - href?: string; - timestamp: string; -}; - -type GetFeedResponse = { items: FeedItemDto[]; nextCursor?: string }; -``` diff --git a/docs/simulation/vortex-simulation-data-model.md b/docs/simulation/vortex-simulation-data-model.md deleted file mode 100644 index 5b509f0..0000000 --- a/docs/simulation/vortex-simulation-data-model.md +++ /dev/null @@ -1,240 +0,0 @@ -# Vortex Simulation Backend — Data Model (v1) - -This document explains how v1 state is stored in Postgres and how that storage maps onto reads, writes, and the feed. - -The schema is implemented in `db/schema.ts` with migrations under `db/migrations/`. - -## Design principles - -- Keep an **append-only event log** for audit and feed. -- Keep **write state** in normalized tables where it matters (votes, formation, courts, era counters). -- Keep the UI stable via a transitional **read-model bridge** (`read_models`) until full projections exist. - -## Transitional read model bridge - -### `read_models` - -Purpose: - -- Store JSON payloads that directly match the DTOs in `docs/simulation/vortex-simulation-api-contract.md`. - -Modes: - -- DB mode: read from `read_models` in Postgres (requires `DATABASE_URL`). -- Inline seeded mode: `READ_MODELS_INLINE=true` serves the same payloads without a DB. -- Clean-by-default mode: `READ_MODELS_INLINE_EMPTY=true` forces empty/default payloads. - -In v1, many pages are primarily served from `read_models`, with live overlays from normalized tables. - -## Identity and gating - -### Users - -- `users`: off-chain account records keyed by address. - -### Eligibility cache - -- `eligibility_cache`: TTL cached RPC results for `GET /api/gate/status`. - -## Events and audit - -### `events` - -Append-only event stream used for: - -- feed items -- admin audit trail -- per-entity history pages (v1: proposal timeline) - -In v1, events are emitted both by user commands and by admin endpoints. - -In v1, proposal history is stored as `events` entries: - -- `events.type = "proposal.timeline.v1"` -- `events.entityType = "proposal"` -- `events.entityId = ` - -## Votes - -### Pool votes - -- `pool_votes`: one row per `(proposalId, voterAddress)` representing the latest up/down direction. - -### Chamber votes - -- `chamber_votes`: one row per `(proposalId, voterAddress)` representing the latest yes/no/abstain choice. -- Optional `score` is stored for yes votes (v1 CM input). - -### Veto votes - -Veto is a bounded, temporary slow-down window after a proposal passes chamber vote. - -- `veto_votes`: one row per `(proposalId, voterAddress)` representing the latest veto council choice: - - `choice = "veto" | "keep"` - -### CM awards - -- `cm_awards`: one row per proposal that passes chamber vote, derived from the average yes `score`. - -## Proposal drafts (Phase 12) - -Proposal creation is stored as author-owned drafts: - -- `proposal_drafts`: one row per draft: - - `id` (draft slug) - - `author_address` - - `payload` (the wizard form, JSON) - - `submitted_at` / `submitted_proposal_id` once submitted into the pool - -Planned (v2+): - -- The draft `payload` will evolve from a single “project-shaped” object with optional `metaGovernance` into a **discriminated union** aligned with proposal wizard templates (project vs system-change flows). -- The wizard architecture and phased migration strategy are described in: - - `docs/simulation/vortex-simulation-proposal-wizard-architecture.md` - -## Proposals (Phase 14) - -Canonical proposals table (first step away from `read_models` as source of truth): - -- `proposals`: one row per proposal: - - `id` (proposal slug) - - `stage` (`pool | vote | build` in v1) - - `author_address` - - `title`, `summary`, `chamber_id` - - `payload` (jsonb; stage-agnostic proposal content in v1, derived from the draft payload) - - veto fields (v1): - - `veto_count` - - `vote_passed_at`, `vote_finalizes_at` - - `veto_council`, `veto_threshold` - - `created_at`, `updated_at` - -In Phase 14, reads begin preferring this table (with `read_models` as a compatibility fallback for seeded legacy DTOs). - -## Proposal stage denominators (Phase 28) - -To keep quorum math stable when eras advance mid-stage, proposal quorums use a stage-entry denominator snapshot: - -- `proposal_stage_denominators`: one row per `(proposalId, stage)` where `stage` is `pool` or `vote`. - - `era`: the era when the proposal entered that stage. - - `active_governors`: the active-governor denominator captured at stage entry. - - `captured_at`: timestamp for audit/debug. - -Reads: - -- Proposal list items and proposal pages prefer the stage-entry denominator when present. -- If no snapshot exists (legacy data), the current era baseline is used as a fallback. - -Writes: - -- The snapshot is captured exactly once per `(proposalId, stage)` and never overwritten. - -## Chambers (Phase 18–21) - -Canonical chambers live in: - -- `chambers`: - - `id`, `title` - - `status` (`active | dissolved`) - - `multiplierTimes10` (integer; e.g. `15` = `1.5`) - - `createdByProposalId`, `dissolvedByProposalId` - - `metadata` (jsonb; room for future fields without schema churn) - -Voting eligibility (paper-aligned, v1-enforced) is stored in: - -- `chamber_memberships`: - - primary key `(chamberId, address)` - - `grantedByProposalId` (when the membership was granted via an accepted proposal) - - `source` (v1: `accepted_proposal`) - -Dissolution never deletes history. It changes chamber status and restricts new writes (e.g., new proposals) while preserving audit trails. - -## Delegation (Phase 29) - -Delegation is a chamber-scoped liquid graph that affects **chamber vote weights** but never affects proposal-pool attention. - -Tables: - -- `delegations`: current delegation graph, keyed by `(chamber_id, delegator_address)` → `delegatee_address`. -- `delegation_events`: append-only history of delegation changes (`set` / `clear`) for audit/debug. - -Vote tally semantics (v1): - -- Chamber votes are stored per-voter in `chamber_votes` as before. -- When computing chamber vote counts, the current delegation graph is applied: - - each voter contributes weight `1 + delegatedVoices`, - - a delegator’s voice only counts if the delegator **did not cast a vote** themselves. - -## Chamber multiplier voting (Phase 31) - -Paper intent: chamber multipliers are set by outsiders (those who do not have LCM history in the chamber). - -Tables: - -- `chamber_multiplier_submissions`: current outsider submissions, keyed by `(chamber_id, voter_address)` with: - - `multiplier_times10` (integer 1..100, representing `0.1..10.0`) - -Aggregation (v1): - -- The multiplier applied to a chamber is the rounded average of all submissions. -- The canonical chamber record is updated in `chambers.multiplier_times10`. - -Display semantics (v1): - -- CM award history (`cm_awards`) is immutable. -- Views that display ACM/MCM compute MCM using the current chamber multiplier (do not rewrite historical award rows). - -## Formation - -Formation stores the mutable parts that can’t remain a static mock: - -- `formation_projects`: per-proposal counters/baselines -- `formation_team`: additional joiners and roles -- `formation_milestones`: per-milestone status (`todo`/`submitted`/`unlocked`) -- `formation_milestone_events`: append-only milestone action history - -## Courts - -Courts store: - -- `court_cases`: case headers and status bucket -- `court_reports`: per-address reports (and optional notes) -- `court_verdicts`: per-address verdicts (guilty/not guilty) - -## Era tracking - -Era tracking supports “My Governance” and rollups: - -- `clock_state`: current era -- `era_snapshots`: per-era aggregates (v1: active governors baseline) -- `era_user_activity`: per-era action counters per address, used for: - - quotas - - my-governance progress - - rollups -- `era_rollups`: per-era rollup output (computed status buckets and next-era counts) -- `era_user_status`: per-address derived rollup status for a specific era - -## Ops controls - -### Idempotency - -- `idempotency_keys`: stored request/response pairs keyed by idempotency key. - -### Rate limiting - -- `api_rate_limits`: per-IP and per-address buckets for `POST /api/command`. - -### Action locks - -- `user_action_locks`: temporary write bans for an address (admin-controlled). - -### Global write freeze - -- `admin_state`: small key/value store for global toggles (including write freeze). - -## What’s expected to change in v2+ - -- Continue migrating away from the read-model bridge (`read_models`) so all pages are served from canonical tables + projections. -- Add chamber multiplier voting state: - - `chamber_multiplier_submissions` (or equivalent) -- Add Meritocratic Measure (MM) history (Formation delivery scoring): - - `mm_awards` (or equivalent per-milestone ratings + derived totals) diff --git a/docs/simulation/vortex-simulation-implementation-plan.md b/docs/simulation/vortex-simulation-implementation-plan.md deleted file mode 100644 index 6f7a35c..0000000 --- a/docs/simulation/vortex-simulation-implementation-plan.md +++ /dev/null @@ -1,1476 +0,0 @@ -# Vortex Simulation Backend — Implementation Plan - -This plan turns `docs/simulation/vortex-simulation-processes.md` + `docs/simulation/vortex-simulation-tech-architecture.md` into an executable roadmap that stays aligned with the current UI. - -For a paper-aligned module map (paper → docs → code), see `docs/simulation/vortex-simulation-modules.md`. - -## Current status (what exists in the repo right now) - -Implemented (v1 simulation backend): - -- API handlers under `api/` -- Auth + gate (wallet signature + mainnet eligibility): - - `GET /api/health` - - `POST /api/auth/nonce` (sets `vortex_nonce` cookie) - - `POST /api/auth/verify` (sets `vortex_session` cookie; Substrate signature verification) - - `POST /api/auth/logout` - - `GET /api/me` - - `GET /api/gate/status` (Humanode mainnet RPC gating; dev bypass supported) -- Cookie-signed nonce + session helpers (requires `SESSION_SECRET`) -- Dev toggles for local progress: - - `DEV_BYPASS_SIGNATURE`, `DEV_BYPASS_GATE`, `DEV_ELIGIBLE_ADDRESSES`, `DEV_INSECURE_COOKIES` -- Local dev notes: `docs/simulation/vortex-simulation-local-dev.md` -- Test harness + CI: - - `yarn test` (Node’s built-in test runner) - - CI runs `yarn test` via `.github/workflows/code.yml` - - API tests: `tests/api-*.test.js` -- v1 decisions + contracts (kept aligned with the UI): - - v1 constants: `docs/simulation/vortex-simulation-v1-constants.md` - - API contract: `docs/simulation/vortex-simulation-api-contract.md` - - DTO types: `src/types/api.ts` -- Postgres (Drizzle) schema + migrations + seed scripts: - - Drizzle config: `drizzle.config.ts` - - Schema: `db/schema.ts` - - Seed script: `scripts/db-seed.ts` (writes read-model payloads into `read_models` + seeds `events`) - - DB scripts: `yarn db:generate`, `yarn db:migrate`, `yarn db:seed` - - Clear script: `yarn db:clear` (wipe data, keep schema) - - Seed tests: `tests/db-seed.test.js`, `tests/migrations.test.js` -- Read endpoints for all pages (Phase 4 read-model bridge): - - `api/routes/*` serves Chambers, Proposals, Feed, Courts, Humans, Factions, Formation, Invision, My Governance - - Clean-by-default mode supported (`READ_MODELS_INLINE_EMPTY=true`), with a shared UI empty state bar (`src/components/NoDataYetBar.tsx`) -- Event log backbone: - - `events` table + schemas + projector; Feed can be served from DB events in DB mode - - Tests: `tests/events-seed.test.js`, `tests/feed-event-projector.test.js` -- Write slices via `POST /api/command` (auth + gate + idempotency + live overlays): - - Proposal pool voting (`pool.vote`) + pool → vote auto-advance - - Chamber voting (`chamber.vote`) + CM awards + vote → build auto-advance (quorum + passing; Formation is optional) - - Formation v1 (`formation.join`, `formation.milestone.submit`, `formation.milestone.requestUnlock`) - - Courts v1 (`court.case.report`, `court.case.verdict`) - - Era snapshots + per-era activity counters (`/api/clock/*` + `/api/my-governance`) - - Era rollups + tier statuses (`POST /api/clock/rollup-era`) -- Hardening + ops controls: - - Rate limiting, per-era quotas, idempotency conflict detection - - Admin tools: action locks, audit/inspection, stats, global write freeze - - Tests: `tests/api-command-*.test.js`, `tests/api-admin-*.test.js` - -Implemented (UI supporting the simulation backend): - -- Proposal creation wizard v2 (template-driven): - - Template registry: `src/pages/proposals/proposalCreation/templates/registry.ts` - - Templates: - - `project` (full flow: Essentials → Plan → Budget → Review) - - `system` (General chamber only; skips budget: Setup → Rationale → Review) - - Tests: `tests/proposal-wizard-template-registry.test.js` - -Not implemented (intentional v1 gaps): - -- Replacing transitional `read_models` with fully normalized domain tables + event-driven projections -- Time-windowed stage logic (vote windows, scheduled transitions) beyond manual/admin clock ops -- Delegation flows and any “real” forum/thread product (threads remain minimal) - -## Guiding principles - -- Ship a **thin vertical slice** first: auth → gate → read models → one write action → feed events. -- Keep domain logic **pure and shared** (state machine + events). The API is a thin adapter. -- Prefer **deterministic**, testable transitions; avoid “magic UI-only numbers”. -- Enforce gating on **every write**: “browse open, write gated”. -- Minimize UI churn: keep the frozen DTOs (`docs/simulation/vortex-simulation-api-contract.md` + `src/types/api.ts`) stable while the backend transitions from `read_models` to normalized tables + an event log. - -## Testing requirement (applies to every phase) - -Each phase is considered “done” only when tests are added and run. - -Testing layers: - -1. **Unit tests** (pure TS): state machines, invariants, calculations (quorums, passing rules, tier rules). -2. **API integration tests**: call API handlers with `Request` objects and assert status/JSON/cookies. -3. **DB integration tests** (once DB exists): migrations apply, basic queries work, constraints enforced. - -Test execution policy: - -- Add a `yarn test` script and run it after each feature batch. -- Keep CI in sync (extend `.github/workflows/code.yml` to run `yarn test` and `yarn build` once tests exist). - -Tooling note: API handlers are tested directly via `Request` objects (no browser/manual flow needed for API testing). - -## Execution sequence (phases in order) - -This is the order we’ll follow from now on, based on what’s already landed. - -1. **Phase 0 — Lock v1 decisions (DONE)** -2. **Phase 1 — Freeze API contracts (DTOs) (DONE)** -3. **Phase 2a — API skeleton (DONE)** -4. **Phase 2b — Test harness for API + domain (DONE)** -5. **Phase 2c — DB skeleton + migrations + seed-from-fixtures (DONE)** -6. **Phase 3 — Auth + eligibility gate (DONE)** -7. **Phase 4 — Read models first (all pages, clean-by-default) (DONE)** -8. **Phase 5 — Event log backbone (DONE)** -9. **Phase 6 — First write slice (pool voting) (DONE)** -10. **Phase 7 — Chamber vote + CM awarding (DONE)** -11. **Phase 8 — Formation v1 (DONE)** -12. **Phase 9 — Courts v1 (DONE)** -13. **Phase 10a — Era snapshots + activity counters (DONE)** -14. **Phase 10b — Era rollups + tier statuses (DONE for v1)** -15. **Phase 11 — Hardening + moderation** -16. **Phase 12 — Proposal drafts + submission (DONE)** -17. **Phase 13 — Eligibility via `Session::Validators` (DONE)** -18. **Phase 14 — Canonical domain tables + projections (DONE)** -19. **Phase 15 — Deterministic state transitions (DONE)** -20. **Phase 16 — Time windows + automation (DONE)** -21. **Phase 17 — Chamber voting eligibility + Formation optionality (DONE)** -22. **Phase 18 — Chambers lifecycle (create/dissolve) (DONE)** -23. **Phase 19 — Chamber detail projections (DONE)** -24. **Phase 20 — Dissolved chamber enforcement (DONE)** -25. **Phase 21 — Chambers directory projections (pipeline/stats) (DONE)** -26. **Phase 22 — Meta-governance chamber.create seeding (backend) (DONE)** -27. **Phase 23 — Proposal drafts (UI ↔ backend) (DONE)** -28. **Phase 24 — Meta-governance proposal type (UI) (DONE)** -29. **Phase 25 — Proposal pages projected from canonical state (DONE)** -30. **Phase 26 — Proposal history timeline (DONE)** -31. **Phase 27 — Active governance v2 (derive and persist active governor set per era) (DONE)** -32. **Phase 28 — Quorum engine v2 (era-derived denominators + paper thresholds) (DONE)** -33. **Phase 29 — Delegation v1 (graph + history + chamber vote weighting) (DONE)** -34. **Phase 30 — Veto v1 (temporary slow-down + attempt limits) (DONE)** -35. **Phase 31 — Chamber multiplier voting v1 (outside-of-chamber aggregation) (DONE)** -36. **Phase 32 — Paper alignment audit pass (process-by-process)** -37. **Phase 33 — Testing readiness v3 (scenario harness + end-to-end validation) (IN PROGRESS)** -38. **Phase 34 — Meritocratic Measure (MM) v1 (post-V3, Formation delivery scoring)** -39. **Phase 35 — Proposal wizard v2 W1 (template runner + registry) (DONE)** -40. **Phase 36 — Proposal wizard v2 W2 (system.chamberCreate flow) (DONE — `system` template v1)** -41. **Phase 37 — Proposal wizard v2 W3 (backend discriminated drafts) (DONE)** -42. **Phase 38 — Proposal wizard v2 W4 (migrate drafts + simplify validation) (DONE)** -43. **Phase 39 — Proposal wizard v2 W5 (cleanup + extension points) (DONE)** - -### Proposal wizard v2 phases (Phases 35–39) - -In parallel to the main backend phases, the proposal wizard is moving toward template-driven flows so that system-change proposals (like chamber creation) do not share project-only steps/fields. - -Reference: - -- `docs/simulation/vortex-simulation-proposal-wizard-architecture.md` (Wizard v2 track W1–W5) - -Notes: - -- These phases are primarily UI/schema refactors and can be executed after Phase 23 (drafts) without blocking the main governance modules. -- The goal is to avoid “one big form” drift and make system-change proposals (like chamber creation) collect only the fields required to create/render the chamber. - -## Phase 0 — Lock v1 decisions (required before DB + real gate) - -Locked for v1 (based on current decisions): - -1. Database: **Postgres** (Neon-compatible serverless Postgres). -2. Gating source: **Humanode mainnet RPC** (no Subscan dependency for v1). -3. Active Human Node rule: address is in the current validator set (`Session::Validators`) on Humanode mainnet. -4. Era length: **configured by us off-chain** (a simulation constant), not a chain parameter. - -Deliverable: a short “v1 constants” section committed to docs or config. - -Tests: - -- None required (doc-only), but we must record decisions so later tests can assert exact thresholds/constants. - -## V3 — Testing readiness (what “ready to test” means) - -V3 is the point where the simulation can be tested as a coherent system against the Vortex 1.0 model (and against our updated paper reference copy), not just as disconnected UI pages. - -V3 is “ready for testing” when: - -- All core governance modules required for chamber/proposal testing are implemented and wired end-to-end: - - **active governance**: “active governors next era” is computed at rollup and persisted - - **quorum engine**: pool + vote quorums use era-derived denominators and are consistent across endpoints/pages - - **proposals**: draft → pool → vote → accepted is testable deterministically (Formation optional) - - **chambers**: General + specialization chambers exist; creation/dissolution is proposal-driven (meta-governance) - - **delegation**: chamber vote weighting works; pool attention remains direct-only - - **veto**: temporary slow-down exists and is auditable/bounded - - **multipliers**: outsider aggregation updates chamber multipliers without rewriting CM award history -- A paper alignment audit has been run process-by-process and deviations are explicitly recorded. -- A scenario harness exists so the above can be validated deterministically (API-level end-to-end tests; no browser required). - -Not required for V3: - -- Meritocratic Measure (MM). MM can be built after V3 without blocking proper testing of proposals/chambers/quorums/delegation/veto. - -V3 phases (to reach testing readiness): - -1. Phase 27 — Active governance v2 -2. Phase 28 — Quorum engine v2 -3. Phase 29 — Delegation v1 -4. Phase 30 — Veto v1 -5. Phase 31 — Chamber multiplier voting v1 -6. Phase 32 — Paper alignment audit pass -7. Phase 33 — Testing readiness harness (scenario-driven) -8. Phase 34 — Meritocratic Measure (MM) v1 (post-V3) - -## Phase 1 — Define contracts that mirror the UI (1–2 days) - -The UI renders from `/api/*` reads. The contract is frozen so backend and frontend stay aligned while the implementation evolves. - -Contract location: - -- `docs/simulation/vortex-simulation-api-contract.md` (human-readable source of truth) -- `src/types/api.ts` (TS source of truth for DTOs) - -1. Define response DTOs that match the current UI needs: - - Chambers directory card: id/name/multiplier + stats + pipeline. - - Chamber detail: stage-filtered proposals + governors + threads/chat. - - Proposals list: the exact data currently rendered in collapsed/expanded cards. - - Proposal pages: PP / Chamber vote / Formation page models. - - Courts list + courtroom page model. - - Human nodes list + profile model. - - Feed item model (the card layout currently used). -2. Decide how IDs work across the system (proposalId, chamberId, humanId) and make them consistent. - -Deliverable: a short “API contract v1” section (types + endpoint list) that the backend must satisfy. - -Tests: - -- Add unit tests that validate DTO payload shapes against deterministic seed fixtures (smoke: “fixture data can be encoded into the DTOs without loss”). - -## Phase 2a — API skeleton (DONE) - -Delivered in this repo: - -- API handlers routes: `health`, `auth`, `me`, `gate` -- Cookie-signed nonce/session (requires `SESSION_SECRET`) -- Dev bypass knobs while we build real auth/gate - -Tests (implemented): - -- `GET /api/health` returns `{ ok: true }`. -- `POST /api/auth/nonce` returns a nonce and sets a `vortex_nonce` cookie. -- `POST /api/auth/verify`: - - rejects invalid signatures when bypass is disabled - - succeeds and sets `vortex_session` for valid signatures (or when bypass is enabled) -- `GET /api/me` reflects authentication state -- `GET /api/gate/status` returns `not_authenticated` when logged out - -## Phase 2b — Test harness for API + domain (DONE) - -Implementation: - -- `tests/` folder + `yarn test` script are in place. -- Tests import API handlers directly and exercise them with synthetic `Request` objects. -- CI runs `yarn test` (see `.github/workflows/code.yml`). - -## Phase 2c — DB skeleton (1–3 days) - -Implemented so far: - -1. Drizzle config + Postgres schema: - - `drizzle.config.ts` - - `db/schema.ts` - - generated migration under `db/migrations/` -2. Seed-from-mocks into `read_models`: - - `db/seed/readModels.ts` (pure seed builder) - - `scripts/db-seed.ts` - - `yarn db:seed` (requires `DATABASE_URL`) -3. Tests: - - `tests/migrations.test.js` asserts core tables are present in the migration. - - `tests/db-seed.test.js` asserts the seed is deterministic, unique-keyed, and JSON-safe. -4. Transitional read endpoints (Phase 2c/4 bridge): - - Read-model store: `api/_lib/readModelsStore.ts` (DB mode via `DATABASE_URL` + inline mode via `READ_MODELS_INLINE=true`) - - Endpoints: `GET /api/chambers`, `GET /api/proposals`, `GET /api/courts`, `GET /api/humans` (+ per-entity detail routes) -5. Simulation clock (admin-only for advancement): - - `GET /api/clock` - - `POST /api/clock/advance-era` (requires `ADMIN_SECRET` via `x-admin-secret`, unless `DEV_BYPASS_ADMIN=true`) - -Ops checklist (to validate Phase 2c against a real DB): - -- Create a Postgres DB (v1: Neon) and set `DATABASE_URL`. -- Run: `yarn db:migrate && yarn db:seed`. -- Verify reads are served from Postgres by unsetting `READ_MODELS_INLINE`. - -Deliverable: deployed API that responds and can connect to the DB. - -Tests: - -- Migrations apply cleanly on a fresh DB. -- Seed job is idempotent (run twice yields the same IDs/state). -- Read endpoints return deterministic results from seeded data. - -## Phase 3 — Auth + eligibility gate (3–7 days) - -1. `POST /api/auth/nonce`: - - store nonce with expiry - - rate limit per IP/address -2. `POST /api/auth/verify`: - - verify signature - - create/find `users` row - - create session cookie/JWT -3. `GET /api/gate/status`: - - read session address - - query eligibility via RPC (`Session::Validators` in v1) - - cache result with TTL (`eligibility_cache`) -4. Frontend wiring: - - show wallet connect/disconnect + gate status in the sidebar (Polkadot extension) - - disable all write buttons unless eligible (and show a short reason on hover) - - allow non-eligible users to browse everything - -Frontend flag: - -- `VITE_SIM_AUTH` controls the sidebar wallet panel and client-side gating UI (default enabled; set `VITE_SIM_AUTH=false` to disable). - -Deliverable: users can log in; the UI knows if they’re eligible; buttons are blocked for non-eligible users. - -Tests: - -- Nonce expires; nonce is single-use. -- Nonce issuance is rate-limited per IP. -- Signature verification passes for valid signatures and fails for invalid ones. -- Eligibility check caches with TTL and returns consistent `expiresAt`. -- Write endpoints that change state are introduced in later phases; Phase 3 only gates UI interactions and exposes `/api/me` + `/api/gate/status`. - -## Phase 4 — Read models first (3–8 days) - -Goal: keep the app fully read-model driven via `/api/*` while the backend transitions from the `read_models` bridge to normalized tables + an event log. - -Read endpoints covered in this phase: - -1. Chambers - - `GET /api/chambers` - - `GET /api/chambers/:id` -2. Proposals - - `GET /api/proposals?stage=...` - - `GET /api/proposals/:id/pool` - - `GET /api/proposals/:id/chamber` - - `GET /api/proposals/:id/formation` - - `GET /api/proposals/drafts` - - `GET /api/proposals/drafts/:id` -3. Feed - - `GET /api/feed?cursor=...&stage=...` (cursor can land later; stage filtering is already supported) -4. Courts - - `GET /api/courts` - - `GET /api/courts/:id` -5. Human nodes - - `GET /api/humans` - - `GET /api/humans/:id` -6. Factions - - `GET /api/factions` - - `GET /api/factions/:id` -7. Singletons/dashboards - - `GET /api/formation` - - `GET /api/invision` - - `GET /api/my-governance` - -Frontend: - -- Use the existing `src/lib/apiClient.ts` wrapper (typed helpers, error handling). -- Keep visuals stable; the data source remains `/api/*`. -- Empty-by-default UX: when the backend returns an empty list, pages show “No … yet” (no fixture fallbacks). - -Deliverable: app renders from backend reads across all pages, with clean empty-state behavior by default. - -Tests: - -- API contract stability checks (seeded inline mode returns DTO-shaped payloads). -- Empty-mode checks: list endpoints return `{ items: [] }` and singleton endpoints return minimal defaults when the read-model store is empty (`READ_MODELS_INLINE_EMPTY=true`). - -## Phase 5 — Event log (feed) as the backbone (2–6 days) - -1. Create `events` table (append-only). -2. Define event types (union) and payload schemas (zod). -3. Implement a simple “projector”: - - basic derived feed cards from events - - cursors for pagination -4. Backfill initial events from seeded mock data (so the feed isn’t empty on day 1). - - Use `db/seed/fixtures/*` as the deterministic starting point for the initial backfill. - -Deliverable: feed is powered by real events; pages can also show histories from the event stream. - -Tests: - -- Events are append-only (no updates/deletes). -- Projector determinism: given the same event stream, derived feed cards are identical. - -## Phase 6 — First write slice: Proposal pool voting (4–10 days) - -1. Implement `POST /api/command` with: - - auth required - - gating required (`isActiveHumanNode`) - - idempotency key support -2. Implement `pool.vote` command: - - write pool vote with unique constraint (proposalId + voter address) - - return updated upvote/downvote counts - - overlay live counts in `GET /api/proposals/:id/pool` - - compute quorum thresholds and stage transitions (pool → vote) -3. Frontend: - - ProposalPP page upvote/downvote calls API - - optimistic UI optional (but must reconcile) - -Current status: - -- Implemented: - - `POST /api/command` + `pool.vote` with idempotency - - `pool_votes` storage (DB mode) with in-memory fallback for tests/dev without a DB - - Proposal pool page reads overlay the live vote counts - - Pool quorum evaluator (`evaluatePoolQuorum`) and pool → vote auto-advance when thresholds are met - - the proposal stage is advanced in the canonical `proposals` table and mirrored into the `proposals:list` read model (compat) - - if the chamber page read model is missing, it is created from the pool page payload - - Pool voting is rejected when a proposal is no longer in the pool stage (HTTP 409) - - ProposalPP UI calls `pool.vote` and refetches the pool page on success -- Not implemented yet: - - centralized state machine for transitions (beyond v1 stage updates) - -Deliverable: users can perform one real action (pool vote) and see it in metrics + feed. - -Tests: - -- One vote per user per proposal (idempotency + uniqueness). -- Pool metrics computed correctly from votes + era baselines. -- Stage transition triggers exactly once when thresholds are met. - -## Phase 7 — Chamber vote (decision) + CM awarding (5–14 days) - -1. Add `chamber.vote` command: - - yes/no/abstain - - quorum + passing rule evaluation - - emit events -2. On pass: - - transition to Formation if eligible - - award CM (LCM per chamber) and recompute derived ACM -3. Frontend: - - ProposalChamber becomes real - -Deliverable: end-to-end proposal lifecycle from pool → vote (pass/fail) is operational. - -Tests: - -- Vote constraints (one vote per user, valid choices). -- Quorum + passing calculation accuracy (including rounding rules like 66.6%). -- CM awarding updates LCM/MCM/ACM deterministically after acceptance. - -Current status: - -- Implemented: - - `chamber.vote` command via `POST /api/command` (auth + gate + idempotency) - - `chamber_votes` storage (DB mode) with in-memory fallback for tests/dev without a DB - - Chamber page reads overlay live vote counts in `GET /api/proposals/:id/chamber` - - Vote → build auto-advance when quorum + passing are met and `formationEligible === true` - - the proposal stage is advanced in the canonical `proposals` table and mirrored into the `proposals:list` read model (compat) - - if the formation page read model is missing, it is generated from the chamber page payload - - CM awarding v1: - - `score` (1–10) can be attached to yes votes - - when a proposal passes, the average yes score is converted into CM points and recorded in `cm_awards` - - human ACM is derived as a baseline from read models plus a delta from `cm_awards` (overlaid in `/api/humans*`) -- Not implemented yet: - - rejection / fail path and time-based vote windows - - richer CM economy (per-chamber breakdowns, ACM/LCM/MCM surfaces across all pages, parameter tuning) - -## Phase 8 — Formation v1 (execution) (5–14 days) - -1. Formation project row is created when proposal enters Formation. -2. `formation.join` fills team slots. -3. `formation.milestone.submit` records deliverables. -4. `formation.milestone.requestUnlock` emits an event; acceptance can be mocked initially. -5. Formation metrics and pages read from DB/events. - -Deliverable: Formation pages become real and emit feed events. - -Tests: - -- Team slots cannot exceed total. -- Milestone unlock rules enforced (cannot unlock before request; cannot double-unlock). - -Current status: - -- Implemented: - - Formation tables: `formation_projects`, `formation_team`, `formation_milestones`, `formation_milestone_events` - - Commands: - - `formation.join` - - `formation.milestone.submit` - - `formation.milestone.requestUnlock` - - Formation read overlays in `GET /api/proposals/:id/formation` (team slots + milestone counts + progress) - - Minimal UI wiring on the Formation proposal page (actions call `/api/command`) -- Tests: - - `tests/api-command-formation.test.js` - -## Phase 9 — Courts v1 (disputes) (5–14 days) - -1. `court.case.report` creates or increments cases. -2. Case state machine: Jury → Session live → Ended (driven by time or thresholds). -3. `court.case.verdict` records guilty/not-guilty. -4. Outcome hooks (v1): - - hold/release a milestone unlock request - - flag identity as “restricted” (simulation only) - -Deliverable: courts flow works and affects off-chain simulation outcomes. - -Tests: - -- Case state machine transitions are valid only. -- Verdict is single-per-user and only allowed in appropriate case states. -- Outcome hooks apply the intended flags (hold/release/restrict). - -Current status: - -- Implemented: - - Courts tables: `court_cases`, `court_reports`, `court_verdicts` - - Commands: - - `court.case.report` - - `court.case.verdict` - - Courts read overlays: - - `GET /api/courts` - - `GET /api/courts/:id` - - Minimal UI wiring: - - Courtroom `Report` action and verdict buttons call `/api/command` -- Tests: - - `tests/api-command-courts.test.js` - -## Phase 10a — Era snapshots + activity counters (DONE) - -Goal: make “time” and “activity” real, without changing UI contracts. - -Implemented: - -- Tables: - - `era_snapshots` (per-era aggregates, including `activeGovernors`) - - `era_user_activity` (per-era counters for actions) -- Active governors baseline: - - `SIM_ACTIVE_GOVERNORS` (or `VORTEX_ACTIVE_GOVERNORS`) sets the default baseline. - - Defaults to `150` if unset/invalid. -- `POST /api/clock/advance-era` ensures the next `era_snapshots` row exists. -- Proposal page overlays: - - `GET /api/proposals/:id/pool` and `GET /api/proposals/:id/chamber` override `activeGovernors` from the current era snapshot. -- My Governance overlay: - - `GET /api/my-governance` returns the base read model for anonymous users. - - When authenticated, the response overlays per-era `done` counts from `era_user_activity` (mapped by action label). -- Era counters are incremented only on first-time actions: - - Vote updates do not inflate era activity (e.g. changing an upvote to a downvote stays a single action). - -Tests: - -- `tests/api-era-activity.test.js` (per-era action counting and reset across `advance-era`). - -## Phase 10b — Era rollups + tier statuses (DONE for v1) - -1. Implement cron rollup: - - freeze era action counts - - compute `isActiveGovernorNextEra` - - compute tier decay + statuses (Ahead/Stable/Falling behind/At risk/Losing status) - - update quorum baselines -2. Store `era_snapshots` and emit `era.rolled` events. - -Deliverable: system “moves” with time and feels like governance. - -Tests: - -- Rollup is deterministic and idempotent for a given era window. -- Tier status mapping (Ahead/Stable/Falling behind/At risk/Losing status) matches policy. - -Current status: - -- Implemented: - - `POST /api/clock/rollup-era` (admin/simulation endpoint) - - `GET /api/clock` includes `activeGovernors` and `currentEraRollup` when a rollup exists - - `GET /api/my-governance` includes `rollup` for authenticated users when the current era is rolled - - Rollup tables: `era_rollups`, `era_user_status` - - Configurable per-era requirements via env: - - `SIM_REQUIRED_POOL_VOTES` (default `1`) - - `SIM_REQUIRED_CHAMBER_VOTES` (default `1`) - - `SIM_REQUIRED_COURT_ACTIONS` (default `0`) - - `SIM_REQUIRED_FORMATION_ACTIONS` (default `0`) - - Era snapshot baseline updates: - - rollups write next era’s `era_snapshots.active_governors` from `activeGovernorsNextEra` -- Tests: - - `tests/api-era-rollup.test.js` - - `tests/api-my-governance-rollup.test.js` - -Notes: - -- Tier decay is tracked separately (future iteration) — v1 rollups compute per-era status + next-era active set only. - -## Phase 11 — Hardening + moderation (DONE for v1) - -- Rate limiting (per IP/address) and anti-spam (per-era quotas). -- Auditability: make all state transitions and changes event-backed. -- Admin tools: manual “advance era”, seed data, freeze/unfreeze. -- Observability: logs + basic metrics for rollups and gating failures. -- Moderation controls (off-chain): - - temporary action lock for a user - - court-driven restrictions flags (simulation) - -Current status: - -- `POST /api/command` rate limiting: - - per IP: `SIM_COMMAND_RATE_LIMIT_PER_MINUTE_IP` - - per address: `SIM_COMMAND_RATE_LIMIT_PER_MINUTE_ADDRESS` - - storage: `api_rate_limits` (DB mode) or in-memory buckets (inline mode) -- Per-era quotas (anti-spam): - - `SIM_MAX_POOL_VOTES_PER_ERA` - - `SIM_MAX_CHAMBER_VOTES_PER_ERA` - - `SIM_MAX_COURT_ACTIONS_PER_ERA` - - `SIM_MAX_FORMATION_ACTIONS_PER_ERA` - - enforcement uses the same “counted actions” as rollups (`era_user_activity`) -- Action locks: - - storage: `user_action_locks` (DB mode) or in-memory locks (inline mode) - - enforcement: all `POST /api/command` writes return HTTP `403` when locked - - admin endpoints: - - `POST /api/admin/users/lock` - - `POST /api/admin/users/unlock` - - inspection endpoints: - - `GET /api/admin/users/locks` - - `GET /api/admin/users/:address` - - audit: - - `GET /api/admin/audit` - - DB mode logs as `events.type = "admin.action.v1"` -- Operational admin endpoints: - - `GET /api/admin/stats` (basic metrics + config snapshot) - - `POST /api/admin/writes/freeze` (toggle write-freeze state) - - deploy-time kill switch: `SIM_WRITE_FREEZE=true` -- Tests: - - `tests/api-command-rate-limit.test.js` - - `tests/api-command-action-lock.test.js` - - `tests/api-command-era-quotas.test.js` - - `tests/api-admin-tools.test.js` - - `tests/api-admin-write-freeze.test.js` - -Notes: - -- `POST /api/clock/*` remains the admin surface for simulation time operations; `POST /api/admin/*` is for moderation/ops. - -## Suggested implementation order (lowest risk / highest value) - -1. Auth + gate -2. Read models for Chambers + Proposals + Feed -3. Event log -4. Pool voting -5. Chamber voting + CM awarding -6. Formation -7. Courts -8. Era rollups + tier statuses - -## Milestone definition for “proto-vortex launch” - -Minimum viable proto-vortex for community: - -- Login with wallet signature -- Eligibility gate from mainnet -- Read-only browsing for all users -- Eligible users can: - - upvote/downvote in pool - - vote yes/no/abstain in chamber vote -- Feed shows real events -- Era rollup runs at least manually (admin endpoint) - -## Notes specific to the current UI - -- The UI already has the key surfaces for v1: - - `ProposalCreation` wizard (draft), ProposalPP (pool), ProposalChamber (vote), ProposalFormation (formation), Courts/Courtroom (courts). -- Keep returning API payloads that match the frozen DTOs so UI components remain stable. - -## Post-v1 roadmap (v2+) - -v1 is a complete, community-playable simulation slice. The next phases focus on replacing transitional components (`read_models`-driven state) with canonical domain tables and a fuller write model, while keeping the current UI DTOs stable. - -## Phase 12 — Proposal drafts + submission (DONE) - -Goal: make the ProposalCreation wizard a real write path (drafts stored in DB, submitted into the pool), without requiring a backend redesign. - -Deliverables: - -- Commands (via `POST /api/command`): - - `proposal.draft.save` (create/update a draft) - - `proposal.draft.delete` - - `proposal.submitToPool` (transition a draft into `pool`) -- Reads: - - `GET /api/proposals/drafts` - - `GET /api/proposals/drafts/:id` - - drafts appear as real data (not seed-only) in DB mode -- Minimal validation that matches the wizard gates (required fields for submission). -- Emit events: - - `proposal.draft.saved`, `proposal.submittedToPool` - -Tests: - -- Draft save is idempotent (Idempotency-Key) and never duplicates. -- Submission enforces required fields and stage (`draft` → `pool` only). -- Non-eligible users can browse drafts only if explicitly allowed (default: drafts are private to the author). - -Current status: - -- `proposal_drafts` table exists (migration + schema). -- `POST /api/command` implements `proposal.draft.save`, `proposal.draft.delete`, `proposal.submitToPool`. -- Draft read endpoints support author-owned drafts in DB mode and memory drafts in non-DB mode, with fixture fallback in `READ_MODELS_INLINE=true`. -- ProposalCreation UI saves drafts via the backend and submits drafts into the proposal pool. -- Tests added: `tests/api-command-drafts.test.js`. - -## Phase 13 — Eligibility via `Session::Validators` (DONE) - -Goal: gate writes based on the **current validator set** on Humanode mainnet (instead of attempting to infer “activeness” via `ImOnline::*`). - -Deliverables: - -- Mainnet gate reads: - - Use `Session::Validators` as the single source of truth for “active Human Node” eligibility. - - Store and cache the result in `eligibility_cache` (DB mode) or memory (no-DB mode), same as today. -- Error / reason codes: - - Standardize on a single negative reason when not in the validator set (e.g. `not_in_validator_set`). -- Local dev: - - Keep `DEV_BYPASS_GATE` and `DEV_ELIGIBLE_ADDRESSES` for local iteration. - -Tests: - -- `GET /api/gate/status` returns `eligible: true` when the address is included in the RPC-returned `Session::Validators`. -- Caching works (second call does not re-hit RPC in memory mode). -- Non-validator address returns `eligible: false` with the expected reason code. - -## Phase 14 — Canonical domain tables + projections (DONE) - -Goal: start migrating away from `read_models` as the “source of truth” by introducing canonical tables for entities that are actively mutated (starting with proposals). - -Deliverables: - -- Introduce canonical tables (v1 order): - - `proposals` (canonical state: stage, chamber, proposer, formation eligibility, etc.) - - `proposal_drafts` (author-owned draft write model) - - optional: `proposal_stage_transitions` (append-only, derived from events) -- Add a projector layer that generates the existing read DTOs from canonical tables/events, writing either: - - derived DTO payloads into `read_models` (compat mode), or - - serving DTOs directly from projector queries (preferred once stable). - -Tests: - -- Projection determinism: same canonical inputs → identical DTO outputs. -- Backwards compatibility: existing endpoints continue returning the same DTO shape. - -Current status: - -- `proposals` table exists (migration + schema). -- `proposal.submitToPool` writes a canonical proposal row (and only writes proposal DTOs into `read_models` when a `read_models` store is available). -- Proposal page reads prefer canonical proposals (pool/chamber/formation), falling back to `read_models` only for seeded legacy proposals. -- Pool → vote and vote → build auto-advance update the canonical proposal stage via compare-and-set transitions. - -## Phase 15 — Deterministic state transitions (DONE) - -Goal: centralize all proposal stage logic in a single, testable state machine (rather than scattered “read model patching”). - -Deliverables: - -- A single transition authority for proposals: - - `draft` → `pool` (submit) - - `pool` → `vote` (quorum met) - - `vote` → `build` (passing met + formation eligible) - - explicit fail paths (v2 decision): `pool`/`vote` rejection or expiry -- All transitions emit events and are enforced (HTTP `409` on invalid transition). - -Tests: - -- Transition matrix coverage (allowed vs forbidden transitions). -- Regression tests for quorum and rounding edges (e.g. 66.6%). - -Current status: - -- A v1 state machine module exists (`api/_lib/proposalStateMachine.ts`) with the core quorum-based advance rules. -- Commands validate stage against canonical proposals first (falling back to `read_models` for legacy seeded proposals). -- `pool.vote` and `chamber.vote` can auto-advance proposals even when `read_models` are missing, by using canonical proposal state as the source of truth. -- Canonical stage transitions are enforced via `transitionProposalStage(...)` (compare-and-set + transition validation), with coverage in tests. - -## Phase 16 — Time windows + automation (DONE) - -Goal: move from “admin-driven clock ops only” to scheduled simulation behavior. - -Deliverables: - -- Cron-based era ops: - - a single “cron entrypoint” endpoint: `POST /api/clock/tick` - - rollup the current era (idempotent) - - optionally advance era when “due” (time-based; configurable) -- Optional vote windows: - - ability to enable/disable stage windows via env (`SIM_ENABLE_STAGE_WINDOWS`) - - reject new votes when `pool` or `vote` windows end (v1 behavior; no automatic stage change) - - deterministic rule for “what happens on expiry” (v2 decision: auto-close/auto-reject vs “stuck”) - -Tests: - -- Clock advancement is idempotent and monotonic. -- Rollups remain deterministic even when scheduled. - -Current status: - -- `POST /api/clock/tick` exists and can run the rollup and (optionally) advance era when due. -- Stage windows are implemented behind `SIM_ENABLE_STAGE_WINDOWS`: - - `pool.vote` and `chamber.vote` return HTTP `409` after the configured windows end. - - `GET /api/proposals` and `GET /api/proposals/:id/chamber` compute `timeLeft` from the canonical proposal stage timestamp when enabled (`"Ended"` once the window is over). - - `POST /api/clock/tick` emits a deduped feed event when a proposal’s `pool` or `vote` window has ended (and returns those in the `endedWindows` response field for visibility). - -## Phase 17 — Chamber voting eligibility + Formation optionality (DONE) - -Goal: align chambers with the Vortex 1.0 model: - -- specialization chambers are votable only by humans who have an **accepted proposal in that chamber** -- General chamber is votable only by humans who have an **accepted proposal in any chamber** -- quorum fractions remain **global**, but denominators are **chamber-scoped** (active governors eligible for that chamber in the era, captured on stage entry) -- not all accepted proposals require Formation (Formation is optional) - -Definitions (v1): - -- “Accepted proposal” means: **passed chamber vote**. -- “Formation required” is a proposal-type property; acceptance does not imply a Formation project must exist. - -Deliverables: - -1. Chamber participation model - - genesis participants/roles (seeded at genesis) - - earned eligibility: - - accepted proposal in chamber X → eligible to vote in X (specialization) - - accepted proposal in any chamber → eligible to vote in General - - no decay/expiration of eligibility (separate from “active governor next era” quorum baselines) -2. Enforce eligibility in writes - - `chamber.vote` must reject when the voter is not eligible for the proposal’s lead chamber. - - The rule applies to **General** and **specialization** chambers. -3. Decouple acceptance from Formation - - chamber vote passing moves a proposal to “accepted” regardless of whether Formation is required. - - Formation actions and Formation page behavior are enabled only when the proposal is Formation-required. - -Tests: - -- Eligibility enforcement: - - voting in a specialization chamber without eligibility is rejected - - voting in General without “any accepted proposal” is rejected - - eligibility is granted after a proposal is accepted -- Formation optionality: - - a non-Formation proposal can still become accepted - - Formation actions are rejected when Formation is not required - -Current status: - -- Chamber membership table added: - - schema: `db/schema.ts` (`chamber_memberships`) - - migration: `db/migrations/0016_chamber_memberships.sql` - - store: `api/_lib/chamberMembershipsStore.ts` -- Eligibility is enforced in writes: - - `POST /api/command` → `chamber.vote` rejects HTTP `403` when the voter is not eligible for the proposal’s lead chamber. - - Dev bypass: `DEV_BYPASS_CHAMBER_ELIGIBILITY=true` (local/testing only). -- Genesis bootstrap: - - `/sim-config.json` can list `genesisChamberMembers` to allow the first chamber votes before any proposals are accepted. - - Tests/local dev can override config via `SIM_CONFIG_JSON`. -- Eligibility is granted on acceptance: - - when a proposal passes chamber vote (vote → build), the proposer gains: - - specialization membership for that chamber (if not `general`) - - General eligibility (`general`) -- Acceptance is decoupled from Formation: - - passing chamber vote advances vote → build regardless of Formation requirement - - Formation state is only seeded when `formationEligible=true` - - Formation commands are rejected when Formation is not required -- Tests: - - `tests/api-chamber-eligibility.test.js` - -## Phase 18 — Chambers lifecycle (create/dissolve) (DONE) - -Goal: model chamber creation and dissolution per Vortex 1.0 as **General chamber** proposals. - -Deliverables: - -- Canonical `chambers` table (id, title, status, createdAt, dissolvedAt, multiplier, metadata). -- Commands/events for: - - create chamber (General chamber proposal outcome) - - dissolve chamber (General chamber proposal outcome; preserve history) -- Read endpoints (replace read-model-only chamber list/detail with canonical + projections). - -Tests: - -- Chamber create/dissolve changes canonical chamber status and read endpoints reflect it. -- Votes and proposals continue to resolve `chamberId` correctly when a chamber is dissolved (history preserved). - -Current status: - -- Canonical `chambers` table exists: - - schema: `db/schema.ts` - - migration: `db/migrations/0017_chambers.sql` -- Genesis chambers are configured via `public/sim-config.json` → `genesisChambers` and auto-seeded when the table is empty. -- Read endpoints are canonical: - - `GET /api/chambers` builds from canonical chambers (empty in `READ_MODELS_INLINE_EMPTY=true` mode). - - `GET /api/chambers/:id` resolves canonical chambers (still returns a minimal detail model in v1). -- Chamber lifecycle is simulated as a General-chamber proposal outcome: - - accepted General proposals with `payload.metaGovernance.action` in `{ "chamber.create", "chamber.dissolve" }` create/dissolve chambers. -- Tests: - - `tests/api-chambers-lifecycle.test.js` - -## Phase 19 — Chamber detail projections (DONE) - -Goal: make `GET /api/chambers/:id` a true projection from canonical state (no chamber read-model drift). - -Deliverables: - -- Project proposal status list from canonical proposals: - - pool → upcoming - - vote → live - - build → ended (meta “Formation” vs “Passed” depends on `formationEligible`) -- Project chamber roster from canonical chamber memberships + genesis members: - - specialization chamber roster = members for that chamber + genesis members for that chamber - - General chamber roster = union of all memberships + genesis members -- Keep threads/chat placeholders as empty arrays (until the forum model exists). - -Tests: - -- `GET /api/chambers/:id` returns roster derived from memberships/genesis. - -Current status: - -- `GET /api/chambers/:id` is projected from canonical stores: - - proposals come from canonical `proposals` (pool → upcoming, vote → live, build → ended) - - roster comes from canonical `chamber_memberships` plus `genesisChamberMembers` - - General roster is the union of all chamber memberships plus all genesis members -- Tests: - - `tests/api-chamber-detail-projection.test.js` - -## Phase 20 — Dissolved chamber enforcement (DONE) - -Goal: define and enforce what “dissolved chamber” means for writes in v1. - -v1 rule: - -- dissolved chambers are not selectable for new proposal submissions -- proposals created before dissolution can finish their lifecycle (votes allowed) - -Deliverables: - -- `proposal.submitToPool` rejects drafts targeting a dissolved chamber. -- `chamber.vote` rejects votes only for the (should-not-exist) case where a proposal was created after the chamber was dissolved. -- Preserve history: dissolved chambers remain in canonical storage and can still be referenced by old proposals. - -Tests: - -- cannot submit a new proposal into a dissolved chamber -- voting remains possible on pre-existing proposals created before dissolution -- voting is rejected for proposals created after dissolution (defensive invariant) - -Current status: - -- Enforcement is implemented in `api/routes/command.ts`: - - `proposal.submitToPool` returns: - - `400` `invalid_chamber` when `draft.chamberId` is unknown - - `409` `chamber_dissolved` when the chamber exists but is not active - - `chamber.vote` returns `409` `chamber_dissolved` when `proposal.createdAt > chamber.dissolvedAt` -- Tests: - - `tests/api-chamber-dissolution.test.js` - -## Phase 21 — Chambers directory projections (pipeline/stats) (DONE) - -Goal: ensure `GET /api/chambers` is a stable projection of canonical state across both DB mode and inline mode. - -Deliverables: - -- Pipeline counts (`pool/vote/build`) projected from canonical proposals. -- Chamber stats projected from canonical state: - - governors: derived from canonical memberships + genesis members (General = union) - - ACM/LCM/MCM: derived from CM awards + multipliers. -- Support `includeDissolved=true` query param (default remains active-only). - -Tests: - -- `GET /api/chambers` returns correct pipeline/stats in inline mode with canonical proposals + CM awards. -- `includeDissolved=true` includes dissolved chambers that are excluded by default. - -Current status: - -- `api/routes/chambers/index.ts` supports `includeDissolved=true`. -- Projections are implemented in `api/_lib/chambersStore.ts` for both DB and inline mode. -- Tests: - - `tests/api-chambers-index-projection.test.js` - -## Phase 22 — Meta-governance chamber.create seeding (backend) (DONE) - -Goal: allow chamber creation to be driven by a General proposal outcome and immediately become usable (no chicken-and-egg for voting). - -Deliverables: - -- Drafts can include an optional `metaGovernance` payload describing: - - `chamber.create` (id/title/multiplier + optional `genesisMembers`) - - `chamber.dissolve` (id) -- Submission validation: - - meta-governance proposals must be in the General chamber - - create rejects existing chambers; dissolve rejects unknown/already-dissolved chambers -- On acceptance of a General `chamber.create` proposal: - - create the canonical chamber entry - - seed initial membership in `chamber_memberships` for: - - proposer address (always) - - `metaGovernance.genesisMembers` (optional) - -Tests: - -- A General `chamber.create` proposal can pass and produces a new chamber visible in `/api/chambers`. -- Seeded members can vote in the newly created chamber immediately. - -Current status: - -- Draft schema supports `metaGovernance` in `api/_lib/proposalDraftsStore.ts`. -- `proposal.submitToPool` validates meta-governance payloads in `api/routes/command.ts`. -- On acceptance, the backend seeds `chamber_memberships` for proposer + genesis members. -- Tests: - - `tests/api-command-chamber-create-members.test.js` - -## Phase 23 — Proposal drafts (UI ↔ backend) (DONE) - -Goal: make the proposal creation wizard use the real backend drafts so “drafts → submit → proposal” is end-to-end through the UI. - -Deliverables: - -- `src/pages/proposals/ProposalCreation.tsx` calls: - - `proposal.draft.save` (create/update draft) instead of only localStorage - - stores the server `draftId` locally to continue editing -- Drafts list and detail become the canonical entry point for submissions: - - drafts created in the wizard show up under `/app/proposals/drafts` - - “Submit to pool” uses `proposal.submitToPool` and navigates to `/app/proposals/:id/pp` - -Tests: - -- UI wiring is smoke-tested via API integration tests (draft save + submit already covered) plus a minimal client-side test if needed. - -Current status: - -- `src/pages/proposals/ProposalCreation.tsx` saves drafts via `proposal.draft.save` when the user is eligible and retains the `draftId` locally for continued edits. -- The wizard UI is split into `src/pages/proposals/proposalCreation/*` step components + storage/sync helpers (so the page orchestrator stays small). -- “Submit proposal” now saves (if needed) and submits via `proposal.submitToPool`, then navigates to `/app/proposals/:id/pp`. - -## Phase 24 — Meta-governance proposal type (UI) (DONE) - -Goal: expose meta-governance proposals in the proposal wizard so chambers can be created/dissolved without manual API calls. - -Deliverables: - -- Add a proposal “kind” selector: - - normal proposals (any chamber) - - General meta-governance: - - create chamber - - dissolve chamber -- Wizard writes `metaGovernance` into the draft payload and enforces the additional fields client-side. -- Meta-governance drafts can submit with zero budget items (budget is optional for system-change proposals). - -Tests: - -- UI submits a chamber.create draft that is accepted and results are visible on chambers pages. - -Current status: - -- `src/pages/proposals/proposalCreation/steps/EssentialsStep.tsx` exposes the “System change (General)” kind and collects `metaGovernance` fields. -- `draftIsSubmittable` allows meta-governance drafts to be submitted without budget items (still requires rules confirmations). -- Tests: - - `tests/api-command-meta-governance-no-budget.test.js` - -## Phase 25 — Proposal pages projected from canonical state (DONE) - -Goal: ensure `/api/proposals` and proposal pages are projections from canonical proposals + overlays (not brittle, seed-only read models). - -Deliverables: - -- `/api/proposals` list is derived from canonical proposals with live overlays for votes, formation, and era context. -- `/api/proposals/:id/pp|chamber|formation` are derived from canonical proposal payloads and normalized tables (votes/formation). -- Eliminate “page-only summary/headers drift”: proposal pages should use the same structural sections across stages, populated from the canonical proposal payload. - -Tests: - -- Proposal list and proposal pages render from canonical state in inline mode and DB mode. - -Current status: - -- Read endpoints prefer canonical proposals (`proposals` table / in-memory store) and compute live overlays from normalized tables (pool votes, chamber votes, formation). -- Proposal stage pages share a consistent header component: - - `src/components/ProposalPageHeader.tsx` - - wired into: - - `src/pages/proposals/ProposalPP.tsx` - - `src/pages/proposals/ProposalChamber.tsx` - - `src/pages/proposals/ProposalFormation.tsx` -- Tests: - - `tests/api-proposals-canonical-precedence.test.js` (canonical proposal takes precedence over seeded read models). - -## Phase 26 — Proposal history timeline (DONE) - -Goal: make proposals auditable and explainable by exposing a single “what happened” timeline. - -Deliverables: - -- Event-backed proposal history: - - submission - - pool votes + threshold met - - chamber votes + pass/fail - - formation joins + milestone actions - - court actions referencing the proposal (when applicable) -- A consistent timeline component in the UI (read-only). - -Tests: - -- Timeline output is deterministic given the same events. - -Current status: - -- Timeline events are stored in the append-only `events` table as `proposal.timeline.v1` entries keyed by proposal ID: - - `api/_lib/proposalTimelineStore.ts` - - `api/routes/proposals/[id]/timeline.ts` -- Commands append timeline events for proposal lifecycle actions (submission, votes, stage advancement, formation actions, and chamber create/dissolve side-effects): - - `api/routes/command.ts` -- Proposal pages render the timeline consistently: - - `src/components/ProposalSections.tsx` → `ProposalTimelineCard` - - `src/pages/proposals/ProposalPP.tsx` - - `src/pages/proposals/ProposalChamber.tsx` - - `src/pages/proposals/ProposalFormation.tsx` -- Tests: - - `tests/api-proposal-timeline.test.js` - -## Phase 27 — Active governance v2 (derive and persist active governor set per era) (DONE) - -Goal: define “active governor” precisely, derive it at rollup, and persist it as the canonical basis for quorums and UI denominators. - -Deliverables: - -- Define “active governor” for era N as a composition of: - - eligibility gate (active Human Node / validator address), and - - previous-era governing activity (configured per-era requirements), so the system can compute “active for next era”. -- Persist the active set size and (optionally) membership: - - `activeGovernorsBaseline` (count) becomes the era denominator source of truth. - - Optional: persist a membership list for audit/debug (not required for v2 quorums, but useful for ops). -- Ensure `POST /api/clock/rollup-era` is the single place that computes next-era baselines, and all reads consume those baselines (no divergent denominators across endpoints/pages). - -Tests: - -- Unit tests for “active governor” derivation from: - - gate status + era activity counters + requirements. -- API integration tests to ensure: - - `GET /api/clock` returns the same baseline that proposal/courts/pages use for denominators. - -Current status: - -- Era activity counters are stored per era (in-memory or Postgres): - - `api/_lib/eraStore.ts` -- `rollupEra` computes per-address status and the next-era active denominator, and persists both: - - `api/_lib/eraRollupStore.ts` writes: - - `era_rollups.active_governors_next_era` - - `era_user_status.is_active_next_era` -- “Active next era” is computed as: - - meets the configured activity requirements for the rolled-up era, AND - - is a Humanode validator (membership in `Session::Validators`) unless explicitly bypassed for local dev. -- The Humanode validator set is read via RPC: - - `api/_lib/humanodeRpc.ts` (`state_getStorage` for `Session::Validators`) -- The rollup endpoint also updates the next era’s snapshot baseline so proposal/quorum reads stay consistent: - - `api/routes/clock/rollup-era.ts` - - `api/routes/clock/tick.ts` -- Address handling is case-sensitive: - - SS58 addresses are not lowercased anywhere; addresses are treated as opaque identifiers and only `trim()` is applied. - -Tests: - -- `tests/api-era-rollup.test.js` (rollup is idempotent and computes counts) -- `tests/api-era-rollup-validator-gate.test.js` (active governors are filtered by `Session::Validators`) - -## Phase 28 — Quorum engine v2 (era-derived denominators + paper thresholds) (DONE) - -Goal: drive all quorum math from the active-governor denominator computed in Phase 27, and decide paper-alignment thresholds. - -Deliverables: - -- Make pool + chamber quorum evaluation use a single explicit denominator source per proposal stage: - - Stage-entry denominator snapshots stored in `proposal_stage_denominators` (one row per `(proposalId, stage)` for `pool` and `vote`). - - When a snapshot exists, quorum math and UI denominators use it (prevents drift when eras advance mid-stage). - - If a snapshot is missing (legacy data), fall back to the current era baseline. -- Decide and document paper-alignment knobs: - - pool attention quorum: aligned to paper `22%` (v1) - - vote window: aligned to paper `7 days` -- Ensure UI surfaces that show “X / needed” and “% / threshold%” derive from the same denominator snapshot (no mixed sources). - -Tests: - -- Unit tests for quorum math against explicit denominators (pool + chamber). -- API integration tests to ensure: - - denominators shown on proposal pages are stable across era rollups (no drift) - - stage transitions evaluate thresholds against the same denominator they display. - -Implemented: - -- `db/schema.ts` + migration: `proposal_stage_denominators` -- `api/_lib/proposalStageDenominatorsStore.ts` (DB-backed, with memory fallback when `DATABASE_URL` is not set) -- `api/routes/command.ts` captures denominators at stage entry and uses them for advancement checks -- `api/routes/proposals/*` reads prefer stage denominators for pool/vote pages and list items -- Test: `tests/api-quorum-stage-denominators.test.js` - -## Phase 29 — Delegation v1 (graph + history + chamber vote weighting) (DONE) - -Goal: implement delegation as a first-class module so chamber votes can aggregate weight, while proposal pool attention remains strictly direct. - -Deliverables: - -- `delegations` (canonical graph) + `delegation_events` (append-only history). -- Commands: - - `delegation.set` - - `delegation.clear` -- Invariants: - - no self-delegation - - no cycles - - at most one delegatee per delegator per chamber -- Chamber vote weighting: - - vote weight = `1 + delegatedVoices` (paper intent) - - delegation affects chamber vote counts/quorum math, but not pool attention mechanics. - - a delegator’s voice only counts if the delegator did **not** cast a chamber vote themselves. - -Tests: - -- Unit tests for cycle detection and vote weight aggregation. -- API integration tests for set/clear + weighted chamber vote aggregation. - -Implemented: - -- Tables + migrations: - - `db/schema.ts`: `delegations`, `delegation_events` - - `db/migrations/0019_delegations.sql` -- Store: - - `api/_lib/delegationsStore.ts` -- Commands: - - `POST /api/command`: `delegation.set`, `delegation.clear` -- Weighted chamber vote counts: - - `api/_lib/chamberVotesStore.ts` uses `delegations` to compute weighted `{ yes, no, abstain }` when `chamberId` is known. -- Tests: - - `tests/delegations-cycle.test.js` - - `tests/api-delegation-weighted-votes.test.js` - -## Phase 30 — Veto v1 (temporary slow-down + attempt limits) (DONE) - -Goal: implement a paper-aligned temporary veto slow-down that is auditable and bounded. - -Deliverables: - -- Command(s) to record veto actions and the required threshold to trigger them. -- Proposal state machine extension: - - veto sends proposal back for a cool-down window - - veto attempt count is bounded; after N approvals no veto applies. -- Timeline + feed events for veto actions. - -Tests: - -- Unit tests for veto attempt counting and state transitions. -- API integration tests for veto flows and timeline output. - -Implemented: - -- DB: - - Migration: `db/migrations/0020_veto.sql` - - Tables/columns: `db/schema.ts` (`proposals` veto fields + `veto_votes`) -- Veto council derivation (paper intent: top LCM holders): - - `api/_lib/vetoCouncilStore.ts` computes one holder per chamber (highest accumulated LCM from `cm_awards`). - - Threshold is computed as `floor(2/3*n) + 1` and snapshotted onto the proposal. -- Veto voting: - - `api/_lib/vetoVotesStore.ts` stores votes in `veto_votes` (DB mode) with a safe in-memory fallback. - - `POST /api/command`: `veto.vote` records votes, emits timeline events, and applies veto when threshold is reached. -- Stage behavior: - - When chamber vote passes, the proposal can enter a pending-veto window (`vote_passed_at` / `vote_finalizes_at`). - - `POST /api/clock/tick` finalizes to `build` once the veto window ends (if no veto was applied). - - Veto is bounded per proposal (`veto_count` max applies = 2). -- Tests: - - `tests/api-veto.test.js` - - `tests/migrations.test.js` asserts `veto_votes` table exists - -## Phase 31 — Chamber multiplier voting v1 (outside-of-chamber aggregation) (DONE) - -Goal: implement paper-aligned multiplier setting (outsiders-only aggregation) without rewriting historical CM awards. - -Deliverables: - -- Multiplier submissions table + aggregation rule (v1: simple average + rounding). -- Outsider rule enforcement (cannot submit for chambers where the address has LCM history). -- Multiplier change history events. - -Tests: - -- Unit tests for outsider eligibility and aggregation. -- API tests that multipliers affect MCM/ACM views without mutating prior award events. - -Implemented: - -- DB: - - Migration: `db/migrations/0021_chamber_multiplier_submissions.sql` - - Table: `chamber_multiplier_submissions` (one submission per `(chamber_id, voter_address)`) -- Store: - - `api/_lib/chamberMultiplierSubmissionsStore.ts` -- Command: - - `POST /api/command`: `chamber.multiplier.submit` - - outsiders-only enforcement (LCM history blocks submissions) - - aggregation rule: rounded average applied to `chambers.multiplier_times10` -- Views: - - CM awards remain immutable; ACM/MCM views are recomputed using current multipliers -- Tests: - - `tests/api-chamber-multiplier-voting.test.js` - -## Phase 32 — Paper alignment audit pass (process-by-process) - -Goal: run a deliberate paper-vs-simulation audit for every major process and reconcile docs/constants before “production-like” testing. - -Deliverables: - -- Update `docs/simulation/vortex-simulation-paper-alignment.md` with the resolved decisions. -- Update `docs/simulation/vortex-simulation-v1-constants.md` if thresholds change. -- Update UI copy/labels where the paper language is more precise. - -Tests: - -- None required (doc-only), but any behavior changes required by the audit must ship with tests in the relevant phase. - -## Phase 33 — Testing readiness v3 (scenario harness + end-to-end validation) (IN PROGRESS) - -Goal: add a deterministic, repeatable testing harness that validates the full governance loop across modules without relying on browser-driven manual testing. - -Deliverables: - -- A small set of “golden flow” scenarios, expressed as API calls: - - proposal draft → submit → pool votes → advance → chamber vote → pass/fail → (optional) Formation actions - - General meta-governance proposal that creates a specialization chamber (and grants genesis membership as configured) - - era rollup produces the next-era active-governor baseline used in quorum denominators - - delegation impacts chamber vote weighting (but not pool attention mechanics) - - veto sends an accepted proposal back through the bounded slow-down flow - - multiplier voting affects MCM/ACM views without rewriting award events - - MM updates on Formation delivery scoring and appears in My Governance/Invision -- Optional: a scriptable seed “scenario pack” for manual UI verification in DB mode (kept separate from production seed). - -Tests: - -- Add scenario-based integration tests that: - - set up a minimal DB state - - execute command sequences - - assert invariants and derived values at each step (statuses, denominators, stage transitions, event logs). - -Current status: - -- Added a baseline scenario test for project proposals: - - `tests/scenario-governance-loop.test.js` - -## Phase 34 — Meritocratic Measure (MM) v1 (post-V3, Formation delivery scoring) - -Goal: model delivery merit earned through Formation in a way that can feed into tiers and Invision, without blocking the core governance loop testing. - -Deliverables: - -- MM events tied to Formation milestone outcomes (review scoring + aggregation). -- Per-address MM views and Invision signals. - -Tests: - -- Unit tests for MM aggregation. -- API tests for MM visibility in `GET /api/my-governance` and `GET /api/invision`. - -Proposal wizard v2 track (Phases 35–39) - -The current UI implementation supports meta-governance, but it still uses a largely “single big form” shape that mixes project fields with system-change fields. For long-term maintainability (and a cleaner chamber-creation UX), the wizard is moving to a template-driven design where proposal types have distinct step flows and payload shapes. - -Reference: - -- `docs/simulation/vortex-simulation-proposal-wizard-architecture.md` (Wizard v2 track W1–W5) - -Track summary (high-level): - -- A template runner + registry so proposal types can define their own steps. -- A dedicated `system.chamberCreate` flow that only collects fields needed to create and render a chamber. -- A discriminated union draft schema in the backend, with compatibility for legacy drafts during migration. - -Phases: - -- Phase 35–39 (Proposal wizard v2 W1–W5), as listed in the execution sequence above. - -## Phase 35 — Proposal wizard v2 W1 (template runner + registry) (DONE) - -Goal: extract the proposal creation flow into a template runner so different proposal kinds can have different step flows without turning `ProposalCreation.tsx` into a branching monolith. - -Deliverables: - -- A template registry that is safe to import from Node tests (no JSX in the template layer). -- A template runner in `src/pages/proposals/ProposalCreation.tsx` that delegates: - - step order + labels - - step-to-step navigation constraints (Next/Back) - - submit gating (`canSubmit`) -- Persist template id in local draft storage. - -Current status: - -- Template registry: - - `src/pages/proposals/proposalCreation/templates/registry.ts` - - `src/pages/proposals/proposalCreation/templates/types.ts` -- Templates implemented: - - `src/pages/proposals/proposalCreation/templates/project.ts` - - `src/pages/proposals/proposalCreation/templates/system.ts` -- Runner integration: - - `src/pages/proposals/ProposalCreation.tsx` - - local storage helpers: `src/pages/proposals/proposalCreation/storage.ts` -- Tests: - - `tests/proposal-wizard-template-registry.test.js` - -## Phase 36 — Proposal wizard v2 W2 (system.chamberCreate flow) (DONE — `system` template v1) - -Goal: make chamber creation proposals feel like system proposals (not project proposals), while still producing a payload the backend can accept today. - -Deliverables: - -- A dedicated **system** flow that: - - forces `chamberId = "general"` - - skips the Budget step - - hides project-only optional sections (timeline/outputs) for system proposals -- Keep `metaGovernance` in the draft payload so existing backend finalizers apply. - -Current status: - -- UI “Kind” selector switches the template: - - `project` (Essentials → Plan → Budget → Review) - - `system` (Setup → Rationale → Review) -- `metaGovernance` fields are still collected in Essentials (action, chamber id, title, multiplier, genesis members). - -## Phase 37 — Proposal wizard v2 W3 (backend discriminated drafts) (DONE) - -Goal: stop requiring project-oriented fields for system proposals and make backend validation match the template. - -Deliverables: - -- Add a discriminant to the stored draft payload (`templateId`) and validate as a union. -- Separate required fields per draft kind: - - `project`: keeps project-required text + budget checks. - - `system`: requires system action fields; project-only fields can be omitted. -- Normalize missing system fields to defaults so payloads remain stable for existing UI readers. - -Current status: - -- `proposalDraftFormSchema` is now a template-aware discriminated union with preprocessing: - - `project` vs `system` templates - - template inference when `templateId` is missing - - defaults applied for optional system fields -- Draft storage normalizes payloads via the schema so later consumers always see consistent arrays/strings. -- Tests: - - `tests/api-command-system-draft-minimal.test.js` - -## Phase 38 — Proposal wizard v2 W4 (migrate drafts + simplify validation) (DONE) - -Goal: migrate stored drafts (DB + local) so the UI and backend no longer carry legacy branches. - -Deliverables: - -- Migration strategy: - - Map old drafts to `project` by default. - - Map drafts with `metaGovernance` to `system`. -- Simplify template logic by removing “mixed” validation branches. - -Tests: - -- Migration tests and a small “legacy draft still loads” smoke check. - -Current status: - -- Draft payloads are normalized on read: - - DB: `listDrafts`/`getDraft` backfill `templateId` when missing. - - Memory: legacy payloads are normalized and cached. -- Project wizard validation no longer handles system proposals. -- Tests: - - `tests/proposal-draft-migration.test.js` - -## Phase 39 — Proposal wizard v2 W5 (cleanup + extension points) (DONE) - -Goal: keep the wizard extensible without reintroducing branching logic everywhere. - -Deliverables: - -- Add extension points for additional system actions without inflating the project flow. -- Keep system-specific fields out of the project flow. - -Tests: - -- Wizard system template validation (project fields are no longer required). - -Current status: - -- System action metadata is centralized in `systemActions.ts`. -- System proposals no longer require project-only fields (`what/why`). -- System review summary renders only system-specific fields. -- Tests: - - `tests/proposal-wizard-system-template.test.js` diff --git a/docs/simulation/vortex-simulation-local-dev.md b/docs/simulation/vortex-simulation-local-dev.md deleted file mode 100644 index d0c2798..0000000 --- a/docs/simulation/vortex-simulation-local-dev.md +++ /dev/null @@ -1,179 +0,0 @@ -# Vortex Simulation Backend — Local Dev (Node API runner + UI proxy) - -## Endpoints (current skeleton) - -- `GET /api/health` -- `POST /api/auth/nonce` → `{ address }` → `{ nonce }` (sets `vortex_nonce` cookie) -- `POST /api/auth/verify` → `{ address, nonce, signature }` (sets `vortex_session` cookie) -- `POST /api/auth/logout` -- `GET /api/me` -- `GET /api/gate/status` -- Read endpoints (Phase 2c/4 bridge; backed by `read_models`): - - `GET /api/chambers` - - `GET /api/chambers/:id` - - `GET /api/proposals?stage=...` - - `GET /api/proposals/:id/pool` - - `GET /api/proposals/:id/chamber` - - `GET /api/proposals/:id/formation` - - `GET /api/proposals/drafts` - - `GET /api/proposals/drafts/:id` - - `GET /api/courts` - - `GET /api/courts/:id` - - `GET /api/humans` - - `GET /api/humans/:id` - - `GET /api/factions` - - `GET /api/factions/:id` - - `GET /api/formation` - - `GET /api/invision` -- `GET /api/my-governance` -- `GET /api/clock` (simulation time snapshot) -- `POST /api/clock/advance-era` (admin-only; increments era by 1) -- `POST /api/clock/rollup-era` (admin-only; computes next-era active set + tier statuses) -- `POST /api/admin/users/lock` (admin-only; temporarily disables writes for an address) -- `POST /api/admin/users/unlock` (admin-only) -- `GET /api/admin/users/locks` (admin-only) -- `GET /api/admin/users/:address` (admin-only) -- `GET /api/admin/audit` (admin-only) -- `GET /api/admin/stats` (admin-only) -- `POST /api/admin/writes/freeze` (admin-only; toggles global write freeze) -- `POST /api/command` (write commands; gated) - -## Required env vars - -These env vars are read by the API runtime (API handlers in production, Node runner locally). - -- `SESSION_SECRET` (required): used to sign `vortex_nonce` and `vortex_session` cookies. -- `DATABASE_URL` (required for persistence): Postgres connection string (v1 expects Neon-compatible serverless Postgres). -- `ADMIN_SECRET` (required for admin endpoints): must be provided via `x-admin-secret` header (unless `DEV_BYPASS_ADMIN=true`). -- Humanode mainnet RPC URL can be configured in either place: - - `HUMANODE_RPC_URL` (recommended for deployments), or - - `public/sim-config.json` via `humanodeRpcUrl` (repo-configured runtime value). - -For convenience, this repo ships with a default `humanodeRpcUrl` pointing to the public Humanode mainnet explorer RPC. - -Local dev note: - -- When using the Node API runner (`yarn dev:api` / `yarn dev:full`), the API server exposes `GET /sim-config.json` by reading `public/sim-config.json`, so real gating works without setting `HUMANODE_RPC_URL`. - -- Chamber voting bootstrap (optional): - - `public/sim-config.json` → `genesisChamberMembers` can list initial eligible voters per `chamberId` (including `general`). - - This is needed to allow the first specialization chamber votes before anyone has an accepted proposal. - -- Chambers bootstrap (recommended): - - `public/sim-config.json` → `genesisChambers` defines the initial chamber set (id/title/multiplier). - - The backend auto-seeds these into the canonical `chambers` table when the table is empty. - - Default in this repo: only the **General** chamber is seeded; specialization chambers are expected to be created via proposals. - -- `SIM_ACTIVE_GOVERNORS` (optional): active governors baseline used for quorum math (defaults to `150`). -- `SIM_REQUIRED_POOL_VOTES` (optional): per-era required pool actions (defaults to `1`). -- `SIM_REQUIRED_CHAMBER_VOTES` (optional): per-era required chamber actions (defaults to `1`). -- `SIM_REQUIRED_COURT_ACTIONS` (optional): per-era required court actions (defaults to `0`). -- `SIM_REQUIRED_FORMATION_ACTIONS` (optional): per-era required formation actions (defaults to `0`). -- Era baseline updates: `/api/clock/rollup-era` sets the next era’s `activeGovernors` baseline from rollup results (`activeGovernorsNextEra`). -- `SIM_COMMAND_RATE_LIMIT_PER_MINUTE_IP` (optional): per-minute IP limit for `POST /api/command` (defaults to `180`). -- `SIM_COMMAND_RATE_LIMIT_PER_MINUTE_ADDRESS` (optional): per-minute address limit for `POST /api/command` (defaults to `60`). -- `SIM_MAX_POOL_VOTES_PER_ERA` (optional): maximum counted pool actions per era per address (unset/0 = unlimited). -- `SIM_MAX_CHAMBER_VOTES_PER_ERA` (optional): maximum counted chamber vote actions per era per address (unset/0 = unlimited). -- `SIM_MAX_COURT_ACTIONS_PER_ERA` (optional): maximum counted court actions per era per address (unset/0 = unlimited). -- `SIM_MAX_FORMATION_ACTIONS_PER_ERA` (optional): maximum counted formation actions per era per address (unset/0 = unlimited). -- `SIM_WRITE_FREEZE` (optional): if `true`, blocks all `POST /api/command` writes regardless of admin state (deploy-time kill switch). -- Phase 16 automation and time windows: - - `SIM_ERA_SECONDS` (optional): tick “due” threshold in seconds (defaults to 7 days). - - `SIM_ENABLE_STAGE_WINDOWS` (optional): when `true`, enforce per-stage pool/vote windows and compute `timeLeft` from canonical timestamps. - - `SIM_POOL_WINDOW_SECONDS` (optional): pool stage window in seconds (defaults to 7 days). - - `SIM_VOTE_WINDOW_SECONDS` (optional): vote stage window in seconds (defaults to 7 days). - - `SIM_NOW_ISO` (optional): override “current time” for test/debug. - -## Frontend build flags - -- `VITE_SIM_AUTH` controls the sidebar wallet panel + client-side gating UI. - - Default: enabled (set `VITE_SIM_AUTH=false` to disable). - - Requires a Substrate wallet browser extension (polkadot{.js}) for message signing with Humanode (HMND) SS58 addresses. - -## Dev-only toggles - -- `DEV_BYPASS_SIGNATURE=true` to accept any signature (demo/dev mode). -- `DEV_BYPASS_GATE=true` to mark any signed-in user as eligible (demo/dev mode). -- `DEV_BYPASS_CHAMBER_ELIGIBILITY=true` to skip chamber membership checks for `chamber.vote` (demo/dev mode). -- `DEV_ELIGIBLE_ADDRESSES=addr1,addr2,...` allowlist for eligibility when `DEV_BYPASS_GATE` is false. -- `DEV_INSECURE_COOKIES=true` to allow auth cookies over plain HTTP (local dev only). -- `READ_MODELS_INLINE=true` to serve read endpoints from the in-repo seed builder (no DB required). -- `READ_MODELS_INLINE_EMPTY=true` to force an empty read-model store (useful for “clean UI” local dev without touching a DB). -- `DEV_BYPASS_ADMIN=true` to allow admin endpoints locally without `ADMIN_SECRET`. -- `SIM_CONFIG_JSON='{"humanodeRpcUrl":"...","genesisChamberMembers":{"engineering":["5..."]}}'` to override `/sim-config.json` in tests or local dev. - -## Running locally (recommended) - -### Option A (one command) - -- `yarn dev:full` (starts a local API server on `:8788`, starts the app on rsbuild dev, and proxies `/api/*`). - -### Option B (two terminals) - -**Terminal 1 (API)** - -1. Start the local API server (default port `8788`): - -`yarn dev:api` - -`yarn dev:api` starts with real signature verification and real gating by default. For a quick demo mode: - -- `DEV_BYPASS_SIGNATURE=true DEV_BYPASS_GATE=true yarn dev:api` - -**Terminal 2 (UI)** - -2. Run the UI with a dev-server proxy to the API: - -`yarn dev` - -Open the provided local URL and call endpoints under `/api/*`. - -Notes: - -- `yarn dev` proxies `/api/*` to `http://127.0.0.1:8788` (config: `rsbuild.config.ts`). -- If you see `ECONNREFUSED` in the UI dev server logs, the backend is not running on `:8788` (start it with `yarn dev:api`). -- Real gating uses `DEV_BYPASS_GATE=false` and a configured Humanode mainnet RPC URL (env var or `public/sim-config.json`). -- The Node API runner defaults to **empty read models** when `DATABASE_URL` is not set (the UI should show “No … yet” on content pages). -- To use the seeded fixtures locally (no DB), run with `READ_MODELS_INLINE=true`. -- To force empty reads even if something is seeding locally, run with `READ_MODELS_INLINE_EMPTY=true`. - -## Production deploy notes - -API handlers read environment variables at runtime. If `DATABASE_URL` is not set, the API runs in an **ephemeral in-memory mode** (useful for quick demos, not durable). - -For a persistent public demo: - -1. Provision a serverless Postgres database (Neon is the v1 target). -2. Set runtime environment variables: - - `DATABASE_URL` - - `SESSION_SECRET` - - `ADMIN_SECRET` (if admin endpoints are used) -3. Run migrations against the DB: - - `yarn db:migrate` - -## Type checking - -- UI + client types: `yarn exec tsc -p tsconfig.json --noEmit` -- API handlers: `yarn exec tsc -p api/tsconfig.json --noEmit` - -## DB (Phase 2c) - -DB setup uses the read-model bridge seeded from `db/seed/fixtures/*`: - -- Generate migrations: `yarn db:generate` -- Apply migrations: `yarn db:migrate` (requires `DATABASE_URL`) -- Seed into `read_models` and the `events` table: `yarn db:seed` (requires `DATABASE_URL`) - - Also truncates `chambers`, `chamber_memberships`, `pool_votes`, `chamber_votes`, `cm_awards`, `idempotency_keys`, Formation tables, Courts tables, and Era tables so repeated seeds stay deterministic. - -### Clearing all data (keep schema) - -To wipe the simulation data without dropping tables: - -- `yarn db:clear` (requires `DATABASE_URL`) - -This truncates the simulation tables and leaves the schema/migrations intact. - -### Clean-by-default vs seeded content - -- Clean-by-default: run without `READ_MODELS_INLINE` and without running `yarn db:seed` (or wipe a seeded DB via `yarn db:clear`). -- Seeded content: run `yarn db:seed` (DB mode) or `READ_MODELS_INLINE=true` (no DB). diff --git a/docs/simulation/vortex-simulation-modules.md b/docs/simulation/vortex-simulation-modules.md deleted file mode 100644 index ecc5cca..0000000 --- a/docs/simulation/vortex-simulation-modules.md +++ /dev/null @@ -1,603 +0,0 @@ -# Vortex Simulation — Modules (Paper → Docs → Code) - -This document defines the major modules we are building, based on: - -- the Vortex 1.0 paper reference (`docs/paper/vortex-1.0-paper.md`) -- the simulation domain docs (`docs/simulation/vortex-simulation-processes.md`, `docs/simulation/vortex-simulation-state-machines.md`) -- the current repo implementation (frontend under `src/`, backend under `api/`, DB in `db/`). - -Goal: keep a stable long-term architecture where each module has: - -- a clear responsibility boundary -- an API surface (read endpoints and/or commands) -- canonical state (tables) + append-only history (events) -- tests that pin behavior - -## Module index - -1. Identity & Sessions (auth) -2. Eligibility Gate (mainnet read) -3. Simulation Config (runtime config + genesis bootstrap) -4. Clock & Era Accounting (simulation time) -5. Quorum Engine (pool + chamber vote math) -6. Proposals (drafts → pool → vote → build) -7. Chambers (catalog + membership + meta-governance) -8. Cognitocratic Measure (CM) (LCM/MCM/ACM projection) -9. Formation (execution layer) -10. Courts (disputes) -11. Feed & Events (audit + activity stream) -12. Invision (insights) -13. Human Profiles (directory + detail) -14. Admin & Safety Controls (public demo hardening) -15. Tiers & Proposition Rights (governor ladder) -16. Delegation (voice delegation + weighting) -17. Veto (temporary slow-down) -18. Chamber Multiplier Voting (outside-of-chamber aggregation) -19. Meritocratic Measure (MM) (Formation delivery merit) - -Where possible we keep “domain logic” in pure helpers under `api/_lib/*` so it can later be extracted into a shared domain package. - ---- - -## 1) Identity & Sessions (auth) - -**Paper intent** - -- Prove address control to perform governance actions. - -**Backend** - -- Endpoints: - - `POST /api/auth/nonce` - - `POST /api/auth/verify` - - `POST /api/auth/logout` - - `GET /api/me` -- Core files: - - `api/_lib/auth.ts`, `api/_lib/nonceStore.ts` - - `api/_lib/signatures.ts`, `api/_lib/tokens.ts`, `api/_lib/cookies.ts` - - `api/routes/auth/*.ts`, `api/routes/me.ts` -- State: - - cookie-backed nonce + session - -**Frontend** - -- Core files: - - `src/app/auth/AuthContext.tsx` - - `src/lib/polkadotExtension.ts` - - `src/lib/apiClient.ts` - -**Tests** - -- `tests/api-auth-nonce.test.js` -- `tests/api-auth-signature.test.js` -- `tests/api-auth.test.js` -- `tests/auth-ui-connect-errors.test.js` - ---- - -## 2) Eligibility Gate (mainnet read) - -**Paper intent** - -- Only real Humanode participants should be able to take actions. - -**Simulation v1 rule** - -- Eligible if the address is in Humanode mainnet `Session::Validators`. - -**Backend** - -- Endpoint: - - `GET /api/gate/status` -- Core files: - - `api/_lib/gate.ts`, `api/_lib/humanodeRpc.ts`, `api/_lib/simConfig.ts` - - `api/routes/gate/status.ts` -- State: - - cached eligibility (DB mode) + TTL; falls back to memory in non-DB mode - -**Frontend** - -- Status is consumed by the auth UI and used to gate UI actions. - -**Tests** - -- `tests/api-gate.test.js` -- `tests/api-gate-rpc.test.js` - ---- - -## 3) Simulation Config (runtime config + genesis bootstrap) - -**Paper intent** - -- Genesis membership exists (initial governors and initial chambers). - -**Backend** - -- Source: - - `/sim-config.json` (`public/sim-config.json`) -- Core files: - - `api/_lib/simConfig.ts` -- Responsibilities: - - provide a runtime Humanode RPC URL fallback - - seed the initial chamber set (when empty) - - provide genesis chamber members for early voting eligibility - -**Frontend** - -- Reads `/sim-config.json` implicitly through backend behavior (not directly). - ---- - -## 4) Clock & Era Accounting (simulation time) - -**Paper intent** - -- Only active governors are counted in quorum baselines; participation matters across time windows. - -**Backend** - -- Endpoints: - - `GET /api/clock` - - `POST /api/clock/tick` - - `POST /api/clock/advance-era` - - `POST /api/clock/rollup-era` -- Core files: - - `api/_lib/clockStore.ts`, `api/_lib/eraStore.ts`, `api/_lib/eraRollupStore.ts` - - `api/_lib/eraQuotas.ts`, `api/_lib/stageWindows.ts`, `api/_lib/v1Constants.ts` - - `api/routes/clock/*.ts` -- State: - - current era, per-era activity counters, era rollup results, next-era baselines - -**Frontend** - -- Used by “My Governance” and for quorum denominators on proposal pages. -- Pages: - - `src/pages/MyGovernance.tsx` - -**Tests** - -- `tests/api-era-activity.test.js` -- `tests/api-era-rollup.test.js` -- `tests/api-my-governance-rollup.test.js` -- `tests/api-stage-windows.test.js` -- `tests/api-clock-tick.test.js` - ---- - -## 5) Quorum Engine (pool + chamber vote math) - -**Paper intent** - -- Pool: quorum of attention (engagement + upvote floor). -- Vote: quorum of vote + passing threshold. -- Delegation affects vote weight (not implemented in v1). - -**Backend** - -- Core files: - - `api/_lib/poolQuorum.ts` - - `api/_lib/chamberQuorum.ts` - - `api/_lib/proposalStateMachine.ts` - - `api/_lib/v1Constants.ts` -- Note: - - The quorum engine is driven by an era-specific “active governors baseline”, then filtered to the proposal’s chamber eligibility set (General = any governor; specialization = members eligible for that chamber). - -**Tests** - -- `tests/pool-quorum.test.js` -- `tests/chamber-quorum.test.js` -- `tests/proposal-stage-transition.test.js` - ---- - -## 6) Proposals (drafts → pool → vote → build) - -**Paper intent** - -- Proposal pool filters attention; chamber vote decides acceptance; Formation is optional for execution. - -**Backend** - -- Read endpoints: - - `GET /api/proposals` - - `GET /api/proposals/:id/pool` - - `GET /api/proposals/:id/chamber` - - `GET /api/proposals/:id/formation` - - `GET /api/proposals/:id/timeline` - - `GET /api/proposals/drafts` - - `GET /api/proposals/drafts/:id` -- Write path: - - `POST /api/command` - - `proposal.draft.save` - - `proposal.draft.delete` - - `proposal.submitToPool` - - `pool.vote` - - `chamber.vote` -- Core files: - - `api/routes/proposals/*` - - `api/routes/command.ts` - - `api/_lib/proposalDraftsStore.ts`, `api/_lib/proposalsStore.ts` - - `api/_lib/poolVotesStore.ts`, `api/_lib/chamberVotesStore.ts` - - `api/_lib/proposalProjector.ts`, `api/_lib/proposalTimelineStore.ts` -- State: - - canonical `proposals` + `proposal_drafts` - - votes tables - - timeline events in `events` (`proposal.timeline.v1`) - -**Frontend** - -- Pages: - - `src/pages/proposals/Proposals.tsx` - - `src/pages/proposals/ProposalCreation.tsx` - - `src/pages/proposals/ProposalDrafts.tsx` - - `src/pages/proposals/ProposalDraft.tsx` - - `src/pages/proposals/ProposalPP.tsx` - - `src/pages/proposals/ProposalChamber.tsx` - - `src/pages/proposals/ProposalFormation.tsx` -- Shared UI: - - `src/components/ProposalPageHeader.tsx` - - `src/components/ProposalSections.tsx` - - `src/lib/apiClient.ts` - -**Wizard architecture** - -- v1 uses a “single big form” draft payload with optional `metaGovernance`. -- Planned (v2+): migrate to a template-driven wizard (project vs system flows) with a discriminated draft schema: - - `docs/simulation/vortex-simulation-proposal-wizard-architecture.md` - -**Tests** - -- `tests/api-command-drafts.test.js` -- `tests/api-command-pool-vote.test.js` -- `tests/api-command-chamber-vote.test.js` -- `tests/api-proposals-canonical-precedence.test.js` -- `tests/api-proposal-timeline.test.js` - ---- - -## 7) Chambers (catalog + membership + meta-governance) - -**Paper intent** - -- General + specialization chambers; chamber creation/dissolution is proposal-driven. - -**Simulation v1 rule** - -- Chamber creation/dissolution is modeled as a meta-governance proposal outcome in the General chamber. -- Voting eligibility is earned by accepted proposals (paper-aligned eligibility rule). - -**Backend** - -- Endpoints: - - `GET /api/chambers` - - `GET /api/chambers/:id` -- Core files: - - `api/_lib/chambersStore.ts` - - `api/_lib/chamberMembershipsStore.ts` - - `api/routes/chambers/*` - - `api/routes/command.ts` (meta-governance side-effects) -- State: - - `chambers` (canonical) - - `chamber_memberships` (eligibility) - -**Frontend** - -- Pages: - - `src/pages/chambers/Chambers.tsx` - - `src/pages/chambers/Chamber.tsx` - -**Tests** - -- `tests/api-chambers-lifecycle.test.js` -- `tests/api-chamber-eligibility.test.js` -- `tests/api-chamber-dissolution.test.js` -- `tests/api-chambers-index-projection.test.js` -- `tests/api-chamber-detail-projection.test.js` - ---- - -## 8) Cognitocratic Measure (CM) (LCM/MCM/ACM projection) - -**Paper intent** - -- Yes voters submit an additional numeric score; proposer receives CM based on the average. -- Multipliers map chamber value to global contribution; ACM is aggregate. - -**Simulation v1** - -- CM is awarded once per passed proposal via yes-vote score average. -- Multipliers are configured on canonical chambers (no multiplier voting yet). - -**Backend** - -- Core files: - - `api/_lib/cmAwardsStore.ts` - - `api/routes/command.ts` (award on pass) - -**Tests** - -- `tests/chamber-votes-score.test.js` - ---- - -## 9) Formation (execution layer) - -**Paper intent** - -- Formation is an execution layer; any bioauthorized human node can participate. - -**Simulation v1** - -- Formation is optional per proposal (`formationEligible`). - -**Backend** - -- Endpoint: - - `GET /api/formation` - - `GET /api/proposals/:id/formation` -- Commands: - - `formation.join` - - `formation.milestone.submit` - - `formation.milestone.requestUnlock` -- Core files: - - `api/_lib/formationStore.ts` - - `api/routes/formation/index.ts` - -**Frontend** - -- Pages: - - `src/pages/formation/Formation.tsx` - - proposal stage pages render Formation sections - -**Tests** - -- `tests/api-command-formation.test.js` - ---- - -## 10) Courts (disputes) - -**Paper reference** - -- Courts and disputes are described in `docs/paper/vortex-1.0-paper.md` (working reference copy, with an added section). - -**Backend** - -- Endpoints: - - `GET /api/courts` - - `GET /api/courts/:id` -- Commands: - - `court.case.report` - - `court.case.verdict` -- Core files: - - `api/_lib/courtsStore.ts` - - `api/routes/courts/*` - -**Frontend** - -- Pages: - - `src/pages/courts/Courts.tsx` - - `src/pages/courts/Courtroom.tsx` - -**Tests** - -- `tests/api-command-courts.test.js` - ---- - -## 11) Feed & Events (audit + activity stream) - -**Paper intent** - -- “Constant deterrence” requires transparency and auditability. - -**Backend** - -- Endpoint: - - `GET /api/feed` -- Core files: - - `api/_lib/eventsStore.ts`, `api/_lib/eventSchemas.ts` - - `api/_lib/feedEventProjector.ts` - - `api/_lib/appendEvents.ts` -- State: - - append-only `events` table - -**Frontend** - -- Page: - - `src/pages/feed/Feed.tsx` - -**Tests** - -- `tests/api-feed.test.js` -- `tests/feed-event-projector.test.js` -- `tests/events-seed.test.js` - ---- - -## 12) Invision (insights) - -**Paper reference** - -- The paper motivates transparency/deterrence; “Invision” is our name for the insights surface in the UI and is described in `docs/paper/vortex-1.0-paper.md` (working reference copy, with an added section). - -**Backend** - -- Endpoint: - - `GET /api/invision` -- Core files: - - `api/routes/invision/index.ts` - -**Frontend** - -- Page: - - `src/pages/invision/Invision.tsx` - ---- - -## 13) Human Profiles (directory + detail) - -**Backend** - -- Endpoints: - - `GET /api/humans` - - `GET /api/humans/:id` - - `GET /api/my-governance` -- Core files: - - `api/routes/humans/*` - - `api/routes/my-governance/index.ts` - - `api/_lib/userStore.ts` - -**Frontend** - -- Pages: - - `src/pages/human-nodes/HumanNodes.tsx` - - `src/pages/human-nodes/HumanNode.tsx` - - `src/pages/MyGovernance.tsx` - ---- - -## 14) Admin & Safety Controls (public demo hardening) - -**Backend** - -- Endpoints: - - `GET /api/admin/stats` - - `GET /api/admin/audit` - - `GET /api/admin/users/:address` - - `GET /api/admin/users/locks` - - `POST /api/admin/users/lock` - - `POST /api/admin/users/unlock` - - `POST /api/admin/writes/freeze` -- Core files: - - `api/_lib/apiRateLimitStore.ts` - - `api/_lib/idempotencyStore.ts` - - `api/_lib/actionLocksStore.ts` - - `api/_lib/adminAuditStore.ts` - - `api/_lib/adminStateStore.ts` - -**Tests** - -- `tests/api-admin-tools.test.js` -- `tests/api-admin-write-freeze.test.js` -- `tests/api-command-rate-limit.test.js` -- `tests/api-command-action-lock.test.js` -- `tests/api-command-era-quotas.test.js` - ---- - -## 15) Tiers & Proposition Rights (governor ladder) - -**Paper intent** - -- Proposition rights are not equal across all governors; they are tied to tiers and merit (PoT/PoD/PoG–like paths). -- Tiers do not change the “1 human = 1 vote” invariant; they change what can be proposed. - -**Simulation v1** - -- Tier labels and status buckets are surfaced in the UI and derived through era rollups. -- Proposition rights are not fully enforced across all proposal types yet (v2+ hardening). - -**Backend** - -- Endpoints: - - `GET /api/my-governance` - - `POST /api/clock/rollup-era` -- Core files: - - `api/_lib/eraRollupStore.ts` - - `api/_lib/eraQuotas.ts` - - `api/routes/my-governance/index.ts` - -**Frontend** - -- Page: - - `src/pages/MyGovernance.tsx` - -**Tests** - -- `tests/api-my-governance-rollup.test.js` -- `tests/api-era-rollup.test.js` - ---- - -## 16) Delegation (voice delegation + weighting) - -**Paper intent** - -- Delegation exists and affects vote aggregation (delegatee voting power grows with delegations). - -**Simulation status** - -Implemented in v1: - -- Delegation graph + invariants (no cycles, no self-delegation), chamber-scoped. -- Delegation history events (auditable). -- Chamber vote weight aggregation: - - vote weight = `1 + delegatedVoices` - - a delegator’s voice only counts if the delegator did **not** cast a chamber vote themselves - - delegation affects chamber voting only; pool attention remains direct-only. - ---- - -## 17) Veto (temporary slow-down) - -**Paper intent** - -- Veto exists as a temporary slow-down mechanism, tied to top LCM holders per chamber. - -**Simulation status** - -Implemented in v1: - -- When a chamber vote passes, the proposal can enter a **veto window** (instead of advancing immediately). -- Veto holders are derived from CM data: - - One holder per chamber: the address with the highest accumulated **LCM** in that chamber (from `cm_awards`). - - The veto council is snapshotted onto the proposal at vote-pass time (`proposals.veto_council`). -- Threshold: - - `floor(2/3 * councilSize) + 1` veto votes are required. -- If the threshold is met during the veto window: - - chamber votes are cleared - - veto votes are cleared - - proposal `veto_count` increments - - voting is paused for `2 weeks` and then re-opens (proposal `updated_at` is set to the re-open time). -- If the veto window ends without a veto: - - the proposal is finalized and advanced to `build` by `POST /api/clock/tick`. -- Max veto applies per proposal: `2` (after that, vote-pass finalizes immediately). - ---- - -## 18) Chamber Multiplier Voting (outside-of-chamber aggregation) - -**Paper intent** - -- Chamber multipliers should be set by cognitocrats outside the chamber (scale 1–100). - -**Simulation status** - -Implemented in v1: - -- Outsider submissions are stored in `chamber_multiplier_submissions` (one per `(chamber_id, voter_address)`). -- Command: `POST /api/command` → `chamber.multiplier.submit`. -- Outsider rule enforcement: - - an address cannot submit for a chamber where it has LCM history (`cm_awards` as proposer). -- Aggregation rule (v1): rounded average of submissions is applied to `chambers.multiplier_times10`. -- CM award history remains immutable; ACM/MCM views are computed using the current chamber multipliers. - ---- - -## 19) Meritocratic Measure (MM) (Formation delivery merit) - -**Paper intent** - -- MM represents delivery merit earned through Formation participation. - -**Simulation status** - -- Not implemented as a first-class subsystem in v1 (the UI can still show Formation progress). - -Planned deliverables (v2+): - -- MM events tied to Formation milestone outcomes -- aggregation into per-address MM views -- Invision signals that incorporate MM without changing voting power diff --git a/docs/simulation/vortex-simulation-paper-alignment.md b/docs/simulation/vortex-simulation-paper-alignment.md deleted file mode 100644 index 5f54aed..0000000 --- a/docs/simulation/vortex-simulation-paper-alignment.md +++ /dev/null @@ -1,171 +0,0 @@ -# Vortex Simulation — Alignment With Vortex 1.0 Paper (Audit Notes) - -This document compares: - -- `docs/paper/vortex-1.0-paper.md` (working reference copy) and -- the simulation docs (`docs/simulation/vortex-simulation-*.md`) and -- the current implementation (`api/`, `db/schema.ts`, `src/`). - -Goal: make it explicit what is **paper-aligned**, what is **deliberately simplified in v1**, and what is **not implemented yet**. - -## Summary (high-signal) - -- Proposal pool attention quorum is **paper: 22% engaged + ≥10% upvotes** vs **simulation v1: 22% engaged + ≥10% upvotes**. -- Chamber vote quorum is **paper: 33%** and **simulation v1: 33%** (aligned). -- Passing rule is **paper: 66.6% + 1** vs **simulation v1: 66.6% + 1** (aligned; strict supermajority). -- Vote weight via delegation is **paper: yes (governor power = 1 + delegations)** and **simulation v1: implemented for chamber vote weighting** (pool attention remains direct-only). -- Veto is **paper: yes** vs **simulation v1: implemented** (temporary slow-down with bounded applies). -- Chamber multiplier voting is **paper: yes (1–100, set by outsiders)** vs **simulation v1: implemented** (outsider submissions + aggregation updates canonical multipliers). -- Stage windows are **paper: vote stage = 1 week** vs **simulation v1: pool = 7 days, vote = 7 days (defaults; configurable)**. - -## Detailed comparison - -### Chambers - -**Paper** - -- Two chamber types: General Chamber (GC) + Specialization Chambers (SC). -- Chamber inception/dissolution is proposal-driven. -- Paper describes both SC-driven and GC-driven dissolution, including a “vote of censure” variant. - -**Simulation v1 (current implementation)** - -- Canonical chambers exist in `db/schema.ts` as `chambers` with `status = active | dissolved`. -- Chambers are seeded from `/sim-config.json` (`public/sim-config.json`) when the DB table is empty. -- Chamber create/dissolve exists as a **meta-governance proposal** action and is enforced as **General-only**: - - `api/routes/command.ts` rejects meta-governance proposals unless `chamberId === "general"`. -- Dissolution is **General-only** (v1 rule) and does not delete history. - -**Not yet modeled (paper)** - -- SC-side dissolution flows and censure exclusions (“target chamber members not counted in quorum”). -- Chamber “sub-chambers” are removed from the paper reference copy by design decision (not in v1). - -### Proposal pools (quorum of attention) - -**Paper** - -- Proposal pool is an attention filter: - - “upvotes or downvotes from 22% of active governors”, and - - “not less than 10% of upvotes”. -- Delegated votes are not counted in proposal pools. - -**Simulation v1** - -- Quorum math is implemented in `api/_lib/poolQuorum.ts`: - - `V1_POOL_ATTENTION_QUORUM_FRACTION = 0.22` (22%) - - `V1_POOL_UPVOTE_FLOOR_FRACTION = 0.1` (10%) -- Pool voting is restricted to governors (addresses with at least one accepted proposal in any chamber). -- Delegation exists but is not applied to proposal-pool attention (pool votes remain direct-only, paper intent). - -**Paper divergence (explicit)** - -- Paper uses 22% attention; v1 simulation uses 22% attention. - -### Chamber vote (quorum of vote + passing) - -**Paper** - -- Quorum: 33% of active governors vote. -- Passing: qualified majority “66.6% + 1” of cast votes (including delegated ones). - -**Simulation v1** - -- Quorum math is implemented in `api/_lib/chamberQuorum.ts`: - - `V1_CHAMBER_QUORUM_FRACTION = 0.33` - - `V1_CHAMBER_PASSING_FRACTION = 2/3` (66.6%), applied as a strict “66.6% + 1 yes vote” rule -- Delegation is implemented and affects chamber vote aggregation: - - vote weight = `1 + delegatedVoices` - - a delegator’s voice only counts if that delegator did **not** cast a chamber vote themselves. - -### Delegation - -**Paper** - -- Delegation exists and affects vote power aggregation: - - governor power equals `1 + number_of_delegations`. -- Delegation is chamber-scoped: governors delegate within the same chamber. - -**Simulation v1** - -- Delegation graph + history are implemented: - - `delegations` + `delegation_events` tables - - commands: `delegation.set`, `delegation.clear` -- Delegation affects chamber vote aggregation only (pool attention remains direct-only). - -### Veto - -**Paper** - -- Veto exists as a temporary slow-down mechanism. -- Veto power is tied to top LCM holders per chamber. - -**Simulation v1** - -- Implemented as a bounded “pending veto” window after a proposal passes chamber vote: - - When vote quorum + passing are met, the proposal does not advance immediately. - - The backend snapshots: - - `vote_passed_at`, `vote_finalizes_at` (veto window end), - - `veto_council` (one holder per chamber: top LCM holder), - - `veto_threshold` (`floor(2/3*n) + 1`). - - Veto votes are recorded during the window (`veto_votes` table). - - If veto threshold is reached: - - chamber votes are cleared - - veto votes are cleared - - `veto_count` increments - - voting is paused for the veto delay window and then re-opens (via a future `updated_at`). - - If the window ends without a veto: - - the accepted proposal is finalized and advances to `build` (via `POST /api/clock/tick`). - - Veto applies are bounded (`max = 2`); after that, accepted votes finalize immediately. - -### CM and multipliers - -**Paper** - -- CM is awarded when a proposition is accepted; yes voters also input a numeric score (example scale 1–10). -- Chamber multipliers are set by outsiders (example scale 1–100). -- LCM/MCM/ACM relationships are defined with ACM as Σ(LCM × multiplier). - -**Simulation v1** - -- Yes-vote scoring exists, and CM awards are computed on pass: - - `api/routes/command.ts` computes `avgScore` and awards a CM event once per proposal. - - `lcmPoints = round(avgScore * 10)`, `mcmPoints = lcmPoints * multiplier`. -- Multipliers are stored on the canonical chamber record (`multiplierTimes10`) and can be updated via outsider submissions: - - `chamber_multiplier_submissions` stores one submission per `(chamber, voter)`. - - the chamber multiplier is updated to the rounded average of all submissions. - - CM award history remains immutable; ACM/MCM views can be recomputed from `lcmPoints` and the current multipliers. - -### Formation - -**Paper** - -- Formation is an execution layer; any bioauthorized human node can participate. - -**Simulation v1** - -- Formation actions exist (`formation.join`, `formation.milestone.submit`, `formation.milestone.requestUnlock`) and are gated by “active human node” eligibility (validator set membership). - -### Courts and disputes - -**Paper** - -- Courts and disputes are described in `docs/paper/vortex-1.0-paper.md` (working reference copy, with an added section). - -**Simulation v1** - -- Courts are modeled and implemented as an off-chain dispute system with report/verdict commands and auditable case state. - -### Invision - -**Paper** - -- “Deterrence” and transparency are described conceptually; this repo’s paper reference copy also includes an “Invision” section that matches the UI’s concept. - -**Simulation v1** - -- Invision exists as a derived “system state / reputation lens” endpoint and page (`GET /api/invision`). - -## Action list (what to change next to be more paper-aligned) - -1. Review whether any other pool quorum details differ from the paper. diff --git a/docs/simulation/vortex-simulation-processes.md b/docs/simulation/vortex-simulation-processes.md deleted file mode 100644 index f9ebbee..0000000 --- a/docs/simulation/vortex-simulation-processes.md +++ /dev/null @@ -1,706 +0,0 @@ -# Vortex Simulation Backend — Processes to Model - -This document defines the domain processes the Vortex simulation backend models to match the UI mockups in this repo. This is not an on-chain implementation; it emulates “how Vortex would work” off-chain with deterministic rules + simulated time. - -For formal rules and invariants, see `docs/simulation/vortex-simulation-state-machines.md`. - -## 0.1) On-chain vs off-chain boundary (proto-vortex architecture) - -Humanode mainnet provides the **identity/eligibility gate** (read-only verification). Everything else is off-chain: - -- Off-chain: proposals, pools, votes, formation projects, courts, factions, reputation/cred, and the feed/event log. -- On-chain (read-only): whether an address is a valid/active Human Node / validator (and any relevant identity mapping and uptime signals). - -This implies an architecture with: - -- Off-chain authentication (wallet signature) + on-chain eligibility checks. -- An authoritative off-chain state machine for all governance flows. - -Implementation mapping: - -- v1 scope and what is already implemented: `docs/simulation/vortex-simulation-scope-v1.md` -- the phased build roadmap (including planned v2+): `docs/simulation/vortex-simulation-implementation-plan.md` - -## 0) Goals and non-goals - -### Goals - -- Provide a stateful backend that powers the UI pages (Feed, Proposals, Chambers, Formation, Courts, Human Nodes, My Governance, Invision, etc.). -- Model governance behavior with realistic **state transitions**, **eligibility**, and **time (epochs/eras)**. -- Produce an event stream for the Feed and per-entity histories (proposal history, court proceedings, human node activity). -- Keep all “facts” derived from canonical state (avoid UI-only hardcoded strings). -- Allow anyone to create an account, but **gate all write actions** (voting, reporting, submitting, joining, etc.) behind **active Human Node** status verified from Humanode mainnet. - -### Non-goals - -- No on-chain writes and no smart-contract integration for the simulation. -- No on-chain transactions. Wallet signatures are used **only** for authentication and gating. -- No token accounting correctness beyond the needs of the simulation UI. -- No full forum product (threads are simulated/limited). - -## 1) Core concepts (entities) - -### 1.1 Human identity + PoBU - -- **Human**: unique participant; in the real system uniqueness comes from **PoBU**. -- **Human Node**: a verified human running a node; may also be a governor if eligible. - -Simulation requirements: - -- Uniqueness constraint: one “Human” → one “active identity” at a time. -- Identity statuses: verified, pending, restricted, revoked (for courts scenarios). -- Authentication: users authenticate by signing a nonce with the **same wallet they run their Human Node with**. -- Eligibility gating: anyone can browse, but action buttons are blocked unless the user is an **active Human Node** (verified via mainnet RPC). - -#### Account vs eligibility - -- **Account**: off-chain profile used for UI personalization/history; can be created by anyone. -- **Eligible actor**: an account that is currently an active Human Node; only eligible actors may perform state-changing actions. - -Recommended modeling: - -- `user` (account): `id`, `address`, `createdAt`, profile fields. -- `eligibility` (cached claim): `address`, `isActiveHumanNode`, `checkedAt`, `source` (`rpc`), `expiresAt`, and optional reason codes. - -#### v1 eligibility source (RPC) - -For v1 we use **Humanode mainnet RPC only** (no Subscan dependency). - -Eligibility rule (v1): an address is an **active Human Node** if it is in the current validator set on Humanode mainnet (`Session::Validators`). - -Implication: - -- Browsing is open to everyone. -- Any state-changing action requires: - 1. proof of address control (wallet signature session), and - 2. a fresh “active validator” eligibility check (cached with TTL). - -Guardrails (off-chain): - -- Even eligible actors can spam. The simulation enforces basic hardening controls: - - rate limiting on write endpoints (per IP and per address), and - - per-era quotas for counted governance actions, and - - optional admin action locks that temporarily disable all writes for an address. - -### 1.2 Governance time - -- **Epoch**: Humanode Bioauth uptime accounting window. -- **Era**: Vortex governance accounting window. - -Simulation requirements: - -- A controllable clock: real-time, accelerated, or manual “advance era”. -- Snapshotting: per-era aggregates for participation, eligibility, and metrics. - -#### Epoch (Bioauth) - -Humanode splits chain time into **epochs** (≈ every ~4 hours). Epochs are used to account **human node uptime** for (simulated) fee distribution eligibility. - -For the purposes of this simulation: - -- A “week” is **42 epochs**. -- A human node must be Bioauthenticated for **42/42 epochs per week** to be eligible for fee distribution. - -What to model: - -- Per human node: `bioauthEpochsThisWeek` and `isFeeEligibleThisWeek`. -- Optional: missed epochs reasons (offline / bioauth failed / restricted). - -#### Era (Governance window) - -Vortex uses **eras** as the governance accounting window. An era is the unit that determines: - -- Whether a governor is counted as an **active governor for the next era**. -- Whether that governor is counted in **quorum baselines** in the next era (pool/vote thresholds). - -What to model: - -- Per human: `actionsCompletedThisEra`, `actionsRequiredThisEra`, and derived `isActiveGovernorNextEra`. -- An era boundary “rollup” that freezes counts and updates quorum baselines for the next era. - -### 1.3 Chambers - -Chambers are specialization-based governance domains in the UI: - -- Design -- Engineering -- Economics -- Marketing -- General -- Product - -Simulation requirements: - -- Chamber membership (who belongs where; can be multiple). -- Chamber multipliers (for CM math). -- Per-chamber pipeline counts: pool / vote / formation. - -#### What a chamber is (in this simulation) - -A **chamber** is a named specialization domain that: - -- tags a proposal with a “lead domain” (`chamberId`) -- defines the **review/vote constituency** (who is expected/allowed to vote in the chamber stage) -- defines a **CM multiplier (M)** used to scale contribution scores across domains - -In other words: - -- Pool stage answers: “Is this proposal worth attention at all?” -- Chamber stage answers: “Do the domain specialists accept it?” -- Formation answers (when applicable): “Can it be executed and tracked?” - -#### Chamber types: General vs specialization - -There are two kinds of chambers: - -- **General chamber**: meta-governance chamber. - - Everyone can vote in General only after they have at least one **accepted proposal** in any chamber. -- **Specialization chambers**: Design/Engineering/Economics/Marketing/Product. - - Only domain participants can vote (see eligibility below). - -#### How chambers are created (v1) - -In v1, the set of chambers is treated as a **genesis configuration** (fixed set of chambers used across the UI). - -- the set of chambers is fixed (Design/Engineering/Economics/Marketing/General/Product) -- `chamberId` is a stable string identifier (used in URLs, DTOs, and DB rows) -- multipliers are adjustable (simulated via the CM Panel) - -Governance rule (paper-aligned): - -- **Chamber creation happens only through a proposal that went through the General chamber.** - -That proposal contains: - -- chamber id/name -- multiplier -- genesis roles/memberships (addresses + roles), represented in the simulation: - - v1 bootstrap: `public/sim-config.json` → `genesisChamberMembers` - - chamber.create proposals may also include `payload.metaGovernance.genesisMembers` to seed initial memberships for the new chamber - -Future (v2+): chamber creation/dissolution becomes fully canonical (not read-model seeded), but the rule stays the same. - -- add a new chamber definition -- set initial multiplier -- define initial membership rules and quorum baseline rules - -#### How chambers function (end-to-end) - -1. **Draft** - - A proposal draft is authored and assigns a `chamberId`. -2. **Proposal pool (attention)** - - Proposal competes for attention in the **proposal pool of its target chamber**. - - Threshold math uses a **denominator snapshot** captured when the proposal enters the pool: - - specialization pool: active governors **eligible for that chamber** in the current era - - General pool: active governors **eligible to vote in General** in the current era -3. **Chamber vote** - - The chamber is the lead domain for the vote stage. - - In v1, quorum fractions are **global**, but the **denominator is chamber-scoped** (active governors eligible for that chamber in the era, captured on stage entry). - - CM scoring is collected here (optional 1–10 input), then awarded on success. -4. **Formation** - - Formation is **optional**. A proposal is considered accepted once it passes the chamber vote, but only some proposal types open a Formation project. - -#### Role in the system - -Chambers are the main mechanism that keeps Vortex “specialization-based” instead of purely global governance: - -- reduces noise (people vote where they have context) -- makes CM comparable across domains (multiplier) -- provides a natural place for domain-specific norms (what “spam” means, acceptance criteria, etc.) - -#### Chamber participation and voting eligibility (paper-aligned rule) - -Voting is not weighted; this is eligibility only (still 1 human = 1 vote). - -Preconditions for any write action: - -1. The actor is an **active Human Node** (on-chain eligibility gate). -2. The actor has the relevant chamber voting eligibility (where applicable). - -Note: v1 currently does not block actions based on “active governor this era” status; “active governor” is used for quorum baselines and rollups. - -Eligibility to vote in chambers is earned by accepted proposals: - -- **Specialization chamber `X`**: a human can vote in `X` if they have at least one **accepted proposal in chamber `X`**. -- **General chamber**: a human can vote in General if they have at least one **accepted proposal in any chamber**. - -### 1.4 Proposals (two axes) - -Proposals are structured “change requests” authored by governors. There are two key axes in v1 that matter to modeling and UI: - -1. **Scope axis (system vs project)** - - **System-change proposals**: affect the simulation itself (a variable or entity the system enforces automatically). Example: **chamber creation/dissolution** via a General proposal. - - **Project proposals**: describe work outside the system (deliver a toolkit, docs, marketing sprint, pallet implementation). The simulation tracks outcomes, but it does not “force-implement” external work beyond its governance lifecycle and accounting. -2. **Proposition-rights axis (tier/type)** - - The _right to propose_ differs by governing tier and by proposal type (e.g. Basic / Administrative / Fee). This axis is modeled separately from the “scope” axis above. - -Genesis exception: - -- genesis roles/memberships are treated as eligible from day one for their chamber(s) via `public/sim-config.json` → `genesisChamberMembers`. - -Wizard note: - -- The proposal wizard is evolving toward template-driven flows (project vs system-change), so chamber creation proposals collect only chamber-defining fields while project proposals retain the multi-step “Who/What/Why/How/How much” flow: - - `docs/simulation/vortex-simulation-proposal-wizard-architecture.md` - -#### Chamber dissolution (paper-aligned rule) - -- Chambers can be dissolved only through a proposal in the **General chamber**. -- General cannot be dissolved. -- Dissolution never deletes history. It changes canonical chamber status and preserves audit trails. -- Dissolved chamber behavior (v1 rule): - - No new proposals can be submitted into a dissolved chamber. - - Proposals that were created before dissolution can continue their lifecycle (including chamber voting). - -#### How chambers are represented in the code today (current state) - -Chambers are now **canonical** (Phase 18): - -- DB table: `chambers` -- Genesis seeding: - - `public/sim-config.json` → `genesisChambers` - - the backend auto-seeds these into `chambers` if the table is empty -- API: - - `api/routes/chambers/index.ts` returns the canonical list (with derived stats/pipeline where possible) - - `api/routes/chambers/[id].ts` returns a minimal canonical detail model (proposals/governors/threads/chat can be empty in v1) -- UI: - - `src/pages/chambers/Chambers.tsx` renders the directory from `GET /api/chambers` - - `src/pages/chambers/Chamber.tsx` renders the detail page from `GET /api/chambers/:id` - -Canonical links: - -- proposal drafts and canonical proposals carry `chamberId` (`proposal_drafts.chamber_id`, `proposals.chamber_id`) -- CM awarding uses `chamberId` and pulls multipliers from canonical chambers (fallback to read-model multipliers in legacy mode) - -Canonical voting eligibility is now modeled and enforced: - -- DB table: `chamber_memberships` (granted on proposal acceptance) -- Enforcement: `POST /api/command` → `chamber.vote` checks membership before recording a vote. -- Rule: - - specialization chamber → must have an accepted proposal in that chamber - - general chamber → must have an accepted proposal in any chamber - -#### Target representation (next audit-driven step) - -To match the chamber model more precisely, chambers are modeled as canonical tables: - -- `chambers`: - - `id`, `name`, `multiplierTimes10`, optional `createdAt`, optional `createdBy` -- `chamber_memberships` (already present in v1): - - `address`, `chamberId`, `grantedByProposalId`, `source`, `createdAt` - - future: add optional `role` and `leftAt` for dissolution/merges (without deleting history) - -From those, the UI’s chamber “stats” and “pipeline” should be derived from canonical state: - -- pipeline counts = number of proposals grouped by (`chamberId`, `stage`) -- governors count = active chamber members (and/or active governors within chamber for the era) -- LCM/MCM/ACM = derived from CM award events + multiplier configuration - -## 2) Next process audits (order) - -This is the sequence to audit and then implement, so the simulation matches the Vortex 1.0 model: - -1. **Chamber governance** (this section): creation/dissolution rules via General chamber proposals. -2. **Chamber participation**: canonical “accepted proposal → chamber voting eligibility” and “accepted proposal anywhere → General eligibility”. -3. **Formation optionality**: ensure “accepted” does not imply “formation exists” and treat Formation as a conditional sub-flow. -4. **Quorum baselines**: confirm global quorum math uses “active governors next era” and enforce “active human node” gating + “active governor” rules consistently. - -### 1.4 Cognitocratic Measure (CM) - -CM is a reputation-like contribution score: - -- **LCM**: local CM earned in a specific chamber. -- **Multiplier (M)**: chamber multiplier. -- **MCM**: LCM × M. -- **ACM**: Σ MCM across chambers. - -Simulation requirements: - -- A deterministic scoring event: when a proposition is accepted, the proposer earns CM. -- Optional “review score” input (1–10) from voters for the accepted proposal. -- Recompute ACM from LCM + multipliers (no manual ACM editing). - -### 1.5 Tiers + proposition rights - -Tier ladder (UI uses): Nominee → Ecclesiast → Legate → Consul → Citizen. - -Simulation requirements: - -- Tier progression rules (PoT / PoD / PoG–like requirements). -- Tier decay + statuses (Ahead / Stable / Falling behind / At risk / Losing status). -- Eligibility gating (what actions are available by tier). - -### 1.6 Delegation - -- Delegator chooses a delegatee to cast their voice (chamber-scoped). - -Simulation requirements: - -- Delegation graph (one delegator → one delegatee; cycles disallowed). -- Eligibility: both delegator and delegatee must be eligible governors in the same chamber. -- Delegation metadata for courts (events, timing windows, alleged abuse scenarios). -- Ability to toggle delegation on/off for a simulation era. - -Paper alignment note: - -- The paper describes delegation as aggregating voting power (delegatee power equals `1 + delegations`). -- v1 implements delegation for **chamber vote weighting** (pool attention remains direct-only). - -### 1.7 Proposals - -Proposal lifecycle stages shown in the UI: - -- Draft -- Proposal pool (attention) -- Chamber vote -- Formation (execution) - -Simulation requirements: - -- Stage transitions with gates: pool thresholds, vote window rules, formation eligibility. -- Per-stage metrics shown in UI (quorums, floors, vote split, budgets, milestones, team slots). -- Attachments metadata (links only; no file storage required for the simulation). - -#### When a proposal is “accepted” - -Paper-aligned rule for v1 simulation: - -- A proposal is considered **accepted** once it **passes the chamber vote**. -- Formation is optional; acceptance does not imply that a Formation project must exist. - -Implications: - -- Chamber voting eligibility (“can vote here”) is earned by having an accepted proposal. -- The General chamber is unlocked by having any accepted proposal in any chamber. - -#### Formation optionality (modeling note) - -In the current UI, the stages are always rendered as Draft → Pool → Chamber vote → Formation. - -To keep DTOs and routes stable while allowing “no formation” proposals, the simulation should model: - -- `formationRequired: boolean` (proposal-type dependent) -- Only when `formationRequired=true`: - - a Formation project is created - - Formation actions/milestones are enabled - -For `formationRequired=false`, the “Formation” page can render a minimal “No formation required” view and a history of the accepted vote. - -### 1.8 Formation (execution layer) - -Formation manages implementation after approval: - -- Project status: gathering / live / completed -- Milestones with unlocks (tranches) -- Team slots (filled/open) - -Simulation requirements: - -- Milestone definitions and acceptance criteria. -- Milestone completion events (deliverable posted, challenged, approved). -- Partial release / conditional release flows (via courts or chamber decision). - -### 1.9 Courts - -Courts handle disputes: - -- Delegation disputes -- Milestone completion contested -- Identity integrity disputes (PoBU anomalies) - -Simulation requirements: - -- Case states: Jury / Session live / Ended. -- Reporting (N reports) and proceedings sections (claim, evidence, planned actions). -- Verdict capture: Guilty / Not guilty (mock UI) and outcome effects (recommendations, sanctions flags, milestone withholding, etc.). - -### 1.10 Factions - -Factions are off-chain-aligned groupings: - -- Membership lists / roster highlights -- Initiatives linked to proposals - -Simulation requirements: - -- Join/leave, membership counts, “focus”, roster highlights. -- Initiative tracking (references to proposals and their states). - -### 1.11 Feed (event stream) - -Feed aggregates events across the system: - -- proposal events (created, moved stage, quorum met, vote updates) -- formation events (milestone posted, tranche released, team joined) -- courts events (case opened, reports count changes, session live, verdict) -- threads events (reply count changes) -- faction events (initiative started, membership changes) - -Simulation requirements: - -- Append-only event log with typed events. -- Derived “cards” per stage for UI consumption. -- Pagination + filtering by stage/category. - -## 2) Processes to implement (workflows) - -Each workflow below should be implemented as a state machine + event emissions. - -### 2.0 Authentication + gating (read-only vs write) - -Login: - -- Client requests a nonce for an address. -- Client signs the nonce with the Human Node wallet. -- Backend verifies signature, issues a session (cookie/JWT). - -Eligibility check (authoritative gating): - -- Backend checks Humanode mainnet data via **RPC queries** (v1). -- Backend caches the eligibility result with a short TTL (e.g. 10–30 minutes) and re-checks on demand. - -Two proofs (explicit): - -- Proof A: “User controls address X” (nonce + signature; SIWE-style but chain-agnostic). -- Proof B: “Address X is an active Human Node” (mainnet read via RPC; v1 reads `Session::Validators`). - -Eligibility claim (cached): - -- `isValidator` / `isActiveHumanNode`: boolean -- `validatorId` (if applicable) -- `checkedAtBlock` or `checkedAtTime` -- `eligibilityExpiresAt` (TTL) - -Rule: - -- Everyone can browse and read. -- Any write action requires `isActiveHumanNode === true` at the moment of the action. - -Recommended API surface (v1): - -- `POST /api/auth/nonce` → returns nonce for `address` -- `POST /api/auth/verify` → verifies signature, returns session -- `GET /api/gate/status` → returns `{ eligible: boolean, reason?: string, expiresAt: string }` - -### 2.1 Onboarding (Human → Human Node → Governor) - -- Create human identity (PoBU verified). -- Enable node running status and uptime tracking. -- Determine governor eligibility for the era (meets uptime + action minima). - -Outputs: - -- Human node profile stats (PoT/PoD/PoG-like) -- Governor status pill (Active / Not active) - -### 2.2 Era rollup (the simulation “cron”) - -At each era boundary: - -- Close voting windows that expired. -- Evaluate pool thresholds and advance eligible proposals. -- Compute governor activity (actions done vs required). -- Derive per-era governing status buckets (Ahead/Stable/Falling behind/At risk/Losing status). -- Compute the “active governor” set for the next era (v1: requirement-based, configurable off-chain). -- Recompute CM aggregates and chamber stats. - -v1 rollup inputs: - -- Per-era action requirements are configured off-chain (env): - - `SIM_REQUIRED_POOL_VOTES` - - `SIM_REQUIRED_CHAMBER_VOTES` - - `SIM_REQUIRED_COURT_ACTIONS` - - `SIM_REQUIRED_FORMATION_ACTIONS` -- The rollup can be triggered manually (admin) and is designed to be idempotent for a given era window. - -Outputs: - -- Updated “My Governance” metrics and statuses. -- Updated chamber directory metrics/pipelines. -- Feed events: era boundary, status changes, proposals moved. - -### 2.3 Proposal drafting (multi-step wizard) - -- Create draft with required fields (Who/What/Why/When/Where/How/How much, attachments optional). -- Save draft any time. -- Validate for submission only at final step. - -Outputs: - -- Draft appears in Drafts list. -- Draft → Pool submission event. - -### 2.4 Proposal pool (attention / signaling) - -Actions: - -- Upvote / downvote (with rules acknowledgment). -- Comment / “watch” (optional, if modeled). - -Gates: - -- Attention quorum: engaged governors >= threshold (absolute and/or %). -- Upvote floor: upvotes >= threshold (absolute and/or %). -- Eligibility: only governors can upvote/downvote in proposal pools (i.e. you must have at least one accepted proposal in any chamber). - -Outputs: - -- Pool metrics (engaged vs needed; upvotes vs needed; % values). -- Pool → Chamber vote transition event when gates met. - -### 2.5 Chamber vote (decision) - -Actions: - -- Vote yes/no/abstain (direct or delegated). -- Optionally attach a “CM score” for proposer (1–10) as part of vote. - -Gates: - -- Voting quorum met. -- Passing rule met (e.g. ≥66.6% yes among cast votes, within quorum). - -Outputs: - -- Vote split + % displayed on proposal page and in cards. -- If passed and formation eligible: move to Formation. -- If failed: close proposal (Ended/Rejected) and emit event. - -### 2.6 Formation execution (projects) - -Actions: - -- Join project (fill team slot). -- Mark milestone “ready for review”. -- Submit deliverable links (attachments). -- Request tranche unlock. - -Gates: - -- Milestone acceptance (reviewers / chamber / court ruling). - -Outputs: - -- Formation metrics (budget allocated, milestones, progress). -- Formation stage changes (gathering/live/completed). -- Feed events for milestone progression. - -### 2.7 Courts (case lifecycle) - -Inputs (create case): - -- Reports threshold reached or manual court filing. -- Link to target entity: delegation incident / proposal milestone / identity anomaly. - -Actions: - -- Add report (increments reports count). -- Add evidence links (structured list). -- Jury “votes” guilty/not guilty (mock). - -Outputs: - -- Case status transitions (Jury → Session live → Ended). -- Recommendations/sanctions flags that can affect: - - delegation visibility / restrictions - - milestone payout hold/release - - identity restriction flags - -### 2.8 Delegation management - -Actions: - -- Delegate to another human. -- Remove delegation. -- Update delegation terms metadata (optional). - -Constraints: - -- Prevent cycles. -- Apply effective delegation at the era snapshot (so votes are consistent). - -Outputs: - -- Delegation events in feed and for courts evidence. - -### 2.9 Chamber directory + chamber detail - -Directory: - -- Show per-chamber multipliers and stats (governors, ACM/LCM/MCM). -- Show pipeline counts (pool/vote/formation). - -Detail: - -- Filter proposals by stage (Upcoming/Live/Ended). -- Governor roster (derived from chamber membership + tier). -- Threads/chat mock (optional). - -Outputs: - -- Stable derived views from the canonical state (no duplicated counters). - -### 2.10 Invision insights (reputation / risk panel) - -For humans/proposers: - -- Aggregated historical performance metrics (approved/abandoned, milestones, delays, slashing, vote participation, delegations). - -Simulation requirements: - -- Deterministic scoring function producing a “confidence” and a “risk” label. -- Update on era rollups and major events (proposal accepted, milestone contested, court verdict). - -## 3) State + invariants (must stay consistent) - -- One human = one vote (delegation only changes _who casts_, not weight). -- Chamber multipliers are used in CM calculations; ACM is derived, not manually edited. -- Proposal stage transitions must be monotonic and recorded (audit trail). -- Court outcomes must not silently mutate history; always emit events and keep a case record. -- All UI “labels” should be derived from IDs/enums in canonical data (avoid free-text drift). -- “Browse is open, write is gated”: any state-changing command must be rejected unless the actor is an active Human Node at execution time. - -## 4) Suggested simulation architecture (implementation shape) - -### 4.1 Modules - -- `identity`: humans, PoBU status -- `governanceTime`: epoch/era clock + rollups -- `chambers`: membership, multipliers, stats derivation -- `cm`: LCM/MCM/ACM computation -- `tiers`: progression + decay + status labels -- `delegation`: edges + events -- `proposals`: drafts + pool + vote + formation transitions -- `formation`: projects, milestones, team slots, tranches -- `courts`: cases, reports, verdicts, recommendations -- `feed`: event store + derived feed cards - -### 4.2 Data flow - -- Write operations produce **events** and mutate **canonical state**. -- Read APIs serve either: - - canonical state (proposals, chambers, humans), or - - derived views (feed cards, stats, metrics) computed from canonical state + events. - -### 4.3 Back-end stack (practical) - -- API: serverless runtime (or API handlers) -- DB: Postgres (v1: Neon-compatible serverless Postgres) -- Event log: append-only `events` table (feed + audit trail) -- Jobs: Cron triggers for era rollups -- Optional: single-writer coordinator for race-free updates (double-vote prevention, counters, quorum snapshots) - -### 4.3 Determinism knobs - -- Seeded random generator for “simulated activity” (optional autopopulation). -- Manual override controls for testing scenarios (force quorum met, force court verdict, etc.). - -## 5) Next step: pick “v1 processes” - -If we scope a first backend v1 that matches the current UI, the minimum set is: - -- Era rollup -- Proposals: draft → pool → vote → formation transitions -- Chamber stats + multipliers + CM computation -- Courts: case open + status + verdict -- Feed event stream diff --git a/docs/simulation/vortex-simulation-proposal-wizard-architecture.md b/docs/simulation/vortex-simulation-proposal-wizard-architecture.md deleted file mode 100644 index c514ebe..0000000 --- a/docs/simulation/vortex-simulation-proposal-wizard-architecture.md +++ /dev/null @@ -1,269 +0,0 @@ -# Vortex Simulation: Proposal Wizard Architecture - -This document defines the long-term structure for the proposal creation wizard so that different proposal types can have different flows, while still producing a single canonical “proposal draft” payload that the backend can validate and submit. - -## Goals - -- Support multiple proposal types with different required fields and step flows. -- Keep the UI scalable (adding a new proposal type should not require rewriting the whole wizard). -- Ensure the backend enforces the same rules as the UI (single source of truth for draft validation). -- Make “system-change” proposals (e.g., chamber creation) reflect the exact data they mutate in the simulation. - -## Proposal Kind vs. Proposal Rights - -There are two separate axes: - -1. **Proposal rights / tier gating** (Basic, Administrative, Fee, etc.) - -- This is about _who can submit what_, based on tier/rights rules. - -2. **Proposal kind (wizard + payload shape)** - -- This is about _what the proposal does to the simulation_ and therefore what fields the wizard should collect. - -This document focuses on (2). - -## Canonical Model: Discriminated Draft Union - -All drafts should include a discriminant that selects the template and payload shape: - -- `kind: "project"` — proposals that represent work/projects; may or may not require Formation. -- `kind: "system"` — proposals that directly change simulation state (system variables/entities). - -For `kind: "system"`, the `systemAction` (or similar) further specifies the exact system mutation, for example: - -- `systemAction: "chamber.create"` -- `systemAction: "chamber.dissolve"` - -The draft payload becomes a discriminated union in both places: - -- Frontend draft state (wizard) -- Backend validation (`api/_lib/proposalDraftsStore.ts`) - -## Template Registry (Wizard Definition) - -The wizard should be driven by a registry of templates (one per proposal flow). - -Each template defines: - -- `id` — stable template identifier (e.g. `project`, `system`) -- `label` and `description` -- `stepOrder` + UI labels -- `compute(draft)` — derived validation and “can submit” gating - -In v1 (current code), templates are intentionally **pure TS** so they can be imported by Node tests without JSX transpilation. React step components remain shared and are selected by `StepKey` (`essentials` / `plan` / `budget` / `review`). - -Suggested folder layout: - -- `src/pages/proposals/proposalCreation/templates/` - - `project.ts` - - `system.ts` -- `src/pages/proposals/proposalCreation/steps/` (shared step components) - -The main wizard page (`src/pages/proposals/ProposalCreation.tsx`) should become “template runner” glue: - -- Determine current template (from query param / initial choice / saved draft) -- Render the template’s current step component -- Persist draft state and step -- Call `apiProposalDraftSave` and `apiProposalSubmitToPool` using `template.toApiForm(draft)` - -## Flow: System Proposal — Chamber Creation - -Chamber creation is a **system-change** proposal, and in the simulation it is only valid as a **General chamber** proposal (the wizard should force `chamberId = "general"`). - -### Template ID - -- `system` (v1) - -### Steps - -1. **Setup** - - `metaGovernance.action = "chamber.create"` - - `metaGovernance.chamberId` (new chamber id/slug) - - `metaGovernance.title` (display title) - - `metaGovernance.multiplier` (initial multiplier) - - plus the current backend-required rationale fields (`title`, `what`, `why`) - -2. **Rationale** - - `how` (required by current backend validation for all drafts) - - In v1 UI, `timeline` and `outputs` are hidden for system proposals to keep the form focused. - -3. **Review & submit** - - Show exactly what will happen on acceptance: - - a new chamber entity is created - - proposer + genesis members become chamber members - -### What is intentionally NOT collected - -- Budget items (system proposals skip the budget step) -- Project-oriented “Where” links (outputs) -- Milestone timeline (hidden in v1 for system proposals) - -Those are meaningful for projects, not for creating the chamber entity itself. Project-only fields are now optional for system drafts (see W3/W4). - -### Backend integration point - -On acceptance (General chamber, vote → build), the backend already finalizes system actions: - -- `api/_lib/proposalFinalizer.ts` - - `createChamberFromAcceptedGeneralProposal(...)` - - membership seeding from `metaGovernance.genesisMembers` + proposer - -The wizard’s responsibility is to produce the correct `metaGovernance` payload only. - -## Flow: Project Proposal — Normal Proposal Creation - -This is the “general” proposal creation flow used for proposals that represent work. - -### Template ID - -- `project` - -### Steps (v1) - -1. **Essentials** - - `title` - - `chamberId` (target chamber pool) - - `summary` - - `what` - - `why` - - Optional: `aboutMe` - -2. **Plan** - - `how` - - `timeline[]` (milestones) - - `outputs[]` - - `attachments[]` (recommended) - -3. **Budget & Formation** - - `formationEligible` (explicit field, if/when we stop inferring it) - - `budgetItems[]` - - confirmations: `agreeRules`, `confirmBudget` - -4. **Review & submit** - - “Save draft” always available - - “Submit” enabled only when `isSubmittable(draft)` passes - -### Backend integration point - -Project proposals are submitted from drafts via: - -- `api/routes/command.ts` (`proposal.submitToPool`) -- draft storage + validation: - - `api/_lib/proposalDraftsStore.ts` (`proposalDraftFormSchema`, `draftIsSubmittable`) - -## Address Handling (HMND) - -All addresses should be treated as Humanode (HMND) SS58 strings. - -Rules: - -- Persist and display canonical addresses as `hm...` (Humanode SS58 format). -- Compare addresses by decoded public key, not by raw string, to avoid SS58 prefix mismatches. - -Implementation helpers: - -- `api/_lib/address.ts` - - `HUMANODE_SS58_FORMAT = 5234` - - `canonicalizeHmndAddress(address)` - - `addressesReferToSameKey(a, b)` - -## Implementation phases (Wizard v2 track) - -The wizard refactor is intentionally staged so the UI can stay usable while the draft schema evolves. - -Status: - -- W1–W5 completed (see `docs/simulation/vortex-simulation-implementation-plan.md`, Phases 35–39). - -### W1 — Template runner + registry (plumbing) - -- Introduce a template registry (`templates/*`) and make `ProposalCreation.tsx` a template runner. -- Keep the existing “project” flow behavior but move orchestration behind a template interface. -- Persist the selected template id in draft storage (local + server draft payload). - -Tests: - -- Minimal unit test for template registry invariants (unique ids, stable step ordering). - -### W2 — System template v1 (`system`) - -- Implement the dedicated system flow: - - forces `chamberId = "general"` - - skips the budget step and hides project-only optional sections - - keeps `metaGovernance` in the draft payload so existing backend finalizers apply (no backend changes required) - -Tests: - -- API scenario test: create chamber.create draft → submit to pool → pass quorum/vote → chamber appears in `/api/chambers`. - -### W3 — Split the backend draft schema into a discriminated union - -- Convert the backend draft payload (`proposalDraftFormSchema`) into a discriminated union matching template ids. -- Keep a compatibility adapter for old drafts until they are migrated/cleared. - -Tests: - -- Schema-level tests: - - old draft payloads still parse (compat mode) - - new template payloads parse and enforce the correct required fields - -Current status: - -- Implemented in `api/_lib/proposalDraftsStore.ts`: - - `templateId` discriminant with preprocessing + defaults - - system drafts can omit project-only fields - -### W4 — Migrate stored drafts + simplify project validation - -- Migrate persisted drafts (DB + local storage) to the new discriminated shape where feasible. -- Remove “fake” required fields for system proposals and remove “system-only” branching from the project flow. - -Tests: - -- Draft submit tests for both `project` and `system.chamberCreate` (required fields + chamber constraints). - -Current status: - -- Stored draft payloads are normalized on read: - - DB: `listDrafts`/`getDraft` backfill `templateId` when missing. - - Memory: legacy payloads are normalized and cached. -- Project wizard validation no longer handles system proposals. -- Tests: - - `tests/proposal-draft-migration.test.js` - -### W5 — Cleanup + extension points - -- Remove legacy branches and deprecate the old “single big form” shape. -- Add extension points for additional system actions (e.g., `system.chamberDissolve`) without inflating the base project flow. - -Tests: - -- Coverage stays stable (no feature regression in `proposal.draft.save` / `proposal.submitToPool` / proposal pages). - -Current status: - -- System action metadata is centralized in `systemActions.ts`. -- System proposals no longer require project-only fields (`what/why`). -- System review summary renders only system-specific fields. -- Tests: - - `tests/proposal-wizard-system-template.test.js` - -## Migration Notes (from current shape) - -Current state uses a “single big form” with `metaGovernance` optional (see `api/_lib/proposalDraftsStore.ts`). - -To migrate cleanly: - -1. Introduce the discriminant (`kind` + template id). -2. Split the backend draft schema into a discriminated union. -3. Keep a compatibility adapter temporarily (if needed) that maps old drafts into `kind: "project"` until old drafts are gone. - -## Definition of “Done” for this refactor - -- The wizard supports at least: - - `project` - - `system` -- `system` does not show the project-only budget step (and keeps optional project sections out of the way). -- Backend validation matches the template rules (no fake required fields). -- Submitting a chamber creation proposal produces a payload that the finalizer can apply without extra UI hacks. diff --git a/docs/simulation/vortex-simulation-scope-v1.md b/docs/simulation/vortex-simulation-scope-v1.md deleted file mode 100644 index fd7474a..0000000 --- a/docs/simulation/vortex-simulation-scope-v1.md +++ /dev/null @@ -1,142 +0,0 @@ -# Vortex Simulation Backend — Scope (v1) - -This document defines the **v1 scope** of the Vortex simulation backend shipped from this repo. It makes the boundary between “implemented now” vs “intentionally deferred” explicit. - -## Purpose - -Ship a community-playable governance simulation that: - -- Uses Humanode mainnet only as a read-only **eligibility gate** -- Runs all governance logic off-chain with deterministic rules -- Produces an auditable history (events + derived read views) -- Powers the UI exclusively via `/api/*` - -## Hard boundary (on-chain vs off-chain) - -- On-chain (read-only): determine whether an address is an **active Human Node** -- Off-chain (authoritative): everything else (proposals, votes, courts, formation, CM/tiers, feed/history) - -## What “done” means for v1 - -v1 is “done” when: - -- The UI can run clean-by-default with empty content and still be usable (no mock-only fallbacks). -- A signed-in, eligible address can execute end-to-end actions and see them reflected: - - pool voting - - chamber voting - - formation join + milestone actions - - courts reporting + verdict -- The feed is event-backed and reflects real actions. -- Era accounting exists: - - per-era counters are tracked - - rollup produces status buckets and next-era active set size -- Safety controls exist for a public demo: - - rate limits - - per-era quotas - - action locks - - write freeze -- Tests cover the above behavior. - -## Implemented (v1) - -### Identity and gating - -- Session auth: wallet signs a nonce (Substrate signature verification). -- Eligibility: Humanode mainnet RPC reads of `Session::Validators` (current validator set membership). -- Cached gate status with TTL; browsing is open, writes are gated. - -### Reads - -- `/api/*` read endpoints exist for all UI pages. -- Read-model bridge exists: - - DB mode reads from Postgres `read_models` - - inline seeded mode (`READ_MODELS_INLINE=true`) - - clean-by-default empty mode (`READ_MODELS_INLINE_EMPTY=true`) - -### Writes (command-based) - -- All writes route through `POST /api/command`. -- Commands implemented in v1: - - `pool.vote` - - `chamber.vote` (yes/no/abstain + optional score on yes) - - `formation.join` - - `formation.milestone.submit` - - `formation.milestone.requestUnlock` - - `court.case.report` - - `court.case.verdict` - - `proposal.draft.save` - - `proposal.draft.delete` - - `proposal.submitToPool` - -Note on proposal wizard UX: - -- v1 includes a working proposal wizard and supports meta-governance payloads for chamber creation/dissolution. -- Planned (v2+): restructure the wizard into template-driven flows so system-change proposals (like chamber creation) do not share project-only steps/fields: - - `docs/simulation/vortex-simulation-proposal-wizard-architecture.md` - -### Events and history - -- Append-only `events` table. -- Feed can be served from DB events (DB mode). -- Proposal pages expose a per-proposal timeline (`GET /api/proposals/:id/timeline`) backed by `events` entries of type `proposal.timeline.v1`. -- Admin actions also emit audit events. - -### Era accounting - -- Current era stored in DB (simulation clock). -- Per-era activity counters per address. -- Manual/admin era advance and era rollup endpoints. -- Rollup outputs: - - per-address status bucket for the era window - - computed next-era `activeGovernors` size (optionally written as the next baseline) - -### Canonical proposals and deterministic transitions - -- Canonical proposals exist in `proposals` (with `read_models` as a compatibility fallback). -- Stage transitions are deterministic and centralized (single transition authority) and enforced by the write path. -- Optional time windows exist for stage expiry and “time left” UX. - -### Canonical chambers and membership - -- Chambers are canonical in `chambers` (seeded from `/sim-config.json`). -- Chamber voting eligibility is enforced via `chamber_memberships`: - - specialization chamber: requires at least one accepted proposal in that chamber - - general chamber: requires at least one accepted proposal in any chamber -- Meta-governance proposals in the General chamber can create/dissolve chambers (v1 simulation rule). - -### Ops controls (public demo safety) - -- Command rate limits (per IP + per address). -- Optional per-era action quotas (per address). -- Address-level action locks (admin). -- Global write freeze (admin) + deploy-time kill switch (`SIM_WRITE_FREEZE=true`). - -## Not in scope (v1) - -These are intentionally deferred: - -- Completing the migration away from the `read_models` bridge for all entities (full projections across every page). -- Delegation flows (graph rules, UI, disputes beyond court-case text). -- Veto rights (and any “slow-down” mechanics for repeatedly approved proposals). -- Chamber multiplier-setting mechanics (including “outside-of-chamber” voting). -- Meritocratic Measure (MM) as a first-class modeled subsystem (Formation delivery scoring and MM history). -- A real forum/threads product (threads remain minimal and simulation-only). -- Bioauth epoch uptime as a first-class modeled subsystem (epochs are defined conceptually but not fully simulated as canonical state). -- “Real tokenomics”: rewards, balances, staking, slashing correctness. - -## Planned after v1 (v2+) - -These are the next build targets after v1 (see `docs/simulation/vortex-simulation-implementation-plan.md` for the full phased checklist): - -- Delegation v1 (set/clear + history + court references). -- Veto v1 (paper-aligned) and a minimal “proposal sent back” lifecycle. -- Chamber multipliers v1 (paper-aligned “outside-of-chamber” multiplier voting). -- Meritocratic Measure v1 (Formation delivery ratings → MM history and Invision signals). -- More event-backed projections (less `read_models`). - -## Sources of truth - -- v1 constants: `docs/simulation/vortex-simulation-v1-constants.md` -- API contract: `docs/simulation/vortex-simulation-api-contract.md` -- State machines + invariants: `docs/simulation/vortex-simulation-state-machines.md` -- Implementation status: `docs/simulation/vortex-simulation-implementation-plan.md` diff --git a/docs/simulation/vortex-simulation-state-machines.md b/docs/simulation/vortex-simulation-state-machines.md deleted file mode 100644 index 2192496..0000000 --- a/docs/simulation/vortex-simulation-state-machines.md +++ /dev/null @@ -1,249 +0,0 @@ -# Vortex Simulation Backend — State Machines (v1) - -This document formalizes the **state machines, invariants, and derived metrics** the simulation backend enforces. - -The UI can evolve, but these rules define what the simulation “means”. - -## Conventions - -- “Address” means a Substrate address string (the session identity). -- “Eligible” means “active Human Node” as returned by `GET /api/gate/status`. -- “Era” means the simulation’s governance accounting window. -- Command writes happen via `POST /api/command` only. - -## Global invariants (v1) - -### Write invariants - -- Every command write requires: - 1. authenticated session - 2. eligibility (unless dev bypass is enabled) - 3. not globally write-frozen (admin state or `SIM_WRITE_FREEZE=true`) - 4. not action-locked for the address - 5. within rate limit + optional per-era quotas -- Idempotency is supported via `Idempotency-Key`. Reuse with different payload returns HTTP `409`. - -### Read invariants - -- Read endpoints are safe for unauthenticated users. -- “Clean-by-default” mode exists (`READ_MODELS_INLINE_EMPTY=true`) and pages must remain usable without seeded content. - -## Proposal lifecycle (v1) - -### Stages - -The UI uses these stages: - -- `draft` -- `pool` (proposal pool / attention) -- `vote` (chamber vote) -- `build` (formation) - -In v1, stage is represented in the proposals list read model and may be auto-advanced by command evaluation. - -### Pool voting (`pool.vote`) - -Command: - -- `type: "pool.vote"` -- `payload: { proposalId, direction: "up" | "down" }` - -Invariants: - -- One vote per `(proposalId, voterAddress)`. -- Re-voting overwrites the prior direction for that pair. -- The command returns updated up/down counts for the proposal. -- The command is rejected if the proposal is not in stage `pool` (HTTP `409`). - -Derived metrics: - -- Upvotes / downvotes are computed from `pool_votes`. -- Quorum thresholds are parameterized by: - - active governors baseline (`SIM_ACTIVE_GOVERNORS` or per-era snapshot) - - pool quorum constants (see `docs/simulation/vortex-simulation-v1-constants.md`) - -Transition (implemented v1 behavior): - -- When pool quorum is met, the backend updates the `proposals:list` read model: - - stage `pool` → `vote` - - it also ensures `proposals:${id}:chamber` exists (created as a minimal placeholder derived from the pool page payload if missing) - -### Chamber voting (`chamber.vote`) - -Command: - -- `type: "chamber.vote"` -- `payload: { proposalId, choice: "yes" | "no" | "abstain", score?: number }` - -Invariants: - -- One vote per `(proposalId, voterAddress)`. -- `score` is only allowed when `choice === "yes"`. -- The command is rejected if the proposal is not in stage `vote` (HTTP `409`). - -Derived metrics: - -- yes/no/abstain totals are computed from `chamber_votes`. -- passing/quorum rules are parameterized by: - - active governors baseline (`SIM_ACTIVE_GOVERNORS` or per-era snapshot) - - vote quorum + passing constants (see `docs/simulation/vortex-simulation-v1-constants.md`) - -Transition (implemented v1 behavior): - -- When quorum + passing are met: - - if the proposal is `formationEligible === true`, the backend updates the proposals list read model: stage `vote` → `build` - - it also ensures `proposals:${id}:formation` exists (created as a minimal placeholder derived from the chamber page payload if missing) - -### CM awarding (on pass) - -Input: - -- yes votes may include `score` (1–10). - -Awarding rule (v1): - -- When a proposal passes chamber vote, the backend records a single `cm_awards` row (unique per proposal). -- Award points are derived from the average yes `score` (exact mapping is a v1 constant). -- Human profiles are served as: - - baseline CM numbers from read models - - plus a delta derived from `cm_awards` overlays - -## Formation (v1) - -### Join (`formation.join`) - -Command: - -- `type: "formation.join"` -- `payload: { proposalId, role?: string }` - -Invariants: - -- Only allowed when proposal is in stage `build` (HTTP `409` otherwise). -- Team slots cannot exceed total. - -### Milestone submit (`formation.milestone.submit`) - -Command: - -- `type: "formation.milestone.submit"` -- `payload: { proposalId, milestoneIndex, note?: string }` - -Invariants: - -- Only allowed in stage `build`. -- Milestone index must exist for the project. -- Submitting does not unlock funds in v1; it records a submission event. - -### Unlock request (`formation.milestone.requestUnlock`) - -Command: - -- `type: "formation.milestone.requestUnlock"` -- `payload: { proposalId, milestoneIndex, note?: string }` - -Invariants: - -- Only allowed in stage `build`. -- Cannot request unlock for a milestone that is already unlocked. - -## Courts (v1) - -### Report (`court.case.report`) - -Command: - -- `type: "court.case.report"` -- `payload: { caseId, note?: string }` - -Invariants: - -- Reporting increments a reports counter and appends a report record. -- Cases have a status bucket surfaced to the UI (`jury`, `live`, `ended`). -- v1 does not attempt to model full legal procedure; it records actions and exposes a readable “proceedings” view. - -### Verdict (`court.case.verdict`) - -Command: - -- `type: "court.case.verdict"` -- `payload: { caseId, verdict: "guilty" | "not_guilty" }` - -Invariants: - -- One verdict per `(caseId, voterAddress)`. -- Verdict is only allowed in allowed case statuses (v1 defines allowed states; enforced by the API). - -## Era accounting (v1) - -### Per-era counters - -Each write command increments the per-era activity counter for the address in `era_user_activity`, by kind: - -- pool votes -- chamber votes -- court actions -- formation actions - -### Rollup (`POST /api/clock/rollup-era`) - -Rollup computes: - -- per-address status bucket for the current era window: - - Ahead / Stable / Falling behind / At risk / Losing status -- next-era active governors baseline: - - either configured constant or derived dynamically (if enabled) - -Rollup invariants: - -- Deterministic: given the same stored activity counters and constants, output is identical. -- Idempotent for a given era. - -## What’s intentionally missing (v1) - -These are intentionally deferred from v1. The state machines above assume they do not exist. - -### Delegation (v2+) - -Not implemented in v1. - -Planned invariants: - -- No self-delegation. -- No cycles (delegation graph must remain acyclic). -- Delegation changes are event-backed (courts can reference the full history). -- Delegation affects chamber vote weight, but must not affect pool attention mechanics. - -### Veto rights (v2+) - -Not implemented in v1. - -Planned behavior (paper-aligned intent): - -- Veto power is tied to top LCM holders per chamber. -- Veto is temporary, limited in count, and slows down acceptance rather than blocking it indefinitely. -- Veto actions are event-backed and auditable. - -### Chamber multiplier setting (v2+) - -Not implemented in v1. - -Planned behavior (paper-aligned intent): - -- Multiplier submissions are made by cognitocrats outside the chamber. -- Chamber multiplier is derived from submissions (aggregation rules are a v2 decision). -- Changes to multipliers must be event-backed and should not rewrite CM history (ACM is re-derived). - -### Meritocratic Measure (MM) (v2+) - -Not implemented in v1. - -Planned behavior: - -- MM is earned through Formation delivery and milestone outcomes. -- MM does not change voting power. -- MM contributes to PoD-like tier progression and to Invision insights. - -### Future court hooks (beyond v1) - -- Outcome hooks that affect Formation unlocks, reputation/cred flags, and visibility (simulation only). diff --git a/docs/simulation/vortex-simulation-tech-architecture.md b/docs/simulation/vortex-simulation-tech-architecture.md deleted file mode 100644 index 815cc61..0000000 --- a/docs/simulation/vortex-simulation-tech-architecture.md +++ /dev/null @@ -1,452 +0,0 @@ -# Vortex Simulation Backend — Tech Architecture - -This document maps `docs/simulation/vortex-simulation-processes.md` onto a technical architecture that fits this repo (React app + API handlers in production, with a Node runner for local dev). - -For a paper-aligned “module map” that links product concepts to concrete code, start with `docs/simulation/vortex-simulation-modules.md`. - -For the DB table inventory, see `docs/simulation/vortex-simulation-data-model.md`. For ops controls and admin endpoints, see `docs/ops/vortex-simulation-ops-runbook.md`. - -## 1) Stack (recommended) - -### Languages - -- **TypeScript** end-to-end (web + API + shared domain engine). -- **SQL** for persistent state and analytics. - -### Runtime + hosting - -- **static hosting**: existing frontend hosting. -- **serverless runtime**: API runtime (REST + optional SSE). -- **Cron Triggers**: era rollups / scheduled jobs (implemented as an explicit `/api/clock/tick` endpoint that a scheduler calls). -- **single-writer coordinator (optional but recommended)**: race-free state transitions for voting/pool/court actions. - -### Database - -- **Chosen for v1: Postgres** (Neon-compatible serverless Postgres) for user history, analytics, and relational integrity. - -Important: because the API runtime is serverless runtime/API handlers (edge), v1 should use a Postgres provider that supports **serverless/HTTP connectivity** from edge runtimes. - -- Recommended: **Neon Postgres** (works with `@neondatabase/serverless` + Drizzle). - -If `DATABASE_URL` is not configured, the API falls back to an **ephemeral in-memory mode** for read models + clock. This keeps the UI usable for quick demos, but it is not durable. - -### Libraries / tools - -- **Drizzle ORM** (Postgres). -- **zod** (request validation; used as needed). -- **@polkadot/util-crypto** (+ **@polkadot/keyring** in tests) for Substrate signature verification and SS58 address handling. - -### External reads (gating) - -- Humanode mainnet via **RPC** (v1). - -## 2) High-level architecture - -### Components - -- **Web app (React/TS/Tailwind)**: UI + calls API. -- **API (Worker)**: - - `auth`: nonce + signature verification - - `gate`: mainnet eligibility checks + TTL caching - - `commands`: apply state transitions (write operations) - - `reads`: serve derived views (feed, proposal pages, profiles) -- **Domain engine (shared TS module)**: - - pure functions implementing state machines, invariants, and event emission - - no network calls; no DB calls -- **DB**: - - canonical state where implemented (votes, formation, courts, era accounting, idempotency, sessions) - - transitional `read_models` payloads for page DTOs while migrating toward canonical domain tables (v2) - - append-only event log (feed/audit) -- **Scheduler**: - - era boundary rollups (governor activity, quorums, tier statuses, CM updates) - - optional era auto-advance when the era is “due” by configured time (`SIM_ERA_SECONDS`) - - optional stage-window closure notifications (when `SIM_ENABLE_STAGE_WINDOWS=true`, `POST /api/clock/tick` emits a deduped feed event when a proposal’s pool/vote window ends) - -### Key principle: authoritative writes - -All state-changing actions go through the API and are validated against: - -1. signature-authenticated user session -2. eligibility gate (active human node) -3. domain invariants (stage constraints, one-vote rules, etc.) - -## 3) Suggested code modules (implementation shape) - -This repo is currently a single frontend app. The backend can live alongside it as: - -- `api/routes/*` (API handlers routes) -- `api/_lib/*` (shared server helpers) -- `api/_lib/pages.d.ts` (local typing for `ApiHandler` in editors) -- `api/tsconfig.json` (separate TS project for `api/`) -- `db/*` (Drizzle schema + migrations) -- `scripts/*` (seed/import jobs) -- `src/server/domain/*` (future: shared domain engine) - -If the repo is later split into a monorepo, these become: - -- `packages/domain` -- `apps/api` -- `apps/web` - -## 4) API surface (v1) - -### Authentication - -- `POST /api/auth/nonce` → `{ address }` → `{ nonce }` -- `POST /api/auth/verify` → `{ address, nonce, signature }` → session cookie/JWT -- `POST /api/auth/logout` - -### Gating - -- `GET /api/gate/status` → `{ eligible: boolean, reason?: string, expiresAt: string }` - -Eligibility source (v1): - -- Query Humanode mainnet RPC for “active human node” status via `Session::Validators` (current validator set membership). - -### Reads - -- `GET /api/feed?cursor=...&stage=...` -- `GET /api/chambers` -- `GET /api/chambers/:id` -- `GET /api/proposals?stage=...` -- `GET /api/proposals/:id/pool` -- `GET /api/proposals/:id/chamber` -- `GET /api/proposals/:id/formation` -- `GET /api/proposals/:id/timeline` -- `GET /api/proposals/drafts` -- `GET /api/proposals/drafts/:id` -- `GET /api/courts` -- `GET /api/courts/:id` -- `GET /api/humans` -- `GET /api/humans/:id` -- `GET /api/clock` (simulation time) -- `GET /api/me` (profile + eligibility snapshot) - -### Writes (commands) - -Prefer a single command endpoint so invariants are centralized: - -- `POST /api/command` → `{ type, payload, idempotencyKey? }` - -Implemented in v1: - -- `proposal.draft.save` (Phase 12) -- `proposal.draft.delete` (Phase 12) -- `proposal.submitToPool` (Phase 12) -- `pool.vote` (upvote/downvote) -- `chamber.vote` (yes/no/abstain + optional CM score 1–10 on yes votes) -- `formation.join` -- `formation.milestone.submit` -- `formation.milestone.requestUnlock` -- `court.case.report` -- `court.case.verdict` -- `delegation.set` -- `delegation.clear` - -Planned (v2+) examples: - -- - -## 5) Data model (tables) — minimal set - -These tables support the workflows and auditability; the system starts lean and expands as features move off the `read_models` bridge. - -### Identity / auth - -- `users` (account): `id`, `address`, `displayName`, `createdAt` -- `auth_nonces`: `address`, `nonce`, `expiresAt`, `usedAt` -- `sessions` (if not JWT-only): `id`, `userId`, `expiresAt` -- `idempotency_keys`: stores request/response pairs for `POST /api/command` retries - -### Eligibility cache (mainnet gating) - -- `eligibility_cache`: - - `address` - - `isActiveHumanNode` (boolean) - - `checkedAt`, `checkedAtBlock?` - - `source` (`rpc`) - - `expiresAt` - - `reasonCode?` - -### Transitional read models (Phase 2c → Phase 4 bridge) - -To avoid rewriting the UI while we build normalized tables + an event log, we seed mock-equivalent payloads into a single table: - -- `read_models`: `{ key, payload (jsonb), updatedAt }` - -This allows early `GET /api/...` endpoints to serve the exact DTOs expected by `docs/simulation/vortex-simulation-api-contract.md` while we progressively replace `read_models` with real projections. - -Local dev modes for reads: - -- DB mode: reads start from `read_models` using `DATABASE_URL` and may prefer canonical domain tables where applicable (e.g. proposals). -- Inline fixtures: `READ_MODELS_INLINE=true` (no DB). -- Clean/empty mode: `READ_MODELS_INLINE_EMPTY=true` (list endpoints return `{ items: [] }` and singleton endpoints return minimal defaults). - -### Governance time - -Current repo: - -- `clock_state`: `currentEra`, `updatedAt` - -Implemented: - -- `era_snapshots`: per-era aggregates (v1: `activeGovernors`) -- `era_user_activity`: per-era counters per address (pool/chamber/courts/formation actions) -- `era_rollups`: per-era rollup output (requirements + active set size for next era) -- `era_user_status`: per-era derived status per address (Ahead/Stable/At risk/etc.) -- `epoch_uptime`: optional (per address, per epoch/week) if Bioauth uptime is modeled in v1/v2 - -### Current tables (implemented) - -- `read_models`: transitional DTO storage for the current UI -- `proposals`: canonical proposal rows (Phase 14) -- `chambers`: canonical chambers (Phase 18) -- `chamber_memberships`: voting eligibility granted by accepted proposals (Phase 17) -- `events`: append-only feed/audit log -- `pool_votes`: unique (proposalId, voterAddress) → up/down -- `chamber_votes`: unique (proposalId, voterAddress) → yes/no/abstain + optional `score` (1–10) on yes votes -- `cm_awards`: CM awards emitted when proposals pass (unique per proposal) -- `idempotency_keys`: stored responses for idempotent command retries -- `era_snapshots`: per-era aggregates (v1: active governors baseline) -- `era_user_activity`: per-era action counters per address -- `era_rollups`: per-era rollup output (requirements + active set size for next era) -- `era_user_status`: per-era derived status per address -- `formation_projects`: per-proposal Formation counters/baselines -- `formation_team`: extra Formation joiners (beyond seed baseline) -- `formation_milestones`: per-proposal milestone status (`todo`/`submitted`/`unlocked`) -- `formation_milestone_events`: append-only milestone submissions/unlock requests -- `proposal_drafts`: author-owned proposal drafts (Phase 12) -- `delegations`: chamber-scoped delegation graph (Phase 29) -- `delegation_events`: append-only delegation history (Phase 29) - -### Optional future domain tables (v2+) - -- `proposal_stage_transitions`: append-only transition history (v1 transitions exist, but are not stored as a dedicated transitions table) -- `proposal_attachments`: `proposalId`, `title`, `href` -- `cm_lcm`: per-chamber LCM materialization (v1 derives ACM deltas from `cm_awards`) -- `tiers`: materialized tier state (v1 derives statuses via era rollups) - -Current repo behavior: - -- `pool_votes` exists and is written via `POST /api/command` (`pool.vote`). -- `chamber_votes` exists and is written via `POST /api/command` (`chamber.vote`). -- `cm_awards` exists and is written when proposals pass chamber vote (derived from average yes `score`). -- Read pages overlay live counts: - - `GET /api/proposals/:id/pool` overlays upvotes/downvotes from `pool_votes` - - `GET /api/proposals/:id/chamber` overlays yes/no/abstain from `chamber_votes` -- Stage transitions are applied deterministically by a single transition authority: - - canonical proposals are updated in `proposals` - - compatibility DTO payloads in `read_models` may also be updated to keep the UI stable -- Proposal timeline is event-backed: - - `GET /api/proposals/:id/timeline` - - `events.type = "proposal.timeline.v1"` - -### Formation - -Implemented (v1): - -- Commands: - - `formation.join` fills team slots (capped by total). - - `formation.milestone.submit` marks a milestone as submitted (does not increase completion yet). - - `formation.milestone.requestUnlock` unlocks a submitted milestone (mock acceptance for v1). -- Read overlay: - - `GET /api/proposals/:id/formation` overlays `teamSlots`, `milestones`, and `progress` from Formation state. -- Tables: - - `formation_projects`: `proposalId`, totals + baselines derived from the Formation read model - - `formation_team`: `(proposalId, memberAddress)` join records (beyond the baseline) - - `formation_milestones`: `(proposalId, milestoneIndex)` state - - `formation_milestone_events`: append-only milestone events - -### Courts - -Implemented (v1): - -- Commands: - - `court.case.report` (per-user report; updates `reports` + can open a live session) - - `court.case.verdict` (guilty/not_guilty; one-per-user; only when live; ends after enough verdicts) -- Tables: - - `court_cases`: current status + baseline report count (seeded from read model) - - `court_reports`: per-user report uniqueness - - `court_verdicts`: per-user verdicts -- Read overlay: - - `GET /api/courts` and `GET /api/courts/:id` overlay live `reports` + `status` from stored state - -Planned (later phases): - -- `court_evidence`: `caseId`, `title`, `href`, `addedByUserId`, `createdAt` -- `court_outcomes`: `caseId`, `result`, `recommendationsJson` - -### Delegation - -Implemented (v1): - -- Commands: - - `delegation.set` - - `delegation.clear` -- Tables: - - `delegations`: `(chamber_id, delegator_address) → delegatee_address` - - `delegation_events`: append-only history (`set` / `clear`) -- Semantics: - - delegation affects **chamber vote weighting** only - - proposal-pool attention remains direct-only - - a delegator’s voice only counts if the delegator did **not** cast a chamber vote themselves - -### Feed / audit trail - -- `events` (append-only): - - `id`, `type`, `actorUserId?`, `entityType`, `entityId`, `payloadJson`, `createdAt` - -In the current repo implementation, `events` exists as an append-only Postgres table and `GET /api/feed` can be served from it in DB mode. - -## 6) Mapping: processes → modules → APIs → tables/events - -This section maps each workflow from `docs/simulation/vortex-simulation-processes.md` to concrete tech. - -### 2.0 Authentication + gating - -- **Module:** `auth`, `gate` -- **API:** `/api/auth/nonce`, `/api/auth/verify`, `/api/gate/status` -- **Tables:** `users`, `auth_nonces`, `eligibility_cache` -- **Events:** `auth.logged_in`, `gate.checked` - -### 2.0b Request hardening (rate limits + action locks) - -- **Module:** `hardening` -- **API:** - - `POST /api/command` (rate limited per IP + per address) - - `POST /api/command` (optional per-era quotas for counted actions) - - `POST /api/admin/users/lock`, `POST /api/admin/users/unlock` (admin-only) - - `GET /api/admin/users/locks`, `GET /api/admin/users/:address` (inspection) - - `GET /api/admin/audit` (admin actions audit log) - - `GET /api/admin/stats` (ops metrics snapshot) - - `POST /api/admin/writes/freeze` (toggle global write freeze) -- **Tables:** - - `api_rate_limits` (DB mode) - - `era_user_activity` (per-era counters used for quota enforcement and rollups) - - `user_action_locks` (DB mode) - - `events` (DB mode; admin actions are logged as `admin.action.v1`) - - `admin_state` (DB mode; write freeze flag) -- **Notes:** - - Rate limiting and action locks are enforced server-side for all state changes so the simulation stays usable during community testing. - - Era quotas enforce a cap on new counted actions (vote/report/join) while still allowing edits (changing a vote) without consuming additional quota. - - Admin actions are appended to an audit log (memory mode for local dev, `events` table for DB mode). - - A write freeze can be toggled via admin endpoints (and overridden by a deploy-time env kill switch). - -### 2.1 Onboarding (Human → Human Node → Governor) - -Current repo: - -- **Module:** `auth`, `gate` -- **API:** `GET /api/me`, `GET /api/gate/status` -- **Tables:** `users`, `eligibility_cache` - -Planned: - -- **Module:** `identity`, `eligibility`, `tiers` -- **Tables:** `tiers` -- **Events:** `tier.updated` - -### 2.2 Era rollup (cron) - -Current repo: - -- **Module:** `clock`, `eraStore` -- **API:** `GET /api/clock`, `POST /api/clock/advance-era` -- **Tables:** `clock_state`, `era_snapshots`, `era_user_activity` - -Planned: - -- **Module:** `governanceTime`, `tiers`, `cm`, `proposals`, `feed` -- **Tables:** `tiers` (or equivalent tier status table), proposal aggregates, `events` -- **Events:** `era.rolled`, `quorum.baseline_updated`, `proposal.advanced` - -### 2.3 Proposal drafting (wizard) - -Current repo: - -- **Module:** `proposals.draft` -- **API:** `POST /api/command` (`proposal.draft.save`, `proposal.submitToPool`) -- **Tables:** `proposal_drafts`, `proposals` -- **Events:** timeline entries in `events` (`proposal.timeline.v1`) - -### 2.4 Proposal pool (attention) - -- **Module:** `proposals.pool` -- **API:** `POST /api/command` (`pool.vote`) -- **Tables:** `pool_votes`, `events` -- **Derived:** pool quorum metrics computed from votes + era snapshot baselines -- **Events:** `pool.vote_cast`, `pool.quorum_met`, `proposal.moved_to_vote` - -### 2.5 Chamber vote (decision) - -- **Module:** `proposals.vote`, `cm` -- **API:** `POST /api/command` (`chamber.vote`) -- **Tables:** `chamber_votes`, `cm_awards`, `events` (+ transitional `read_models` stage updates) -- **Events:** `vote.cast`, `vote.quorum_met`, `proposal.passed`, `proposal.rejected`, `cm.awarded` - -### 2.6 Formation execution (projects) - -- **Module:** `formation` -- **API:** `POST /api/command` (`formation.join`, `formation.milestone.submit`, `formation.milestone.requestUnlock`) -- **Tables:** `formation_projects`, `formation_team`, `formation_milestones`, `formation_milestone_events` -- **Events:** `formation.joined`, `formation.milestone_submitted`, `formation.unlock_requested`, `formation.milestone_accepted` - -### 2.7 Courts (case lifecycle) - -- **Module:** `courts` -- **API:** `POST /api/command` (`court.case.report`, `court.case.verdict`) -- **Tables:** `court_cases`, `court_reports`, `court_verdicts` -- **Events:** `court.case_opened`, `court.report_added`, `court.session_live`, `court.verdict_cast`, `court.case_closed` - -### 2.8 Delegation management - -- **Module:** `delegation` -- **API:** `POST /api/command` (`delegation.set`, `delegation.clear`) -- **Tables:** `delegations`, `delegation_events` -- **Events:** `delegation.set`, `delegation.cleared` - -### 2.9 Chambers directory + chamber detail - -- **Module:** `chambers` -- **API:** `GET /api/chambers`, `GET /api/chambers/:id` -- **Tables:** `chambers`, `chamber_memberships`, `proposals` (derived counts), optional `read_models` fallback -- **Events:** chamber create/dissolve side-effects are appended via proposal timeline events (and/or feed events, depending on stage) - -### 2.10 Invision insights - -- **Module:** `invision` (derived scoring) -- **API:** `GET /api/humans/:id` (includes insights) -- **Tables:** derived from `events`, proposals/courts/milestones; optionally `invision_snapshots` -- **Events:** `invision.updated` (optional) - -## 7) Concurrency + integrity (why single-writer coordinator may be needed) - -If multiple users vote at once, race conditions must be prevented: - -- double-voting -- inconsistent quorum counters -- stage transitions happening twice - -Two approaches: - -- **DB constraints + transactions** (Postgres can do this well). -- **Durable Object per entity** (proposal/case) that serializes commands. - -Recommendation: - -- Start with DB constraints + transactions. -- Add DOs for high-contention entities (popular proposals) or for simpler correctness in Worker code. - -## 8) Anti-abuse controls (even for eligible human nodes) - -- Per-era action limits (proposal submissions, reports, etc.) -- Idempotency keys for commands (client retries) -- Rate limiting per address (Worker middleware) -- Court/report spam prevention (minimum stake is out-of-scope unless added as a simulated rule) - -## 9) Migration path from today’s mock data - -- The frontend renders from `/api/*` reads; mock data is not part of the runtime anymore. -- Transitional read-model payloads are maintained as seed fixtures in `db/seed/fixtures/*` (and stored in Postgres `read_models` in DB mode). -- Next migrations continue moving from `read_models` to canonical tables + event-backed projections, while keeping DTOs stable. diff --git a/docs/simulation/vortex-simulation-v1-constants.md b/docs/simulation/vortex-simulation-v1-constants.md deleted file mode 100644 index c4af803..0000000 --- a/docs/simulation/vortex-simulation-v1-constants.md +++ /dev/null @@ -1,106 +0,0 @@ -# Vortex Simulation Backend — v1 Constants - -This file records the v1 decisions used by the simulation backend so implementation and tests share the same assumptions. - -## Stack decisions - -- **Database:** Postgres (v1 recommendation: **Neon**, for edge/serverless connectivity) -- **On-chain read source:** Humanode mainnet RPC (no Subscan dependency for v1) -- **Eligibility (“active Human Node”):** derived from mainnet RPC reads of `Session::Validators` (current validator set membership). The Humanode RPC URL is configured via `HUMANODE_RPC_URL` or `public/sim-config.json`. - -## Simulation time decisions - -- **Era length:** configured off-chain by the simulation (not a chain parameter) - - v1 default: **7 days** (can still be advanced manually via `/api/clock/advance-era`) - - vote/pool stage windows default to: - - pool: **7 days** - - vote: **7 days** -- **Per-era activity requirements:** configured off-chain by env vars (v1 defaults) - - `SIM_REQUIRED_POOL_VOTES=1` - - `SIM_REQUIRED_CHAMBER_VOTES=1` - - `SIM_REQUIRED_COURT_ACTIONS=0` - - `SIM_REQUIRED_FORMATION_ACTIONS=0` - -## Current v1 progress checkpoints - -- Backend exists in the repo (`api/`, DB schema/migrations under `db/`, seed script under `scripts/`). -- Read endpoints exist and are wired to either: - - Postgres-backed reads from `read_models` (requires `DATABASE_URL` + `yarn db:migrate && yarn db:seed`), or - - Inline seed reads via `READ_MODELS_INLINE=true` (no DB required). - - Empty reads via `READ_MODELS_INLINE_EMPTY=true` (clean UI; list endpoints return `{ items: [] }`). -- DB can be wiped without dropping schema via `yarn db:clear` (requires `DATABASE_URL`). -- Event log scaffold exists as `events` (append-only table) and `GET /api/feed` can be backed by it in DB mode. -- Phase 6 write slice exists: - - `POST /api/command` supports `pool.vote` (auth + gate + idempotency). - - `pool_votes` stores one vote per address per proposal and `GET /api/proposals/:id/pool` overlays live counts. - - Pool quorum evaluation exists (`evaluatePoolQuorum`) and proposals auto-advance from pool → vote by updating the `proposals:list` read model. -- Phase 7 write slice exists: - - `POST /api/command` supports `chamber.vote` (auth + gate + idempotency). - - `chamber_votes` stores one vote per address per proposal and `GET /api/proposals/:id/chamber` overlays live counts. - - Vote quorum + passing evaluation exists (`evaluateChamberQuorum`) and proposals auto-advance from vote → build when quorum + passing are met. - - Formation is optional: formation state is only seeded/usable when `formationEligible` is true on the proposal payload. - - CM awards v1 are recorded in `cm_awards` when proposals pass (derived from average yes `score`), and `/api/humans*` overlays ACM deltas from awards. -- Phase 8 write slice exists: - - Formation tables exist: - - `formation_projects`, `formation_team`, `formation_milestones`, `formation_milestone_events` - - `POST /api/command` supports: - - `formation.join` - - `formation.milestone.submit` - - `formation.milestone.requestUnlock` - - `GET /api/proposals/:id/formation` overlays live Formation state (team slots, milestones, progress). - - Formation commands are rejected when a proposal does not require Formation (`formationEligible=false`). -- Phase 9 write slice exists: - - Courts tables exist: - - `court_cases`, `court_reports`, `court_verdicts` - - `POST /api/command` supports: - - `court.case.report` - - `court.case.verdict` - - `GET /api/courts` and `GET /api/courts/:id` overlay live `reports` and `status`. -- Phase 10a write slice exists: - - Era tracking tables exist: - - `era_snapshots` (per-era active governors baseline) - - `era_user_activity` (per-era action counters per address) - - Active governors baseline defaults to `150` and can be configured via `SIM_ACTIVE_GOVERNORS` (or `VORTEX_ACTIVE_GOVERNORS`). - - Quorum denominators are chamber-scoped: - - when no prior era rollup exists, denominators use `min(activeGovernorsBaseline, eligibleGovernorsInChamber)` - - when a prior era rollup exists, denominators use the rollup’s active set filtered to `eligibleGovernorsInChamber` - - `GET /api/clock` returns the current era + active governors baseline (used as the fallback cap when rollups are missing). - - `GET /api/my-governance` overlays per-era `done` counts for authenticated users. -- Phase 10b write slice exists: - - `POST /api/clock/rollup-era` computes: - - per-era governing status buckets (Ahead/Stable/Falling behind/At risk/Losing status) - - `activeGovernorsNextEra` based on configured requirements - - next era baseline update (next era uses `activeGovernorsNextEra`) - - Rollup output is stored in: - - `era_rollups`, `era_user_status` - -- Phase 17 write slice exists: - - Chamber vote eligibility is enforced (paper-aligned): - - Specialization chamber: vote requires an accepted proposal in that chamber. - - General chamber: vote requires an accepted proposal in any chamber. - - Genesis bootstrap for the first votes can be configured via `public/sim-config.json` (`genesisChamberMembers`). - - Eligibility is persisted in `chamber_memberships` and granted when a proposal is accepted (vote → build transition). - - Dev bypass: `DEV_BYPASS_CHAMBER_ELIGIBILITY=true` disables chamber-membership checks (local/testing only). - -- Phase 18 write slice exists: - - Chambers are canonical in `chambers` (auto-seeded from `public/sim-config.json` → `genesisChambers`). - - General-chamber proposal outcomes can create/dissolve chambers (simulated via proposal payload `metaGovernance`). - -## Paper alignment notes (v1) - -- Pool attention quorum: - - paper: **22% engaged** + **≥10% upvotes** - - simulation v1: **22% engaged** + **≥10% upvotes** -- Vote quorum (33%) is aligned; passing uses **66.6% + 1 yes vote within quorum** in v1. -- Delegation and veto are implemented in v1 (vote-weight aggregation + veto slow-down). -- Chamber multiplier voting is implemented in v1; Meritocratic Measure (MM) is not implemented yet. - -## Post-v1 roadmap (v2+) - -v1 constants are intentionally kept small and testable. The simulation already includes drafts, canonical proposals/chambers, deterministic transitions, optional time windows, and proposal timelines. - -The next paper-aligned expansions (v2+) are: - -- Meritocratic Measure (MM) from Formation delivery/review. - -Source of truth: `docs/simulation/vortex-simulation-implementation-plan.md`. diff --git a/docs/updates/dev-log-1.md b/docs/updates/dev-log-1.md deleted file mode 100644 index 08af8c5..0000000 --- a/docs/updates/dev-log-1.md +++ /dev/null @@ -1,114 +0,0 @@ -# Dev Log #1 — Vortex Simulator v0.1 - -**Date:** 2025-12-24 - -## TL;DR - -This update turns Vortex from a **UI-only mock** into a **stateful governance simulator** with a real backend: - -- Wallet auth (signature-based) + **real Humanode mainnet gating** -- Proposal drafts + multi-step wizard (project vs system/meta-governance flows) -- Proposal pools → chamber voting → acceptance (with real quorums/thresholds) -- Chamber lifecycle automation (create/dissolve via General chamber proposals) -- Formation execution for project proposals (team slots + milestones) -- Courts/disputes + a canonical event timeline/feed -- Admin tools for moderation/ops (freeze, user locks, audit trail) - -## What changed (as features) - -### 1) A real backend exists now - -Previous master was mostly front-end mock data. This release adds a working simulation backend (API handlers) that the UI talks to via `/api/*`, including: - -- Command API for actions (`pool.vote`, `chamber.vote`, `formation.join`, court actions, admin actions) -- Read endpoints for every page model (proposals, chambers, courts, humans, factions, formation, invision, my-governance) -- A canonical event stream (feed + per-proposal timeline) so the UI reflects what actually happened, in order - -### 2) Real Humanode mainnet gating (validator-only actions) - -The simulator now supports a “real gate” mode: anyone can browse, but actions are blocked unless the connected wallet is an **active validator** on Humanode mainnet. - -Under the hood, gating reads the mainnet validator set (via RPC) and caches results so the UI can show: - -- Wallet status (connected / disconnected) -- Gate status (active / not active) - -### 3) Proposal creation is now “drafts + templates” - -The proposal wizard is no longer a single hardcoded form. It supports: - -- **Drafts**: save anytime, resume later, submit when ready -- **Project proposals**: “execute something” (may go into Formation after acceptance) -- **System/meta-governance proposals**: “change the system” (e.g. create/dissolve a chamber) - -This matters because system proposals should be realized immediately by the simulator (they change the system state), while project proposals represent broader execution work. - -### 4) Proposal Pools work (quorum of attention) - -Each proposal enters the appropriate pool first. Governors can upvote/downvote. - -- Proposals advance from pool → chamber vote when they meet the attention threshold. -- Vote updates are idempotent and counted toward per-era action quotas. - -### 5) Chamber voting works (quorum + 66.6% + 1) - -Once a proposal reaches chamber vote, governors vote **yes/no/abstain**. - -- Quorum is derived from the active-governor denominator for that chamber/era. -- Passing is the strict rule: **66.6% + 1 vote** among those voting. -- Optional vote scoring on “yes” is used for cognitocratic measure (CM) flows. - -### 6) System proposals can create/dissolve chambers (and they appear in the UI) - -The biggest “it feels real” change: a General-chamber system proposal can be: - -Draft → Proposal Pool → Chamber Vote → **Passed** → chamber is created/dissolved - -When a chamber is created, the simulator seeds initial membership (genesis members + proposer), and the new chamber appears on `/app/chambers` immediately. - -### 7) Formation is now an execution module (for project proposals) - -For proposals that require Formation: - -- Formation pages exist and derive from the accepted proposal -- Team slots can be filled (join) -- Milestones can be submitted and unlocks requested (mock execution loop) - -System proposals bypass Formation by design. - -### 8) Courts/disputes are stateful - -Courts now support a real “case lifecycle” in the simulator: - -- Reports increment and can move cases through statuses -- Verdicts are restricted by case status (e.g. only when live) -- Case events show up in feed/timelines - -### 9) My Governance is backed by era logic - -There’s a real concept of “era activity” in the backend now: - -- Actions count toward an era (with quotas) -- Rollups produce per-user status for the next era -- Denominators for quorums are snapshotted per stage so they don’t drift mid-vote - -### 10) Admin tooling exists (so the demo can be run safely) - -Basic moderation/ops endpoints were added for the simulator: - -- Freeze writes (temporary global pause) -- Lock/unlock users (block actions) -- Audit log for admin actions - -## How to try it (quick) - -- Local full-stack (UI + API): run `yarn dev:full`. -- If `/api/*` is missing: use the API handlers flow described in `docs/simulation/vortex-simulation-local-dev.md`. - -## What’s next - -This v0.1 milestone is “the simulator exists”. Next work should focus on deepening correctness and closing the remaining “paper vs simulator” gaps: - -- Active governance rules and quorums fully derived from era rollups -- Completing the module set for end-to-end realism (delegation, veto UX, more proposal types) -- Hardening persistence mode (Postgres) and deployment configuration diff --git a/drizzle.config.ts b/drizzle.config.ts deleted file mode 100644 index 339dca0..0000000 --- a/drizzle.config.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { defineConfig } from "drizzle-kit"; - -export default defineConfig({ - dialect: "postgresql", - schema: "./db/schema.ts", - out: "./db/migrations", - dbCredentials: { - url: - process.env.DATABASE_URL ?? - "postgres://postgres:postgres@localhost:5432/vortex", - }, -}); diff --git a/functions/_lib/pages.d.ts b/functions/_lib/pages.d.ts deleted file mode 100644 index 15922df..0000000 --- a/functions/_lib/pages.d.ts +++ /dev/null @@ -1,7 +0,0 @@ -// Minimal runtime handler types for editor/typecheck support. - -type ApiHandler> = (context: { - request: Request; - env: Env; - params?: Record; -}) => Response | Promise; diff --git a/functions/api/admin/audit/index.ts b/functions/api/admin/audit/index.ts deleted file mode 100644 index 661316f..0000000 --- a/functions/api/admin/audit/index.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { assertAdmin } from "../../../_lib/clockStore.ts"; -import { listAdminAudit } from "../../../_lib/adminAuditStore.ts"; -import { errorResponse, jsonResponse } from "../../../_lib/http.ts"; - -const DEFAULT_LIMIT = 50; - -export const onRequestGet: ApiHandler = async (context) => { - try { - assertAdmin(context); - } catch (error) { - const status = (error as Error & { status?: number }).status ?? 500; - return errorResponse(status, (error as Error).message); - } - - const url = new URL(context.request.url); - const cursor = url.searchParams.get("cursor"); - let beforeSeq: number | undefined; - if (cursor !== null) { - const parsed = Number.parseInt(cursor, 10); - if (!Number.isFinite(parsed) || parsed < 0) { - return errorResponse(400, "Invalid cursor"); - } - beforeSeq = parsed; - } - - const page = await listAdminAudit(context.env, { - beforeSeq, - limit: DEFAULT_LIMIT, - }); - const response = - page.nextSeq !== undefined - ? { items: page.items, nextCursor: String(page.nextSeq) } - : { items: page.items }; - return jsonResponse(response); -}; diff --git a/functions/api/admin/stats.ts b/functions/api/admin/stats.ts deleted file mode 100644 index 955febf..0000000 --- a/functions/api/admin/stats.ts +++ /dev/null @@ -1,199 +0,0 @@ -import { eq, sql } from "drizzle-orm"; - -import { - adminState, - apiRateLimits, - chamberVotes, - cmAwards, - courtCases, - courtReports, - courtVerdicts, - eraRollups, - eraUserActivity, - events, - formationMilestoneEvents, - formationTeam, - poolVotes, - userActionLocks, - users, -} from "../../../db/schema.ts"; -import { createDb } from "../../_lib/db.ts"; -import { getCommandRateLimitConfig } from "../../_lib/apiRateLimitStore.ts"; -import { assertAdmin, createClockStore } from "../../_lib/clockStore.ts"; -import { getEraQuotaConfig } from "../../_lib/eraQuotas.ts"; -import { listEraUserActivity } from "../../_lib/eraStore.ts"; -import { errorResponse, jsonResponse } from "../../_lib/http.ts"; - -type Env = Record; - -function sum( - rows: Array<{ - poolVotes: number; - chamberVotes: number; - courtActions: number; - formationActions: number; - }>, -) { - return rows.reduce( - (acc, r) => ({ - poolVotes: acc.poolVotes + r.poolVotes, - chamberVotes: acc.chamberVotes + r.chamberVotes, - courtActions: acc.courtActions + r.courtActions, - formationActions: acc.formationActions + r.formationActions, - }), - { poolVotes: 0, chamberVotes: 0, courtActions: 0, formationActions: 0 }, - ); -} - -async function getWritesFrozen(env: Env): Promise { - if (env.SIM_WRITE_FREEZE === "true") return true; - if (env.READ_MODELS_INLINE === "true") return false; - if (!env.DATABASE_URL) return false; - const db = createDb(env); - await db.insert(adminState).values({ id: 1 }).onConflictDoNothing(); - const rows = await db - .select({ writesFrozen: adminState.writesFrozen }) - .from(adminState) - .where(eq(adminState.id, 1)) - .limit(1); - return rows[0]?.writesFrozen ?? false; -} - -export const onRequestGet: ApiHandler = async (context) => { - try { - assertAdmin(context); - } catch (error) { - const status = (error as Error & { status?: number }).status ?? 500; - return errorResponse(status, (error as Error).message); - } - - const clock = createClockStore(context.env); - const { currentEra } = await clock.get(); - const rate = getCommandRateLimitConfig(context.env); - const quotas = getEraQuotaConfig(context.env); - const writesFrozen = await getWritesFrozen(context.env); - - if (!context.env.DATABASE_URL) { - const rows = await listEraUserActivity(context.env, { era: currentEra }); - const totals = sum(rows); - return jsonResponse({ - currentEra, - writesFrozen, - config: { - rateLimitsPerMinute: rate, - eraQuotas: quotas, - }, - currentEraActivity: { - rows: rows.length, - totals, - }, - db: null, - }); - } - - const db = createDb(context.env); - const now = new Date(); - - const [ - usersCount, - eventsCount, - adminAuditCount, - feedEventCount, - poolVotesCount, - chamberVotesCount, - cmAwardsCount, - formationTeamCount, - formationMilestoneEventsCount, - courtCasesCount, - courtReportsCount, - courtVerdictsCount, - rateLimitBucketsCount, - activeLocksCount, - currentEraActivityRowsCount, - currentEraActivityTotals, - rollupsCount, - ] = await Promise.all([ - db.select({ n: sql`count(*)` }).from(users), - db.select({ n: sql`count(*)` }).from(events), - db - .select({ n: sql`count(*)` }) - .from(events) - .where(sql`${events.type} = 'admin.action.v1'`), - db - .select({ n: sql`count(*)` }) - .from(events) - .where(sql`${events.type} = 'feed.item.v1'`), - db.select({ n: sql`count(*)` }).from(poolVotes), - db.select({ n: sql`count(*)` }).from(chamberVotes), - db.select({ n: sql`count(*)` }).from(cmAwards), - db.select({ n: sql`count(*)` }).from(formationTeam), - db.select({ n: sql`count(*)` }).from(formationMilestoneEvents), - db.select({ n: sql`count(*)` }).from(courtCases), - db.select({ n: sql`count(*)` }).from(courtReports), - db.select({ n: sql`count(*)` }).from(courtVerdicts), - db.select({ n: sql`count(*)` }).from(apiRateLimits), - db - .select({ n: sql`count(*)` }) - .from(userActionLocks) - .where(sql`${userActionLocks.lockedUntil} > ${now}`), - db - .select({ n: sql`count(*)` }) - .from(eraUserActivity) - .where(sql`${eraUserActivity.era} = ${currentEra}`), - db - .select({ - poolVotes: sql`sum(${eraUserActivity.poolVotes})`, - chamberVotes: sql`sum(${eraUserActivity.chamberVotes})`, - courtActions: sql`sum(${eraUserActivity.courtActions})`, - formationActions: sql`sum(${eraUserActivity.formationActions})`, - }) - .from(eraUserActivity) - .where(sql`${eraUserActivity.era} = ${currentEra}`), - db.select({ n: sql`count(*)` }).from(eraRollups), - ]); - - return jsonResponse({ - currentEra, - writesFrozen, - config: { - rateLimitsPerMinute: rate, - eraQuotas: quotas, - }, - db: { - users: Number(usersCount[0]?.n ?? 0), - events: { - total: Number(eventsCount[0]?.n ?? 0), - feedItems: Number(feedEventCount[0]?.n ?? 0), - adminAudit: Number(adminAuditCount[0]?.n ?? 0), - }, - actions: { - poolVotes: Number(poolVotesCount[0]?.n ?? 0), - chamberVotes: Number(chamberVotesCount[0]?.n ?? 0), - cmAwards: Number(cmAwardsCount[0]?.n ?? 0), - formationTeam: Number(formationTeamCount[0]?.n ?? 0), - formationMilestoneEvents: Number( - formationMilestoneEventsCount[0]?.n ?? 0, - ), - courtCases: Number(courtCasesCount[0]?.n ?? 0), - courtReports: Number(courtReportsCount[0]?.n ?? 0), - courtVerdicts: Number(courtVerdictsCount[0]?.n ?? 0), - }, - hardening: { - rateLimitBuckets: Number(rateLimitBucketsCount[0]?.n ?? 0), - activeLocks: Number(activeLocksCount[0]?.n ?? 0), - }, - eras: { - rollups: Number(rollupsCount[0]?.n ?? 0), - currentEraActivityRows: Number(currentEraActivityRowsCount[0]?.n ?? 0), - currentEraTotals: { - poolVotes: Number(currentEraActivityTotals[0]?.poolVotes ?? 0), - chamberVotes: Number(currentEraActivityTotals[0]?.chamberVotes ?? 0), - courtActions: Number(currentEraActivityTotals[0]?.courtActions ?? 0), - formationActions: Number( - currentEraActivityTotals[0]?.formationActions ?? 0, - ), - }, - }, - }, - }); -}; diff --git a/functions/api/admin/users/[address].ts b/functions/api/admin/users/[address].ts deleted file mode 100644 index 387c567..0000000 --- a/functions/api/admin/users/[address].ts +++ /dev/null @@ -1,54 +0,0 @@ -import { assertAdmin } from "../../../_lib/clockStore.ts"; -import { createActionLocksStore } from "../../../_lib/actionLocksStore.ts"; -import { getEraQuotaConfig } from "../../../_lib/eraQuotas.ts"; -import { getUserEraActivity } from "../../../_lib/eraStore.ts"; -import { errorResponse, jsonResponse } from "../../../_lib/http.ts"; - -export const onRequestGet: ApiHandler<{ address: string }> = async ( - context, -) => { - try { - assertAdmin(context); - } catch (error) { - const status = (error as Error & { status?: number }).status ?? 500; - return errorResponse(status, (error as Error).message); - } - - const address = (context.params.address ?? "").trim(); - if (!address) return errorResponse(400, "Missing address"); - - const activity = await getUserEraActivity(context.env, { address }); - const quotas = getEraQuotaConfig(context.env); - const lock = await createActionLocksStore(context.env).getActiveLock(address); - - const remaining = { - poolVotes: - quotas.maxPoolVotes === null - ? null - : Math.max(0, quotas.maxPoolVotes - activity.counts.poolVotes), - chamberVotes: - quotas.maxChamberVotes === null - ? null - : Math.max(0, quotas.maxChamberVotes - activity.counts.chamberVotes), - courtActions: - quotas.maxCourtActions === null - ? null - : Math.max(0, quotas.maxCourtActions - activity.counts.courtActions), - formationActions: - quotas.maxFormationActions === null - ? null - : Math.max( - 0, - quotas.maxFormationActions - activity.counts.formationActions, - ), - }; - - return jsonResponse({ - address, - era: activity.era, - counts: activity.counts, - quotas, - remaining, - lock, - }); -}; diff --git a/functions/api/admin/users/lock.ts b/functions/api/admin/users/lock.ts deleted file mode 100644 index 21b4f8d..0000000 --- a/functions/api/admin/users/lock.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { z } from "zod"; - -import { assertAdmin } from "../../../_lib/clockStore.ts"; -import { createActionLocksStore } from "../../../_lib/actionLocksStore.ts"; -import { appendAdminAudit } from "../../../_lib/adminAuditStore.ts"; -import { errorResponse, jsonResponse, readJson } from "../../../_lib/http.ts"; - -const schema = z.object({ - address: z.string().min(1), - lockedUntil: z.string().min(1), - reason: z.string().min(1).optional(), -}); - -export const onRequestPost: ApiHandler = async (context) => { - try { - assertAdmin(context); - } catch (error) { - const status = (error as Error & { status?: number }).status ?? 500; - return errorResponse(status, (error as Error).message); - } - - let body: unknown; - try { - body = await readJson(context.request); - } catch (error) { - return errorResponse(400, (error as Error).message); - } - - const parsed = schema.safeParse(body); - if (!parsed.success) { - return errorResponse(400, "Invalid body", { issues: parsed.error.issues }); - } - - const lockedUntil = new Date(parsed.data.lockedUntil); - if (Number.isNaN(lockedUntil.getTime())) { - return errorResponse(400, "Invalid lockedUntil"); - } - - await createActionLocksStore(context.env).setLock({ - address: parsed.data.address, - lockedUntil, - reason: parsed.data.reason ?? null, - }); - - await appendAdminAudit(context.env, { - action: "user.lock", - targetAddress: parsed.data.address, - lockedUntil: lockedUntil.toISOString(), - reason: parsed.data.reason ?? null, - }); - - return jsonResponse({ ok: true }); -}; diff --git a/functions/api/admin/users/locks.ts b/functions/api/admin/users/locks.ts deleted file mode 100644 index 73437e7..0000000 --- a/functions/api/admin/users/locks.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { assertAdmin } from "../../../_lib/clockStore.ts"; -import { createActionLocksStore } from "../../../_lib/actionLocksStore.ts"; -import { errorResponse, jsonResponse } from "../../../_lib/http.ts"; - -export const onRequestGet: ApiHandler = async (context) => { - try { - assertAdmin(context); - } catch (error) { - const status = (error as Error & { status?: number }).status ?? 500; - return errorResponse(status, (error as Error).message); - } - - const locks = await createActionLocksStore(context.env).listActiveLocks(); - return jsonResponse({ items: locks }); -}; diff --git a/functions/api/admin/users/unlock.ts b/functions/api/admin/users/unlock.ts deleted file mode 100644 index df64197..0000000 --- a/functions/api/admin/users/unlock.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { z } from "zod"; - -import { assertAdmin } from "../../../_lib/clockStore.ts"; -import { createActionLocksStore } from "../../../_lib/actionLocksStore.ts"; -import { appendAdminAudit } from "../../../_lib/adminAuditStore.ts"; -import { errorResponse, jsonResponse, readJson } from "../../../_lib/http.ts"; - -const schema = z.object({ - address: z.string().min(1), -}); - -export const onRequestPost: ApiHandler = async (context) => { - try { - assertAdmin(context); - } catch (error) { - const status = (error as Error & { status?: number }).status ?? 500; - return errorResponse(status, (error as Error).message); - } - - let body: unknown; - try { - body = await readJson(context.request); - } catch (error) { - return errorResponse(400, (error as Error).message); - } - - const parsed = schema.safeParse(body); - if (!parsed.success) { - return errorResponse(400, "Invalid body", { issues: parsed.error.issues }); - } - - await createActionLocksStore(context.env).clearLock(parsed.data.address); - await appendAdminAudit(context.env, { - action: "user.unlock", - targetAddress: parsed.data.address, - }); - return jsonResponse({ ok: true }); -}; diff --git a/functions/api/admin/writes/freeze.ts b/functions/api/admin/writes/freeze.ts deleted file mode 100644 index 4328da5..0000000 --- a/functions/api/admin/writes/freeze.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { z } from "zod"; - -import { assertAdmin } from "../../../_lib/clockStore.ts"; -import { appendAdminAudit } from "../../../_lib/adminAuditStore.ts"; -import { createAdminStateStore } from "../../../_lib/adminStateStore.ts"; -import { errorResponse, jsonResponse, readJson } from "../../../_lib/http.ts"; - -const schema = z.object({ - enabled: z.boolean(), -}); - -export const onRequestPost: ApiHandler = async (context) => { - try { - assertAdmin(context); - } catch (error) { - const status = (error as Error & { status?: number }).status ?? 500; - return errorResponse(status, (error as Error).message); - } - - let body: unknown; - try { - body = await readJson(context.request); - } catch (error) { - return errorResponse(400, (error as Error).message); - } - - const parsed = schema.safeParse(body); - if (!parsed.success) { - return errorResponse(400, "Invalid body", { issues: parsed.error.issues }); - } - - const enabled = parsed.data.enabled; - await createAdminStateStore(context.env).setWritesFrozen(enabled); - - await appendAdminAudit(context.env, { - action: enabled ? "writes.freeze" : "writes.unfreeze", - targetAddress: "global", - }); - - return jsonResponse({ ok: true, writesFrozen: enabled }); -}; diff --git a/functions/api/auth/logout.ts b/functions/api/auth/logout.ts deleted file mode 100644 index e783681..0000000 --- a/functions/api/auth/logout.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { clearSession } from "../../_lib/auth.ts"; -import { jsonResponse } from "../../_lib/http.ts"; - -export const onRequestPost: ApiHandler = async (context) => { - const headers = new Headers(); - await clearSession(headers, context.env, context.request.url); - return jsonResponse({ ok: true }, { headers }); -}; diff --git a/functions/api/auth/nonce.ts b/functions/api/auth/nonce.ts deleted file mode 100644 index 217f3d3..0000000 --- a/functions/api/auth/nonce.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { issueNonce } from "../../_lib/auth.ts"; -import { createNonceStore } from "../../_lib/nonceStore.ts"; -import { errorResponse, jsonResponse, readJson } from "../../_lib/http.ts"; -import { getRequestIp } from "../../_lib/requestIp.ts"; -import { canonicalizeHmndAddress } from "../../_lib/address.ts"; - -type Body = { address?: string }; - -export const onRequestPost: ApiHandler = async (context) => { - let body: Body; - try { - body = await readJson(context.request); - } catch (error) { - return errorResponse(400, (error as Error).message); - } - - const address = (body.address ?? "").trim(); - if (!address) return errorResponse(400, "Missing address"); - const canonical = (await canonicalizeHmndAddress(address)) ?? address; - - const headers = new Headers(); - try { - const nonceStore = createNonceStore(context.env); - const requestIp = getRequestIp(context.request); - const rate = await nonceStore.canIssue({ address: canonical, requestIp }); - if (!rate.ok) - return errorResponse(429, "Rate limited", { - retryAfterSeconds: rate.retryAfterSeconds, - }); - - const { nonce, expiresAt } = await issueNonce( - headers, - context.env, - context.request.url, - canonical, - ); - - await nonceStore.create({ - address: canonical, - nonce, - requestIp, - expiresAt: new Date(expiresAt), - }); - return jsonResponse({ nonce }, { headers }); - } catch (error) { - return errorResponse(500, (error as Error).message); - } -}; diff --git a/functions/api/auth/verify.ts b/functions/api/auth/verify.ts deleted file mode 100644 index 986cd6f..0000000 --- a/functions/api/auth/verify.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { issueSession, verifyNonceCookie } from "../../_lib/auth.ts"; -import { envBoolean } from "../../_lib/env.ts"; -import { createNonceStore } from "../../_lib/nonceStore.ts"; -import { verifySubstrateSignature } from "../../_lib/signatures.ts"; -import { errorResponse, jsonResponse, readJson } from "../../_lib/http.ts"; -import { upsertUser } from "../../_lib/userStore.ts"; -import { - canonicalizeHmndAddress, - addressesReferToSameKey, -} from "../../_lib/address.ts"; - -type Body = { - address?: string; - nonce?: string; - signature?: string; -}; - -export const onRequestPost: ApiHandler = async (context) => { - let body: Body; - try { - body = await readJson(context.request); - } catch (error) { - return errorResponse(400, (error as Error).message); - } - - const address = (body.address ?? "").trim(); - const nonce = (body.nonce ?? "").trim(); - const signature = (body.signature ?? "").trim(); - if (!address) return errorResponse(400, "Missing address"); - if (!nonce) return errorResponse(400, "Missing nonce"); - if (!signature) return errorResponse(400, "Missing signature"); - const canonical = (await canonicalizeHmndAddress(address)) ?? address; - - const nonceToken = await verifyNonceCookie(context.request, context.env); - if (!nonceToken) - return errorResponse( - 401, - "Nonce expired or missing; call /api/auth/nonce again", - ); - if (!(await addressesReferToSameKey(nonceToken.address, canonical))) - return errorResponse(401, "Nonce was issued for a different address"); - if (nonceToken.nonce !== nonce) return errorResponse(401, "Nonce mismatch"); - - const nonceStore = createNonceStore(context.env); - const consume = await nonceStore.consume({ address: canonical, nonce }); - if (!consume.ok) { - const message = - consume.reason === "expired" - ? "Nonce expired; call /api/auth/nonce again" - : consume.reason === "used" - ? "Nonce already used; call /api/auth/nonce again" - : "Nonce invalid; call /api/auth/nonce again"; - return errorResponse(401, message, { reason: consume.reason }); - } - - if (!envBoolean(context.env, "DEV_BYPASS_SIGNATURE")) { - const ok = await verifySubstrateSignature({ - address: canonical, - message: nonce, - signature, - }); - if (!ok) return errorResponse(401, "Invalid signature"); - } - - const headers = new Headers(); - await issueSession(headers, context.env, context.request.url, canonical); - await upsertUser(context.env, { address: canonical }); - return jsonResponse({ ok: true, address: canonical }, { headers }); -}; diff --git a/functions/api/chambers/[id].ts b/functions/api/chambers/[id].ts deleted file mode 100644 index a94a361..0000000 --- a/functions/api/chambers/[id].ts +++ /dev/null @@ -1,187 +0,0 @@ -import { errorResponse, jsonResponse } from "../../_lib/http.ts"; -import { getChamber } from "../../_lib/chambersStore.ts"; -import { listProposals } from "../../_lib/proposalsStore.ts"; -import { createReadModelsStore } from "../../_lib/readModelsStore.ts"; -import { - listAllChamberMembers, - listChamberMembers, -} from "../../_lib/chamberMembershipsStore.ts"; -import { getSimConfig } from "../../_lib/simConfig.ts"; -import { resolveUserTierFromSimConfig } from "../../_lib/userTier.ts"; - -export const onRequestGet: ApiHandler = async (context) => { - try { - const id = context.params?.id; - if (!id) return errorResponse(400, "Missing chamber id"); - if (context.env.READ_MODELS_INLINE_EMPTY === "true") { - const store = await createReadModelsStore(context.env).catch(() => null); - const fallback = store ? await store.get(`chambers:${id}`) : null; - if (!fallback) return errorResponse(404, "Chamber not found"); - } - - let chamber = await getChamber(context.env, context.request.url, id); - if (!chamber) { - const store = await createReadModelsStore(context.env).catch(() => null); - const listPayload = store ? await store.get("chambers:list") : null; - const items = - listPayload && - typeof listPayload === "object" && - !Array.isArray(listPayload) && - Array.isArray((listPayload as { items?: unknown[] }).items) - ? (listPayload as { items: unknown[] }).items - : []; - const entry = items.find( - (item) => - item && - typeof item === "object" && - !Array.isArray(item) && - String((item as { id?: string }).id ?? "").toLowerCase() === - id.toLowerCase(), - ) as - | { - id?: string; - name?: string; - multiplier?: number; - status?: string; - } - | undefined; - if (entry) { - const multiplier = - typeof entry.multiplier === "number" ? entry.multiplier : 1; - const now = new Date(); - chamber = { - id: String(entry.id ?? id).toLowerCase(), - title: entry.name ?? entry.id ?? id, - status: - entry.status === "dissolved" ? "dissolved" : ("active" as const), - multiplierTimes10: Math.round(multiplier * 10), - createdAt: now, - updatedAt: now, - dissolvedAt: null, - }; - } - } - if (!chamber) return errorResponse(404, "Chamber not found"); - - const stageOptions = [ - { value: "upcoming", label: "Upcoming" }, - { value: "live", label: "Live" }, - { value: "ended", label: "Ended" }, - ] as const; - - const proposalsList: Array<{ - id: string; - title: string; - meta: string; - summary: string; - lead: string; - nextStep: string; - timing: string; - stage: "upcoming" | "live" | "ended"; - }> = []; - - const proposalRows = await listProposals(context.env); - for (const proposal of proposalRows) { - if ((proposal.chamberId ?? "general").toLowerCase() !== id.toLowerCase()) - continue; - const stage = - proposal.stage === "pool" - ? "upcoming" - : proposal.stage === "vote" - ? "live" - : "ended"; - const formationEligible = (() => { - const payload = proposal.payload as Record | null; - if (!payload || typeof payload !== "object" || Array.isArray(payload)) - return true; - if (payload.templateId === "system") return false; - if ( - typeof payload.metaGovernance === "object" && - payload.metaGovernance !== null && - !Array.isArray(payload.metaGovernance) - ) - return false; - if (typeof payload.formationEligible === "boolean") - return payload.formationEligible; - if (typeof payload.formation === "boolean") return payload.formation; - return true; - })(); - - const meta = - stage === "upcoming" - ? "Proposal pool" - : stage === "live" - ? "Chamber vote" - : formationEligible - ? "Formation" - : "Passed"; - - proposalsList.push({ - id: proposal.id, - title: proposal.title, - meta, - summary: proposal.summary, - lead: chamber.title, - nextStep: - stage === "upcoming" - ? "Cast attention vote" - : stage === "live" - ? "Cast chamber vote" - : formationEligible - ? "Open Formation" - : "Read outcome", - timing: proposal.createdAt.toISOString().slice(0, 10), - stage, - }); - } - - const cfg = await getSimConfig(context.env, context.request.url); - const genesisMembers = cfg?.genesisChamberMembers ?? undefined; - const memberAddresses = new Set(); - const normalizeAddress = (value: string) => value.trim(); - const chamberId = id.toLowerCase(); - - if (chamberId === "general") { - if (genesisMembers) { - for (const list of Object.values(genesisMembers)) { - for (const addr of list) memberAddresses.add(normalizeAddress(addr)); - } - } - // In v1, the roster for General is the set of anyone with any membership. - // This will be refined once canonical human profiles and era activity are in place. - const seeded = await listAllChamberMembers(context.env); - for (const addr of seeded) memberAddresses.add(normalizeAddress(addr)); - } else { - if (genesisMembers) { - for (const addr of genesisMembers[chamberId] ?? []) - memberAddresses.add(normalizeAddress(addr)); - } - const seeded = await listChamberMembers(context.env, id); - for (const addr of seeded) memberAddresses.add(normalizeAddress(addr)); - } - - const governors = await Promise.all( - Array.from(memberAddresses) - .sort() - .map(async (address) => ({ - id: address, - name: - address.length > 12 - ? `${address.slice(0, 6)}…${address.slice(-4)}` - : address, - tier: await resolveUserTierFromSimConfig(cfg, address), - focus: chamber.title, - })), - ); - - return jsonResponse({ - proposals: proposalsList.sort((a, b) => a.title.localeCompare(b.title)), - governors, - threads: [], - chatLog: [], - stageOptions, - }); - } catch (error) { - return errorResponse(500, (error as Error).message); - } -}; diff --git a/functions/api/chambers/index.ts b/functions/api/chambers/index.ts deleted file mode 100644 index 261dd66..0000000 --- a/functions/api/chambers/index.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { errorResponse, jsonResponse } from "../../_lib/http.ts"; -import { - listChambers, - projectChamberPipeline, - projectChamberStats, -} from "../../_lib/chambersStore.ts"; - -export const onRequestGet: ApiHandler = async (context) => { - try { - if (context.env.READ_MODELS_INLINE_EMPTY === "true") { - return jsonResponse({ items: [] }); - } - const url = new URL(context.request.url); - const includeDissolved = - url.searchParams.get("includeDissolved") === "true"; - const chambers = await listChambers(context.env, context.request.url, { - includeDissolved, - }); - const items = await Promise.all( - chambers.map(async (chamber) => { - const pipeline = await projectChamberPipeline(context.env, { - chamberId: chamber.id, - }); - const stats = await projectChamberStats( - context.env, - context.request.url, - { chamberId: chamber.id }, - ); - return { - id: chamber.id, - name: chamber.title, - multiplier: Math.round((chamber.multiplierTimes10 / 10) * 10) / 10, - stats: { - governors: stats.governors.toLocaleString(), - acm: stats.acm.toLocaleString(), - lcm: stats.lcm.toLocaleString(), - mcm: stats.mcm.toLocaleString(), - }, - pipeline, - }; - }), - ); - return jsonResponse({ items }); - } catch (error) { - return errorResponse(500, (error as Error).message); - } -}; diff --git a/functions/api/clock/advance-era.ts b/functions/api/clock/advance-era.ts deleted file mode 100644 index d79a965..0000000 --- a/functions/api/clock/advance-era.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { assertAdmin, createClockStore } from "../../_lib/clockStore.ts"; -import { ensureEraSnapshot } from "../../_lib/eraStore.ts"; -import { errorResponse, jsonResponse } from "../../_lib/http.ts"; - -export const onRequestPost: ApiHandler = async (context) => { - try { - assertAdmin(context); - const clock = createClockStore(context.env); - const next = await clock.advanceEra(); - await ensureEraSnapshot(context.env, next.currentEra).catch(() => {}); - return jsonResponse(next); - } catch (error) { - const err = error as Error & { status?: number }; - if (err.status) return errorResponse(err.status, err.message); - return errorResponse(500, err.message); - } -}; diff --git a/functions/api/clock/index.ts b/functions/api/clock/index.ts deleted file mode 100644 index f91fda5..0000000 --- a/functions/api/clock/index.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { createClockStore } from "../../_lib/clockStore.ts"; -import { getActiveGovernorsForCurrentEra } from "../../_lib/eraStore.ts"; -import { getEraRollupMeta } from "../../_lib/eraRollupStore.ts"; -import { errorResponse, jsonResponse } from "../../_lib/http.ts"; - -export const onRequestGet: ApiHandler = async (context) => { - try { - const clock = createClockStore(context.env); - const snapshot = await clock.get(); - const activeGovernors = await getActiveGovernorsForCurrentEra(context.env); - const rollup = await getEraRollupMeta(context.env, { - era: snapshot.currentEra, - }).catch(() => null); - return jsonResponse({ - ...snapshot, - activeGovernors, - ...(rollup ? { currentEraRollup: rollup } : {}), - }); - } catch (error) { - return errorResponse(500, (error as Error).message); - } -}; diff --git a/functions/api/clock/rollup-era.ts b/functions/api/clock/rollup-era.ts deleted file mode 100644 index 1d9de51..0000000 --- a/functions/api/clock/rollup-era.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { assertAdmin, createClockStore } from "../../_lib/clockStore.ts"; -import { rollupEra } from "../../_lib/eraRollupStore.ts"; -import { setEraSnapshotActiveGovernors } from "../../_lib/eraStore.ts"; -import { errorResponse, jsonResponse } from "../../_lib/http.ts"; - -export const onRequestPost: ApiHandler = async (context) => { - try { - assertAdmin(context); - - const clock = createClockStore(context.env); - const { currentEra } = await clock.get(); - - let era = currentEra; - const contentType = context.request.headers.get("content-type") ?? ""; - if (contentType.toLowerCase().includes("application/json")) { - const body = (await context.request.json().catch(() => null)) as { - era?: number; - } | null; - if (body && typeof body.era === "number") era = Math.floor(body.era); - } - - const result = await rollupEra(context.env, { - era, - requestUrl: context.request.url, - }); - - await setEraSnapshotActiveGovernors(context.env, { - era: era + 1, - activeGovernors: result.activeGovernorsNextEra, - }).catch(() => {}); - - return jsonResponse({ ok: true as const, ...result }); - } catch (error) { - const err = error as Error & { status?: number }; - if (err.status) return errorResponse(err.status, err.message); - return errorResponse(500, err.message); - } -}; diff --git a/functions/api/clock/tick.ts b/functions/api/clock/tick.ts deleted file mode 100644 index e0b9032..0000000 --- a/functions/api/clock/tick.ts +++ /dev/null @@ -1,226 +0,0 @@ -import { assertAdmin, createClockStore } from "../../_lib/clockStore.ts"; -import { errorResponse, jsonResponse } from "../../_lib/http.ts"; -import { appendFeedItemEventOnce } from "../../_lib/appendEvents.ts"; -import { rollupEra } from "../../_lib/eraRollupStore.ts"; -import { - ensureEraSnapshot, - setEraSnapshotActiveGovernors, -} from "../../_lib/eraStore.ts"; -import { formatChamberLabel } from "../../_lib/proposalDraftsStore.ts"; -import { listProposals } from "../../_lib/proposalsStore.ts"; -import { - getSimNow, - getStageDeadlineIso, - getStageWindowSeconds, - isStageOpen, - stageWindowsEnabled, -} from "../../_lib/stageWindows.ts"; -import { V1_ERA_SECONDS_DEFAULT } from "../../_lib/v1Constants.ts"; -import { finalizeAcceptedProposalFromVote } from "../../_lib/proposalFinalizer.ts"; -import { appendProposalTimelineItem } from "../../_lib/proposalTimelineStore.ts"; -import { randomHex } from "../../_lib/random.ts"; - -type Env = Record; - -function getEraSeconds(env: Env): number { - const raw = env.SIM_ERA_SECONDS ?? ""; - const parsed = Number(raw); - if (Number.isFinite(parsed) && parsed > 0) return Math.floor(parsed); - return V1_ERA_SECONDS_DEFAULT; -} - -export const onRequestPost: ApiHandler = async (context) => { - try { - assertAdmin(context); - - const contentType = context.request.headers.get("content-type") ?? ""; - const body = contentType.toLowerCase().includes("application/json") - ? ((await context.request.json().catch(() => null)) as { - forceAdvance?: boolean; - rollup?: boolean; - } | null) - : null; - - const forceAdvance = body?.forceAdvance === true; - const shouldRollup = body?.rollup !== false; - - const clock = createClockStore(context.env); - const snapshot = await clock.get(); - await ensureEraSnapshot(context.env, snapshot.currentEra).catch(() => {}); - - const now = getSimNow(context.env); - const eraSeconds = getEraSeconds(context.env); - const updatedAt = new Date(snapshot.updatedAt); - const dueByTime = - Number.isFinite(updatedAt.getTime()) && - now.getTime() - updatedAt.getTime() >= eraSeconds * 1000; - - const due = forceAdvance || dueByTime; - - const rollup = shouldRollup - ? await rollupEra(context.env, { - era: snapshot.currentEra, - requestUrl: context.request.url, - }) - : null; - - if (rollup) { - await setEraSnapshotActiveGovernors(context.env, { - era: snapshot.currentEra + 1, - activeGovernors: rollup.activeGovernorsNextEra, - }).catch(() => {}); - } - - let advancedTo = snapshot.currentEra; - let advanced = false; - if (due) { - const next = await clock.advanceEra(); - advancedTo = next.currentEra; - advanced = advancedTo !== snapshot.currentEra; - await ensureEraSnapshot(context.env, next.currentEra).catch(() => {}); - } - - const endedWindows: Array<{ - proposalId: string; - stage: "pool" | "vote"; - endedAt: string; - emitted: boolean; - }> = []; - - if (stageWindowsEnabled(context.env)) { - const proposals = await listProposals(context.env).catch(() => []); - for (const proposal of proposals) { - if (proposal.stage !== "pool" && proposal.stage !== "vote") continue; - if (proposal.stage === "vote" && proposal.votePassedAt) continue; - const windowSeconds = getStageWindowSeconds( - context.env, - proposal.stage, - ); - if (windowSeconds <= 0) continue; - - const stageStartedAt = proposal.updatedAt; - if (now.getTime() < stageStartedAt.getTime()) continue; - const endedAt = getStageDeadlineIso({ stageStartedAt, windowSeconds }); - const open = isStageOpen({ now, stageStartedAt, windowSeconds }); - if (open) continue; - - const entityType = "proposal.stage_window_ended.v1"; - const entityId = `${proposal.id}:${proposal.stage}:${endedAt}`; - - const chamberLabel = proposal.chamberId - ? formatChamberLabel(proposal.chamberId) - : "General chamber"; - - const emitted = await appendFeedItemEventOnce(context.env, { - stage: proposal.stage, - entityType, - entityId, - payload: { - id: `proposal-window-ended:${proposal.id}:${proposal.stage}:${endedAt}`, - title: - proposal.stage === "pool" - ? "Proposal pool window ended" - : "Chamber voting window ended", - meta: `${chamberLabel} · System`, - stage: proposal.stage, - summaryPill: - proposal.stage === "pool" ? "Proposal pool" : "Chamber vote", - summary: - proposal.stage === "pool" - ? "Pool voting is now closed for this proposal." - : "Voting is now closed for this proposal.", - ctaPrimary: "Open proposal", - href: - proposal.stage === "pool" - ? `/app/proposals/${proposal.id}/pp` - : `/app/proposals/${proposal.id}/chamber`, - timestamp: endedAt, - }, - }); - - endedWindows.push({ - proposalId: proposal.id, - stage: proposal.stage, - endedAt, - emitted, - }); - } - } - - const finalized: Array<{ proposalId: string; ok: boolean }> = []; - { - const proposals = await listProposals(context.env, { - stage: "vote", - }).catch(() => []); - for (const proposal of proposals) { - const finalizesAt = proposal.voteFinalizesAt; - if (!finalizesAt) continue; - if (now.getTime() < finalizesAt.getTime()) continue; - if (!proposal.votePassedAt) continue; - - const result = await finalizeAcceptedProposalFromVote(context.env, { - proposalId: proposal.id, - requestUrl: context.request.url, - }); - finalized.push({ proposalId: proposal.id, ok: result.ok }); - if (!result.ok) continue; - - await appendFeedItemEventOnce(context.env, { - stage: "build", - entityType: "proposal", - entityId: `vote-finalized:${proposal.id}:${finalizesAt.toISOString()}`, - payload: { - id: `vote-finalized:${proposal.id}:${finalizesAt.toISOString()}`, - title: "Proposal accepted", - meta: "Chamber vote", - stage: "build", - summaryPill: "Accepted", - summary: - "Veto window ended; chamber vote is finalized and the proposal is now accepted.", - stats: [ - ...(result.avgScore !== null - ? [{ label: "Avg CM", value: result.avgScore.toFixed(1) }] - : []), - ], - ctaPrimary: "Open proposal", - href: result.formationEligible - ? `/app/proposals/${proposal.id}/formation` - : `/app/proposals/${proposal.id}/chamber`, - timestamp: now.toISOString(), - }, - }); - - await appendProposalTimelineItem(context.env, { - proposalId: proposal.id, - stage: "build", - actorAddress: null, - item: { - id: `timeline:vote-finalized:${proposal.id}:${randomHex(4)}`, - type: "proposal.vote.finalized", - title: "Proposal accepted", - detail: "Veto window ended", - actor: "system", - timestamp: now.toISOString(), - }, - }); - } - } - - return jsonResponse({ - ok: true as const, - now: now.toISOString(), - eraSeconds, - due, - advanced, - fromEra: snapshot.currentEra, - toEra: advancedTo, - ...(endedWindows.length > 0 ? { endedWindows } : {}), - ...(finalized.length > 0 ? { finalized } : {}), - ...(rollup ? { rollup } : {}), - }); - } catch (error) { - const err = error as Error & { status?: number }; - if (err.status) return errorResponse(err.status, err.message); - return errorResponse(500, err.message); - } -}; diff --git a/functions/api/command.ts b/functions/api/command.ts deleted file mode 100644 index 6727f33..0000000 --- a/functions/api/command.ts +++ /dev/null @@ -1,3159 +0,0 @@ -import { z } from "zod"; - -import { readSession } from "../_lib/auth.ts"; -import { checkEligibility } from "../_lib/gate.ts"; -import { errorResponse, jsonResponse, readJson } from "../_lib/http.ts"; -import { - getIdempotencyResponse, - storeIdempotencyResponse, -} from "../_lib/idempotencyStore.ts"; -import { castPoolVote } from "../_lib/poolVotesStore.ts"; -import { appendFeedItemEvent } from "../_lib/appendEvents.ts"; -import { appendProposalTimelineItem } from "../_lib/proposalTimelineStore.ts"; -import { createReadModelsStore } from "../_lib/readModelsStore.ts"; -import { evaluatePoolQuorum } from "../_lib/poolQuorum.ts"; -import { - castChamberVote, - getChamberYesScoreAverage, - clearChamberVotesForProposal, -} from "../_lib/chamberVotesStore.ts"; -import { evaluateChamberQuorum } from "../_lib/chamberQuorum.ts"; -import { awardCmOnce, hasLcmHistoryInChamber } from "../_lib/cmAwardsStore.ts"; -import { - joinFormationProject, - ensureFormationSeed, - getFormationMilestoneStatus, - isFormationTeamMember, - requestFormationMilestoneUnlock, - submitFormationMilestone, -} from "../_lib/formationStore.ts"; -import { - castCourtVerdict, - hasCourtReport, - hasCourtVerdict, - reportCourtCase, -} from "../_lib/courtsStore.ts"; -import { - getActiveGovernorsForCurrentEra, - incrementEraUserActivity, - getUserEraActivity, -} from "../_lib/eraStore.ts"; -import { - createApiRateLimitStore, - getCommandRateLimitConfig, -} from "../_lib/apiRateLimitStore.ts"; -import { getRequestIp } from "../_lib/requestIp.ts"; -import { createActionLocksStore } from "../_lib/actionLocksStore.ts"; -import { getEraQuotaConfig } from "../_lib/eraQuotas.ts"; -import { hasPoolVote } from "../_lib/poolVotesStore.ts"; -import { hasChamberVote } from "../_lib/chamberVotesStore.ts"; -import { createAdminStateStore } from "../_lib/adminStateStore.ts"; -import { - deleteDraft, - draftIsSubmittable, - formatChamberLabel, - getDraft, - markDraftSubmitted, - proposalDraftFormSchema, - upsertDraft, -} from "../_lib/proposalDraftsStore.ts"; -import { - createProposal, - setProposalVotePendingVeto, - getProposal, - transitionProposalStage, - applyProposalVeto, -} from "../_lib/proposalsStore.ts"; -import { - captureProposalStageDenominator, - getProposalStageDenominator, -} from "../_lib/proposalStageDenominatorsStore.ts"; -import { clearDelegation, setDelegation } from "../_lib/delegationsStore.ts"; -import { - ensureChamberMembership, - hasAnyChamberMembership, - hasChamberMembership, -} from "../_lib/chamberMembershipsStore.ts"; -import { getActiveGovernorsDenominatorForChamberCurrentEra } from "../_lib/chamberActiveDenominators.ts"; -import { randomHex } from "../_lib/random.ts"; -import { - computePoolUpvoteFloor, - shouldAdvancePoolToVote, - shouldAdvanceVoteToBuild, -} from "../_lib/proposalStateMachine.ts"; -import { - V1_ACTIVE_GOVERNORS_FALLBACK, - V1_CHAMBER_PASSING_FRACTION, - V1_CHAMBER_QUORUM_FRACTION, - V1_POOL_ATTENTION_QUORUM_FRACTION, - V1_VETO_DELAY_SECONDS_DEFAULT, - V1_VETO_MAX_APPLIES, -} from "../_lib/v1Constants.ts"; -import { computeVetoCouncilSnapshot } from "../_lib/vetoCouncilStore.ts"; -import { - castVetoVote, - clearVetoVotesForProposal, -} from "../_lib/vetoVotesStore.ts"; -import { finalizeAcceptedProposalFromVote } from "../_lib/proposalFinalizer.ts"; -import { - formatTimeLeftDaysHours, - getSimNow, - getStageDeadlineIso, - getStageRemainingSeconds, - getStageWindowSeconds, - isStageOpen, - stageWindowsEnabled, -} from "../_lib/stageWindows.ts"; -import { envBoolean } from "../_lib/env.ts"; -import { getSimConfig } from "../_lib/simConfig.ts"; -import { resolveUserTierFromSimConfig } from "../_lib/userTier.ts"; -import { addressesReferToSameKey } from "../_lib/address.ts"; -import { - createChamberFromAcceptedGeneralProposal, - dissolveChamberFromAcceptedGeneralProposal, - getChamber, - parseChamberGovernanceFromPayload, - setChamberMultiplierTimes10, -} from "../_lib/chambersStore.ts"; -import { - getChamberMultiplierAggregate, - upsertChamberMultiplierSubmission, -} from "../_lib/chamberMultiplierSubmissionsStore.ts"; - -function getGenesisMembersForDenominators( - simConfig: Awaited> | null, - chamberId: string, -): string[] | null { - const genesis = simConfig?.genesisChamberMembers; - if (!genesis) return null; - const normalized = chamberId.trim().toLowerCase(); - if (normalized === "general") return Object.values(genesis).flat(); - return genesis[normalized] ?? null; -} - -const poolVoteSchema = z.object({ - type: z.literal("pool.vote"), - payload: z.object({ - proposalId: z.string().min(1), - direction: z.enum(["up", "down"]), - }), - idempotencyKey: z.string().min(8).optional(), -}); - -const chamberVoteSchema = z.object({ - type: z.literal("chamber.vote"), - payload: z.object({ - proposalId: z.string().min(1), - choice: z.enum(["yes", "no", "abstain"]), - score: z.number().int().min(1).max(10).optional(), - }), - idempotencyKey: z.string().min(8).optional(), -}); - -const formationJoinSchema = z.object({ - type: z.literal("formation.join"), - payload: z.object({ - proposalId: z.string().min(1), - role: z.string().min(1).optional(), - }), - idempotencyKey: z.string().min(8).optional(), -}); - -const formationMilestoneSubmitSchema = z.object({ - type: z.literal("formation.milestone.submit"), - payload: z.object({ - proposalId: z.string().min(1), - milestoneIndex: z.number().int().min(1), - note: z.string().min(1).optional(), - }), - idempotencyKey: z.string().min(8).optional(), -}); - -const formationMilestoneUnlockSchema = z.object({ - type: z.literal("formation.milestone.requestUnlock"), - payload: z.object({ - proposalId: z.string().min(1), - milestoneIndex: z.number().int().min(1), - }), - idempotencyKey: z.string().min(8).optional(), -}); - -const courtReportSchema = z.object({ - type: z.literal("court.case.report"), - payload: z.object({ - caseId: z.string().min(1), - }), - idempotencyKey: z.string().min(8).optional(), -}); - -const courtVerdictSchema = z.object({ - type: z.literal("court.case.verdict"), - payload: z.object({ - caseId: z.string().min(1), - verdict: z.enum(["guilty", "not_guilty"]), - }), - idempotencyKey: z.string().min(8).optional(), -}); - -const proposalDraftSaveSchema = z.object({ - type: z.literal("proposal.draft.save"), - payload: z.object({ - draftId: z.string().min(1).optional(), - form: proposalDraftFormSchema, - }), - idempotencyKey: z.string().min(8).optional(), -}); - -const proposalDraftDeleteSchema = z.object({ - type: z.literal("proposal.draft.delete"), - payload: z.object({ - draftId: z.string().min(1), - }), - idempotencyKey: z.string().min(8).optional(), -}); - -const proposalSubmitToPoolSchema = z.object({ - type: z.literal("proposal.submitToPool"), - payload: z.object({ - draftId: z.string().min(1), - }), - idempotencyKey: z.string().min(8).optional(), -}); - -const delegationSetSchema = z.object({ - type: z.literal("delegation.set"), - payload: z.object({ - chamberId: z.string().min(1), - delegateeAddress: z.string().min(1), - }), - idempotencyKey: z.string().min(8).optional(), -}); - -const delegationClearSchema = z.object({ - type: z.literal("delegation.clear"), - payload: z.object({ - chamberId: z.string().min(1), - }), - idempotencyKey: z.string().min(8).optional(), -}); - -const vetoVoteSchema = z.object({ - type: z.literal("veto.vote"), - payload: z.object({ - proposalId: z.string().min(1), - choice: z.enum(["veto", "keep"]), - }), - idempotencyKey: z.string().min(8).optional(), -}); - -const chamberMultiplierSubmitSchema = z.object({ - type: z.literal("chamber.multiplier.submit"), - payload: z.object({ - chamberId: z.string().min(1), - multiplierTimes10: z.number().int().min(1).max(100), - }), - idempotencyKey: z.string().min(8).optional(), -}); - -const commandSchema = z.discriminatedUnion("type", [ - poolVoteSchema, - chamberVoteSchema, - formationJoinSchema, - formationMilestoneSubmitSchema, - formationMilestoneUnlockSchema, - courtReportSchema, - courtVerdictSchema, - proposalDraftSaveSchema, - proposalDraftDeleteSchema, - proposalSubmitToPoolSchema, - delegationSetSchema, - delegationClearSchema, - vetoVoteSchema, - chamberMultiplierSubmitSchema, -]); - -type CommandInput = z.infer; - -export const onRequestPost: ApiHandler = async (context) => { - let body: unknown; - try { - body = await readJson(context.request); - } catch (error) { - return errorResponse(400, (error as Error).message); - } - - const parsed = commandSchema.safeParse(body); - if (!parsed.success) { - return errorResponse(400, "Invalid command", { - issues: parsed.error.issues, - }); - } - - const session = await readSession(context.request, context.env); - if (!session) return errorResponse(401, "Not authenticated"); - const sessionAddress = session.address; - - const gate = await checkEligibility( - context.env, - sessionAddress, - context.request.url, - ); - if (!gate.eligible) { - return errorResponse(403, gate.reason ?? "not_eligible", { gate }); - } - - if (context.env.SIM_WRITE_FREEZE === "true") { - return errorResponse(503, "Writes are temporarily disabled", { - code: "writes_frozen", - }); - } - const adminState = await createAdminStateStore(context.env) - .get() - .catch(() => ({ writesFrozen: false })); - if (adminState.writesFrozen) { - return errorResponse(503, "Writes are temporarily disabled", { - code: "writes_frozen", - }); - } - - const locks = createActionLocksStore(context.env); - const activeLock = await locks.getActiveLock(sessionAddress); - if (activeLock) { - return errorResponse(403, "Action locked", { - code: "action_locked", - lock: activeLock, - }); - } - - const rateLimits = createApiRateLimitStore(context.env); - const rateConfig = getCommandRateLimitConfig(context.env); - const requestIp = getRequestIp(context.request); - - if (requestIp) { - const ipLimit = await rateLimits.consume({ - bucket: `command:ip:${requestIp}`, - limit: rateConfig.perIpPerMinute, - windowSeconds: 60, - }); - if (!ipLimit.ok) { - return errorResponse(429, "Rate limited", { - scope: "ip", - retryAfterSeconds: ipLimit.retryAfterSeconds, - resetAt: ipLimit.resetAt, - }); - } - } - - const addressLimit = await rateLimits.consume({ - bucket: `command:address:${session.address}`, - limit: rateConfig.perAddressPerMinute, - windowSeconds: 60, - }); - if (!addressLimit.ok) { - return errorResponse(429, "Rate limited", { - scope: "address", - retryAfterSeconds: addressLimit.retryAfterSeconds, - resetAt: addressLimit.resetAt, - }); - } - - const input: CommandInput = parsed.data; - const headerKey = - context.request.headers.get("idempotency-key") ?? - context.request.headers.get("x-idempotency-key") ?? - undefined; - const idempotencyKey = headerKey ?? input.idempotencyKey; - const requestForIdem = { type: input.type, payload: input.payload }; - - if (idempotencyKey) { - const hit = await getIdempotencyResponse(context.env, { - key: idempotencyKey, - address: session.address, - request: requestForIdem, - }); - if ("conflict" in hit && hit.conflict) { - return errorResponse(409, "Idempotency key conflict"); - } - if (hit.hit) return jsonResponse(hit.response); - } - - const readModels = await createReadModelsStore(context.env).catch(() => null); - const activeGovernorsBaseline = await getActiveGovernorsForCurrentEra( - context.env, - ).catch(() => null); - - const quotas = getEraQuotaConfig(context.env); - - async function enforceEraQuota(input: { - kind: "poolVotes" | "chamberVotes" | "courtActions" | "formationActions"; - wouldCount: boolean; - }): Promise { - if (!input.wouldCount) return null; - const limit = - input.kind === "poolVotes" - ? quotas.maxPoolVotes - : input.kind === "chamberVotes" - ? quotas.maxChamberVotes - : input.kind === "courtActions" - ? quotas.maxCourtActions - : quotas.maxFormationActions; - if (limit === null) return null; - - const activity = await getUserEraActivity(context.env, { - address: sessionAddress, - }); - const used = activity.counts[input.kind] ?? 0; - if (used >= limit) { - return errorResponse(429, "Era quota exceeded", { - code: "era_quota_exceeded", - era: activity.era, - kind: input.kind, - limit, - used, - }); - } - return null; - } - if ( - input.type === "pool.vote" || - input.type === "chamber.vote" || - input.type === "formation.join" || - input.type === "formation.milestone.submit" || - input.type === "formation.milestone.requestUnlock" - ) { - const requiredStage = - input.type === "pool.vote" - ? "pool" - : input.type === "chamber.vote" - ? "vote" - : "build"; - const stage = - (await getProposal(context.env, input.payload.proposalId))?.stage ?? - (readModels - ? await getProposalStage(readModels, input.payload.proposalId) - : null); - if (!stage) return errorResponse(404, "Unknown proposal"); - if (stage !== requiredStage) { - return errorResponse(409, "Proposal is not in the required stage", { - stage, - requiredStage, - }); - } - } - - if (input.type === "proposal.draft.save") { - const record = await upsertDraft(context.env, { - authorAddress: sessionAddress, - draftId: input.payload.draftId, - form: input.payload.form, - }); - - const response = { - ok: true as const, - type: input.type, - draftId: record.id, - updatedAt: record.updatedAt.toISOString(), - }; - - if (idempotencyKey) { - await storeIdempotencyResponse(context.env, { - key: idempotencyKey, - address: sessionAddress, - request: requestForIdem, - response, - }); - } - - return jsonResponse(response); - } - - if (input.type === "proposal.draft.delete") { - const deleted = await deleteDraft(context.env, { - authorAddress: sessionAddress, - draftId: input.payload.draftId, - }); - - const response = { - ok: true as const, - type: input.type, - draftId: input.payload.draftId, - deleted, - }; - - if (idempotencyKey) { - await storeIdempotencyResponse(context.env, { - key: idempotencyKey, - address: sessionAddress, - request: requestForIdem, - response, - }); - } - - return jsonResponse(response); - } - - if (input.type === "proposal.submitToPool") { - const draft = await getDraft(context.env, { - authorAddress: sessionAddress, - draftId: input.payload.draftId, - }); - if (!draft) return errorResponse(404, "Draft not found"); - if (draft.submittedAt || draft.submittedProposalId) { - return errorResponse(409, "Draft already submitted"); - } - if (!draftIsSubmittable(draft.payload)) { - return errorResponse(400, "Draft is not ready for submission", { - code: "draft_not_submittable", - }); - } - - const now = new Date(); - const baseSlug = draft.title - .trim() - .toLowerCase() - .replace(/[^a-z0-9]+/g, "-") - .replace(/^-+|-+$/g, "") - .slice(0, 48); - const proposalId = `${baseSlug || "proposal"}-${randomHex(2)}`; - - const chamberId = (draft.chamberId ?? "").trim().toLowerCase(); - if (chamberId && chamberId !== "general") { - const chamber = await getChamber( - context.env, - context.request.url, - chamberId, - ); - if (!chamber) { - return errorResponse(400, "Unknown chamber", { - code: "invalid_chamber", - chamberId, - }); - } - if (chamber.status !== "active") { - return errorResponse(409, "Chamber is dissolved", { - code: "chamber_dissolved", - chamberId, - status: chamber.status, - dissolvedAt: chamber.dissolvedAt?.toISOString() ?? null, - }); - } - } - - const meta = (() => { - const payload = draft.payload as Record | null; - if (!payload || typeof payload !== "object" || Array.isArray(payload)) - return null; - const mg = payload.metaGovernance; - if (!mg || typeof mg !== "object" || Array.isArray(mg)) return null; - const record = mg as Record; - const action = typeof record.action === "string" ? record.action : ""; - if (action !== "chamber.create" && action !== "chamber.dissolve") - return { invalid: true as const }; - - const id = typeof record.chamberId === "string" ? record.chamberId : ""; - const title = typeof record.title === "string" ? record.title : ""; - const multiplier = - typeof record.multiplier === "number" ? record.multiplier : null; - const genesisMembersRaw = record.genesisMembers; - const genesisMembers = Array.isArray(genesisMembersRaw) - ? genesisMembersRaw - .filter((v): v is string => typeof v === "string") - .map((v) => v.trim()) - .filter(Boolean) - : []; - return { - action, - id, - title, - multiplier, - genesisMembers, - } as const; - })(); - - if (meta?.invalid) { - return errorResponse(400, "Invalid meta-governance payload", { - code: "invalid_meta_governance", - }); - } - - if (meta && meta.action) { - if (chamberId !== "general") { - return errorResponse( - 400, - "Meta-governance proposals must use General chamber", - { - code: "meta_governance_requires_general", - }, - ); - } - - const targetId = meta.id.trim().toLowerCase(); - if (!targetId || targetId === "general") { - return errorResponse(400, "Invalid target chamber", { - code: "invalid_meta_chamber", - }); - } - - const existing = await getChamber( - context.env, - context.request.url, - targetId, - ); - - if (meta.action === "chamber.create") { - if (existing) { - return errorResponse(409, "Chamber already exists", { - code: "chamber_exists", - chamberId: targetId, - status: existing.status, - }); - } - if (!meta.title.trim()) { - return errorResponse(400, "Chamber title is required", { - code: "invalid_meta_chamber", - }); - } - if (meta.multiplier !== null && !(meta.multiplier > 0)) { - return errorResponse(400, "Multiplier must be > 0", { - code: "invalid_meta_chamber", - }); - } - } else { - if (!existing) { - return errorResponse(400, "Unknown chamber", { - code: "invalid_chamber", - chamberId: targetId, - }); - } - if (existing.status !== "active") { - return errorResponse(409, "Chamber is already dissolved", { - code: "chamber_dissolved", - chamberId: targetId, - status: existing.status, - dissolvedAt: existing.dissolvedAt?.toISOString() ?? null, - }); - } - } - } - - const normalizedChamberId = meta ? "general" : draft.chamberId; - - await createProposal(context.env, { - id: proposalId, - stage: "pool", - authorAddress: sessionAddress, - title: draft.title, - chamberId: normalizedChamberId ?? null, - summary: draft.summary, - payload: draft.payload, - }); - - const chamber = formatChamberLabel(normalizedChamberId ?? null); - const budgetTotal = draft.payload.budgetItems.reduce((sum, item) => { - const n = Number(item.amount); - if (!Number.isFinite(n) || n <= 0) return sum; - return sum + n; - }, 0); - const budget = - budgetTotal > 0 ? `${budgetTotal.toLocaleString()} HMND` : "—"; - - const poolChamberId = (normalizedChamberId ?? "general") - .trim() - .toLowerCase(); - const simConfig = await getSimConfig( - context.env, - context.request.url, - ).catch(() => null); - const authorTier = await resolveUserTierFromSimConfig( - simConfig, - sessionAddress, - ); - const genesisMembers = getGenesisMembersForDenominators( - simConfig, - poolChamberId, - ); - const poolActiveGovernors = - await getActiveGovernorsDenominatorForChamberCurrentEra(context.env, { - chamberId: poolChamberId || "general", - fallbackActiveGovernors: - typeof activeGovernorsBaseline === "number" - ? activeGovernorsBaseline - : V1_ACTIVE_GOVERNORS_FALLBACK, - genesisMembers, - }); - const attentionQuorum = V1_POOL_ATTENTION_QUORUM_FRACTION; - const upvoteFloor = computePoolUpvoteFloor(poolActiveGovernors); - - const formationEligible = getFormationEligibleFromProposalPayload( - draft.payload, - ); - - const poolPagePayload = { - title: draft.title, - proposer: sessionAddress, - proposerId: sessionAddress, - chamber, - focus: "—", - tier: authorTier, - budget, - cooldown: "Withdraw cooldown: 12h", - formationEligible, - templateId: isRecord(draft.payload) - ? draft.payload.templateId - : undefined, - metaGovernance: isRecord(draft.payload) - ? draft.payload.metaGovernance - : undefined, - teamSlots: "1 / 3", - milestones: String(draft.payload.timeline.length), - upvotes: 0, - downvotes: 0, - attentionQuorum, - activeGovernors: poolActiveGovernors, - upvoteFloor, - rules: [ - `${Math.round(attentionQuorum * 100)}% attention from active governors required.`, - poolActiveGovernors > 0 - ? `At least ${Math.round((upvoteFloor / poolActiveGovernors) * 100)}% upvotes to move to chamber vote.` - : "At least 0% upvotes to move to chamber vote.", - ], - attachments: draft.payload.attachments - .filter((a) => a.label.trim().length > 0) - .map((a) => ({ id: a.id, title: a.label })), - teamLocked: [{ name: sessionAddress, role: "Proposer" }], - openSlotNeeds: [], - milestonesDetail: draft.payload.timeline.map((m, idx) => ({ - title: m.title.trim().length ? m.title : `Milestone ${idx + 1}`, - desc: m.timeframe.trim().length ? m.timeframe : "Timeline TBD", - })), - summary: draft.payload.summary, - overview: draft.payload.what, - executionPlan: draft.payload.how - .split("\n") - .map((line) => line.trim()) - .filter(Boolean), - budgetScope: draft.payload.budgetItems - .filter((b) => b.description.trim().length > 0) - .map((b) => `${b.description}: ${b.amount} HMND`) - .join("\n"), - invisionInsight: { - role: "Draft author", - bullets: [ - "Submitted via the simulation backend proposal wizard.", - "This is an off-chain governance simulation (not mainnet).", - ], - }, - }; - - const listPayload = readModels?.set - ? await readModels.get("proposals:list") - : null; - const existingItems = - readModels?.set && - isRecord(listPayload) && - Array.isArray(listPayload.items) - ? listPayload.items - : []; - - const listItem = { - id: proposalId, - title: draft.title, - meta: `${chamber} · ${authorTier} tier`, - stage: "pool", - summaryPill: `${draft.payload.timeline.length} milestones`, - summary: draft.payload.summary, - stageData: [ - { - title: "Pool momentum", - description: "Upvotes / Downvotes", - value: "0 / 0", - }, - { - title: "Attention quorum", - description: "20% active or ≥10% upvotes", - value: "Needs · 0% engaged", - tone: "warn", - }, - { title: "Votes casted", description: "Backing seats", value: "0" }, - ], - stats: [ - { label: "Budget ask", value: budget }, - { label: "Formation", value: formationEligible ? "Yes" : "No" }, - ], - proposer: sessionAddress, - proposerId: sessionAddress, - chamber, - tier: authorTier, - proofFocus: "pot", - tags: [], - keywords: [], - date: now.toISOString().slice(0, 10), - votes: 0, - activityScore: 0, - ctaPrimary: "Open proposal", - ctaSecondary: "", - }; - - if (readModels?.set) { - await readModels.set("proposals:list", { - ...(isRecord(listPayload) ? listPayload : {}), - items: [...existingItems, listItem], - }); - await readModels.set(`proposals:${proposalId}:pool`, poolPagePayload); - } - - await captureProposalStageDenominator(context.env, { - proposalId, - stage: "pool", - activeGovernors: poolActiveGovernors, - }).catch(() => {}); - - await markDraftSubmitted(context.env, { - authorAddress: sessionAddress, - draftId: input.payload.draftId, - proposalId, - }); - - const response = { - ok: true as const, - type: input.type, - draftId: input.payload.draftId, - proposalId, - }; - - if (idempotencyKey) { - await storeIdempotencyResponse(context.env, { - key: idempotencyKey, - address: sessionAddress, - request: requestForIdem, - response, - }); - } - - await appendFeedItemEvent(context.env, { - stage: "pool", - actorAddress: sessionAddress, - entityType: "proposal", - entityId: proposalId, - payload: { - id: `proposal-submitted:${proposalId}:${Date.now()}`, - title: "Proposal submitted", - meta: "Proposal pool · New", - stage: "pool", - summaryPill: "Submitted", - summary: `Submitted "${draft.title}" to the proposal pool.`, - stats: [{ label: "Budget ask", value: budget }], - ctaPrimary: "Open proposal", - href: `/app/proposals/${proposalId}/pp`, - timestamp: new Date().toISOString(), - }, - }); - - await appendProposalTimelineItem(context.env, { - proposalId, - stage: "pool", - actorAddress: sessionAddress, - item: { - id: `timeline:proposal-submitted:${proposalId}:${randomHex(4)}`, - type: "proposal.submitted", - title: "Proposal submitted", - detail: `Submitted to ${chamber}`, - actor: sessionAddress, - timestamp: new Date().toISOString(), - }, - }); - - return jsonResponse(response); - } - - if (input.type === "delegation.set") { - const chamberId = input.payload.chamberId.trim().toLowerCase(); - const delegateeAddress = input.payload.delegateeAddress.trim(); - - const isDelegatorEligible = - chamberId === "general" - ? await hasAnyChamberMembership(context.env, sessionAddress) - : await hasChamberMembership(context.env, { - address: sessionAddress, - chamberId, - }); - if (!isDelegatorEligible) { - return errorResponse(400, "Delegator is not eligible for delegation", { - code: "delegator_not_eligible", - chamberId, - }); - } - - const isDelegateeEligible = - chamberId === "general" - ? await hasAnyChamberMembership(context.env, delegateeAddress) - : await hasChamberMembership(context.env, { - address: delegateeAddress, - chamberId, - }); - if (!isDelegateeEligible) { - return errorResponse(400, "Delegatee is not eligible for delegation", { - code: "delegatee_not_eligible", - chamberId, - }); - } - - let record; - try { - record = await setDelegation(context.env, { - chamberId, - delegatorAddress: sessionAddress, - delegateeAddress, - }); - } catch (error) { - const code = (error as Error).message; - return errorResponse(400, "Unable to set delegation", { code }); - } - - const response = { - ok: true as const, - type: input.type, - chamberId: record.chamberId, - delegatorAddress: record.delegatorAddress, - delegateeAddress: record.delegateeAddress, - updatedAt: record.updatedAt, - }; - - if (idempotencyKey) { - await storeIdempotencyResponse(context.env, { - key: idempotencyKey, - address: session.address, - request: requestForIdem, - response, - }); - } - - await appendFeedItemEvent(context.env, { - stage: "vote", - actorAddress: session.address, - entityType: "delegation", - entityId: `${record.chamberId}:${record.delegatorAddress}`, - payload: { - id: `delegation-set:${record.chamberId}:${record.delegatorAddress}:${Date.now()}`, - title: "Delegation set", - meta: "Delegation", - stage: "vote", - summaryPill: "Delegated", - summary: `Delegated voting power in ${record.chamberId} chamber.`, - stats: [{ label: "Delegatee", value: record.delegateeAddress }], - ctaPrimary: "Open My Governance", - href: "/app/my-governance", - timestamp: new Date().toISOString(), - }, - }); - - return jsonResponse(response); - } - - if (input.type === "delegation.clear") { - const chamberId = input.payload.chamberId.trim().toLowerCase(); - - let cleared; - try { - cleared = await clearDelegation(context.env, { - chamberId, - delegatorAddress: sessionAddress, - }); - } catch (error) { - const code = (error as Error).message; - return errorResponse(400, "Unable to clear delegation", { code }); - } - - const response = { - ok: true as const, - type: input.type, - chamberId, - delegatorAddress: sessionAddress, - cleared: cleared.cleared, - }; - - if (idempotencyKey) { - await storeIdempotencyResponse(context.env, { - key: idempotencyKey, - address: session.address, - request: requestForIdem, - response, - }); - } - - return jsonResponse(response); - } - - if (input.type === "chamber.multiplier.submit") { - const chamberId = input.payload.chamberId.trim().toLowerCase(); - const multiplierTimes10 = input.payload.multiplierTimes10; - - const chamber = await getChamber( - context.env, - context.request.url, - chamberId, - ); - if (!chamber) { - return errorResponse(400, "Unknown chamber", { - code: "invalid_chamber", - chamberId, - }); - } - if (chamber.status !== "active") { - return errorResponse(409, "Chamber is dissolved", { - code: "chamber_dissolved", - chamberId, - }); - } - - const simConfig = await getSimConfig(context.env, context.request.url); - const genesis = simConfig?.genesisChamberMembers ?? null; - const hasGenesisMembership = (() => { - if (!genesis) return false; - for (const list of Object.values(genesis)) { - if (list.some((addr) => addr.trim() === sessionAddress)) return true; - } - return false; - })(); - - const isGovernor = - hasGenesisMembership || - (await hasAnyChamberMembership(context.env, sessionAddress)); - if (!isGovernor) { - return errorResponse(403, "Only governors can set chamber multipliers", { - code: "not_governor", - }); - } - - const hasLcmHere = await hasLcmHistoryInChamber(context.env, { - proposerId: sessionAddress, - chamberId, - }); - if (hasLcmHere) { - return errorResponse(400, "Multiplier voting is outsiders-only", { - code: "multiplier_outsider_required", - chamberId, - }); - } - - const { submission } = await upsertChamberMultiplierSubmission( - context.env, - { - chamberId, - voterAddress: sessionAddress, - multiplierTimes10, - }, - ); - - const aggregate = await getChamberMultiplierAggregate(context.env, { - chamberId, - }); - - const applied = - typeof aggregate.avgTimes10 === "number" - ? await setChamberMultiplierTimes10(context.env, context.request.url, { - id: chamberId, - multiplierTimes10: aggregate.avgTimes10, - }) - : null; - - const response = { - ok: true as const, - type: input.type, - chamberId, - submission: { - multiplierTimes10: submission.multiplierTimes10, - }, - aggregate, - applied: applied - ? { - updated: applied.updated, - prevMultiplierTimes10: applied.prevTimes10, - nextMultiplierTimes10: applied.nextTimes10, - } - : null, - }; - - if (idempotencyKey) { - await storeIdempotencyResponse(context.env, { - key: idempotencyKey, - address: session.address, - request: requestForIdem, - response, - }); - } - - await appendFeedItemEvent(context.env, { - stage: "vote", - actorAddress: sessionAddress, - entityType: "chamber", - entityId: `multiplier:${chamberId}`, - payload: { - id: `chamber-multiplier-submit:${chamberId}:${sessionAddress}:${Date.now()}`, - title: "Multiplier submitted", - meta: "Chambers · CM", - stage: "vote", - summaryPill: "Multiplier", - summary: `Submitted a chamber multiplier for ${chamberId}.`, - stats: [ - { label: "Submitted", value: String(submission.multiplierTimes10) }, - ...(typeof aggregate.avgTimes10 === "number" - ? [{ label: "Avg", value: String(aggregate.avgTimes10) }] - : []), - ], - ctaPrimary: "Open chambers", - href: "/app/chambers", - timestamp: new Date().toISOString(), - }, - }); - - return jsonResponse(response); - } - - if (input.type === "veto.vote") { - const proposalId = input.payload.proposalId; - const proposal = await getProposal(context.env, proposalId); - if (!proposal) return errorResponse(404, "Unknown proposal"); - if (proposal.stage !== "vote") { - return errorResponse(409, "Proposal is not in chamber vote stage", { - code: "stage_invalid", - stage: proposal.stage, - }); - } - - const now = getSimNow(context.env); - if (!proposal.votePassedAt || !proposal.voteFinalizesAt) { - return errorResponse(409, "No veto window is open for this proposal", { - code: "veto_not_open", - }); - } - if (now.getTime() >= proposal.voteFinalizesAt.getTime()) { - return errorResponse(409, "Veto window ended", { - code: "veto_window_ended", - finalizesAt: proposal.voteFinalizesAt.toISOString(), - }); - } - - const council = proposal.vetoCouncil ?? []; - const threshold = proposal.vetoThreshold ?? 0; - if (council.length === 0 || threshold <= 0) { - return errorResponse(409, "Veto is not enabled for this proposal", { - code: "veto_disabled", - }); - } - if (!council.includes(sessionAddress)) { - return errorResponse(403, "Not eligible to cast a veto vote", { - code: "not_veto_holder", - }); - } - - const { counts, created } = await castVetoVote(context.env, { - proposalId, - voterAddress: sessionAddress, - choice: input.payload.choice, - }); - - const response = { - ok: true as const, - type: input.type, - proposalId, - choice: input.payload.choice, - counts, - threshold, - }; - - if (idempotencyKey) { - await storeIdempotencyResponse(context.env, { - key: idempotencyKey, - address: session.address, - request: requestForIdem, - response, - }); - } - - await appendProposalTimelineItem(context.env, { - proposalId, - stage: "vote", - actorAddress: sessionAddress, - item: { - id: `timeline:veto-vote:${proposalId}:${sessionAddress}:${randomHex(4)}`, - type: "veto.vote", - title: "Veto vote cast", - detail: input.payload.choice === "veto" ? "Veto" : "Keep", - actor: sessionAddress, - timestamp: now.toISOString(), - }, - }); - - if (counts.veto >= threshold) { - await clearVetoVotesForProposal(context.env, proposalId).catch(() => {}); - await clearChamberVotesForProposal(context.env, proposalId).catch( - () => {}, - ); - - const nextVoteStartsAt = new Date( - now.getTime() + V1_VETO_DELAY_SECONDS_DEFAULT * 1000, - ); - await applyProposalVeto(context.env, { proposalId, nextVoteStartsAt }); - - await appendFeedItemEvent(context.env, { - stage: "vote", - actorAddress: session.address, - entityType: "proposal", - entityId: proposalId, - payload: { - id: `veto-applied:${proposalId}:${Date.now()}`, - title: "Veto applied", - meta: "Veto", - stage: "vote", - summaryPill: "Vetoed", - summary: - "Veto threshold met; chamber vote is reset and voting is paused.", - stats: [ - { label: "Veto votes", value: `${counts.veto} / ${threshold}` }, - ], - ctaPrimary: "Open proposal", - href: `/app/proposals/${proposalId}/chamber`, - timestamp: now.toISOString(), - }, - }); - - await appendProposalTimelineItem(context.env, { - proposalId, - stage: "vote", - actorAddress: null, - item: { - id: `timeline:veto-applied:${proposalId}:${randomHex(4)}`, - type: "veto.applied", - title: "Veto applied", - detail: `Voting resumes at ${nextVoteStartsAt.toISOString()}`, - actor: "system", - timestamp: now.toISOString(), - }, - }); - } - - if (created) { - await incrementEraUserActivity(context.env, { - address: session.address, - delta: { chamberVotes: 1 }, - }).catch(() => {}); - } - - return jsonResponse(response); - } - - if (input.type === "pool.vote") { - const poolEligibilityError = await enforcePoolVoteEligibility( - context.env, - readModels, - { - proposalId: input.payload.proposalId, - voterAddress: sessionAddress, - }, - context.request.url, - ); - if (poolEligibilityError) return poolEligibilityError; - - const proposal = await getProposal(context.env, input.payload.proposalId); - if ( - proposal && - stageWindowsEnabled(context.env) && - proposal.stage === "pool" - ) { - const now = getSimNow(context.env); - const windowSeconds = getStageWindowSeconds(context.env, "pool"); - if ( - !isStageOpen({ - now, - stageStartedAt: proposal.updatedAt, - windowSeconds, - }) - ) { - return errorResponse(409, "Pool window ended", { - code: "stage_closed", - stage: "pool", - endedAt: getStageDeadlineIso({ - stageStartedAt: proposal.updatedAt, - windowSeconds, - }), - timeLeft: (() => { - const remaining = getStageRemainingSeconds({ - now, - stageStartedAt: proposal.updatedAt, - windowSeconds, - }); - return remaining === 0 - ? "Ended" - : formatTimeLeftDaysHours(remaining); - })(), - }); - } - } - - const wouldCount = !(await hasPoolVote(context.env, { - proposalId: input.payload.proposalId, - voterAddress: sessionAddress, - })); - const quotaError = await enforceEraQuota({ - kind: "poolVotes", - wouldCount, - }); - if (quotaError) return quotaError; - - const direction = input.payload.direction === "up" ? 1 : -1; - const { counts, created } = await castPoolVote(context.env, { - proposalId: input.payload.proposalId, - voterAddress: session.address, - direction, - }); - - const response = { - ok: true as const, - type: input.type, - proposalId: input.payload.proposalId, - direction: input.payload.direction, - counts, - }; - - if (idempotencyKey) { - await storeIdempotencyResponse(context.env, { - key: idempotencyKey, - address: session.address, - request: requestForIdem, - response, - }); - } - - await appendFeedItemEvent(context.env, { - stage: "pool", - actorAddress: session.address, - entityType: "proposal", - entityId: input.payload.proposalId, - payload: { - id: `pool-vote:${input.payload.proposalId}:${session.address}:${Date.now()}`, - title: "Pool vote cast", - meta: "Proposal pool · Vote", - stage: "pool", - summaryPill: input.payload.direction === "up" ? "Upvote" : "Downvote", - summary: `Recorded a ${input.payload.direction}vote in the proposal pool.`, - stats: [ - { label: "Upvotes", value: String(counts.upvotes) }, - { label: "Downvotes", value: String(counts.downvotes) }, - ], - ctaPrimary: "Open proposal", - href: `/app/proposals/${input.payload.proposalId}/pp`, - timestamp: new Date().toISOString(), - }, - }); - - await appendProposalTimelineItem(context.env, { - proposalId: input.payload.proposalId, - stage: "pool", - actorAddress: session.address, - item: { - id: `timeline:pool-vote:${input.payload.proposalId}:${session.address}:${randomHex(4)}`, - type: "pool.vote", - title: "Pool vote cast", - detail: input.payload.direction === "up" ? "Upvote" : "Downvote", - actor: session.address, - timestamp: new Date().toISOString(), - }, - }); - - const storedPoolDenominator = await getProposalStageDenominator( - context.env, - { - proposalId: input.payload.proposalId, - stage: "pool", - }, - ).catch(() => null); - const poolChamberId = await getProposalChamberIdForPool( - context.env, - readModels, - { proposalId: input.payload.proposalId }, - ); - const simConfig = await getSimConfig( - context.env, - context.request.url, - ).catch(() => null); - const genesisMembers = getGenesisMembersForDenominators( - simConfig, - poolChamberId, - ); - const poolDenominator = - storedPoolDenominator?.activeGovernors ?? - (await getActiveGovernorsDenominatorForChamberCurrentEra(context.env, { - chamberId: poolChamberId, - fallbackActiveGovernors: - typeof activeGovernorsBaseline === "number" - ? activeGovernorsBaseline - : V1_ACTIVE_GOVERNORS_FALLBACK, - genesisMembers, - })); - if (!storedPoolDenominator) { - await captureProposalStageDenominator(context.env, { - proposalId: input.payload.proposalId, - stage: "pool", - activeGovernors: poolDenominator, - }).catch(() => {}); - } - - const canonicalAdvanced = await maybeAdvancePoolProposalToVoteCanonical( - context.env, - { - proposalId: input.payload.proposalId, - counts, - activeGovernors: poolDenominator, - }, - ); - const readModelAdvanced = - !canonicalAdvanced && - readModels && - (await maybeAdvancePoolProposalToVote(readModels, { - proposalId: input.payload.proposalId, - counts, - activeGovernors: poolDenominator, - })); - const advanced = canonicalAdvanced || Boolean(readModelAdvanced); - - if (advanced) { - const voteDenominator = - await getActiveGovernorsDenominatorForChamberCurrentEra(context.env, { - chamberId: poolChamberId, - fallbackActiveGovernors: - typeof activeGovernorsBaseline === "number" - ? activeGovernorsBaseline - : V1_ACTIVE_GOVERNORS_FALLBACK, - genesisMembers, - }); - await captureProposalStageDenominator(context.env, { - proposalId: input.payload.proposalId, - stage: "vote", - activeGovernors: voteDenominator, - }).catch(() => {}); - - await appendFeedItemEvent(context.env, { - stage: "vote", - actorAddress: session.address, - entityType: "proposal", - entityId: input.payload.proposalId, - payload: { - id: `pool-advance:${input.payload.proposalId}:${Date.now()}`, - title: "Proposal advanced", - meta: "Chamber vote", - stage: "vote", - summaryPill: "Advanced", - summary: "Attention quorum met; proposal moved to chamber vote.", - stats: [ - { label: "Upvotes", value: String(counts.upvotes) }, - { - label: "Engaged", - value: String(counts.upvotes + counts.downvotes), - }, - ], - ctaPrimary: "Open proposal", - href: `/app/proposals/${input.payload.proposalId}/chamber`, - timestamp: new Date().toISOString(), - }, - }); - - await appendProposalTimelineItem(context.env, { - proposalId: input.payload.proposalId, - stage: "vote", - actorAddress: session.address, - item: { - id: `timeline:pool-advance:${input.payload.proposalId}:${randomHex(4)}`, - type: "proposal.stage.advanced", - title: "Advanced to chamber vote", - detail: "Attention quorum met", - actor: "system", - timestamp: new Date().toISOString(), - }, - }); - } - - if (created) { - await incrementEraUserActivity(context.env, { - address: session.address, - delta: { poolVotes: 1 }, - }).catch(() => {}); - } - - return jsonResponse(response); - } - - if (input.type === "formation.join") { - if (!readModels) return errorResponse(500, "Read models store unavailable"); - const formationGate = await requireFormationEnabled(context.env, { - proposalId: input.payload.proposalId, - }); - if (!formationGate.ok) return formationGate.error; - const wouldCount = !(await isFormationTeamMember(context.env, { - proposalId: input.payload.proposalId, - memberAddress: session.address, - })); - const quotaError = await enforceEraQuota({ - kind: "formationActions", - wouldCount, - }); - if (quotaError) return quotaError; - - let summary; - let created = false; - try { - const result = await joinFormationProject(context.env, readModels, { - proposalId: input.payload.proposalId, - memberAddress: session.address, - role: input.payload.role ?? null, - }); - summary = result.summary; - created = result.created; - } catch (error) { - const message = (error as Error).message; - if (message === "team_full") - return errorResponse(409, "Formation team is full"); - return errorResponse(400, "Unable to join formation project", { - code: message, - }); - } - - const response = { - ok: true as const, - type: input.type, - proposalId: input.payload.proposalId, - teamSlots: { filled: summary.teamFilled, total: summary.teamTotal }, - }; - - if (idempotencyKey) { - await storeIdempotencyResponse(context.env, { - key: idempotencyKey, - address: session.address, - request: requestForIdem, - response, - }); - } - - await appendFeedItemEvent(context.env, { - stage: "build", - actorAddress: session.address, - entityType: "proposal", - entityId: input.payload.proposalId, - payload: { - id: `formation-join:${input.payload.proposalId}:${session.address}:${Date.now()}`, - title: "Joined formation project", - meta: "Formation", - stage: "build", - summaryPill: "Joined", - summary: "Joined the formation project team (mock).", - stats: [ - { - label: "Team slots", - value: `${summary.teamFilled} / ${summary.teamTotal}`, - }, - ], - ctaPrimary: "Open proposal", - href: `/app/proposals/${input.payload.proposalId}/formation`, - timestamp: new Date().toISOString(), - }, - }); - - await appendProposalTimelineItem(context.env, { - proposalId: input.payload.proposalId, - stage: "build", - actorAddress: session.address, - item: { - id: `timeline:formation-join:${input.payload.proposalId}:${session.address}:${randomHex(4)}`, - type: "formation.join", - title: "Joined formation project", - detail: input.payload.role - ? `Role: ${input.payload.role}` - : "Joined as contributor", - actor: session.address, - timestamp: new Date().toISOString(), - }, - }); - - if (created) { - await incrementEraUserActivity(context.env, { - address: session.address, - delta: { formationActions: 1 }, - }).catch(() => {}); - } - - return jsonResponse(response); - } - - if (input.type === "formation.milestone.submit") { - if (!readModels) return errorResponse(500, "Read models store unavailable"); - const formationGate = await requireFormationEnabled(context.env, { - proposalId: input.payload.proposalId, - }); - if (!formationGate.ok) return formationGate.error; - const status = await getFormationMilestoneStatus(context.env, readModels, { - proposalId: input.payload.proposalId, - milestoneIndex: input.payload.milestoneIndex, - }).catch(() => null); - const wouldCount = - status !== null && status !== "submitted" && status !== "unlocked"; - const quotaError = await enforceEraQuota({ - kind: "formationActions", - wouldCount, - }); - if (quotaError) return quotaError; - - let summary; - let created = false; - try { - const result = await submitFormationMilestone(context.env, readModels, { - proposalId: input.payload.proposalId, - milestoneIndex: input.payload.milestoneIndex, - actorAddress: session.address, - note: input.payload.note ?? null, - }); - summary = result.summary; - created = result.created; - } catch (error) { - const message = (error as Error).message; - if (message === "milestone_out_of_range") - return errorResponse(400, "Milestone index is out of range"); - if (message === "milestone_already_unlocked") - return errorResponse(409, "Milestone is already unlocked"); - return errorResponse(400, "Unable to submit milestone", { - code: message, - }); - } - - const response = { - ok: true as const, - type: input.type, - proposalId: input.payload.proposalId, - milestoneIndex: input.payload.milestoneIndex, - milestones: { - completed: summary.milestonesCompleted, - total: summary.milestonesTotal, - }, - }; - - if (idempotencyKey) { - await storeIdempotencyResponse(context.env, { - key: idempotencyKey, - address: session.address, - request: requestForIdem, - response, - }); - } - - await appendFeedItemEvent(context.env, { - stage: "build", - actorAddress: session.address, - entityType: "proposal", - entityId: input.payload.proposalId, - payload: { - id: `formation-milestone-submit:${input.payload.proposalId}:${input.payload.milestoneIndex}:${Date.now()}`, - title: "Milestone submitted", - meta: "Formation", - stage: "build", - summaryPill: `M${input.payload.milestoneIndex}`, - summary: "Submitted a milestone deliverable for review (mock).", - stats: [ - { - label: "Milestones", - value: `${summary.milestonesCompleted} / ${summary.milestonesTotal}`, - }, - ], - ctaPrimary: "Open proposal", - href: `/app/proposals/${input.payload.proposalId}/formation`, - timestamp: new Date().toISOString(), - }, - }); - - await appendProposalTimelineItem(context.env, { - proposalId: input.payload.proposalId, - stage: "build", - actorAddress: session.address, - item: { - id: `timeline:formation-milestone-unlock:${input.payload.proposalId}:${input.payload.milestoneIndex}:${randomHex(4)}`, - type: "formation.milestone.unlockRequested", - title: `Unlock requested (M${input.payload.milestoneIndex})`, - detail: "Requested unlock for milestone payout (mock)", - actor: session.address, - timestamp: new Date().toISOString(), - }, - }); - - if (created) { - await incrementEraUserActivity(context.env, { - address: session.address, - delta: { formationActions: 1 }, - }).catch(() => {}); - } - - return jsonResponse(response); - } - - if (input.type === "formation.milestone.requestUnlock") { - if (!readModels) return errorResponse(500, "Read models store unavailable"); - const formationGate = await requireFormationEnabled(context.env, { - proposalId: input.payload.proposalId, - }); - if (!formationGate.ok) return formationGate.error; - const quotaError = await enforceEraQuota({ - kind: "formationActions", - wouldCount: true, - }); - if (quotaError) return quotaError; - - let summary; - let created = false; - try { - const result = await requestFormationMilestoneUnlock( - context.env, - readModels, - { - proposalId: input.payload.proposalId, - milestoneIndex: input.payload.milestoneIndex, - actorAddress: session.address, - }, - ); - summary = result.summary; - created = result.created; - } catch (error) { - const message = (error as Error).message; - if (message === "milestone_out_of_range") - return errorResponse(400, "Milestone index is out of range"); - if (message === "milestone_not_submitted") - return errorResponse(409, "Milestone must be submitted first"); - if (message === "milestone_already_unlocked") - return errorResponse(409, "Milestone is already unlocked"); - return errorResponse(400, "Unable to request unlock", { code: message }); - } - - const response = { - ok: true as const, - type: input.type, - proposalId: input.payload.proposalId, - milestoneIndex: input.payload.milestoneIndex, - milestones: { - completed: summary.milestonesCompleted, - total: summary.milestonesTotal, - }, - }; - - if (idempotencyKey) { - await storeIdempotencyResponse(context.env, { - key: idempotencyKey, - address: session.address, - request: requestForIdem, - response, - }); - } - - await appendFeedItemEvent(context.env, { - stage: "build", - actorAddress: session.address, - entityType: "proposal", - entityId: input.payload.proposalId, - payload: { - id: `formation-milestone-unlock:${input.payload.proposalId}:${input.payload.milestoneIndex}:${Date.now()}`, - title: "Milestone unlocked", - meta: "Formation", - stage: "build", - summaryPill: `M${input.payload.milestoneIndex}`, - summary: "Milestone marked as unlocked (mock).", - stats: [ - { - label: "Milestones", - value: `${summary.milestonesCompleted} / ${summary.milestonesTotal}`, - }, - ], - ctaPrimary: "Open proposal", - href: `/app/proposals/${input.payload.proposalId}/formation`, - timestamp: new Date().toISOString(), - }, - }); - - if (created) { - await incrementEraUserActivity(context.env, { - address: session.address, - delta: { formationActions: 1 }, - }).catch(() => {}); - } - - return jsonResponse(response); - } - - if (input.type === "court.case.report") { - if (!readModels) return errorResponse(500, "Read models store unavailable"); - const wouldCount = !(await hasCourtReport(context.env, { - caseId: input.payload.caseId, - reporterAddress: session.address, - })); - const quotaError = await enforceEraQuota({ - kind: "courtActions", - wouldCount, - }); - if (quotaError) return quotaError; - - let overlay; - let created = false; - try { - const result = await reportCourtCase(context.env, readModels, { - caseId: input.payload.caseId, - reporterAddress: session.address, - }); - overlay = result.overlay; - created = result.created; - } catch (error) { - const code = (error as Error).message; - if (code === "court_case_missing") - return errorResponse(404, "Unknown case"); - return errorResponse(400, "Unable to report case", { code }); - } - - const response = { - ok: true as const, - type: input.type, - caseId: input.payload.caseId, - reports: overlay.reports, - status: overlay.status, - }; - - if (idempotencyKey) { - await storeIdempotencyResponse(context.env, { - key: idempotencyKey, - address: session.address, - request: requestForIdem, - response, - }); - } - - await appendFeedItemEvent(context.env, { - stage: "courts", - actorAddress: session.address, - entityType: "court_case", - entityId: input.payload.caseId, - payload: { - id: `court-report:${input.payload.caseId}:${session.address}:${Date.now()}`, - title: "Court case reported", - meta: "Courts", - stage: "courts", - summaryPill: "Report", - summary: "Filed a report for a court case (mock).", - stats: [{ label: "Reports", value: String(overlay.reports) }], - ctaPrimary: "Open courtroom", - href: `/app/courts/${input.payload.caseId}`, - timestamp: new Date().toISOString(), - }, - }); - - if (created) { - await incrementEraUserActivity(context.env, { - address: session.address, - delta: { courtActions: 1 }, - }).catch(() => {}); - } - - return jsonResponse(response); - } - - if (input.type === "court.case.verdict") { - if (!readModels) return errorResponse(500, "Read models store unavailable"); - const wouldCount = !(await hasCourtVerdict(context.env, { - caseId: input.payload.caseId, - voterAddress: session.address, - })); - const quotaError = await enforceEraQuota({ - kind: "courtActions", - wouldCount, - }); - if (quotaError) return quotaError; - - let overlay; - let created = false; - try { - const result = await castCourtVerdict(context.env, readModels, { - caseId: input.payload.caseId, - voterAddress: session.address, - verdict: input.payload.verdict, - }); - overlay = result.overlay; - created = result.created; - } catch (error) { - const code = (error as Error).message; - if (code === "court_case_missing") - return errorResponse(404, "Unknown case"); - if (code === "case_not_live") - return errorResponse(409, "Case is not live"); - return errorResponse(400, "Unable to cast verdict", { code }); - } - - const response = { - ok: true as const, - type: input.type, - caseId: input.payload.caseId, - verdict: input.payload.verdict, - status: overlay.status, - totals: { - guilty: overlay.verdicts.guilty, - notGuilty: overlay.verdicts.notGuilty, - }, - }; - - if (idempotencyKey) { - await storeIdempotencyResponse(context.env, { - key: idempotencyKey, - address: session.address, - request: requestForIdem, - response, - }); - } - - await appendFeedItemEvent(context.env, { - stage: "courts", - actorAddress: session.address, - entityType: "court_case", - entityId: input.payload.caseId, - payload: { - id: `court-verdict:${input.payload.caseId}:${session.address}:${Date.now()}`, - title: "Verdict cast", - meta: "Courtroom", - stage: "courts", - summaryPill: - input.payload.verdict === "guilty" ? "Guilty" : "Not guilty", - summary: "Cast a verdict in a courtroom session (mock).", - stats: [ - { label: "Guilty", value: String(overlay.verdicts.guilty) }, - { label: "Not guilty", value: String(overlay.verdicts.notGuilty) }, - ], - ctaPrimary: "Open courtroom", - href: `/app/courts/${input.payload.caseId}`, - timestamp: new Date().toISOString(), - }, - }); - - if (created) { - await incrementEraUserActivity(context.env, { - address: session.address, - delta: { courtActions: 1 }, - }).catch(() => {}); - } - - return jsonResponse(response); - } - - if (input.type !== "chamber.vote") { - return errorResponse(400, "Unsupported command"); - } - - const proposal = await getProposal(context.env, input.payload.proposalId); - if (proposal && proposal.stage !== "vote") { - return errorResponse(409, "Proposal is not in chamber vote stage", { - code: "stage_invalid", - stage: proposal.stage, - }); - } - - if (proposal && proposal.stage === "vote") { - const now = getSimNow(context.env); - if (proposal.votePassedAt && proposal.voteFinalizesAt) { - if (now.getTime() < proposal.voteFinalizesAt.getTime()) { - return errorResponse(409, "Vote already passed (pending veto)", { - code: "vote_pending_veto", - finalizesAt: proposal.voteFinalizesAt.toISOString(), - }); - } - } - if (now.getTime() < proposal.updatedAt.getTime()) { - return errorResponse(409, "Voting is paused", { - code: "vote_paused", - resumesAt: proposal.updatedAt.toISOString(), - }); - } - } - - if ( - proposal && - stageWindowsEnabled(context.env) && - proposal.stage === "vote" - ) { - const now = getSimNow(context.env); - const windowSeconds = getStageWindowSeconds(context.env, "vote"); - if ( - !isStageOpen({ - now, - stageStartedAt: proposal.updatedAt, - windowSeconds, - }) - ) { - return errorResponse(409, "Voting window ended", { - code: "stage_closed", - stage: "vote", - endedAt: getStageDeadlineIso({ - stageStartedAt: proposal.updatedAt, - windowSeconds, - }), - timeLeft: (() => { - const remaining = getStageRemainingSeconds({ - now, - stageStartedAt: proposal.updatedAt, - windowSeconds, - }); - return remaining === 0 ? "Ended" : formatTimeLeftDaysHours(remaining); - })(), - }); - } - } - - if (proposal) { - const chamberId = (proposal.chamberId ?? "general").toLowerCase(); - if (chamberId !== "general") { - const chamber = await getChamber( - context.env, - context.request.url, - chamberId, - ); - if (chamber?.status === "dissolved" && chamber.dissolvedAt) { - const proposalCreatedAt = proposal.createdAt.getTime(); - const dissolvedAt = chamber.dissolvedAt.getTime(); - if (proposalCreatedAt > dissolvedAt) { - return errorResponse(409, "Chamber is dissolved", { - code: "chamber_dissolved", - chamberId, - dissolvedAt: chamber.dissolvedAt.toISOString(), - }); - } - } - } - } - - const eligibilityError = await enforceChamberVoteEligibility( - context.env, - readModels, - { - proposalId: input.payload.proposalId, - voterAddress: session.address, - }, - context.request.url, - ); - if (eligibilityError) return eligibilityError; - - if (input.payload.choice !== "yes" && input.payload.score !== undefined) { - return errorResponse(400, "Score is only allowed for yes votes"); - } - - const wouldCount = !(await hasChamberVote(context.env, { - proposalId: input.payload.proposalId, - voterAddress: session.address, - })); - const quotaError = await enforceEraQuota({ - kind: "chamberVotes", - wouldCount, - }); - if (quotaError) return quotaError; - - const chamberIdForVote = await getProposalChamberIdForVote( - context.env, - readModels, - { proposalId: input.payload.proposalId }, - ); - const choice = - input.payload.choice === "yes" ? 1 : input.payload.choice === "no" ? -1 : 0; - const { counts, created } = await castChamberVote(context.env, { - proposalId: input.payload.proposalId, - voterAddress: session.address, - choice, - score: - input.payload.choice === "yes" ? (input.payload.score ?? null) : null, - chamberId: chamberIdForVote, - }); - - const response = { - ok: true as const, - type: input.type, - proposalId: input.payload.proposalId, - choice: input.payload.choice, - counts, - }; - - if (idempotencyKey) { - await storeIdempotencyResponse(context.env, { - key: idempotencyKey, - address: session.address, - request: requestForIdem, - response, - }); - } - - await appendFeedItemEvent(context.env, { - stage: "vote", - actorAddress: session.address, - entityType: "proposal", - entityId: input.payload.proposalId, - payload: { - id: `chamber-vote:${input.payload.proposalId}:${session.address}:${Date.now()}`, - title: "Chamber vote cast", - meta: "Chamber vote", - stage: "vote", - summaryPill: - input.payload.choice === "yes" - ? "Yes" - : input.payload.choice === "no" - ? "No" - : "Abstain", - summary: "Recorded a vote in chamber stage.", - stats: [ - { label: "Yes", value: String(counts.yes) }, - { label: "No", value: String(counts.no) }, - { label: "Abstain", value: String(counts.abstain) }, - ], - ctaPrimary: "Open proposal", - href: `/app/proposals/${input.payload.proposalId}/chamber`, - timestamp: new Date().toISOString(), - }, - }); - - await appendProposalTimelineItem(context.env, { - proposalId: input.payload.proposalId, - stage: "vote", - actorAddress: session.address, - item: { - id: `timeline:chamber-vote:${input.payload.proposalId}:${session.address}:${randomHex(4)}`, - type: "chamber.vote", - title: "Chamber vote cast", - detail: - input.payload.choice === "yes" - ? `Yes${input.payload.score ? ` (score ${input.payload.score})` : ""}` - : input.payload.choice === "no" - ? "No" - : "Abstain", - actor: session.address, - timestamp: new Date().toISOString(), - }, - }); - - const storedVoteDenominator = await getProposalStageDenominator(context.env, { - proposalId: input.payload.proposalId, - stage: "vote", - }).catch(() => null); - const simConfig = await getSimConfig(context.env, context.request.url).catch( - () => null, - ); - const genesisMembers = getGenesisMembersForDenominators( - simConfig, - chamberIdForVote, - ); - const voteDenominator = - storedVoteDenominator?.activeGovernors ?? - (await getActiveGovernorsDenominatorForChamberCurrentEra(context.env, { - chamberId: chamberIdForVote, - fallbackActiveGovernors: - typeof activeGovernorsBaseline === "number" - ? activeGovernorsBaseline - : V1_ACTIVE_GOVERNORS_FALLBACK, - genesisMembers, - })); - if (!storedVoteDenominator) { - await captureProposalStageDenominator(context.env, { - proposalId: input.payload.proposalId, - stage: "vote", - activeGovernors: voteDenominator, - }).catch(() => {}); - } - - const canonicalOutcome = await maybeAdvanceVoteProposalToBuildCanonical( - context.env, - { - proposalId: input.payload.proposalId, - counts, - activeGovernors: voteDenominator, - }, - context.request.url, - ); - - const readModelAdvanced = - canonicalOutcome.status === "none" && - readModels && - (await maybeAdvanceVoteProposalToBuild(context.env, readModels, { - proposalId: input.payload.proposalId, - counts, - activeGovernors: voteDenominator, - requestUrl: context.request.url, - })); - - const advanced = - canonicalOutcome.status === "advanced" || Boolean(readModelAdvanced); - - if (canonicalOutcome.status === "pending_veto") { - await appendFeedItemEvent(context.env, { - stage: "vote", - actorAddress: session.address, - entityType: "proposal", - entityId: input.payload.proposalId, - payload: { - id: `vote-pass-pending-veto:${input.payload.proposalId}:${Date.now()}`, - title: "Proposal passed (pending veto)", - meta: "Chamber vote", - stage: "vote", - summaryPill: "Passed", - summary: - "Chamber vote passed; the proposal is in the veto window before acceptance is finalized.", - stats: [ - { label: "Yes", value: String(counts.yes) }, - { - label: "Engaged", - value: String(counts.yes + counts.no + counts.abstain), - }, - { - label: "Veto", - value: `${canonicalOutcome.vetoCouncilSize} holders · ${canonicalOutcome.vetoThreshold} needed`, - }, - ], - ctaPrimary: "Open proposal", - href: `/app/proposals/${input.payload.proposalId}/chamber`, - timestamp: new Date().toISOString(), - }, - }); - - await appendProposalTimelineItem(context.env, { - proposalId: input.payload.proposalId, - stage: "vote", - actorAddress: session.address, - item: { - id: `timeline:vote-pass-pending-veto:${input.payload.proposalId}:${randomHex(4)}`, - type: "proposal.vote.passed", - title: "Chamber vote passed", - detail: `Pending veto until ${canonicalOutcome.finalizesAt}`, - actor: "system", - timestamp: new Date().toISOString(), - }, - }); - } - - if (advanced) { - const avgScore = - canonicalOutcome.status === "advanced" - ? canonicalOutcome.avgScore - : ((await getChamberYesScoreAverage( - context.env, - input.payload.proposalId, - )) ?? null); - const formationEligible = - canonicalOutcome.status === "advanced" - ? canonicalOutcome.formationEligible - : await (async () => { - if (!readModels) return true; - const chamberPayload = await readModels.get( - `proposals:${input.payload.proposalId}:chamber`, - ); - if (isRecord(chamberPayload)) { - const meta = parseChamberGovernanceFromPayload(chamberPayload); - if (meta) return false; - if (typeof chamberPayload.formationEligible === "boolean") { - return chamberPayload.formationEligible; - } - } - const poolPayload = await readModels.get( - `proposals:${input.payload.proposalId}:pool`, - ); - if (isRecord(poolPayload)) { - const meta = parseChamberGovernanceFromPayload(poolPayload); - if (meta) return false; - if (typeof poolPayload.formationEligible === "boolean") { - return poolPayload.formationEligible; - } - } - return true; - })(); - - await appendFeedItemEvent(context.env, { - stage: "build", - actorAddress: session.address, - entityType: "proposal", - entityId: input.payload.proposalId, - payload: { - id: `vote-pass:${input.payload.proposalId}:${Date.now()}`, - title: "Proposal accepted", - meta: "Chamber vote", - stage: "build", - summaryPill: "Accepted", - summary: "Chamber vote finalized; proposal is now accepted.", - stats: [ - ...(avgScore !== null - ? [{ label: "Avg CM", value: avgScore.toFixed(1) }] - : []), - { label: "Yes", value: String(counts.yes) }, - { - label: "Engaged", - value: String(counts.yes + counts.no + counts.abstain), - }, - ], - ctaPrimary: "Open proposal", - href: formationEligible - ? `/app/proposals/${input.payload.proposalId}/formation` - : `/app/proposals/${input.payload.proposalId}/chamber`, - timestamp: new Date().toISOString(), - }, - }); - - await appendProposalTimelineItem(context.env, { - proposalId: input.payload.proposalId, - stage: "build", - actorAddress: session.address, - item: { - id: `timeline:vote-pass:${input.payload.proposalId}:${randomHex(4)}`, - type: "proposal.stage.advanced", - title: "Advanced to accepted", - detail: "Chamber vote finalized", - actor: "system", - timestamp: new Date().toISOString(), - }, - }); - } - - if (created) { - await incrementEraUserActivity(context.env, { - address: session.address, - delta: { chamberVotes: 1 }, - }).catch(() => {}); - } - - return jsonResponse(response); -}; - -function isRecord(value: unknown): value is Record { - return Boolean(value) && typeof value === "object" && !Array.isArray(value); -} - -async function getProposalStage( - store: Awaited>, - proposalId: string, -): Promise { - const listPayload = await store.get("proposals:list"); - if (!isRecord(listPayload)) return null; - const items = listPayload.items; - if (!Array.isArray(items)) return null; - const item = items.find( - (entry) => isRecord(entry) && entry.id === proposalId, - ); - if (!item || !isRecord(item)) return null; - return typeof item.stage === "string" ? item.stage : null; -} - -async function maybeAdvancePoolProposalToVote( - store: Awaited>, - input: { - proposalId: string; - counts: { upvotes: number; downvotes: number }; - activeGovernors: number; - }, -): Promise { - if (!store.set) return false; - - const poolPayload = await store.get(`proposals:${input.proposalId}:pool`); - if (!isRecord(poolPayload)) return false; - const attentionQuorum = poolPayload.attentionQuorum; - const activeGovernors = input.activeGovernors; - const upvoteFloor = computePoolUpvoteFloor(activeGovernors); - if ( - typeof attentionQuorum !== "number" || - typeof activeGovernors !== "number" || - typeof upvoteFloor !== "number" - ) { - return false; - } - - const quorum = evaluatePoolQuorum( - { attentionQuorum, activeGovernors, upvoteFloor }, - input.counts, - ); - if (!quorum.shouldAdvance) return false; - - const listPayload = await store.get("proposals:list"); - if (!isRecord(listPayload)) return false; - const items = listPayload.items; - if (!Array.isArray(items)) return false; - - const chamberPayload = await ensureChamberProposalPage( - store, - input.proposalId, - poolPayload, - { - activeGovernors, - }, - ); - const voteStageData = buildVoteStageData(chamberPayload); - - let changed = false; - const nextItems = items.map((item) => { - if (!isRecord(item) || item.id !== input.proposalId) return item; - if (item.stage !== "pool") return item; - changed = true; - return { - ...item, - stage: "vote", - summaryPill: "Chamber vote", - stageData: voteStageData ?? item.stageData, - }; - }); - if (!changed) return false; - - await store.set("proposals:list", { ...listPayload, items: nextItems }); - return true; -} - -async function maybeAdvancePoolProposalToVoteCanonical( - env: Record, - input: { - proposalId: string; - counts: { upvotes: number; downvotes: number }; - activeGovernors: number; - }, -): Promise { - const proposal = await getProposal(env, input.proposalId); - if (!proposal) return false; - if (proposal.stage !== "pool") return false; - - const shouldAdvance = shouldAdvancePoolToVote({ - activeGovernors: input.activeGovernors, - counts: input.counts, - }); - if (!shouldAdvance) return false; - - return transitionProposalStage(env, { - proposalId: input.proposalId, - from: "pool", - to: "vote", - }); -} - -async function ensureChamberProposalPage( - store: Awaited>, - proposalId: string, - poolPayload: Record, - input: { activeGovernors: number }, -): Promise { - const existing = await store.get(`proposals:${proposalId}:chamber`); - if (existing) return existing; - if (!store.set) return existing; - - const generated = buildChamberProposalPageFromPool(poolPayload); - (generated as Record).activeGovernors = - input.activeGovernors; - await store.set(`proposals:${proposalId}:chamber`, generated); - return generated; -} - -function asString(value: unknown, fallback = ""): string { - return typeof value === "string" ? value : fallback; -} - -function asNumber(value: unknown, fallback = 0): number { - const n = typeof value === "number" ? value : Number(value); - return Number.isFinite(n) ? n : fallback; -} - -function asBoolean(value: unknown, fallback = false): boolean { - return typeof value === "boolean" ? value : fallback; -} - -function asArray(value: unknown): T[] { - return Array.isArray(value) ? (value as T[]) : []; -} - -function buildChamberProposalPageFromPool( - poolPayload: Record, -): Record { - const activeGovernors = asNumber(poolPayload.activeGovernors, 0); - return { - title: asString(poolPayload.title, "Proposal"), - proposer: asString(poolPayload.proposer, "Unknown"), - proposerId: asString(poolPayload.proposerId, "unknown"), - chamber: asString(poolPayload.chamber, "General chamber"), - budget: asString(poolPayload.budget, "—"), - formationEligible: asBoolean(poolPayload.formationEligible, false), - templateId: poolPayload.templateId, - metaGovernance: poolPayload.metaGovernance, - teamSlots: asString(poolPayload.teamSlots, "—"), - milestones: asString(poolPayload.milestones, "—"), - timeLeft: "3d 00h", - votes: { yes: 0, no: 0, abstain: 0 }, - attentionQuorum: V1_CHAMBER_QUORUM_FRACTION, - passingRule: `≥${(V1_CHAMBER_PASSING_FRACTION * 100).toFixed(1)}% + 1 yes within quorum`, - engagedGovernors: 0, - activeGovernors, - attachments: asArray(poolPayload.attachments), - teamLocked: asArray(poolPayload.teamLocked), - openSlotNeeds: asArray(poolPayload.openSlotNeeds), - milestonesDetail: asArray(poolPayload.milestonesDetail), - summary: asString(poolPayload.summary, ""), - overview: asString(poolPayload.overview, ""), - executionPlan: asArray(poolPayload.executionPlan), - budgetScope: asString(poolPayload.budgetScope, ""), - invisionInsight: isRecord(poolPayload.invisionInsight) - ? poolPayload.invisionInsight - : { role: "—", bullets: [] }, - }; -} - -function buildVoteStageData(payload: unknown): Array<{ - title: string; - description: string; - value: string; - tone?: "ok" | "warn"; -}> | null { - if (!isRecord(payload)) return null; - const attentionQuorum = payload.attentionQuorum; - const activeGovernors = payload.activeGovernors; - const engagedGovernors = payload.engagedGovernors; - const passingRule = payload.passingRule; - const timeLeft = payload.timeLeft; - const votes = payload.votes; - if ( - typeof attentionQuorum !== "number" || - typeof activeGovernors !== "number" || - typeof engagedGovernors !== "number" || - typeof passingRule !== "string" || - typeof timeLeft !== "string" || - !isRecord(votes) - ) { - return null; - } - - const yes = Number(votes.yes ?? 0); - const no = Number(votes.no ?? 0); - const abstain = Number(votes.abstain ?? 0); - const total = Math.max(0, yes) + Math.max(0, no) + Math.max(0, abstain); - const yesPct = total > 0 ? (yes / total) * 100 : 0; - - const quorumNeeded = Math.ceil( - Math.max(0, activeGovernors) * attentionQuorum, - ); - const quorumPct = - activeGovernors > 0 ? (engagedGovernors / activeGovernors) * 100 : 0; - const quorumMet = engagedGovernors >= quorumNeeded; - - return [ - { - title: "Voting quorum", - description: `Strict ${Math.round(attentionQuorum * 100)}% active governors`, - value: `${quorumMet ? "Met" : "Needs"} · ${Math.round(quorumPct)}%`, - tone: quorumMet ? "ok" : "warn", - }, - { - title: "Passing rule", - description: passingRule, - value: `Current ${Math.round(yesPct)}%`, - tone: yesPct >= V1_CHAMBER_PASSING_FRACTION * 100 ? "ok" : "warn", - }, - { title: "Time left", description: "Voting window", value: timeLeft }, - ]; -} - -async function upsertChamberReadModel( - store: Awaited>, - input: { - action: "create" | "dissolve"; - id: string; - title?: string; - multiplier?: number; - }, -): Promise { - if (!store.set) return; - const listPayload = await store.get("chambers:list"); - const existing = - isRecord(listPayload) && Array.isArray(listPayload.items) - ? listPayload.items - : []; - - const normalizedId = input.id.trim().toLowerCase(); - const nextItems = existing.filter( - (item) => !isRecord(item) || String(item.id).toLowerCase() !== normalizedId, - ); - - if (input.action === "create") { - const multiplier = - typeof input.multiplier === "number" && Number.isFinite(input.multiplier) - ? input.multiplier - : 1; - nextItems.push({ - id: normalizedId, - name: input.title?.trim() || normalizedId, - multiplier, - stats: { governors: "0", acm: "0", mcm: "0", lcm: "0" }, - pipeline: { pool: 0, vote: 0, build: 0 }, - status: "active", - }); - - await store.set(`chambers:${normalizedId}`, { - proposals: [], - governors: [], - threads: [], - chatLog: [], - stageOptions: [ - { value: "upcoming", label: "Upcoming" }, - { value: "live", label: "Live" }, - { value: "ended", label: "Ended" }, - ], - }); - } - - await store.set("chambers:list", { - ...(isRecord(listPayload) ? listPayload : {}), - items: nextItems, - }); -} - -async function maybeAdvanceVoteProposalToBuild( - env: Record, - store: Awaited>, - input: { - proposalId: string; - counts: { yes: number; no: number; abstain: number }; - activeGovernors: number; - requestUrl: string; - }, -): Promise { - if (!store.set) return false; - - const chamberPayload = await store.get( - `proposals:${input.proposalId}:chamber`, - ); - if (!isRecord(chamberPayload)) return false; - - const attentionQuorum = chamberPayload.attentionQuorum; - const activeGovernors = input.activeGovernors; - let meta = parseChamberGovernanceFromPayload(chamberPayload); - let poolPayload: Record | null = null; - if (!meta) { - const candidate = await store.get(`proposals:${input.proposalId}:pool`); - if (isRecord(candidate)) { - poolPayload = candidate; - meta = parseChamberGovernanceFromPayload(candidate); - } - } - const formationEligible = meta - ? false - : typeof chamberPayload.formationEligible === "boolean" - ? chamberPayload.formationEligible - : poolPayload && typeof poolPayload.formationEligible === "boolean" - ? poolPayload.formationEligible - : true; - if ( - typeof attentionQuorum !== "number" || - typeof activeGovernors !== "number" || - typeof formationEligible !== "boolean" - ) { - return false; - } - - const minQuorum = - env.SIM_ACTIVE_GOVERNORS || env.VORTEX_ACTIVE_GOVERNORS - ? undefined - : activeGovernors > 1 - ? 2 - : undefined; - - const quorum = evaluateChamberQuorum( - { - quorumFraction: attentionQuorum, - activeGovernors, - passingFraction: V1_CHAMBER_PASSING_FRACTION, - minQuorum, - }, - input.counts, - ); - if (!quorum.shouldAdvance) return false; - - const listPayload = await store.get("proposals:list"); - if (!isRecord(listPayload)) return false; - const items = listPayload.items; - if (!Array.isArray(items)) return false; - - if (meta?.action === "chamber.create" && meta.title && meta.id) { - await createChamberFromAcceptedGeneralProposal(env, input.requestUrl, { - id: meta.id, - title: meta.title, - multiplier: meta.multiplier, - proposalId: input.proposalId, - }); - - await upsertChamberReadModel(store, { - action: "create", - id: meta.id, - title: meta.title, - multiplier: meta.multiplier, - }); - - const genesisMembers = (() => { - const source = - (chamberPayload.metaGovernance as { genesisMembers?: unknown }) ?? - (poolPayload?.metaGovernance as { genesisMembers?: unknown }); - const raw = source?.genesisMembers; - if (!Array.isArray(raw)) return []; - return raw - .filter((v): v is string => typeof v === "string") - .map((v) => v.trim()) - .filter(Boolean); - })(); - - const proposerId = asString(chamberPayload.proposerId, "").trim(); - const memberSet = new Set(genesisMembers); - if (proposerId) memberSet.add(proposerId); - for (const address of memberSet) { - await ensureChamberMembership(env, { - address, - chamberId: meta.id, - grantedByProposalId: input.proposalId, - source: "chamber_genesis", - }); - } - } - - if (meta?.action === "chamber.dissolve" && meta.id) { - await dissolveChamberFromAcceptedGeneralProposal(env, input.requestUrl, { - id: meta.id, - proposalId: input.proposalId, - }); - - await upsertChamberReadModel(store, { - action: "dissolve", - id: meta.id, - }); - } - - if (formationEligible) { - await ensureFormationProposalPage(store, input.proposalId, chamberPayload); - await ensureFormationSeed(env, store, input.proposalId); - } - - let changed = false; - const nextItems = items.map((item) => { - if (!isRecord(item) || item.id !== input.proposalId) return item; - if (item.stage !== "vote") return item; - changed = true; - return { - ...item, - stage: "build", - summaryPill: formationEligible ? "Formation" : "Passed", - }; - }); - if (!changed) return false; - - await store.set("proposals:list", { ...listPayload, items: nextItems }); - - const proposerId = asString(chamberPayload.proposerId, ""); - const chamberLabel = asString(chamberPayload.chamber, ""); - const chamberId = normalizeChamberId(chamberLabel); - const multiplierTimes10 = await getChamberMultiplierTimes10(store, chamberId); - const avgScore = - (await getChamberYesScoreAverage(env, input.proposalId)) ?? null; - - if (proposerId && avgScore !== null) { - const lcmPoints = Math.round(avgScore * 10); - const mcmPoints = Math.round((lcmPoints * multiplierTimes10) / 10); - await awardCmOnce(env, { - proposalId: input.proposalId, - proposerId, - chamberId, - avgScore, - lcmPoints, - chamberMultiplierTimes10: multiplierTimes10, - mcmPoints, - }); - } - - return true; -} - -async function maybeAdvanceVoteProposalToBuildCanonical( - env: Record, - input: { - proposalId: string; - counts: { yes: number; no: number; abstain: number }; - activeGovernors: number; - }, - requestUrl: string, -): Promise< - | { status: "none" } - | { - status: "pending_veto"; - finalizesAt: string; - vetoCouncilSize: number; - vetoThreshold: number; - } - | { status: "advanced"; formationEligible: boolean; avgScore: number | null } -> { - const proposal = await getProposal(env, input.proposalId); - if (!proposal) return { status: "none" }; - if (proposal.stage !== "vote") return { status: "none" }; - if (proposal.votePassedAt && proposal.voteFinalizesAt) { - const now = getSimNow(env); - if (now.getTime() < proposal.voteFinalizesAt.getTime()) { - return { status: "none" }; - } - } - - const minQuorum = - env.SIM_ACTIVE_GOVERNORS || env.VORTEX_ACTIVE_GOVERNORS - ? undefined - : input.activeGovernors > 1 - ? 2 - : undefined; - - const shouldAdvance = shouldAdvanceVoteToBuild({ - activeGovernors: input.activeGovernors, - counts: input.counts, - minQuorum, - }); - if (!shouldAdvance) return { status: "none" }; - - const vetoCount = proposal.vetoCount ?? 0; - if (vetoCount < V1_VETO_MAX_APPLIES) { - const snapshot = await computeVetoCouncilSnapshot(env, requestUrl); - if (snapshot.members.length > 0 && snapshot.threshold > 0) { - const now = getSimNow(env); - const finalizesAt = new Date( - now.getTime() + V1_VETO_DELAY_SECONDS_DEFAULT * 1000, - ); - await clearVetoVotesForProposal(env, proposal.id).catch(() => {}); - await setProposalVotePendingVeto(env, { - proposalId: proposal.id, - passedAt: now, - finalizesAt, - vetoCouncil: snapshot.members, - vetoThreshold: snapshot.threshold, - }); - return { - status: "pending_veto", - finalizesAt: finalizesAt.toISOString(), - vetoCouncilSize: snapshot.members.length, - vetoThreshold: snapshot.threshold, - }; - } - } - - const finalized = await finalizeAcceptedProposalFromVote(env, { - proposalId: proposal.id, - requestUrl, - }); - if (!finalized.ok) return { status: "none" }; - - return { - status: "advanced", - formationEligible: finalized.formationEligible, - avgScore: finalized.avgScore, - }; -} - -function getFormationEligibleFromProposalPayload(payload: unknown): boolean { - if (!isRecord(payload)) return true; - if (payload.templateId === "system") return false; - if ( - typeof payload.metaGovernance === "object" && - payload.metaGovernance !== null && - !Array.isArray(payload.metaGovernance) - ) - return false; - if (typeof payload.formationEligible === "boolean") - return payload.formationEligible; - if (typeof payload.formation === "boolean") return payload.formation; - return true; -} - -async function requireFormationEnabled( - env: Record, - input: { proposalId: string }, -): Promise< - | { ok: true } - | { - ok: false; - error: Response; - } -> { - const proposal = await getProposal(env, input.proposalId); - if (!proposal) return { ok: true }; - if (proposal.stage !== "build") { - return { - ok: false, - error: errorResponse(409, "Proposal is not in formation stage", { - code: "stage_invalid", - stage: proposal.stage, - }), - }; - } - if (!getFormationEligibleFromProposalPayload(proposal.payload)) { - return { - ok: false, - error: errorResponse(409, "Formation is not required for this proposal", { - code: "formation_not_required", - }), - }; - } - return { ok: true }; -} - -async function ensureFormationProposalPage( - store: Awaited>, - proposalId: string, - chamberPayload: Record, -): Promise { - const existing = await store.get(`proposals:${proposalId}:formation`); - if (existing) return; - if (!store.set) return; - await store.set( - `proposals:${proposalId}:formation`, - buildFormationProposalPageFromChamber(chamberPayload), - ); -} - -function buildFormationProposalPageFromChamber( - chamberPayload: Record, -): Record { - return { - title: asString(chamberPayload.title, "Proposal"), - chamber: asString(chamberPayload.chamber, "General chamber"), - proposer: asString(chamberPayload.proposer, "Unknown"), - proposerId: asString(chamberPayload.proposerId, "unknown"), - budget: asString(chamberPayload.budget, "—"), - timeLeft: "12w", - teamSlots: asString(chamberPayload.teamSlots, "0 / 0"), - milestones: asString(chamberPayload.milestones, "0 / 0"), - progress: "0%", - stageData: [ - { title: "Budget allocated", description: "HMND", value: "0 / —" }, - { title: "Team slots", description: "Filled / Total", value: "0 / —" }, - { title: "Milestones", description: "Completed / Total", value: "0 / —" }, - ], - stats: [{ label: "Lead chamber", value: asString(chamberPayload.chamber) }], - lockedTeam: asArray(chamberPayload.teamLocked), - openSlots: asArray(chamberPayload.openSlotNeeds), - milestonesDetail: asArray(chamberPayload.milestonesDetail), - attachments: asArray(chamberPayload.attachments), - summary: asString(chamberPayload.summary, ""), - overview: asString(chamberPayload.overview, ""), - executionPlan: asArray(chamberPayload.executionPlan), - budgetScope: asString(chamberPayload.budgetScope, ""), - invisionInsight: isRecord(chamberPayload.invisionInsight) - ? chamberPayload.invisionInsight - : { role: "—", bullets: [] }, - }; -} - -function normalizeChamberId(chamberLabel: string): string { - const match = chamberLabel.trim().match(/^([A-Za-z]+)/); - return (match?.[1] ?? chamberLabel).toLowerCase(); -} - -async function enforceChamberVoteEligibility( - env: Record, - readModels: Awaited> | null, - input: { proposalId: string; voterAddress: string }, - requestUrl: string, -): Promise { - if (envBoolean(env, "DEV_BYPASS_CHAMBER_ELIGIBILITY")) return null; - - const simConfig = await getSimConfig(env, requestUrl); - const genesis = simConfig?.genesisChamberMembers; - - const chamberId = await getProposalChamberIdForVote(env, readModels, { - proposalId: input.proposalId, - }); - const voterAddress = input.voterAddress.trim(); - - const hasGenesisMembership = async ( - targetChamberId: string, - ): Promise => { - if (!genesis) return false; - const members = genesis[targetChamberId.toLowerCase()] ?? []; - for (const member of members) { - if (await addressesReferToSameKey(member, voterAddress)) return true; - } - return false; - }; - const hasAnyGenesisMembership = async (): Promise => { - if (!genesis) return false; - for (const members of Object.values(genesis)) { - for (const member of members) { - if (await addressesReferToSameKey(member, voterAddress)) return true; - } - } - return false; - }; - - if (chamberId === "general") { - // Bootstrap: if the user is explicitly configured with a tier, treat them as eligible in General. - const tier = await resolveUserTierFromSimConfig(simConfig, voterAddress); - if (tier !== "Nominee") return null; - - return null; - } - - const eligible = await hasChamberMembership(env, { - address: voterAddress, - chamberId, - }); - if (!eligible && !(await hasGenesisMembership(chamberId))) { - return errorResponse(403, "Not eligible to vote in this chamber", { - code: "chamber_vote_ineligible", - chamberId, - }); - } - return null; -} - -async function enforcePoolVoteEligibility( - env: Record, - readModels: Awaited> | null, - input: { proposalId: string; voterAddress: string }, - requestUrl: string, -): Promise { - if (envBoolean(env, "DEV_BYPASS_CHAMBER_ELIGIBILITY")) return null; - - const simConfig = await getSimConfig(env, requestUrl); - const genesis = simConfig?.genesisChamberMembers; - - const chamberId = await getProposalChamberIdForPool(env, readModels, { - proposalId: input.proposalId, - }); - const voterAddress = input.voterAddress.trim(); - - const hasAnyGenesisMembership = async (): Promise => { - if (!genesis) return false; - for (const members of Object.values(genesis)) { - for (const member of members) { - if (await addressesReferToSameKey(member, voterAddress)) return true; - } - } - return false; - }; - - const hasGenesisMembership = async (target: string): Promise => { - const members = genesis?.[target]?.map((m) => m.trim()) ?? []; - for (const member of members) { - if (await addressesReferToSameKey(member, voterAddress)) return true; - } - return false; - }; - - if (chamberId === "general") { - // Bootstrap: if the user is explicitly configured with a tier, treat them as eligible in General. - const tier = await resolveUserTierFromSimConfig(simConfig, voterAddress); - if (tier !== "Nominee") return null; - - const eligible = - (await hasAnyChamberMembership(env, voterAddress)) || - (await hasChamberMembership(env, { - address: voterAddress, - chamberId: "general", - })) || - (await hasAnyGenesisMembership()); - if (!eligible) { - return errorResponse(403, "Not eligible to vote in the proposal pool", { - code: "pool_vote_ineligible", - chamberId, - }); - } - return null; - } - - const eligible = - (await hasChamberMembership(env, { address: voterAddress, chamberId })) || - (await hasGenesisMembership(chamberId)); - if (!eligible) { - return errorResponse(403, "Not eligible to vote in the proposal pool", { - code: "pool_vote_ineligible", - chamberId, - }); - } - return null; -} - -async function getProposalChamberIdForVote( - env: Record, - readModels: Awaited> | null, - input: { proposalId: string }, -): Promise { - const proposal = await getProposal(env, input.proposalId); - if (proposal) return (proposal.chamberId ?? "general").toLowerCase(); - - if (!readModels) return "general"; - - const chamberPayload = await readModels.get( - `proposals:${input.proposalId}:chamber`, - ); - if (isRecord(chamberPayload)) { - const label = asString(chamberPayload.chamber, ""); - const normalized = normalizeChamberId(label); - return normalized || "general"; - } - - const listPayload = await readModels.get("proposals:list"); - if (isRecord(listPayload) && Array.isArray(listPayload.items)) { - const entry = listPayload.items.find( - (item) => isRecord(item) && item.id === input.proposalId, - ); - if (isRecord(entry)) { - const label = asString(entry.chamber, asString(entry.meta, "")); - const normalized = normalizeChamberId(label); - return normalized || "general"; - } - } - - return "general"; -} - -async function getProposalChamberIdForPool( - env: Record, - readModels: Awaited> | null, - input: { proposalId: string }, -): Promise { - const proposal = await getProposal(env, input.proposalId); - if (proposal) return (proposal.chamberId ?? "general").toLowerCase(); - - if (!readModels) return "general"; - - const poolPayload = await readModels.get( - `proposals:${input.proposalId}:pool`, - ); - if (isRecord(poolPayload)) { - const label = asString(poolPayload.chamber, ""); - const normalized = normalizeChamberId(label); - return normalized || "general"; - } - - const listPayload = await readModels.get("proposals:list"); - if (isRecord(listPayload) && Array.isArray(listPayload.items)) { - const entry = listPayload.items.find( - (item) => isRecord(item) && item.id === input.proposalId, - ); - if (isRecord(entry)) { - const label = asString(entry.chamber, asString(entry.meta, "")); - const normalized = normalizeChamberId(label); - return normalized || "general"; - } - } - - return "general"; -} - -async function getChamberMultiplierTimes10( - store: Awaited>, - chamberId: string, -): Promise { - const payload = await store.get("chambers:list"); - if (!isRecord(payload)) return 10; - const items = payload.items; - if (!Array.isArray(items)) return 10; - const entry = items.find( - (item) => - isRecord(item) && - (item.id === chamberId || - (typeof item.name === "string" && - item.name.toLowerCase() === chamberId)), - ); - if (!isRecord(entry)) return 10; - const mult = entry.multiplier; - if (typeof mult !== "number") return 10; - return Math.round(mult * 10); -} diff --git a/functions/api/courts/[id].ts b/functions/api/courts/[id].ts deleted file mode 100644 index 1930b6c..0000000 --- a/functions/api/courts/[id].ts +++ /dev/null @@ -1,32 +0,0 @@ -import { createReadModelsStore } from "../../_lib/readModelsStore.ts"; -import { errorResponse, jsonResponse } from "../../_lib/http.ts"; -import { getCourtOverlay } from "../../_lib/courtsStore.ts"; - -export const onRequestGet: ApiHandler = async (context) => { - try { - const id = context.params?.id; - if (!id) return errorResponse(400, "Missing case id"); - const store = await createReadModelsStore(context.env); - const payload = await store.get(`courts:${id}`); - if (!payload) return errorResponse(404, `Missing read model: courts:${id}`); - - let overlay; - try { - overlay = await getCourtOverlay(context.env, store, id); - } catch { - overlay = null; - } - - if (!overlay) return jsonResponse(payload); - if (!payload || typeof payload !== "object" || Array.isArray(payload)) - return jsonResponse(payload); - const record = payload as Record; - return jsonResponse({ - ...record, - status: overlay.status, - reports: overlay.reports, - }); - } catch (error) { - return errorResponse(500, (error as Error).message); - } -}; diff --git a/functions/api/courts/index.ts b/functions/api/courts/index.ts deleted file mode 100644 index fe7c8d9..0000000 --- a/functions/api/courts/index.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { createReadModelsStore } from "../../_lib/readModelsStore.ts"; -import { errorResponse, jsonResponse } from "../../_lib/http.ts"; -import { getCourtOverlay } from "../../_lib/courtsStore.ts"; - -export const onRequestGet: ApiHandler = async (context) => { - try { - const store = await createReadModelsStore(context.env); - const payload = await store.get("courts:list"); - if (!payload) return jsonResponse({ items: [] }); - if ( - typeof payload !== "object" || - payload === null || - Array.isArray(payload) - ) - return jsonResponse({ items: [] }); - - const record = payload as Record; - const items = Array.isArray(record.items) ? record.items : []; - - const nextItems = await Promise.all( - items.map(async (item) => { - if (!item || typeof item !== "object" || Array.isArray(item)) - return item; - const row = item as Record; - const id = typeof row.id === "string" ? row.id : null; - if (!id) return item; - try { - const overlay = await getCourtOverlay(context.env, store, id); - return { ...row, status: overlay.status, reports: overlay.reports }; - } catch { - return item; - } - }), - ); - - return jsonResponse({ ...record, items: nextItems }); - } catch (error) { - return errorResponse(500, (error as Error).message); - } -}; diff --git a/functions/api/factions/[id].ts b/functions/api/factions/[id].ts deleted file mode 100644 index fcfcacf..0000000 --- a/functions/api/factions/[id].ts +++ /dev/null @@ -1,16 +0,0 @@ -import { createReadModelsStore } from "../../_lib/readModelsStore.ts"; -import { errorResponse, jsonResponse } from "../../_lib/http.ts"; - -export const onRequestGet: ApiHandler = async (context) => { - try { - const id = context.params?.id; - if (!id) return errorResponse(400, "Missing faction id"); - const store = await createReadModelsStore(context.env); - const payload = await store.get(`factions:${id}`); - if (!payload) - return errorResponse(404, `Missing read model: factions:${id}`); - return jsonResponse(payload); - } catch (error) { - return errorResponse(500, (error as Error).message); - } -}; diff --git a/functions/api/factions/index.ts b/functions/api/factions/index.ts deleted file mode 100644 index d476e0f..0000000 --- a/functions/api/factions/index.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { createReadModelsStore } from "../../_lib/readModelsStore.ts"; -import { errorResponse, jsonResponse } from "../../_lib/http.ts"; - -export const onRequestGet: ApiHandler = async (context) => { - try { - const store = await createReadModelsStore(context.env); - const payload = await store.get("factions:list"); - return jsonResponse(payload ?? { items: [] }); - } catch (error) { - return errorResponse(500, (error as Error).message); - } -}; diff --git a/functions/api/feed/index.ts b/functions/api/feed/index.ts deleted file mode 100644 index 4ef6f51..0000000 --- a/functions/api/feed/index.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { createReadModelsStore } from "../../_lib/readModelsStore.ts"; -import { errorResponse, jsonResponse } from "../../_lib/http.ts"; -import { base64UrlDecode, base64UrlEncode } from "../../_lib/base64url.ts"; -import { listFeedEventsPage } from "../../_lib/eventsStore.ts"; - -const DEFAULT_PAGE_SIZE = 25; - -type Cursor = - | { kind: "read_models"; ts: string; id: string } - | { kind: "events"; seq: number }; - -function decodeCursor(input: string): Cursor | null { - try { - const bytes = base64UrlDecode(input); - const raw = new TextDecoder().decode(bytes); - const parsed = JSON.parse(raw) as { - ts?: unknown; - id?: unknown; - seq?: unknown; - }; - if (typeof parsed.seq === "number" && Number.isFinite(parsed.seq)) { - return { kind: "events", seq: parsed.seq }; - } - if (typeof parsed.ts === "string" && typeof parsed.id === "string") { - return { kind: "read_models", ts: parsed.ts, id: parsed.id }; - } - return null; - } catch { - return null; - } -} - -function encodeCursor(input: { ts: string; id: string } | { seq: number }) { - const raw = JSON.stringify(input); - const bytes = new TextEncoder().encode(raw); - return base64UrlEncode(bytes); -} - -export const onRequestGet: ApiHandler = async (context) => { - try { - const url = new URL(context.request.url); - const stage = url.searchParams.get("stage"); - const cursor = url.searchParams.get("cursor"); - const decoded = cursor ? decodeCursor(cursor) : null; - if (cursor && !decoded) return errorResponse(400, "Invalid cursor"); - - const wantsInlineReadModels = context.env.READ_MODELS_INLINE === "true"; - const hasDatabase = Boolean(context.env.DATABASE_URL); - - if (hasDatabase && !wantsInlineReadModels) { - if (decoded && decoded.kind !== "events") { - return errorResponse(400, "Invalid cursor"); - } - const beforeSeq = decoded?.seq ?? null; - const page = await listFeedEventsPage(context.env, { - stage, - beforeSeq, - limit: DEFAULT_PAGE_SIZE, - }); - const nextCursor = - page.nextSeq !== undefined - ? encodeCursor({ seq: page.nextSeq }) - : undefined; - return jsonResponse( - nextCursor ? { items: page.items, nextCursor } : { items: page.items }, - ); - } - - const store = await createReadModelsStore(context.env); - const payload = await store.get("feed:list"); - if (!payload) return jsonResponse({ items: [] }); - if (decoded && decoded.kind !== "read_models") { - return errorResponse(400, "Invalid cursor"); - } - - const typed = payload as { - items?: { id: string; stage: string; timestamp: string }[]; - }; - let items = [...(typed.items ?? [])]; - - if (stage) items = items.filter((item) => item.stage === stage); - - items.sort( - (a, b) => - new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime(), - ); - - if (decoded?.kind === "read_models") { - const idx = items.findIndex( - (item) => item.timestamp === decoded.ts && item.id === decoded.id, - ); - if (idx >= 0) items = items.slice(idx + 1); - } - - const page = items.slice(0, DEFAULT_PAGE_SIZE); - const next = - items.length > DEFAULT_PAGE_SIZE - ? encodeCursor({ - ts: page[page.length - 1]?.timestamp ?? "", - id: page[page.length - 1]?.id ?? "", - }) - : undefined; - - const response = next ? { items: page, nextCursor: next } : { items: page }; - return jsonResponse(response); - } catch (error) { - return errorResponse(500, (error as Error).message); - } -}; diff --git a/functions/api/formation/index.ts b/functions/api/formation/index.ts deleted file mode 100644 index f2897dc..0000000 --- a/functions/api/formation/index.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { createReadModelsStore } from "../../_lib/readModelsStore.ts"; -import { errorResponse, jsonResponse } from "../../_lib/http.ts"; - -export const onRequestGet: ApiHandler = async (context) => { - try { - const store = await createReadModelsStore(context.env); - const payload = await store.get("formation:directory"); - return jsonResponse(payload ?? { metrics: [], projects: [] }); - } catch (error) { - return errorResponse(500, (error as Error).message); - } -}; diff --git a/functions/api/gate/status.ts b/functions/api/gate/status.ts deleted file mode 100644 index b63e099..0000000 --- a/functions/api/gate/status.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { readSession } from "../../_lib/auth.ts"; -import { checkEligibility } from "../../_lib/gate.ts"; -import { jsonResponse } from "../../_lib/http.ts"; - -export const onRequestGet: ApiHandler = async (context) => { - const session = await readSession(context.request, context.env); - if (!session) { - return jsonResponse({ - eligible: false, - reason: "not_authenticated", - expiresAt: new Date().toISOString(), - }); - } - return jsonResponse( - await checkEligibility(context.env, session.address, context.request.url), - ); -}; diff --git a/functions/api/health.ts b/functions/api/health.ts deleted file mode 100644 index b4b8972..0000000 --- a/functions/api/health.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { jsonResponse } from "../_lib/http.ts"; - -export const onRequest: ApiHandler = async () => { - return jsonResponse({ - ok: true, - service: "vortex-simulation-api", - time: new Date().toISOString(), - }); -}; diff --git a/functions/api/humans/[id].ts b/functions/api/humans/[id].ts deleted file mode 100644 index 3d9cd26..0000000 --- a/functions/api/humans/[id].ts +++ /dev/null @@ -1,31 +0,0 @@ -import { createReadModelsStore } from "../../_lib/readModelsStore.ts"; -import { errorResponse, jsonResponse } from "../../_lib/http.ts"; -import { getAcmDelta } from "../../_lib/cmAwardsStore.ts"; - -export const onRequestGet: ApiHandler = async (context) => { - try { - const id = context.params?.id; - if (!id) return errorResponse(400, "Missing human id"); - const store = await createReadModelsStore(context.env); - const payload = await store.get(`humans:${id}`); - if (!payload) return errorResponse(404, `Missing read model: humans:${id}`); - - const delta = await getAcmDelta(context.env, id); - if (!delta) return jsonResponse(payload); - - const typed = payload as Record; - const heroStats = Array.isArray(typed.heroStats) - ? (typed.heroStats as Array>) - : []; - const nextHeroStats = heroStats.map((stat) => { - if (stat.label !== "ACM") return stat; - const raw = typeof stat.value === "string" ? stat.value : "0"; - const base = Number(raw.replace(/,/g, "")) || 0; - return { ...stat, value: String(base + delta) }; - }); - - return jsonResponse({ ...typed, heroStats: nextHeroStats }); - } catch (error) { - return errorResponse(500, (error as Error).message); - } -}; diff --git a/functions/api/humans/index.ts b/functions/api/humans/index.ts deleted file mode 100644 index 2b3d085..0000000 --- a/functions/api/humans/index.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { createReadModelsStore } from "../../_lib/readModelsStore.ts"; -import { errorResponse, jsonResponse } from "../../_lib/http.ts"; -import { getAcmDelta } from "../../_lib/cmAwardsStore.ts"; - -export const onRequestGet: ApiHandler = async (context) => { - try { - const store = await createReadModelsStore(context.env); - const payload = await store.get("humans:list"); - if (!payload) return jsonResponse({ items: [] }); - const typed = payload as { items?: Array> }; - const items = Array.isArray(typed.items) ? typed.items : []; - - const nextItems = await Promise.all( - items.map(async (item) => { - const id = typeof item.id === "string" ? item.id : null; - if (!id) return item; - const delta = await getAcmDelta(context.env, id); - const base = typeof item.acm === "number" ? item.acm : 0; - return { ...item, acm: base + delta }; - }), - ); - - return jsonResponse({ ...typed, items: nextItems }); - } catch (error) { - return errorResponse(500, (error as Error).message); - } -}; diff --git a/functions/api/invision/index.ts b/functions/api/invision/index.ts deleted file mode 100644 index 847c634..0000000 --- a/functions/api/invision/index.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { createReadModelsStore } from "../../_lib/readModelsStore.ts"; -import { errorResponse, jsonResponse } from "../../_lib/http.ts"; - -export const onRequestGet: ApiHandler = async (context) => { - try { - const store = await createReadModelsStore(context.env); - const payload = await store.get("invision:dashboard"); - return jsonResponse( - payload ?? { - governanceState: { label: "—", metrics: [] }, - economicIndicators: [], - riskSignals: [], - chamberProposals: [], - }, - ); - } catch (error) { - return errorResponse(500, (error as Error).message); - } -}; diff --git a/functions/api/me.ts b/functions/api/me.ts deleted file mode 100644 index 3f1a144..0000000 --- a/functions/api/me.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { readSession } from "../_lib/auth.ts"; -import { checkEligibility } from "../_lib/gate.ts"; -import { jsonResponse } from "../_lib/http.ts"; - -export const onRequestGet: ApiHandler = async (context) => { - const session = await readSession(context.request, context.env); - if (!session) return jsonResponse({ authenticated: false }); - const gate = await checkEligibility( - context.env, - session.address, - context.request.url, - ); - return jsonResponse({ - authenticated: true, - address: session.address, - gate, - }); -}; diff --git a/functions/api/my-governance/index.ts b/functions/api/my-governance/index.ts deleted file mode 100644 index 76b32cf..0000000 --- a/functions/api/my-governance/index.ts +++ /dev/null @@ -1,181 +0,0 @@ -import { createReadModelsStore } from "../../_lib/readModelsStore.ts"; -import { readSession } from "../../_lib/auth.ts"; -import { getUserEraActivity } from "../../_lib/eraStore.ts"; -import { - getEraRollupMeta, - getEraUserStatus, -} from "../../_lib/eraRollupStore.ts"; -import { errorResponse, jsonResponse } from "../../_lib/http.ts"; - -function envInt( - env: Record, - key: string, - fallback: number, -): number { - const raw = env[key]; - if (!raw) return fallback; - const n = Number(raw); - if (!Number.isFinite(n)) return fallback; - if (n < 0) return fallback; - return Math.floor(n); -} - -export const onRequestGet: ApiHandler = async (context) => { - try { - const store = await createReadModelsStore(context.env); - const payload = await store.get("my-governance:summary"); - const base = - payload && typeof payload === "object" && !Array.isArray(payload) - ? (payload as Record) - : { - eraActivity: { - era: "Era 0", - required: 0, - completed: 0, - actions: [], - timeLeft: "—", - }, - myChamberIds: [], - }; - - const requiredByLabel: Record = { - "Pool votes": envInt(context.env, "SIM_REQUIRED_POOL_VOTES", 1), - "Chamber votes": envInt(context.env, "SIM_REQUIRED_CHAMBER_VOTES", 1), - "Court actions": envInt(context.env, "SIM_REQUIRED_COURT_ACTIONS", 0), - "Formation actions": envInt( - context.env, - "SIM_REQUIRED_FORMATION_ACTIONS", - 0, - ), - }; - - const session = await readSession(context.request, context.env); - if (!session) { - // Normalize the base model to the configured requirements (even for anon users). - const baseEraActivity = - base && typeof base === "object" && !Array.isArray(base) - ? (base as Record).eraActivity - : null; - const actions = - baseEraActivity && - typeof baseEraActivity === "object" && - baseEraActivity !== null && - !Array.isArray(baseEraActivity) && - Array.isArray((baseEraActivity as Record).actions) - ? ((baseEraActivity as Record).actions as Array< - Record - >) - : []; - - const normalizedActions = actions - .map((action) => { - const label = String(action.label ?? ""); - if (!(label in requiredByLabel)) return null; - return { - ...action, - label, - required: requiredByLabel[label], - }; - }) - .filter(Boolean) as Array>; - - const requiredTotal = normalizedActions.reduce((sum, action) => { - return ( - sum + (typeof action.required === "number" ? action.required : 0) - ); - }, 0); - - return jsonResponse({ - ...base, - eraActivity: { - ...(baseEraActivity as Record), - required: requiredTotal, - actions: normalizedActions, - }, - }); - } - - const era = await getUserEraActivity(context.env, { - address: session.address, - }).catch(() => null); - if (!era) return jsonResponse(base); - - const baseEraActivity = - base && typeof base === "object" && !Array.isArray(base) - ? (base as Record).eraActivity - : null; - const actions = - baseEraActivity && - typeof baseEraActivity === "object" && - baseEraActivity !== null && - !Array.isArray(baseEraActivity) && - Array.isArray((baseEraActivity as Record).actions) - ? ((baseEraActivity as Record).actions as Array< - Record - >) - : []; - - const nextActions = actions - .map((action) => { - const label = String(action.label ?? ""); - if (!(label in requiredByLabel)) return null; - const done = - label === "Pool votes" - ? era.counts.poolVotes - : label === "Chamber votes" - ? era.counts.chamberVotes - : label === "Court actions" - ? era.counts.courtActions - : label === "Formation actions" - ? era.counts.formationActions - : 0; - return { ...action, label, required: requiredByLabel[label], done }; - }) - .filter(Boolean) as Array>; - - const requiredTotal = nextActions.reduce((sum, action) => { - return sum + (typeof action.required === "number" ? action.required : 0); - }, 0); - const completedTotal = nextActions.reduce( - (sum, action) => - sum + (typeof action.done === "number" ? action.done : 0), - 0, - ); - - const rollupMeta = await getEraRollupMeta(context.env, { - era: era.era, - }).catch(() => null); - const rollupUser = rollupMeta - ? await getEraUserStatus(context.env, { - era: rollupMeta.era, - address: session.address, - }).catch(() => null) - : null; - - return jsonResponse({ - ...base, - eraActivity: { - ...(baseEraActivity as Record), - era: String(era.era), - required: requiredTotal, - completed: completedTotal, - actions: nextActions, - }, - ...(rollupMeta - ? { - rollup: { - era: rollupMeta.era, - rolledAt: rollupMeta.rolledAt, - status: rollupUser?.status ?? "Losing status", - requiredTotal: rollupMeta.requiredTotal, - completedTotal: rollupUser?.completedTotal ?? 0, - isActiveNextEra: rollupUser?.isActiveNextEra ?? false, - activeGovernorsNextEra: rollupMeta.activeGovernorsNextEra, - }, - } - : {}), - }); - } catch (error) { - return errorResponse(500, (error as Error).message); - } -}; diff --git a/functions/api/proposals/[id]/chamber.ts b/functions/api/proposals/[id]/chamber.ts deleted file mode 100644 index cf9be38..0000000 --- a/functions/api/proposals/[id]/chamber.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { createReadModelsStore } from "../../../_lib/readModelsStore.ts"; -import { errorResponse, jsonResponse } from "../../../_lib/http.ts"; -import { getChamberVoteCounts } from "../../../_lib/chamberVotesStore.ts"; -import { getActiveGovernorsForCurrentEra } from "../../../_lib/eraStore.ts"; -import { getProposal } from "../../../_lib/proposalsStore.ts"; -import { projectChamberProposalPage } from "../../../_lib/proposalProjector.ts"; -import { getProposalStageDenominator } from "../../../_lib/proposalStageDenominatorsStore.ts"; -import { V1_ACTIVE_GOVERNORS_FALLBACK } from "../../../_lib/v1Constants.ts"; -import { - getSimNow, - getStageWindowSeconds, - stageWindowsEnabled, -} from "../../../_lib/stageWindows.ts"; - -function normalizeChamberId(chamberLabel: string): string { - const match = chamberLabel.trim().match(/^([A-Za-z]+)/); - return (match?.[1] ?? chamberLabel).toLowerCase(); -} - -export const onRequestGet: ApiHandler = async (context) => { - try { - const id = context.params?.id; - if (!id) return errorResponse(400, "Missing proposal id"); - - const baseline = - (await getActiveGovernorsForCurrentEra(context.env).catch(() => null)) ?? - V1_ACTIVE_GOVERNORS_FALLBACK; - const activeGovernors = - ( - await getProposalStageDenominator(context.env, { - proposalId: id, - stage: "vote", - }).catch(() => null) - )?.activeGovernors ?? baseline; - - const proposal = await getProposal(context.env, id); - if (proposal) { - const counts = await getChamberVoteCounts(context.env, id, { - chamberId: (proposal.chamberId ?? "general").toLowerCase(), - }); - const now = getSimNow(context.env); - return jsonResponse( - projectChamberProposalPage(proposal, { - counts, - activeGovernors, - now, - voteWindowSeconds: stageWindowsEnabled(context.env) - ? getStageWindowSeconds(context.env, "vote") - : undefined, - }), - ); - } - - const store = await createReadModelsStore(context.env); - const payload = await store.get(`proposals:${id}:chamber`); - if (!payload) - return errorResponse(404, `Missing read model: proposals:${id}:chamber`); - - const typed = payload as Record; - const chamberId = - normalizeChamberId(String(typed.chamber ?? "general")) || "general"; - const counts = await getChamberVoteCounts(context.env, id, { chamberId }); - const engagedGovernors = counts.yes + counts.no + counts.abstain; - return jsonResponse({ - ...typed, - votes: counts, - engagedGovernors, - activeGovernors, - }); - } catch (error) { - return errorResponse(500, (error as Error).message); - } -}; diff --git a/functions/api/proposals/[id]/formation.ts b/functions/api/proposals/[id]/formation.ts deleted file mode 100644 index 93ab478..0000000 --- a/functions/api/proposals/[id]/formation.ts +++ /dev/null @@ -1,149 +0,0 @@ -import { createReadModelsStore } from "../../../_lib/readModelsStore.ts"; -import { errorResponse, jsonResponse } from "../../../_lib/http.ts"; -import { - getFormationSummary, - listFormationJoiners, - ensureFormationSeedFromInput, - buildV1FormationSeedFromProposalPayload, -} from "../../../_lib/formationStore.ts"; -import { getProposal } from "../../../_lib/proposalsStore.ts"; -import type { ReadModelsStore } from "../../../_lib/readModelsStore.ts"; -import { projectFormationProposalPage } from "../../../_lib/proposalProjector.ts"; - -export const onRequestGet: ApiHandler = async (context) => { - try { - const id = context.params?.id; - if (!id) return errorResponse(400, "Missing proposal id"); - - const proposal = await getProposal(context.env, id); - if (proposal) { - const store: ReadModelsStore = (await createReadModelsStore( - context.env, - ).catch(() => null)) ?? { - get: async () => null, - }; - - const formationEligible = (() => { - const payload = proposal.payload; - if (!payload || typeof payload !== "object" || Array.isArray(payload)) - return true; - const record = payload as Record; - if (record.templateId === "system") return false; - if ( - typeof record.metaGovernance === "object" && - record.metaGovernance !== null && - !Array.isArray(record.metaGovernance) - ) - return false; - if (typeof record.formationEligible === "boolean") - return record.formationEligible; - if (typeof record.formation === "boolean") return record.formation; - return true; - })(); - - if (!formationEligible) { - return jsonResponse( - projectFormationProposalPage(proposal, { - summary: { - teamFilled: 0, - teamTotal: 0, - milestonesCompleted: 0, - milestonesTotal: 0, - }, - joiners: [], - }), - ); - } - - if (formationEligible) { - const seed = buildV1FormationSeedFromProposalPayload(proposal.payload); - await ensureFormationSeedFromInput(context.env, { - proposalId: id, - seed, - }); - } - - const summary = await getFormationSummary(context.env, store, id); - const joiners = await listFormationJoiners(context.env, id); - return jsonResponse( - projectFormationProposalPage(proposal, { summary, joiners }), - ); - } - - const store = await createReadModelsStore(context.env); - const readModelKey = `proposals:${id}:formation`; - const payload = await store.get(readModelKey); - if (!payload) - return errorResponse(404, `Missing read model: ${readModelKey}`); - - const summary = await getFormationSummary(context.env, store, id); - const joiners = await listFormationJoiners(context.env, id); - - const next = patchFormationReadModel(payload, { - teamFilled: summary.teamFilled, - teamTotal: summary.teamTotal, - milestonesCompleted: summary.milestonesCompleted, - milestonesTotal: summary.milestonesTotal, - joiners, - }); - - return jsonResponse(next); - } catch (error) { - return errorResponse(500, (error as Error).message); - } -}; - -function patchFormationReadModel( - payload: unknown, - input: { - teamFilled: number; - teamTotal: number; - milestonesCompleted: number; - milestonesTotal: number; - joiners: { address: string; role?: string | null }[]; - }, -): unknown { - if (!payload || typeof payload !== "object" || Array.isArray(payload)) { - return payload; - } - - const record = payload as Record; - const teamSlots = `${input.teamFilled} / ${input.teamTotal}`; - const milestones = `${input.milestonesCompleted} / ${input.milestonesTotal}`; - const progress = - input.milestonesTotal > 0 - ? `${Math.round((input.milestonesCompleted / input.milestonesTotal) * 100)}%` - : "0%"; - - const baseTeam = Array.isArray(record.lockedTeam) ? record.lockedTeam : []; - const joinerItems = input.joiners.map((entry) => ({ - name: shortenAddress(entry.address), - role: entry.role ?? "Contributor", - })); - - const stageData = Array.isArray(record.stageData) ? record.stageData : []; - const nextStageData = stageData.map((entry) => { - if (!entry || typeof entry !== "object" || Array.isArray(entry)) - return entry; - const row = entry as Record; - const title = String(row.title ?? "").toLowerCase(); - if (title.includes("team slots")) return { ...row, value: teamSlots }; - if (title.includes("milestones")) return { ...row, value: milestones }; - return entry; - }); - - return { - ...record, - teamSlots, - milestones, - progress, - stageData: nextStageData, - lockedTeam: [...baseTeam, ...joinerItems], - }; -} - -function shortenAddress(address: string): string { - const normalized = address.trim(); - if (normalized.length <= 12) return normalized; - return `${normalized.slice(0, 6)}…${normalized.slice(-4)}`; -} diff --git a/functions/api/proposals/[id]/pool.ts b/functions/api/proposals/[id]/pool.ts deleted file mode 100644 index ab000c9..0000000 --- a/functions/api/proposals/[id]/pool.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { createReadModelsStore } from "../../../_lib/readModelsStore.ts"; -import { errorResponse, jsonResponse } from "../../../_lib/http.ts"; -import { getPoolVoteCounts } from "../../../_lib/poolVotesStore.ts"; -import { getActiveGovernorsForCurrentEra } from "../../../_lib/eraStore.ts"; -import { getProposal } from "../../../_lib/proposalsStore.ts"; -import { projectPoolProposalPage } from "../../../_lib/proposalProjector.ts"; -import { getProposalStageDenominator } from "../../../_lib/proposalStageDenominatorsStore.ts"; -import { V1_ACTIVE_GOVERNORS_FALLBACK } from "../../../_lib/v1Constants.ts"; -import { getSimConfig } from "../../../_lib/simConfig.ts"; -import { resolveUserTierFromSimConfig } from "../../../_lib/userTier.ts"; - -export const onRequestGet: ApiHandler = async (context) => { - try { - const id = context.params?.id; - if (!id) return errorResponse(400, "Missing proposal id"); - - const counts = await getPoolVoteCounts(context.env, id); - const baseline = - (await getActiveGovernorsForCurrentEra(context.env).catch(() => null)) ?? - V1_ACTIVE_GOVERNORS_FALLBACK; - const activeGovernors = - ( - await getProposalStageDenominator(context.env, { - proposalId: id, - stage: "pool", - }).catch(() => null) - )?.activeGovernors ?? baseline; - - const proposal = await getProposal(context.env, id); - if (proposal) { - const simConfig = await getSimConfig( - context.env, - context.request.url, - ).catch(() => null); - return jsonResponse( - projectPoolProposalPage(proposal, { - counts, - activeGovernors, - tier: await resolveUserTierFromSimConfig( - simConfig, - proposal.authorAddress, - ), - }), - ); - } - - const store = await createReadModelsStore(context.env); - const payload = await store.get(`proposals:${id}:pool`); - if (!payload) - return errorResponse(404, `Missing read model: proposals:${id}:pool`); - const patched = { - ...(payload as Record), - upvotes: counts.upvotes, - downvotes: counts.downvotes, - activeGovernors, - }; - return jsonResponse(patched); - } catch (error) { - return errorResponse(500, (error as Error).message); - } -}; diff --git a/functions/api/proposals/[id]/timeline.ts b/functions/api/proposals/[id]/timeline.ts deleted file mode 100644 index 847d48b..0000000 --- a/functions/api/proposals/[id]/timeline.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { errorResponse, jsonResponse } from "../../../_lib/http.ts"; -import { listProposalTimelineItems } from "../../../_lib/proposalTimelineStore.ts"; - -export const onRequestGet: ApiHandler = async (context) => { - try { - const id = context.params?.id; - if (!id) return errorResponse(400, "Missing proposal id"); - - const url = new URL(context.request.url); - const limitParam = url.searchParams.get("limit"); - const limitRaw = limitParam ? Number.parseInt(limitParam, 10) : 100; - const limit = Number.isFinite(limitRaw) - ? Math.max(1, Math.min(500, limitRaw)) - : 100; - - const items = await listProposalTimelineItems(context.env, { - proposalId: id, - limit, - }); - return jsonResponse({ items }); - } catch (error) { - return errorResponse(500, (error as Error).message); - } -}; diff --git a/functions/api/proposals/drafts/[id].ts b/functions/api/proposals/drafts/[id].ts deleted file mode 100644 index 294896c..0000000 --- a/functions/api/proposals/drafts/[id].ts +++ /dev/null @@ -1,170 +0,0 @@ -import { createReadModelsStore } from "../../../_lib/readModelsStore.ts"; -import { readSession } from "../../../_lib/auth.ts"; -import { errorResponse, jsonResponse } from "../../../_lib/http.ts"; -import { - getDraft, - formatChamberLabel, -} from "../../../_lib/proposalDraftsStore.ts"; -import { getUserTier } from "../../../_lib/userTier.ts"; - -export const onRequestGet: ApiHandler = async (context) => { - try { - const id = context.params?.id; - if (!id) return errorResponse(400, "Missing draft id"); - const session = await readSession(context.request, context.env); - - if (!context.env.DATABASE_URL) { - if (session) { - const tier = await getUserTier( - context.env, - context.request.url, - session.address, - ); - const draft = await getDraft(context.env, { - authorAddress: session.address, - draftId: id, - }); - if (draft) { - const budgetTotal = draft.payload.budgetItems.reduce((sum, item) => { - const n = Number(item.amount); - if (!Number.isFinite(n) || n <= 0) return sum; - return sum + n; - }, 0); - - return jsonResponse({ - title: draft.title, - proposer: session.address, - chamber: formatChamberLabel(draft.chamberId), - focus: draft.payload.chamberId - ? "Chamber-scoped proposal" - : "General proposal", - tier, - budget: - budgetTotal > 0 ? `${budgetTotal.toLocaleString()} HMND` : "—", - formationEligible: !draft.payload.metaGovernance, - teamSlots: "1 / 3", - milestonesPlanned: `${draft.payload.timeline.length} milestones`, - summary: draft.payload.summary, - rationale: draft.payload.why, - budgetScope: draft.payload.budgetItems - .filter((b) => b.description.trim().length > 0) - .map((b) => `${b.description}: ${b.amount} HMND`) - .join("\n"), - invisionInsight: { - role: "Draft author", - bullets: [ - "This is a saved draft in the off-chain simulation backend.", - "Submission to the pool is gated by active Humanode status.", - ], - }, - checklist: draft.payload.how - .split("\n") - .map((line) => line.trim()) - .filter(Boolean) - .slice(0, 12), - milestones: draft.payload.timeline - .map((m) => m.title) - .filter(Boolean), - teamLocked: [ - { - name: session.address, - role: "Proposer", - }, - ], - openSlotNeeds: [ - { - title: "Contributor (open slot)", - desc: "Join the taskforce if the proposal reaches Formation.", - }, - ], - milestonesDetail: draft.payload.timeline.map((m, idx) => ({ - title: m.title.trim().length ? m.title : `Milestone ${idx + 1}`, - desc: m.timeframe.trim().length ? m.timeframe : "Timeline TBD", - })), - attachments: draft.payload.attachments - .filter((a) => a.label.trim().length > 0) - .map((a) => ({ title: a.label, href: a.url || "#" })), - }); - } - } - - const store = await createReadModelsStore(context.env); - const payload = await store.get(`proposals:drafts:${id}`); - if (!payload) - return errorResponse(404, `Missing read model: proposals:drafts:${id}`); - return jsonResponse(payload); - } - - if (!session) return errorResponse(404, "Draft not found"); - const tier = await getUserTier( - context.env, - context.request.url, - session.address, - ); - const draft = await getDraft(context.env, { - authorAddress: session.address, - draftId: id, - }); - if (!draft) return errorResponse(404, "Draft not found"); - - const budgetTotal = draft.payload.budgetItems.reduce((sum, item) => { - const n = Number(item.amount); - if (!Number.isFinite(n) || n <= 0) return sum; - return sum + n; - }, 0); - - return jsonResponse({ - title: draft.title, - proposer: session.address, - chamber: formatChamberLabel(draft.chamberId), - focus: draft.payload.chamberId - ? "Chamber-scoped proposal" - : "General proposal", - tier, - budget: budgetTotal > 0 ? `${budgetTotal.toLocaleString()} HMND` : "—", - formationEligible: !draft.payload.metaGovernance, - teamSlots: "1 / 3", - milestonesPlanned: `${draft.payload.timeline.length} milestones`, - summary: draft.payload.summary, - rationale: draft.payload.why, - budgetScope: draft.payload.budgetItems - .filter((b) => b.description.trim().length > 0) - .map((b) => `${b.description}: ${b.amount} HMND`) - .join("\n"), - invisionInsight: { - role: "Draft author", - bullets: [ - "This is a saved draft in the off-chain simulation backend.", - "Submission to the pool is gated by active Humanode status.", - ], - }, - checklist: draft.payload.how - .split("\n") - .map((line) => line.trim()) - .filter(Boolean) - .slice(0, 12), - milestones: draft.payload.timeline.map((m) => m.title).filter(Boolean), - teamLocked: [ - { - name: session.address, - role: "Proposer", - }, - ], - openSlotNeeds: [ - { - title: "Contributor (open slot)", - desc: "Join the taskforce if the proposal reaches Formation.", - }, - ], - milestonesDetail: draft.payload.timeline.map((m, idx) => ({ - title: m.title.trim().length ? m.title : `Milestone ${idx + 1}`, - desc: m.timeframe.trim().length ? m.timeframe : "Timeline TBD", - })), - attachments: draft.payload.attachments - .filter((a) => a.label.trim().length > 0) - .map((a) => ({ title: a.label, href: a.url || "#" })), - }); - } catch (error) { - return errorResponse(500, (error as Error).message); - } -}; diff --git a/functions/api/proposals/drafts/index.ts b/functions/api/proposals/drafts/index.ts deleted file mode 100644 index a101f56..0000000 --- a/functions/api/proposals/drafts/index.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { createReadModelsStore } from "../../../_lib/readModelsStore.ts"; -import { readSession } from "../../../_lib/auth.ts"; -import { errorResponse, jsonResponse } from "../../../_lib/http.ts"; -import { - listDrafts, - formatChamberLabel, -} from "../../../_lib/proposalDraftsStore.ts"; -import { getUserTier } from "../../../_lib/userTier.ts"; - -export const onRequestGet: ApiHandler = async (context) => { - try { - const session = await readSession(context.request, context.env); - - if (!context.env.DATABASE_URL) { - if (session) { - const tier = await getUserTier( - context.env, - context.request.url, - session.address, - ); - const drafts = await listDrafts(context.env, { - authorAddress: session.address, - }); - return jsonResponse({ - items: drafts.map((d) => ({ - id: d.id, - title: d.title, - chamber: formatChamberLabel(d.chamberId), - tier, - summary: d.summary, - updated: d.updatedAt.toISOString().slice(0, 10), - })), - }); - } - - const store = await createReadModelsStore(context.env); - const payload = await store.get("proposals:drafts:list"); - return jsonResponse(payload ?? { items: [] }); - } - - if (!session) return jsonResponse({ items: [] }); - const tier = await getUserTier( - context.env, - context.request.url, - session.address, - ); - const drafts = await listDrafts(context.env, { - authorAddress: session.address, - }); - return jsonResponse({ - items: drafts.map((d) => ({ - id: d.id, - title: d.title, - chamber: formatChamberLabel(d.chamberId), - tier, - summary: d.summary, - updated: d.updatedAt.toISOString().slice(0, 10), - })), - }); - } catch (error) { - return errorResponse(500, (error as Error).message); - } -}; diff --git a/functions/api/proposals/index.ts b/functions/api/proposals/index.ts deleted file mode 100644 index d44bb1a..0000000 --- a/functions/api/proposals/index.ts +++ /dev/null @@ -1,139 +0,0 @@ -import { createReadModelsStore } from "../../_lib/readModelsStore.ts"; -import { errorResponse, jsonResponse } from "../../_lib/http.ts"; -import { listProposals } from "../../_lib/proposalsStore.ts"; -import { getActiveGovernorsForCurrentEra } from "../../_lib/eraStore.ts"; -import { getPoolVoteCounts } from "../../_lib/poolVotesStore.ts"; -import { getChamberVoteCounts } from "../../_lib/chamberVotesStore.ts"; -import { getFormationSummary } from "../../_lib/formationStore.ts"; -import { getProposalStageDenominatorMap } from "../../_lib/proposalStageDenominatorsStore.ts"; -import { - parseProposalStageQuery, - projectProposalListItem, -} from "../../_lib/proposalProjector.ts"; -import { V1_ACTIVE_GOVERNORS_FALLBACK } from "../../_lib/v1Constants.ts"; -import { getSimConfig } from "../../_lib/simConfig.ts"; -import { resolveUserTierFromSimConfig } from "../../_lib/userTier.ts"; -import { - getSimNow, - getStageWindowSeconds, - stageWindowsEnabled, -} from "../../_lib/stageWindows.ts"; - -export const onRequestGet: ApiHandler = async (context) => { - try { - const store = await createReadModelsStore(context.env); - const now = getSimNow(context.env); - const voteWindowSeconds = stageWindowsEnabled(context.env) - ? getStageWindowSeconds(context.env, "vote") - : undefined; - const url = new URL(context.request.url); - const stage = url.searchParams.get("stage"); - - const listPayload = await store.get("proposals:list"); - const readModelItems = - listPayload && - typeof listPayload === "object" && - !Array.isArray(listPayload) && - Array.isArray((listPayload as { items?: unknown[] }).items) - ? ((listPayload as { items: unknown[] }).items.filter( - (entry) => - entry && typeof entry === "object" && !Array.isArray(entry), - ) as Array>) - : []; - - const activeGovernors = - (await getActiveGovernorsForCurrentEra(context.env).catch(() => null)) ?? - V1_ACTIVE_GOVERNORS_FALLBACK; - - const stageQuery = - stage === "draft" ? null : parseProposalStageQuery(stage ?? null); - const proposals = - stage === "draft" - ? [] - : await listProposals(context.env, { stage: stageQuery }); - const simConfig = await getSimConfig( - context.env, - context.request.url, - ).catch(() => null); - - const poolDenominators = await getProposalStageDenominatorMap(context.env, { - stage: "pool", - proposalIds: proposals.filter((p) => p.stage === "pool").map((p) => p.id), - }); - const voteDenominators = await getProposalStageDenominatorMap(context.env, { - stage: "vote", - proposalIds: proposals.filter((p) => p.stage === "vote").map((p) => p.id), - }); - - const projected = await Promise.all( - proposals.map(async (proposal) => { - const formationEligible = (() => { - const payload = proposal.payload; - if (!payload || typeof payload !== "object" || Array.isArray(payload)) - return true; - const record = payload as Record; - if (record.templateId === "system") return false; - if ( - typeof record.metaGovernance === "object" && - record.metaGovernance !== null && - !Array.isArray(record.metaGovernance) - ) - return false; - if (typeof record.formationEligible === "boolean") - return record.formationEligible; - if (typeof record.formation === "boolean") return record.formation; - return true; - })(); - - const poolCounts = - proposal.stage === "pool" - ? await getPoolVoteCounts(context.env, proposal.id) - : undefined; - const chamberCounts = - proposal.stage === "vote" - ? await getChamberVoteCounts(context.env, proposal.id, { - chamberId: (proposal.chamberId ?? "general").toLowerCase(), - }) - : undefined; - const formationSummary = - proposal.stage === "build" && formationEligible - ? await getFormationSummary(context.env, store, proposal.id).catch( - () => null, - ) - : null; - const stageDenominator = - proposal.stage === "pool" - ? poolDenominators.get(proposal.id)?.activeGovernors - : proposal.stage === "vote" - ? voteDenominators.get(proposal.id)?.activeGovernors - : undefined; - return projectProposalListItem(proposal, { - activeGovernors: stageDenominator ?? activeGovernors, - tier: await resolveUserTierFromSimConfig( - simConfig, - proposal.authorAddress, - ), - now, - voteWindowSeconds, - poolCounts, - chamberCounts, - formationSummary: formationSummary ?? undefined, - }); - }), - ); - - const projectedIds = new Set(projected.map((item) => item.id)); - const merged = [ - ...readModelItems.filter((item) => !projectedIds.has(String(item.id))), - ...projected, - ]; - - const filtered = stage - ? merged.filter((item) => String(item.stage) === stage) - : merged; - - return jsonResponse({ items: filtered }); - } catch (error) { - return errorResponse(500, (error as Error).message); - } -}; diff --git a/package.json b/package.json index bd65944..95ef082 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,8 @@ "dev:full": "node scripts/dev-full.mjs", "build": "rsbuild build", "preview": "rsbuild preview", - "test": "node --test --experimental-transform-types tests/**/*.test.js", + "typecheck": "tsc -p tsconfig.json --noEmit", + "test": "yarn typecheck && yarn build", "db:generate": "drizzle-kit generate", "db:migrate": "drizzle-kit migrate", "db:clear": "node --experimental-transform-types scripts/db-clear.ts", diff --git a/prolog/vortexopedia.pl b/prolog/vortexopedia.pl deleted file mode 100644 index f0da573..0000000 --- a/prolog/vortexopedia.pl +++ /dev/null @@ -1,1137 +0,0 @@ -% Vortexopedia knowledge base (SWI-Prolog) -% Schema: vortex_term(RefNum, Id, Name, Category, ShortDesc, LongDescList, Tags, RelatedIds, Examples, Stages, Links, Source, Updated). -% Stages is a list of atoms like pool | chamber | formation | courts | feed. -% Links is a list of dicts: link{label:Label, url:Url}. - -:- module(vortexopedia, [vortex_term/12, stage_label/2]). - -% --- Stage labels (keep in sync with UI) --- -stage_label(pool, "Proposal pool"). -stage_label(chamber, "Chamber vote"). -stage_label(formation, "Formation"). -stage_label(courts, "Courts"). -stage_label(feed, "Feed"). -stage_label(factions, "Factions"). -stage_label(cm, "CM panel"). - -% --- Terms --- - -vortex_term( - 1, - "vortex", - "Vortex", - governance, - "Main governing body of the Humanode network with cognitocratic egalitarian voting among human validators.", - [ - "Vortex is the on-chain governing body that gradually absorbs the authority of Humanode Core and disperses it among human nodes.", - "It is designed so that each governor has equal formal voting power, relying on cryptobiometrics to ensure that every governor is a unique living human.", - "Vortex is implemented as a stack of proposal pools, voting chambers and the Formation executive layer." - ], - [governance, dao, humanode, vortex], - ["vortex-structure", "cognitocracy", "proposal-pool-system-vortex-formation-stack", "specialization-chamber", "general-chamber", "formation", "governor", "human-node"], - ["Changing fee distribution on Humanode via a Vortex proposal voted in the relevant chamber."], - [chamber, pool, formation], - [link{label:"Docs", url:"https://gitbook.humanode.io/vortex-1.0"}, link{label:"App", url:"/vortexopedia/vortex"}], - "gitbook:vortex-1.0:synopsis", - "2025-12-04" -). - -vortex_term( - 2, - "human_node", - "Human node", - governance, - "A uniquely biometric-verified person who runs a validator node, participates in consensus, and earns fees, but may or may not participate in governance.", - [ - "Defined as a person who has undergone cryptobiometric processing and runs a node in the Humanode network.", - "Receives network transaction fees as a validator.", - "Does not necessarily participate in governance (non-governing by default).", - "Can become a Governor by meeting governance participation requirements." - ], - [role, humanode, validator, sybil_resistance], - ["governor", "delegator", "proof_of_time_pot", "proof_of_human_existence", "tier1_nominee"], - ["In the app you can treat “human node” as the base identity type; every governor profile is a specialized human node."], - [global], - [link{label:"Docs", url:"https://gitbook.humanode.io/vortex-1.0/basis-of-vortex"}], - "Basis of Vortex – Vortex Roles", - "2025-12-04" -). - -vortex_term( - 3, - "cognitocracy", - "Cognitocracy", - governance, - "Legislative model where only those who can bring constructive, deployable innovation get voting rights (cognitocrats/governors).", - [ - "Grants voting rights only to those who have proven professional, creative merit in a specialization.", - "Cognitocrat and governor are interchangeable; one cannot be a governor without being a cognitocrat." - ], - [principle, governance, voting_rights, specialization], - ["meritocracy", "governor", "human_node", "vortex"], - ["Only cognitocrats can vote on matters of their specialization."], - [global, chamber], - [link{label:"Docs", url:"https://gitbook.humanode.io/vortex-1.0"}], - "Basis of Vortex – Principles", - "2025-12-04" -). - -vortex_term( - 4, - "meritocracy", - "Meritocracy", - governance, - "Concentrates power in those with proof of proficiency; Vortex evaluates innovation merit separately from functional work.", - [ - "Aims to concentrate decision-making in hands of those with proven proficiency.", - "Vortex uses PoT and PoD to emancipate governors from Nominee to Citizen." - ], - [principle, governance, merit], - ["cognitocracy", "proof_of_time_pot", "proof_of_devotion_pod"], - ["Governors progress tiers via PoT/PoD merit rather than popularity."], - [global], - [link{label:"Docs", url:"https://gitbook.humanode.io/vortex-1.0"}], - "Basis of Vortex – Principles", - "2025-12-04" -). - -vortex_term( - 5, - "local_determinism", - "Local determinism", - governance, - "Rejects ideology; values solutions that work efficiently regardless of political spectrum.", - [ - "Denies ideology as a means for power; focuses on field-specific, workable solutions.", - "As long as a solution works efficiently, its ideological alignment is irrelevant." - ], - [principle, governance, pragmatism], - ["cognitocracy", "meritocracy"], - ["Chambers choose the most efficient fix, not an ideologically pure one."], - [global], - [link{label:"Docs", url:"https://gitbook.humanode.io/vortex-1.0"}], - "Basis of Vortex – Principles", - "2025-12-04" -). - -vortex_term( - 6, - "constant_deterrence", - "Constant deterrence", - governance, - "Active, transparent guarding against centralization; emphasizes direct participation and active quorum.", - [ - "Governors must actively seek and mitigate centralization threats and avoid excessive delegation.", - "Requires transparent state visibility and active quorum: only active governors counted." - ], - [principle, governance, deterrence, decentralization], - ["active_quorum", "delegation", "cognitocracy"], - ["Governors monitor system state and vote directly to deter collusion."], - [global, chamber, pool], - [link{label:"Docs", url:"https://gitbook.humanode.io/vortex-1.0"}], - "Basis of Vortex – Principles", - "2025-12-04" -). - -vortex_term( - 7, - "power_detachment_resilience", - "Power detachment resilience", - governance, - "Minimizes gap between validation power and governance by ensuring one-human-one-node and maximizing validator participation.", - [ - "Addresses power concentration common in capital-based protocols.", - "Ensures each node is an individual with equal validation power; governors are validators; seeks high validator governance participation." - ], - [principle, governance, decentralization, equality], - ["human_node", "governor", "active_quorum"], - ["Validators are individual humans; governance aims to reflect that base."], - [global], - [link{label:"Docs", url:"https://gitbook.humanode.io/vortex-1.0"}], - "Basis of Vortex – Principles", - "2025-12-04" -). - -vortex_term( - 8, - "vortex_structure", - "Vortex structure", - governance, - "Three-part stack: proposal pools and voting chambers (legislative), Formation (executive).", - [ - "Vortex consists of proposal pools and voting chambers in the legislative branch, and Formation in the executive branch." - ], - [structure, governance, legislative, executive], - ["proposal_pools", "voting_chambers", "formation"], - ["Proposal pools filter; chambers vote; Formation executes."], - [pool, chamber, formation], - [link{label:"Docs", url:"https://gitbook.humanode.io/vortex-1.0"}], - "Basis of Vortex – Structure", - "2025-12-04" -). - -vortex_term( - 9, - "governor", - "Governor (cognitocrat)", - governance, - "Human node that meets governing requirements and participates in voting; reverts to non-governing if requirements lapse.", - [ - "A human node who participates in voting procedures according to governing requirements.", - "If requirements are not met, protocol converts them back to a non-governing human node automatically." - ], - [role, governor, voting], - ["human_node", "delegator", "cognitocracy"], - ["Governor status is lost if era action thresholds are not met."], - [chamber, pool], - [link{label:"Docs", url:"https://gitbook.humanode.io/vortex-1.0/basis-of-vortex"}], - "Basis of Vortex – Roles", - "2025-12-04" -). - -vortex_term( - 10, - "delegator", - "Delegator", - governance, - "Governor who delegates voting power to another governor.", - [ - "A governor who decides to delegate their voting power to another governor." - ], - [role, delegation, governor], - ["governor", "delegation"], - ["Governors may delegate votes in chamber stage but not in proposal pools."], - [chamber], - [link{label:"Docs", url:"https://gitbook.humanode.io/vortex-1.0/basis-of-vortex"}], - "Basis of Vortex – Roles", - "2025-12-04" -). - -vortex_term( - 11, - "proposal_pools", - "Proposal pools", - governance, - "Legislative attention filter where proposals gather support before chamber voting.", - [ - "Part of the legislative branch; proposals enter pools to gather attention before advancing to chambers." - ], - [pool, governance, attention], - ["vortex_structure", "voting_chambers", "formation"], - ["Proposals must clear attention thresholds in pools to reach chambers."], - [pool], - [link{label:"Docs", url:"https://gitbook.humanode.io/vortex-1.0"}], - "Basis of Vortex – Structure", - "2025-12-04" -). - -vortex_term( - 12, - "formation", - "Formation", - formation, - "Executive branch for executing approved proposals, managing milestones, budget, and teams.", - [ - "Formation belongs to the executive branch and handles execution of approved proposals.", - "Covers milestones, budget usage, and team assembly." - ], - [formation, executive, milestones, budget, team], - ["vortex_structure", "proposal_pools", "voting_chambers"], - ["An approved chamber proposal becomes a Formation project for delivery."], - [formation], - [link{label:"Docs", url:"https://gitbook.humanode.io/vortex-1.0"}], - "Basis of Vortex – Structure", - "2025-12-04" -). - -vortex_term( - 13, - "technocracy", - "Technocracy", - governance, - "Decision-makers selected by technological knowledge; cognitocracy inherits innovation focus but rejects plutocratic traits.", - [ - "Centers decision-making on technological expertise and innovation.", - "Criticized for elitism via capital control; cognitocracy keeps innovation focus but discards plutocratic concentration." - ], - [principle, governance, technology, innovation], - ["cognitocracy", "meritocracy"], - ["Cognitocracy borrows the innovation drive of technocracy without the plutocratic tilt."], - [global], - [link{label:"Docs", url:"https://gitbook.humanode.io/vortex-1.0"}], - "Basis of Vortex – Principles", - "2025-12-04" -). - -vortex_term( - 14, - "intellectual_barrier", - "Intellectual barrier for voting rights", - governance, - "Voting rights granted through demonstrated expertise and deployable proposals, not formal diplomas.", - [ - "Introduces on-the-spot proof of expertise via proposals instead of third-party credentials.", - "Aims to separate power from popularity and formal degrees." - ], - [principle, governance, qualification, expertise], - ["cognitocracy", "meritocracy"], - ["Governors earn voting rights by proving deployable innovation in their field."], - [global, chamber], - [link{label:"Docs", url:"https://gitbook.humanode.io/vortex-1.0"}], - "Basis of Vortex – Principles", - "2025-12-04" -). - -vortex_term( - 15, - "direct_democracy", - "Direct democracy", - governance, - "Cognitocrats vote directly on issues without intermediaries to keep decisions aligned with active participants.", - [ - "Relies on direct participation of cognitocrats for decisions.", - "Keeps power with active governors rather than intermediaries." - ], - [principle, governance, democracy, delegation], - ["representative_democracy", "liquid_democracy", "cognitocracy"], - ["Cognitocrats vote directly in chambers to reflect active will."], - [global, chamber], - [link{label:"Docs", url:"https://gitbook.humanode.io/vortex-1.0"}], - "Basis of Vortex – Principles", - "2025-12-04" -). - -vortex_term( - 16, - "representative_democracy", - "Representative democracy", - governance, - "Delegation to representatives for flexibility; in cognitocracy, only cognitocrats may delegate to other cognitocrats.", - [ - "Allows targeted delegation when direct participation is not feasible.", - "Seeks reduced polarization via issue-specific representation." - ], - [principle, governance, democracy, delegation], - ["direct_democracy", "liquid_democracy", "cognitocracy", "delegator"], - ["Cognitocrats may delegate votes per issue to stay responsive."], - [global, chamber], - [link{label:"Docs", url:"https://gitbook.humanode.io/vortex-1.0"}], - "Basis of Vortex – Principles", - "2025-12-04" -). - -vortex_term( - 17, - "liquid_democracy", - "Liquid democracy (cognitocracy)", - governance, - "Vote delegation among cognitocrats only, retractable at any time; no elections, voice stays dynamic.", - [ - "Cognitocrats delegate only to other cognitocrats; delegation is retractable.", - "Reduces polarization by enabling issue-specific support; adapts to changing preferences." - ], - [principle, governance, delegation, liquid_democracy], - ["direct_democracy", "representative_democracy", "cognitocracy", "delegator"], - ["Delegated votes can be reclaimed at any moment, keeping representation aligned."], - [chamber], - [link{label:"Docs", url:"https://gitbook.humanode.io/vortex-1.0"}], - "Basis of Vortex – Principles", - "2025-12-04" -). - -vortex_term( - 18, - "specialization_chamber", - "Specialization Chamber (SC)", - governance, - "Chamber for a specific field; only specialists with accepted proposals in that field vote on related matters.", - [ - "Admits governors who proved creative merit in the chamber’s field.", - "Shards legislation to maintain professionalism and efficiency.", - "Invariant: 1 governor-cognitocrat = 1 vote." - ], - [chamber, specialization, governance], - ["general_chamber", "vortex_structure"], - ["Programming chamber admits engineers whose proposals were accepted."], - [chamber], - [link{label:"Docs", url:"https://gitbook.humanode.io/vortex-1.0"}], - "Basis of Vortex – Chambers", - "2025-12-04" -). - -vortex_term( - 19, - "general_chamber", - "General Chamber (GC)", - governance, - "Chamber comprising all cognitocrats; its rulings supersede SCs and can force admittance of proposals.", - [ - "Includes all cognitocrat-governors regardless of specialization.", - "Acts on system-wide proposals; can enforce acceptance of proposals declined in SCs.", - "Harder to reach quorum than SCs." - ], - [chamber, general, governance], - ["specialization_chamber"], - ["GC can override an SC by accepting a repeatedly declined proposal."], - [chamber], - [link{label:"Docs", url:"https://gitbook.humanode.io/vortex-1.0"}], - "Basis of Vortex – Chambers", - "2025-12-04" -). - -vortex_term( - 20, - "chamber_inception", - "Chamber inception", - governance, - "Process to create a Specialization Chamber: proposed and voted by existing cognitocrats; initial members nominated; CM approach chosen.", - [ - "Only an established governor-cognitocrat can propose forming an SC.", - "Initial cognitocrats are nominated; Cognitocratic Measure approach is chosen at creation." - ], - [process, chamber, governance], - ["specialization_chamber", "cognitocratic_measure"], - ["Formation of a new SC requires a proposal, vote, nominations, and CM setup."], - [chamber], - [link{label:"Docs", url:"https://gitbook.humanode.io/vortex-1.0"}], - "Basis of Vortex – Chambers", - "2025-12-04" -). - -vortex_term( - 21, - "chamber_dissolution", - "Chamber dissolution", - governance, - "Ending an SC via SC or GC proposal; GC censure excludes targeted SC members from quorum.", - [ - "Can be proposed inside the SC or in the GC.", - "GC vote of censure excludes members of the targeted SC from quorum and voting.", - "Penalties are contextual to the dissolution cause." - ], - [process, chamber, governance], - ["specialization_chamber", "general_chamber"], - ["GC censure dissolves a corrupt SC; targeted members don’t count toward quorum."], - [chamber], - [link{label:"Docs", url:"https://gitbook.humanode.io/vortex-1.0"}], - "Basis of Vortex – Chambers", - "2025-12-04" -). - -vortex_term( - 22, - "quorum_of_attention", - "Quorum of attention", - governance, - "Proposal-pool quorum: 22% of active governors engaged AND at least 10% upvotes to advance to a chamber.", - [ - "Applied in every proposal pool.", - "Proposal advances when ≥22% of active governors engage and ≥10% of them upvote.", - "Delegated votes do NOT count in proposal pools." - ], - [quorum, pool, governance, attention], - ["proposal_pools", "quorum_of_vote", "delegation_policy"], - ["A pool item with 24% engagement and 14% upvotes moves to chamber voting."], - [pool], - [link{label:"Docs", url:"https://gitbook.humanode.io/vortex-1.0"}], - "Voting, Delegation and Quorum", - "2025-12-04" -). - -vortex_term( - 23, - "quorum_of_vote", - "Quorum of vote", - governance, - "Chamber quorum: 33.3% participation; passing rule 66.6% + 1 yes within the quorum (≈22% of all active governors).", - [ - "Chamber quorum is reached at 33.3% of active governors voting.", - "Passing rule: 66.6% + 1 yes within the quorum (≈22% of active governors).", - "Non-governing human nodes are not counted in quorum.", - "Pulled-from-pool proposals get one week to be voted in chamber." - ], - [quorum, chamber, governance, voting], - ["quorum_of_attention", "delegation_policy", "veto_rights"], - ["A chamber vote with 35% turnout passes if 67% yes within that turnout."], - [chamber], - [link{label:"Docs", url:"https://gitbook.humanode.io/vortex-1.0"}], - "Voting, Delegation and Quorum", - "2025-12-04" -). - -vortex_term( - 24, - "delegation_policy", - "Delegation and quorum policy", - governance, - "Counts only active governors for quorum, while allowing non-active cognitocrats to delegate to active ones in the same chamber.", - [ - "Only active governors count toward quorum.", - "Non-active cognitocrats may delegate to an active cognitocrat in the same chamber.", - "Balances elitism of active-only voting with broader delegated input." - ], - [delegation, quorum, governance], - ["quorum_of_vote", "quorum_of_attention", "liquid_democracy"], - ["Non-active members delegate to active ones; delegated votes count in chamber stage, not in pools."], - [chamber], - [link{label:"Docs", url:"https://gitbook.humanode.io/vortex-1.0"}], - "Voting, Delegation and Quorum", - "2025-12-04" -). - -vortex_term( - 25, - "veto_rights", - "Veto rights", - governance, - "Temporary, breakable veto held by Citizens; 66.6% + 1 veto sends a proposal back for two weeks (max twice).", - [ - "Veto power is distributed to all Citizens.", - "If 66.6% + 1 veto, the proposal returns for 2 weeks; can be vetoed twice; third approval cannot be vetoed.", - "Acts as deterrence against majority mistakes or attacks." - ], - [veto, governance, deterrence, lcm], - ["quorum_of_vote", "constant_deterrence"], - ["If vetoed twice, a third approval is final with no further veto allowed."], - [chamber], - [link{label:"Docs", url:"https://gitbook.humanode.io/vortex-1.0"}], - "Voting, Delegation and Quorum", - "2025-12-04" -). - -vortex_term( - 26, - "cognitocratic_measure", - "Cognitocratic Measure (CM)", - governance, - "A subjective contribution score awarded when a chamber accepts a proposal; voters rate it (e.g., 1–10) and the average becomes the CM.", - [ - "Cognitocratic Measure (CM) is an attempt to objectify contribution of each cognitocrat to the system as a whole.", - "A cognitocrat receives CM each time a proposition is accepted by a chamber.", - "Instead of only voting “Yes”, voters also input a number (for example, on a 1–10 scale). The average rating becomes the CM received by the proposer.", - "CM is still subjective and should not directly empower the mandate of any particular cognitocrat.", - "Instead, it signals to others the perceived magnitude of a cognitocrat’s contribution: the larger the CM, the larger the perceived contribution." - ], - [cm, score, contribution, governance], - ["cognitocratic_measure_multiplier", "lcm", "mcm", "acm"], - ["A proposal is accepted with an average rating of 8 → proposer receives CM=8."], - [chamber], - [link{label:"Docs", url:"https://gitbook.humanode.io/vortex-1.0"}], - "Voting, Delegation and Quorum", - "2025-12-04" -). - -vortex_term( - 27, - "cognitocratic_measure_multiplier", - "Cognitocratic Measure multiplier", - governance, - "A chamber-specific weight used to compare CM/LCM across specializations by defining proportional “value” between chambers.", - [ - "A cognitocratic system contains multiple specialization chambers, so CM/LCM from different chambers cannot be treated as equal by default.", - "A CM of 5 in one chamber can be meaningfully different from a CM of 5 in another depending on what the system values at that time.", - "The chamber multiplier defines these proportions between chambers so contributions can be normalized for aggregation.", - "In this demo, the multiplier is set collectively by cognitocrats who have not received LCM in that chamber (average of their inputs)." - ], - [cm, multiplier, chamber, weighting], - ["cognitocratic_measure", "lcm", "mcm", "acm"], - ["Philosophy multiplier 3 vs Finance multiplier 5: the same LCM produces different MCM after weighting."], - [cm], - [link{label:"Docs", url:"https://gitbook.humanode.io/vortex-1.0"}], - "Voting, Delegation and Quorum", - "2025-12-04" -). - -vortex_term( - 28, - "lcm", - "Local Cognitocratic Measure (LCM)", - governance, - "A per-chamber contribution signal: CM accrued within a specific chamber before applying that chamber’s multiplier.", - [ - "Local Cognitocratic Measure (LCM) subjectively demonstrates the amount of contribution from a cognitocrat in a specific chamber.", - "LCM is the input to calculate MCM (after applying the chamber multiplier) and ACM (sum across chambers)." - ], - [cm, lcm, chamber], - ["mcm", "acm", "cognitocratic_measure_multiplier"], - ["Bob has 5 LCM in Philosophy and 10 LCM in Finance."], - [chamber], - [link{label:"Docs", url:"https://gitbook.humanode.io/vortex-1.0"}], - "Voting, Delegation and Quorum", - "2025-12-04" -). - -vortex_term( - 29, - "mcm", - "Multiplied Cognitocratic Measure (MCM)", - governance, - "LCM multiplied by its chamber multiplier.", - [ - "Multiplied Cognitocratic Measure (MCM) is LCM multiplied by the chamber multiplier.", - "This adjusts LCM to reflect the specialization value defined by the multiplier, and feeds into ACM." - ], - [cm, mcm, chamber, multiplier], - ["lcm", "acm", "cognitocratic_measure_multiplier"], - ["LCM 5 in Philosophy × multiplier 3 = 15 MCM."], - [chamber], - [link{label:"Docs", url:"https://gitbook.humanode.io/vortex-1.0"}], - "Voting, Delegation and Quorum", - "2025-12-04" -). - -vortex_term( - 30, - "acm", - "Absolute Cognitocratic Measure (ACM)", - governance, - "Sum of all MCMs across chambers: ACM = Σ(LCM_chamber(i) × M_chamber(i)).", - [ - "Absolute Cognitocratic Measure (ACM) represents the sum of all MCMs received by a cognitocrat across chambers.", - "Formula: ACM = Σ_{i=1..n} (LCM_chamber(i) × M_chamber(i)), where i is a chamber and M is that chamber’s multiplier.", - "Example: Bob has 5 LCM in Philosophy (multiplier 3) and 10 LCM in Finance (multiplier 5). ACM = (5×3) + (10×5) = 65." - ], - [cm, acm, aggregate, governance], - ["lcm", "mcm", "cognitocratic_measure_multiplier"], - ["Bob: (5×3) + (10×5) = 65 ACM."], - [cm, chamber], - [link{label:"Docs", url:"https://gitbook.humanode.io/vortex-1.0"}], - "Voting, Delegation and Quorum", - "2025-12-04" -). - -vortex_term( - 31, - "multiplier_setting", - "Multiplier setting process", - governance, - "Cognitocrats set a 1–100 multiplier for chambers where they have no LCM; the average becomes the chamber multiplier.", - [ - "Each cognitocrat can vote a multiplier (1–100) for chambers in which they hold no LCM.", - "Average of submissions becomes the chamber’s multiplier.", - "If a cognitocrat holds LCM in multiple chambers, they are locked out from setting multipliers in those chambers." - ], - [process, cm, multiplier, chamber], - ["cognitocratic_measure_multiplier", "lcm", "acm"], - ["A cognitocrat without LCM in Finance submits 70; combined with others sets Finance’s multiplier."], - [cm], - [link{label:"Docs", url:"https://gitbook.humanode.io/vortex-1.0"}], - "Voting, Delegation and Quorum", - "2025-12-04" -). - -vortex_term( - 32, - "meritocratic_measure", - "Meritocratic Measure (MM)", - formation, - "Score awarded for participation and delivery in Formation projects; governors rate milestones and contributors.", - [ - "Earned through contribution to Formation project milestones.", - "Rated by governors when milestones are delivered.", - "Signals execution merit separate from chamber governance CM." - ], - [mm, formation, merit, milestones, rating], - ["formation", "formation_project", "cognitocratic_measure"], - ["A contributor receives MM based on governor ratings after a milestone delivery."], - [formation], - [link{label:"Docs", url:"https://gitbook.humanode.io/vortex-1.0"}], - "Formation – Meritocratic Measure", - "2025-12-04" -). - -vortex_term( - 33, - "proposition_rights", - "Proposition rights", - governance, - "Tier-based rights to make/promote proposals; tiers derive from PoT, PoD, PoG and do not change voting power.", - [ - "Tiers are based on Proof-of-Time, Proof-of-Devotion, and Proof-of-Governance.", - "Higher tiers unlock additional proposal types but do not add voting power." - ], - [proposals, tiers, rights, governance], - ["proof_of_time_pot", "proof_of_devotion_pod", "proof_of_governance_pog", "tier1_nominee", "tier2_ecclesiast", "tier3_legate", "tier4_consul", "tier5_citizen"], - ["Tier progression unlocks proposal types without changing vote weight."], - [chamber, pool], - [link{label:"Docs", url:"https://gitbook.humanode.io/vortex-1.0"}], - "Proposition rights", - "2025-12-04" -). - -vortex_term( - 34, - "governing_era", - "Governing era", - governance, - "168-epoch (~1 month) period; a governor stays active by running a node 164/168 epochs and meeting action thresholds.", - [ - "Era = 168 epochs; each epoch is ~4 hours.", - "Active if bioauthenticated and node ran 164/168 epochs and required actions met in previous era.", - "Required actions include voting/upvoting/downvoting proposals or chamber votes." - ], - [era, quorum, activity, governance], - ["governor", "proof_of_governance_pog"], - ["Passing era action threshold keeps a governor counted in quorums next era."], - [global], - [link{label:"Docs", url:"https://gitbook.humanode.io/vortex-1.0"}], - "Proposition rights", - "2025-12-04" -). - -vortex_term( - 35, - "proof_of_time_pot", - "Proof-of-Time (PoT)", - governance, - "Longevity of being a human node and a governor; contributes to tier progression.", - [ - "Tracks how long a human node and governor have been active.", - "Used for tier progression and proposal rights." - ], - [proof, time, longevity, tier], - ["proof_of_devotion_pod", "proof_of_governance_pog"], - ["Longer node/governor uptime supports higher tiers."], - [global], - [link{label:"Docs", url:"https://gitbook.humanode.io/vortex-1.0"}], - "Proposition rights – Proof types", - "2025-12-04" -). - -vortex_term( - 36, - "proof_of_devotion_pod", - "Proof-of-Devotion (PoD)", - governance, - "Contribution via proposal approval in Vortex and participation in Formation projects.", - [ - "Counts accepted proposals in Vortex.", - "Counts participation in Formation projects." - ], - [proof, devotion, proposals, formation, tier], - ["proof_of_time_pot", "proof_of_governance_pog"], - ["Accepted proposal + Formation participation advance PoD."], - [global, formation], - [link{label:"Docs", url:"https://gitbook.humanode.io/vortex-1.0"}], - "Proposition rights – Proof types", - "2025-12-04" -). - -vortex_term( - 37, - "proof_of_governance_pog", - "Proof-of-Governance (PoG)", - governance, - "Measures active governing streak and era actions to stay counted in quorums.", - [ - "Longevity of being an active governor.", - "Maintaining active governing status through required actions." - ], - [proof, governance, quorum, tier], - ["proof_of_time_pot", "proof_of_devotion_pod", "governing_era"], - ["Complete era action thresholds to retain active governor status."], - [global, chamber], - [link{label:"Docs", url:"https://gitbook.humanode.io/vortex-1.0"}], - "Proposition rights – Proof types", - "2025-12-04" -). - -vortex_term( - 38, - "tier1_nominee", - "Tier 1 · Nominee", - governance, - "Entry tier: human node seeking voting rights; can join Formation and propose most items except restricted categories.", - [ - "Requirements: Run a node.", - "New actions: Make any proposal excluding fee distribution, monetary system, core infrastructure, administrative, DAO core; participate in Formation; start earning longevity as governor." - ], - [tier, nominee, governance], - ["proof_of_time_pot", "proof_of_devotion_pod"], - ["Nominee can propose general items and join Formation but has no vote yet."], - [pool, formation], - [link{label:"Docs", url:"https://gitbook.humanode.io/vortex-1.0"}], - "Proposition rights – Tiers", - "2025-12-04" -). - -vortex_term( - 39, - "tier2_ecclesiast", - "Tier 2 · Ecclesiast", - governance, - "Unlocked when a nominee’s proposal is accepted; enables fee distribution and monetary modification proposals.", - [ - "Requirements: Run a node; have a proposal accepted in Vortex.", - "New available proposal types: Fee distribution; Monetary modification." - ], - [tier, ecclesiast, governance], - ["proof_of_time_pot", "proof_of_devotion_pod"], - ["Ecclesiast can propose fee splits or monetary changes after first accepted proposal."], - [chamber, pool], - [link{label:"Docs", url:"https://gitbook.humanode.io/vortex-1.0"}], - "Proposition rights – Tiers", - "2025-12-04" -). - -vortex_term( - 40, - "tier3_legate", - "Tier 3 · Legate", - governance, - "Requires 1 year node + active governor, accepted proposal, and Formation participation; unlocks core infrastructure changes.", - [ - "Requirements: Run a node for 1 year; be an active governor for 1 year; have a proposal accepted; participate in Formation.", - "New available proposal types: Core infrastructure changes (e.g., cryptobiometrics, CVM control, delegation mechanics)." - ], - [tier, legate, governance], - ["proof_of_time_pot", "proof_of_devotion_pod", "proof_of_governance_pog"], - ["Legate can propose core infrastructure changes after sustained activity and Formation work."], - [chamber], - [link{label:"Docs", url:"https://gitbook.humanode.io/vortex-1.0"}], - "Proposition rights – Tiers", - "2025-12-04" -). - -vortex_term( - 41, - "tier4_consul", - "Tier 4 · Consul", - governance, - "Requires 2 years node + active governor, accepted proposal, Formation participation; unlocks administrative proposals.", - [ - "Requirements: Run a node for 2 years; be an active governor for 2 years; have a proposal accepted; participate in Formation.", - "New available proposal types: Administrative (e.g., human node types, governor tiers, Formation procedures)." - ], - [tier, consul, governance], - ["proof_of_time_pot", "proof_of_devotion_pod", "proof_of_governance_pog"], - ["Consul can propose administrative changes after 2-year tenure and Formation work."], - [chamber], - [link{label:"Docs", url:"https://gitbook.humanode.io/vortex-1.0"}], - "Proposition rights – Tiers", - "2025-12-04" -). - -vortex_term( - 42, - "tier5_citizen", - "Tier 5 · Citizen", - governance, - "Highest tier with unrestricted proposition rights (DAO core); requires long tenure and active governance.", - [ - "Requirements: Run a node for 4 years; be a governor for 4 years; be an active governor for 3 years; have a proposal accepted; participate in Formation.", - "New available proposal types: DAO core (e.g., proposal system values, voting protocol, human node/governor types)." - ], - [tier, citizen, governance], - ["proof_of_time_pot", "proof_of_devotion_pod", "proof_of_governance_pog"], - ["Citizen tier can propose DAO core changes after long-term tenure and activity."], - [chamber], - [link{label:"Docs", url:"https://gitbook.humanode.io/vortex-1.0"}], - "Proposition rights – Tiers", - "2025-12-04" -). - -vortex_term( - 43, - "gradual_decentralization", - "Gradual decentralization", - governance, - "Humanode core designs and bootstraps Vortex/Formation, aiming for transparent, decentralized governance driven by active governors.", - [ - "Core promotes transparency, builds decentralized governing processes, participates in community, and drafts proposals.", - "Governance stack combines proposal pools, chambers, and Formation with PoT/PoD/PoH safeguards." - ], - [decentralization, governance, transparency], - ["voter_apathy"], - ["Core designs the stack but expects governors to drive decisions as decentralization grows."], - [global], - [link{label:"Docs", url:"https://gitbook.humanode.io/vortex-1.0"}], - "Discussion", - "2025-12-04" -). - -vortex_term( - 44, - "voter_apathy", - "Voter apathy", - governance, - "Low participation can stall governance; Vortex addresses apathy by requiring activity to stay governor and counting only active governors toward quorum.", - [ - "Apathy can block quorums and delay decisions.", - "Governors must meet monthly action thresholds or revert to non-governing.", - "Quorum (33%) counts only active governors; non-participants are excluded." - ], - [apathy, quorum, governance], - ["gradual_decentralization", "quorum_of_vote"], - ["Inactivity drops governor status; only active participants count toward quorum."], - [chamber, pool], - [link{label:"Docs", url:"https://gitbook.humanode.io/vortex-1.0"}], - "Discussion", - "2025-12-04" -). - -vortex_term( - 45, - "iron_law_of_oligarchy", - "Iron law of oligarchy", - governance, - "Any organization tends toward elite control; Vortex counters via equal vote power, intellectual barriers, and delegation transparency.", - [ - "Acknowledges inevitability of leadership classes; seeks balance between efficiency and democratic involvement.", - "Combines equal voting power, intellectual barriers, and active quorum/delegation to limit oligarchic capture." - ], - [oligarchy, governance, deterrence], - ["plutocracy_risk", "cognitocratic_populism"], - ["Equal votes plus tiers/intellectual barriers aim to temper oligarchic drift."], - [global], - [link{label:"Docs", url:"https://gitbook.humanode.io/vortex-1.0"}], - "Discussion", - "2025-12-04" -). - -vortex_term( - 46, - "plutocracy_risk", - "Plutocracy risk", - governance, - "Risk of capital holders influencing decisions; mitigated by no elections, equal vote power, and proposal merit barriers.", - [ - "No elections or variable vote weights; all governors have equal vote power.", - "Proposals must be accepted on merit, reducing impact of pure capital/media influence." - ], - [plutocracy, governance, risk], - ["iron_law_of_oligarchy"], - ["Capital alone cannot buy vote weight; proposals need specialist acceptance."], - [global, chamber], - [link{label:"Docs", url:"https://gitbook.humanode.io/vortex-1.0"}], - "Discussion", - "2025-12-04" -). - -vortex_term( - 47, - "cognitocratic_populism", - "Cognitocratic populism", - governance, - "Populist influence is dampened by specialist voting and proof barriers; liquid delegation still allows crowd support.", - [ - "Specialist-only voting and proposal acceptance reduce mass populist sway.", - "Delegation remains liquid, so popular governors can accumulate delegations." - ], - [populism, governance, delegation], - ["proof_of_devotion_pod"], - ["Populists must appeal to cognitocrats, not the mass public, to gain delegations."], - [chamber], - [link{label:"Docs", url:"https://gitbook.humanode.io/vortex-1.0"}], - "Discussion", - "2025-12-04" -). - -vortex_term( - 48, - "cognitocratic_drain", - "Cognitocratic drain", - governance, - "State where a chamber’s innovation slows, risking lowered admission barriers, impractical proposals, or cartelization.", - [ - "Too much implemented innovation can raise barriers to new creative proposals.", - "Risks: lowered standards, non-practical proposals, or chamber cartelization.", - "Mitigation: dissolve or merge chambers if innovation throughput drops." - ], - [drain, chamber, innovation, governance], - ["specialization_chamber", "chamber_dissolution"], - ["Merge or dissolve an SC if it stagnates and lowers its admission quality."], - [chamber], - [link{label:"Docs", url:"https://gitbook.humanode.io/vortex-1.0"}], - "Discussion", - "2025-12-04" -). - -vortex_term( - 49, - "chamber_vote", - "Chamber vote", - governance, - "Stage where governors cast binding votes on proposals that cleared proposal pools; requires quorum and a passing threshold.", - [ - "Proposals reaching attention quorum in a proposal pool advance to a chamber vote.", - "Chamber voting counts delegations and requires a voting quorum (e.g., 33.3% of active governors).", - "Passing typically needs ≥66.6% + 1 yes vote within quorum." - ], - [vote, chamber, quorum, governance], - ["proposal_pools", "quorum_of_vote", "quorum_of_attention", "delegation_policy"], - ["A proposal that met pool attention quorum proceeds to chamber vote; if 66.6% + 1 yes within quorum, it passes."], - [chamber], - [link{label:"Docs", url:"https://gitbook.humanode.io/vortex-1.0"}], - "Voting, Delegation and Quorum", - "2025-12-04" -). - -vortex_term( - 50, - "governing_threshold", - "Governing threshold", - governance, - "Action quota a governor must meet each era (e.g., votes/upvotes) plus node uptime to remain active for the next era’s quorums.", - [ - "A governor is active if bioauthenticated, node ran 164/168 epochs, and required actions were met in the previous era.", - "Required actions per era include upvoting/downvoting proposals or voting on chamber proposals in Vortex.", - "Meeting the threshold keeps the governor eligible to be counted in quorums for the upcoming era." - ], - [threshold, quorum, activity, governor], - ["governing_era", "governor", "quorum_of_vote", "quorum_of_attention"], - [ - "If the action threshold is met and uptime is 164/168 epochs, the governor is counted as active in the next era’s quorum." - ], - [global], - [link{label:"Docs", url:"https://gitbook.humanode.io/vortex-1.0"}], - "Proposition rights", - "2025-12-04" -). - -vortex_term( - 51, - "governing_status_ahead", - "Ahead", - governance, - "You are comfortably above the governing threshold pace for the current era. Status scale: Ahead → Stable → Falling behind → At risk → Losing status.", - [ - "Ahead means you have already met (or are well on track to exceed) the era’s action threshold early, leaving a buffer for the rest of the era.", - "Staying Ahead typically requires continuing normal participation (votes/upvotes/court actions) while maintaining node uptime.", - "This status is based on your completed actions vs required actions for the current governing era, not on proposal outcomes." - ], - [status, governance, threshold, governor, activity], - [ - "governing_threshold", - "governing_era", - "proof_of_governance_pog", - "governing_status_stable", - "governing_status_falling_behind", - "governing_status_at_risk", - "governing_status_losing_status" - ], - ["If the era requires 18 actions and you have already completed 20, you’re Ahead."], - [global], - [link{label:"Docs", url:"https://gitbook.humanode.io/vortex-1.0"}], - "App UX: governing status scale", - "2025-12-18" -). - -vortex_term( - 52, - "governing_status_stable", - "Stable", - governance, - "You are on pace to meet the governing threshold for the era. Status scale: Ahead → Stable → Falling behind → At risk → Losing status.", - [ - "Stable means your completed actions are at or near the required threshold pace, and you are not currently trending toward inactivity for the next era.", - "If you stay Stable through the era (and maintain uptime), you remain counted as an active governor for quorum calculations in the next era.", - "This status summarizes action progress for the current era; it can change as time passes and requirements are assessed." - ], - [status, governance, threshold, governor, activity], - [ - "governing_threshold", - "governing_era", - "proof_of_governance_pog", - "governing_status_ahead", - "governing_status_falling_behind", - "governing_status_at_risk", - "governing_status_losing_status" - ], - ["If the era requires 18 actions and you have completed 18, you’re Stable."], - [global], - [link{label:"Docs", url:"https://gitbook.humanode.io/vortex-1.0"}], - "App UX: governing status scale", - "2025-12-18" -). - -vortex_term( - 53, - "governing_status_falling_behind", - "Falling behind", - governance, - "You are below the desired pace for the era’s governing threshold, but can still recover by completing more actions. Status scale: Ahead → Stable → Falling behind → At risk → Losing status.", - [ - "Falling behind indicates you are not yet at the target action pace for the current era, but your deficit is still manageable.", - "To move back toward Stable, complete additional required actions (e.g., proposal pool votes, chamber votes, court actions) before the era ends.", - "This status is meant to prompt action early enough to avoid becoming At risk." - ], - [status, governance, threshold, governor, activity], - [ - "governing_threshold", - "governing_era", - "proof_of_governance_pog", - "governing_status_ahead", - "governing_status_stable", - "governing_status_at_risk", - "governing_status_losing_status" - ], - [ - "If the era requires 18 actions and you have completed 14 with little time left, you may be Falling behind." - ], - [global], - [link{label:"Docs", url:"https://gitbook.humanode.io/vortex-1.0"}], - "App UX: governing status scale", - "2025-12-18" -). - -vortex_term( - 54, - "governing_status_at_risk", - "At risk", - governance, - "You are unlikely to meet the governing threshold without immediate additional actions. Status scale: Ahead → Stable → Falling behind → At risk → Losing status.", - [ - "At risk means your current action count is far enough below the era requirement that you may lose active governor status for the next era if you do not act.", - "To improve: complete additional required actions (pool votes, chamber votes, court actions, proposals) before the era ends and maintain node uptime.", - "This status summarizes your action deficit; it does not imply slashing or permanent removal—only loss of active quorum eligibility in the next era." - ], - [status, governance, threshold, governor, activity], - [ - "governing_threshold", - "governing_era", - "proof_of_governance_pog", - "governing_status_ahead", - "governing_status_stable", - "governing_status_falling_behind", - "governing_status_losing_status" - ], - ["If the era requires 18 actions and you have completed 11, you are At risk unless you catch up."], - [global], - [link{label:"Docs", url:"https://gitbook.humanode.io/vortex-1.0"}], - "App UX: governing status scale", - "2025-12-18" -). - -vortex_term( - 55, - "governing_status_losing_status", - "Losing status", - governance, - "You are on course to lose active governor status for the next era unless you substantially increase participation now. Status scale: Ahead → Stable → Falling behind → At risk → Losing status.", - [ - "Losing status indicates a severe shortfall against the era action threshold and/or insufficient remaining time to realistically catch up.", - "If this remains at era close, you may not be counted as an active governor for quorum calculations in the next era.", - "To recover, complete the highest-impact required actions immediately and maintain node uptime; otherwise you transition out of active quorum eligibility." - ], - [status, governance, threshold, governor, activity], - [ - "governing_threshold", - "governing_era", - "proof_of_governance_pog", - "governing_status_ahead", - "governing_status_stable", - "governing_status_falling_behind", - "governing_status_at_risk" - ], - [ - "If the era requires 18 actions and you have completed 4 near the end of the era, you are Losing status." - ], - [global], - [link{label:"Docs", url:"https://gitbook.humanode.io/vortex-1.0"}], - "App UX: governing status scale", - "2025-12-18" -). - -% --- -% You can add a search helper later (e.g., search_terms/3) to return dicts/JSON. diff --git a/scripts/db-clear.ts b/scripts/db-clear.ts deleted file mode 100644 index bffbadd..0000000 --- a/scripts/db-clear.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { neon } from "@neondatabase/serverless"; -import { drizzle } from "drizzle-orm/neon-http"; -import { sql } from "drizzle-orm"; -import { pathToFileURL } from "node:url"; - -function requireEnv(key: string): string { - const value = process.env[key]; - if (!value) throw new Error(`Missing ${key}`); - return value; -} - -async function main() { - const databaseUrl = requireEnv("DATABASE_URL"); - const client = neon(databaseUrl); - const db = drizzle(client); - - await db.execute( - sql`TRUNCATE TABLE auth_nonces, eligibility_cache, users, clock_state, read_models, proposal_drafts, proposals, proposal_stage_denominators, veto_votes, chamber_multiplier_submissions, chamber_memberships, chambers, delegations, delegation_events, events, pool_votes, chamber_votes, cm_awards, idempotency_keys, formation_projects, formation_team, formation_milestones, formation_milestone_events, court_cases, court_reports, court_verdicts, era_snapshots, era_user_activity, era_rollups, era_user_status, api_rate_limits, user_action_locks, admin_state RESTART IDENTITY`, - ); - - console.log("Cleared simulation tables (data removed, schema preserved)."); -} - -const isMain = import.meta.url === pathToFileURL(process.argv[1] ?? "").href; -if (isMain) { - main().catch((error) => { - console.error(error); - process.exitCode = 1; - }); -} diff --git a/scripts/db-seed.ts b/scripts/db-seed.ts deleted file mode 100644 index 27e14f8..0000000 --- a/scripts/db-seed.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { neon } from "@neondatabase/serverless"; -import { drizzle } from "drizzle-orm/neon-http"; -import { sql } from "drizzle-orm"; -import { pathToFileURL } from "node:url"; - -import { chambers as chambersTable, events, readModels } from "../db/schema.ts"; - -import { - buildReadModelSeed, - type ReadModelSeedEntry, -} from "../db/seed/readModels.ts"; -import { buildEventSeed } from "../db/seed/events.ts"; -import { chambers as chamberFixtures } from "../db/seed/fixtures/chambers.ts"; - -function requireEnv(key: string): string { - const value = process.env[key]; - if (!value) throw new Error(`Missing ${key}`); - return value; -} - -async function upsertReadModel( - db: ReturnType, - key: string, - payload: unknown, -) { - const now = new Date(); - await db - .insert(readModels) - .values({ key, payload, updatedAt: now }) - .onConflictDoUpdate({ - target: readModels.key, - set: { payload, updatedAt: now }, - }); -} - -async function main() { - const databaseUrl = requireEnv("DATABASE_URL"); - const client = neon(databaseUrl); - const db = drizzle(client); - - for (const entry of buildReadModelSeed()) { - await upsertReadModel(db, entry.key, entry.payload); - } - - const eventSeed = buildEventSeed(); - await db.execute(sql`TRUNCATE TABLE events RESTART IDENTITY`); - await db.execute( - sql`TRUNCATE TABLE chambers, chamber_memberships, pool_votes, chamber_votes, cm_awards, idempotency_keys, formation_projects, formation_team, formation_milestones, formation_milestone_events, court_cases, court_reports, court_verdicts, era_snapshots, era_user_activity, era_rollups, era_user_status RESTART IDENTITY`, - ); - - await db.insert(chambersTable).values( - chamberFixtures.map((chamber) => ({ - id: chamber.id, - title: chamber.name, - status: "active", - multiplierTimes10: Math.round(chamber.multiplier * 10), - metadata: {}, - createdAt: new Date(), - updatedAt: new Date(), - dissolvedAt: null, - createdByProposalId: null, - dissolvedByProposalId: null, - })), - ); - - if (eventSeed.length > 0) { - await db.insert(events).values( - eventSeed.map((event) => ({ - type: event.type, - stage: event.stage, - actorAddress: event.actorAddress, - entityType: event.entityType, - entityId: event.entityId, - payload: event.payload, - createdAt: event.createdAt, - })), - ); - } - - console.log("Seeded read models and events into Postgres."); -} - -const isMain = import.meta.url === pathToFileURL(process.argv[1] ?? "").href; -if (isMain) { - main().catch((error) => { - console.error(error); - process.exitCode = 1; - }); -} diff --git a/scripts/dev-api-node.mjs b/scripts/dev-api-node.mjs deleted file mode 100644 index 9cb5c77..0000000 --- a/scripts/dev-api-node.mjs +++ /dev/null @@ -1,283 +0,0 @@ -import http from "node:http"; -import { readFileSync } from "node:fs"; -import { readFile } from "node:fs/promises"; -import { resolve } from "node:path"; -import { URL } from "node:url"; - -function setDefaultEnv() { - process.env.SESSION_SECRET ??= "dev-secret"; - process.env.DEV_BYPASS_SIGNATURE ??= "false"; - process.env.DEV_BYPASS_GATE ??= "false"; - process.env.DEV_INSECURE_COOKIES ??= "true"; - - // Ensure the backend always has access to sim config (RPC URL, genesis members) - // even when requests come through a proxy and `request.url` origin isn't the API server. - // `api/_lib/simConfig.ts` prefers `SIM_CONFIG_JSON` over fetching `/sim-config.json`. - if (!process.env.SIM_CONFIG_JSON) { - try { - const filepath = resolve(process.cwd(), "public", "sim-config.json"); - process.env.SIM_CONFIG_JSON = readFileSync(filepath, "utf8"); - } catch { - // ignore - } - } - - const hasDb = Boolean(process.env.DATABASE_URL); - - if (hasDb) { - process.env.READ_MODELS_INLINE ??= "false"; - process.env.READ_MODELS_INLINE_EMPTY ??= "false"; - return; - } - - process.env.READ_MODELS_INLINE ??= "false"; - if (process.env.READ_MODELS_INLINE === "true") { - process.env.READ_MODELS_INLINE_EMPTY ??= "false"; - } else { - process.env.READ_MODELS_INLINE_EMPTY ??= "true"; - } -} - -function readBody(req) { - return new Promise((resolve, reject) => { - const chunks = []; - req.on("data", (c) => chunks.push(c)); - req.on("end", () => resolve(Buffer.concat(chunks))); - req.on("error", reject); - }); -} - -function resolveRoute(pathname) { - const patterns = [ - ["GET", /^\/api\/health$/, () => import("../api/routes/health.ts")], - ["GET", /^\/api\/me$/, () => import("../api/routes/me.ts")], - [ - "GET", - /^\/api\/gate\/status$/, - () => import("../api/routes/gate/status.ts"), - ], - [ - "POST", - /^\/api\/auth\/nonce$/, - () => import("../api/routes/auth/nonce.ts"), - ], - [ - "POST", - /^\/api\/auth\/verify$/, - () => import("../api/routes/auth/verify.ts"), - ], - [ - "POST", - /^\/api\/auth\/logout$/, - () => import("../api/routes/auth/logout.ts"), - ], - ["POST", /^\/api\/command$/, () => import("../api/routes/command.ts")], - ["GET", /^\/api\/clock$/, () => import("../api/routes/clock/index.ts")], - [ - "POST", - /^\/api\/clock\/advance-era$/, - () => import("../api/routes/clock/advance-era.ts"), - ], - [ - "POST", - /^\/api\/clock\/rollup-era$/, - () => import("../api/routes/clock/rollup-era.ts"), - ], - [ - "GET", - /^\/api\/chambers$/, - () => import("../api/routes/chambers/index.ts"), - ], - [ - "GET", - /^\/api\/chambers\/([^/]+)$/, - () => import("../api/routes/chambers/[id].ts"), - ], - [ - "GET", - /^\/api\/proposals$/, - () => import("../api/routes/proposals/index.ts"), - ], - [ - "GET", - /^\/api\/proposals\/drafts$/, - () => import("../api/routes/proposals/drafts/index.ts"), - ], - [ - "GET", - /^\/api\/proposals\/drafts\/([^/]+)$/, - () => import("../api/routes/proposals/drafts/[id].ts"), - ], - ["GET", /^\/api\/feed$/, () => import("../api/routes/feed/index.ts")], - [ - "GET", - /^\/api\/proposals\/([^/]+)\/pool$/, - () => import("../api/routes/proposals/[id]/pool.ts"), - ], - [ - "GET", - /^\/api\/proposals\/([^/]+)\/chamber$/, - () => import("../api/routes/proposals/[id]/chamber.ts"), - ], - [ - "GET", - /^\/api\/proposals\/([^/]+)\/formation$/, - () => import("../api/routes/proposals/[id]/formation.ts"), - ], - ["GET", /^\/api\/courts$/, () => import("../api/routes/courts/index.ts")], - [ - "GET", - /^\/api\/courts\/([^/]+)$/, - () => import("../api/routes/courts/[id].ts"), - ], - ["GET", /^\/api\/humans$/, () => import("../api/routes/humans/index.ts")], - [ - "GET", - /^\/api\/humans\/([^/]+)$/, - () => import("../api/routes/humans/[id].ts"), - ], - [ - "GET", - /^\/api\/factions$/, - () => import("../api/routes/factions/index.ts"), - ], - [ - "GET", - /^\/api\/factions\/([^/]+)$/, - () => import("../api/routes/factions/[id].ts"), - ], - [ - "GET", - /^\/api\/formation$/, - () => import("../api/routes/formation/index.ts"), - ], - [ - "GET", - /^\/api\/invision$/, - () => import("../api/routes/invision/index.ts"), - ], - [ - "GET", - /^\/api\/my-governance$/, - () => import("../api/routes/my-governance/index.ts"), - ], - ]; - - for (const [method, re, load] of patterns) { - const match = pathname.match(re); - if (!match) continue; - return { - method, - load, - params: match[1] ? { id: match[1] } : {}, - }; - } - return null; -} - -function getSetCookieHeaders(headers) { - const getSetCookie = headers?.getSetCookie?.bind(headers); - if (getSetCookie) return getSetCookie(); - const v = headers?.get?.("set-cookie"); - return v ? [v] : []; -} - -async function handleSimConfig(_nodeReq, nodeRes) { - try { - const filepath = resolve(process.cwd(), "public", "sim-config.json"); - const raw = await readFile(filepath, "utf8"); - nodeRes.statusCode = 200; - nodeRes.setHeader("content-type", "application/json; charset=utf-8"); - nodeRes.setHeader("cache-control", "no-store"); - nodeRes.end(raw); - } catch { - nodeRes.statusCode = 404; - nodeRes.setHeader("content-type", "application/json; charset=utf-8"); - nodeRes.end( - JSON.stringify({ - error: { message: "Missing public/sim-config.json for local dev" }, - }), - ); - } -} - -async function handleRequest(nodeReq, nodeRes) { - const origin = `http://${nodeReq.headers.host ?? "127.0.0.1"}`; - const url = new URL(nodeReq.url ?? "/", origin); - - if (nodeReq.method === "GET" && url.pathname === "/sim-config.json") { - await handleSimConfig(nodeReq, nodeRes); - return; - } - - const route = resolveRoute(url.pathname); - if (!route) { - nodeRes.statusCode = 404; - nodeRes.setHeader("content-type", "application/json"); - nodeRes.end(JSON.stringify({ error: { message: "Not found" } })); - return; - } - - if (nodeReq.method !== route.method) { - nodeRes.statusCode = 405; - nodeRes.setHeader("content-type", "application/json"); - nodeRes.end(JSON.stringify({ error: { message: "Method not allowed" } })); - return; - } - - const body = await readBody(nodeReq); - const request = new Request(url.toString(), { - method: nodeReq.method, - headers: nodeReq.headers, - body: body.length ? body : undefined, - }); - - const mod = await route.load(); - const handler = - nodeReq.method === "POST" ? mod.onRequestPost : mod.onRequestGet; - - if (typeof handler !== "function") { - nodeRes.statusCode = 500; - nodeRes.setHeader("content-type", "application/json"); - nodeRes.end( - JSON.stringify({ error: { message: "Handler not implemented" } }), - ); - return; - } - - const env = { ...process.env }; - const response = await handler({ request, env, params: route.params }); - - nodeRes.statusCode = response.status; - - const setCookies = getSetCookieHeaders(response.headers); - for (const cookie of setCookies) { - nodeRes.appendHeader?.("set-cookie", cookie); - } - for (const [key, value] of response.headers.entries()) { - if (key.toLowerCase() === "set-cookie") continue; - nodeRes.setHeader(key, value); - } - - const arrayBuffer = await response.arrayBuffer(); - nodeRes.end(Buffer.from(arrayBuffer)); -} - -setDefaultEnv(); - -const port = Number(process.env.API_PORT ?? "8788"); -const host = process.env.API_HOST ?? "127.0.0.1"; - -const server = http.createServer((req, res) => { - void handleRequest(req, res).catch((err) => { - res.statusCode = 500; - res.setHeader("content-type", "application/json"); - res.end( - JSON.stringify({ error: { message: err?.message ?? String(err) } }), - ); - }); -}); - -server.listen(port, host, () => { - console.log(`[api] listening on http://${host}:${port}`); -}); diff --git a/scripts/dev-full.mjs b/scripts/dev-full.mjs deleted file mode 100644 index 713d28d..0000000 --- a/scripts/dev-full.mjs +++ /dev/null @@ -1,26 +0,0 @@ -import { spawn } from "node:child_process"; - -function spawnProc(command, args, name) { - const child = spawn(command, args, { stdio: "inherit" }); - child.on("exit", (code) => { - if (code && code !== 0) { - console.error(`[${name}] exited with code ${code}`); - } - }); - return child; -} - -const api = spawnProc( - "node", - ["--experimental-transform-types", "scripts/dev-api-node.mjs"], - "api", -); -const app = spawnProc("yarn", ["dev"], "app"); - -function shutdown() { - api.kill("SIGTERM"); - app.kill("SIGTERM"); -} - -process.on("SIGINT", shutdown); -process.on("SIGTERM", shutdown); diff --git a/tests/api-admin-tools.test.js b/tests/api-admin-tools.test.js deleted file mode 100644 index e4f2a9f..0000000 --- a/tests/api-admin-tools.test.js +++ /dev/null @@ -1,181 +0,0 @@ -import assert from "node:assert/strict"; -import { test } from "node:test"; - -import { onRequestGet as auditGet } from "../api/routes/admin/audit/index.ts"; -import { onRequestGet as statsGet } from "../api/routes/admin/stats.ts"; -import { onRequestGet as adminUserGet } from "../api/routes/admin/users/[address].ts"; -import { onRequestGet as adminLocksGet } from "../api/routes/admin/users/locks.ts"; -import { onRequestPost as adminLockPost } from "../api/routes/admin/users/lock.ts"; -import { onRequestPost as adminUnlockPost } from "../api/routes/admin/users/unlock.ts"; -import { onRequestPost as commandPost } from "../api/routes/command.ts"; -import { getSessionCookieName, issueSession } from "../api/_lib/auth.ts"; -import { clearActionLocksForTests } from "../api/_lib/actionLocksStore.ts"; -import { clearAdminAuditForTests } from "../api/_lib/adminAuditStore.ts"; -import { clearApiRateLimitsForTests } from "../api/_lib/apiRateLimitStore.ts"; -import { clearEraForTests } from "../api/_lib/eraStore.ts"; -import { clearIdempotencyForTests } from "../api/_lib/idempotencyStore.ts"; -import { clearPoolVotesForTests } from "../api/_lib/poolVotesStore.ts"; -import { clearInlineReadModelsForTests } from "../api/_lib/readModelsStore.ts"; -import { - clearChamberMembershipsForTests, - ensureChamberMembership, -} from "../api/_lib/chamberMembershipsStore.ts"; - -function makeContext({ url, env, params, method = "GET", headers, body }) { - return { - request: new Request(url, { method, headers, body }), - env, - params, - }; -} - -async function makeSessionCookie(env, address) { - const headers = new Headers(); - await issueSession(headers, env, "https://local.test/api/command", address); - const setCookie = headers.get("set-cookie"); - assert.ok(setCookie); - const tokenPair = setCookie.split(";")[0]; - const [name, value] = tokenPair.split("="); - assert.equal(name, getSessionCookieName()); - await ensureChamberMembership(env, { - address, - chamberId: "general", - source: "test", - }); - await ensureChamberMembership(env, { - address, - chamberId: "engineering", - source: "test", - }); - return `${name}=${value}`; -} - -const baseEnv = { - SESSION_SECRET: "test-secret", - DEV_BYPASS_GATE: "true", - DEV_INSECURE_COOKIES: "true", - READ_MODELS_INLINE: "true", - ADMIN_SECRET: "admin-secret", - SIM_COMMAND_RATE_LIMIT_PER_MINUTE_ADDRESS: "1000", - SIM_COMMAND_RATE_LIMIT_PER_MINUTE_IP: "1000", - SIM_MAX_POOL_VOTES_PER_ERA: "2", -}; - -async function resetAll() { - await clearPoolVotesForTests(); - clearIdempotencyForTests(); - clearInlineReadModelsForTests(); - clearEraForTests(); - clearApiRateLimitsForTests(); - clearActionLocksForTests(); - clearAdminAuditForTests(); - clearChamberMembershipsForTests(); -} - -test("admin endpoints: list locks, inspect user, and audit lock/unlock actions (memory mode)", async () => { - await resetAll(); - - const address = "5AdminTarget"; - const cookie = await makeSessionCookie(baseEnv, address); - - const vote = await commandPost( - makeContext({ - url: "https://local.test/api/command", - env: baseEnv, - method: "POST", - headers: { "content-type": "application/json", cookie }, - body: JSON.stringify({ - type: "pool.vote", - payload: { proposalId: "biometric-account-recovery", direction: "up" }, - }), - }), - ); - assert.equal(vote.status, 200); - - const lockedUntil = new Date(Date.now() + 60_000).toISOString(); - const lockRes = await adminLockPost( - makeContext({ - url: "https://local.test/api/admin/users/lock", - env: baseEnv, - method: "POST", - headers: { - "content-type": "application/json", - "x-admin-secret": "admin-secret", - }, - body: JSON.stringify({ address, lockedUntil, reason: "testing" }), - }), - ); - assert.equal(lockRes.status, 200); - - const locksRes = await adminLocksGet( - makeContext({ - url: "https://local.test/api/admin/users/locks", - env: baseEnv, - method: "GET", - headers: { "x-admin-secret": "admin-secret" }, - }), - ); - assert.equal(locksRes.status, 200); - const locksJson = await locksRes.json(); - assert.ok(Array.isArray(locksJson.items)); - assert.ok(locksJson.items.find((l) => l.address === address)); - - const statusRes = await adminUserGet( - makeContext({ - url: `https://local.test/api/admin/users/${address}`, - env: baseEnv, - params: { address }, - method: "GET", - headers: { "x-admin-secret": "admin-secret" }, - }), - ); - assert.equal(statusRes.status, 200); - const statusJson = await statusRes.json(); - assert.equal(statusJson.address, address); - assert.equal(statusJson.counts.poolVotes, 1); - assert.equal(statusJson.quotas.maxPoolVotes, 2); - assert.equal(statusJson.remaining.poolVotes, 1); - assert.equal(statusJson.lock.address, address); - - const unlockRes = await adminUnlockPost( - makeContext({ - url: "https://local.test/api/admin/users/unlock", - env: baseEnv, - method: "POST", - headers: { - "content-type": "application/json", - "x-admin-secret": "admin-secret", - }, - body: JSON.stringify({ address }), - }), - ); - assert.equal(unlockRes.status, 200); - - const auditRes = await auditGet( - makeContext({ - url: "https://local.test/api/admin/audit", - env: baseEnv, - method: "GET", - headers: { "x-admin-secret": "admin-secret" }, - }), - ); - assert.equal(auditRes.status, 200); - const auditJson = await auditRes.json(); - assert.ok(Array.isArray(auditJson.items)); - assert.ok(auditJson.items.find((e) => e.action === "user.lock")); - assert.ok(auditJson.items.find((e) => e.action === "user.unlock")); - - const statsRes = await statsGet( - makeContext({ - url: "https://local.test/api/admin/stats", - env: baseEnv, - method: "GET", - headers: { "x-admin-secret": "admin-secret" }, - }), - ); - assert.equal(statsRes.status, 200); - const statsJson = await statsRes.json(); - assert.equal(statsJson.currentEra, 0); - assert.equal(statsJson.writesFrozen, false); - assert.equal(statsJson.currentEraActivity.rows, 1); -}); diff --git a/tests/api-admin-write-freeze.test.js b/tests/api-admin-write-freeze.test.js deleted file mode 100644 index 6a53d64..0000000 --- a/tests/api-admin-write-freeze.test.js +++ /dev/null @@ -1,121 +0,0 @@ -import assert from "node:assert/strict"; -import { test } from "node:test"; - -import { onRequestPost as freezePost } from "../api/routes/admin/writes/freeze.ts"; -import { onRequestPost as commandPost } from "../api/routes/command.ts"; -import { getSessionCookieName, issueSession } from "../api/_lib/auth.ts"; -import { clearAdminStateForTests } from "../api/_lib/adminStateStore.ts"; -import { clearApiRateLimitsForTests } from "../api/_lib/apiRateLimitStore.ts"; -import { clearEraForTests } from "../api/_lib/eraStore.ts"; -import { clearIdempotencyForTests } from "../api/_lib/idempotencyStore.ts"; -import { clearPoolVotesForTests } from "../api/_lib/poolVotesStore.ts"; -import { clearInlineReadModelsForTests } from "../api/_lib/readModelsStore.ts"; -import { - clearChamberMembershipsForTests, - ensureChamberMembership, -} from "../api/_lib/chamberMembershipsStore.ts"; - -function makeContext({ url, env, params, method = "POST", headers, body }) { - return { - request: new Request(url, { method, headers, body }), - env, - params, - }; -} - -async function makeSessionCookie(env, address) { - const headers = new Headers(); - await issueSession(headers, env, "https://local.test/api/command", address); - const setCookie = headers.get("set-cookie"); - assert.ok(setCookie); - const tokenPair = setCookie.split(";")[0]; - const [name, value] = tokenPair.split("="); - assert.equal(name, getSessionCookieName()); - await ensureChamberMembership(env, { - address, - chamberId: "general", - source: "test", - }); - await ensureChamberMembership(env, { - address, - chamberId: "engineering", - source: "test", - }); - return `${name}=${value}`; -} - -const baseEnv = { - SESSION_SECRET: "test-secret", - DEV_BYPASS_GATE: "true", - DEV_INSECURE_COOKIES: "true", - READ_MODELS_INLINE: "true", - ADMIN_SECRET: "admin-secret", - SIM_COMMAND_RATE_LIMIT_PER_MINUTE_ADDRESS: "1000", - SIM_COMMAND_RATE_LIMIT_PER_MINUTE_IP: "1000", -}; - -test("admin write freeze blocks /api/command until unfrozen (memory mode)", async () => { - await clearPoolVotesForTests(); - clearIdempotencyForTests(); - clearInlineReadModelsForTests(); - clearEraForTests(); - clearApiRateLimitsForTests(); - clearAdminStateForTests(); - clearChamberMembershipsForTests(); - - const cookie = await makeSessionCookie(baseEnv, "5FreezeAddr"); - - const freeze = await freezePost( - makeContext({ - url: "https://local.test/api/admin/writes/freeze", - env: baseEnv, - headers: { - "content-type": "application/json", - "x-admin-secret": "admin-secret", - }, - body: JSON.stringify({ enabled: true }), - }), - ); - assert.equal(freeze.status, 200); - - const blocked = await commandPost( - makeContext({ - url: "https://local.test/api/command", - env: baseEnv, - headers: { "content-type": "application/json", cookie }, - body: JSON.stringify({ - type: "pool.vote", - payload: { proposalId: "biometric-account-recovery", direction: "up" }, - }), - }), - ); - assert.equal(blocked.status, 503); - const blockedJson = await blocked.json(); - assert.equal(blockedJson.error.code, "writes_frozen"); - - const unfreeze = await freezePost( - makeContext({ - url: "https://local.test/api/admin/writes/freeze", - env: baseEnv, - headers: { - "content-type": "application/json", - "x-admin-secret": "admin-secret", - }, - body: JSON.stringify({ enabled: false }), - }), - ); - assert.equal(unfreeze.status, 200); - - const allowed = await commandPost( - makeContext({ - url: "https://local.test/api/command", - env: baseEnv, - headers: { "content-type": "application/json", cookie }, - body: JSON.stringify({ - type: "pool.vote", - payload: { proposalId: "biometric-account-recovery", direction: "up" }, - }), - }), - ); - assert.equal(allowed.status, 200); -}); diff --git a/tests/api-auth-nonce.test.js b/tests/api-auth-nonce.test.js deleted file mode 100644 index a0736cd..0000000 --- a/tests/api-auth-nonce.test.js +++ /dev/null @@ -1,100 +0,0 @@ -import assert from "node:assert/strict"; -import { test } from "node:test"; - -import { onRequestPost as noncePost } from "../api/routes/auth/nonce.ts"; -import { onRequestPost as verifyPost } from "../api/routes/auth/verify.ts"; - -function getSetCookies(response) { - const maybe = response.headers.getSetCookie?.bind(response.headers); - if (maybe) return maybe(); - const single = response.headers.get("set-cookie"); - return single ? [single] : []; -} - -function cookiePair(setCookieValue) { - return setCookieValue.split(";")[0]; -} - -function makeContext({ url, method, env, body, cookie, headers: headersInit }) { - const headers = new Headers(headersInit); - if (cookie) headers.set("cookie", cookie); - if (body !== undefined) headers.set("content-type", "application/json"); - const request = new Request(url, { - method, - headers, - body: body !== undefined ? JSON.stringify(body) : undefined, - }); - return { request, env }; -} - -test("auth/nonce is rate limited per IP (memory mode)", async () => { - const env = { SESSION_SECRET: "test-secret" }; - const address = "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY"; - - const originalNow = Date.now; - Date.now = () => 0; - try { - for (let i = 0; i < 20; i++) { - const res = await noncePost( - makeContext({ - url: "https://local.test/api/auth/nonce", - method: "POST", - env, - body: { address }, - headers: { "x-forwarded-for": "203.0.113.9" }, - }), - ); - assert.equal(res.status, 200); - } - - const limited = await noncePost( - makeContext({ - url: "https://local.test/api/auth/nonce", - method: "POST", - env, - body: { address }, - headers: { "x-forwarded-for": "203.0.113.9" }, - }), - ); - assert.equal(limited.status, 429); - const json = await limited.json(); - assert.equal(json.error?.retryAfterSeconds, 60); - } finally { - Date.now = originalNow; - } -}); - -test("auth/verify rejects expired nonce cookie", async () => { - const env = { SESSION_SECRET: "test-secret", DEV_BYPASS_SIGNATURE: "true" }; - const address = "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY"; - - const originalNow = Date.now; - Date.now = () => 0; - try { - const nonceRes = await noncePost( - makeContext({ - url: "https://local.test/api/auth/nonce", - method: "POST", - env, - body: { address }, - }), - ); - assert.equal(nonceRes.status, 200); - const nonceJson = await nonceRes.json(); - const nonceCookie = cookiePair(getSetCookies(nonceRes)[0]); - - Date.now = () => 10 * 60_000 + 1; - const verifyRes = await verifyPost( - makeContext({ - url: "https://local.test/api/auth/verify", - method: "POST", - env: { ...env, DEV_BYPASS_GATE: "true" }, - body: { address, nonce: nonceJson.nonce, signature: "0xsig" }, - cookie: nonceCookie, - }), - ); - assert.equal(verifyRes.status, 401); - } finally { - Date.now = originalNow; - } -}); diff --git a/tests/api-auth-signature.test.js b/tests/api-auth-signature.test.js deleted file mode 100644 index 3d97200..0000000 --- a/tests/api-auth-signature.test.js +++ /dev/null @@ -1,85 +0,0 @@ -import assert from "node:assert/strict"; -import { test } from "node:test"; - -import { Keyring } from "@polkadot/keyring"; -import { cryptoWaitReady } from "@polkadot/util-crypto"; -import { u8aToHex } from "@polkadot/util"; - -import { onRequestPost as noncePost } from "../api/routes/auth/nonce.ts"; -import { onRequestPost as verifyPost } from "../api/routes/auth/verify.ts"; - -function getSetCookies(response) { - const maybe = response.headers.getSetCookie?.bind(response.headers); - if (maybe) return maybe(); - const single = response.headers.get("set-cookie"); - return single ? [single] : []; -} - -function cookiePair(setCookieValue) { - return setCookieValue.split(";")[0]; -} - -function makeContext({ url, method, env, body, cookie }) { - const headers = new Headers(); - if (cookie) headers.set("cookie", cookie); - if (body !== undefined) headers.set("content-type", "application/json"); - const request = new Request(url, { - method, - headers, - body: body !== undefined ? JSON.stringify(body) : undefined, - }); - return { request, env }; -} - -test("auth/verify: valid Substrate signature succeeds and nonce is single-use", async () => { - await cryptoWaitReady(); - const keyring = new Keyring({ type: "sr25519" }); - const pair = keyring.addFromUri("//Alice"); - - const env = { SESSION_SECRET: "test-secret", DEV_BYPASS_GATE: "true" }; - - const nonceRes = await noncePost( - makeContext({ - url: "https://local.test/api/auth/nonce", - method: "POST", - env, - body: { address: pair.address }, - }), - ); - assert.equal(nonceRes.status, 200); - const nonceJson = await nonceRes.json(); - const nonceCookie = cookiePair(getSetCookies(nonceRes)[0]); - - const messageBytes = new TextEncoder().encode(nonceJson.nonce); - const signatureHex = u8aToHex(pair.sign(messageBytes)); - - const verifyRes = await verifyPost( - makeContext({ - url: "https://local.test/api/auth/verify", - method: "POST", - env, - body: { - address: pair.address, - nonce: nonceJson.nonce, - signature: signatureHex, - }, - cookie: nonceCookie, - }), - ); - assert.equal(verifyRes.status, 200); - - const verifyRes2 = await verifyPost( - makeContext({ - url: "https://local.test/api/auth/verify", - method: "POST", - env, - body: { - address: pair.address, - nonce: nonceJson.nonce, - signature: signatureHex, - }, - cookie: nonceCookie, - }), - ); - assert.equal(verifyRes2.status, 401); -}); diff --git a/tests/api-auth.test.js b/tests/api-auth.test.js deleted file mode 100644 index 7a01155..0000000 --- a/tests/api-auth.test.js +++ /dev/null @@ -1,129 +0,0 @@ -import assert from "node:assert/strict"; -import { test } from "node:test"; - -import { onRequestGet as meGet } from "../api/routes/me.ts"; -import { onRequestPost as noncePost } from "../api/routes/auth/nonce.ts"; -import { onRequestPost as logoutPost } from "../api/routes/auth/logout.ts"; -import { onRequestPost as verifyPost } from "../api/routes/auth/verify.ts"; -import { canonicalizeHmndAddress } from "../api/_lib/address.ts"; - -function getSetCookies(response) { - // Node fetch supports getSetCookie(), but runtime-specific headers may not. - const maybe = response.headers.getSetCookie?.bind(response.headers); - if (maybe) return maybe(); - const single = response.headers.get("set-cookie"); - return single ? [single] : []; -} - -function cookiePair(setCookieValue) { - return setCookieValue.split(";")[0]; -} - -function makeContext({ url, method, env, body, cookie }) { - const headers = new Headers(); - if (cookie) headers.set("cookie", cookie); - if (body !== undefined) headers.set("content-type", "application/json"); - const request = new Request(url, { - method, - headers, - body: body !== undefined ? JSON.stringify(body) : undefined, - }); - return { request, env }; -} - -test("auth flow: nonce -> verify (bypass) -> me -> logout", async () => { - const baseEnv = { SESSION_SECRET: "test-secret" }; - const address = "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY"; - - const nonceCtx = makeContext({ - url: "https://local.test/api/auth/nonce", - method: "POST", - env: baseEnv, - body: { address }, - }); - const nonceRes = await noncePost(nonceCtx); - assert.equal(nonceRes.status, 200); - const nonceJson = await nonceRes.json(); - assert.equal(typeof nonceJson.nonce, "string"); - assert.ok(nonceJson.nonce.length >= 8); - - const [nonceSetCookie] = getSetCookies(nonceRes); - assert.ok(nonceSetCookie?.startsWith("vortex_nonce=")); - const nonceCookie = cookiePair(nonceSetCookie); - - const verifyCtx = makeContext({ - url: "https://local.test/api/auth/verify", - method: "POST", - env: { ...baseEnv, DEV_BYPASS_SIGNATURE: "true", DEV_BYPASS_GATE: "true" }, - body: { address, nonce: nonceJson.nonce, signature: "0xsig" }, - cookie: nonceCookie, - }); - const verifyRes = await verifyPost(verifyCtx); - assert.equal(verifyRes.status, 200); - const [sessionSetCookie] = getSetCookies(verifyRes); - assert.ok(sessionSetCookie?.startsWith("vortex_session=")); - const sessionCookie = cookiePair(sessionSetCookie); - - const meCtx = makeContext({ - url: "https://local.test/api/me", - method: "GET", - env: { ...baseEnv, DEV_BYPASS_GATE: "true" }, - cookie: sessionCookie, - }); - const meRes = await meGet(meCtx); - assert.equal(meRes.status, 200); - const meJson = await meRes.json(); - assert.equal(meJson.authenticated, true); - assert.equal(meJson.address, await canonicalizeHmndAddress(address)); - assert.equal(meJson.gate.eligible, true); - - const logoutCtx = makeContext({ - url: "https://local.test/api/auth/logout", - method: "POST", - env: { ...baseEnv }, - cookie: sessionCookie, - }); - const logoutRes = await logoutPost(logoutCtx); - assert.equal(logoutRes.status, 200); - const [logoutCookie] = getSetCookies(logoutRes); - assert.ok(logoutCookie?.startsWith("vortex_session=")); - assert.match(logoutCookie, /Max-Age=0/); -}); - -test("auth/nonce rejects missing address", async () => { - const ctx = makeContext({ - url: "https://local.test/api/auth/nonce", - method: "POST", - env: { SESSION_SECRET: "test-secret" }, - body: {}, - }); - const res = await noncePost(ctx); - assert.equal(res.status, 400); -}); - -test("auth/verify rejects invalid signature (no bypass)", async () => { - const env = { SESSION_SECRET: "test-secret" }; - const address = "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY"; - - const nonceRes = await noncePost( - makeContext({ - url: "https://local.test/api/auth/nonce", - method: "POST", - env, - body: { address }, - }), - ); - const nonceJson = await nonceRes.json(); - const nonceCookie = cookiePair(getSetCookies(nonceRes)[0]); - - const verifyRes = await verifyPost( - makeContext({ - url: "https://local.test/api/auth/verify", - method: "POST", - env, - body: { address, nonce: nonceJson.nonce, signature: "0xsig" }, - cookie: nonceCookie, - }), - ); - assert.equal(verifyRes.status, 401); -}); diff --git a/tests/api-chamber-detail-projection.test.js b/tests/api-chamber-detail-projection.test.js deleted file mode 100644 index 39803ff..0000000 --- a/tests/api-chamber-detail-projection.test.js +++ /dev/null @@ -1,161 +0,0 @@ -import assert from "node:assert/strict"; -import { test } from "node:test"; - -import { onRequestGet as chamberGet } from "../api/routes/chambers/[id].ts"; -import { - clearChamberMembershipsForTests, - ensureChamberMembership, -} from "../api/_lib/chamberMembershipsStore.ts"; -import { clearChambersForTests } from "../api/_lib/chambersStore.ts"; -import { clearInlineReadModelsForTests } from "../api/_lib/readModelsStore.ts"; -import { - clearProposalsForTests, - createProposal, -} from "../api/_lib/proposalsStore.ts"; - -function makeContext({ url, env, params, method = "GET", headers, body }) { - return { - request: new Request(url, { method, headers, body }), - env, - params, - }; -} - -function baseEnv(overrides = {}) { - return { - READ_MODELS_INLINE: "true", - DEV_BYPASS_ADMIN: "true", - SIM_CONFIG_JSON: JSON.stringify({ - genesisChamberMembers: { - engineering: ["5GenesisEng"], - marketing: ["5GenesisMkt"], - }, - genesisChambers: [ - { id: "general", title: "General", multiplier: 1.2 }, - { id: "engineering", title: "Engineering", multiplier: 1.5 }, - { id: "marketing", title: "Marketing", multiplier: 1.1 }, - ], - }), - ...overrides, - }; -} - -test("GET /api/chambers/:id projects proposals and roster from canonical stores", async () => { - clearProposalsForTests(); - clearChamberMembershipsForTests(); - clearChambersForTests(); - clearInlineReadModelsForTests(); - - const env = baseEnv(); - - await ensureChamberMembership(env, { - address: "5MemberEng", - chamberId: "engineering", - source: "accepted_proposal", - }); - await ensureChamberMembership(env, { - address: "5MemberProd", - chamberId: "product", - source: "accepted_proposal", - }); - - await createProposal(env, { - id: "eng-pool", - stage: "pool", - authorAddress: "5Author", - title: "Eng pool proposal", - chamberId: "engineering", - summary: "Pool summary", - payload: {}, - }); - await createProposal(env, { - id: "eng-vote", - stage: "vote", - authorAddress: "5Author", - title: "Eng vote proposal", - chamberId: "engineering", - summary: "Vote summary", - payload: {}, - }); - await createProposal(env, { - id: "eng-build-formation", - stage: "build", - authorAddress: "5Author", - title: "Eng build formation proposal", - chamberId: "engineering", - summary: "Build summary", - payload: { formationEligible: true }, - }); - await createProposal(env, { - id: "eng-build-passed", - stage: "build", - authorAddress: "5Author", - title: "Eng build passed proposal", - chamberId: "engineering", - summary: "Build passed summary", - payload: { formationEligible: false }, - }); - - const engineeringRes = await chamberGet( - makeContext({ - url: "https://local.test/api/chambers/engineering", - env, - params: { id: "engineering" }, - }), - ); - assert.equal(engineeringRes.status, 200); - const engineeringJson = await engineeringRes.json(); - - const engGovernorIds = engineeringJson.governors.map((g) => g.id).sort(); - assert.deepEqual(engGovernorIds, ["5GenesisEng", "5MemberEng"]); - - const engProposals = engineeringJson.proposals; - assert.ok( - engProposals.some( - (p) => - p.id === "eng-pool" && - p.stage === "upcoming" && - p.meta === "Proposal pool", - ), - ); - assert.ok( - engProposals.some( - (p) => - p.id === "eng-vote" && p.stage === "live" && p.meta === "Chamber vote", - ), - ); - assert.ok( - engProposals.some( - (p) => - p.id === "eng-build-formation" && - p.stage === "ended" && - p.meta === "Formation", - ), - ); - assert.ok( - engProposals.some( - (p) => - p.id === "eng-build-passed" && - p.stage === "ended" && - p.meta === "Passed", - ), - ); - - const generalRes = await chamberGet( - makeContext({ - url: "https://local.test/api/chambers/general", - env, - params: { id: "general" }, - }), - ); - assert.equal(generalRes.status, 200); - const generalJson = await generalRes.json(); - - const generalGovernorIds = generalJson.governors.map((g) => g.id).sort(); - assert.deepEqual(generalGovernorIds, [ - "5GenesisEng", - "5GenesisMkt", - "5MemberEng", - "5MemberProd", - ]); -}); diff --git a/tests/api-chamber-dissolution.test.js b/tests/api-chamber-dissolution.test.js deleted file mode 100644 index 28f3003..0000000 --- a/tests/api-chamber-dissolution.test.js +++ /dev/null @@ -1,328 +0,0 @@ -import assert from "node:assert/strict"; -import { test } from "node:test"; - -import { onRequestPost as commandPost } from "../api/routes/command.ts"; -import { onRequestPost as tickPost } from "../api/routes/clock/tick.ts"; -import { getSessionCookieName, issueSession } from "../api/_lib/auth.ts"; -import { clearChamberVotesForTests } from "../api/_lib/chamberVotesStore.ts"; -import { clearChamberMembershipsForTests } from "../api/_lib/chamberMembershipsStore.ts"; -import { clearChambersForTests } from "../api/_lib/chambersStore.ts"; -import { clearCmAwardsForTests } from "../api/_lib/cmAwardsStore.ts"; -import { clearIdempotencyForTests } from "../api/_lib/idempotencyStore.ts"; -import { clearProposalDraftsForTests } from "../api/_lib/proposalDraftsStore.ts"; -import { - clearProposalsForTests, - createProposal, - getProposal, -} from "../api/_lib/proposalsStore.ts"; -import { clearInlineReadModelsForTests } from "../api/_lib/readModelsStore.ts"; -import { getChamber } from "../api/_lib/chambersStore.ts"; - -function makeContext({ url, env, params, method = "POST", headers, body }) { - return { - request: new Request(url, { method, headers, body }), - env, - params, - }; -} - -async function finalizeIfPendingVeto(env, proposalId) { - const proposal = await getProposal(env, proposalId); - if (!proposal?.voteFinalizesAt) return; - const envAfter = { - ...env, - SIM_NOW_ISO: new Date( - proposal.voteFinalizesAt.getTime() + 1000, - ).toISOString(), - }; - const res = await tickPost( - makeContext({ - url: "https://local.test/api/clock/tick", - env: envAfter, - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ rollup: false }), - }), - ); - assert.equal(res.status, 200); -} - -async function makeSessionCookie(env, address) { - const headers = new Headers(); - await issueSession(headers, env, "https://local.test/api/command", address); - const setCookie = headers.get("set-cookie"); - assert.ok(setCookie); - const tokenPair = setCookie.split(";")[0]; - const [name, value] = tokenPair.split("="); - assert.equal(name, getSessionCookieName()); - return `${name}=${value}`; -} - -function baseEnv(overrides = {}) { - const genesisVoter = "5GenesisEng"; - return { - SESSION_SECRET: "test-secret", - DEV_BYPASS_GATE: "true", - DEV_INSECURE_COOKIES: "true", - READ_MODELS_INLINE: "true", - DEV_BYPASS_ADMIN: "true", - SIM_ACTIVE_GOVERNORS: "3", - SIM_CONFIG_JSON: JSON.stringify({ - genesisChamberMembers: { engineering: [genesisVoter] }, - genesisChambers: [ - { id: "general", title: "General", multiplier: 1.2 }, - { id: "engineering", title: "Engineering", multiplier: 1.5 }, - ], - }), - ...overrides, - }; -} - -function makeDraftForm({ title, chamberId }) { - return { - title, - chamberId, - summary: "Short summary.", - what: "What", - why: "Why", - how: "How", - timeline: [{ id: "ms-1", title: "Milestone 1", timeframe: "2 weeks" }], - outputs: [], - budgetItems: [{ id: "b-1", description: "Work", amount: "1000" }], - aboutMe: "", - attachments: [], - agreeRules: true, - confirmBudget: true, - }; -} - -test("cannot submit new proposals to a dissolved chamber", async () => { - clearIdempotencyForTests(); - clearProposalDraftsForTests(); - clearProposalsForTests(); - await clearChamberVotesForTests(); - clearChamberMembershipsForTests(); - clearChambersForTests(); - clearInlineReadModelsForTests(); - await clearCmAwardsForTests(); - - const env = baseEnv(); - const voterCookie = await makeSessionCookie(env, "5GenesisEng"); - - await createProposal(env, { - id: "general-dissolve", - stage: "vote", - authorAddress: "5Creator", - title: "Dissolve engineering", - chamberId: "general", - summary: "Dissolve Engineering", - payload: { - title: "Dissolve engineering", - timeline: [], - budgetItems: [], - metaGovernance: { action: "chamber.dissolve", chamberId: "engineering" }, - }, - }); - - const dissolveVote = await commandPost( - makeContext({ - url: "https://local.test/api/command", - env, - headers: { "content-type": "application/json", cookie: voterCookie }, - body: JSON.stringify({ - type: "chamber.vote", - payload: { proposalId: "general-dissolve", choice: "yes", score: 8 }, - }), - }), - ); - assert.equal(dissolveVote.status, 200); - await finalizeIfPendingVeto(env, "general-dissolve"); - - const proposerCookie = await makeSessionCookie(env, "5Proposer"); - const saveRes = await commandPost( - makeContext({ - url: "https://local.test/api/command", - env, - headers: { "content-type": "application/json", cookie: proposerCookie }, - body: JSON.stringify({ - type: "proposal.draft.save", - payload: { - form: makeDraftForm({ - title: "New engineering proposal after dissolution", - chamberId: "engineering", - }), - }, - }), - }), - ); - assert.equal(saveRes.status, 200); - const saveJson = await saveRes.json(); - - const submitRes = await commandPost( - makeContext({ - url: "https://local.test/api/command", - env, - headers: { "content-type": "application/json", cookie: proposerCookie }, - body: JSON.stringify({ - type: "proposal.submitToPool", - payload: { draftId: saveJson.draftId }, - }), - }), - ); - assert.equal(submitRes.status, 409); - const submitJson = await submitRes.json(); - assert.equal(submitJson.error?.code, "chamber_dissolved"); -}); - -test("dissolved chambers still allow voting on proposals created before dissolution", async () => { - clearIdempotencyForTests(); - clearProposalDraftsForTests(); - clearProposalsForTests(); - await clearChamberVotesForTests(); - clearChamberMembershipsForTests(); - clearChambersForTests(); - clearInlineReadModelsForTests(); - await clearCmAwardsForTests(); - - const env = baseEnv(); - const voterCookie = await makeSessionCookie(env, "5GenesisEng"); - - await createProposal(env, { - id: "eng-pre-dissolve", - stage: "vote", - authorAddress: "5Creator", - title: "Engineering proposal before dissolution", - chamberId: "engineering", - summary: "Old proposal", - payload: { title: "Engineering proposal before dissolution" }, - }); - - await createProposal(env, { - id: "general-dissolve", - stage: "vote", - authorAddress: "5Creator", - title: "Dissolve engineering", - chamberId: "general", - summary: "Dissolve Engineering", - payload: { - title: "Dissolve engineering", - timeline: [], - budgetItems: [], - metaGovernance: { action: "chamber.dissolve", chamberId: "engineering" }, - }, - }); - - const dissolveVote = await commandPost( - makeContext({ - url: "https://local.test/api/command", - env, - headers: { "content-type": "application/json", cookie: voterCookie }, - body: JSON.stringify({ - type: "chamber.vote", - payload: { proposalId: "general-dissolve", choice: "yes", score: 8 }, - }), - }), - ); - assert.equal(dissolveVote.status, 200); - await finalizeIfPendingVeto(env, "general-dissolve"); - - const chamber = await getChamber( - env, - "https://local.test/api/command", - "engineering", - ); - assert.equal(chamber?.status, "dissolved"); - - const voteRes = await commandPost( - makeContext({ - url: "https://local.test/api/command", - env, - headers: { "content-type": "application/json", cookie: voterCookie }, - body: JSON.stringify({ - type: "chamber.vote", - payload: { proposalId: "eng-pre-dissolve", choice: "yes", score: 7 }, - }), - }), - ); - assert.equal(voteRes.status, 200); -}); - -test("dissolved chambers reject voting on proposals created after dissolution", async () => { - clearIdempotencyForTests(); - clearProposalDraftsForTests(); - clearProposalsForTests(); - await clearChamberVotesForTests(); - clearChamberMembershipsForTests(); - clearChambersForTests(); - clearInlineReadModelsForTests(); - await clearCmAwardsForTests(); - - const env = baseEnv(); - const voterCookie = await makeSessionCookie(env, "5GenesisEng"); - - await createProposal(env, { - id: "general-dissolve", - stage: "vote", - authorAddress: "5Creator", - title: "Dissolve engineering", - chamberId: "general", - summary: "Dissolve Engineering", - payload: { - title: "Dissolve engineering", - timeline: [], - budgetItems: [], - metaGovernance: { action: "chamber.dissolve", chamberId: "engineering" }, - }, - }); - - const dissolveVote = await commandPost( - makeContext({ - url: "https://local.test/api/command", - env, - headers: { "content-type": "application/json", cookie: voterCookie }, - body: JSON.stringify({ - type: "chamber.vote", - payload: { proposalId: "general-dissolve", choice: "yes", score: 8 }, - }), - }), - ); - assert.equal(dissolveVote.status, 200); - await finalizeIfPendingVeto(env, "general-dissolve"); - - const chamber = await getChamber( - env, - "https://local.test/api/command", - "engineering", - ); - assert.equal(chamber?.status, "dissolved"); - assert.ok(chamber?.dissolvedAt); - - while (Date.now() <= chamber.dissolvedAt.getTime()) { - await new Promise((r) => setTimeout(r, 1)); - } - - await createProposal(env, { - id: "eng-post-dissolve", - stage: "vote", - authorAddress: "5Creator", - title: "Engineering proposal after dissolution", - chamberId: "engineering", - summary: "New proposal", - payload: { title: "Engineering proposal after dissolution" }, - }); - - const voteRes = await commandPost( - makeContext({ - url: "https://local.test/api/command", - env, - headers: { "content-type": "application/json", cookie: voterCookie }, - body: JSON.stringify({ - type: "chamber.vote", - payload: { proposalId: "eng-post-dissolve", choice: "yes", score: 7 }, - }), - }), - ); - assert.equal(voteRes.status, 409); - const voteJson = await voteRes.json(); - assert.equal(voteJson.error?.code, "chamber_dissolved"); -}); diff --git a/tests/api-chamber-eligibility.test.js b/tests/api-chamber-eligibility.test.js deleted file mode 100644 index e33204a..0000000 --- a/tests/api-chamber-eligibility.test.js +++ /dev/null @@ -1,282 +0,0 @@ -import assert from "node:assert/strict"; -import { test } from "node:test"; - -import { onRequestPost as commandPost } from "../api/routes/command.ts"; -import { getSessionCookieName, issueSession } from "../api/_lib/auth.ts"; -import { clearIdempotencyForTests } from "../api/_lib/idempotencyStore.ts"; -import { clearProposalDraftsForTests } from "../api/_lib/proposalDraftsStore.ts"; -import { - clearProposalsForTests, - createProposal, - getProposal, - transitionProposalStage, -} from "../api/_lib/proposalsStore.ts"; -import { clearChamberVotesForTests } from "../api/_lib/chamberVotesStore.ts"; -import { clearEraForTests } from "../api/_lib/eraStore.ts"; -import { clearInlineReadModelsForTests } from "../api/_lib/readModelsStore.ts"; -import { - clearChamberMembershipsForTests, - ensureChamberMembership, - hasChamberMembership, -} from "../api/_lib/chamberMembershipsStore.ts"; - -function makeContext({ url, env, params, method = "POST", headers, body }) { - return { - request: new Request(url, { method, headers, body }), - env, - params, - }; -} - -async function makeSessionCookie(env, address) { - const headers = new Headers(); - await issueSession(headers, env, "https://local.test/api/command", address); - const setCookie = headers.get("set-cookie"); - assert.ok(setCookie); - const tokenPair = setCookie.split(";")[0]; - const [name, value] = tokenPair.split("="); - assert.equal(name, getSessionCookieName()); - return `${name}=${value}`; -} - -function baseEnv(overrides = {}) { - return { - SESSION_SECRET: "test-secret", - DEV_BYPASS_GATE: "true", - DEV_INSECURE_COOKIES: "true", - READ_MODELS_INLINE: "true", - DEV_BYPASS_ADMIN: "true", - SIM_ACTIVE_GOVERNORS: "3", - SIM_CONFIG_JSON: JSON.stringify({ - genesisChambers: [ - { id: "general", title: "General", multiplier: 1.2 }, - { id: "engineering", title: "Engineering", multiplier: 1.5 }, - { id: "economics", title: "Economics", multiplier: 1.3 }, - ], - genesisChamberMembers: {}, - }), - ...overrides, - }; -} - -function makeDraftForm({ title, chamberId }) { - return { - title, - chamberId, - summary: "Short summary for the draft.", - what: "What", - why: "Why", - how: "How", - timeline: [{ id: "ms-1", title: "Milestone 1", timeframe: "2 weeks" }], - outputs: [], - budgetItems: [{ id: "b-1", description: "Work", amount: "1000" }], - aboutMe: "", - attachments: [], - agreeRules: true, - confirmBudget: true, - }; -} - -test("chamber voting is restricted by chamber membership and membership is granted on acceptance", async () => { - clearIdempotencyForTests(); - clearProposalDraftsForTests(); - clearProposalsForTests(); - await clearChamberVotesForTests(); - clearChamberMembershipsForTests(); - clearEraForTests(); - clearInlineReadModelsForTests(); - - const env = baseEnv(); - - const proposerAddress = "5Author"; - const proposerCookie = await makeSessionCookie(env, proposerAddress); - - const saveRes = await commandPost( - makeContext({ - url: "https://local.test/api/command", - env, - headers: { "content-type": "application/json", cookie: proposerCookie }, - body: JSON.stringify({ - type: "proposal.draft.save", - payload: { - form: makeDraftForm({ - title: "Engineering acceptance grants membership", - chamberId: "engineering", - }), - }, - }), - }), - ); - assert.equal(saveRes.status, 200); - const saveJson = await saveRes.json(); - - const submitRes = await commandPost( - makeContext({ - url: "https://local.test/api/command", - env, - headers: { "content-type": "application/json", cookie: proposerCookie }, - body: JSON.stringify({ - type: "proposal.submitToPool", - payload: { draftId: saveJson.draftId }, - }), - }), - ); - assert.equal(submitRes.status, 200); - const submitJson = await submitRes.json(); - - const moved = await transitionProposalStage(env, { - proposalId: submitJson.proposalId, - from: "pool", - to: "vote", - }); - assert.equal(moved, true); - - const genesisVoter = "5GenesisEng"; - await ensureChamberMembership(env, { - address: genesisVoter, - chamberId: "engineering", - source: "genesis", - }); - - const genesisCookie = await makeSessionCookie(env, genesisVoter); - const voteRes = await commandPost( - makeContext({ - url: "https://local.test/api/command", - env, - headers: { "content-type": "application/json", cookie: genesisCookie }, - body: JSON.stringify({ - type: "chamber.vote", - payload: { proposalId: submitJson.proposalId, choice: "yes", score: 8 }, - }), - }), - ); - assert.equal(voteRes.status, 200); - - const accepted = await getProposal(env, submitJson.proposalId); - assert.ok(accepted); - assert.equal(accepted.stage, "build"); - - assert.equal( - await hasChamberMembership(env, { - address: proposerAddress, - chamberId: "engineering", - }), - true, - ); - assert.equal( - await hasChamberMembership(env, { - address: proposerAddress, - chamberId: "general", - }), - true, - ); - - const p2 = await createProposal(env, { - id: "p2-engineering", - stage: "vote", - authorAddress: "5SomeoneElse", - title: "Another engineering proposal", - chamberId: "engineering", - summary: "Summary", - payload: {}, - }); - assert.equal(p2.stage, "vote"); - - const voteAllowed = await commandPost( - makeContext({ - url: "https://local.test/api/command", - env, - headers: { "content-type": "application/json", cookie: proposerCookie }, - body: JSON.stringify({ - type: "chamber.vote", - payload: { proposalId: p2.id, choice: "yes", score: 8 }, - }), - }), - ); - assert.equal(voteAllowed.status, 200); - - const p3 = await createProposal(env, { - id: "p3-economics", - stage: "vote", - authorAddress: "5SomeoneElse", - title: "Economics proposal", - chamberId: "economics", - summary: "Summary", - payload: {}, - }); - - const voteDenied = await commandPost( - makeContext({ - url: "https://local.test/api/command", - env, - headers: { "content-type": "application/json", cookie: proposerCookie }, - body: JSON.stringify({ - type: "chamber.vote", - payload: { proposalId: p3.id, choice: "yes", score: 8 }, - }), - }), - ); - assert.equal(voteDenied.status, 403); -}); - -test("genesis chamber memberships from SIM_CONFIG_JSON allow initial chamber voting", async () => { - clearIdempotencyForTests(); - clearProposalDraftsForTests(); - clearProposalsForTests(); - await clearChamberVotesForTests(); - clearChamberMembershipsForTests(); - clearEraForTests(); - clearInlineReadModelsForTests(); - - const genesisVoter = "5GenesisEng"; - const env = baseEnv({ - SIM_CONFIG_JSON: JSON.stringify({ - genesisChambers: [ - { id: "general", title: "General", multiplier: 1.2 }, - { id: "engineering", title: "Engineering", multiplier: 1.5 }, - ], - genesisChamberMembers: { engineering: [genesisVoter] }, - }), - }); - - const proposal = await createProposal(env, { - id: "p-genesis-1", - stage: "vote", - authorAddress: "5SomeoneElse", - title: "Genesis voter can vote without stored membership", - chamberId: "engineering", - summary: "Summary", - payload: {}, - }); - assert.equal(proposal.stage, "vote"); - - assert.equal( - await hasChamberMembership(env, { - address: genesisVoter, - chamberId: "engineering", - }), - false, - ); - - const genesisCookie = await makeSessionCookie(env, genesisVoter); - const voteRes = await commandPost( - makeContext({ - url: "https://local.test/api/command", - env, - headers: { "content-type": "application/json", cookie: genesisCookie }, - body: JSON.stringify({ - type: "chamber.vote", - payload: { proposalId: proposal.id, choice: "yes", score: 8 }, - }), - }), - ); - assert.equal(voteRes.status, 200); - - assert.equal( - await hasChamberMembership(env, { - address: genesisVoter, - chamberId: "engineering", - }), - false, - ); -}); diff --git a/tests/api-chamber-multiplier-voting.test.js b/tests/api-chamber-multiplier-voting.test.js deleted file mode 100644 index fba8428..0000000 --- a/tests/api-chamber-multiplier-voting.test.js +++ /dev/null @@ -1,143 +0,0 @@ -import assert from "node:assert/strict"; -import { test } from "node:test"; - -import { onRequestPost as commandPost } from "../api/routes/command.ts"; -import { onRequestGet as humanGet } from "../api/routes/humans/[id].ts"; -import { getSessionCookieName, issueSession } from "../api/_lib/auth.ts"; -import { clearChambersForTests } from "../api/_lib/chambersStore.ts"; -import { clearChamberMultiplierSubmissionsForTests } from "../api/_lib/chamberMultiplierSubmissionsStore.ts"; -import { - awardCmOnce, - clearCmAwardsForTests, -} from "../api/_lib/cmAwardsStore.ts"; -import { - clearInlineReadModelsForTests, - createReadModelsStore, -} from "../api/_lib/readModelsStore.ts"; - -function makeContext({ url, env, params, method = "POST", headers, body }) { - return { - request: new Request(url, { method, headers, body }), - env, - params, - }; -} - -async function makeSessionCookie(env, address) { - const headers = new Headers(); - await issueSession(headers, env, "https://local.test/api/command", address); - const setCookie = headers.get("set-cookie"); - assert.ok(setCookie); - const tokenPair = setCookie.split(";")[0]; - const [name, value] = tokenPair.split("="); - assert.equal(name, getSessionCookieName()); - return `${name}=${value}`; -} - -function baseEnv(overrides = {}) { - return { - SESSION_SECRET: "test-secret", - DEV_BYPASS_GATE: "true", - DEV_INSECURE_COOKIES: "true", - READ_MODELS_INLINE_EMPTY: "true", - DEV_BYPASS_ADMIN: "true", - DEV_BYPASS_CHAMBER_ELIGIBILITY: "true", - SIM_CONFIG_JSON: JSON.stringify({ - genesisChamberMembers: { marketing: ["5Outsider"], general: ["5Alice"] }, - genesisChambers: [ - { id: "general", title: "General", multiplier: 1.2 }, - { id: "engineering", title: "Engineering", multiplier: 1.5 }, - { id: "marketing", title: "Marketing", multiplier: 1.1 }, - ], - }), - ...overrides, - }; -} - -test("multiplier voting is outsiders-only and affects ACM view without rewriting awards", async () => { - clearChambersForTests(); - clearChamberMultiplierSubmissionsForTests(); - await clearCmAwardsForTests(); - clearInlineReadModelsForTests(); - - const env = baseEnv(); - - const store = await createReadModelsStore(env); - await store.set("humans:5Alice", { - id: "5Alice", - name: "Alice", - heroStats: [{ label: "ACM", value: "0" }], - }); - - await awardCmOnce(env, { - proposalId: "award-1", - proposerId: "5Alice", - chamberId: "engineering", - avgScore: 10, - lcmPoints: 100, - chamberMultiplierTimes10: 15, - mcmPoints: 150, - }); - - const beforeRes = await humanGet( - makeContext({ - url: "https://local.test/api/humans/5Alice", - env, - params: { id: "5Alice" }, - method: "GET", - }), - ); - assert.equal(beforeRes.status, 200); - const beforeJson = await beforeRes.json(); - const beforeAcm = beforeJson.heroStats.find((s) => s.label === "ACM")?.value; - assert.equal(beforeAcm, "150"); - - // A user who has LCM history in a chamber cannot submit a multiplier for that chamber. - const aliceCookie = await makeSessionCookie(env, "5Alice"); - const deniedRes = await commandPost( - makeContext({ - url: "https://local.test/api/command", - env, - headers: { "content-type": "application/json", cookie: aliceCookie }, - body: JSON.stringify({ - type: "chamber.multiplier.submit", - payload: { chamberId: "engineering", multiplierTimes10: 10 }, - }), - }), - ); - assert.equal(deniedRes.status, 400); - const deniedJson = await deniedRes.json(); - assert.equal(deniedJson.error?.code, "multiplier_outsider_required"); - - // An outsider governor can submit and update the canonical chamber multiplier. - const outsiderCookie = await makeSessionCookie(env, "5Outsider"); - const submitRes = await commandPost( - makeContext({ - url: "https://local.test/api/command", - env, - headers: { "content-type": "application/json", cookie: outsiderCookie }, - body: JSON.stringify({ - type: "chamber.multiplier.submit", - payload: { chamberId: "engineering", multiplierTimes10: 10 }, - }), - }), - ); - assert.equal(submitRes.status, 200); - const submitJson = await submitRes.json(); - assert.equal(submitJson.chamberId, "engineering"); - assert.equal(submitJson.aggregate.avgTimes10, 10); - assert.equal(submitJson.applied?.nextMultiplierTimes10, 10); - - const afterRes = await humanGet( - makeContext({ - url: "https://local.test/api/humans/5Alice", - env, - params: { id: "5Alice" }, - method: "GET", - }), - ); - assert.equal(afterRes.status, 200); - const afterJson = await afterRes.json(); - const afterAcm = afterJson.heroStats.find((s) => s.label === "ACM")?.value; - assert.equal(afterAcm, "100"); -}); diff --git a/tests/api-chambers-index-projection.test.js b/tests/api-chambers-index-projection.test.js deleted file mode 100644 index cf74750..0000000 --- a/tests/api-chambers-index-projection.test.js +++ /dev/null @@ -1,196 +0,0 @@ -import assert from "node:assert/strict"; -import { test } from "node:test"; - -import { onRequestGet as chambersGet } from "../api/routes/chambers/index.ts"; -import { onRequestPost as commandPost } from "../api/routes/command.ts"; -import { getSessionCookieName, issueSession } from "../api/_lib/auth.ts"; -import { - clearCmAwardsForTests, - awardCmOnce, -} from "../api/_lib/cmAwardsStore.ts"; -import { - clearChamberMembershipsForTests, - ensureChamberMembership, -} from "../api/_lib/chamberMembershipsStore.ts"; -import { clearChamberVotesForTests } from "../api/_lib/chamberVotesStore.ts"; -import { clearChambersForTests } from "../api/_lib/chambersStore.ts"; -import { clearIdempotencyForTests } from "../api/_lib/idempotencyStore.ts"; -import { clearInlineReadModelsForTests } from "../api/_lib/readModelsStore.ts"; -import { - clearProposalsForTests, - createProposal, -} from "../api/_lib/proposalsStore.ts"; - -function makeContext({ url, env, params, method = "GET", headers, body }) { - return { - request: new Request(url, { method, headers, body }), - env, - params, - }; -} - -async function makeSessionCookie(env, address) { - const headers = new Headers(); - await issueSession(headers, env, "https://local.test/api/command", address); - const setCookie = headers.get("set-cookie"); - assert.ok(setCookie); - const tokenPair = setCookie.split(";")[0]; - const [name, value] = tokenPair.split("="); - assert.equal(name, getSessionCookieName()); - return `${name}=${value}`; -} - -function baseEnv(overrides = {}) { - return { - SESSION_SECRET: "test-secret", - DEV_BYPASS_GATE: "true", - DEV_INSECURE_COOKIES: "true", - READ_MODELS_INLINE: "true", - DEV_BYPASS_ADMIN: "true", - SIM_ACTIVE_GOVERNORS: "3", - SIM_CONFIG_JSON: JSON.stringify({ - genesisChamberMembers: { - engineering: ["5GenesisEng"], - marketing: ["5GenesisMkt"], - }, - genesisChambers: [ - { id: "general", title: "General", multiplier: 1.2 }, - { id: "engineering", title: "Engineering", multiplier: 1.5 }, - { id: "marketing", title: "Marketing", multiplier: 1.1 }, - ], - }), - ...overrides, - }; -} - -test("GET /api/chambers projects pipeline + stats from canonical stores (inline mode)", async () => { - clearIdempotencyForTests(); - clearProposalsForTests(); - clearChambersForTests(); - clearChamberMembershipsForTests(); - await clearChamberVotesForTests(); - await clearCmAwardsForTests(); - clearInlineReadModelsForTests(); - - const env = baseEnv(); - - await ensureChamberMembership(env, { - address: "5MemberEng", - chamberId: "engineering", - source: "accepted_proposal", - }); - - await createProposal(env, { - id: "eng-pool", - stage: "pool", - authorAddress: "5Author", - title: "Eng pool proposal", - chamberId: "engineering", - summary: "Pool summary", - payload: {}, - }); - await createProposal(env, { - id: "eng-vote", - stage: "vote", - authorAddress: "5Author", - title: "Eng vote proposal", - chamberId: "engineering", - summary: "Vote summary", - payload: {}, - }); - await createProposal(env, { - id: "gen-vote", - stage: "vote", - authorAddress: "5Author", - title: "General vote proposal", - chamberId: "general", - summary: "Vote summary", - payload: {}, - }); - - await awardCmOnce(env, { - proposalId: "p-award-1", - proposerId: "5MemberEng", - chamberId: "engineering", - avgScore: 8, - lcmPoints: 80, - chamberMultiplierTimes10: 15, - mcmPoints: 120, - }); - - const res = await chambersGet( - makeContext({ url: "https://local.test/api/chambers", env }), - ); - assert.equal(res.status, 200); - const json = await res.json(); - assert.ok(Array.isArray(json.items)); - - const engineering = json.items.find((c) => c.id === "engineering"); - assert.ok(engineering); - assert.deepEqual(engineering.pipeline, { pool: 1, vote: 1, build: 0 }); - - const general = json.items.find((c) => c.id === "general"); - assert.ok(general); - - // General governors = union of all genesis members + all memberships. - assert.equal(general.stats.governors, "3"); -}); - -test("GET /api/chambers supports includeDissolved=true", async () => { - clearIdempotencyForTests(); - clearProposalsForTests(); - clearChambersForTests(); - clearChamberMembershipsForTests(); - await clearChamberVotesForTests(); - await clearCmAwardsForTests(); - clearInlineReadModelsForTests(); - - const env = baseEnv(); - const voterCookie = await makeSessionCookie(env, "5GenesisEng"); - - await createProposal(env, { - id: "general-dissolve", - stage: "vote", - authorAddress: "5Creator", - title: "Dissolve engineering", - chamberId: "general", - summary: "Dissolve Engineering", - payload: { - title: "Dissolve engineering", - timeline: [], - budgetItems: [], - metaGovernance: { action: "chamber.dissolve", chamberId: "engineering" }, - }, - }); - - const voteDissolve = await commandPost( - makeContext({ - url: "https://local.test/api/command", - env, - method: "POST", - headers: { "content-type": "application/json", cookie: voterCookie }, - body: JSON.stringify({ - type: "chamber.vote", - payload: { proposalId: "general-dissolve", choice: "yes", score: 8 }, - }), - }), - ); - assert.equal(voteDissolve.status, 200); - - const resNo = await chambersGet( - makeContext({ url: "https://local.test/api/chambers", env }), - ); - assert.equal(resNo.status, 200); - const jsonNo = await resNo.json(); - assert.ok(!jsonNo.items.some((c) => c.id === "engineering")); - - const resYes = await chambersGet( - makeContext({ - url: "https://local.test/api/chambers?includeDissolved=true", - env, - }), - ); - assert.equal(resYes.status, 200); - const jsonYes = await resYes.json(); - assert.ok(jsonYes.items.some((c) => c.id === "engineering")); -}); diff --git a/tests/api-chambers-lifecycle.test.js b/tests/api-chambers-lifecycle.test.js deleted file mode 100644 index 4dbea4c..0000000 --- a/tests/api-chambers-lifecycle.test.js +++ /dev/null @@ -1,187 +0,0 @@ -import assert from "node:assert/strict"; -import { test } from "node:test"; - -import { onRequestGet as chambersGet } from "../api/routes/chambers/index.ts"; -import { onRequestGet as chamberGet } from "../api/routes/chambers/[id].ts"; -import { onRequestPost as commandPost } from "../api/routes/command.ts"; -import { onRequestPost as tickPost } from "../api/routes/clock/tick.ts"; -import { getSessionCookieName, issueSession } from "../api/_lib/auth.ts"; -import { clearIdempotencyForTests } from "../api/_lib/idempotencyStore.ts"; -import { - clearProposalsForTests, - createProposal, - getProposal, -} from "../api/_lib/proposalsStore.ts"; -import { clearChamberVotesForTests } from "../api/_lib/chamberVotesStore.ts"; -import { clearChamberMembershipsForTests } from "../api/_lib/chamberMembershipsStore.ts"; -import { clearChambersForTests } from "../api/_lib/chambersStore.ts"; -import { clearInlineReadModelsForTests } from "../api/_lib/readModelsStore.ts"; -import { clearCmAwardsForTests } from "../api/_lib/cmAwardsStore.ts"; - -function makeContext({ url, env, params, method = "GET", headers, body }) { - return { - request: new Request(url, { method, headers, body }), - env, - params, - }; -} - -async function finalizeIfPendingVeto(env, proposalId) { - const proposal = await getProposal(env, proposalId); - if (!proposal?.voteFinalizesAt) return; - const envAfter = { - ...env, - SIM_NOW_ISO: new Date( - proposal.voteFinalizesAt.getTime() + 1000, - ).toISOString(), - }; - const res = await tickPost( - makeContext({ - url: "https://local.test/api/clock/tick", - env: envAfter, - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ rollup: false }), - }), - ); - assert.equal(res.status, 200); -} - -async function makeSessionCookie(env, address) { - const headers = new Headers(); - await issueSession(headers, env, "https://local.test/api/command", address); - const setCookie = headers.get("set-cookie"); - assert.ok(setCookie); - const tokenPair = setCookie.split(";")[0]; - const [name, value] = tokenPair.split("="); - assert.equal(name, getSessionCookieName()); - return `${name}=${value}`; -} - -function baseEnv(overrides = {}) { - return { - SESSION_SECRET: "test-secret", - DEV_BYPASS_GATE: "true", - DEV_INSECURE_COOKIES: "true", - READ_MODELS_INLINE: "true", - DEV_BYPASS_ADMIN: "true", - SIM_ACTIVE_GOVERNORS: "3", - SIM_CONFIG_JSON: JSON.stringify({ - genesisChamberMembers: { engineering: ["5GenesisEng"] }, - genesisChambers: [ - { id: "general", title: "General", multiplier: 1.2 }, - { id: "engineering", title: "Engineering", multiplier: 1.5 }, - ], - }), - ...overrides, - }; -} - -test("accepted General proposal can create and dissolve chambers", async () => { - clearIdempotencyForTests(); - clearProposalsForTests(); - await clearChamberVotesForTests(); - clearChamberMembershipsForTests(); - clearChambersForTests(); - clearInlineReadModelsForTests(); - await clearCmAwardsForTests(); - - const env = baseEnv(); - const voterCookie = await makeSessionCookie(env, "5GenesisEng"); - - await createProposal(env, { - id: "general-create", - stage: "vote", - authorAddress: "5Creator", - title: "Create science chamber", - chamberId: "general", - summary: "Create a new chamber", - payload: { - title: "Create science chamber", - timeline: [], - budgetItems: [], - metaGovernance: { - action: "chamber.create", - chamberId: "science", - title: "Science", - multiplier: 1.7, - }, - }, - }); - - const voteCreate = await commandPost( - makeContext({ - url: "https://local.test/api/command", - env, - method: "POST", - headers: { "content-type": "application/json", cookie: voterCookie }, - body: JSON.stringify({ - type: "chamber.vote", - payload: { proposalId: "general-create", choice: "yes", score: 8 }, - }), - }), - ); - assert.equal(voteCreate.status, 200); - await finalizeIfPendingVeto(env, "general-create"); - const afterCreate = await getProposal(env, "general-create"); - assert.ok(afterCreate); - assert.equal(afterCreate.stage, "build"); - - const listRes1 = await chambersGet( - makeContext({ url: "https://local.test/api/chambers", env }), - ); - assert.equal(listRes1.status, 200); - const listJson1 = await listRes1.json(); - assert.ok(Array.isArray(listJson1.items)); - assert.ok(listJson1.items.some((c) => c.id === "science")); - - await createProposal(env, { - id: "general-dissolve", - stage: "vote", - authorAddress: "5Creator", - title: "Dissolve engineering", - chamberId: "general", - summary: "Dissolve Engineering", - payload: { - title: "Dissolve engineering", - timeline: [], - budgetItems: [], - metaGovernance: { action: "chamber.dissolve", chamberId: "engineering" }, - }, - }); - - const voteDissolve = await commandPost( - makeContext({ - url: "https://local.test/api/command", - env, - method: "POST", - headers: { "content-type": "application/json", cookie: voterCookie }, - body: JSON.stringify({ - type: "chamber.vote", - payload: { proposalId: "general-dissolve", choice: "yes", score: 8 }, - }), - }), - ); - assert.equal(voteDissolve.status, 200); - await finalizeIfPendingVeto(env, "general-dissolve"); - - const listRes2 = await chambersGet( - makeContext({ url: "https://local.test/api/chambers", env }), - ); - assert.equal(listRes2.status, 200); - const listJson2 = await listRes2.json(); - assert.ok(!listJson2.items.some((c) => c.id === "engineering")); - - const detailRes = await chamberGet( - makeContext({ - url: "https://local.test/api/chambers/engineering", - env, - params: { id: "engineering" }, - }), - ); - assert.equal(detailRes.status, 200); - const detailJson = await detailRes.json(); - assert.ok(Array.isArray(detailJson.stageOptions)); - assert.ok(Array.isArray(detailJson.governors)); - assert.ok(detailJson.governors.length > 0); -}); diff --git a/tests/api-clock-tick.test.js b/tests/api-clock-tick.test.js deleted file mode 100644 index 329e2e9..0000000 --- a/tests/api-clock-tick.test.js +++ /dev/null @@ -1,157 +0,0 @@ -import assert from "node:assert/strict"; -import { test } from "node:test"; - -import { onRequestPost as tickPost } from "../api/routes/clock/tick.ts"; -import { clearClockForTests } from "../api/_lib/clockStore.ts"; -import { clearEraForTests } from "../api/_lib/eraStore.ts"; -import { clearEraRollupsForTests } from "../api/_lib/eraRollupStore.ts"; -import { - clearProposalsForTests, - createProposal, -} from "../api/_lib/proposalsStore.ts"; -import { - clearFeedEventsForTests, - listFeedEventsPage, -} from "../api/_lib/eventsStore.ts"; - -function makeContext({ url, env, method = "POST", headers, body }) { - return { - request: new Request(url, { method, headers, body }), - env, - params: {}, - }; -} - -test("clock tick: no advance when not due", async () => { - clearClockForTests(); - clearEraForTests(); - clearEraRollupsForTests(); - - const env = { - READ_MODELS_INLINE: "true", - DEV_BYPASS_ADMIN: "true", - SIM_ERA_SECONDS: "9999999", - }; - - const res = await tickPost( - makeContext({ - url: "https://local.test/api/clock/tick", - env, - headers: { "content-type": "application/json" }, - body: JSON.stringify({ rollup: false }), - }), - ); - - assert.equal(res.status, 200); - const json = await res.json(); - assert.equal(json.ok, true); - assert.equal(json.due, false); - assert.equal(json.advanced, false); - assert.equal(json.fromEra, 0); - assert.equal(json.toEra, 0); -}); - -test("clock tick: force advance + rollup", async () => { - clearClockForTests(); - clearEraForTests(); - clearEraRollupsForTests(); - - const env = { - READ_MODELS_INLINE: "true", - DEV_BYPASS_ADMIN: "true", - DEV_BYPASS_GATE: "true", - SIM_ERA_SECONDS: "9999999", - }; - - const res = await tickPost( - makeContext({ - url: "https://local.test/api/clock/tick", - env, - headers: { "content-type": "application/json" }, - body: JSON.stringify({ forceAdvance: true, rollup: true }), - }), - ); - - assert.equal(res.status, 200); - const json = await res.json(); - assert.equal(json.ok, true); - assert.equal(json.due, true); - assert.equal(json.advanced, true); - assert.equal(json.fromEra, 0); - assert.equal(json.toEra, 1); - assert.ok(json.rollup); - assert.equal(json.rollup.era, 0); -}); - -test("clock tick: emits window-ended feed events (deduped)", async () => { - clearClockForTests(); - clearEraForTests(); - clearEraRollupsForTests(); - clearProposalsForTests(); - clearFeedEventsForTests(); - - const env = { - READ_MODELS_INLINE: "true", - DEV_BYPASS_ADMIN: "true", - SIM_ERA_SECONDS: "9999999", - SIM_ENABLE_STAGE_WINDOWS: "true", - SIM_POOL_WINDOW_SECONDS: "1", - SIM_VOTE_WINDOW_SECONDS: "1", - SIM_NOW_ISO: new Date(Date.now() + 5_000).toISOString(), - }; - - await createProposal(env, { - id: "p-1", - stage: "pool", - authorAddress: "5TestAddr", - title: "Test proposal", - chamberId: "engineering", - summary: "Summary", - payload: {}, - }); - - await createProposal(env, { - id: "p-2", - stage: "vote", - authorAddress: "5TestAddr", - title: "Test proposal 2", - chamberId: "engineering", - summary: "Summary", - payload: {}, - }); - - const res1 = await tickPost( - makeContext({ - url: "https://local.test/api/clock/tick", - env, - headers: { "content-type": "application/json" }, - body: JSON.stringify({ rollup: false }), - }), - ); - assert.equal(res1.status, 200); - const json1 = await res1.json(); - assert.ok(Array.isArray(json1.endedWindows)); - assert.equal(json1.endedWindows.length, 2); - assert.equal(json1.endedWindows.filter((window) => window.emitted).length, 2); - - const page1 = await listFeedEventsPage(env, { limit: 10 }); - assert.equal(page1.items.length, 2); - assert.ok(page1.items.some((item) => item.stage === "pool")); - assert.ok(page1.items.some((item) => item.stage === "vote")); - - const res2 = await tickPost( - makeContext({ - url: "https://local.test/api/clock/tick", - env, - headers: { "content-type": "application/json" }, - body: JSON.stringify({ rollup: false }), - }), - ); - assert.equal(res2.status, 200); - const json2 = await res2.json(); - assert.equal(json2.endedWindows.length, 2); - assert.equal(json2.endedWindows.filter((window) => window.emitted).length, 0); - - const page2 = await listFeedEventsPage(env, { limit: 10 }); - assert.equal(page2.items.length, 2); -}); diff --git a/tests/api-command-action-lock.test.js b/tests/api-command-action-lock.test.js deleted file mode 100644 index 91d7c90..0000000 --- a/tests/api-command-action-lock.test.js +++ /dev/null @@ -1,123 +0,0 @@ -import assert from "node:assert/strict"; -import { test } from "node:test"; - -import { onRequestPost as commandPost } from "../api/routes/command.ts"; -import { onRequestPost as adminLockPost } from "../api/routes/admin/users/lock.ts"; -import { onRequestPost as adminUnlockPost } from "../api/routes/admin/users/unlock.ts"; -import { getSessionCookieName, issueSession } from "../api/_lib/auth.ts"; -import { clearActionLocksForTests } from "../api/_lib/actionLocksStore.ts"; -import { clearApiRateLimitsForTests } from "../api/_lib/apiRateLimitStore.ts"; -import { clearIdempotencyForTests } from "../api/_lib/idempotencyStore.ts"; -import { clearPoolVotesForTests } from "../api/_lib/poolVotesStore.ts"; -import { clearInlineReadModelsForTests } from "../api/_lib/readModelsStore.ts"; -import { - clearChamberMembershipsForTests, - ensureChamberMembership, -} from "../api/_lib/chamberMembershipsStore.ts"; - -function makeContext({ url, env, params, method = "POST", headers, body }) { - return { - request: new Request(url, { method, headers, body }), - env, - params, - }; -} - -async function makeSessionCookie(env, address) { - const headers = new Headers(); - await issueSession(headers, env, "https://local.test/api/command", address); - const setCookie = headers.get("set-cookie"); - assert.ok(setCookie); - const tokenPair = setCookie.split(";")[0]; - const [name, value] = tokenPair.split("="); - assert.equal(name, getSessionCookieName()); - await ensureChamberMembership(env, { - address, - chamberId: "general", - source: "test", - }); - await ensureChamberMembership(env, { - address, - chamberId: "engineering", - source: "test", - }); - return `${name}=${value}`; -} - -const baseEnv = { - SESSION_SECRET: "test-secret", - DEV_BYPASS_GATE: "true", - DEV_INSECURE_COOKIES: "true", - READ_MODELS_INLINE: "true", - ADMIN_SECRET: "admin-secret", - SIM_COMMAND_RATE_LIMIT_PER_MINUTE_ADDRESS: "1000", - SIM_COMMAND_RATE_LIMIT_PER_MINUTE_IP: "1000", -}; - -test("admin user lock blocks /api/command until unlocked (memory mode)", async () => { - await clearPoolVotesForTests(); - clearIdempotencyForTests(); - clearInlineReadModelsForTests(); - clearApiRateLimitsForTests(); - clearActionLocksForTests(); - clearChamberMembershipsForTests(); - - const address = "5FakeAddr"; - const cookie = await makeSessionCookie(baseEnv, address); - const lockedUntil = new Date(Date.now() + 60_000).toISOString(); - - const lockRes = await adminLockPost( - makeContext({ - url: "https://local.test/api/admin/users/lock", - env: baseEnv, - headers: { - "content-type": "application/json", - "x-admin-secret": "admin-secret", - }, - body: JSON.stringify({ address, lockedUntil, reason: "testing" }), - }), - ); - assert.equal(lockRes.status, 200); - - const blocked = await commandPost( - makeContext({ - url: "https://local.test/api/command", - env: baseEnv, - headers: { "content-type": "application/json", cookie }, - body: JSON.stringify({ - type: "pool.vote", - payload: { proposalId: "biometric-account-recovery", direction: "up" }, - }), - }), - ); - assert.equal(blocked.status, 403); - const blockedJson = await blocked.json(); - assert.equal(blockedJson.error.code, "action_locked"); - assert.equal(blockedJson.error.lock.address, address); - - const unlockRes = await adminUnlockPost( - makeContext({ - url: "https://local.test/api/admin/users/unlock", - env: baseEnv, - headers: { - "content-type": "application/json", - "x-admin-secret": "admin-secret", - }, - body: JSON.stringify({ address }), - }), - ); - assert.equal(unlockRes.status, 200); - - const allowed = await commandPost( - makeContext({ - url: "https://local.test/api/command", - env: baseEnv, - headers: { "content-type": "application/json", cookie }, - body: JSON.stringify({ - type: "pool.vote", - payload: { proposalId: "biometric-account-recovery", direction: "up" }, - }), - }), - ); - assert.equal(allowed.status, 200); -}); diff --git a/tests/api-command-chamber-create-members.test.js b/tests/api-command-chamber-create-members.test.js deleted file mode 100644 index 8ef5db6..0000000 --- a/tests/api-command-chamber-create-members.test.js +++ /dev/null @@ -1,156 +0,0 @@ -import assert from "node:assert/strict"; -import { test } from "node:test"; - -import { onRequestPost as commandPost } from "../api/routes/command.ts"; -import { onRequestGet as chambersGet } from "../api/routes/chambers/index.ts"; -import { getSessionCookieName, issueSession } from "../api/_lib/auth.ts"; -import { clearChamberVotesForTests } from "../api/_lib/chamberVotesStore.ts"; -import { - clearChamberMembershipsForTests, - hasChamberMembership, -} from "../api/_lib/chamberMembershipsStore.ts"; -import { clearChambersForTests } from "../api/_lib/chambersStore.ts"; -import { clearIdempotencyForTests } from "../api/_lib/idempotencyStore.ts"; -import { clearInlineReadModelsForTests } from "../api/_lib/readModelsStore.ts"; -import { - clearProposalsForTests, - createProposal, -} from "../api/_lib/proposalsStore.ts"; - -function makeContext({ url, env, params, method = "POST", headers, body }) { - return { - request: new Request(url, { method, headers, body }), - env, - params, - }; -} - -async function makeSessionCookie(env, address) { - const headers = new Headers(); - await issueSession(headers, env, "https://local.test/api/command", address); - const setCookie = headers.get("set-cookie"); - assert.ok(setCookie); - const tokenPair = setCookie.split(";")[0]; - const [name, value] = tokenPair.split("="); - assert.equal(name, getSessionCookieName()); - return `${name}=${value}`; -} - -function baseEnv(overrides = {}) { - const genesisVoter = "5GenesisEng"; - return { - SESSION_SECRET: "test-secret", - DEV_BYPASS_GATE: "true", - DEV_INSECURE_COOKIES: "true", - READ_MODELS_INLINE: "true", - DEV_BYPASS_ADMIN: "true", - SIM_ACTIVE_GOVERNORS: "3", - SIM_CONFIG_JSON: JSON.stringify({ - genesisChamberMembers: { engineering: [genesisVoter] }, - genesisChambers: [ - { id: "general", title: "General", multiplier: 1.2 }, - { id: "engineering", title: "Engineering", multiplier: 1.5 }, - ], - }), - ...overrides, - }; -} - -test("General chamber.create proposal seeds initial chamber memberships", async () => { - clearIdempotencyForTests(); - clearProposalsForTests(); - await clearChamberVotesForTests(); - clearChamberMembershipsForTests(); - clearChambersForTests(); - clearInlineReadModelsForTests(); - - const env = baseEnv(); - const voterCookie = await makeSessionCookie(env, "5GenesisEng"); - - await createProposal(env, { - id: "general-create", - stage: "vote", - authorAddress: "5Creator", - title: "Create science chamber", - chamberId: "general", - summary: "Create a new chamber", - payload: { - title: "Create science chamber", - timeline: [], - budgetItems: [], - metaGovernance: { - action: "chamber.create", - chamberId: "science", - title: "Science", - multiplier: 1.7, - genesisMembers: ["5SciLead", "5SciMember"], - }, - }, - }); - - const voteCreate = await commandPost( - makeContext({ - url: "https://local.test/api/command", - env, - headers: { "content-type": "application/json", cookie: voterCookie }, - body: JSON.stringify({ - type: "chamber.vote", - payload: { proposalId: "general-create", choice: "yes", score: 8 }, - }), - }), - ); - assert.equal(voteCreate.status, 200); - - const listRes = await chambersGet( - makeContext({ url: "https://local.test/api/chambers", env, method: "GET" }), - ); - assert.equal(listRes.status, 200); - const listJson = await listRes.json(); - assert.ok(listJson.items.some((c) => c.id === "science")); - - assert.equal( - await hasChamberMembership(env, { - address: "5SciLead", - chamberId: "science", - }), - true, - ); - assert.equal( - await hasChamberMembership(env, { - address: "5SciMember", - chamberId: "science", - }), - true, - ); - assert.equal( - await hasChamberMembership(env, { - address: "5Creator", - chamberId: "science", - }), - true, - ); - - await createProposal(env, { - id: "science-proposal", - stage: "vote", - authorAddress: "5SomeoneElse", - title: "Science proposal", - chamberId: "science", - summary: "Summary", - payload: {}, - }); - - const sciCookie = await makeSessionCookie(env, "5SciLead"); - const sciVote = await commandPost( - makeContext({ - url: "https://local.test/api/command", - env, - headers: { "content-type": "application/json", cookie: sciCookie }, - body: JSON.stringify({ - type: "chamber.vote", - payload: { proposalId: "science-proposal", choice: "yes", score: 7 }, - }), - }), - ); - assert.equal(sciVote.status, 200); -}); diff --git a/tests/api-command-chamber-vote.test.js b/tests/api-command-chamber-vote.test.js deleted file mode 100644 index 92d279c..0000000 --- a/tests/api-command-chamber-vote.test.js +++ /dev/null @@ -1,207 +0,0 @@ -import assert from "node:assert/strict"; -import { test } from "node:test"; - -import { onRequestPost as commandPost } from "../api/routes/command.ts"; -import { onRequestGet as proposalsGet } from "../api/routes/proposals/index.ts"; -import { onRequestGet as chamberPageGet } from "../api/routes/proposals/[id]/chamber.ts"; -import { onRequestGet as formationPageGet } from "../api/routes/proposals/[id]/formation.ts"; -import { getSessionCookieName, issueSession } from "../api/_lib/auth.ts"; -import { clearIdempotencyForTests } from "../api/_lib/idempotencyStore.ts"; -import { clearInlineReadModelsForTests } from "../api/_lib/readModelsStore.ts"; -import { clearChamberVotesForTests } from "../api/_lib/chamberVotesStore.ts"; -import { clearCmAwardsForTests } from "../api/_lib/cmAwardsStore.ts"; -import { onRequestGet as humansGet } from "../api/routes/humans/index.ts"; -import { - clearChamberMembershipsForTests, - ensureChamberMembership, -} from "../api/_lib/chamberMembershipsStore.ts"; - -function makeContext({ url, env, params, method = "POST", headers, body }) { - return { - request: new Request(url, { method, headers, body }), - env, - params, - }; -} - -async function makeSessionCookie(env, address) { - const headers = new Headers(); - await issueSession(headers, env, "https://local.test/api/command", address); - const setCookie = headers.get("set-cookie"); - assert.ok(setCookie); - const tokenPair = setCookie.split(";")[0]; - const [name, value] = tokenPair.split("="); - assert.equal(name, getSessionCookieName()); - return `${name}=${value}`; -} - -const baseEnv = { - SESSION_SECRET: "test-secret", - DEV_BYPASS_GATE: "true", - DEV_INSECURE_COOKIES: "true", - READ_MODELS_INLINE: "true", - DEV_BYPASS_ADMIN: "true", -}; - -test("POST /api/command chamber.vote rejects when not authenticated", async () => { - await clearChamberVotesForTests(); - clearChamberMembershipsForTests(); - clearIdempotencyForTests(); - clearInlineReadModelsForTests(); - await clearCmAwardsForTests(); - - const res = await commandPost( - makeContext({ - url: "https://local.test/api/command", - env: baseEnv, - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - type: "chamber.vote", - payload: { proposalId: "tier-decay-v1", choice: "yes" }, - }), - }), - ); - assert.equal(res.status, 401); -}); - -test("GET /api/proposals/:id/chamber overlays live vote counts", async () => { - await clearChamberVotesForTests(); - clearChamberMembershipsForTests(); - clearIdempotencyForTests(); - clearInlineReadModelsForTests(); - await clearCmAwardsForTests(); - - const cookie = await makeSessionCookie(baseEnv, "5VoteAddr"); - await ensureChamberMembership(baseEnv, { - address: "5VoteAddr", - chamberId: "general", - source: "test", - }); - const res1 = await commandPost( - makeContext({ - url: "https://local.test/api/command", - env: baseEnv, - headers: { "content-type": "application/json", cookie }, - body: JSON.stringify({ - type: "chamber.vote", - payload: { proposalId: "tier-decay-v1", choice: "yes" }, - }), - }), - ); - assert.equal(res1.status, 200); - - const res2 = await chamberPageGet( - makeContext({ - url: "https://local.test/api/proposals/tier-decay-v1/chamber", - env: baseEnv, - params: { id: "tier-decay-v1" }, - method: "GET", - }), - ); - assert.equal(res2.status, 200); - const json = await res2.json(); - assert.equal( - json.title, - "Tier Decay v1: Nominee → Ecclesiast → Legate → Consul → Citizen", - ); - assert.deepEqual(json.votes, { yes: 1, no: 0, abstain: 0 }); - assert.equal(json.engagedGovernors, 1); -}); - -test("chamber vote passing auto-advances proposal from vote → build and creates formation page", async () => { - await clearChamberVotesForTests(); - clearChamberMembershipsForTests(); - clearIdempotencyForTests(); - clearInlineReadModelsForTests(); - await clearCmAwardsForTests(); - - const proposalId = "tier-decay-v1"; - - for (let i = 0; i < 50; i += 1) { - const address = `5ChamberAddr${i}`; - await ensureChamberMembership(baseEnv, { - address, - chamberId: "general", - source: "test", - }); - const cookie = await makeSessionCookie(baseEnv, address); - const choice = i < 34 ? "yes" : "no"; - const res = await commandPost( - makeContext({ - url: "https://local.test/api/command", - env: baseEnv, - headers: { "content-type": "application/json", cookie }, - body: JSON.stringify({ - type: "chamber.vote", - payload: { - proposalId, - choice, - ...(choice === "yes" ? { score: 8 } : {}), - }, - }), - }), - ); - if (res.status === 409) break; - assert.equal(res.status, 200); - } - - const proposalsRes = await proposalsGet( - makeContext({ - url: "https://local.test/api/proposals", - env: baseEnv, - method: "GET", - }), - ); - assert.equal(proposalsRes.status, 200); - const proposalsJson = await proposalsRes.json(); - const item = proposalsJson.items.find((p) => p.id === proposalId); - assert.ok(item); - assert.equal(item.stage, "build"); - - const formationRes = await formationPageGet( - makeContext({ - url: "https://local.test/api/proposals/tier-decay-v1/formation", - env: baseEnv, - params: { id: proposalId }, - method: "GET", - }), - ); - assert.equal(formationRes.status, 200); - const formationJson = await formationRes.json(); - assert.ok( - typeof formationJson.title === "string" && formationJson.title.length > 0, - ); - assert.match(formationJson.title, /^Tier Decay v1/); - - await ensureChamberMembership(baseEnv, { - address: "5AfterPass", - chamberId: "general", - source: "test", - }); - const cookieAfter = await makeSessionCookie(baseEnv, "5AfterPass"); - const resAfter = await commandPost( - makeContext({ - url: "https://local.test/api/command", - env: baseEnv, - headers: { "content-type": "application/json", cookie: cookieAfter }, - body: JSON.stringify({ - type: "chamber.vote", - payload: { proposalId, choice: "yes" }, - }), - }), - ); - assert.equal(resAfter.status, 409); - - const humansRes = await humansGet( - makeContext({ - url: "https://local.test/api/humans", - env: baseEnv, - method: "GET", - }), - ); - assert.equal(humansRes.status, 200); - const humansJson = await humansRes.json(); - const andrei = humansJson.items.find((h) => h.id === "andrei"); - assert.ok(andrei); - assert.equal(andrei.acm, 266); -}); diff --git a/tests/api-command-courts.test.js b/tests/api-command-courts.test.js deleted file mode 100644 index 60fe00c..0000000 --- a/tests/api-command-courts.test.js +++ /dev/null @@ -1,160 +0,0 @@ -import assert from "node:assert/strict"; -import { test } from "node:test"; - -import { onRequestPost as commandPost } from "../api/routes/command.ts"; -import { onRequestGet as courtsGet } from "../api/routes/courts/index.ts"; -import { onRequestGet as courtGet } from "../api/routes/courts/[id].ts"; -import { getSessionCookieName, issueSession } from "../api/_lib/auth.ts"; -import { clearIdempotencyForTests } from "../api/_lib/idempotencyStore.ts"; -import { clearInlineReadModelsForTests } from "../api/_lib/readModelsStore.ts"; -import { clearCourtsForTests } from "../api/_lib/courtsStore.ts"; - -function makeContext({ url, env, params, method = "POST", headers, body }) { - return { - request: new Request(url, { method, headers, body }), - env, - params, - }; -} - -async function makeSessionCookie(env, address) { - const headers = new Headers(); - await issueSession(headers, env, "https://local.test/api/command", address); - const setCookie = headers.get("set-cookie"); - assert.ok(setCookie); - const tokenPair = setCookie.split(";")[0]; - const [name, value] = tokenPair.split("="); - assert.equal(name, getSessionCookieName()); - return `${name}=${value}`; -} - -const baseEnv = { - SESSION_SECRET: "test-secret", - DEV_BYPASS_GATE: "true", - DEV_INSECURE_COOKIES: "true", - READ_MODELS_INLINE: "true", - DEV_BYPASS_ADMIN: "true", -}; - -test("court.case.report increments reports and can transition jury → live", async () => { - clearCourtsForTests(); - clearIdempotencyForTests(); - clearInlineReadModelsForTests(); - - const caseId = "delegation-farming-forum-whale"; // base: jury, 9 reports - - for (let i = 0; i < 3; i += 1) { - const cookie = await makeSessionCookie(baseEnv, `5CourtReport${i}`); - const res = await commandPost( - makeContext({ - url: "https://local.test/api/command", - env: baseEnv, - headers: { "content-type": "application/json", cookie }, - body: JSON.stringify({ - type: "court.case.report", - payload: { caseId }, - }), - }), - ); - assert.equal(res.status, 200); - } - - const detailRes = await courtGet( - makeContext({ - url: `https://local.test/api/courts/${caseId}`, - env: baseEnv, - params: { id: caseId }, - method: "GET", - }), - ); - assert.equal(detailRes.status, 200); - const json = await detailRes.json(); - assert.equal(json.reports, 12); - assert.equal(json.status, "live"); - - const listRes = await courtsGet( - makeContext({ - url: "https://local.test/api/courts", - env: baseEnv, - method: "GET", - }), - ); - assert.equal(listRes.status, 200); - const listJson = await listRes.json(); - const item = listJson.items.find((c) => c.id === caseId); - assert.ok(item); - assert.equal(item.reports, 12); - assert.equal(item.status, "live"); -}); - -test("court.case.verdict rejects when case is not live", async () => { - clearCourtsForTests(); - clearIdempotencyForTests(); - clearInlineReadModelsForTests(); - - const caseId = "delegation-farming-forum-whale"; // jury in seed - const cookie = await makeSessionCookie(baseEnv, "5CourtVerdictJury"); - const res = await commandPost( - makeContext({ - url: "https://local.test/api/command", - env: baseEnv, - headers: { "content-type": "application/json", cookie }, - body: JSON.stringify({ - type: "court.case.verdict", - payload: { caseId, verdict: "guilty" }, - }), - }), - ); - assert.equal(res.status, 409); -}); - -test("court.case.verdict ends the case after 12 distinct verdicts", async () => { - clearCourtsForTests(); - clearIdempotencyForTests(); - clearInlineReadModelsForTests(); - - const caseId = "delegation-reroute-keeper-nyx"; // live in seed - - for (let i = 0; i < 12; i += 1) { - const cookie = await makeSessionCookie(baseEnv, `5CourtVoter${i}`); - const verdict = i % 2 === 0 ? "guilty" : "not_guilty"; - const res = await commandPost( - makeContext({ - url: "https://local.test/api/command", - env: baseEnv, - headers: { "content-type": "application/json", cookie }, - body: JSON.stringify({ - type: "court.case.verdict", - payload: { caseId, verdict }, - }), - }), - ); - assert.equal(res.status, 200); - } - - const detailRes = await courtGet( - makeContext({ - url: `https://local.test/api/courts/${caseId}`, - env: baseEnv, - params: { id: caseId }, - method: "GET", - }), - ); - assert.equal(detailRes.status, 200); - const json = await detailRes.json(); - assert.equal(json.status, "ended"); - - const cookieAfter = await makeSessionCookie(baseEnv, "5CourtAfterEnded"); - const resAfter = await commandPost( - makeContext({ - url: "https://local.test/api/command", - env: baseEnv, - headers: { "content-type": "application/json", cookie: cookieAfter }, - body: JSON.stringify({ - type: "court.case.verdict", - payload: { caseId, verdict: "guilty" }, - }), - }), - ); - assert.equal(resAfter.status, 409); -}); diff --git a/tests/api-command-drafts.test.js b/tests/api-command-drafts.test.js deleted file mode 100644 index 8e48053..0000000 --- a/tests/api-command-drafts.test.js +++ /dev/null @@ -1,497 +0,0 @@ -import assert from "node:assert/strict"; -import { test } from "node:test"; - -import { onRequestPost as commandPost } from "../api/routes/command.ts"; -import { onRequestGet as draftsGet } from "../api/routes/proposals/drafts/index.ts"; -import { onRequestGet as draftGet } from "../api/routes/proposals/drafts/[id].ts"; -import { onRequestGet as proposalsGet } from "../api/routes/proposals/index.ts"; -import { onRequestGet as proposalPoolGet } from "../api/routes/proposals/[id]/pool.ts"; -import { getSessionCookieName, issueSession } from "../api/_lib/auth.ts"; -import { clearIdempotencyForTests } from "../api/_lib/idempotencyStore.ts"; -import { clearInlineReadModelsForTests } from "../api/_lib/readModelsStore.ts"; -import { clearProposalDraftsForTests } from "../api/_lib/proposalDraftsStore.ts"; -import { - clearProposalsForTests, - getProposal, -} from "../api/_lib/proposalsStore.ts"; -import { clearPoolVotesForTests } from "../api/_lib/poolVotesStore.ts"; -import { clearEraForTests } from "../api/_lib/eraStore.ts"; -import { - clearChamberMembershipsForTests, - ensureChamberMembership, -} from "../api/_lib/chamberMembershipsStore.ts"; - -function makeContext({ url, env, params, method = "POST", headers, body }) { - return { - request: new Request(url, { method, headers, body }), - env, - params, - }; -} - -async function makeSessionCookie(env, address) { - const headers = new Headers(); - await issueSession(headers, env, "https://local.test/api/command", address); - const setCookie = headers.get("set-cookie"); - assert.ok(setCookie); - const tokenPair = setCookie.split(";")[0]; - const [name, value] = tokenPair.split("="); - assert.equal(name, getSessionCookieName()); - await ensureChamberMembership(env, { - address, - chamberId: "general", - source: "test", - }); - await ensureChamberMembership(env, { - address, - chamberId: "engineering", - source: "test", - }); - return `${name}=${value}`; -} - -const baseEnv = { - SESSION_SECRET: "test-secret", - DEV_BYPASS_GATE: "true", - DEV_INSECURE_COOKIES: "true", - READ_MODELS_INLINE_EMPTY: "true", - DEV_BYPASS_ADMIN: "true", - SIM_CONFIG_JSON: JSON.stringify({ - genesisChambers: [ - { id: "general", title: "General", multiplier: 1.2 }, - { id: "engineering", title: "Engineering", multiplier: 1.5 }, - ], - genesisChamberMembers: {}, - }), -}; - -function makeDraftForm() { - return { - title: "Test proposal draft", - chamberId: "engineering", - summary: "Short summary for the draft.", - what: "What: build something useful.", - why: "Why: make governance easier.", - how: "Step 1\nStep 2", - timeline: [{ id: "ms-1", title: "Milestone 1", timeframe: "2 weeks" }], - outputs: [{ id: "out-1", label: "Docs", url: "https://example.com" }], - budgetItems: [{ id: "b-1", description: "Work", amount: "1000" }], - aboutMe: "", - attachments: [ - { id: "a-1", label: "Spec", url: "https://example.com/spec" }, - ], - agreeRules: true, - confirmBudget: true, - }; -} - -test("proposal drafts: save → list/detail → submit to pool", async () => { - clearIdempotencyForTests(); - clearInlineReadModelsForTests(); - clearProposalDraftsForTests(); - clearProposalsForTests(); - clearChamberMembershipsForTests(); - - const cookie = await makeSessionCookie(baseEnv, "5TestAddr"); - - const saveRes = await commandPost( - makeContext({ - url: "https://local.test/api/command", - env: baseEnv, - headers: { "content-type": "application/json", cookie }, - body: JSON.stringify({ - type: "proposal.draft.save", - payload: { form: makeDraftForm() }, - }), - }), - ); - assert.equal(saveRes.status, 200); - const saved = await saveRes.json(); - assert.equal(saved.ok, true); - assert.equal(saved.type, "proposal.draft.save"); - assert.ok(typeof saved.draftId === "string"); - - const listRes = await draftsGet( - makeContext({ - url: "https://local.test/api/proposals/drafts", - env: baseEnv, - method: "GET", - headers: { cookie }, - }), - ); - assert.equal(listRes.status, 200); - const listJson = await listRes.json(); - assert.ok(Array.isArray(listJson.items)); - assert.equal(listJson.items.length, 1); - assert.equal(listJson.items[0].id, saved.draftId); - - const detailRes = await draftGet( - makeContext({ - url: `https://local.test/api/proposals/drafts/${saved.draftId}`, - env: baseEnv, - method: "GET", - headers: { cookie }, - params: { id: saved.draftId }, - }), - ); - assert.equal(detailRes.status, 200); - const detailJson = await detailRes.json(); - assert.equal(detailJson.title, "Test proposal draft"); - assert.ok(Array.isArray(detailJson.checklist)); - - const submitRes = await commandPost( - makeContext({ - url: "https://local.test/api/command", - env: baseEnv, - headers: { "content-type": "application/json", cookie }, - body: JSON.stringify({ - type: "proposal.submitToPool", - payload: { draftId: saved.draftId }, - }), - }), - ); - assert.equal(submitRes.status, 200); - const submitJson = await submitRes.json(); - assert.equal(submitJson.ok, true); - assert.equal(submitJson.type, "proposal.submitToPool"); - assert.ok(typeof submitJson.proposalId === "string"); - - const proposal = await getProposal(baseEnv, submitJson.proposalId); - assert.ok(proposal, "canonical proposal exists"); - assert.equal(proposal.stage, "pool"); - assert.equal(proposal.title, "Test proposal draft"); - - const proposalsRes = await proposalsGet( - makeContext({ - url: "https://local.test/api/proposals?stage=pool", - env: baseEnv, - method: "GET", - }), - ); - assert.equal(proposalsRes.status, 200); - const proposalsJson = await proposalsRes.json(); - assert.ok(Array.isArray(proposalsJson.items)); - assert.ok( - proposalsJson.items.some((p) => p.id === submitJson.proposalId), - "submitted proposal appears in proposals list", - ); - - const poolPageRes = await proposalPoolGet( - makeContext({ - url: `https://local.test/api/proposals/${submitJson.proposalId}/pool`, - env: baseEnv, - method: "GET", - params: { id: submitJson.proposalId }, - }), - ); - assert.equal(poolPageRes.status, 200); - const poolPageJson = await poolPageRes.json(); - assert.equal(poolPageJson.title, "Test proposal draft"); - assert.equal(poolPageJson.upvotes, 0); - - const listAfterSubmit = await draftsGet( - makeContext({ - url: "https://local.test/api/proposals/drafts", - env: baseEnv, - method: "GET", - headers: { cookie }, - }), - ); - assert.equal(listAfterSubmit.status, 200); - assert.deepEqual(await listAfterSubmit.json(), { items: [] }); -}); - -test("proposal reads prefer canonical proposals over read models", async () => { - clearIdempotencyForTests(); - clearInlineReadModelsForTests(); - clearProposalDraftsForTests(); - clearProposalsForTests(); - clearChamberMembershipsForTests(); - - const cookie = await makeSessionCookie(baseEnv, "5TestAddr"); - - const saveRes = await commandPost( - makeContext({ - url: "https://local.test/api/command", - env: baseEnv, - headers: { "content-type": "application/json", cookie }, - body: JSON.stringify({ - type: "proposal.draft.save", - payload: { form: makeDraftForm() }, - }), - }), - ); - assert.equal(saveRes.status, 200); - const saved = await saveRes.json(); - - const submitRes = await commandPost( - makeContext({ - url: "https://local.test/api/command", - env: baseEnv, - headers: { "content-type": "application/json", cookie }, - body: JSON.stringify({ - type: "proposal.submitToPool", - payload: { draftId: saved.draftId }, - }), - }), - ); - assert.equal(submitRes.status, 200); - const submitJson = await submitRes.json(); - assert.ok(submitJson.proposalId); - - const proposal = await getProposal(baseEnv, submitJson.proposalId); - assert.ok(proposal); - - clearInlineReadModelsForTests(); - - const proposalsRes = await proposalsGet( - makeContext({ - url: "https://local.test/api/proposals?stage=pool", - env: baseEnv, - method: "GET", - }), - ); - assert.equal(proposalsRes.status, 200); - const proposalsJson = await proposalsRes.json(); - assert.ok( - proposalsJson.items.some((p) => p.id === submitJson.proposalId), - "proposal still appears without read models", - ); - - const poolPageRes = await proposalPoolGet( - makeContext({ - url: `https://local.test/api/proposals/${submitJson.proposalId}/pool`, - env: baseEnv, - method: "GET", - params: { id: submitJson.proposalId }, - }), - ); - assert.equal(poolPageRes.status, 200); - const poolPageJson = await poolPageRes.json(); - assert.equal(poolPageJson.title, "Test proposal draft"); -}); - -test("canonical proposals: stage gating and pool→vote advance work without read models", async () => { - clearIdempotencyForTests(); - clearInlineReadModelsForTests(); - clearProposalDraftsForTests(); - clearProposalsForTests(); - clearChamberMembershipsForTests(); - await clearPoolVotesForTests(); - clearEraForTests(); - - const env = { - ...baseEnv, - READ_MODELS_INLINE: "true", - SIM_ACTIVE_GOVERNORS: "10", - }; - - for (let i = 0; i < 10; i += 1) { - await ensureChamberMembership(env, { - address: `5EngMember${i}`, - chamberId: "engineering", - source: "test", - }); - } - - const cookieProposer = await makeSessionCookie(env, "5ProposerAddr"); - const saveRes = await commandPost( - makeContext({ - url: "https://local.test/api/command", - env, - headers: { "content-type": "application/json", cookie: cookieProposer }, - body: JSON.stringify({ - type: "proposal.draft.save", - payload: { form: makeDraftForm() }, - }), - }), - ); - assert.equal(saveRes.status, 200); - const saved = await saveRes.json(); - - const submitRes = await commandPost( - makeContext({ - url: "https://local.test/api/command", - env, - headers: { "content-type": "application/json", cookie: cookieProposer }, - body: JSON.stringify({ - type: "proposal.submitToPool", - payload: { draftId: saved.draftId }, - }), - }), - ); - assert.equal(submitRes.status, 200); - const submitted = await submitRes.json(); - const proposalId = submitted.proposalId; - assert.ok(proposalId); - - clearInlineReadModelsForTests(); - - const cookieVoter1 = await makeSessionCookie(env, "5VoterA"); - const vote1 = await commandPost( - makeContext({ - url: "https://local.test/api/command", - env, - headers: { "content-type": "application/json", cookie: cookieVoter1 }, - body: JSON.stringify({ - type: "pool.vote", - payload: { proposalId, direction: "up" }, - }), - }), - ); - assert.equal(vote1.status, 200); - - const cookieVoter2 = await makeSessionCookie(env, "5VoterB"); - const vote2 = await commandPost( - makeContext({ - url: "https://local.test/api/command", - env, - headers: { "content-type": "application/json", cookie: cookieVoter2 }, - body: JSON.stringify({ - type: "pool.vote", - payload: { proposalId, direction: "down" }, - }), - }), - ); - assert.equal(vote2.status, 200); - - const cookieVoter3 = await makeSessionCookie(env, "5VoterC"); - const vote3 = await commandPost( - makeContext({ - url: "https://local.test/api/command", - env, - headers: { "content-type": "application/json", cookie: cookieVoter3 }, - body: JSON.stringify({ - type: "pool.vote", - payload: { proposalId, direction: "down" }, - }), - }), - ); - assert.equal(vote3.status, 200); - - const canonical = await getProposal(env, proposalId); - assert.ok(canonical); - assert.equal(canonical.stage, "vote"); - - const proposalsRes = await proposalsGet( - makeContext({ - url: "https://local.test/api/proposals?stage=vote", - env, - method: "GET", - }), - ); - assert.equal(proposalsRes.status, 200); - const proposalsJson = await proposalsRes.json(); - assert.ok(proposalsJson.items.some((p) => p.id === proposalId)); - - const blocked = await commandPost( - makeContext({ - url: "https://local.test/api/command", - env, - headers: { "content-type": "application/json", cookie: cookieVoter1 }, - body: JSON.stringify({ - type: "pool.vote", - payload: { proposalId, direction: "up" }, - }), - }), - ); - assert.equal(blocked.status, 409); -}); - -test("proposal drafts: submit requires submittable draft", async () => { - clearIdempotencyForTests(); - clearInlineReadModelsForTests(); - clearProposalDraftsForTests(); - clearProposalsForTests(); - clearChamberMembershipsForTests(); - - const cookie = await makeSessionCookie(baseEnv, "5TestAddr"); - const badForm = makeDraftForm(); - badForm.why = ""; - - const saveRes = await commandPost( - makeContext({ - url: "https://local.test/api/command", - env: baseEnv, - headers: { "content-type": "application/json", cookie }, - body: JSON.stringify({ - type: "proposal.draft.save", - payload: { form: badForm }, - }), - }), - ); - assert.equal(saveRes.status, 200); - const saved = await saveRes.json(); - - const submitRes = await commandPost( - makeContext({ - url: "https://local.test/api/command", - env: baseEnv, - headers: { "content-type": "application/json", cookie }, - body: JSON.stringify({ - type: "proposal.submitToPool", - payload: { draftId: saved.draftId }, - }), - }), - ); - assert.equal(submitRes.status, 400); -}); - -test("proposal drafts: save is idempotent (Idempotency-Key)", async () => { - clearIdempotencyForTests(); - clearInlineReadModelsForTests(); - clearProposalDraftsForTests(); - clearProposalsForTests(); - clearChamberMembershipsForTests(); - - const cookie = await makeSessionCookie(baseEnv, "5TestAddr"); - - const headers = { - "content-type": "application/json", - cookie, - "idempotency-key": "draft-save-1", - }; - const body = JSON.stringify({ - type: "proposal.draft.save", - payload: { form: makeDraftForm() }, - }); - - const first = await commandPost( - makeContext({ - url: "https://local.test/api/command", - env: baseEnv, - headers, - body, - }), - ); - assert.equal(first.status, 200); - const firstJson = await first.json(); - assert.equal(firstJson.ok, true); - assert.ok(typeof firstJson.draftId === "string"); - - const second = await commandPost( - makeContext({ - url: "https://local.test/api/command", - env: baseEnv, - headers, - body, - }), - ); - assert.equal(second.status, 200); - const secondJson = await second.json(); - assert.deepEqual(secondJson, firstJson); - - const listRes = await draftsGet( - makeContext({ - url: "https://local.test/api/proposals/drafts", - env: baseEnv, - method: "GET", - headers: { cookie }, - }), - ); - assert.equal(listRes.status, 200); - const listJson = await listRes.json(); - assert.ok(Array.isArray(listJson.items)); - assert.equal(listJson.items.length, 1); - assert.equal(listJson.items[0].id, firstJson.draftId); -}); diff --git a/tests/api-command-era-quotas.test.js b/tests/api-command-era-quotas.test.js deleted file mode 100644 index 6f5c954..0000000 --- a/tests/api-command-era-quotas.test.js +++ /dev/null @@ -1,319 +0,0 @@ -import assert from "node:assert/strict"; -import { test } from "node:test"; - -import { onRequestPost as commandPost } from "../api/routes/command.ts"; -import { getSessionCookieName, issueSession } from "../api/_lib/auth.ts"; -import { clearApiRateLimitsForTests } from "../api/_lib/apiRateLimitStore.ts"; -import { clearActionLocksForTests } from "../api/_lib/actionLocksStore.ts"; -import { clearChamberVotesForTests } from "../api/_lib/chamberVotesStore.ts"; -import { clearCourtsForTests } from "../api/_lib/courtsStore.ts"; -import { clearEraForTests } from "../api/_lib/eraStore.ts"; -import { clearFormationForTests } from "../api/_lib/formationStore.ts"; -import { clearIdempotencyForTests } from "../api/_lib/idempotencyStore.ts"; -import { clearPoolVotesForTests } from "../api/_lib/poolVotesStore.ts"; -import { clearInlineReadModelsForTests } from "../api/_lib/readModelsStore.ts"; -import { - clearChamberMembershipsForTests, - ensureChamberMembership, -} from "../api/_lib/chamberMembershipsStore.ts"; - -function makeContext({ url, env, params, method = "POST", headers, body }) { - return { - request: new Request(url, { method, headers, body }), - env, - params, - }; -} - -async function makeSessionCookie(env, address) { - const headers = new Headers(); - await issueSession(headers, env, "https://local.test/api/command", address); - const setCookie = headers.get("set-cookie"); - assert.ok(setCookie); - const tokenPair = setCookie.split(";")[0]; - const [name, value] = tokenPair.split("="); - assert.equal(name, getSessionCookieName()); - await ensureChamberMembership(env, { - address, - chamberId: "general", - source: "test", - }); - await ensureChamberMembership(env, { - address, - chamberId: "engineering", - source: "test", - }); - await ensureChamberMembership(env, { - address, - chamberId: "design", - source: "test", - }); - return `${name}=${value}`; -} - -function baseEnv(overrides = {}) { - return { - SESSION_SECRET: "test-secret", - DEV_BYPASS_GATE: "true", - DEV_INSECURE_COOKIES: "true", - READ_MODELS_INLINE: "true", - DEV_BYPASS_ADMIN: "true", - SIM_COMMAND_RATE_LIMIT_PER_MINUTE_ADDRESS: "1000", - SIM_COMMAND_RATE_LIMIT_PER_MINUTE_IP: "1000", - ...overrides, - }; -} - -async function resetAll() { - await clearPoolVotesForTests(); - await clearChamberVotesForTests(); - clearChamberMembershipsForTests(); - clearCourtsForTests(); - clearFormationForTests(); - clearEraForTests(); - clearApiRateLimitsForTests(); - clearActionLocksForTests(); - clearIdempotencyForTests(); - clearInlineReadModelsForTests(); -} - -async function seedMembers(env, input) { - for (let i = 0; i < input.count; i += 1) { - await ensureChamberMembership(env, { - address: `${input.prefix}${i}`, - chamberId: input.chamberId, - source: "test", - }); - } -} - -test("era quota: pool votes limit blocks new votes but allows updates", async () => { - await resetAll(); - const env = baseEnv({ SIM_MAX_POOL_VOTES_PER_ERA: "1" }); - await seedMembers(env, { - prefix: "5EngMember", - chamberId: "engineering", - count: 10, - }); - await seedMembers(env, { - prefix: "5DesignMember", - chamberId: "design", - count: 10, - }); - const cookie = await makeSessionCookie(env, "5QuotaAddr"); - - const first = await commandPost( - makeContext({ - url: "https://local.test/api/command", - env, - headers: { "content-type": "application/json", cookie }, - body: JSON.stringify({ - type: "pool.vote", - payload: { proposalId: "biometric-account-recovery", direction: "up" }, - }), - }), - ); - assert.equal(first.status, 200); - - const blocked = await commandPost( - makeContext({ - url: "https://local.test/api/command", - env, - headers: { "content-type": "application/json", cookie }, - body: JSON.stringify({ - type: "pool.vote", - payload: { - proposalId: "humanode-dreamscapes-visual-lore", - direction: "up", - }, - }), - }), - ); - assert.equal(blocked.status, 429); - const blockedJson = await blocked.json(); - assert.equal(blockedJson.error.code, "era_quota_exceeded"); - assert.equal(blockedJson.error.kind, "poolVotes"); - - const update = await commandPost( - makeContext({ - url: "https://local.test/api/command", - env, - headers: { "content-type": "application/json", cookie }, - body: JSON.stringify({ - type: "pool.vote", - payload: { - proposalId: "biometric-account-recovery", - direction: "down", - }, - }), - }), - ); - assert.equal(update.status, 200); -}); - -test("era quota: chamber votes limit blocks new votes but allows updates", async () => { - await resetAll(); - const env = baseEnv({ SIM_MAX_CHAMBER_VOTES_PER_ERA: "1" }); - await seedMembers(env, { - prefix: "5GeneralMember", - chamberId: "general", - count: 20, - }); - await seedMembers(env, { - prefix: "5EconomicsMember", - chamberId: "economics", - count: 20, - }); - const cookie = await makeSessionCookie(env, "5QuotaAddr"); - await ensureChamberMembership(env, { - address: "5QuotaAddr", - chamberId: "general", - source: "test", - }); - await ensureChamberMembership(env, { - address: "5QuotaAddr", - chamberId: "economics", - source: "test", - }); - - const first = await commandPost( - makeContext({ - url: "https://local.test/api/command", - env, - headers: { "content-type": "application/json", cookie }, - body: JSON.stringify({ - type: "chamber.vote", - payload: { proposalId: "tier-decay-v1", choice: "yes" }, - }), - }), - ); - assert.equal(first.status, 200); - - const blocked = await commandPost( - makeContext({ - url: "https://local.test/api/command", - env, - headers: { "content-type": "application/json", cookie }, - body: JSON.stringify({ - type: "chamber.vote", - payload: { - proposalId: "fixed-governor-stake-spam-slashing", - choice: "yes", - }, - }), - }), - ); - assert.equal(blocked.status, 429); - const blockedJson = await blocked.json(); - assert.equal(blockedJson.error.code, "era_quota_exceeded"); - assert.equal(blockedJson.error.kind, "chamberVotes"); - - const update = await commandPost( - makeContext({ - url: "https://local.test/api/command", - env, - headers: { "content-type": "application/json", cookie }, - body: JSON.stringify({ - type: "chamber.vote", - payload: { proposalId: "tier-decay-v1", choice: "no" }, - }), - }), - ); - assert.equal(update.status, 200); -}); - -test("era quota: court actions limit blocks new reports but allows duplicates", async () => { - await resetAll(); - const env = baseEnv({ SIM_MAX_COURT_ACTIONS_PER_ERA: "1" }); - const cookie = await makeSessionCookie(env, "5QuotaAddr"); - - const first = await commandPost( - makeContext({ - url: "https://local.test/api/command", - env, - headers: { "content-type": "application/json", cookie }, - body: JSON.stringify({ - type: "court.case.report", - payload: { caseId: "delegation-reroute-keeper-nyx" }, - }), - }), - ); - assert.equal(first.status, 200); - - const blocked = await commandPost( - makeContext({ - url: "https://local.test/api/command", - env, - headers: { "content-type": "application/json", cookie }, - body: JSON.stringify({ - type: "court.case.report", - payload: { caseId: "delegation-farming-forum-whale" }, - }), - }), - ); - assert.equal(blocked.status, 429); - const blockedJson = await blocked.json(); - assert.equal(blockedJson.error.code, "era_quota_exceeded"); - assert.equal(blockedJson.error.kind, "courtActions"); - - const duplicate = await commandPost( - makeContext({ - url: "https://local.test/api/command", - env, - headers: { "content-type": "application/json", cookie }, - body: JSON.stringify({ - type: "court.case.report", - payload: { caseId: "delegation-reroute-keeper-nyx" }, - }), - }), - ); - assert.equal(duplicate.status, 200); -}); - -test("era quota: formation actions limit blocks new joins but allows duplicates", async () => { - await resetAll(); - const env = baseEnv({ SIM_MAX_FORMATION_ACTIONS_PER_ERA: "1" }); - const cookie = await makeSessionCookie(env, "5QuotaAddr"); - - const first = await commandPost( - makeContext({ - url: "https://local.test/api/command", - env, - headers: { "content-type": "application/json", cookie }, - body: JSON.stringify({ - type: "formation.join", - payload: { proposalId: "evm-dev-starter-kit" }, - }), - }), - ); - assert.equal(first.status, 200); - - const blocked = await commandPost( - makeContext({ - url: "https://local.test/api/command", - env, - headers: { "content-type": "application/json", cookie }, - body: JSON.stringify({ - type: "formation.join", - payload: { proposalId: "mev-safe-dex-v1-launch-sprint" }, - }), - }), - ); - assert.equal(blocked.status, 429); - const blockedJson = await blocked.json(); - assert.equal(blockedJson.error.code, "era_quota_exceeded"); - assert.equal(blockedJson.error.kind, "formationActions"); - - const duplicate = await commandPost( - makeContext({ - url: "https://local.test/api/command", - env, - headers: { "content-type": "application/json", cookie }, - body: JSON.stringify({ - type: "formation.join", - payload: { proposalId: "evm-dev-starter-kit" }, - }), - }), - ); - assert.equal(duplicate.status, 200); -}); diff --git a/tests/api-command-formation.test.js b/tests/api-command-formation.test.js deleted file mode 100644 index 954b697..0000000 --- a/tests/api-command-formation.test.js +++ /dev/null @@ -1,198 +0,0 @@ -import assert from "node:assert/strict"; -import { test } from "node:test"; - -import { onRequestPost as commandPost } from "../api/routes/command.ts"; -import { onRequestGet as formationPageGet } from "../api/routes/proposals/[id]/formation.ts"; -import { getSessionCookieName, issueSession } from "../api/_lib/auth.ts"; -import { clearIdempotencyForTests } from "../api/_lib/idempotencyStore.ts"; -import { clearInlineReadModelsForTests } from "../api/_lib/readModelsStore.ts"; -import { clearFormationForTests } from "../api/_lib/formationStore.ts"; - -function makeContext({ url, env, params, method = "POST", headers, body }) { - return { - request: new Request(url, { method, headers, body }), - env, - params, - }; -} - -async function makeSessionCookie(env, address) { - const headers = new Headers(); - await issueSession(headers, env, "https://local.test/api/command", address); - const setCookie = headers.get("set-cookie"); - assert.ok(setCookie); - const tokenPair = setCookie.split(";")[0]; - const [name, value] = tokenPair.split("="); - assert.equal(name, getSessionCookieName()); - return `${name}=${value}`; -} - -const baseEnv = { - SESSION_SECRET: "test-secret", - DEV_BYPASS_GATE: "true", - DEV_INSECURE_COOKIES: "true", - READ_MODELS_INLINE: "true", - DEV_BYPASS_ADMIN: "true", -}; - -test("formation.join increments team slots in formation read model overlay", async () => { - clearFormationForTests(); - clearIdempotencyForTests(); - clearInlineReadModelsForTests(); - - const proposalId = "evm-dev-starter-kit"; - const cookie = await makeSessionCookie(baseEnv, "5JoinAddr"); - - const res = await commandPost( - makeContext({ - url: "https://local.test/api/command", - env: baseEnv, - headers: { "content-type": "application/json", cookie }, - body: JSON.stringify({ - type: "formation.join", - payload: { proposalId }, - }), - }), - ); - assert.equal(res.status, 200); - - const formationRes = await formationPageGet( - makeContext({ - url: `https://local.test/api/proposals/${proposalId}/formation`, - env: baseEnv, - params: { id: proposalId }, - method: "GET", - }), - ); - assert.equal(formationRes.status, 200); - const json = await formationRes.json(); - assert.equal(json.teamSlots, "2 / 3"); - assert.equal(json.milestones, "1 / 3"); - assert.ok(Array.isArray(json.lockedTeam)); - assert.ok( - json.lockedTeam.some((m) => String(m.name).toLowerCase().includes("5join")), - ); -}); - -test("formation.join enforces team slots capacity", async () => { - clearFormationForTests(); - clearIdempotencyForTests(); - clearInlineReadModelsForTests(); - - const proposalId = "evm-dev-starter-kit"; - - const c1 = await makeSessionCookie(baseEnv, "5JoinFill1"); - const r1 = await commandPost( - makeContext({ - url: "https://local.test/api/command", - env: baseEnv, - headers: { "content-type": "application/json", cookie: c1 }, - body: JSON.stringify({ - type: "formation.join", - payload: { proposalId }, - }), - }), - ); - assert.equal(r1.status, 200); - - const c2 = await makeSessionCookie(baseEnv, "5JoinFill2"); - const r2 = await commandPost( - makeContext({ - url: "https://local.test/api/command", - env: baseEnv, - headers: { "content-type": "application/json", cookie: c2 }, - body: JSON.stringify({ - type: "formation.join", - payload: { proposalId }, - }), - }), - ); - assert.equal(r2.status, 200); - - const c3 = await makeSessionCookie(baseEnv, "5JoinFill3"); - const r3 = await commandPost( - makeContext({ - url: "https://local.test/api/command", - env: baseEnv, - headers: { "content-type": "application/json", cookie: c3 }, - body: JSON.stringify({ - type: "formation.join", - payload: { proposalId }, - }), - }), - ); - assert.equal(r3.status, 409); -}); - -test("formation milestone submit + unlock updates milestones and progress", async () => { - clearFormationForTests(); - clearIdempotencyForTests(); - clearInlineReadModelsForTests(); - - const proposalId = "evm-dev-starter-kit"; - const cookie = await makeSessionCookie(baseEnv, "5MilestoneAddr"); - - const unlockFirst = await commandPost( - makeContext({ - url: "https://local.test/api/command", - env: baseEnv, - headers: { "content-type": "application/json", cookie }, - body: JSON.stringify({ - type: "formation.milestone.requestUnlock", - payload: { proposalId, milestoneIndex: 2 }, - }), - }), - ); - assert.equal(unlockFirst.status, 409); - - const submit = await commandPost( - makeContext({ - url: "https://local.test/api/command", - env: baseEnv, - headers: { "content-type": "application/json", cookie }, - body: JSON.stringify({ - type: "formation.milestone.submit", - payload: { proposalId, milestoneIndex: 2 }, - }), - }), - ); - assert.equal(submit.status, 200); - - const afterSubmit = await formationPageGet( - makeContext({ - url: `https://local.test/api/proposals/${proposalId}/formation`, - env: baseEnv, - params: { id: proposalId }, - method: "GET", - }), - ); - assert.equal(afterSubmit.status, 200); - const afterSubmitJson = await afterSubmit.json(); - assert.equal(afterSubmitJson.milestones, "1 / 3"); - - const unlock = await commandPost( - makeContext({ - url: "https://local.test/api/command", - env: baseEnv, - headers: { "content-type": "application/json", cookie }, - body: JSON.stringify({ - type: "formation.milestone.requestUnlock", - payload: { proposalId, milestoneIndex: 2 }, - }), - }), - ); - assert.equal(unlock.status, 200); - - const formationRes = await formationPageGet( - makeContext({ - url: `https://local.test/api/proposals/${proposalId}/formation`, - env: baseEnv, - params: { id: proposalId }, - method: "GET", - }), - ); - assert.equal(formationRes.status, 200); - const json = await formationRes.json(); - assert.equal(json.milestones, "2 / 3"); - assert.equal(json.progress, "67%"); -}); diff --git a/tests/api-command-meta-governance-no-budget.test.js b/tests/api-command-meta-governance-no-budget.test.js deleted file mode 100644 index 57e7970..0000000 --- a/tests/api-command-meta-governance-no-budget.test.js +++ /dev/null @@ -1,101 +0,0 @@ -import assert from "node:assert/strict"; -import { test } from "node:test"; - -import { onRequestPost as commandPost } from "../api/routes/command.ts"; -import { getSessionCookieName, issueSession } from "../api/_lib/auth.ts"; -import { clearIdempotencyForTests } from "../api/_lib/idempotencyStore.ts"; -import { clearProposalDraftsForTests } from "../api/_lib/proposalDraftsStore.ts"; -import { clearInlineReadModelsForTests } from "../api/_lib/readModelsStore.ts"; -import { clearProposalsForTests } from "../api/_lib/proposalsStore.ts"; - -function makeContext({ url, env, params, method = "POST", headers, body }) { - return { - request: new Request(url, { method, headers, body }), - env, - params, - }; -} - -async function makeSessionCookie(env, address) { - const headers = new Headers(); - await issueSession(headers, env, "https://local.test/api/command", address); - const setCookie = headers.get("set-cookie"); - assert.ok(setCookie); - const tokenPair = setCookie.split(";")[0]; - const [name, value] = tokenPair.split("="); - assert.equal(name, getSessionCookieName()); - return `${name}=${value}`; -} - -const baseEnv = { - SESSION_SECRET: "test-secret", - DEV_BYPASS_GATE: "true", - DEV_INSECURE_COOKIES: "true", - READ_MODELS_INLINE_EMPTY: "true", - DEV_BYPASS_ADMIN: "true", -}; - -test("meta-governance draft can submit without budget items", async () => { - clearIdempotencyForTests(); - clearInlineReadModelsForTests(); - clearProposalDraftsForTests(); - clearProposalsForTests(); - - const cookie = await makeSessionCookie(baseEnv, "5MetaGovernor"); - - const saveRes = await commandPost( - makeContext({ - url: "https://local.test/api/command", - env: baseEnv, - headers: { "content-type": "application/json", cookie }, - body: JSON.stringify({ - type: "proposal.draft.save", - payload: { - form: { - title: "Create chamber: Test", - chamberId: "general", - summary: "", - what: "Create the Product chamber in the simulation.", - why: "We need a specialization chamber for product decisions.", - how: "Define chamber parameters and seed initial members.", - metaGovernance: { - action: "chamber.create", - chamberId: "test-chamber", - title: "Test chamber", - genesisMembers: ["5FGenesisA", "5FGenesisB"], - }, - timeline: [], - outputs: [], - budgetItems: [], - aboutMe: "", - attachments: [], - agreeRules: true, - confirmBudget: true, - }, - }, - }), - }), - ); - assert.equal(saveRes.status, 200); - const saved = await saveRes.json(); - assert.equal(saved.ok, true); - assert.equal(saved.type, "proposal.draft.save"); - assert.ok(typeof saved.draftId === "string"); - - const submitRes = await commandPost( - makeContext({ - url: "https://local.test/api/command", - env: baseEnv, - headers: { "content-type": "application/json", cookie }, - body: JSON.stringify({ - type: "proposal.submitToPool", - payload: { draftId: saved.draftId }, - }), - }), - ); - assert.equal(submitRes.status, 200); - const submitted = await submitRes.json(); - assert.equal(submitted.ok, true); - assert.equal(submitted.type, "proposal.submitToPool"); - assert.ok(typeof submitted.proposalId === "string"); -}); diff --git a/tests/api-command-pool-vote.test.js b/tests/api-command-pool-vote.test.js deleted file mode 100644 index 19c1b23..0000000 --- a/tests/api-command-pool-vote.test.js +++ /dev/null @@ -1,240 +0,0 @@ -import assert from "node:assert/strict"; -import { test } from "node:test"; - -import { onRequestPost as commandPost } from "../api/routes/command.ts"; -import { onRequestGet as proposalsGet } from "../api/routes/proposals/index.ts"; -import { onRequestGet as poolPageGet } from "../api/routes/proposals/[id]/pool.ts"; -import { getSessionCookieName, issueSession } from "../api/_lib/auth.ts"; -import { clearIdempotencyForTests } from "../api/_lib/idempotencyStore.ts"; -import { clearPoolVotesForTests } from "../api/_lib/poolVotesStore.ts"; -import { clearInlineReadModelsForTests } from "../api/_lib/readModelsStore.ts"; -import { - clearChamberMembershipsForTests, - ensureChamberMembership, -} from "../api/_lib/chamberMembershipsStore.ts"; - -function makeContext({ url, env, params, method = "POST", headers, body }) { - return { - request: new Request(url, { method, headers, body }), - env, - params, - }; -} - -async function makeSessionCookie(env, address) { - const headers = new Headers(); - await issueSession(headers, env, "https://local.test/api/command", address); - const setCookie = headers.get("set-cookie"); - assert.ok(setCookie); - const tokenPair = setCookie.split(";")[0]; - const [name, value] = tokenPair.split("="); - assert.equal(name, getSessionCookieName()); - await ensureChamberMembership(env, { - address, - chamberId: "general", - source: "test", - }); - await ensureChamberMembership(env, { - address, - chamberId: "engineering", - source: "test", - }); - return `${name}=${value}`; -} - -const baseEnv = { - SESSION_SECRET: "test-secret", - DEV_BYPASS_GATE: "true", - DEV_INSECURE_COOKIES: "true", - READ_MODELS_INLINE: "true", - DEV_BYPASS_ADMIN: "true", -}; - -async function seedMembers(env, input) { - for (let i = 0; i < input.count; i += 1) { - await ensureChamberMembership(env, { - address: `${input.prefix}${i}`, - chamberId: input.chamberId, - source: "test", - }); - } -} - -test("POST /api/command rejects when not authenticated", async () => { - await clearPoolVotesForTests(); - clearIdempotencyForTests(); - clearInlineReadModelsForTests(); - clearChamberMembershipsForTests(); - const res = await commandPost( - makeContext({ - url: "https://local.test/api/command", - env: baseEnv, - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - type: "pool.vote", - payload: { proposalId: "biometric-account-recovery", direction: "up" }, - }), - }), - ); - assert.equal(res.status, 401); -}); - -test("POST /api/command pool.vote stores a single vote and supports idempotency", async () => { - await clearPoolVotesForTests(); - clearIdempotencyForTests(); - clearInlineReadModelsForTests(); - clearChamberMembershipsForTests(); - - await seedMembers(baseEnv, { - prefix: "5EngMember", - chamberId: "engineering", - count: 10, - }); - - const cookie = await makeSessionCookie(baseEnv, "5FakeAddr"); - const idempotencyKey = "idem-00000001"; - - const requestBody = { - type: "pool.vote", - payload: { proposalId: "biometric-account-recovery", direction: "up" }, - idempotencyKey, - }; - - const res1 = await commandPost( - makeContext({ - url: "https://local.test/api/command", - env: baseEnv, - headers: { - "content-type": "application/json", - cookie, - "idempotency-key": idempotencyKey, - }, - body: JSON.stringify(requestBody), - }), - ); - assert.equal(res1.status, 200); - const json1 = await res1.json(); - assert.equal(json1.ok, true); - assert.deepEqual(json1.counts, { upvotes: 1, downvotes: 0 }); - - const res2 = await commandPost( - makeContext({ - url: "https://local.test/api/command", - env: baseEnv, - headers: { - "content-type": "application/json", - cookie, - "idempotency-key": idempotencyKey, - }, - body: JSON.stringify(requestBody), - }), - ); - assert.equal(res2.status, 200); - const json2 = await res2.json(); - assert.deepEqual(json2, json1); - - const res3 = await commandPost( - makeContext({ - url: "https://local.test/api/command", - env: baseEnv, - headers: { - "content-type": "application/json", - cookie, - "idempotency-key": idempotencyKey, - }, - body: JSON.stringify({ - type: "pool.vote", - payload: { - proposalId: "biometric-account-recovery", - direction: "down", - }, - }), - }), - ); - assert.equal(res3.status, 409); -}); - -test("GET /api/proposals/:id/pool overlays live vote counts", async () => { - await clearPoolVotesForTests(); - clearIdempotencyForTests(); - clearInlineReadModelsForTests(); - clearChamberMembershipsForTests(); - - await seedMembers(baseEnv, { - prefix: "5EngMember", - chamberId: "engineering", - count: 10, - }); - - const cookie = await makeSessionCookie(baseEnv, "5FakeAddr"); - const res1 = await commandPost( - makeContext({ - url: "https://local.test/api/command", - env: baseEnv, - headers: { "content-type": "application/json", cookie }, - body: JSON.stringify({ - type: "pool.vote", - payload: { proposalId: "biometric-account-recovery", direction: "up" }, - }), - }), - ); - assert.equal(res1.status, 200); - - const res2 = await poolPageGet( - makeContext({ - url: "https://local.test/api/proposals/biometric-account-recovery/pool", - env: baseEnv, - params: { id: "biometric-account-recovery" }, - method: "GET", - }), - ); - assert.equal(res2.status, 200); - const json = await res2.json(); - assert.equal(json.title, "Biometric Account Recovery & Key Rotation Pallet"); - assert.equal(json.upvotes, 1); - assert.equal(json.downvotes, 0); -}); - -test("pool quorum auto-advances proposal from pool → vote", async () => { - await clearPoolVotesForTests(); - clearIdempotencyForTests(); - clearInlineReadModelsForTests(); - clearChamberMembershipsForTests(); - - const proposalId = "biometric-account-recovery"; - - for (let i = 0; i < 200; i += 1) { - const address = `5FakeAddr${i}`; - const cookie = await makeSessionCookie(baseEnv, address); - const direction = i < 100 ? "up" : "down"; - const res = await commandPost( - makeContext({ - url: "https://local.test/api/command", - env: baseEnv, - headers: { "content-type": "application/json", cookie }, - body: JSON.stringify({ - type: "pool.vote", - payload: { proposalId, direction }, - }), - }), - ); - if (res.status === 200) continue; - if (res.status === 409) break; // proposal advanced to vote - assert.equal(res.status, 200); - } - - const res = await proposalsGet( - makeContext({ - url: "https://local.test/api/proposals", - env: baseEnv, - method: "GET", - }), - ); - assert.equal(res.status, 200); - const json = await res.json(); - const item = json.items.find((p) => p.id === proposalId); - assert.ok(item); - assert.equal(item.stage, "vote"); - assert.equal(item.summaryPill, "Chamber vote"); - assert.equal(item.stageData[0].title, "Voting quorum"); -}); diff --git a/tests/api-command-rate-limit.test.js b/tests/api-command-rate-limit.test.js deleted file mode 100644 index cf055d5..0000000 --- a/tests/api-command-rate-limit.test.js +++ /dev/null @@ -1,110 +0,0 @@ -import assert from "node:assert/strict"; -import { test } from "node:test"; - -import { onRequestPost as commandPost } from "../api/routes/command.ts"; -import { getSessionCookieName, issueSession } from "../api/_lib/auth.ts"; -import { clearApiRateLimitsForTests } from "../api/_lib/apiRateLimitStore.ts"; -import { clearIdempotencyForTests } from "../api/_lib/idempotencyStore.ts"; -import { clearPoolVotesForTests } from "../api/_lib/poolVotesStore.ts"; -import { clearInlineReadModelsForTests } from "../api/_lib/readModelsStore.ts"; -import { - clearChamberMembershipsForTests, - ensureChamberMembership, -} from "../api/_lib/chamberMembershipsStore.ts"; - -function makeContext({ url, env, params, method = "POST", headers, body }) { - return { - request: new Request(url, { method, headers, body }), - env, - params, - }; -} - -async function makeSessionCookie(env, address) { - const headers = new Headers(); - await issueSession(headers, env, "https://local.test/api/command", address); - const setCookie = headers.get("set-cookie"); - assert.ok(setCookie); - const tokenPair = setCookie.split(";")[0]; - const [name, value] = tokenPair.split("="); - assert.equal(name, getSessionCookieName()); - await ensureChamberMembership(env, { - address, - chamberId: "general", - source: "test", - }); - await ensureChamberMembership(env, { - address, - chamberId: "engineering", - source: "test", - }); - return `${name}=${value}`; -} - -const baseEnv = { - SESSION_SECRET: "test-secret", - DEV_BYPASS_GATE: "true", - DEV_INSECURE_COOKIES: "true", - READ_MODELS_INLINE: "true", - DEV_BYPASS_ADMIN: "true", - SIM_COMMAND_RATE_LIMIT_PER_MINUTE_ADDRESS: "2", - SIM_COMMAND_RATE_LIMIT_PER_MINUTE_IP: "1000", -}; - -async function seedMembers(env, input) { - for (let i = 0; i < input.count; i += 1) { - await ensureChamberMembership(env, { - address: `${input.prefix}${i}`, - chamberId: input.chamberId, - source: "test", - }); - } -} - -test("POST /api/command is rate limited per address (memory mode)", async () => { - await clearPoolVotesForTests(); - clearIdempotencyForTests(); - clearInlineReadModelsForTests(); - clearApiRateLimitsForTests(); - clearChamberMembershipsForTests(); - - await seedMembers(baseEnv, { - prefix: "5EngMember", - chamberId: "engineering", - count: 10, - }); - - const cookie = await makeSessionCookie(baseEnv, "5FakeAddr"); - - const makeRequest = () => - commandPost( - makeContext({ - url: "https://local.test/api/command", - env: baseEnv, - headers: { - "content-type": "application/json", - cookie, - "x-forwarded-for": "203.0.113.10", - }, - body: JSON.stringify({ - type: "pool.vote", - payload: { - proposalId: "biometric-account-recovery", - direction: "up", - }, - }), - }), - ); - - const ok1 = await makeRequest(); - assert.equal(ok1.status, 200); - const ok2 = await makeRequest(); - assert.equal(ok2.status, 200); - - const limited = await makeRequest(); - assert.equal(limited.status, 429); - const json = await limited.json(); - assert.equal(json.error.message, "Rate limited"); - assert.equal(json.error.scope, "address"); - assert.equal(typeof json.error.retryAfterSeconds, "number"); -}); diff --git a/tests/api-command-system-draft-minimal.test.js b/tests/api-command-system-draft-minimal.test.js deleted file mode 100644 index 566fdde..0000000 --- a/tests/api-command-system-draft-minimal.test.js +++ /dev/null @@ -1,91 +0,0 @@ -import assert from "node:assert/strict"; -import { test } from "node:test"; - -import { onRequestPost as commandPost } from "../api/routes/command.ts"; -import { getSessionCookieName, issueSession } from "../api/_lib/auth.ts"; -import { clearIdempotencyForTests } from "../api/_lib/idempotencyStore.ts"; -import { clearProposalDraftsForTests } from "../api/_lib/proposalDraftsStore.ts"; -import { clearInlineReadModelsForTests } from "../api/_lib/readModelsStore.ts"; -import { clearProposalsForTests } from "../api/_lib/proposalsStore.ts"; - -function makeContext({ url, env, params, method = "POST", headers, body }) { - return { - request: new Request(url, { method, headers, body }), - env, - params, - }; -} - -async function makeSessionCookie(env, address) { - const headers = new Headers(); - await issueSession(headers, env, "https://local.test/api/command", address); - const setCookie = headers.get("set-cookie"); - assert.ok(setCookie); - const tokenPair = setCookie.split(";")[0]; - const [name, value] = tokenPair.split("="); - assert.equal(name, getSessionCookieName()); - return `${name}=${value}`; -} - -const baseEnv = { - SESSION_SECRET: "test-secret", - DEV_BYPASS_GATE: "true", - DEV_INSECURE_COOKIES: "true", - READ_MODELS_INLINE_EMPTY: "true", - DEV_BYPASS_ADMIN: "true", -}; - -test("system draft submits with minimal fields", async () => { - clearIdempotencyForTests(); - clearInlineReadModelsForTests(); - clearProposalDraftsForTests(); - clearProposalsForTests(); - - const cookie = await makeSessionCookie(baseEnv, "5SystemGovernor"); - - const saveRes = await commandPost( - makeContext({ - url: "https://local.test/api/command", - env: baseEnv, - headers: { "content-type": "application/json", cookie }, - body: JSON.stringify({ - type: "proposal.draft.save", - payload: { - form: { - templateId: "system", - title: "Create chamber: Ops", - chamberId: "general", - metaGovernance: { - action: "chamber.create", - chamberId: "ops", - title: "Ops chamber", - }, - agreeRules: true, - confirmBudget: true, - }, - }, - }), - }), - ); - assert.equal(saveRes.status, 200); - const saved = await saveRes.json(); - assert.equal(saved.ok, true); - assert.equal(saved.type, "proposal.draft.save"); - - const submitRes = await commandPost( - makeContext({ - url: "https://local.test/api/command", - env: baseEnv, - headers: { "content-type": "application/json", cookie }, - body: JSON.stringify({ - type: "proposal.submitToPool", - payload: { draftId: saved.draftId }, - }), - }), - ); - assert.equal(submitRes.status, 200); - const submitted = await submitRes.json(); - assert.equal(submitted.ok, true); - assert.equal(submitted.type, "proposal.submitToPool"); - assert.ok(typeof submitted.proposalId === "string"); -}); diff --git a/tests/api-delegation-weighted-votes.test.js b/tests/api-delegation-weighted-votes.test.js deleted file mode 100644 index a2a66c6..0000000 --- a/tests/api-delegation-weighted-votes.test.js +++ /dev/null @@ -1,163 +0,0 @@ -import assert from "node:assert/strict"; -import { test } from "node:test"; - -import { onRequestPost as commandPost } from "../api/routes/command.ts"; -import { onRequestGet as chamberPageGet } from "../api/routes/proposals/[id]/chamber.ts"; -import { getSessionCookieName, issueSession } from "../api/_lib/auth.ts"; -import { clearChamberVotesForTests } from "../api/_lib/chamberVotesStore.ts"; -import { - clearChamberMembershipsForTests, - ensureChamberMembership, -} from "../api/_lib/chamberMembershipsStore.ts"; -import { clearDelegationsForTests } from "../api/_lib/delegationsStore.ts"; -import { clearIdempotencyForTests } from "../api/_lib/idempotencyStore.ts"; -import { clearInlineReadModelsForTests } from "../api/_lib/readModelsStore.ts"; - -function makeContext({ url, env, params, method = "POST", headers, body }) { - return { - request: new Request(url, { method, headers, body }), - env, - params, - }; -} - -async function makeSessionCookie(env, address) { - const headers = new Headers(); - await issueSession(headers, env, "https://local.test/api/command", address); - const setCookie = headers.get("set-cookie"); - assert.ok(setCookie); - const tokenPair = setCookie.split(";")[0]; - const [name, value] = tokenPair.split("="); - assert.equal(name, getSessionCookieName()); - return `${name}=${value}`; -} - -const env = { - SESSION_SECRET: "test-secret", - DEV_BYPASS_GATE: "true", - DEV_INSECURE_COOKIES: "true", - READ_MODELS_INLINE: "true", - DEV_BYPASS_ADMIN: "true", -}; - -test("delegation increases chamber vote weight (delegators who vote are excluded)", async () => { - await clearChamberVotesForTests(); - clearChamberMembershipsForTests(); - clearDelegationsForTests(); - clearIdempotencyForTests(); - clearInlineReadModelsForTests(); - - const chamberId = "general"; - const proposalId = "tier-decay-v1"; - - const delegatee = "5DelegateeAddr"; - const delegatorVoter = "5DelegatorVoterAddr"; - const delegatorNonVoter = "5DelegatorNonVoterAddr"; - - await ensureChamberMembership(env, { - address: delegatee, - chamberId, - source: "test", - }); - await ensureChamberMembership(env, { - address: delegatorVoter, - chamberId, - source: "test", - }); - await ensureChamberMembership(env, { - address: delegatorNonVoter, - chamberId, - source: "test", - }); - for (let i = 0; i < 7; i += 1) { - await ensureChamberMembership(env, { - address: `5ExtraGov${i}`, - chamberId, - source: "test", - }); - } - - const cookieDelegatorVoter = await makeSessionCookie(env, delegatorVoter); - const cookieDelegatorNonVoter = await makeSessionCookie( - env, - delegatorNonVoter, - ); - - const set1 = await commandPost( - makeContext({ - url: "https://local.test/api/command", - env, - headers: { - "content-type": "application/json", - cookie: cookieDelegatorVoter, - }, - body: JSON.stringify({ - type: "delegation.set", - payload: { chamberId, delegateeAddress: delegatee }, - }), - }), - ); - assert.equal(set1.status, 200); - - const set2 = await commandPost( - makeContext({ - url: "https://local.test/api/command", - env, - headers: { - "content-type": "application/json", - cookie: cookieDelegatorNonVoter, - }, - body: JSON.stringify({ - type: "delegation.set", - payload: { chamberId, delegateeAddress: delegatee }, - }), - }), - ); - assert.equal(set2.status, 200); - - const cookieDelegatee = await makeSessionCookie(env, delegatee); - const vote1 = await commandPost( - makeContext({ - url: "https://local.test/api/command", - env, - headers: { "content-type": "application/json", cookie: cookieDelegatee }, - body: JSON.stringify({ - type: "chamber.vote", - payload: { proposalId, choice: "yes", score: 7 }, - }), - }), - ); - assert.equal(vote1.status, 200); - const voteJson1 = await vote1.json(); - assert.deepEqual(voteJson1.counts, { yes: 3, no: 0, abstain: 0 }); - - const vote2 = await commandPost( - makeContext({ - url: "https://local.test/api/command", - env, - headers: { - "content-type": "application/json", - cookie: cookieDelegatorVoter, - }, - body: JSON.stringify({ - type: "chamber.vote", - payload: { proposalId, choice: "no" }, - }), - }), - ); - assert.equal(vote2.status, 200); - const voteJson2 = await vote2.json(); - assert.deepEqual(voteJson2.counts, { yes: 2, no: 1, abstain: 0 }); - - const page = await chamberPageGet( - makeContext({ - url: "https://local.test/api/proposals/tier-decay-v1/chamber", - env, - params: { id: proposalId }, - method: "GET", - }), - ); - assert.equal(page.status, 200); - const pageJson = await page.json(); - assert.deepEqual(pageJson.votes, { yes: 2, no: 1, abstain: 0 }); -}); diff --git a/tests/api-era-activity.test.js b/tests/api-era-activity.test.js deleted file mode 100644 index ac91976..0000000 --- a/tests/api-era-activity.test.js +++ /dev/null @@ -1,205 +0,0 @@ -import assert from "node:assert/strict"; -import { test } from "node:test"; - -import { onRequestPost as commandPost } from "../api/routes/command.ts"; -import { onRequestGet as myGovernanceGet } from "../api/routes/my-governance/index.ts"; -import { onRequestPost as advanceEraPost } from "../api/routes/clock/advance-era.ts"; -import { getSessionCookieName, issueSession } from "../api/_lib/auth.ts"; -import { clearChamberVotesForTests } from "../api/_lib/chamberVotesStore.ts"; -import { clearCourtsForTests } from "../api/_lib/courtsStore.ts"; -import { clearEraForTests } from "../api/_lib/eraStore.ts"; -import { clearFormationForTests } from "../api/_lib/formationStore.ts"; -import { clearIdempotencyForTests } from "../api/_lib/idempotencyStore.ts"; -import { clearPoolVotesForTests } from "../api/_lib/poolVotesStore.ts"; -import { clearInlineReadModelsForTests } from "../api/_lib/readModelsStore.ts"; -import { - clearChamberMembershipsForTests, - ensureChamberMembership, -} from "../api/_lib/chamberMembershipsStore.ts"; - -function makeContext({ url, env, params, method = "POST", headers, body }) { - return { - request: new Request(url, { method, headers, body }), - env, - params, - }; -} - -async function makeSessionCookie(env, address) { - const headers = new Headers(); - await issueSession(headers, env, "https://local.test/api/command", address); - const setCookie = headers.get("set-cookie"); - assert.ok(setCookie); - const tokenPair = setCookie.split(";")[0]; - const [name, value] = tokenPair.split("="); - assert.equal(name, getSessionCookieName()); - return `${name}=${value}`; -} - -function getDoneCount(myGovJson, label) { - const actions = myGovJson?.eraActivity?.actions; - assert.ok(Array.isArray(actions), "Expected eraActivity.actions array"); - const action = actions.find((entry) => entry?.label === label); - assert.ok(action, `Expected action with label "${label}"`); - assert.equal(typeof action.done, "number", "Expected action.done number"); - return action.done; -} - -const baseEnv = { - SESSION_SECRET: "test-secret", - DEV_BYPASS_GATE: "true", - DEV_INSECURE_COOKIES: "true", - READ_MODELS_INLINE: "true", - DEV_BYPASS_ADMIN: "true", -}; - -test("My Governance era activity counts only the first action per entity per era", async () => { - await clearPoolVotesForTests(); - await clearChamberVotesForTests(); - clearChamberMembershipsForTests(); - clearCourtsForTests(); - clearFormationForTests(); - clearEraForTests(); - clearIdempotencyForTests(); - clearInlineReadModelsForTests(); - - const address = "5EraAddr"; - const cookie = await makeSessionCookie(baseEnv, address); - await ensureChamberMembership(baseEnv, { - address, - chamberId: "general", - source: "test", - }); - await ensureChamberMembership(baseEnv, { - address, - chamberId: "engineering", - source: "test", - }); - for (let i = 0; i < 10; i += 1) { - await ensureChamberMembership(baseEnv, { - address: `5EngMember${i}`, - chamberId: "engineering", - source: "test", - }); - } - - const poolProposalId = "biometric-account-recovery"; - - const vote1 = await commandPost( - makeContext({ - url: "https://local.test/api/command", - env: baseEnv, - headers: { "content-type": "application/json", cookie }, - body: JSON.stringify({ - type: "pool.vote", - payload: { proposalId: poolProposalId, direction: "up" }, - }), - }), - ); - assert.equal(vote1.status, 200); - - const vote2 = await commandPost( - makeContext({ - url: "https://local.test/api/command", - env: baseEnv, - headers: { "content-type": "application/json", cookie }, - body: JSON.stringify({ - type: "pool.vote", - payload: { proposalId: poolProposalId, direction: "down" }, - }), - }), - ); - assert.equal(vote2.status, 200); - - const chamberProposalId = "tier-decay-v1"; - const chamberVote1 = await commandPost( - makeContext({ - url: "https://local.test/api/command", - env: baseEnv, - headers: { "content-type": "application/json", cookie }, - body: JSON.stringify({ - type: "chamber.vote", - payload: { proposalId: chamberProposalId, choice: "yes", score: 8 }, - }), - }), - ); - assert.equal(chamberVote1.status, 200); - - const chamberVote2 = await commandPost( - makeContext({ - url: "https://local.test/api/command", - env: baseEnv, - headers: { "content-type": "application/json", cookie }, - body: JSON.stringify({ - type: "chamber.vote", - payload: { proposalId: chamberProposalId, choice: "no" }, - }), - }), - ); - assert.equal(chamberVote2.status, 200); - - const caseId = "delegation-farming-forum-whale"; - const report1 = await commandPost( - makeContext({ - url: "https://local.test/api/command", - env: baseEnv, - headers: { "content-type": "application/json", cookie }, - body: JSON.stringify({ - type: "court.case.report", - payload: { caseId }, - }), - }), - ); - assert.equal(report1.status, 200); - - const report2 = await commandPost( - makeContext({ - url: "https://local.test/api/command", - env: baseEnv, - headers: { "content-type": "application/json", cookie }, - body: JSON.stringify({ - type: "court.case.report", - payload: { caseId }, - }), - }), - ); - assert.equal(report2.status, 200); - - const myGovRes = await myGovernanceGet( - makeContext({ - url: "https://local.test/api/my-governance", - env: baseEnv, - method: "GET", - headers: { cookie }, - }), - ); - assert.equal(myGovRes.status, 200); - const myGovJson = await myGovRes.json(); - assert.equal(getDoneCount(myGovJson, "Pool votes"), 1); - assert.equal(getDoneCount(myGovJson, "Chamber votes"), 1); - assert.equal(getDoneCount(myGovJson, "Court actions"), 1); - - const advanceRes = await advanceEraPost( - makeContext({ - url: "https://local.test/api/clock/advance-era", - env: baseEnv, - method: "POST", - headers: { "content-type": "application/json" }, - }), - ); - assert.equal(advanceRes.status, 200); - - const myGovAfterRes = await myGovernanceGet( - makeContext({ - url: "https://local.test/api/my-governance", - env: baseEnv, - method: "GET", - headers: { cookie }, - }), - ); - assert.equal(myGovAfterRes.status, 200); - const myGovAfterJson = await myGovAfterRes.json(); - assert.equal(getDoneCount(myGovAfterJson, "Pool votes"), 0); - assert.equal(getDoneCount(myGovAfterJson, "Chamber votes"), 0); - assert.equal(getDoneCount(myGovAfterJson, "Court actions"), 0); -}); diff --git a/tests/api-era-rollup-next-era-baseline.test.js b/tests/api-era-rollup-next-era-baseline.test.js deleted file mode 100644 index aede316..0000000 --- a/tests/api-era-rollup-next-era-baseline.test.js +++ /dev/null @@ -1,139 +0,0 @@ -import assert from "node:assert/strict"; -import { test } from "node:test"; - -import { onRequestPost as commandPost } from "../api/routes/command.ts"; -import { onRequestGet as clockGet } from "../api/routes/clock/index.ts"; -import { onRequestPost as advanceEraPost } from "../api/routes/clock/advance-era.ts"; -import { onRequestPost as rollupEraPost } from "../api/routes/clock/rollup-era.ts"; -import { getSessionCookieName, issueSession } from "../api/_lib/auth.ts"; -import { clearChamberVotesForTests } from "../api/_lib/chamberVotesStore.ts"; -import { clearClockForTests } from "../api/_lib/clockStore.ts"; -import { clearCourtsForTests } from "../api/_lib/courtsStore.ts"; -import { - clearChamberMembershipsForTests, - ensureChamberMembership, -} from "../api/_lib/chamberMembershipsStore.ts"; -import { clearEraRollupsForTests } from "../api/_lib/eraRollupStore.ts"; -import { clearEraForTests } from "../api/_lib/eraStore.ts"; -import { clearFormationForTests } from "../api/_lib/formationStore.ts"; -import { clearIdempotencyForTests } from "../api/_lib/idempotencyStore.ts"; -import { clearInlineReadModelsForTests } from "../api/_lib/readModelsStore.ts"; -import { clearPoolVotesForTests } from "../api/_lib/poolVotesStore.ts"; - -function makeContext({ url, env, params, method = "POST", headers, body }) { - return { - request: new Request(url, { method, headers, body }), - env, - params, - }; -} - -async function makeSessionCookie(env, address) { - const headers = new Headers(); - await issueSession(headers, env, "https://local.test/api/command", address); - const setCookie = headers.get("set-cookie"); - assert.ok(setCookie); - const tokenPair = setCookie.split(";")[0]; - const [name, value] = tokenPair.split("="); - assert.equal(name, getSessionCookieName()); - return `${name}=${value}`; -} - -const env = { - SESSION_SECRET: "test-secret", - DEV_INSECURE_COOKIES: "true", - READ_MODELS_INLINE: "true", - DEV_BYPASS_ADMIN: "true", - DEV_BYPASS_GATE: "true", - SIM_ACTIVE_GOVERNORS: "150", - SIM_REQUIRED_POOL_VOTES: "1", - SIM_REQUIRED_CHAMBER_VOTES: "0", - SIM_REQUIRED_COURT_ACTIONS: "0", - SIM_REQUIRED_FORMATION_ACTIONS: "0", -}; - -test("rollup writes next-era activeGovernors baseline", async () => { - await clearPoolVotesForTests(); - await clearChamberVotesForTests(); - clearChamberMembershipsForTests(); - clearCourtsForTests(); - clearFormationForTests(); - clearClockForTests(); - clearEraForTests(); - clearEraRollupsForTests(); - clearIdempotencyForTests(); - clearInlineReadModelsForTests(); - - const clockRes1 = await clockGet( - makeContext({ - url: "https://local.test/api/clock", - env, - method: "GET", - }), - ); - assert.equal(clockRes1.status, 200); - const clockJson1 = await clockRes1.json(); - const era = clockJson1.currentEra; - assert.equal(era, 0); - assert.equal(clockJson1.activeGovernors, 150); - - const address = "5RollupBaselineAddr"; - await ensureChamberMembership(env, { - address, - chamberId: "general", - source: "test", - }); - await ensureChamberMembership(env, { - address, - chamberId: "engineering", - source: "test", - }); - const cookie = await makeSessionCookie(env, address); - - const poolVote = await commandPost( - makeContext({ - url: "https://local.test/api/command", - env, - headers: { "content-type": "application/json", cookie }, - body: JSON.stringify({ - type: "pool.vote", - payload: { proposalId: "biometric-account-recovery", direction: "up" }, - }), - }), - ); - assert.equal(poolVote.status, 200); - - const rollupRes = await rollupEraPost( - makeContext({ - url: "https://local.test/api/clock/rollup-era", - env, - headers: { "content-type": "application/json" }, - body: JSON.stringify({ era }), - }), - ); - assert.equal(rollupRes.status, 200); - const rollupJson = await rollupRes.json(); - assert.equal(rollupJson.activeGovernorsNextEra, 1); - - const advanceRes = await advanceEraPost( - makeContext({ - url: "https://local.test/api/clock/advance-era", - env, - headers: { "content-type": "application/json" }, - body: JSON.stringify({}), - }), - ); - assert.equal(advanceRes.status, 200); - - const clockRes2 = await clockGet( - makeContext({ - url: "https://local.test/api/clock", - env, - method: "GET", - }), - ); - assert.equal(clockRes2.status, 200); - const clockJson2 = await clockRes2.json(); - assert.equal(clockJson2.currentEra, 1); - assert.equal(clockJson2.activeGovernors, 1); -}); diff --git a/tests/api-era-rollup-validator-gate.test.js b/tests/api-era-rollup-validator-gate.test.js deleted file mode 100644 index 100e4f9..0000000 --- a/tests/api-era-rollup-validator-gate.test.js +++ /dev/null @@ -1,224 +0,0 @@ -import assert from "node:assert/strict"; -import { test } from "node:test"; - -import { Keyring } from "@polkadot/keyring"; -import { cryptoWaitReady, xxhashAsHex } from "@polkadot/util-crypto"; -import { u8aToHex } from "@polkadot/util"; - -import { onRequestPost as commandPost } from "../api/routes/command.ts"; -import { onRequestGet as clockGet } from "../api/routes/clock/index.ts"; -import { onRequestPost as rollupEraPost } from "../api/routes/clock/rollup-era.ts"; -import { getSessionCookieName, issueSession } from "../api/_lib/auth.ts"; -import { clearChamberVotesForTests } from "../api/_lib/chamberVotesStore.ts"; -import { clearCourtsForTests } from "../api/_lib/courtsStore.ts"; -import { - clearChamberMembershipsForTests, - ensureChamberMembership, -} from "../api/_lib/chamberMembershipsStore.ts"; -import { - clearEraRollupsForTests, - getEraUserStatus, -} from "../api/_lib/eraRollupStore.ts"; -import { clearEraForTests } from "../api/_lib/eraStore.ts"; -import { clearFormationForTests } from "../api/_lib/formationStore.ts"; -import { clearIdempotencyForTests } from "../api/_lib/idempotencyStore.ts"; -import { clearInlineReadModelsForTests } from "../api/_lib/readModelsStore.ts"; -import { clearPoolVotesForTests } from "../api/_lib/poolVotesStore.ts"; - -function makeContext({ url, env, params, method = "POST", headers, body }) { - return { - request: new Request(url, { method, headers, body }), - env, - params, - }; -} - -async function makeSessionCookie(env, address) { - const headers = new Headers(); - await issueSession(headers, env, "https://local.test/api/command", address); - const setCookie = headers.get("set-cookie"); - assert.ok(setCookie); - const tokenPair = setCookie.split(";")[0]; - const [name, value] = tokenPair.split("="); - assert.equal(name, getSessionCookieName()); - return `${name}=${value}`; -} - -function scaleEncodeVecAccountId32(publicKeys) { - if (publicKeys.length > 63) throw new Error("test helper: too many keys"); - const lengthByte = (publicKeys.length << 2) | 0; // compact-encoded small int - const out = new Uint8Array(1 + publicKeys.length * 32); - out[0] = lengthByte; - publicKeys.forEach((pk, i) => { - out.set(pk, 1 + i * 32); - }); - return out; -} - -const baseEnv = { - SESSION_SECRET: "test-secret", - DEV_INSECURE_COOKIES: "true", - READ_MODELS_INLINE: "true", - DEV_BYPASS_ADMIN: "true", - SIM_REQUIRED_POOL_VOTES: "1", - SIM_REQUIRED_CHAMBER_VOTES: "0", - SIM_REQUIRED_COURT_ACTIONS: "0", - SIM_REQUIRED_FORMATION_ACTIONS: "0", -}; - -test("rollup: active governors next era are filtered by Session::Validators", async () => { - await cryptoWaitReady(); - - await clearPoolVotesForTests(); - await clearChamberVotesForTests(); - clearChamberMembershipsForTests(); - clearCourtsForTests(); - clearFormationForTests(); - clearEraForTests(); - clearEraRollupsForTests(); - clearIdempotencyForTests(); - clearInlineReadModelsForTests(); - - const keyring = new Keyring({ type: "sr25519" }); - const validator = keyring.addFromUri("//Alice"); - const nonValidator = keyring.addFromUri("//Bob"); - - const envActions = { ...baseEnv, DEV_BYPASS_GATE: "true" }; - - const clockRes = await clockGet( - makeContext({ - url: "https://local.test/api/clock", - env: envActions, - method: "GET", - }), - ); - assert.equal(clockRes.status, 200); - const clockJson = await clockRes.json(); - assert.equal(typeof clockJson.currentEra, "number"); - const era = clockJson.currentEra; - - await ensureChamberMembership(envActions, { - address: validator.address, - chamberId: "general", - source: "test", - }); - await ensureChamberMembership(envActions, { - address: nonValidator.address, - chamberId: "general", - source: "test", - }); - await ensureChamberMembership(envActions, { - address: validator.address, - chamberId: "engineering", - source: "test", - }); - await ensureChamberMembership(envActions, { - address: nonValidator.address, - chamberId: "engineering", - source: "test", - }); - for (let i = 0; i < 10; i += 1) { - await ensureChamberMembership(envActions, { - address: `5EngMember${i}`, - chamberId: "engineering", - source: "test", - }); - } - - const cookieValidator = await makeSessionCookie( - envActions, - validator.address, - ); - const cookieNonValidator = await makeSessionCookie( - envActions, - nonValidator.address, - ); - - const vote1 = await commandPost( - makeContext({ - url: "https://local.test/api/command", - env: envActions, - headers: { "content-type": "application/json", cookie: cookieValidator }, - body: JSON.stringify({ - type: "pool.vote", - payload: { proposalId: "biometric-account-recovery", direction: "up" }, - }), - }), - ); - assert.equal(vote1.status, 200); - - const vote2 = await commandPost( - makeContext({ - url: "https://local.test/api/command", - env: envActions, - headers: { - "content-type": "application/json", - cookie: cookieNonValidator, - }, - body: JSON.stringify({ - type: "pool.vote", - payload: { proposalId: "biometric-account-recovery", direction: "up" }, - }), - }), - ); - assert.equal(vote2.status, 200); - - const validatorsKey = - "0x" + - xxhashAsHex("Session", 128).slice(2) + - xxhashAsHex("Validators", 128).slice(2); - - const originalFetch = globalThis.fetch; - globalThis.fetch = async (_url, init) => { - const body = init?.body ? JSON.parse(String(init.body)) : {}; - const key = body?.params?.[0]; - - const result = - key === validatorsKey - ? u8aToHex(scaleEncodeVecAccountId32([validator.publicKey])) - : null; - - return new Response(JSON.stringify({ jsonrpc: "2.0", id: 1, result }), { - status: 200, - headers: { "content-type": "application/json" }, - }); - }; - - try { - const envRollup = { - ...baseEnv, - DEV_BYPASS_GATE: "false", - HUMANODE_RPC_URL: "https://rpc.test", - }; - - const rollupRes = await rollupEraPost( - makeContext({ - url: "https://local.test/api/clock/rollup-era", - env: envRollup, - headers: { "content-type": "application/json" }, - body: JSON.stringify({ era }), - }), - ); - assert.equal(rollupRes.status, 200); - const json = await rollupRes.json(); - assert.equal(json.ok, true); - assert.equal(json.usersRolled, 2); - assert.equal(json.activeGovernorsNextEra, 1); - - const statusValidator = await getEraUserStatus(envRollup, { - era, - address: validator.address, - }); - assert.ok(statusValidator); - assert.equal(statusValidator.isActiveNextEra, true); - - const statusNonValidator = await getEraUserStatus(envRollup, { - era, - address: nonValidator.address, - }); - assert.ok(statusNonValidator); - assert.equal(statusNonValidator.isActiveNextEra, false); - } finally { - globalThis.fetch = originalFetch; - } -}); diff --git a/tests/api-era-rollup.test.js b/tests/api-era-rollup.test.js deleted file mode 100644 index bdd123c..0000000 --- a/tests/api-era-rollup.test.js +++ /dev/null @@ -1,167 +0,0 @@ -import assert from "node:assert/strict"; -import { test } from "node:test"; - -import { onRequestPost as commandPost } from "../api/routes/command.ts"; -import { onRequestGet as clockGet } from "../api/routes/clock/index.ts"; -import { onRequestPost as rollupEraPost } from "../api/routes/clock/rollup-era.ts"; -import { getSessionCookieName, issueSession } from "../api/_lib/auth.ts"; -import { clearChamberVotesForTests } from "../api/_lib/chamberVotesStore.ts"; -import { clearCourtsForTests } from "../api/_lib/courtsStore.ts"; -import { clearEraRollupsForTests } from "../api/_lib/eraRollupStore.ts"; -import { clearEraForTests } from "../api/_lib/eraStore.ts"; -import { clearFormationForTests } from "../api/_lib/formationStore.ts"; -import { clearIdempotencyForTests } from "../api/_lib/idempotencyStore.ts"; -import { clearPoolVotesForTests } from "../api/_lib/poolVotesStore.ts"; -import { clearInlineReadModelsForTests } from "../api/_lib/readModelsStore.ts"; -import { - clearChamberMembershipsForTests, - ensureChamberMembership, -} from "../api/_lib/chamberMembershipsStore.ts"; - -function makeContext({ url, env, params, method = "POST", headers, body }) { - return { - request: new Request(url, { method, headers, body }), - env, - params, - }; -} - -async function makeSessionCookie(env, address) { - const headers = new Headers(); - await issueSession(headers, env, "https://local.test/api/command", address); - const setCookie = headers.get("set-cookie"); - assert.ok(setCookie); - const tokenPair = setCookie.split(";")[0]; - const [name, value] = tokenPair.split("="); - assert.equal(name, getSessionCookieName()); - return `${name}=${value}`; -} - -const baseEnv = { - SESSION_SECRET: "test-secret", - DEV_BYPASS_GATE: "true", - DEV_INSECURE_COOKIES: "true", - READ_MODELS_INLINE: "true", - DEV_BYPASS_ADMIN: "true", - SIM_REQUIRED_POOL_VOTES: "1", - SIM_REQUIRED_CHAMBER_VOTES: "0", - SIM_REQUIRED_COURT_ACTIONS: "0", - SIM_REQUIRED_FORMATION_ACTIONS: "0", -}; - -test("POST /api/clock/rollup-era is idempotent and computes status + active governors", async () => { - await clearPoolVotesForTests(); - await clearChamberVotesForTests(); - clearChamberMembershipsForTests(); - clearCourtsForTests(); - clearFormationForTests(); - clearEraForTests(); - clearEraRollupsForTests(); - clearIdempotencyForTests(); - clearInlineReadModelsForTests(); - - const clockRes = await clockGet( - makeContext({ - url: "https://local.test/api/clock", - env: baseEnv, - method: "GET", - }), - ); - assert.equal(clockRes.status, 200); - const clockJson = await clockRes.json(); - assert.equal(typeof clockJson.currentEra, "number"); - const era = clockJson.currentEra; - - const cookie = await makeSessionCookie(baseEnv, "5RollupAddr"); - await ensureChamberMembership(baseEnv, { - address: "5RollupAddr", - chamberId: "general", - source: "test", - }); - await ensureChamberMembership(baseEnv, { - address: "5RollupAddr", - chamberId: "engineering", - source: "test", - }); - - const poolVote = await commandPost( - makeContext({ - url: "https://local.test/api/command", - env: baseEnv, - headers: { "content-type": "application/json", cookie }, - body: JSON.stringify({ - type: "pool.vote", - payload: { proposalId: "biometric-account-recovery", direction: "up" }, - }), - }), - ); - assert.equal(poolVote.status, 200); - - const chamberVote = await commandPost( - makeContext({ - url: "https://local.test/api/command", - env: baseEnv, - headers: { "content-type": "application/json", cookie }, - body: JSON.stringify({ - type: "chamber.vote", - payload: { proposalId: "tier-decay-v1", choice: "yes", score: 8 }, - }), - }), - ); - assert.equal(chamberVote.status, 200); - - const report = await commandPost( - makeContext({ - url: "https://local.test/api/command", - env: baseEnv, - headers: { "content-type": "application/json", cookie }, - body: JSON.stringify({ - type: "court.case.report", - payload: { caseId: "delegation-farming-forum-whale" }, - }), - }), - ); - assert.equal(report.status, 200); - - const rollup1 = await rollupEraPost( - makeContext({ - url: "https://local.test/api/clock/rollup-era", - env: baseEnv, - headers: { "content-type": "application/json" }, - body: JSON.stringify({ era }), - }), - ); - assert.equal(rollup1.status, 200); - const json1 = await rollup1.json(); - assert.equal(json1.ok, true); - assert.equal(json1.era, era); - assert.equal(json1.requiredTotal, 1); - assert.equal(json1.activeGovernorsNextEra, 1); - assert.equal(json1.usersRolled, 1); - assert.equal(json1.statusCounts.Ahead, 1); - - const clockAfterRes = await clockGet( - makeContext({ - url: "https://local.test/api/clock", - env: baseEnv, - method: "GET", - }), - ); - assert.equal(clockAfterRes.status, 200); - const clockAfterJson = await clockAfterRes.json(); - assert.equal(clockAfterJson.currentEra, era); - assert.ok(clockAfterJson.currentEraRollup); - assert.equal(clockAfterJson.currentEraRollup.era, era); - - const rollup2 = await rollupEraPost( - makeContext({ - url: "https://local.test/api/clock/rollup-era", - env: baseEnv, - headers: { "content-type": "application/json" }, - body: JSON.stringify({ era }), - }), - ); - assert.equal(rollup2.status, 200); - const json2 = await rollup2.json(); - assert.deepEqual(json2, json1); -}); diff --git a/tests/api-feed.test.js b/tests/api-feed.test.js deleted file mode 100644 index ee84138..0000000 --- a/tests/api-feed.test.js +++ /dev/null @@ -1,39 +0,0 @@ -import assert from "node:assert/strict"; -import { test } from "node:test"; - -import { onRequestGet as feedGet } from "../api/routes/feed/index.ts"; - -function makeContext({ url, env, params, method = "GET", headers }) { - return { - request: new Request(url, { method, headers }), - env, - params, - }; -} - -const inlineEnv = { READ_MODELS_INLINE: "true", DEV_BYPASS_ADMIN: "true" }; - -test("GET /api/feed returns items", async () => { - const res = await feedGet( - makeContext({ url: "https://local.test/api/feed", env: inlineEnv }), - ); - assert.equal(res.status, 200); - const json = await res.json(); - assert.ok(Array.isArray(json.items)); - assert.ok(json.items.length > 0); - assert.ok(json.items[0].id); -}); - -test("GET /api/feed can filter by stage", async () => { - const res = await feedGet( - makeContext({ - url: "https://local.test/api/feed?stage=faction", - env: inlineEnv, - }), - ); - assert.equal(res.status, 200); - const json = await res.json(); - assert.ok(Array.isArray(json.items)); - assert.ok(json.items.length > 0); - for (const item of json.items) assert.equal(item.stage, "faction"); -}); diff --git a/tests/api-gate-rpc.test.js b/tests/api-gate-rpc.test.js deleted file mode 100644 index 9f87f92..0000000 --- a/tests/api-gate-rpc.test.js +++ /dev/null @@ -1,137 +0,0 @@ -import assert from "node:assert/strict"; -import { test } from "node:test"; - -import { Keyring } from "@polkadot/keyring"; -import { cryptoWaitReady, xxhashAsHex } from "@polkadot/util-crypto"; -import { u8aToHex } from "@polkadot/util"; - -import { onRequestGet as gateGet } from "../api/routes/gate/status.ts"; -import { onRequestPost as noncePost } from "../api/routes/auth/nonce.ts"; -import { onRequestPost as verifyPost } from "../api/routes/auth/verify.ts"; - -function getSetCookies(response) { - const maybe = response.headers.getSetCookie?.bind(response.headers); - if (maybe) return maybe(); - const single = response.headers.get("set-cookie"); - return single ? [single] : []; -} - -function cookiePair(setCookieValue) { - return setCookieValue.split(";")[0]; -} - -function makeContext({ url, method, env, body, cookie }) { - const headers = new Headers(); - if (cookie) headers.set("cookie", cookie); - if (body !== undefined) headers.set("content-type", "application/json"); - const request = new Request(url, { - method, - headers, - body: body !== undefined ? JSON.stringify(body) : undefined, - }); - return { request, env, params: {} }; -} - -function scaleEncodeVecAccountId32(publicKeys) { - if (publicKeys.length > 63) throw new Error("test helper: too many keys"); - const lengthByte = (publicKeys.length << 2) | 0; // compact-encoded small int - const out = new Uint8Array(1 + publicKeys.length * 32); - out[0] = lengthByte; - publicKeys.forEach((pk, i) => { - out.set(pk, 1 + i * 32); - }); - return out; -} - -test("gate/status: real RPC gate uses cached result (memory mode)", async () => { - await cryptoWaitReady(); - const keyring = new Keyring({ type: "sr25519" }); - const pair = keyring.addFromUri("//Alice"); - - const validatorsKey = - "0x" + - xxhashAsHex("Session", 128).slice(2) + - xxhashAsHex("Validators", 128).slice(2); - - let rpcCalls = 0; - const originalFetch = globalThis.fetch; - globalThis.fetch = async (_url, init) => { - rpcCalls += 1; - const body = init?.body ? JSON.parse(String(init.body)) : {}; - const key = body?.params?.[0]; - let result; - - if (key === validatorsKey) { - result = u8aToHex(scaleEncodeVecAccountId32([pair.publicKey])); - } else { - result = null; - } - - return new Response(JSON.stringify({ jsonrpc: "2.0", id: 1, result }), { - status: 200, - headers: { "content-type": "application/json" }, - }); - }; - - try { - const env = { - SESSION_SECRET: "test-secret", - HUMANODE_RPC_URL: "https://rpc.test", - }; - - const nonceRes = await noncePost( - makeContext({ - url: "https://local.test/api/auth/nonce", - method: "POST", - env, - body: { address: pair.address }, - }), - ); - const nonceJson = await nonceRes.json(); - const nonceCookie = cookiePair(getSetCookies(nonceRes)[0]); - - const messageBytes = new TextEncoder().encode(nonceJson.nonce); - const signatureHex = u8aToHex(pair.sign(messageBytes)); - - const verifyRes = await verifyPost( - makeContext({ - url: "https://local.test/api/auth/verify", - method: "POST", - env, - body: { - address: pair.address, - nonce: nonceJson.nonce, - signature: signatureHex, - }, - cookie: nonceCookie, - }), - ); - const sessionCookie = cookiePair(getSetCookies(verifyRes)[0]); - - const gateRes1 = await gateGet( - makeContext({ - url: "https://local.test/api/gate/status", - method: "GET", - env, - cookie: sessionCookie, - }), - ); - const json1 = await gateRes1.json(); - assert.equal(json1.eligible, true); - - const gateRes2 = await gateGet( - makeContext({ - url: "https://local.test/api/gate/status", - method: "GET", - env, - cookie: sessionCookie, - }), - ); - const json2 = await gateRes2.json(); - assert.equal(json2.eligible, true); - - assert.equal(rpcCalls, 1, "expected eligibility to be cached"); - } finally { - globalThis.fetch = originalFetch; - } -}); diff --git a/tests/api-gate.test.js b/tests/api-gate.test.js deleted file mode 100644 index fde51bc..0000000 --- a/tests/api-gate.test.js +++ /dev/null @@ -1,79 +0,0 @@ -import assert from "node:assert/strict"; -import { test } from "node:test"; - -import { onRequestGet as gateGet } from "../api/routes/gate/status.ts"; -import { onRequestPost as noncePost } from "../api/routes/auth/nonce.ts"; -import { onRequestPost as verifyPost } from "../api/routes/auth/verify.ts"; - -function getSetCookies(response) { - const maybe = response.headers.getSetCookie?.bind(response.headers); - if (maybe) return maybe(); - const single = response.headers.get("set-cookie"); - return single ? [single] : []; -} - -function cookiePair(setCookieValue) { - return setCookieValue.split(";")[0]; -} - -function makeContext({ url, method, env, body, cookie }) { - const headers = new Headers(); - if (cookie) headers.set("cookie", cookie); - if (body !== undefined) headers.set("content-type", "application/json"); - const request = new Request(url, { - method, - headers, - body: body !== undefined ? JSON.stringify(body) : undefined, - }); - return { request, env }; -} - -test("gate/status: unauthenticated vs authenticated eligible", async () => { - const env = { SESSION_SECRET: "test-secret", DEV_BYPASS_SIGNATURE: "true" }; - const address = "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY"; - - const unauthRes = await gateGet( - makeContext({ - url: "https://local.test/api/gate/status", - method: "GET", - env, - }), - ); - assert.equal(unauthRes.status, 200); - const unauthJson = await unauthRes.json(); - assert.equal(unauthJson.eligible, false); - assert.equal(unauthJson.reason, "not_authenticated"); - - const nonceRes = await noncePost( - makeContext({ - url: "https://local.test/api/auth/nonce", - method: "POST", - env, - body: { address }, - }), - ); - const nonceJson = await nonceRes.json(); - const nonceCookie = cookiePair(getSetCookies(nonceRes)[0]); - - const verifyRes = await verifyPost( - makeContext({ - url: "https://local.test/api/auth/verify", - method: "POST", - env: { ...env, DEV_BYPASS_GATE: "true" }, - body: { address, nonce: nonceJson.nonce, signature: "0xsig" }, - cookie: nonceCookie, - }), - ); - const sessionCookie = cookiePair(getSetCookies(verifyRes)[0]); - - const gateRes = await gateGet( - makeContext({ - url: "https://local.test/api/gate/status", - method: "GET", - env: { ...env, DEV_BYPASS_GATE: "true" }, - cookie: sessionCookie, - }), - ); - const gateJson = await gateRes.json(); - assert.equal(gateJson.eligible, true); -}); diff --git a/tests/api-my-governance-rollup.test.js b/tests/api-my-governance-rollup.test.js deleted file mode 100644 index 4160ea2..0000000 --- a/tests/api-my-governance-rollup.test.js +++ /dev/null @@ -1,142 +0,0 @@ -import assert from "node:assert/strict"; -import { test } from "node:test"; - -import { onRequestPost as commandPost } from "../api/routes/command.ts"; -import { onRequestPost as rollupEraPost } from "../api/routes/clock/rollup-era.ts"; -import { onRequestGet as myGovernanceGet } from "../api/routes/my-governance/index.ts"; -import { getSessionCookieName, issueSession } from "../api/_lib/auth.ts"; -import { clearChamberVotesForTests } from "../api/_lib/chamberVotesStore.ts"; -import { clearCourtsForTests } from "../api/_lib/courtsStore.ts"; -import { clearEraRollupsForTests } from "../api/_lib/eraRollupStore.ts"; -import { clearEraForTests } from "../api/_lib/eraStore.ts"; -import { clearFormationForTests } from "../api/_lib/formationStore.ts"; -import { clearIdempotencyForTests } from "../api/_lib/idempotencyStore.ts"; -import { clearPoolVotesForTests } from "../api/_lib/poolVotesStore.ts"; -import { clearInlineReadModelsForTests } from "../api/_lib/readModelsStore.ts"; -import { - clearChamberMembershipsForTests, - ensureChamberMembership, -} from "../api/_lib/chamberMembershipsStore.ts"; - -function makeContext({ url, env, params, method = "POST", headers, body }) { - return { - request: new Request(url, { method, headers, body }), - env, - params, - }; -} - -async function makeSessionCookie(env, address) { - const headers = new Headers(); - await issueSession(headers, env, "https://local.test/api/command", address); - const setCookie = headers.get("set-cookie"); - assert.ok(setCookie); - const tokenPair = setCookie.split(";")[0]; - const [name, value] = tokenPair.split("="); - assert.equal(name, getSessionCookieName()); - return `${name}=${value}`; -} - -const baseEnv = { - SESSION_SECRET: "test-secret", - DEV_BYPASS_GATE: "true", - DEV_INSECURE_COOKIES: "true", - READ_MODELS_INLINE: "true", - DEV_BYPASS_ADMIN: "true", - SIM_REQUIRED_POOL_VOTES: "1", - SIM_REQUIRED_CHAMBER_VOTES: "0", - SIM_REQUIRED_COURT_ACTIONS: "0", - SIM_REQUIRED_FORMATION_ACTIONS: "0", -}; - -test("GET /api/my-governance includes rollup status after rollup", async () => { - await clearPoolVotesForTests(); - await clearChamberVotesForTests(); - clearChamberMembershipsForTests(); - clearCourtsForTests(); - clearFormationForTests(); - clearEraForTests(); - clearEraRollupsForTests(); - clearIdempotencyForTests(); - clearInlineReadModelsForTests(); - - const cookie = await makeSessionCookie(baseEnv, "5GovRollupAddr"); - await ensureChamberMembership(baseEnv, { - address: "5GovRollupAddr", - chamberId: "general", - source: "test", - }); - await ensureChamberMembership(baseEnv, { - address: "5GovRollupAddr", - chamberId: "engineering", - source: "test", - }); - - // 3 distinct actions so status becomes Ahead (requiredTotal=1). - const poolVote = await commandPost( - makeContext({ - url: "https://local.test/api/command", - env: baseEnv, - headers: { "content-type": "application/json", cookie }, - body: JSON.stringify({ - type: "pool.vote", - payload: { proposalId: "biometric-account-recovery", direction: "up" }, - }), - }), - ); - assert.equal(poolVote.status, 200); - - const chamberVote = await commandPost( - makeContext({ - url: "https://local.test/api/command", - env: baseEnv, - headers: { "content-type": "application/json", cookie }, - body: JSON.stringify({ - type: "chamber.vote", - payload: { proposalId: "tier-decay-v1", choice: "yes", score: 8 }, - }), - }), - ); - assert.equal(chamberVote.status, 200); - - const report = await commandPost( - makeContext({ - url: "https://local.test/api/command", - env: baseEnv, - headers: { "content-type": "application/json", cookie }, - body: JSON.stringify({ - type: "court.case.report", - payload: { caseId: "delegation-farming-forum-whale" }, - }), - }), - ); - assert.equal(report.status, 200); - - const rollupRes = await rollupEraPost( - makeContext({ - url: "https://local.test/api/clock/rollup-era", - env: baseEnv, - headers: { "content-type": "application/json" }, - body: JSON.stringify({}), - }), - ); - assert.equal(rollupRes.status, 200); - const rollupJson = await rollupRes.json(); - assert.equal(rollupJson.ok, true); - - const myGovRes = await myGovernanceGet( - makeContext({ - url: "https://local.test/api/my-governance", - env: baseEnv, - method: "GET", - headers: { cookie }, - }), - ); - assert.equal(myGovRes.status, 200); - const myGovJson = await myGovRes.json(); - assert.ok(myGovJson.rollup); - assert.equal(myGovJson.rollup.status, "Ahead"); - assert.equal(myGovJson.rollup.isActiveNextEra, true); - assert.equal(myGovJson.rollup.requiredTotal, 1); - assert.equal(myGovJson.rollup.completedTotal, 3); -}); diff --git a/tests/api-proposal-timeline.test.js b/tests/api-proposal-timeline.test.js deleted file mode 100644 index 14c9a91..0000000 --- a/tests/api-proposal-timeline.test.js +++ /dev/null @@ -1,149 +0,0 @@ -import assert from "node:assert/strict"; -import { test } from "node:test"; - -import { onRequestPost as commandPost } from "../api/routes/command.ts"; -import { onRequestGet as timelineGet } from "../api/routes/proposals/[id]/timeline.ts"; -import { getSessionCookieName, issueSession } from "../api/_lib/auth.ts"; -import { clearIdempotencyForTests } from "../api/_lib/idempotencyStore.ts"; -import { clearPoolVotesForTests } from "../api/_lib/poolVotesStore.ts"; -import { clearInlineReadModelsForTests } from "../api/_lib/readModelsStore.ts"; -import { clearProposalDraftsForTests } from "../api/_lib/proposalDraftsStore.ts"; -import { clearProposalsForTests } from "../api/_lib/proposalsStore.ts"; -import { clearProposalTimelineForTests } from "../api/_lib/proposalTimelineStore.ts"; -import { - clearChamberMembershipsForTests, - ensureChamberMembership, -} from "../api/_lib/chamberMembershipsStore.ts"; - -function makeContext({ url, env, params, method = "POST", headers, body }) { - return { - request: new Request(url, { method, headers, body }), - env, - params, - }; -} - -async function makeSessionCookie(env, address) { - const headers = new Headers(); - await issueSession(headers, env, "https://local.test/api/command", address); - const setCookie = headers.get("set-cookie"); - assert.ok(setCookie); - const tokenPair = setCookie.split(";")[0]; - const [name, value] = tokenPair.split("="); - assert.equal(name, getSessionCookieName()); - await ensureChamberMembership(env, { - address, - chamberId: "general", - source: "test", - }); - await ensureChamberMembership(env, { - address, - chamberId: "engineering", - source: "test", - }); - return `${name}=${value}`; -} - -const env = { - SESSION_SECRET: "test-secret", - DEV_BYPASS_GATE: "true", - DEV_INSECURE_COOKIES: "true", - READ_MODELS_INLINE: "true", - DEV_BYPASS_ADMIN: "true", - SIM_CONFIG_JSON: JSON.stringify({ - genesisChambers: [ - { id: "general", title: "General", multiplier: 1.2 }, - { id: "engineering", title: "Engineering", multiplier: 1.5 }, - ], - genesisChamberMembers: {}, - }), -}; - -test("GET /api/proposals/:id/timeline returns proposal events in order", async () => { - clearProposalTimelineForTests(); - clearIdempotencyForTests(); - await clearPoolVotesForTests(); - clearInlineReadModelsForTests(); - clearProposalDraftsForTests(); - clearProposalsForTests(); - clearChamberMembershipsForTests(); - - const cookie = await makeSessionCookie(env, "5timeline"); - - const draftRes = await commandPost( - makeContext({ - url: "https://local.test/api/command", - env, - headers: { "content-type": "application/json", cookie }, - body: JSON.stringify({ - type: "proposal.draft.save", - payload: { - form: { - title: "Timeline test proposal", - chamberId: "engineering", - summary: "Summary", - what: "Overview", - why: "Why", - when: "When", - where: "Where", - how: "How", - howMuch: "How much", - timeline: [{ id: "m1", title: "Milestone", timeframe: "Week 1" }], - outputs: [], - budgetItems: [{ id: "b1", description: "Work", amount: "1" }], - aboutMe: "", - attachments: [], - agreeRules: true, - confirmBudget: true, - }, - }, - }), - }), - ); - assert.equal(draftRes.status, 200); - const draftJson = await draftRes.json(); - assert.ok(draftJson.draftId); - - const submitRes = await commandPost( - makeContext({ - url: "https://local.test/api/command", - env, - headers: { "content-type": "application/json", cookie }, - body: JSON.stringify({ - type: "proposal.submitToPool", - payload: { draftId: draftJson.draftId }, - }), - }), - ); - assert.equal(submitRes.status, 200); - const submitJson = await submitRes.json(); - assert.ok(submitJson.proposalId); - - const voteRes = await commandPost( - makeContext({ - url: "https://local.test/api/command", - env, - headers: { "content-type": "application/json", cookie }, - body: JSON.stringify({ - type: "pool.vote", - payload: { proposalId: submitJson.proposalId, direction: "up" }, - }), - }), - ); - assert.equal(voteRes.status, 200); - - const timelineRes = await timelineGet( - makeContext({ - url: `https://local.test/api/proposals/${submitJson.proposalId}/timeline`, - env, - params: { id: submitJson.proposalId }, - method: "GET", - }), - ); - assert.equal(timelineRes.status, 200); - const timelineJson = await timelineRes.json(); - assert.ok(Array.isArray(timelineJson.items)); - assert.ok(timelineJson.items.length >= 2); - assert.equal(timelineJson.items[0].type, "proposal.submitted"); - assert.ok(timelineJson.items.some((item) => item.type === "pool.vote")); -}); diff --git a/tests/api-proposals-canonical-precedence.test.js b/tests/api-proposals-canonical-precedence.test.js deleted file mode 100644 index 3118c09..0000000 --- a/tests/api-proposals-canonical-precedence.test.js +++ /dev/null @@ -1,102 +0,0 @@ -import assert from "node:assert/strict"; -import { test } from "node:test"; - -import { onRequestGet as proposalsGet } from "../api/routes/proposals/index.ts"; -import { onRequestGet as proposalPoolGet } from "../api/routes/proposals/[id]/pool.ts"; -import { - clearProposalsForTests, - createProposal, -} from "../api/_lib/proposalsStore.ts"; -import { clearPoolVotesForTests } from "../api/_lib/poolVotesStore.ts"; - -function makeContext({ url, env, params, method = "GET", headers }) { - return { - request: new Request(url, { method, headers }), - env, - params, - }; -} - -const env = { READ_MODELS_INLINE: "true", DEV_BYPASS_ADMIN: "true" }; -const proposalId = "biometric-account-recovery"; - -test("GET /api/proposals prefers canonical proposals over seeded read models", async () => { - clearProposalsForTests(); - await clearPoolVotesForTests(); - - await createProposal(env, { - id: proposalId, - stage: "pool", - authorAddress: "5canonical-author", - title: "Canonical proposal title", - chamberId: "engineering", - summary: "Canonical summary", - payload: { - title: "Canonical proposal title", - chamberId: "engineering", - summary: "Canonical summary", - what: "Canonical overview", - why: "", - how: "Step 1\nStep 2", - aboutMe: "", - agreeRules: true, - confirmBudget: true, - timeline: [], - outputs: [], - budgetItems: [], - attachments: [], - }, - }); - - const res = await proposalsGet( - makeContext({ url: "https://local.test/api/proposals", env }), - ); - assert.equal(res.status, 200); - const json = await res.json(); - const item = json.items.find((entry) => entry.id === proposalId); - assert.ok(item, "Expected the proposal to be present in the list"); - assert.equal(item.title, "Canonical proposal title"); - assert.equal(item.summary, "Canonical summary"); -}); - -test("GET /api/proposals/:id/pool prefers canonical proposals over seeded read models", async () => { - clearProposalsForTests(); - await clearPoolVotesForTests(); - - await createProposal(env, { - id: proposalId, - stage: "pool", - authorAddress: "5canonical-author", - title: "Canonical proposal title", - chamberId: "engineering", - summary: "Canonical summary", - payload: { - title: "Canonical proposal title", - chamberId: "engineering", - summary: "Canonical summary", - what: "Canonical overview", - why: "", - how: "Step 1\nStep 2", - aboutMe: "", - agreeRules: true, - confirmBudget: true, - timeline: [], - outputs: [], - budgetItems: [], - attachments: [], - }, - }); - - const res = await proposalPoolGet( - makeContext({ - url: `https://local.test/api/proposals/${proposalId}/pool`, - env, - params: { id: proposalId }, - }), - ); - assert.equal(res.status, 200); - const json = await res.json(); - assert.equal(json.title, "Canonical proposal title"); - assert.equal(json.summary, "Canonical summary"); - assert.equal(json.overview, "Canonical overview"); -}); diff --git a/tests/api-quorum-stage-denominators.test.js b/tests/api-quorum-stage-denominators.test.js deleted file mode 100644 index 36e0a27..0000000 --- a/tests/api-quorum-stage-denominators.test.js +++ /dev/null @@ -1,188 +0,0 @@ -import assert from "node:assert/strict"; -import { test } from "node:test"; - -import { onRequestPost as commandPost } from "../api/routes/command.ts"; -import { onRequestGet as clockGet } from "../api/routes/clock/index.ts"; -import { onRequestPost as advanceEraPost } from "../api/routes/clock/advance-era.ts"; -import { onRequestPost as rollupEraPost } from "../api/routes/clock/rollup-era.ts"; -import { onRequestGet as poolPageGet } from "../api/routes/proposals/[id]/pool.ts"; -import { onRequestGet as chamberPageGet } from "../api/routes/proposals/[id]/chamber.ts"; -import { getSessionCookieName, issueSession } from "../api/_lib/auth.ts"; -import { clearChamberVotesForTests } from "../api/_lib/chamberVotesStore.ts"; -import { - clearChamberMembershipsForTests, - ensureChamberMembership, -} from "../api/_lib/chamberMembershipsStore.ts"; -import { clearClockForTests } from "../api/_lib/clockStore.ts"; -import { clearEraRollupsForTests } from "../api/_lib/eraRollupStore.ts"; -import { clearEraForTests } from "../api/_lib/eraStore.ts"; -import { clearIdempotencyForTests } from "../api/_lib/idempotencyStore.ts"; -import { clearPoolVotesForTests } from "../api/_lib/poolVotesStore.ts"; -import { clearProposalStageDenominatorsForTests } from "../api/_lib/proposalStageDenominatorsStore.ts"; -import { clearInlineReadModelsForTests } from "../api/_lib/readModelsStore.ts"; - -function makeContext({ url, env, params, method = "POST", headers, body }) { - return { - request: new Request(url, { method, headers, body }), - env, - params, - }; -} - -async function makeSessionCookie(env, address) { - const headers = new Headers(); - await issueSession(headers, env, "https://local.test/api/command", address); - const setCookie = headers.get("set-cookie"); - assert.ok(setCookie); - const tokenPair = setCookie.split(";")[0]; - const [name, value] = tokenPair.split("="); - assert.equal(name, getSessionCookieName()); - return `${name}=${value}`; -} - -const env = { - SESSION_SECRET: "test-secret", - DEV_INSECURE_COOKIES: "true", - READ_MODELS_INLINE: "true", - DEV_BYPASS_ADMIN: "true", - DEV_BYPASS_GATE: "true", - SIM_ACTIVE_GOVERNORS: "150", - SIM_REQUIRED_POOL_VOTES: "1", - SIM_REQUIRED_CHAMBER_VOTES: "0", - SIM_REQUIRED_COURT_ACTIONS: "0", - SIM_REQUIRED_FORMATION_ACTIONS: "0", -}; - -test("proposal quorum denominators are snapshotted per stage and do not drift across eras", async () => { - await clearPoolVotesForTests(); - await clearChamberVotesForTests(); - clearChamberMembershipsForTests(); - clearClockForTests(); - clearEraForTests(); - clearEraRollupsForTests(); - clearIdempotencyForTests(); - clearInlineReadModelsForTests(); - clearProposalStageDenominatorsForTests(); - - const clockRes1 = await clockGet( - makeContext({ - url: "https://local.test/api/clock", - env, - method: "GET", - }), - ); - assert.equal(clockRes1.status, 200); - const clockJson1 = await clockRes1.json(); - assert.equal(clockJson1.currentEra, 0); - assert.equal(clockJson1.activeGovernors, 150); - - const address = "5StageDenomAddr"; - await ensureChamberMembership(env, { - address, - chamberId: "general", - source: "test", - }); - await ensureChamberMembership(env, { - address, - chamberId: "engineering", - source: "test", - }); - for (let i = 0; i < 150; i += 1) { - const member = `5DenomMember${i}`; - await ensureChamberMembership(env, { - address: member, - chamberId: "general", - source: "test", - }); - await ensureChamberMembership(env, { - address: member, - chamberId: "engineering", - source: "test", - }); - } - const cookie = await makeSessionCookie(env, address); - - const poolVote = await commandPost( - makeContext({ - url: "https://local.test/api/command", - env, - headers: { "content-type": "application/json", cookie }, - body: JSON.stringify({ - type: "pool.vote", - payload: { proposalId: "biometric-account-recovery", direction: "up" }, - }), - }), - ); - assert.equal(poolVote.status, 200); - - const chamberVote = await commandPost( - makeContext({ - url: "https://local.test/api/command", - env, - headers: { "content-type": "application/json", cookie }, - body: JSON.stringify({ - type: "chamber.vote", - payload: { proposalId: "tier-decay-v1", choice: "yes" }, - }), - }), - ); - assert.equal(chamberVote.status, 200); - - const rollupRes = await rollupEraPost( - makeContext({ - url: "https://local.test/api/clock/rollup-era", - env, - headers: { "content-type": "application/json" }, - body: JSON.stringify({ era: 0 }), - }), - ); - assert.equal(rollupRes.status, 200); - const rollupJson = await rollupRes.json(); - assert.equal(rollupJson.activeGovernorsNextEra, 1); - - const advanceRes = await advanceEraPost( - makeContext({ - url: "https://local.test/api/clock/advance-era", - env, - headers: { "content-type": "application/json" }, - body: JSON.stringify({}), - }), - ); - assert.equal(advanceRes.status, 200); - - const clockRes2 = await clockGet( - makeContext({ - url: "https://local.test/api/clock", - env, - method: "GET", - }), - ); - assert.equal(clockRes2.status, 200); - const clockJson2 = await clockRes2.json(); - assert.equal(clockJson2.currentEra, 1); - assert.equal(clockJson2.activeGovernors, 1); - - const poolPageRes = await poolPageGet( - makeContext({ - url: "https://local.test/api/proposals/biometric-account-recovery/pool", - env, - params: { id: "biometric-account-recovery" }, - method: "GET", - }), - ); - assert.equal(poolPageRes.status, 200); - const poolJson = await poolPageRes.json(); - assert.equal(poolJson.activeGovernors, 150); - - const chamberPageRes = await chamberPageGet( - makeContext({ - url: "https://local.test/api/proposals/tier-decay-v1/chamber", - env, - params: { id: "tier-decay-v1" }, - method: "GET", - }), - ); - assert.equal(chamberPageRes.status, 200); - const chamberJson = await chamberPageRes.json(); - assert.equal(chamberJson.activeGovernors, 150); -}); diff --git a/tests/api-readmodels.test.js b/tests/api-readmodels.test.js deleted file mode 100644 index 1c4b884..0000000 --- a/tests/api-readmodels.test.js +++ /dev/null @@ -1,318 +0,0 @@ -import assert from "node:assert/strict"; -import { test } from "node:test"; - -import { onRequestGet as chambersGet } from "../api/routes/chambers/index.ts"; -import { onRequestGet as chamberGet } from "../api/routes/chambers/[id].ts"; -import { onRequestGet as feedGet } from "../api/routes/feed/index.ts"; -import { onRequestGet as proposalsGet } from "../api/routes/proposals/index.ts"; -import { onRequestGet as proposalPoolGet } from "../api/routes/proposals/[id]/pool.ts"; -import { onRequestGet as courtsGet } from "../api/routes/courts/index.ts"; -import { onRequestGet as courtGet } from "../api/routes/courts/[id].ts"; -import { onRequestGet as humansGet } from "../api/routes/humans/index.ts"; -import { onRequestGet as humanGet } from "../api/routes/humans/[id].ts"; -import { onRequestGet as factionsGet } from "../api/routes/factions/index.ts"; -import { onRequestGet as factionGet } from "../api/routes/factions/[id].ts"; -import { onRequestGet as formationGet } from "../api/routes/formation/index.ts"; -import { onRequestGet as invisionGet } from "../api/routes/invision/index.ts"; -import { onRequestGet as myGovGet } from "../api/routes/my-governance/index.ts"; -import { onRequestGet as draftListGet } from "../api/routes/proposals/drafts/index.ts"; -import { onRequestGet as draftGet } from "../api/routes/proposals/drafts/[id].ts"; -import { onRequestGet as clockGet } from "../api/routes/clock/index.ts"; -import { onRequestPost as clockAdvancePost } from "../api/routes/clock/advance-era.ts"; - -function makeContext({ url, env, params, method = "GET", headers }) { - return { - request: new Request(url, { method, headers }), - env, - params, - }; -} - -const inlineEnv = { READ_MODELS_INLINE: "true", DEV_BYPASS_ADMIN: "true" }; -const emptyEnv = { READ_MODELS_INLINE_EMPTY: "true", DEV_BYPASS_ADMIN: "true" }; - -test("GET /api/chambers returns items", async () => { - const res = await chambersGet( - makeContext({ url: "https://local.test/api/chambers", env: inlineEnv }), - ); - assert.equal(res.status, 200); - const json = await res.json(); - assert.ok(Array.isArray(json.items)); - assert.ok(json.items.length > 0); - assert.ok(json.items[0].id); -}); - -test("GET /api/chambers/:id returns seeded chamber detail (engineering)", async () => { - const res = await chamberGet( - makeContext({ - url: "https://local.test/api/chambers/engineering", - env: inlineEnv, - params: { id: "engineering" }, - }), - ); - assert.equal(res.status, 200); - const json = await res.json(); - assert.ok(Array.isArray(json.proposals)); - assert.ok(Array.isArray(json.governors)); -}); - -test("GET /api/proposals returns items and can filter by stage", async () => { - const res = await proposalsGet( - makeContext({ - url: "https://local.test/api/proposals?stage=pool", - env: inlineEnv, - }), - ); - assert.equal(res.status, 200); - const json = await res.json(); - assert.ok(Array.isArray(json.items)); - for (const item of json.items) assert.equal(item.stage, "pool"); -}); - -test("GET /api/proposals/:id/pool returns page model", async () => { - const res = await proposalPoolGet( - makeContext({ - url: "https://local.test/api/proposals/biometric-account-recovery/pool", - env: inlineEnv, - params: { id: "biometric-account-recovery" }, - }), - ); - assert.equal(res.status, 200); - const json = await res.json(); - assert.equal(json.title, "Biometric Account Recovery & Key Rotation Pallet"); - assert.ok(typeof json.summary === "string"); -}); - -test("GET /api/courts returns list items", async () => { - const res = await courtsGet( - makeContext({ url: "https://local.test/api/courts", env: inlineEnv }), - ); - assert.equal(res.status, 200); - const json = await res.json(); - assert.ok(Array.isArray(json.items)); - assert.ok(json.items[0].id); - assert.equal(typeof json.items[0].triggeredBy, "string"); -}); - -test("GET /api/courts/:id returns detail model", async () => { - const res = await courtGet( - makeContext({ - url: "https://local.test/api/courts/delegation-reroute-keeper-nyx", - env: inlineEnv, - params: { id: "delegation-reroute-keeper-nyx" }, - }), - ); - assert.equal(res.status, 200); - const json = await res.json(); - assert.equal(json.id, "delegation-reroute-keeper-nyx"); - assert.ok(Array.isArray(json.parties)); - assert.ok(Array.isArray(json.proceedings?.evidence)); -}); - -test("GET /api/humans returns items", async () => { - const res = await humansGet( - makeContext({ url: "https://local.test/api/humans", env: inlineEnv }), - ); - assert.equal(res.status, 200); - const json = await res.json(); - assert.ok(Array.isArray(json.items)); - assert.ok(json.items[0].id); -}); - -test("GET /api/humans/:id returns profile model", async () => { - const res = await humanGet( - makeContext({ - url: "https://local.test/api/humans/dato", - env: inlineEnv, - params: { id: "dato" }, - }), - ); - assert.equal(res.status, 200); - const json = await res.json(); - assert.equal(json.id, "dato"); - assert.ok(Array.isArray(json.heroStats)); - assert.ok(json.proofSections?.time); -}); - -test("GET /api/factions returns items", async () => { - const res = await factionsGet( - makeContext({ url: "https://local.test/api/factions", env: inlineEnv }), - ); - assert.equal(res.status, 200); - const json = await res.json(); - assert.ok(Array.isArray(json.items)); - assert.ok(json.items.length > 0); - assert.ok(json.items[0].id); -}); - -test("GET /api/factions/:id returns a faction model", async () => { - const res = await factionGet( - makeContext({ - url: "https://local.test/api/factions/delegation-removal-supporters", - env: inlineEnv, - params: { id: "delegation-removal-supporters" }, - }), - ); - assert.equal(res.status, 200); - const json = await res.json(); - assert.equal(json.id, "delegation-removal-supporters"); - assert.ok(Array.isArray(json.goals)); - assert.ok(Array.isArray(json.roster)); -}); - -test("GET /api/formation returns metrics and projects", async () => { - const res = await formationGet( - makeContext({ url: "https://local.test/api/formation", env: inlineEnv }), - ); - assert.equal(res.status, 200); - const json = await res.json(); - assert.ok(Array.isArray(json.metrics)); - assert.ok(Array.isArray(json.projects)); -}); - -test("GET /api/invision returns dashboard model", async () => { - const res = await invisionGet( - makeContext({ url: "https://local.test/api/invision", env: inlineEnv }), - ); - assert.equal(res.status, 200); - const json = await res.json(); - assert.equal(typeof json.governanceState?.label, "string"); - assert.ok(Array.isArray(json.economicIndicators)); - assert.ok(Array.isArray(json.riskSignals)); -}); - -test("GET /api/my-governance returns era activity and chambers", async () => { - const res = await myGovGet( - makeContext({ - url: "https://local.test/api/my-governance", - env: inlineEnv, - }), - ); - assert.equal(res.status, 200); - const json = await res.json(); - assert.equal(typeof json.eraActivity?.era, "string"); - assert.ok(Array.isArray(json.myChamberIds)); -}); - -test("GET /api/proposals/drafts returns items", async () => { - const res = await draftListGet( - makeContext({ - url: "https://local.test/api/proposals/drafts", - env: inlineEnv, - }), - ); - assert.equal(res.status, 200); - const json = await res.json(); - assert.ok(Array.isArray(json.items)); - assert.ok(json.items[0].id); -}); - -test("GET /api/proposals/drafts/:id returns detail model", async () => { - const res = await draftGet( - makeContext({ - url: "https://local.test/api/proposals/drafts/draft-vortex-ux-v1", - env: inlineEnv, - params: { id: "draft-vortex-ux-v1" }, - }), - ); - assert.equal(res.status, 200); - const json = await res.json(); - assert.equal( - json.title, - "Vortex Governance Hub UX Refresh & Design System v1", - ); - assert.ok(Array.isArray(json.checklist)); - assert.ok(Array.isArray(json.attachments)); -}); - -test("GET /api/clock returns a snapshot and POST /api/clock/advance-era increments", async () => { - const res1 = await clockGet( - makeContext({ url: "https://local.test/api/clock", env: inlineEnv }), - ); - assert.equal(res1.status, 200); - const snap1 = await res1.json(); - assert.equal(typeof snap1.currentEra, "number"); - - const res2 = await clockAdvancePost( - makeContext({ - url: "https://local.test/api/clock/advance-era", - env: inlineEnv, - method: "POST", - }), - ); - assert.equal(res2.status, 200); - const snap2 = await res2.json(); - assert.equal(snap2.currentEra, snap1.currentEra + 1); -}); - -test("read endpoints: empty read-model store returns empty defaults for list/singletons", async () => { - const chambersRes = await chambersGet( - makeContext({ url: "https://local.test/api/chambers", env: emptyEnv }), - ); - assert.equal(chambersRes.status, 200); - assert.deepEqual(await chambersRes.json(), { items: [] }); - - const proposalsRes = await proposalsGet( - makeContext({ url: "https://local.test/api/proposals", env: emptyEnv }), - ); - assert.equal(proposalsRes.status, 200); - assert.deepEqual(await proposalsRes.json(), { items: [] }); - - const feedRes = await feedGet( - makeContext({ url: "https://local.test/api/feed", env: emptyEnv }), - ); - assert.equal(feedRes.status, 200); - assert.deepEqual(await feedRes.json(), { items: [] }); - - const courtsRes = await courtsGet( - makeContext({ url: "https://local.test/api/courts", env: emptyEnv }), - ); - assert.equal(courtsRes.status, 200); - assert.deepEqual(await courtsRes.json(), { items: [] }); - - const humansRes = await humansGet( - makeContext({ url: "https://local.test/api/humans", env: emptyEnv }), - ); - assert.equal(humansRes.status, 200); - assert.deepEqual(await humansRes.json(), { items: [] }); - - const factionsRes = await factionsGet( - makeContext({ url: "https://local.test/api/factions", env: emptyEnv }), - ); - assert.equal(factionsRes.status, 200); - assert.deepEqual(await factionsRes.json(), { items: [] }); - - const formationRes = await formationGet( - makeContext({ url: "https://local.test/api/formation", env: emptyEnv }), - ); - assert.equal(formationRes.status, 200); - assert.deepEqual(await formationRes.json(), { metrics: [], projects: [] }); - - const invisionRes = await invisionGet( - makeContext({ url: "https://local.test/api/invision", env: emptyEnv }), - ); - assert.equal(invisionRes.status, 200); - assert.deepEqual(await invisionRes.json(), { - governanceState: { label: "—", metrics: [] }, - economicIndicators: [], - riskSignals: [], - chamberProposals: [], - }); - - const myGovRes = await myGovGet( - makeContext({ url: "https://local.test/api/my-governance", env: emptyEnv }), - ); - assert.equal(myGovRes.status, 200); - const myGovJson = await myGovRes.json(); - assert.equal(typeof myGovJson.eraActivity?.era, "string"); - assert.ok(Array.isArray(myGovJson.myChamberIds)); - - const draftsRes = await draftListGet( - makeContext({ - url: "https://local.test/api/proposals/drafts", - env: emptyEnv, - }), - ); - assert.equal(draftsRes.status, 200); - assert.deepEqual(await draftsRes.json(), { items: [] }); -}); diff --git a/tests/api-stage-windows.test.js b/tests/api-stage-windows.test.js deleted file mode 100644 index 6c19208..0000000 --- a/tests/api-stage-windows.test.js +++ /dev/null @@ -1,212 +0,0 @@ -import assert from "node:assert/strict"; -import { test } from "node:test"; - -import { onRequestPost as commandPost } from "../api/routes/command.ts"; -import { getSessionCookieName, issueSession } from "../api/_lib/auth.ts"; -import { clearIdempotencyForTests } from "../api/_lib/idempotencyStore.ts"; -import { clearInlineReadModelsForTests } from "../api/_lib/readModelsStore.ts"; -import { clearProposalDraftsForTests } from "../api/_lib/proposalDraftsStore.ts"; -import { - clearProposalsForTests, - transitionProposalStage, -} from "../api/_lib/proposalsStore.ts"; -import { clearPoolVotesForTests } from "../api/_lib/poolVotesStore.ts"; -import { clearChamberVotesForTests } from "../api/_lib/chamberVotesStore.ts"; -import { clearEraForTests } from "../api/_lib/eraStore.ts"; -import { - clearChamberMembershipsForTests, - ensureChamberMembership, -} from "../api/_lib/chamberMembershipsStore.ts"; - -function makeContext({ url, env, params, method = "POST", headers, body }) { - return { - request: new Request(url, { method, headers, body }), - env, - params, - }; -} - -async function makeSessionCookie(env, address) { - const headers = new Headers(); - await issueSession(headers, env, "https://local.test/api/command", address); - const setCookie = headers.get("set-cookie"); - assert.ok(setCookie); - const tokenPair = setCookie.split(";")[0]; - const [name, value] = tokenPair.split("="); - assert.equal(name, getSessionCookieName()); - await ensureChamberMembership(env, { - address, - chamberId: "general", - source: "test", - }); - await ensureChamberMembership(env, { - address, - chamberId: "engineering", - source: "test", - }); - return `${name}=${value}`; -} - -const baseEnv = { - SESSION_SECRET: "test-secret", - DEV_BYPASS_GATE: "true", - DEV_INSECURE_COOKIES: "true", - READ_MODELS_INLINE_EMPTY: "true", - DEV_BYPASS_ADMIN: "true", - SIM_ENABLE_STAGE_WINDOWS: "true", - SIM_POOL_WINDOW_SECONDS: "1", - SIM_VOTE_WINDOW_SECONDS: "1", - SIM_CONFIG_JSON: JSON.stringify({ - genesisChambers: [ - { id: "general", title: "General", multiplier: 1.2 }, - { id: "engineering", title: "Engineering", multiplier: 1.5 }, - ], - genesisChamberMembers: {}, - }), -}; - -function makeDraftForm() { - return { - title: "Stage window test proposal", - chamberId: "engineering", - summary: "Short summary for the draft.", - what: "What", - why: "Why", - how: "How", - timeline: [{ id: "ms-1", title: "Milestone 1", timeframe: "2 weeks" }], - outputs: [], - budgetItems: [{ id: "b-1", description: "Work", amount: "1000" }], - aboutMe: "", - attachments: [], - agreeRules: true, - confirmBudget: true, - }; -} - -test("stage windows: pool votes rejected after pool window ends", async () => { - clearIdempotencyForTests(); - clearInlineReadModelsForTests(); - clearProposalDraftsForTests(); - clearProposalsForTests(); - clearPoolVotesForTests(); - await clearChamberVotesForTests(); - clearChamberMembershipsForTests(); - clearEraForTests(); - - const cookie = await makeSessionCookie(baseEnv, "5TestAddr"); - - const saveRes = await commandPost( - makeContext({ - url: "https://local.test/api/command", - env: baseEnv, - headers: { "content-type": "application/json", cookie }, - body: JSON.stringify({ - type: "proposal.draft.save", - payload: { form: makeDraftForm() }, - }), - }), - ); - assert.equal(saveRes.status, 200); - const saved = await saveRes.json(); - - const submitRes = await commandPost( - makeContext({ - url: "https://local.test/api/command", - env: baseEnv, - headers: { "content-type": "application/json", cookie }, - body: JSON.stringify({ - type: "proposal.submitToPool", - payload: { draftId: saved.draftId }, - }), - }), - ); - assert.equal(submitRes.status, 200); - const submitJson = await submitRes.json(); - - const envExpired = { - ...baseEnv, - SIM_NOW_ISO: new Date(Date.now() + 5_000).toISOString(), - }; - const voteRes = await commandPost( - makeContext({ - url: "https://local.test/api/command", - env: envExpired, - headers: { "content-type": "application/json", cookie }, - body: JSON.stringify({ - type: "pool.vote", - payload: { proposalId: submitJson.proposalId, direction: "up" }, - }), - }), - ); - assert.equal(voteRes.status, 409); -}); - -test("stage windows: chamber votes rejected after voting window ends", async () => { - clearIdempotencyForTests(); - clearInlineReadModelsForTests(); - clearProposalDraftsForTests(); - clearProposalsForTests(); - clearPoolVotesForTests(); - await clearChamberVotesForTests(); - clearChamberMembershipsForTests(); - clearEraForTests(); - - const cookie = await makeSessionCookie(baseEnv, "5TestAddr"); - await ensureChamberMembership(baseEnv, { - address: "5TestAddr", - chamberId: "engineering", - source: "test", - }); - - const saveRes = await commandPost( - makeContext({ - url: "https://local.test/api/command", - env: baseEnv, - headers: { "content-type": "application/json", cookie }, - body: JSON.stringify({ - type: "proposal.draft.save", - payload: { form: makeDraftForm() }, - }), - }), - ); - assert.equal(saveRes.status, 200); - const saved = await saveRes.json(); - - const submitRes = await commandPost( - makeContext({ - url: "https://local.test/api/command", - env: baseEnv, - headers: { "content-type": "application/json", cookie }, - body: JSON.stringify({ - type: "proposal.submitToPool", - payload: { draftId: saved.draftId }, - }), - }), - ); - assert.equal(submitRes.status, 200); - const submitJson = await submitRes.json(); - - const moved = await transitionProposalStage(baseEnv, { - proposalId: submitJson.proposalId, - from: "pool", - to: "vote", - }); - assert.equal(moved, true); - - const envExpired = { - ...baseEnv, - SIM_NOW_ISO: new Date(Date.now() + 5_000).toISOString(), - }; - const voteRes = await commandPost( - makeContext({ - url: "https://local.test/api/command", - env: envExpired, - headers: { "content-type": "application/json", cookie }, - body: JSON.stringify({ - type: "chamber.vote", - payload: { proposalId: submitJson.proposalId, choice: "yes" }, - }), - }), - ); - assert.equal(voteRes.status, 409); -}); diff --git a/tests/api-veto.test.js b/tests/api-veto.test.js deleted file mode 100644 index f0b55bf..0000000 --- a/tests/api-veto.test.js +++ /dev/null @@ -1,270 +0,0 @@ -import assert from "node:assert/strict"; -import { test } from "node:test"; - -import { onRequestPost as commandPost } from "../api/routes/command.ts"; -import { onRequestPost as tickPost } from "../api/routes/clock/tick.ts"; -import { getSessionCookieName, issueSession } from "../api/_lib/auth.ts"; -import { clearChamberVotesForTests } from "../api/_lib/chamberVotesStore.ts"; -import { - awardCmOnce, - clearCmAwardsForTests, -} from "../api/_lib/cmAwardsStore.ts"; -import { clearIdempotencyForTests } from "../api/_lib/idempotencyStore.ts"; -import { clearInlineReadModelsForTests } from "../api/_lib/readModelsStore.ts"; -import { - clearChamberMembershipsForTests, - ensureChamberMembership, -} from "../api/_lib/chamberMembershipsStore.ts"; -import { - clearProposalsForTests, - createProposal, - getProposal, -} from "../api/_lib/proposalsStore.ts"; -import { clearVetoVotesForTests } from "../api/_lib/vetoVotesStore.ts"; - -function makeContext({ url, env, params, method = "POST", headers, body }) { - return { - request: new Request(url, { method, headers, body }), - env, - params, - }; -} - -async function makeSessionCookie(env, address) { - const headers = new Headers(); - await issueSession(headers, env, "https://local.test/api/command", address); - const setCookie = headers.get("set-cookie"); - assert.ok(setCookie); - const tokenPair = setCookie.split(";")[0]; - const [name, value] = tokenPair.split("="); - assert.equal(name, getSessionCookieName()); - return `${name}=${value}`; -} - -const baseEnv = { - SESSION_SECRET: "test-secret", - DEV_BYPASS_GATE: "true", - DEV_INSECURE_COOKIES: "true", - READ_MODELS_INLINE: "true", - READ_MODELS_INLINE_EMPTY: "true", - DEV_BYPASS_ADMIN: "true", - DEV_BYPASS_CHAMBER_ELIGIBILITY: "true", - SIM_ENABLE_STAGE_WINDOWS: "false", -}; - -test("veto vote can reset a passed chamber vote and pause voting", async () => { - await clearChamberVotesForTests(); - clearChamberMembershipsForTests(); - clearIdempotencyForTests(); - clearInlineReadModelsForTests(); - await clearCmAwardsForTests(); - clearVetoVotesForTests(); - clearProposalsForTests(); - - const proposalId = "veto-test-proposal"; - await createProposal(baseEnv, { - id: proposalId, - stage: "vote", - authorAddress: "5Proposer", - title: "Veto test", - chamberId: "general", - summary: "Test proposal", - payload: { formationEligible: false }, - }); - const created = await getProposal(baseEnv, proposalId); - assert.ok(created); - const envNow = { ...baseEnv, SIM_NOW_ISO: created.updatedAt.toISOString() }; - - await awardCmOnce(envNow, { - proposalId: "award-general-1", - proposerId: "5VetoHolder", - chamberId: "general", - avgScore: 8, - lcmPoints: 80, - chamberMultiplierTimes10: 10, - mcmPoints: 80, - }); - - await ensureChamberMembership(envNow, { - address: "5VetoHolder", - chamberId: "general", - source: "test", - }); - for (let i = 0; i < 50; i += 1) { - await ensureChamberMembership(envNow, { - address: `5Vote${i}`, - chamberId: "general", - source: "test", - }); - } - - for (let i = 0; i < 50; i += 1) { - const voter = `5Vote${i}`; - const cookie = await makeSessionCookie(envNow, voter); - const choice = i < 34 ? "yes" : "no"; - const res = await commandPost( - makeContext({ - url: "https://local.test/api/command", - env: envNow, - headers: { "content-type": "application/json", cookie }, - body: JSON.stringify({ - type: "chamber.vote", - payload: { - proposalId, - choice, - ...(choice === "yes" ? { score: 8 } : {}), - }, - }), - }), - ); - if (res.status === 409) break; - assert.equal(res.status, 200); - } - - const afterPass = await getProposal(envNow, proposalId); - assert.ok(afterPass); - assert.equal(afterPass.stage, "vote"); - assert.ok(afterPass.votePassedAt); - assert.ok(afterPass.voteFinalizesAt); - assert.ok(Array.isArray(afterPass.vetoCouncil)); - assert.equal(afterPass.vetoThreshold, 1); - - const vetoCookie = await makeSessionCookie(envNow, "5VetoHolder"); - const vetoRes = await commandPost( - makeContext({ - url: "https://local.test/api/command", - env: envNow, - headers: { "content-type": "application/json", cookie: vetoCookie }, - body: JSON.stringify({ - type: "veto.vote", - payload: { proposalId, choice: "veto" }, - }), - }), - ); - assert.equal(vetoRes.status, 200); - - const afterVeto = await getProposal(envNow, proposalId); - assert.ok(afterVeto); - assert.equal(afterVeto.stage, "vote"); - assert.equal(afterVeto.vetoCount, 1); - assert.equal(afterVeto.votePassedAt, null); - assert.equal(afterVeto.voteFinalizesAt, null); - assert.equal(afterVeto.vetoCouncil, null); - assert.equal(afterVeto.vetoThreshold, null); - assert.ok( - afterVeto.updatedAt.getTime() > new Date(envNow.SIM_NOW_ISO).getTime(), - ); - - const cookieAfter = await makeSessionCookie(envNow, "5VoteAfter"); - const resAfter = await commandPost( - makeContext({ - url: "https://local.test/api/command", - env: envNow, - headers: { "content-type": "application/json", cookie: cookieAfter }, - body: JSON.stringify({ - type: "chamber.vote", - payload: { proposalId, choice: "yes", score: 8 }, - }), - }), - ); - assert.equal(resAfter.status, 409); - const jsonAfter = await resAfter.json(); - assert.equal(jsonAfter.error?.code, "vote_paused"); -}); - -test("tick finalizes a passed chamber vote when veto window ends", async () => { - await clearChamberVotesForTests(); - clearChamberMembershipsForTests(); - clearIdempotencyForTests(); - clearInlineReadModelsForTests(); - await clearCmAwardsForTests(); - clearVetoVotesForTests(); - clearProposalsForTests(); - - const proposalId = "veto-test-finalize"; - await createProposal(baseEnv, { - id: proposalId, - stage: "vote", - authorAddress: "5Proposer", - title: "Veto finalize test", - chamberId: "general", - summary: "Test proposal", - payload: { formationEligible: false }, - }); - const created = await getProposal(baseEnv, proposalId); - assert.ok(created); - const envNow = { ...baseEnv, SIM_NOW_ISO: created.updatedAt.toISOString() }; - - await awardCmOnce(envNow, { - proposalId: "award-general-2", - proposerId: "5VetoHolder", - chamberId: "general", - avgScore: 8, - lcmPoints: 80, - chamberMultiplierTimes10: 10, - mcmPoints: 80, - }); - - await ensureChamberMembership(envNow, { - address: "5VetoHolder", - chamberId: "general", - source: "test", - }); - for (let i = 0; i < 50; i += 1) { - await ensureChamberMembership(envNow, { - address: `5VoteF${i}`, - chamberId: "general", - source: "test", - }); - } - - for (let i = 0; i < 50; i += 1) { - const voter = `5VoteF${i}`; - const cookie = await makeSessionCookie(envNow, voter); - const choice = i < 34 ? "yes" : "no"; - const res = await commandPost( - makeContext({ - url: "https://local.test/api/command", - env: envNow, - headers: { "content-type": "application/json", cookie }, - body: JSON.stringify({ - type: "chamber.vote", - payload: { - proposalId, - choice, - ...(choice === "yes" ? { score: 8 } : {}), - }, - }), - }), - ); - if (res.status === 409) break; - assert.equal(res.status, 200); - } - - const passed = await getProposal(envNow, proposalId); - assert.ok(passed); - assert.equal(passed.stage, "vote"); - assert.ok(passed.voteFinalizesAt); - - const envAfter = { - ...envNow, - SIM_NOW_ISO: new Date( - passed.voteFinalizesAt.getTime() + 1000, - ).toISOString(), - }; - - const tickRes = await tickPost( - makeContext({ - url: "https://local.test/api/clock/tick", - env: envAfter, - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ rollup: false }), - }), - ); - assert.equal(tickRes.status, 200); - - const finalized = await getProposal(envAfter, proposalId); - assert.ok(finalized); - assert.equal(finalized.stage, "build"); -}); diff --git a/tests/auth-ui-connect-errors.test.js b/tests/auth-ui-connect-errors.test.js deleted file mode 100644 index 6a88cf1..0000000 --- a/tests/auth-ui-connect-errors.test.js +++ /dev/null @@ -1,18 +0,0 @@ -import assert from "node:assert/strict"; -import { test } from "node:test"; - -import { formatAuthConnectError } from "../src/app/auth/connectErrors.ts"; - -test("formatAuthConnectError: shows API handlers hint on HTTP 404", () => { - assert.equal( - formatAuthConnectError({ message: "HTTP 404" }), - "API is not available at `/api/*`. Start the backend with `yarn dev:api` (after `yarn build`) or run `yarn dev:full`. If you only run `yarn dev`, there is no API.", - ); -}); - -test("formatAuthConnectError: shows API handlers hint on network failures", () => { - assert.equal( - formatAuthConnectError({ message: "Failed to fetch" }), - "API is not reachable at `/api/*`. If you are running locally, start the backend with `yarn dev:api` (after `yarn build`) or run `yarn dev:full`. If you are on a deployed site, check that the backend is deployed and `/api/health` responds.", - ); -}); diff --git a/tests/chamber-quorum.test.js b/tests/chamber-quorum.test.js deleted file mode 100644 index f61cf03..0000000 --- a/tests/chamber-quorum.test.js +++ /dev/null @@ -1,39 +0,0 @@ -import assert from "node:assert/strict"; -import { test } from "node:test"; - -import { evaluateChamberQuorum } from "../api/_lib/chamberQuorum.ts"; - -test("evaluateChamberQuorum requires quorum + passing", () => { - const inputs = { - quorumFraction: 0.33, - activeGovernors: 150, - passingFraction: 2 / 3, - }; - - const notEnoughQuorum = evaluateChamberQuorum(inputs, { - yes: 40, - no: 0, - abstain: 0, - }); - assert.equal(notEnoughQuorum.quorumNeeded, 50); - assert.equal(notEnoughQuorum.quorumMet, false); - assert.equal(notEnoughQuorum.shouldAdvance, false); - - const quorumButNotPassing = evaluateChamberQuorum(inputs, { - yes: 33, - no: 17, - abstain: 0, - }); - assert.equal(quorumButNotPassing.quorumMet, true); - assert.equal(quorumButNotPassing.passMet, false); - assert.equal(quorumButNotPassing.shouldAdvance, false); - - const quorumAndPassing = evaluateChamberQuorum(inputs, { - yes: 35, - no: 17, - abstain: 0, - }); - assert.equal(quorumAndPassing.quorumMet, true); - assert.equal(quorumAndPassing.passMet, true); - assert.equal(quorumAndPassing.shouldAdvance, true); -}); diff --git a/tests/chamber-votes-score.test.js b/tests/chamber-votes-score.test.js deleted file mode 100644 index 4576726..0000000 --- a/tests/chamber-votes-score.test.js +++ /dev/null @@ -1,42 +0,0 @@ -import assert from "node:assert/strict"; -import { test } from "node:test"; - -import { - castChamberVote, - clearChamberVotesForTests, - getChamberYesScoreAverage, -} from "../api/_lib/chamberVotesStore.ts"; - -const env = {}; - -test("chamber vote score average is computed from yes votes only", async () => { - await clearChamberVotesForTests(); - - await castChamberVote(env, { - proposalId: "tier-decay-v1", - voterAddress: "5A", - choice: 1, - score: 8, - }); - await castChamberVote(env, { - proposalId: "tier-decay-v1", - voterAddress: "5B", - choice: 1, - score: 6, - }); - await castChamberVote(env, { - proposalId: "tier-decay-v1", - voterAddress: "5C", - choice: -1, - score: 10, - }); - await castChamberVote(env, { - proposalId: "tier-decay-v1", - voterAddress: "5D", - choice: 0, - score: 10, - }); - - const avg = await getChamberYesScoreAverage(env, "tier-decay-v1"); - assert.equal(avg, 7); -}); diff --git a/tests/db-seed.test.js b/tests/db-seed.test.js deleted file mode 100644 index d39a7fb..0000000 --- a/tests/db-seed.test.js +++ /dev/null @@ -1,50 +0,0 @@ -import assert from "node:assert/strict"; -import { test } from "node:test"; - -import { buildReadModelSeed } from "../db/seed/readModels.ts"; - -function stableStringify(value) { - if (value === null || typeof value !== "object") return JSON.stringify(value); - if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`; - const obj = value; - const keys = Object.keys(obj).sort(); - return `{${keys.map((k) => `${JSON.stringify(k)}:${stableStringify(obj[k])}`).join(",")}}`; -} - -test("db seed: produces deterministic, unique keys and JSON-safe payloads", () => { - const seed = buildReadModelSeed(); - assert.ok(seed.length > 0); - - const keys = seed.map((e) => e.key); - const uniqueKeys = new Set(keys); - assert.equal(uniqueKeys.size, keys.length, "seed contains duplicate keys"); - - // Basic must-have read models - for (const required of [ - "chambers:list", - "chambers:engineering", - "proposals:list", - "courts:list", - "humans:list", - "factions:list", - "formation:directory", - "invision:dashboard", - "my-governance:summary", - "proposals:drafts:list", - ]) { - assert.ok(uniqueKeys.has(required), `missing seed entry: ${required}`); - } - - // JSON-safe: should be serializable without throwing and without Symbols/functions. - for (const entry of seed) { - assert.doesNotThrow( - () => JSON.stringify(entry.payload), - `payload not JSON-safe: ${entry.key}`, - ); - } - - // Deterministic ordering for snapshot-like stability (in case we later hash it for idempotency). - const first = stableStringify(seed); - const second = stableStringify(buildReadModelSeed()); - assert.equal(first, second); -}); diff --git a/tests/delegations-cycle.test.js b/tests/delegations-cycle.test.js deleted file mode 100644 index 0732d0a..0000000 --- a/tests/delegations-cycle.test.js +++ /dev/null @@ -1,32 +0,0 @@ -import assert from "node:assert/strict"; -import { test } from "node:test"; - -import { - clearDelegationsForTests, - setDelegation, -} from "../api/_lib/delegationsStore.ts"; - -const env = {}; - -test("delegation: rejects cycles within a chamber", async () => { - clearDelegationsForTests(); - - await setDelegation(env, { - chamberId: "general", - delegatorAddress: "A", - delegateeAddress: "B", - }); - - await assert.rejects( - () => - setDelegation(env, { - chamberId: "general", - delegatorAddress: "B", - delegateeAddress: "A", - }), - (err) => { - assert.equal(err?.message, "delegation_cycle"); - return true; - }, - ); -}); diff --git a/tests/events-seed.test.js b/tests/events-seed.test.js deleted file mode 100644 index 35e373e..0000000 --- a/tests/events-seed.test.js +++ /dev/null @@ -1,33 +0,0 @@ -import assert from "node:assert/strict"; -import { test } from "node:test"; - -import { buildEventSeed } from "../db/seed/events.ts"; - -function stableStringify(value) { - if (value === null || typeof value !== "object") return JSON.stringify(value); - if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`; - const obj = value; - const keys = Object.keys(obj).sort(); - return `{${keys.map((k) => `${JSON.stringify(k)}:${stableStringify(obj[k])}`).join(",")}}`; -} - -test("events seed: deterministic and JSON-safe", () => { - const seed = buildEventSeed(); - assert.ok(seed.length > 0); - - for (const entry of seed) { - assert.equal(entry.type, "feed.item.v1"); - assert.equal(entry.entityType, "feed"); - assert.equal(typeof entry.entityId, "string"); - assert.equal(entry.stage, entry.payload.stage); - assert.equal(entry.entityId, entry.payload.id); - assert.ok(entry.createdAt instanceof Date); - - assert.doesNotThrow(() => JSON.stringify(entry.payload)); - } - - // Deterministic ordering and stable output. - const first = stableStringify(seed); - const second = stableStringify(buildEventSeed()); - assert.equal(first, second); -}); diff --git a/tests/feed-event-projector.test.js b/tests/feed-event-projector.test.js deleted file mode 100644 index eb69864..0000000 --- a/tests/feed-event-projector.test.js +++ /dev/null @@ -1,45 +0,0 @@ -import assert from "node:assert/strict"; -import { test } from "node:test"; - -import { projectFeedPageFromEvents } from "../api/_lib/feedEventProjector.ts"; - -function makeItem(id, stage) { - return { - id, - title: `Title ${id}`, - meta: "Meta", - stage, - summaryPill: "Pill", - summary: "Summary", - timestamp: "2026-01-01T00:00:00Z", - }; -} - -test("feed event projector: paginates by seq and filters by stage", () => { - const rows = [ - { seq: 1, stage: "pool", payload: makeItem("a", "pool") }, - { seq: 2, stage: "vote", payload: makeItem("b", "vote") }, - { seq: 3, stage: "pool", payload: makeItem("c", "pool") }, - { seq: 4, stage: "courts", payload: makeItem("d", "courts") }, - ]; - - const page1 = projectFeedPageFromEvents(rows, { limit: 2 }); - assert.deepEqual( - page1.items.map((i) => i.id), - ["d", "c"], - ); - assert.equal(page1.nextSeq, 2); - - const page2 = projectFeedPageFromEvents(rows, { limit: 2, beforeSeq: 2 }); - assert.deepEqual( - page2.items.map((i) => i.id), - ["a"], - ); - assert.equal(page2.nextSeq, undefined); - - const pools = projectFeedPageFromEvents(rows, { limit: 10, stage: "pool" }); - assert.deepEqual( - pools.items.map((i) => i.id), - ["c", "a"], - ); -}); diff --git a/tests/migrations.test.js b/tests/migrations.test.js deleted file mode 100644 index 9dd292b..0000000 --- a/tests/migrations.test.js +++ /dev/null @@ -1,49 +0,0 @@ -import assert from "node:assert/strict"; -import { test } from "node:test"; -import { readFileSync, readdirSync } from "node:fs"; - -test("db migrations: contain core tables", () => { - const migrationFiles = readdirSync("db/migrations") - .filter((name) => name.endsWith(".sql")) - .sort((a, b) => a.localeCompare(b)); - const sql = migrationFiles - .map((name) => readFileSync(`db/migrations/${name}`, "utf8")) - .join("\n"); - for (const table of [ - "admin_state", - "users", - "auth_nonces", - "eligibility_cache", - "clock_state", - "read_models", - "events", - "pool_votes", - "chamber_votes", - "chamber_memberships", - "chambers", - "proposal_drafts", - "proposal_stage_denominators", - "proposals", - "veto_votes", - "chamber_multiplier_submissions", - "delegations", - "delegation_events", - "idempotency_keys", - "api_rate_limits", - "cm_awards", - "formation_projects", - "formation_team", - "formation_milestones", - "formation_milestone_events", - "court_cases", - "court_reports", - "court_verdicts", - "era_snapshots", - "era_user_activity", - "era_rollups", - "era_user_status", - "user_action_locks", - ]) { - assert.match(sql, new RegExp(`CREATE TABLE\\s+\\"${table}\\"`)); - } -}); diff --git a/tests/pool-quorum.test.js b/tests/pool-quorum.test.js deleted file mode 100644 index 193d313..0000000 --- a/tests/pool-quorum.test.js +++ /dev/null @@ -1,35 +0,0 @@ -import assert from "node:assert/strict"; -import { test } from "node:test"; - -import { evaluatePoolQuorum } from "../api/_lib/poolQuorum.ts"; - -test("evaluatePoolQuorum uses ceil for engagedNeeded", () => { - const result = evaluatePoolQuorum( - { attentionQuorum: 0.22, activeGovernors: 150, upvoteFloor: 15 }, - { upvotes: 15, downvotes: 14 }, - ); - assert.equal(result.engagedNeeded, 33); - assert.equal(result.attentionMet, false); - assert.equal(result.upvoteMet, true); - assert.equal(result.shouldAdvance, false); -}); - -test("evaluatePoolQuorum advances only when attention + upvote floor are met", () => { - const result = evaluatePoolQuorum( - { attentionQuorum: 0.22, activeGovernors: 150, upvoteFloor: 15 }, - { upvotes: 15, downvotes: 18 }, - ); - assert.equal(result.engaged, 33); - assert.equal(result.attentionMet, true); - assert.equal(result.upvoteMet, true); - assert.equal(result.shouldAdvance, true); -}); - -test("evaluatePoolQuorum never advances when activeGovernors is zero", () => { - const result = evaluatePoolQuorum( - { attentionQuorum: 0.22, activeGovernors: 0, upvoteFloor: 1 }, - { upvotes: 10, downvotes: 0 }, - ); - assert.equal(result.attentionMet, false); - assert.equal(result.shouldAdvance, false); -}); diff --git a/tests/proposal-draft-migration.test.js b/tests/proposal-draft-migration.test.js deleted file mode 100644 index 0e0acfe..0000000 --- a/tests/proposal-draft-migration.test.js +++ /dev/null @@ -1,74 +0,0 @@ -import assert from "node:assert/strict"; -import { test } from "node:test"; - -import { - clearProposalDraftsForTests, - listDrafts, - seedLegacyDraftForTests, -} from "../api/_lib/proposalDraftsStore.ts"; - -test("proposal drafts: legacy project payloads infer templateId", async () => { - clearProposalDraftsForTests(); - - seedLegacyDraftForTests({ - authorAddress: "5TestAddr", - draftId: "draft-legacy-project", - title: "Legacy Project Draft", - chamberId: "engineering", - summary: "Legacy summary", - payload: { - title: "Legacy Project Draft", - chamberId: "engineering", - summary: "Legacy summary", - what: "What: build something.", - why: "Why: governance needs it.", - how: "How: step 1.", - timeline: [{ id: "ms-1", title: "Milestone 1", timeframe: "2 weeks" }], - outputs: [{ id: "out-1", label: "Docs", url: "" }], - budgetItems: [{ id: "b-1", description: "Work", amount: "1000" }], - aboutMe: "", - attachments: [], - agreeRules: true, - confirmBudget: true, - }, - }); - - const drafts = await listDrafts( - {}, - { authorAddress: "5TestAddr", includeSubmitted: true }, - ); - assert.equal(drafts.length, 1); - assert.equal(drafts[0].payload.templateId, "project"); -}); - -test("proposal drafts: legacy system payloads infer templateId + defaults", async () => { - clearProposalDraftsForTests(); - - seedLegacyDraftForTests({ - authorAddress: "5TestAddr", - draftId: "draft-legacy-system", - title: "Legacy System Draft", - chamberId: "general", - summary: "", - payload: { - title: "Legacy System Draft", - chamberId: "general", - metaGovernance: { - action: "chamber.create", - chamberId: "design", - title: "Design chamber", - }, - agreeRules: true, - confirmBudget: true, - }, - }); - - const drafts = await listDrafts( - {}, - { authorAddress: "5TestAddr", includeSubmitted: true }, - ); - assert.equal(drafts.length, 1); - assert.equal(drafts[0].payload.templateId, "system"); - assert.equal(drafts[0].payload.summary, ""); - assert.deepEqual(drafts[0].payload.timeline, []); -}); diff --git a/tests/proposal-stage-transition.test.js b/tests/proposal-stage-transition.test.js deleted file mode 100644 index 38267ea..0000000 --- a/tests/proposal-stage-transition.test.js +++ /dev/null @@ -1,50 +0,0 @@ -import assert from "node:assert/strict"; -import { test } from "node:test"; - -import { - clearProposalsForTests, - createProposal, - getProposal, - transitionProposalStage, -} from "../api/_lib/proposalsStore.ts"; - -const env = {}; - -test("proposal stage transitions: compare-and-set + validation", async () => { - clearProposalsForTests(); - - await createProposal(env, { - id: "p-1", - stage: "pool", - authorAddress: "alice", - title: "Test", - chamberId: "engineering", - summary: "Summary", - payload: {}, - }); - - const moved = await transitionProposalStage(env, { - proposalId: "p-1", - from: "pool", - to: "vote", - }); - assert.equal(moved, true); - assert.equal((await getProposal(env, "p-1"))?.stage, "vote"); - - const movedAgain = await transitionProposalStage(env, { - proposalId: "p-1", - from: "pool", - to: "vote", - }); - assert.equal(movedAgain, false); - - await assert.rejects( - () => - transitionProposalStage(env, { - proposalId: "p-1", - from: "vote", - to: "pool", - }), - /invalid_transition/, - ); -}); diff --git a/tests/proposal-wizard-system-template.test.js b/tests/proposal-wizard-system-template.test.js deleted file mode 100644 index 37b7b2f..0000000 --- a/tests/proposal-wizard-system-template.test.js +++ /dev/null @@ -1,33 +0,0 @@ -import assert from "node:assert/strict"; -import { test } from "node:test"; - -import { systemTemplate } from "../src/pages/proposals/proposalCreation/templates/system.ts"; - -test("system wizard: compute does not require project fields", () => { - const draft = { - title: "Create Design Chamber", - chamberId: "general", - summary: "", - what: "", - why: "", - how: "Apply the change after approval and announce.", - metaGovernance: { - action: "chamber.create", - chamberId: "design", - title: "Design chamber", - multiplier: 3, - genesisMembers: [], - }, - timeline: [], - outputs: [], - budgetItems: [], - aboutMe: "", - attachments: [], - agreeRules: true, - confirmBudget: true, - }; - const computed = systemTemplate.compute(draft, { budgetTotal: 0 }); - assert.equal(computed.essentialsValid, true); - assert.equal(computed.planValid, true); - assert.equal(computed.canSubmit, true); -}); diff --git a/tests/proposal-wizard-template-registry.test.js b/tests/proposal-wizard-template-registry.test.js deleted file mode 100644 index a920cd2..0000000 --- a/tests/proposal-wizard-template-registry.test.js +++ /dev/null @@ -1,40 +0,0 @@ -import assert from "node:assert/strict"; -import { test } from "node:test"; - -import { - DEFAULT_WIZARD_TEMPLATE_ID, - WIZARD_TEMPLATES, - getWizardTemplate, -} from "../src/pages/proposals/proposalCreation/templates/registry.ts"; - -test("proposal wizard template registry: ids are stable and unique", () => { - const keys = Object.keys(WIZARD_TEMPLATES); - assert.ok(keys.length > 0); - assert.equal(new Set(keys).size, keys.length); - assert.ok(keys.includes(DEFAULT_WIZARD_TEMPLATE_ID)); -}); - -test("proposal wizard project template has the expected step order", () => { - const template = getWizardTemplate("project"); - assert.equal(template.id, "project"); - assert.deepEqual(template.stepOrder, [ - "essentials", - "plan", - "budget", - "review", - ]); - for (const step of template.stepOrder) { - assert.equal(typeof template.stepTitles[step], "string"); - assert.equal(typeof template.stepTabLabels[step], "string"); - } -}); - -test("proposal wizard system template skips the budget step", () => { - const template = getWizardTemplate("system"); - assert.equal(template.id, "system"); - assert.deepEqual(template.stepOrder, ["essentials", "plan", "review"]); - for (const step of template.stepOrder) { - assert.equal(typeof template.stepTitles[step], "string"); - assert.equal(typeof template.stepTabLabels[step], "string"); - } -}); diff --git a/tests/scenario-chamber-create-flow.test.js b/tests/scenario-chamber-create-flow.test.js deleted file mode 100644 index 3e26070..0000000 --- a/tests/scenario-chamber-create-flow.test.js +++ /dev/null @@ -1,201 +0,0 @@ -import assert from "node:assert/strict"; -import { test } from "node:test"; - -import { onRequestPost as commandPost } from "../api/routes/command.ts"; -import { onRequestGet as chambersGet } from "../api/routes/chambers/index.ts"; -import { getSessionCookieName, issueSession } from "../api/_lib/auth.ts"; -import { clearApiRateLimitsForTests } from "../api/_lib/apiRateLimitStore.ts"; -import { clearChamberVotesForTests } from "../api/_lib/chamberVotesStore.ts"; -import { - clearChamberMembershipsForTests, - ensureChamberMembership, -} from "../api/_lib/chamberMembershipsStore.ts"; -import { clearChambersForTests } from "../api/_lib/chambersStore.ts"; -import { clearCmAwardsForTests } from "../api/_lib/cmAwardsStore.ts"; -import { clearIdempotencyForTests } from "../api/_lib/idempotencyStore.ts"; -import { clearPoolVotesForTests } from "../api/_lib/poolVotesStore.ts"; -import { clearProposalsForTests } from "../api/_lib/proposalsStore.ts"; -import { clearProposalDraftsForTests } from "../api/_lib/proposalDraftsStore.ts"; -import { clearInlineReadModelsForTests } from "../api/_lib/readModelsStore.ts"; -import { clearVetoVotesForTests } from "../api/_lib/vetoVotesStore.ts"; -import { getProposal } from "../api/_lib/proposalsStore.ts"; - -function makeContext({ url, env, params, method = "POST", headers, body }) { - return { - request: new Request(url, { method, headers, body }), - env, - params, - }; -} - -async function makeSessionCookie(env, address) { - const headers = new Headers(); - await issueSession(headers, env, "https://local.test/api/command", address); - const setCookie = headers.get("set-cookie"); - assert.ok(setCookie); - const tokenPair = setCookie.split(";")[0]; - const [name, value] = tokenPair.split("="); - assert.equal(name, getSessionCookieName()); - return `${name}=${value}`; -} - -async function resetAll() { - await clearPoolVotesForTests(); - await clearChamberVotesForTests(); - clearChamberMembershipsForTests(); - clearChambersForTests(); - await clearCmAwardsForTests(); - clearVetoVotesForTests(); - clearIdempotencyForTests(); - clearProposalDraftsForTests(); - clearProposalsForTests(); - clearInlineReadModelsForTests(); - clearApiRateLimitsForTests(); -} - -function baseEnv(overrides = {}) { - return { - SESSION_SECRET: "test-secret", - DEV_BYPASS_GATE: "true", - DEV_INSECURE_COOKIES: "true", - READ_MODELS_INLINE: "true", - DEV_BYPASS_ADMIN: "true", - SIM_COMMAND_RATE_LIMIT_PER_MINUTE_ADDRESS: "1000", - SIM_COMMAND_RATE_LIMIT_PER_MINUTE_IP: "1000", - SIM_CONFIG_JSON: JSON.stringify({ - genesisChambers: [{ id: "general", title: "General", multiplier: 1.2 }], - genesisChamberMembers: {}, - }), - ...overrides, - }; -} - -test("scenario: create chamber via General meta-governance proposal (draft → pool → vote → chambers)", async () => { - await resetAll(); - const env = baseEnv(); - - const proposerAddress = "5Proposer"; - const proposerCookie = await makeSessionCookie(env, proposerAddress); - - const voters = Array.from({ length: 15 }, (_, i) => `5Gov${i}`); - for (const address of voters) { - await ensureChamberMembership(env, { - address, - chamberId: "engineering", - source: "test", - }); - } - - const saveDraft = await commandPost( - makeContext({ - url: "https://local.test/api/command", - env, - headers: { "content-type": "application/json", cookie: proposerCookie }, - body: JSON.stringify({ - type: "proposal.draft.save", - payload: { - form: { - title: "Create Science chamber", - chamberId: "general", - summary: "Creates a new specialization chamber.", - what: "Create a new specialization chamber for science.", - why: "Need a dedicated domain for proposals.", - how: "Create the chamber and seed initial members.", - metaGovernance: { - action: "chamber.create", - chamberId: "science", - title: "Science", - multiplier: 1.25, - genesisMembers: ["5Founder1", "5Founder2"], - }, - timeline: [], - outputs: [], - budgetItems: [], - aboutMe: "", - attachments: [], - agreeRules: true, - confirmBudget: true, - }, - }, - }), - }), - ); - assert.equal(saveDraft.status, 200); - const saveJson = await saveDraft.json(); - assert.ok( - typeof saveJson.draftId === "string" && saveJson.draftId.length > 0, - ); - - const submit = await commandPost( - makeContext({ - url: "https://local.test/api/command", - env, - headers: { "content-type": "application/json", cookie: proposerCookie }, - body: JSON.stringify({ - type: "proposal.submitToPool", - payload: { draftId: saveJson.draftId }, - }), - }), - ); - assert.equal(submit.status, 200); - const submitJson = await submit.json(); - assert.ok( - typeof submitJson.proposalId === "string" && - submitJson.proposalId.length > 0, - ); - const proposalId = submitJson.proposalId; - - for (let i = 0; i < voters.length; i += 1) { - const cookie = await makeSessionCookie(env, voters[i]); - const res = await commandPost( - makeContext({ - url: "https://local.test/api/command", - env, - headers: { "content-type": "application/json", cookie }, - body: JSON.stringify({ - type: "pool.vote", - payload: { proposalId, direction: "up" }, - }), - }), - ); - if (res.status === 409) break; - assert.equal(res.status, 200); - } - - const afterPool = await getProposal(env, proposalId); - assert.ok(afterPool); - assert.equal(afterPool.stage, "vote"); - - for (let i = 0; i < 10; i += 1) { - const cookie = await makeSessionCookie(env, voters[i]); - const res = await commandPost( - makeContext({ - url: "https://local.test/api/command", - env, - headers: { "content-type": "application/json", cookie }, - body: JSON.stringify({ - type: "chamber.vote", - payload: { proposalId, choice: "yes", score: 8 }, - }), - }), - ); - if (res.status === 409) break; - assert.equal(res.status, 200); - } - - const afterVote = await getProposal(env, proposalId); - assert.ok(afterVote); - assert.equal(afterVote.stage, "build"); - - const chambersRes = await chambersGet( - makeContext({ - url: "https://local.test/api/chambers", - env, - method: "GET", - }), - ); - assert.equal(chambersRes.status, 200); - const chambersJson = await chambersRes.json(); - assert.ok(Array.isArray(chambersJson.items)); - assert.ok(chambersJson.items.some((c) => c.id === "science")); -}); diff --git a/tests/scenario-governance-loop.test.js b/tests/scenario-governance-loop.test.js deleted file mode 100644 index 698a8fa..0000000 --- a/tests/scenario-governance-loop.test.js +++ /dev/null @@ -1,196 +0,0 @@ -import assert from "node:assert/strict"; -import { test } from "node:test"; - -import { onRequestPost as commandPost } from "../api/routes/command.ts"; -import { onRequestGet as timelineGet } from "../api/routes/proposals/[id]/timeline.ts"; -import { getSessionCookieName, issueSession } from "../api/_lib/auth.ts"; -import { clearApiRateLimitsForTests } from "../api/_lib/apiRateLimitStore.ts"; -import { clearChamberVotesForTests } from "../api/_lib/chamberVotesStore.ts"; -import { - clearChamberMembershipsForTests, - ensureChamberMembership, -} from "../api/_lib/chamberMembershipsStore.ts"; -import { clearIdempotencyForTests } from "../api/_lib/idempotencyStore.ts"; -import { clearPoolVotesForTests } from "../api/_lib/poolVotesStore.ts"; -import { - clearProposalsForTests, - getProposal, -} from "../api/_lib/proposalsStore.ts"; -import { clearProposalDraftsForTests } from "../api/_lib/proposalDraftsStore.ts"; -import { clearInlineReadModelsForTests } from "../api/_lib/readModelsStore.ts"; - -function makeContext({ url, env, params, method = "POST", headers, body }) { - return { - request: new Request(url, { method, headers, body }), - env, - params, - }; -} - -async function makeSessionCookie(env, address) { - const headers = new Headers(); - await issueSession(headers, env, "https://local.test/api/command", address); - const setCookie = headers.get("set-cookie"); - assert.ok(setCookie); - const tokenPair = setCookie.split(";")[0]; - const [name, value] = tokenPair.split("="); - assert.equal(name, getSessionCookieName()); - return `${name}=${value}`; -} - -async function resetAll() { - await clearPoolVotesForTests(); - await clearChamberVotesForTests(); - clearChamberMembershipsForTests(); - clearIdempotencyForTests(); - clearProposalDraftsForTests(); - clearProposalsForTests(); - clearInlineReadModelsForTests(); - clearApiRateLimitsForTests(); -} - -function baseEnv(overrides = {}) { - return { - SESSION_SECRET: "test-secret", - DEV_BYPASS_GATE: "true", - DEV_INSECURE_COOKIES: "true", - READ_MODELS_INLINE: "true", - DEV_BYPASS_ADMIN: "true", - SIM_COMMAND_RATE_LIMIT_PER_MINUTE_ADDRESS: "1000", - SIM_COMMAND_RATE_LIMIT_PER_MINUTE_IP: "1000", - SIM_CONFIG_JSON: JSON.stringify({ - genesisChambers: [ - { id: "general", title: "General", multiplier: 1.2 }, - { id: "engineering", title: "Engineering", multiplier: 1.4 }, - ], - genesisChamberMembers: {}, - }), - ...overrides, - }; -} - -test("scenario: project proposal goes pool → vote → build with timeline entries", async () => { - await resetAll(); - const env = baseEnv(); - - const proposer = "5Proposer"; - const proposerCookie = await makeSessionCookie(env, proposer); - await ensureChamberMembership(env, { - address: proposer, - chamberId: "engineering", - source: "test", - }); - - const voters = ["5GovA", "5GovB"]; - for (const address of voters) { - await ensureChamberMembership(env, { - address, - chamberId: "engineering", - source: "test", - }); - } - - const saveDraft = await commandPost( - makeContext({ - url: "https://local.test/api/command", - env, - headers: { "content-type": "application/json", cookie: proposerCookie }, - body: JSON.stringify({ - type: "proposal.draft.save", - payload: { - form: { - title: "Engineering tooling proposal", - chamberId: "engineering", - summary: "Short proposal summary.", - what: "Build a validator ops toolkit.", - why: "Improve tooling for node operators.", - how: "Deliver CLI + docs in 4 weeks.", - timeline: [ - { id: "ms-1", title: "Milestone 1", timeframe: "4 weeks" }, - ], - outputs: [ - { id: "out-1", label: "GitHub repo", url: "https://example.com" }, - ], - budgetItems: [{ id: "b-1", description: "Work", amount: "1000" }], - aboutMe: "", - attachments: [], - agreeRules: true, - confirmBudget: true, - }, - }, - }), - }), - ); - assert.equal(saveDraft.status, 200); - const saveJson = await saveDraft.json(); - assert.ok(typeof saveJson.draftId === "string"); - - const submit = await commandPost( - makeContext({ - url: "https://local.test/api/command", - env, - headers: { "content-type": "application/json", cookie: proposerCookie }, - body: JSON.stringify({ - type: "proposal.submitToPool", - payload: { draftId: saveJson.draftId }, - }), - }), - ); - assert.equal(submit.status, 200); - const submitJson = await submit.json(); - const proposalId = submitJson.proposalId; - assert.ok(typeof proposalId === "string" && proposalId.length > 0); - - for (const voter of voters) { - const cookie = await makeSessionCookie(env, voter); - const res = await commandPost( - makeContext({ - url: "https://local.test/api/command", - env, - headers: { "content-type": "application/json", cookie }, - body: JSON.stringify({ - type: "pool.vote", - payload: { proposalId, direction: "up" }, - }), - }), - ); - assert.equal(res.status, 200); - } - - const afterPool = await getProposal(env, proposalId); - assert.ok(afterPool); - assert.equal(afterPool.stage, "vote"); - - for (const voter of voters) { - const cookie = await makeSessionCookie(env, voter); - const res = await commandPost( - makeContext({ - url: "https://local.test/api/command", - env, - headers: { "content-type": "application/json", cookie }, - body: JSON.stringify({ - type: "chamber.vote", - payload: { proposalId, choice: "yes", score: 8 }, - }), - }), - ); - assert.equal(res.status, 200); - } - - const afterVote = await getProposal(env, proposalId); - assert.ok(afterVote); - assert.equal(afterVote.stage, "build"); - - const timelineRes = await timelineGet( - makeContext({ - url: `https://local.test/api/proposals/${proposalId}/timeline`, - env, - method: "GET", - params: { id: proposalId }, - }), - ); - assert.equal(timelineRes.status, 200); - const timelineJson = await timelineRes.json(); - assert.ok(Array.isArray(timelineJson.items)); - assert.ok(timelineJson.items.length >= 3); -});