From f582a867b80f10987150a4b1983e2b07ab44a41b Mon Sep 17 00:00:00 2001
From: Perla Imad
Date: Mon, 10 Aug 2026 10:29:18 +0300
Subject: [PATCH 01/23] feat: implement canon voting system with rate limiting
and proposal management
- Added auth-rate-limit-store for managing rate limits on API requests.
- Introduced canon-voting-policy for defining voting rules and tallying votes.
- Developed canon-voting module for handling proposal voting logic, including casting votes and finalizing decisions.
- Created API routes for managing proposals, including fetching details, voting, and confirming transactions.
- Implemented client-side API functions for interacting with the new proposal and voting endpoints.
---
.env.example | 6 +-
README.md | 9 +-
apps/api/.env.example | 6 +-
apps/api/package.json | 1 +
.../migration.sql | 11 +
.../migration.sql | 1 +
.../migration.sql | 86 ++
apps/api/prisma/schema.prisma | 88 ++
apps/api/src/app.ts | 11 +
apps/api/src/config/env.test.ts | 23 +-
apps/api/src/config/env.ts | 51 +-
apps/api/src/lib/auth-challenges.test.ts | 12 +
apps/api/src/lib/auth-challenges.ts | 8 +
apps/api/src/lib/auth-middleware.test.ts | 51 +-
apps/api/src/lib/auth-middleware.ts | 22 +-
.../api/src/lib/auth-rate-limit-store.test.ts | 124 +++
apps/api/src/lib/auth-rate-limit-store.ts | 161 ++++
apps/api/src/lib/auth-sessions.test.ts | 15 +-
apps/api/src/lib/auth-sessions.ts | 39 +
apps/api/src/lib/canon-voting-policy.ts | 155 ++++
apps/api/src/lib/canon-voting.ts | 793 ++++++++++++++++++
apps/api/src/lib/contributions.ts | 23 +-
apps/api/src/lib/prisma-conflicts.test.ts | 27 +-
apps/api/src/lib/prisma-conflicts.ts | 21 +
apps/api/src/lib/worlds.ts | 227 +++--
apps/api/src/routes/auth.ts | 345 ++++----
apps/api/src/routes/contributions.test.ts | 13 +
apps/api/src/routes/contributions.ts | 15 +-
apps/api/src/routes/proposals.ts | 265 ++++++
apps/api/src/routes/worlds.test.ts | 469 ++++++++++-
apps/api/src/routes/worlds.ts | 229 +++--
apps/web/src/app/login/page.tsx | 4 +-
apps/web/src/app/page.tsx | 9 +-
apps/web/src/lib/api/proposals.ts | 165 ++++
package-lock.json | 65 +-
35 files changed, 3175 insertions(+), 375 deletions(-)
create mode 100644 apps/api/prisma/migrations/20260805170000_add_auth_rate_limit_buckets/migration.sql
create mode 100644 apps/api/prisma/migrations/20260810120000_add_world_bible_audit_action/migration.sql
create mode 100644 apps/api/prisma/migrations/20260810143000_add_canon_voting/migration.sql
create mode 100644 apps/api/src/lib/auth-rate-limit-store.test.ts
create mode 100644 apps/api/src/lib/auth-rate-limit-store.ts
create mode 100644 apps/api/src/lib/canon-voting-policy.ts
create mode 100644 apps/api/src/lib/canon-voting.ts
create mode 100644 apps/api/src/routes/proposals.ts
create mode 100644 apps/web/src/lib/api/proposals.ts
diff --git a/.env.example b/.env.example
index dc73003..660900b 100644
--- a/.env.example
+++ b/.env.example
@@ -17,15 +17,17 @@ NODE_ENV=development
HOST=0.0.0.0
PORT=3001
CORS_ORIGIN=http://localhost:3000
+TRUST_PROXY=false
-AUTH_JWT_SECRET=development-only-auth-secret-change-before-production
+AUTH_JWT_SECRET=replace-with-a-long-random-secret
+AUTH_REFRESH_SECRET=replace-with-a-different-long-random-secret
AUTH_JWT_ISSUER=hivelore
AUTH_JWT_AUDIENCE=hivelore-web
AUTH_ACCESS_TOKEN_TTL_SECONDS=900
AUTH_REFRESH_TOKEN_TTL_SECONDS=1209600
AUTH_CHALLENGE_TTL_SECONDS=300
AUTH_COOKIE_DOMAIN=
-AUTH_COOKIE_SECURE=false
+AUTH_COOKIE_SECURE=true
HIVE_RPC_URL=https://api.hive.blog
HIVE_AUTH_AUDIENCE=hivelore-local-api
diff --git a/README.md b/README.md
index 0789cf3..d556e96 100644
--- a/README.md
+++ b/README.md
@@ -194,7 +194,7 @@ HiveLore supports Hive Keychain and HiveSigner through the same backend challeng
The current verifier supports a single posting public key whose configured key weight satisfies the account's posting threshold. Accounts requiring multiple posting signatures or delegated account authorities are not accepted by the login flow yet; that avoids falsely authenticating a key that cannot independently satisfy posting authority.
-Successful authentication issues a short-lived access JWT and a long-lived opaque refresh token. Refresh tokens are stored only as one-way HMAC hashes in PostgreSQL, rotate on every successful refresh, and revoke the session family if a rotated token is reused. Browser refresh tokens are set in an `httpOnly` cookie and are not returned in JSON responses; access tokens are returned to the client for authenticated API requests. Logout is idempotent and revokes the submitted refresh session or session family when requested. Already-issued access JWTs are stateless and remain usable until their short expiration unless future server-side revocation checks are added.
+Successful authentication issues a short-lived access JWT and a long-lived opaque refresh token. Refresh tokens are stored only as one-way HMAC hashes in PostgreSQL using a dedicated refresh-token secret, rotate on every successful refresh, and revoke the session family if a rotated token is reused. Browser refresh tokens are set in an `httpOnly` cookie and are not returned in JSON responses; access tokens are returned to the client for authenticated API requests. Authenticated API requests verify the access token session id against a non-revoked refresh session so logout invalidates already-issued access tokens. Logout is idempotent and revokes the submitted refresh session or session family when requested.
HiveLore never requests, receives, logs, stores, or returns Hive posting, active, owner, or memo private keys. Login proves identity only. It does not grant the backend permission to publish content, vote, transfer funds, or perform any on-chain action for the user.
@@ -315,6 +315,8 @@ Implemented authentication endpoints:
- `POST /auth/logout`: revokes the current refresh session or session family when requested and clears the refresh cookie. Repeated logout is safe.
- `GET /me`: requires a valid access JWT and returns the safe canonical Hive-linked user representation.
+Auth challenge, verification, and refresh endpoints use Fastify rate limiting backed by PostgreSQL buckets so limits are shared across API instances. Set `TRUST_PROXY=true` on deployments that run behind a trusted proxy or load balancer so client IPs are resolved from trusted forwarding headers.
+
Google auth routes are not registered as a default login path. With `GOOGLE_AUTH_ENABLED=false`, `/auth/google/*` returns a not-enabled response. Enabling Google currently exposes the guarded boundary only; Google OAuth account linking and automatic Hive provisioning remain deferred until secure non-custodial account creation and key delivery are specified.
Implemented world endpoints:
@@ -498,13 +500,14 @@ DIRECT_URL="postgresql://USER:PASSWORD@DIRECT_HOST:5432/postgres"
# Hive authentication and secure session settings.
AUTH_JWT_SECRET="replace-with-a-long-random-secret"
+AUTH_REFRESH_SECRET="replace-with-a-different-long-random-secret"
AUTH_JWT_ISSUER="hivelore"
AUTH_JWT_AUDIENCE="hivelore-web"
AUTH_ACCESS_TOKEN_TTL_SECONDS=900
AUTH_REFRESH_TOKEN_TTL_SECONDS=1209600
AUTH_CHALLENGE_TTL_SECONDS=300
AUTH_COOKIE_DOMAIN=
-AUTH_COOKIE_SECURE=false
+AUTH_COOKIE_SECURE=true
HIVE_AUTH_AUDIENCE="hivelore-local-api"
# Hive integration defaults to public development endpoints.
@@ -527,7 +530,7 @@ HIVE_RC_DELEGATION_AMOUNT=
`DATABASE_URL` is the pooled connection used by normal API queries. `DIRECT_URL` is the direct PostgreSQL connection Prisma uses for migrations and administrative operations. Supabase service-role keys are not required for Prisma's PostgreSQL connection. Hive private keys must never be stored.
-Use separate credentials and databases for development and production. Do not reset a shared development database without team coordination.
+Use separate credentials and databases for development and production. Any internet-facing API environment, including shared development hosts, must set long random `AUTH_JWT_SECRET` and `AUTH_REFRESH_SECRET` values and `AUTH_COOKIE_SECURE=true`. Deployments behind a trusted proxy or load balancer must set `TRUST_PROXY=true`. Do not reset a shared development database without team coordination.
### Supabase Setup
diff --git a/apps/api/.env.example b/apps/api/.env.example
index 61c427b..cf5a90a 100644
--- a/apps/api/.env.example
+++ b/apps/api/.env.example
@@ -10,15 +10,17 @@ NODE_ENV=development
HOST=0.0.0.0
PORT=3001
CORS_ORIGIN=http://localhost:3000
+TRUST_PROXY=false
-AUTH_JWT_SECRET=development-only-auth-secret-change-before-production
+AUTH_JWT_SECRET=replace-with-a-long-random-secret
+AUTH_REFRESH_SECRET=replace-with-a-different-long-random-secret
AUTH_JWT_ISSUER=hivelore
AUTH_JWT_AUDIENCE=hivelore-web
AUTH_ACCESS_TOKEN_TTL_SECONDS=900
AUTH_REFRESH_TOKEN_TTL_SECONDS=1209600
AUTH_CHALLENGE_TTL_SECONDS=300
AUTH_COOKIE_DOMAIN=
-AUTH_COOKIE_SECURE=false
+AUTH_COOKIE_SECURE=true
# Hive integration defaults to public read/write endpoints for development.
# HAF_API_URL points at the public HAF Block Explorer API.
diff --git a/apps/api/package.json b/apps/api/package.json
index 2e6dec9..441e6cc 100644
--- a/apps/api/package.json
+++ b/apps/api/package.json
@@ -27,6 +27,7 @@
},
"dependencies": {
"@fastify/cors": "^11.0.1",
+ "@fastify/rate-limit": "^11.2.0",
"@hiveio/dhive": "^1.3.6",
"@hiveio/wax": "^2.0.2",
"@prisma/adapter-pg": "^7.8.0",
diff --git a/apps/api/prisma/migrations/20260805170000_add_auth_rate_limit_buckets/migration.sql b/apps/api/prisma/migrations/20260805170000_add_auth_rate_limit_buckets/migration.sql
new file mode 100644
index 0000000..a18d796
--- /dev/null
+++ b/apps/api/prisma/migrations/20260805170000_add_auth_rate_limit_buckets/migration.sql
@@ -0,0 +1,11 @@
+CREATE TABLE "AuthRateLimitBucket" (
+ "key" TEXT NOT NULL,
+ "count" INTEGER NOT NULL DEFAULT 0,
+ "expiresAt" TIMESTAMP(3) NOT NULL,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" TIMESTAMP(3) NOT NULL,
+
+ CONSTRAINT "AuthRateLimitBucket_pkey" PRIMARY KEY ("key")
+);
+
+CREATE INDEX "AuthRateLimitBucket_expiresAt_idx" ON "AuthRateLimitBucket"("expiresAt");
diff --git a/apps/api/prisma/migrations/20260810120000_add_world_bible_audit_action/migration.sql b/apps/api/prisma/migrations/20260810120000_add_world_bible_audit_action/migration.sql
new file mode 100644
index 0000000..6456e3f
--- /dev/null
+++ b/apps/api/prisma/migrations/20260810120000_add_world_bible_audit_action/migration.sql
@@ -0,0 +1 @@
+ALTER TYPE "WorldAuditAction" ADD VALUE 'WORLD_BIBLE_UPDATED';
diff --git a/apps/api/prisma/migrations/20260810143000_add_canon_voting/migration.sql b/apps/api/prisma/migrations/20260810143000_add_canon_voting/migration.sql
new file mode 100644
index 0000000..403fc11
--- /dev/null
+++ b/apps/api/prisma/migrations/20260810143000_add_canon_voting/migration.sql
@@ -0,0 +1,86 @@
+-- CreateEnum
+CREATE TYPE "ProposalDecisionOutcome" AS ENUM ('APPROVED_FOR_PUBLICATION', 'REJECTED', 'NEEDS_REVISION', 'ALTERNATE_TIMELINE', 'STALE_BASE_CONFLICT');
+
+-- AlterEnum
+ALTER TYPE "ProposalStatus" ADD VALUE IF NOT EXISTS 'NEEDS_REVISION';
+ALTER TYPE "ProposalStatus" ADD VALUE IF NOT EXISTS 'ALTERNATE_TIMELINE';
+
+-- AlterEnum
+ALTER TYPE "VoteChoice" ADD VALUE IF NOT EXISTS 'NEEDS_REVISION';
+ALTER TYPE "VoteChoice" ADD VALUE IF NOT EXISTS 'ALTERNATE_TIMELINE';
+
+-- AlterEnum
+ALTER TYPE "WorldAuditAction" ADD VALUE IF NOT EXISTS 'CANON_DECISION_FINALIZED';
+
+-- AlterTable
+ALTER TABLE "Proposal"
+ ADD COLUMN "votingEndsAt" TIMESTAMP(3),
+ ADD COLUMN "decidedAt" TIMESTAMP(3),
+ ADD COLUMN "contentHash" TEXT,
+ ADD COLUMN "aiWarningAcknowledgedAt" TIMESTAMP(3),
+ ADD COLUMN "baseCanonVersionId" TEXT,
+ ADD COLUMN "branchParentProposalId" TEXT,
+ ADD COLUMN "branchBaseLoreEntryId" TEXT,
+ ADD COLUMN "branchLabel" TEXT,
+ ADD COLUMN "conflictMetadata" JSONB,
+ ADD COLUMN "staleBaseAtDecision" BOOLEAN NOT NULL DEFAULT false;
+
+-- CreateTable
+CREATE TABLE "ProposalDecision" (
+ "id" TEXT NOT NULL,
+ "proposalId" TEXT NOT NULL,
+ "outcome" "ProposalDecisionOutcome" NOT NULL,
+ "decidedAt" TIMESTAMP(3) NOT NULL,
+ "rulesVersion" TEXT NOT NULL,
+ "payloadSchemaVersion" INTEGER NOT NULL,
+ "minimumVotes" INTEGER NOT NULL,
+ "approvalThresholdBps" INTEGER NOT NULL,
+ "votingWindowHours" INTEGER NOT NULL,
+ "approveCount" INTEGER NOT NULL,
+ "rejectCount" INTEGER NOT NULL,
+ "needsRevisionCount" INTEGER NOT NULL,
+ "alternateTimelineCount" INTEGER NOT NULL,
+ "totalVotes" INTEGER NOT NULL,
+ "approvalNumerator" INTEGER NOT NULL,
+ "approvalDenominator" INTEGER NOT NULL,
+ "approvalPercentageBps" INTEGER NOT NULL,
+ "aiWarningAcknowledged" BOOLEAN NOT NULL DEFAULT false,
+ "aiWarningSummary" TEXT,
+ "baseCanonVersionId" TEXT,
+ "branchParentProposalId" TEXT,
+ "branchBaseLoreEntryId" TEXT,
+ "branchLabel" TEXT,
+ "conflictMetadata" JSONB,
+ "staleBaseAtDecision" BOOLEAN NOT NULL DEFAULT false,
+ "contentHash" TEXT NOT NULL,
+ "decisionPayload" JSONB NOT NULL,
+ "decisionPayloadHash" TEXT NOT NULL,
+ "customJsonId" TEXT,
+ "expectedSigner" TEXT,
+ "hiveEventId" TEXT,
+ "transactionId" TEXT,
+ "blockNumber" BIGINT,
+ "operationIndex" INTEGER,
+ "blockchainTimestamp" TIMESTAMP(3),
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" TIMESTAMP(3) NOT NULL,
+ CONSTRAINT "ProposalDecision_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateIndex
+CREATE UNIQUE INDEX "ProposalDecision_proposalId_key" ON "ProposalDecision"("proposalId");
+CREATE UNIQUE INDEX "ProposalDecision_decisionPayloadHash_key" ON "ProposalDecision"("decisionPayloadHash");
+CREATE UNIQUE INDEX "ProposalDecision_hiveEventId_key" ON "ProposalDecision"("hiveEventId");
+CREATE UNIQUE INDEX "ProposalDecision_transactionId_operationIndex_key" ON "ProposalDecision"("transactionId", "operationIndex");
+CREATE INDEX "ProposalDecision_outcome_idx" ON "ProposalDecision"("outcome");
+CREATE INDEX "ProposalDecision_decidedAt_idx" ON "ProposalDecision"("decidedAt");
+CREATE INDEX "ProposalDecision_customJsonId_idx" ON "ProposalDecision"("customJsonId");
+CREATE INDEX "ProposalDecision_baseCanonVersionId_idx" ON "ProposalDecision"("baseCanonVersionId");
+CREATE INDEX "ProposalDecision_branchBaseLoreEntryId_idx" ON "ProposalDecision"("branchBaseLoreEntryId");
+CREATE INDEX "Proposal_votingEndsAt_idx" ON "Proposal"("votingEndsAt");
+CREATE INDEX "Proposal_baseCanonVersionId_idx" ON "Proposal"("baseCanonVersionId");
+CREATE INDEX "Proposal_branchBaseLoreEntryId_idx" ON "Proposal"("branchBaseLoreEntryId");
+
+-- AddForeignKey
+ALTER TABLE "ProposalDecision" ADD CONSTRAINT "ProposalDecision_proposalId_fkey" FOREIGN KEY ("proposalId") REFERENCES "Proposal"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
+ALTER TABLE "ProposalDecision" ADD CONSTRAINT "ProposalDecision_hiveEventId_fkey" FOREIGN KEY ("hiveEventId") REFERENCES "HiveEvent"("id") ON DELETE SET NULL ON UPDATE CASCADE;
diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma
index 07028b0..c96237e 100644
--- a/apps/api/prisma/schema.prisma
+++ b/apps/api/prisma/schema.prisma
@@ -50,6 +50,8 @@ enum ProposalStatus {
VOTING
APPROVED_FOR_PUBLICATION
REJECTED
+ NEEDS_REVISION
+ ALTERNATE_TIMELINE
PUBLISHED
WITHDRAWN
}
@@ -57,9 +59,19 @@ enum ProposalStatus {
enum VoteChoice {
APPROVE
REJECT
+ NEEDS_REVISION
+ ALTERNATE_TIMELINE
ABSTAIN
}
+enum ProposalDecisionOutcome {
+ APPROVED_FOR_PUBLICATION
+ REJECTED
+ NEEDS_REVISION
+ ALTERNATE_TIMELINE
+ STALE_BASE_CONFLICT
+}
+
enum AIReportStatus {
PENDING
COMPLETED
@@ -145,6 +157,8 @@ enum WorldAuditAction {
CONTRIBUTION_DELETED
CONTRIBUTION_SUBMITTED
PROPOSAL_CREATED
+ CANON_DECISION_FINALIZED
+ WORLD_BIBLE_UPDATED
}
enum AuthProvider {
@@ -249,6 +263,16 @@ model RefreshSession {
@@index([revokedAt])
}
+model AuthRateLimitBucket {
+ key String @id
+ count Int @default(0)
+ expiresAt DateTime
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+
+ @@index([expiresAt])
+}
+
model ExternalIdentityLink {
id String @id @default(cuid())
userId String
@@ -477,13 +501,24 @@ model Proposal {
resultingBibleVersion WorldBibleVersion? @relation("ProposalResultingBibleVersion", fields: [resultingBibleVersionId], references: [id], onDelete: SetNull)
submittedAt DateTime?
votingStartedAt DateTime?
+ votingEndsAt DateTime?
+ decidedAt DateTime?
approvedAt DateTime?
rejectedAt DateTime?
publishedAt DateTime?
+ contentHash String?
+ aiWarningAcknowledgedAt DateTime?
+ baseCanonVersionId String?
+ branchParentProposalId String?
+ branchBaseLoreEntryId String?
+ branchLabel String?
+ conflictMetadata Json?
+ staleBaseAtDecision Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
contributionDraft ContributionDraft?
votes AppVote[]
+ decision ProposalDecision?
aiReports AIReport[]
moderationReports ModerationReport[]
@@ -491,6 +526,9 @@ model Proposal {
@@index([authorId])
@@index([worldId, contributionKind])
@@index([targetLoreEntryId])
+ @@index([votingEndsAt])
+ @@index([baseCanonVersionId])
+ @@index([branchBaseLoreEntryId])
}
model ContributionDraft {
@@ -538,6 +576,55 @@ model AppVote {
@@index([voterId])
}
+model ProposalDecision {
+ id String @id @default(cuid())
+ proposalId String @unique
+ proposal Proposal @relation(fields: [proposalId], references: [id], onDelete: Restrict)
+ outcome ProposalDecisionOutcome
+ decidedAt DateTime
+ rulesVersion String
+ payloadSchemaVersion Int
+ minimumVotes Int
+ approvalThresholdBps Int
+ votingWindowHours Int
+ approveCount Int
+ rejectCount Int
+ needsRevisionCount Int
+ alternateTimelineCount Int
+ totalVotes Int
+ approvalNumerator Int
+ approvalDenominator Int
+ approvalPercentageBps Int
+ aiWarningAcknowledged Boolean @default(false)
+ aiWarningSummary String?
+ baseCanonVersionId String?
+ branchParentProposalId String?
+ branchBaseLoreEntryId String?
+ branchLabel String?
+ conflictMetadata Json?
+ staleBaseAtDecision Boolean @default(false)
+ contentHash String
+ decisionPayload Json
+ decisionPayloadHash String @unique
+ customJsonId String?
+ expectedSigner String?
+ hiveEventId String? @unique
+ hiveEvent HiveEvent? @relation(fields: [hiveEventId], references: [id], onDelete: SetNull)
+ transactionId String?
+ blockNumber BigInt?
+ operationIndex Int?
+ blockchainTimestamp DateTime?
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+
+ @@unique([transactionId, operationIndex])
+ @@index([outcome])
+ @@index([decidedAt])
+ @@index([customJsonId])
+ @@index([baseCanonVersionId])
+ @@index([branchBaseLoreEntryId])
+}
+
model AIReport {
id String @id @default(cuid())
proposalId String
@@ -624,6 +711,7 @@ model HiveEvent {
processedAt DateTime @default(now())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
+ proposalDecision ProposalDecision?
/// Rebuildable indexed Hive operation; the blockchain remains authoritative.
@@unique([transactionId, operationIndex])
diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts
index 944811f..2df0cc3 100644
--- a/apps/api/src/app.ts
+++ b/apps/api/src/app.ts
@@ -1,10 +1,14 @@
import cors from '@fastify/cors';
+import rateLimit from '@fastify/rate-limit';
import Fastify from 'fastify';
import { env } from './config/env.js';
+import { createPrismaRateLimitStore } from './lib/auth-rate-limit-store.js';
+import { prisma } from './lib/prisma.js';
import { registerAuthRoutes } from './routes/auth.js';
import { registerContributionRoutes } from './routes/contributions.js';
import { registerHealthRoute } from './routes/health.js';
+import { registerProposalRoutes } from './routes/proposals.js';
import { registerWorldRoutes } from './routes/worlds.js';
export async function buildApp() {
@@ -25,17 +29,24 @@ export async function buildApp() {
'body.refresh_token',
],
},
+ trustProxy: env.TRUST_PROXY,
});
await app.register(cors, {
credentials: true,
origin: env.NODE_ENV === 'production' ? env.CORS_ORIGIN : [env.CORS_ORIGIN],
});
+ await app.register(rateLimit, {
+ global: false,
+ skipOnError: false,
+ store: createPrismaRateLimitStore(prisma),
+ });
await registerHealthRoute(app);
await registerAuthRoutes(app);
await registerWorldRoutes(app);
await registerContributionRoutes(app);
+ await registerProposalRoutes(app);
return app;
}
diff --git a/apps/api/src/config/env.test.ts b/apps/api/src/config/env.test.ts
index 2fc30d5..601e6cc 100644
--- a/apps/api/src/config/env.test.ts
+++ b/apps/api/src/config/env.test.ts
@@ -1,12 +1,16 @@
import assert from 'node:assert/strict';
import { describe, test } from 'node:test';
-import { parseEnv } from './env.js';
+process.env.NODE_ENV = 'test';
+
+const { parseEnv } = await import('./env.js');
describe('API environment validation', () => {
test('parses false feature flags as false', () => {
const env = parseEnv({
AUTH_COOKIE_SECURE: 'false',
+ AUTH_JWT_SECRET: 'test-jwt-secret-that-is-long-enough',
+ AUTH_REFRESH_SECRET: 'test-refresh-secret-that-is-long-enough',
GOOGLE_AUTH_ENABLED: 'false',
GOOGLE_HIVE_PROVISIONING_ENABLED: 'false',
HIVE_RC_DELEGATION_ENABLED: 'false',
@@ -25,12 +29,16 @@ describe('API environment validation', () => {
parseEnv({
GOOGLE_AUTH_ENABLED: 'true',
NODE_ENV: 'development',
+ AUTH_JWT_SECRET: 'test-jwt-secret-that-is-long-enough',
+ AUTH_REFRESH_SECRET: 'test-refresh-secret-that-is-long-enough',
}),
/Google auth is enabled/,
);
assert.equal(
parseEnv({
+ AUTH_JWT_SECRET: 'test-jwt-secret-that-is-long-enough',
+ AUTH_REFRESH_SECRET: 'test-refresh-secret-that-is-long-enough',
GOOGLE_AUTH_ENABLED: 'false',
NODE_ENV: 'development',
}).GOOGLE_AUTH_ENABLED,
@@ -38,13 +46,22 @@ describe('API environment validation', () => {
);
});
- test('rejects the development JWT secret in production', () => {
+ test('rejects missing auth secrets in every non-test environment', () => {
assert.throws(
() =>
parseEnv({
- NODE_ENV: 'production',
+ NODE_ENV: 'development',
}),
/AUTH_JWT_SECRET/,
);
});
+
+ test('allows missing auth secrets only in test', () => {
+ const env = parseEnv({
+ NODE_ENV: 'test',
+ });
+
+ assert.match(env.AUTH_JWT_SECRET, /test-only/);
+ assert.match(env.AUTH_REFRESH_SECRET, /test-only/);
+ });
});
diff --git a/apps/api/src/config/env.ts b/apps/api/src/config/env.ts
index 9e24344..d5390b2 100644
--- a/apps/api/src/config/env.ts
+++ b/apps/api/src/config/env.ts
@@ -18,10 +18,9 @@ const envSchema = z.object({
HOST: z.string().min(1).default('0.0.0.0'),
PORT: z.coerce.number().int().positive().max(65535).default(3001),
CORS_ORIGIN: z.string().url().default('http://localhost:3000'),
- AUTH_JWT_SECRET: z
- .string()
- .min(32)
- .default('development-only-auth-secret-change-before-production'),
+ TRUST_PROXY: booleanEnv(false),
+ AUTH_JWT_SECRET: z.string().min(32).optional(),
+ AUTH_REFRESH_SECRET: z.string().min(32).optional(),
AUTH_JWT_ISSUER: z.string().min(1).default('hivelore'),
AUTH_JWT_AUDIENCE: z.string().min(1).default('hivelore-web'),
AUTH_ACCESS_TOKEN_TTL_SECONDS: z.coerce.number().int().positive().default(900),
@@ -62,13 +61,36 @@ export function parseEnv(environment: NodeJS.ProcessEnv) {
throw new Error(`Invalid API environment configuration: ${errors}`);
}
- const parsedEnv = parsed.data;
+ const parsedEnv =
+ parsed.data.NODE_ENV === 'test'
+ ? {
+ ...parsed.data,
+ AUTH_JWT_SECRET:
+ parsed.data.AUTH_JWT_SECRET ?? 'test-only-jwt-secret-with-enough-entropy',
+ AUTH_REFRESH_SECRET:
+ parsed.data.AUTH_REFRESH_SECRET ?? 'test-only-refresh-secret-with-enough-entropy',
+ }
+ : parsed.data;
- if (
- parsedEnv.NODE_ENV === 'production' &&
- parsedEnv.AUTH_JWT_SECRET.includes('development-only')
- ) {
- throw new Error('AUTH_JWT_SECRET must be set to a production secret.');
+ if (parsedEnv.NODE_ENV !== 'test') {
+ if (!parsedEnv.AUTH_JWT_SECRET) {
+ throw new Error('AUTH_JWT_SECRET must be set.');
+ }
+
+ if (!parsedEnv.AUTH_REFRESH_SECRET) {
+ throw new Error('AUTH_REFRESH_SECRET must be set.');
+ }
+
+ if (
+ parsedEnv.AUTH_JWT_SECRET.includes('development-only') ||
+ parsedEnv.AUTH_REFRESH_SECRET.includes('development-only')
+ ) {
+ throw new Error('Authentication secrets must not use development-only defaults.');
+ }
+ }
+
+ if (parsedEnv.NODE_ENV === 'production' && parsedEnv.AUTH_COOKIE_SECURE === false) {
+ throw new Error('AUTH_COOKIE_SECURE must be true in production.');
}
if (
@@ -87,7 +109,14 @@ export function parseEnv(environment: NodeJS.ProcessEnv) {
throw new Error('Hive RC delegation is enabled but delegation configuration is missing.');
}
- return parsedEnv;
+ if (!parsedEnv.AUTH_JWT_SECRET || !parsedEnv.AUTH_REFRESH_SECRET) {
+ throw new Error('Authentication secrets must be set.');
+ }
+
+ return parsedEnv as typeof parsedEnv & {
+ AUTH_JWT_SECRET: string;
+ AUTH_REFRESH_SECRET: string;
+ };
}
export const env = parseEnv(process.env);
diff --git a/apps/api/src/lib/auth-challenges.test.ts b/apps/api/src/lib/auth-challenges.test.ts
index dee86ad..20f3df8 100644
--- a/apps/api/src/lib/auth-challenges.test.ts
+++ b/apps/api/src/lib/auth-challenges.test.ts
@@ -43,6 +43,18 @@ function createChallengeDatabase() {
id: record.id,
};
},
+ async deleteMany(args: { where: { expiresAt: { lt: Date } } }) {
+ let count = 0;
+
+ for (const [id, record] of records.entries()) {
+ if (record.expiresAt < args.where.expiresAt.lt) {
+ records.delete(id);
+ count += 1;
+ }
+ }
+
+ return { count };
+ },
async findUnique(args: {
where: { id: string };
select: {
diff --git a/apps/api/src/lib/auth-challenges.ts b/apps/api/src/lib/auth-challenges.ts
index c15588e..55ce514 100644
--- a/apps/api/src/lib/auth-challenges.ts
+++ b/apps/api/src/lib/auth-challenges.ts
@@ -64,6 +64,14 @@ export async function createAuthChallenge(
nonce: input.nonce,
username: normalizedHiveUsername,
});
+ await database.authChallenge.deleteMany({
+ where: {
+ expiresAt: {
+ lt: issuedAt,
+ },
+ },
+ });
+
const challenge = await database.authChallenge.create({
data: {
challengeHash: sha256Hmac(message, input.hmacSecret),
diff --git a/apps/api/src/lib/auth-middleware.test.ts b/apps/api/src/lib/auth-middleware.test.ts
index c6a376a..3fd3ae8 100644
--- a/apps/api/src/lib/auth-middleware.test.ts
+++ b/apps/api/src/lib/auth-middleware.test.ts
@@ -9,12 +9,28 @@ import { authenticateRequest } from './auth-middleware.js';
const jwtOptions = {
audience: 'hivelore-web',
+ database: {
+ refreshSession: {
+ async findUnique(args: { where: { id: string }; select: unknown }) {
+ if (args.where.id !== 'session-1') {
+ return null;
+ }
+
+ return {
+ expiresAt: new Date('2026-08-01T01:00:00.000Z'),
+ revokedAt: null,
+ userId: 'user-1',
+ };
+ },
+ },
+ },
issuer: 'hivelore',
jwtSecret: 'test-secret-that-is-long-enough-for-hmac',
+ now: new Date('2026-08-01T00:00:00.000Z'),
};
describe('auth middleware', () => {
- test('decorates request.user from trusted JWT claims including platform role', () => {
+ test('decorates request.user from trusted JWT claims including platform role', async () => {
const token = signAccessToken(
{
hiveUsername: 'alice',
@@ -36,21 +52,48 @@ describe('auth middleware', () => {
},
} as FastifyRequest;
- const user = authenticateRequest(request, jwtOptions);
+ const user = await authenticateRequest(request, jwtOptions);
assert.equal(user?.id, 'user-1');
assert.equal(user?.platformRole, PlatformRole.ADMIN);
assert.equal(request.user?.platformRole, PlatformRole.ADMIN);
});
- test('rejects missing or malformed bearer tokens', () => {
+ test('rejects missing or malformed bearer tokens', async () => {
const request = {
headers: {
authorization: 'Basic abc',
},
} as FastifyRequest;
- assert.equal(authenticateRequest(request, jwtOptions), null);
+ assert.equal(await authenticateRequest(request, jwtOptions), null);
+ assert.equal(request.user, undefined);
+ });
+
+ test('rejects otherwise valid access tokens after session revocation', async () => {
+ const token = signAccessToken(
+ {
+ hiveUsername: 'alice',
+ normalizedHiveUsername: 'alice',
+ platformRole: PlatformRole.USER,
+ sid: 'revoked-session',
+ sub: 'user-1',
+ },
+ {
+ audience: jwtOptions.audience,
+ issuer: jwtOptions.issuer,
+ now: jwtOptions.now,
+ secret: jwtOptions.jwtSecret,
+ ttlSeconds: 900,
+ },
+ );
+ const request = {
+ headers: {
+ authorization: `Bearer ${token}`,
+ },
+ } as FastifyRequest;
+
+ assert.equal(await authenticateRequest(request, jwtOptions), null);
assert.equal(request.user, undefined);
});
});
diff --git a/apps/api/src/lib/auth-middleware.ts b/apps/api/src/lib/auth-middleware.ts
index 8f8a734..4c88181 100644
--- a/apps/api/src/lib/auth-middleware.ts
+++ b/apps/api/src/lib/auth-middleware.ts
@@ -1,6 +1,7 @@
import type { FastifyReply, FastifyRequest, preHandlerHookHandler } from 'fastify';
import { verifyAccessToken } from './auth-crypto.js';
+import { isRefreshSessionActive, type SessionVerificationDatabase } from './auth-sessions.js';
import type { AuthenticatedUser } from './world-authorization.js';
export function readBearerToken(request: FastifyRequest) {
@@ -19,12 +20,14 @@ export function readBearerToken(request: FastifyRequest) {
return token;
}
-export function authenticateRequest(
+export async function authenticateRequest(
request: FastifyRequest,
options: {
audience: string;
+ database: SessionVerificationDatabase;
issuer: string;
jwtSecret: string;
+ now?: Date;
},
) {
const token = readBearerToken(request);
@@ -38,7 +41,19 @@ export function authenticateRequest(
audience: options.audience,
issuer: options.issuer,
secret: options.jwtSecret,
+ ...(options.now ? { now: options.now } : {}),
});
+
+ const hasActiveSession = await isRefreshSessionActive(options.database, {
+ sessionId: claims.sid,
+ userId: claims.sub,
+ ...(options.now ? { now: options.now } : {}),
+ });
+
+ if (!hasActiveSession) {
+ return null;
+ }
+
const user: AuthenticatedUser = {
hiveUsername: claims.hiveUsername,
id: claims.sub,
@@ -56,14 +71,15 @@ export function authenticateRequest(
export function requireSession(options: {
audience: string;
+ database: SessionVerificationDatabase;
issuer: string;
jwtSecret: string;
}): preHandlerHookHandler {
return async (request: FastifyRequest, reply: FastifyReply) => {
- const user = authenticateRequest(request, options);
+ const user = await authenticateRequest(request, options);
if (!user) {
- await reply.code(401).send({
+ return reply.code(401).send({
error: 'Authentication required.',
});
}
diff --git a/apps/api/src/lib/auth-rate-limit-store.test.ts b/apps/api/src/lib/auth-rate-limit-store.test.ts
new file mode 100644
index 0000000..1fd9295
--- /dev/null
+++ b/apps/api/src/lib/auth-rate-limit-store.test.ts
@@ -0,0 +1,124 @@
+import assert from 'node:assert/strict';
+import { describe, test } from 'node:test';
+
+import { createPrismaRateLimitStore } from './auth-rate-limit-store.js';
+
+function createDatabase() {
+ const buckets = new Map();
+
+ return {
+ authRateLimitBucket: {
+ async deleteMany(args: { where: { expiresAt: { lt: Date } } }) {
+ let count = 0;
+
+ for (const [key, bucket] of buckets) {
+ if (bucket.expiresAt < args.where.expiresAt.lt) {
+ buckets.delete(key);
+ count += 1;
+ }
+ }
+
+ return { count };
+ },
+ async findUnique(args: { select: { count: true; expiresAt: true }; where: { key: string } }) {
+ return buckets.get(args.where.key) ?? null;
+ },
+ },
+ async $queryRaw(_strings: TemplateStringsArray, ...values: unknown[]) {
+ const key = values[0] as string;
+ const nextExpiresAt = values[1] as Date;
+ const now = values[2] as Date;
+ const current = buckets.get(key);
+ const next =
+ !current || current.expiresAt <= now
+ ? { count: 1, expiresAt: nextExpiresAt }
+ : { count: current.count + 1, expiresAt: current.expiresAt };
+
+ buckets.set(key, next);
+
+ return [next];
+ },
+ buckets,
+ };
+}
+
+function increment(store: {
+ incr(
+ key: string,
+ callback: (error: Error | null, result?: unknown) => void,
+ timeWindow: number,
+ ): void;
+}) {
+ return new Promise<{ current: number; ttl: number }>((resolve, reject) => {
+ store.incr(
+ '127.0.0.1',
+ (error, result) => {
+ if (error) {
+ reject(error);
+ return;
+ }
+
+ resolve(result as { current: number; ttl: number });
+ },
+ 60_000,
+ );
+ });
+}
+
+function read(store: {
+ read(
+ key: string,
+ callback: (error: Error | null, result?: unknown) => void,
+ timeWindow: number,
+ ): void;
+}) {
+ return new Promise<{ current: number; ttl: number }>((resolve, reject) => {
+ store.read(
+ '127.0.0.1',
+ (error, result) => {
+ if (error) {
+ reject(error);
+ return;
+ }
+
+ resolve(result as { current: number; ttl: number });
+ },
+ 60_000,
+ );
+ });
+}
+
+describe('Prisma auth rate-limit store', () => {
+ test('increments and reads counters from a shared database bucket', async () => {
+ const database = createDatabase();
+ const Store = createPrismaRateLimitStore(database as never);
+ const store = new Store();
+
+ assert.equal((await increment(store)).current, 1);
+ assert.equal((await increment(store)).current, 2);
+ assert.equal((await read(store)).current, 2);
+ assert.equal(database.buckets.size, 1);
+ });
+
+ test('scopes counters by route child store', async () => {
+ const database = createDatabase();
+ const Store = createPrismaRateLimitStore(database as never);
+ const store = new Store();
+ const challengeStore = store.child({
+ method: 'POST',
+ path: '/auth/challenge',
+ prefix: '',
+ url: '/auth/challenge',
+ } as never);
+ const verifyStore = store.child({
+ method: 'POST',
+ path: '/auth/verify',
+ prefix: '',
+ url: '/auth/verify',
+ } as never);
+
+ assert.equal((await increment(challengeStore)).current, 1);
+ assert.equal((await increment(verifyStore)).current, 1);
+ assert.equal(database.buckets.size, 2);
+ });
+});
diff --git a/apps/api/src/lib/auth-rate-limit-store.ts b/apps/api/src/lib/auth-rate-limit-store.ts
new file mode 100644
index 0000000..f64e099
--- /dev/null
+++ b/apps/api/src/lib/auth-rate-limit-store.ts
@@ -0,0 +1,161 @@
+import type { PrismaClient } from '../generated/prisma/client.js';
+
+type RateLimitBucketDatabase = Pick<
+ PrismaClient,
+ '$executeRaw' | '$queryRaw' | 'authRateLimitBucket'
+>;
+
+type RateLimitResult = {
+ current: number;
+ ttl: number;
+};
+
+type RateLimitCallback = (error: Error | null, result?: RateLimitResult) => void;
+
+type RateLimitStoreOptions = {
+ nameSpace?: string | undefined;
+ routeInfo?: RateLimitRouteInfo | undefined;
+};
+
+type RateLimitRouteInfo = {
+ method?: unknown;
+ path?: string | undefined;
+ url?: string | undefined;
+};
+
+type RateLimitRow = {
+ count: number;
+ expiresAt: Date;
+};
+
+type RateLimitStore = {
+ child(routeOptions: RateLimitRouteInfo): RateLimitStore;
+ incr(key: string, callback: RateLimitCallback, timeWindow: number): void;
+ read(key: string, callback: RateLimitCallback, timeWindow: number): void;
+};
+
+type RateLimitStoreConstructor = new (options?: RateLimitStoreOptions) => RateLimitStore;
+
+const DEFAULT_NAMESPACE = 'hivelore-auth-rate-limit';
+const CLEANUP_INTERVAL_INCREMENTS = 100;
+
+function routeScope(routeInfo: RateLimitStoreOptions['routeInfo']) {
+ if (!routeInfo) {
+ return 'global';
+ }
+
+ return `${String(routeInfo.method ?? 'ANY')}:${routeInfo.path ?? routeInfo.url ?? 'unknown'}`;
+}
+
+function buildBucketKey(options: RateLimitStoreOptions, key: string) {
+ const namespace = options.nameSpace ?? DEFAULT_NAMESPACE;
+
+ return `${namespace}:${routeScope(options.routeInfo)}:${key}`;
+}
+
+export function createPrismaRateLimitStore(
+ database: RateLimitBucketDatabase,
+): RateLimitStoreConstructor {
+ return class PrismaRateLimitStore {
+ cleanupCounter = 0;
+
+ constructor(readonly options: RateLimitStoreOptions = {}) {}
+
+ incr(key: string, callback: RateLimitCallback, timeWindow: number) {
+ void this.increment(key, timeWindow).then(
+ (result) => callback(null, result),
+ (error: unknown) => callback(error instanceof Error ? error : new Error(String(error))),
+ );
+ }
+
+ read(key: string, callback: RateLimitCallback, timeWindow: number) {
+ void this.get(key, timeWindow).then(
+ (result) => callback(null, result),
+ (error: unknown) => callback(error instanceof Error ? error : new Error(String(error))),
+ );
+ }
+
+ child(routeOptions: RateLimitRouteInfo) {
+ return new PrismaRateLimitStore({
+ ...this.options,
+ routeInfo: routeOptions,
+ });
+ }
+
+ async increment(key: string, timeWindow: number) {
+ const now = new Date();
+ const nextExpiresAt = new Date(now.getTime() + timeWindow);
+ const bucketKey = buildBucketKey(this.options, key);
+
+ await this.maybeDeleteExpiredBuckets(now);
+
+ const rows = await database.$queryRaw`
+ INSERT INTO "AuthRateLimitBucket" ("key", "count", "expiresAt", "updatedAt")
+ VALUES (${bucketKey}, 1, ${nextExpiresAt}, ${now})
+ ON CONFLICT ("key") DO UPDATE SET
+ "count" = CASE
+ WHEN "AuthRateLimitBucket"."expiresAt" <= ${now} THEN 1
+ ELSE "AuthRateLimitBucket"."count" + 1
+ END,
+ "expiresAt" = CASE
+ WHEN "AuthRateLimitBucket"."expiresAt" <= ${now} THEN ${nextExpiresAt}
+ ELSE "AuthRateLimitBucket"."expiresAt"
+ END,
+ "updatedAt" = ${now}
+ RETURNING "count", "expiresAt"
+ `;
+ const row = rows[0];
+
+ if (!row) {
+ throw new Error('Rate limit bucket update did not return a row.');
+ }
+
+ return toRateLimitResult(row, now);
+ }
+
+ async get(key: string, timeWindow: number) {
+ const now = new Date();
+ const bucket = await database.authRateLimitBucket.findUnique({
+ where: {
+ key: buildBucketKey(this.options, key),
+ },
+ select: {
+ count: true,
+ expiresAt: true,
+ },
+ });
+
+ if (!bucket || bucket.expiresAt <= now) {
+ return {
+ current: 0,
+ ttl: timeWindow,
+ };
+ }
+
+ return toRateLimitResult(bucket, now);
+ }
+
+ async maybeDeleteExpiredBuckets(now: Date) {
+ this.cleanupCounter += 1;
+
+ if (this.cleanupCounter % CLEANUP_INTERVAL_INCREMENTS !== 0) {
+ return;
+ }
+
+ await database.authRateLimitBucket.deleteMany({
+ where: {
+ expiresAt: {
+ lt: now,
+ },
+ },
+ });
+ }
+ };
+}
+
+function toRateLimitResult(row: Pick, now: Date) {
+ return {
+ current: row.count,
+ ttl: Math.max(0, row.expiresAt.getTime() - now.getTime()),
+ };
+}
diff --git a/apps/api/src/lib/auth-sessions.test.ts b/apps/api/src/lib/auth-sessions.test.ts
index b7d204e..86443d4 100644
--- a/apps/api/src/lib/auth-sessions.test.ts
+++ b/apps/api/src/lib/auth-sessions.test.ts
@@ -86,7 +86,13 @@ function createDatabase() {
async findUnique(args: {
where: { refreshTokenHash?: string; id?: string };
include?: { user: true };
- select?: { id: true; sessionFamilyId: true };
+ select?: {
+ expiresAt?: true;
+ id?: true;
+ revokedAt?: true;
+ sessionFamilyId?: true;
+ userId?: true;
+ };
}) {
const session = [...sessions.values()].find((candidate) => {
if (args.where.refreshTokenHash) {
@@ -102,8 +108,11 @@ function createDatabase() {
if (args.select) {
return {
- id: session.id,
- sessionFamilyId: session.sessionFamilyId,
+ ...(args.select.expiresAt ? { expiresAt: session.expiresAt } : {}),
+ ...(args.select.id ? { id: session.id } : {}),
+ ...(args.select.revokedAt ? { revokedAt: session.revokedAt } : {}),
+ ...(args.select.sessionFamilyId ? { sessionFamilyId: session.sessionFamilyId } : {}),
+ ...(args.select.userId ? { userId: session.userId } : {}),
};
}
diff --git a/apps/api/src/lib/auth-sessions.ts b/apps/api/src/lib/auth-sessions.ts
index b67a3d2..d8f8cf9 100644
--- a/apps/api/src/lib/auth-sessions.ts
+++ b/apps/api/src/lib/auth-sessions.ts
@@ -72,6 +72,20 @@ type UserProjectionDatabase = Pick;
type SessionIssueDatabase = Pick;
type SessionRotationDatabase = Pick;
type SessionRevocationDatabase = Pick;
+export type SessionVerificationDatabase = {
+ refreshSession: {
+ findUnique(args: {
+ where: {
+ id: string;
+ };
+ select: {
+ expiresAt: true;
+ revokedAt: true;
+ userId: true;
+ };
+ }): Promise | null>;
+ };
+};
export async function upsertHiveUser(
database: UserProjectionDatabase,
@@ -302,3 +316,28 @@ async function revokeSessionFamily(
export function isRefreshSessionValid(session: Pick) {
return !session.revokedAt && session.expiresAt > new Date();
}
+
+export async function isRefreshSessionActive(
+ database: SessionVerificationDatabase,
+ input: {
+ sessionId: string;
+ userId: string;
+ now?: Date;
+ },
+) {
+ const now = input.now ?? new Date();
+ const session = await database.refreshSession.findUnique({
+ where: {
+ id: input.sessionId,
+ },
+ select: {
+ expiresAt: true,
+ revokedAt: true,
+ userId: true,
+ },
+ });
+
+ return Boolean(
+ session && session.userId === input.userId && !session.revokedAt && session.expiresAt > now,
+ );
+}
diff --git a/apps/api/src/lib/canon-voting-policy.ts b/apps/api/src/lib/canon-voting-policy.ts
new file mode 100644
index 0000000..45d9626
--- /dev/null
+++ b/apps/api/src/lib/canon-voting-policy.ts
@@ -0,0 +1,155 @@
+import { createHash } from 'node:crypto';
+
+import { ProposalDecisionOutcome, VoteChoice } from '../generated/prisma/enums.js';
+
+export const CANON_VOTING_RULES = {
+ approvalThresholdBps: 7_000,
+ minimumVotes: 5,
+ payloadSchemaVersion: 1,
+ rulesVersion: 'canon-voting-mvp-2026-08-10',
+ votingWindowHours: 48,
+} as const;
+
+export type CanonVoteCounts = {
+ approve: number;
+ reject: number;
+ needsRevision: number;
+ alternateTimeline: number;
+};
+
+export type CanonTally = CanonVoteCounts & {
+ approvalDenominator: number;
+ approvalNumerator: number;
+ approvalPercentageBps: number;
+ totalVotes: number;
+};
+
+export type CanonVotingWindowState = 'BEFORE_END' | 'AT_OR_AFTER_END';
+
+export function addVotingWindow(startedAt: Date) {
+ return new Date(startedAt.getTime() + CANON_VOTING_RULES.votingWindowHours * 60 * 60 * 1_000);
+}
+
+export function getVotingWindowState(input: { now: Date; votingEndsAt: Date }) {
+ return input.now.getTime() < input.votingEndsAt.getTime() ? 'BEFORE_END' : 'AT_OR_AFTER_END';
+}
+
+export function tallyCanonVotes(votes: Array<{ choice: VoteChoice }>): CanonTally {
+ const counts: CanonVoteCounts = {
+ alternateTimeline: 0,
+ approve: 0,
+ needsRevision: 0,
+ reject: 0,
+ };
+
+ for (const vote of votes) {
+ if (vote.choice === VoteChoice.APPROVE) {
+ counts.approve += 1;
+ } else if (vote.choice === VoteChoice.REJECT) {
+ counts.reject += 1;
+ } else if (vote.choice === VoteChoice.NEEDS_REVISION) {
+ counts.needsRevision += 1;
+ } else if (vote.choice === VoteChoice.ALTERNATE_TIMELINE) {
+ counts.alternateTimeline += 1;
+ }
+ }
+
+ const approvalDenominator = counts.approve + counts.reject;
+ const approvalPercentageBps =
+ approvalDenominator > 0 ? Math.floor((counts.approve * 10_000) / approvalDenominator) : 0;
+
+ return {
+ ...counts,
+ approvalDenominator,
+ approvalNumerator: counts.approve,
+ approvalPercentageBps,
+ totalVotes: counts.approve + counts.reject + counts.needsRevision + counts.alternateTimeline,
+ };
+}
+
+export function decideCanonOutcome(input: {
+ staleBaseAtDecision?: boolean;
+ tally: CanonTally;
+ votingEndsAt: Date;
+ now: Date;
+}) {
+ if (getVotingWindowState(input) === 'BEFORE_END') {
+ return null;
+ }
+
+ if (input.staleBaseAtDecision) {
+ return ProposalDecisionOutcome.STALE_BASE_CONFLICT;
+ }
+
+ const passesApproval =
+ input.tally.totalVotes >= CANON_VOTING_RULES.minimumVotes &&
+ input.tally.approvalPercentageBps >= CANON_VOTING_RULES.approvalThresholdBps;
+
+ if (passesApproval) {
+ return ProposalDecisionOutcome.APPROVED_FOR_PUBLICATION;
+ }
+
+ const feedbackChoices = [
+ {
+ choice: ProposalDecisionOutcome.NEEDS_REVISION,
+ count: input.tally.needsRevision,
+ rank: 1,
+ },
+ {
+ choice: ProposalDecisionOutcome.ALTERNATE_TIMELINE,
+ count: input.tally.alternateTimeline,
+ rank: 2,
+ },
+ {
+ choice: ProposalDecisionOutcome.REJECTED,
+ count: input.tally.reject,
+ rank: 3,
+ },
+ ].sort((left, right) => right.count - left.count || left.rank - right.rank);
+
+ if (feedbackChoices[0] && feedbackChoices[0].count > 0) {
+ return feedbackChoices[0].choice;
+ }
+
+ return ProposalDecisionOutcome.REJECTED;
+}
+
+export function proposalStatusForDecision(outcome: ProposalDecisionOutcome) {
+ if (outcome === ProposalDecisionOutcome.APPROVED_FOR_PUBLICATION) {
+ return 'APPROVED_FOR_PUBLICATION' as const;
+ }
+
+ if (outcome === ProposalDecisionOutcome.ALTERNATE_TIMELINE) {
+ return 'ALTERNATE_TIMELINE' as const;
+ }
+
+ if (
+ outcome === ProposalDecisionOutcome.NEEDS_REVISION ||
+ outcome === ProposalDecisionOutcome.STALE_BASE_CONFLICT
+ ) {
+ return 'NEEDS_REVISION' as const;
+ }
+
+ return 'REJECTED' as const;
+}
+
+export function stableStringify(value: unknown): string {
+ if (Array.isArray(value)) {
+ return `[${value.map(stableStringify).join(',')}]`;
+ }
+
+ if (value && typeof value === 'object') {
+ const record = value as Record;
+
+ return `{${Object.keys(record)
+ .sort()
+ .map((key) => `${JSON.stringify(key)}:${stableStringify(record[key])}`)
+ .join(',')}}`;
+ }
+
+ return JSON.stringify(value);
+}
+
+export function hashCanonicalJson(value: unknown) {
+ return createHash('sha256').update(stableStringify(value), 'utf8').digest('hex');
+}
diff --git a/apps/api/src/lib/canon-voting.ts b/apps/api/src/lib/canon-voting.ts
new file mode 100644
index 0000000..97428c0
--- /dev/null
+++ b/apps/api/src/lib/canon-voting.ts
@@ -0,0 +1,793 @@
+import type { Prisma, PrismaClient } from '../generated/prisma/client.js';
+import {
+ HiveEventType,
+ ProposalDecisionOutcome,
+ ProposalStatus,
+ VoteChoice,
+ WorldAuditAction,
+} from '../generated/prisma/enums.js';
+import { HIVELORE_CUSTOM_JSON_ID } from './hive/constants.js';
+import {
+ buildHiveLoreCustomJsonOperation,
+ parseHiveLoreCustomJsonPayload,
+} from './hive/operations.js';
+import { normalizeHafOperation } from './hive/projection.js';
+import { verifyHiveLoreOperation } from './hive/verification.js';
+import type { HafClient } from './hive/haf-client.js';
+import type { NormalizedHiveOperation } from './hive/types.js';
+import {
+ addVotingWindow,
+ CANON_VOTING_RULES,
+ decideCanonOutcome,
+ hashCanonicalJson,
+ proposalStatusForDecision,
+ tallyCanonVotes,
+} from './canon-voting-policy.js';
+
+export class CanonVotingError extends Error {
+ constructor(
+ public readonly statusCode: number,
+ public readonly code: string,
+ message: string,
+ ) {
+ super(message);
+ }
+}
+
+export type CanonVotingDatabase = PrismaClient;
+
+const voteChoices = new Set([
+ VoteChoice.APPROVE,
+ VoteChoice.REJECT,
+ VoteChoice.NEEDS_REVISION,
+ VoteChoice.ALTERNATE_TIMELINE,
+]);
+
+type ProposalWithDetails = Prisma.ProposalGetPayload<{
+ include: {
+ aiReports: true;
+ author: { select: { hiveUsername: true; id: true } };
+ decision: true;
+ votes: { select: { choice: true; updatedAt: true; voterId: true } };
+ world: { select: { id: true; title: true } };
+ };
+}>;
+
+function iso(value: Date | null | undefined) {
+ return value ? value.toISOString() : null;
+}
+
+function assertVoteChoice(choice: string): VoteChoice {
+ if (!voteChoices.has(choice)) {
+ throw new CanonVotingError(400, 'INVALID_VOTE_CHOICE', 'Unsupported vote choice.');
+ }
+
+ return choice as VoteChoice;
+}
+
+function isMajorAiWarning(report: { findings: unknown; summary: string | null }) {
+ const source = `${report.summary ?? ''} ${JSON.stringify(report.findings ?? {})}`.toLowerCase();
+
+ return source.includes('major') || source.includes('contradiction') || source.includes('warning');
+}
+
+function aiWarningSnapshot(proposal: {
+ aiReports: Array<{ findings: unknown; summary: string | null }>;
+}) {
+ const warning = proposal.aiReports.find(isMajorAiWarning);
+
+ return {
+ acknowledged: Boolean(warning),
+ summary: warning?.summary ?? null,
+ };
+}
+
+export function serializeDecision(decision: ProposalWithDetails['decision']) {
+ if (!decision) {
+ return null;
+ }
+
+ return {
+ aiWarningAcknowledged: decision.aiWarningAcknowledged,
+ aiWarningSummary: decision.aiWarningSummary,
+ approvalDenominator: decision.approvalDenominator,
+ approvalNumerator: decision.approvalNumerator,
+ approvalPercentageBps: decision.approvalPercentageBps,
+ approvalThresholdBps: decision.approvalThresholdBps,
+ approveCount: decision.approveCount,
+ alternateTimelineCount: decision.alternateTimelineCount,
+ blockchainTimestamp: iso(decision.blockchainTimestamp),
+ blockNumber: decision.blockNumber?.toString() ?? null,
+ contentHash: decision.contentHash,
+ customJsonId: decision.customJsonId,
+ decidedAt: decision.decidedAt.toISOString(),
+ decisionPayload: decision.decisionPayload,
+ decisionPayloadHash: decision.decisionPayloadHash,
+ expectedSigner: decision.expectedSigner,
+ hiveEventId: decision.hiveEventId,
+ id: decision.id,
+ minimumVotes: decision.minimumVotes,
+ needsRevisionCount: decision.needsRevisionCount,
+ operationIndex: decision.operationIndex,
+ outcome: decision.outcome,
+ rejectCount: decision.rejectCount,
+ rulesVersion: decision.rulesVersion,
+ totalVotes: decision.totalVotes,
+ transactionId: decision.transactionId,
+ votingWindowHours: decision.votingWindowHours,
+ };
+}
+
+export function serializeProposalDetail(
+ proposal: ProposalWithDetails,
+ currentUserId?: string | undefined,
+) {
+ const summary = tallyCanonVotes(proposal.votes);
+ const currentVote = currentUserId
+ ? proposal.votes.find((vote) => vote.voterId === currentUserId)
+ : null;
+ const warning = aiWarningSnapshot(proposal);
+
+ return {
+ aiWarning: warning,
+ author: proposal.author,
+ authorId: proposal.authorId,
+ baseCanonVersionId: proposal.baseCanonVersionId,
+ branchBaseLoreEntryId: proposal.branchBaseLoreEntryId,
+ branchLabel: proposal.branchLabel,
+ branchParentProposalId: proposal.branchParentProposalId,
+ conflictMetadata: proposal.conflictMetadata,
+ contentHash: proposal.contentHash,
+ currentUserVote: currentVote
+ ? {
+ choice: currentVote.choice,
+ updatedAt: currentVote.updatedAt.toISOString(),
+ }
+ : null,
+ decision: serializeDecision(proposal.decision),
+ id: proposal.id,
+ proposedContent: proposal.proposedContent,
+ proposalType: proposal.proposalType,
+ status: proposal.status,
+ submittedAt: iso(proposal.submittedAt),
+ summary: proposal.summary,
+ tally: summary,
+ title: proposal.title,
+ votingEndsAt: iso(proposal.votingEndsAt),
+ votingStartedAt: iso(proposal.votingStartedAt),
+ world: proposal.world,
+ worldId: proposal.worldId,
+ };
+}
+
+async function findProposal(database: CanonVotingDatabase, worldId: string, proposalId: string) {
+ return database.proposal.findFirst({
+ include: {
+ aiReports: true,
+ author: {
+ select: {
+ hiveUsername: true,
+ id: true,
+ },
+ },
+ decision: true,
+ votes: {
+ select: {
+ choice: true,
+ updatedAt: true,
+ voterId: true,
+ },
+ },
+ world: {
+ select: {
+ id: true,
+ title: true,
+ },
+ },
+ },
+ where: {
+ id: proposalId,
+ worldId,
+ },
+ });
+}
+
+export async function getProposalDetail(
+ database: CanonVotingDatabase,
+ input: { currentUserId?: string; proposalId: string; worldId: string },
+) {
+ const proposal = await findProposal(database, input.worldId, input.proposalId);
+
+ if (!proposal) {
+ throw new CanonVotingError(404, 'PROPOSAL_NOT_FOUND', 'Proposal not found.');
+ }
+
+ return serializeProposalDetail(proposal, input.currentUserId);
+}
+
+export async function getVoteSummary(
+ database: CanonVotingDatabase,
+ input: { proposalId: string; worldId: string },
+) {
+ const proposal = await database.proposal.findFirst({
+ select: {
+ id: true,
+ status: true,
+ votingEndsAt: true,
+ votingStartedAt: true,
+ votes: {
+ select: {
+ choice: true,
+ },
+ },
+ worldId: true,
+ },
+ where: {
+ id: input.proposalId,
+ worldId: input.worldId,
+ },
+ });
+
+ if (!proposal) {
+ throw new CanonVotingError(404, 'PROPOSAL_NOT_FOUND', 'Proposal not found.');
+ }
+
+ return {
+ rules: CANON_VOTING_RULES,
+ status: proposal.status,
+ tally: tallyCanonVotes(proposal.votes),
+ votingEndsAt: iso(proposal.votingEndsAt),
+ votingStartedAt: iso(proposal.votingStartedAt),
+ };
+}
+
+export async function castCanonVote(
+ database: CanonVotingDatabase,
+ input: {
+ choice: string;
+ proposalId: string;
+ voterId: string;
+ worldId: string;
+ now?: Date;
+ },
+) {
+ const choice = assertVoteChoice(input.choice);
+ const now = input.now ?? new Date();
+
+ return database.$transaction(async (transaction) => {
+ const proposal = await transaction.proposal.findFirst({
+ select: {
+ decision: { select: { id: true } },
+ id: true,
+ status: true,
+ votingEndsAt: true,
+ worldId: true,
+ },
+ where: {
+ id: input.proposalId,
+ worldId: input.worldId,
+ },
+ });
+
+ if (!proposal) {
+ throw new CanonVotingError(404, 'PROPOSAL_NOT_FOUND', 'Proposal not found.');
+ }
+
+ if (proposal.status !== ProposalStatus.VOTING || proposal.decision) {
+ throw new CanonVotingError(409, 'PROPOSAL_NOT_VOTING', 'Proposal is not open for voting.');
+ }
+
+ if (!proposal.votingEndsAt || now.getTime() >= proposal.votingEndsAt.getTime()) {
+ throw new CanonVotingError(409, 'VOTING_CLOSED', 'Voting is closed for this proposal.');
+ }
+
+ const vote = await transaction.appVote.upsert({
+ create: {
+ choice,
+ proposalId: input.proposalId,
+ voterId: input.voterId,
+ },
+ update: {
+ choice,
+ },
+ where: {
+ proposalId_voterId: {
+ proposalId: input.proposalId,
+ voterId: input.voterId,
+ },
+ },
+ });
+
+ return {
+ vote: {
+ choice: vote.choice,
+ createdAt: vote.createdAt.toISOString(),
+ id: vote.id,
+ proposalId: vote.proposalId,
+ updatedAt: vote.updatedAt.toISOString(),
+ voterId: vote.voterId,
+ },
+ };
+ });
+}
+
+function buildDecisionPayload(input: {
+ aiWarningAcknowledged: boolean;
+ aiWarningSummary: string | null;
+ outcome: ProposalDecisionOutcome;
+ proposal: {
+ baseCanonVersionId: string | null;
+ branchBaseLoreEntryId: string | null;
+ branchLabel: string | null;
+ branchParentProposalId: string | null;
+ conflictMetadata: unknown;
+ contentHash: string | null;
+ decidedAt: Date;
+ id: string;
+ proposedContent: unknown;
+ staleBaseAtDecision: boolean;
+ votingEndsAt: Date;
+ votingStartedAt: Date;
+ worldId: string;
+ };
+ tally: ReturnType;
+}) {
+ const contentHash =
+ input.proposal.contentHash ?? hashCanonicalJson(input.proposal.proposedContent);
+
+ return {
+ aiWarning: {
+ acknowledged: input.aiWarningAcknowledged,
+ summary: input.aiWarningSummary,
+ },
+ approval: {
+ denominator: input.tally.approvalDenominator,
+ numerator: input.tally.approvalNumerator,
+ percentageBps: input.tally.approvalPercentageBps,
+ },
+ branch: {
+ baseCanonVersionId: input.proposal.baseCanonVersionId,
+ baseLoreEntryId: input.proposal.branchBaseLoreEntryId,
+ conflictMetadata: input.proposal.conflictMetadata ?? null,
+ label: input.proposal.branchLabel,
+ parentProposalId: input.proposal.branchParentProposalId,
+ staleBaseAtDecision: input.proposal.staleBaseAtDecision,
+ },
+ contentHash,
+ counts: {
+ alternateTimeline: input.tally.alternateTimeline,
+ approve: input.tally.approve,
+ needsRevision: input.tally.needsRevision,
+ reject: input.tally.reject,
+ total: input.tally.totalVotes,
+ },
+ eventType: 'canon_decision',
+ outcome: input.outcome,
+ proposalId: input.proposal.id,
+ rules: CANON_VOTING_RULES,
+ voting: {
+ decidedAt: input.proposal.decidedAt.toISOString(),
+ votingEndsAt: input.proposal.votingEndsAt.toISOString(),
+ votingStartedAt: input.proposal.votingStartedAt.toISOString(),
+ },
+ worldId: input.proposal.worldId,
+ };
+}
+
+export async function finalizeCanonDecision(
+ database: CanonVotingDatabase,
+ input: { actorId: string; proposalId: string; worldId: string; now?: Date },
+) {
+ const now = input.now ?? new Date();
+
+ return database.$transaction(async (transaction) => {
+ const existing = await transaction.proposalDecision.findUnique({
+ where: {
+ proposalId: input.proposalId,
+ },
+ });
+
+ if (existing) {
+ return {
+ decision: serializeDecision(existing),
+ idempotent: true,
+ };
+ }
+
+ const proposal = await transaction.proposal.findFirst({
+ include: {
+ aiReports: true,
+ votes: {
+ select: {
+ choice: true,
+ },
+ },
+ },
+ where: {
+ id: input.proposalId,
+ worldId: input.worldId,
+ },
+ });
+
+ if (!proposal) {
+ throw new CanonVotingError(404, 'PROPOSAL_NOT_FOUND', 'Proposal not found.');
+ }
+
+ if (proposal.status !== ProposalStatus.VOTING) {
+ throw new CanonVotingError(
+ 409,
+ 'PROPOSAL_NOT_VOTING',
+ 'Proposal is not open for finalization.',
+ );
+ }
+
+ if (!proposal.votingStartedAt || !proposal.votingEndsAt) {
+ throw new CanonVotingError(409, 'VOTING_WINDOW_MISSING', 'Proposal has no voting window.');
+ }
+
+ if (now.getTime() < proposal.votingEndsAt.getTime()) {
+ throw new CanonVotingError(409, 'VOTING_STILL_OPEN', 'Voting is still open.');
+ }
+
+ const currentBible = await transaction.worldBibleVersion.findFirst({
+ orderBy: {
+ versionNumber: 'desc',
+ },
+ select: {
+ id: true,
+ },
+ where: {
+ worldId: input.worldId,
+ },
+ });
+
+ const staleBaseAtDecision = Boolean(
+ proposal.baseCanonVersionId &&
+ currentBible?.id &&
+ proposal.baseCanonVersionId !== currentBible.id,
+ );
+ const tally = tallyCanonVotes(proposal.votes);
+ const outcome = decideCanonOutcome({
+ now,
+ staleBaseAtDecision,
+ tally,
+ votingEndsAt: proposal.votingEndsAt,
+ });
+
+ if (!outcome) {
+ throw new CanonVotingError(409, 'VOTING_STILL_OPEN', 'Voting is still open.');
+ }
+
+ const warning = aiWarningSnapshot(proposal);
+ const decidedAt = now;
+ const contentHash = proposal.contentHash ?? hashCanonicalJson(proposal.proposedContent);
+ const payload = buildDecisionPayload({
+ aiWarningAcknowledged: warning.acknowledged,
+ aiWarningSummary: warning.summary,
+ outcome,
+ proposal: {
+ ...proposal,
+ contentHash,
+ decidedAt,
+ staleBaseAtDecision,
+ votingEndsAt: proposal.votingEndsAt,
+ votingStartedAt: proposal.votingStartedAt,
+ },
+ tally,
+ });
+ const payloadHash = hashCanonicalJson(payload);
+ const decision = await transaction.proposalDecision.create({
+ data: {
+ aiWarningAcknowledged: warning.acknowledged,
+ aiWarningSummary: warning.summary,
+ alternateTimelineCount: tally.alternateTimeline,
+ approvalDenominator: tally.approvalDenominator,
+ approvalNumerator: tally.approvalNumerator,
+ approvalPercentageBps: tally.approvalPercentageBps,
+ approvalThresholdBps: CANON_VOTING_RULES.approvalThresholdBps,
+ approveCount: tally.approve,
+ baseCanonVersionId: proposal.baseCanonVersionId,
+ branchBaseLoreEntryId: proposal.branchBaseLoreEntryId,
+ branchLabel: proposal.branchLabel,
+ branchParentProposalId: proposal.branchParentProposalId,
+ conflictMetadata: proposal.conflictMetadata as Prisma.InputJsonValue,
+ contentHash,
+ decidedAt,
+ decisionPayload: payload as Prisma.InputJsonValue,
+ decisionPayloadHash: payloadHash,
+ minimumVotes: CANON_VOTING_RULES.minimumVotes,
+ needsRevisionCount: tally.needsRevision,
+ outcome,
+ payloadSchemaVersion: CANON_VOTING_RULES.payloadSchemaVersion,
+ proposalId: proposal.id,
+ rejectCount: tally.reject,
+ rulesVersion: CANON_VOTING_RULES.rulesVersion,
+ staleBaseAtDecision,
+ totalVotes: tally.totalVotes,
+ votingWindowHours: CANON_VOTING_RULES.votingWindowHours,
+ },
+ });
+
+ await transaction.proposal.update({
+ data: {
+ ...(outcome === ProposalDecisionOutcome.APPROVED_FOR_PUBLICATION
+ ? { approvedAt: decidedAt }
+ : { rejectedAt: decidedAt }),
+ contentHash,
+ decidedAt,
+ staleBaseAtDecision,
+ status: proposalStatusForDecision(outcome),
+ },
+ where: {
+ id: proposal.id,
+ },
+ });
+
+ await transaction.worldAuditLog.create({
+ data: {
+ action: WorldAuditAction.CANON_DECISION_FINALIZED,
+ actorId: input.actorId,
+ metadata: {
+ decisionId: decision.id,
+ outcome,
+ payloadHash,
+ tally,
+ },
+ targetId: proposal.id,
+ targetType: 'PROPOSAL',
+ worldId: input.worldId,
+ },
+ });
+
+ return {
+ decision: serializeDecision(decision),
+ idempotent: false,
+ };
+ });
+}
+
+export async function createCanonTransactionOperation(
+ database: CanonVotingDatabase,
+ input: { proposalId: string; signerId: string; worldId: string },
+) {
+ const proposal = await database.proposal.findFirst({
+ include: {
+ author: {
+ select: {
+ hiveUsername: true,
+ id: true,
+ },
+ },
+ decision: true,
+ },
+ where: {
+ id: input.proposalId,
+ worldId: input.worldId,
+ },
+ });
+
+ if (!proposal?.decision) {
+ throw new CanonVotingError(
+ 409,
+ 'DECISION_NOT_FINALIZED',
+ 'Proposal decision is not finalized.',
+ );
+ }
+
+ if (proposal.decision.outcome !== ProposalDecisionOutcome.APPROVED_FOR_PUBLICATION) {
+ throw new CanonVotingError(
+ 409,
+ 'DECISION_NOT_APPROVED',
+ 'Only approved decisions go on-chain.',
+ );
+ }
+
+ if (proposal.authorId !== input.signerId) {
+ throw new CanonVotingError(
+ 403,
+ 'SIGNER_NOT_AUTHOR',
+ 'Only the proposal author can sign the canon decision.',
+ );
+ }
+
+ const signer = proposal.author.hiveUsername.toLowerCase();
+ const operation = buildHiveLoreCustomJsonOperation({
+ action: 'canon_approval',
+ entityId: proposal.decision.id,
+ entityType: 'CANON_DECISION',
+ payload: proposal.decision.decisionPayload as Record,
+ proposalId: proposal.id,
+ signer,
+ worldId: proposal.worldId,
+ });
+
+ await database.proposalDecision.update({
+ data: {
+ customJsonId: HIVELORE_CUSTOM_JSON_ID,
+ expectedSigner: signer,
+ },
+ where: {
+ id: proposal.decision.id,
+ },
+ });
+
+ return {
+ customJsonId: HIVELORE_CUSTOM_JSON_ID,
+ decisionId: proposal.decision.id,
+ operation,
+ signer,
+ };
+}
+
+async function findConfirmedOperation(input: {
+ blockNumber: number;
+ hafClient: HafClient;
+ operationIndex: number;
+ transactionId: string;
+}): Promise {
+ const page = await input.hafClient.searchBlocks({
+ fromBlock: input.blockNumber,
+ toBlock: input.blockNumber,
+ });
+
+ for (const row of page.operations) {
+ const operation = normalizeHafOperation(row);
+
+ if (
+ operation.transactionId === input.transactionId &&
+ operation.operationIndex === input.operationIndex
+ ) {
+ return operation;
+ }
+ }
+
+ return null;
+}
+
+export async function confirmCanonTransaction(
+ database: CanonVotingDatabase,
+ input: {
+ blockNumber: number;
+ hafClient: HafClient;
+ operationIndex: number;
+ proposalId: string;
+ transactionId: string;
+ worldId: string;
+ },
+) {
+ const proposal = await database.proposal.findFirst({
+ include: {
+ author: {
+ select: {
+ hiveUsername: true,
+ },
+ },
+ decision: true,
+ },
+ where: {
+ id: input.proposalId,
+ worldId: input.worldId,
+ },
+ });
+
+ if (!proposal?.decision) {
+ throw new CanonVotingError(
+ 409,
+ 'DECISION_NOT_FINALIZED',
+ 'Proposal decision is not finalized.',
+ );
+ }
+
+ if (
+ proposal.decision.transactionId === input.transactionId &&
+ proposal.decision.operationIndex === input.operationIndex
+ ) {
+ return {
+ decision: serializeDecision(proposal.decision),
+ idempotent: true,
+ };
+ }
+
+ const operation = await findConfirmedOperation({
+ blockNumber: input.blockNumber,
+ hafClient: input.hafClient,
+ operationIndex: input.operationIndex,
+ transactionId: input.transactionId,
+ });
+
+ if (!operation) {
+ throw new CanonVotingError(
+ 404,
+ 'HIVE_OPERATION_NOT_FOUND',
+ 'Hive operation was not confirmed.',
+ );
+ }
+
+ const signer = (proposal.decision.expectedSigner ?? proposal.author.hiveUsername).toLowerCase();
+ const verification = verifyHiveLoreOperation({
+ expectedSigner: signer,
+ operation: operation.operation,
+ });
+
+ if (!verification.ok) {
+ throw new CanonVotingError(
+ 400,
+ 'HIVE_OPERATION_INVALID',
+ verification.reason ?? 'Invalid Hive operation.',
+ );
+ }
+
+ const payload = parseHiveLoreCustomJsonPayload(operation.operation);
+
+ if (
+ !payload ||
+ payload.action !== 'canon_approval' ||
+ payload.entityType !== 'CANON_DECISION' ||
+ payload.entityId !== proposal.decision.id ||
+ payload.worldId !== input.worldId ||
+ payload.proposalId !== input.proposalId ||
+ hashCanonicalJson(payload.payload) !== proposal.decision.decisionPayloadHash
+ ) {
+ throw new CanonVotingError(
+ 400,
+ 'HIVE_OPERATION_MISMATCH',
+ 'Hive operation does not match the frozen decision.',
+ );
+ }
+
+ return database.$transaction(async (transaction) => {
+ const hiveEvent = await transaction.hiveEvent.upsert({
+ create: {
+ blockNumber: operation.blockNumber,
+ blockchainTimestamp: operation.blockchainTimestamp,
+ eventType: HiveEventType.CUSTOM_JSON,
+ operationIndex: operation.operationIndex,
+ payload: operation.operation as Prisma.InputJsonValue,
+ transactionId: operation.transactionId,
+ },
+ update: {
+ blockNumber: operation.blockNumber,
+ blockchainTimestamp: operation.blockchainTimestamp,
+ eventType: HiveEventType.CUSTOM_JSON,
+ payload: operation.operation as Prisma.InputJsonValue,
+ },
+ where: {
+ transactionId_operationIndex: {
+ operationIndex: operation.operationIndex,
+ transactionId: operation.transactionId,
+ },
+ },
+ });
+
+ const decision = await transaction.proposalDecision.update({
+ data: {
+ blockchainTimestamp: operation.blockchainTimestamp,
+ blockNumber: operation.blockNumber,
+ customJsonId: HIVELORE_CUSTOM_JSON_ID,
+ expectedSigner: signer,
+ hiveEventId: hiveEvent.id,
+ operationIndex: operation.operationIndex,
+ transactionId: operation.transactionId,
+ },
+ where: {
+ id: proposal.decision.id,
+ },
+ });
+
+ return {
+ decision: serializeDecision(decision),
+ idempotent: false,
+ };
+ });
+}
+
+export function prepareSubmittedProposalVotingFields(input: {
+ proposedContent: unknown;
+ submittedAt: Date;
+}) {
+ return {
+ contentHash: hashCanonicalJson(input.proposedContent),
+ votingEndsAt: addVotingWindow(input.submittedAt),
+ votingStartedAt: input.submittedAt,
+ };
+}
diff --git a/apps/api/src/lib/contributions.ts b/apps/api/src/lib/contributions.ts
index 675109b..527f733 100644
--- a/apps/api/src/lib/contributions.ts
+++ b/apps/api/src/lib/contributions.ts
@@ -6,6 +6,7 @@ import {
ProposalType,
WorldAuditAction,
} from '../generated/prisma/enums.js';
+import { prepareSubmittedProposalVotingFields } from './canon-voting.js';
const STRUCTURED_DOCUMENT_MAX_BYTES = 100 * 1024;
@@ -24,6 +25,7 @@ export type ContributionDatabase = Pick<
| 'contributionDraft'
| 'loreEntry'
| 'proposal'
+ | 'refreshSession'
| 'world'
| 'worldAuditLog'
| 'worldMembership'
@@ -652,6 +654,21 @@ export async function submitContribution(
await assertTargetLoreEntry(transaction, input.worldId, existing.targetLoreEntryId);
const submittedAt = new Date();
+ const votingFields = prepareSubmittedProposalVotingFields({
+ proposedContent: structuredContent,
+ submittedAt,
+ });
+ const currentBible = await transaction.worldBibleVersion.findFirst({
+ orderBy: {
+ versionNumber: 'desc',
+ },
+ select: {
+ id: true,
+ },
+ where: {
+ worldId: input.worldId,
+ },
+ });
const transition = await transaction.contributionDraft.updateMany({
data: {
status: ContributionStatus.SUBMITTED,
@@ -691,17 +708,21 @@ export async function submitContribution(
const proposal = await transaction.proposal.create({
data: {
authorId: input.authorId,
+ baseCanonVersionId: currentBible?.id ?? null,
contributionKind: existing.kind,
+ contentHash: votingFields.contentHash,
proposedContent: structuredContent,
proposalType: proposalTypeForContribution(
existing.kind,
Boolean(existing.targetLoreEntryId),
),
- status: ProposalStatus.SUBMITTED,
+ status: ProposalStatus.VOTING,
submittedAt,
summary: existing.summary ?? '',
targetLoreEntryId: existing.targetLoreEntryId,
title: existing.title,
+ votingEndsAt: votingFields.votingEndsAt,
+ votingStartedAt: votingFields.votingStartedAt,
worldId: input.worldId,
},
});
diff --git a/apps/api/src/lib/prisma-conflicts.test.ts b/apps/api/src/lib/prisma-conflicts.test.ts
index 37c58bc..9815b8c 100644
--- a/apps/api/src/lib/prisma-conflicts.test.ts
+++ b/apps/api/src/lib/prisma-conflicts.test.ts
@@ -1,7 +1,7 @@
import assert from 'node:assert/strict';
import { describe, test } from 'node:test';
-import { mapDuplicateVoteConflict } from './prisma-conflicts.js';
+import { mapDuplicateVoteConflict, mapWorldSlugConflict } from './prisma-conflicts.js';
describe('Prisma conflict mapping', () => {
test('maps duplicate proposal vote unique constraint to 409', () => {
@@ -29,6 +29,31 @@ describe('Prisma conflict mapping', () => {
assert.equal(conflict, null);
});
+ test('maps only world slug unique conflicts to 409', () => {
+ assert.deepEqual(
+ mapWorldSlugConflict({
+ code: 'P2002',
+ meta: {
+ target: ['slug'],
+ },
+ }),
+ {
+ statusCode: 409,
+ error: 'World slug is already in use.',
+ },
+ );
+
+ assert.equal(
+ mapWorldSlugConflict({
+ code: 'P2002',
+ meta: {
+ target: ['proposalId', 'voterId'],
+ },
+ }),
+ null,
+ );
+ });
+
test('does not map non-unique Prisma errors', () => {
const conflict = mapDuplicateVoteConflict({
code: 'P2025',
diff --git a/apps/api/src/lib/prisma-conflicts.ts b/apps/api/src/lib/prisma-conflicts.ts
index 8f29bdb..57a7e18 100644
--- a/apps/api/src/lib/prisma-conflicts.ts
+++ b/apps/api/src/lib/prisma-conflicts.ts
@@ -14,6 +14,14 @@ function hasDuplicateVoteTarget(target: unknown) {
return Array.isArray(target) && target.includes('proposalId') && target.includes('voterId');
}
+function hasWorldSlugTarget(target: unknown) {
+ if (Array.isArray(target)) {
+ return target.length === 1 && target.includes('slug');
+ }
+
+ return target === 'World_slug_key';
+}
+
export function mapDuplicateVoteConflict(error: unknown): ConflictHttpError | null {
const knownError = error as PrismaKnownRequestErrorLike;
@@ -26,3 +34,16 @@ export function mapDuplicateVoteConflict(error: unknown): ConflictHttpError | nu
error: 'User has already voted on this proposal.',
};
}
+
+export function mapWorldSlugConflict(error: unknown): ConflictHttpError | null {
+ const knownError = error as PrismaKnownRequestErrorLike;
+
+ if (knownError.code !== 'P2002' || !hasWorldSlugTarget(knownError.meta?.target)) {
+ return null;
+ }
+
+ return {
+ statusCode: 409,
+ error: 'World slug is already in use.',
+ };
+}
diff --git a/apps/api/src/lib/worlds.ts b/apps/api/src/lib/worlds.ts
index a6bfcc1..a1a6401 100644
--- a/apps/api/src/lib/worlds.ts
+++ b/apps/api/src/lib/worlds.ts
@@ -5,6 +5,7 @@ import {
WorldAuditAction,
WorldRole,
} from '../generated/prisma/enums.js';
+import { mapWorldSlugConflict } from './prisma-conflicts.js';
export type WorldSeedInput = {
premise: string;
@@ -32,6 +33,7 @@ export type CreateWorldInput = {
export type UpdateWorldInput = {
worldId: string;
+ actorId: string;
title?: string | undefined;
description?: string | undefined;
seed?: Partial | undefined;
@@ -43,6 +45,7 @@ export type WorldDatabase = Pick<
| '$transaction'
| 'loreEntry'
| 'proposal'
+ | 'refreshSession'
| 'world'
| 'worldAuditLog'
| 'worldBibleVersion'
@@ -50,6 +53,15 @@ export type WorldDatabase = Pick<
| 'worldSeed'
>;
+export class WorldError extends Error {
+ constructor(
+ public readonly statusCode: number,
+ message: string,
+ ) {
+ super(message);
+ }
+}
+
const worldInclude = {
bibleVersions: {
orderBy: {
@@ -69,10 +81,43 @@ const worldInclude = {
seed: true,
} as const;
+const worldListInclude = {
+ bibleVersions: {
+ orderBy: {
+ versionNumber: 'desc',
+ },
+ select: {
+ changeSummary: true,
+ createdAt: true,
+ creatorId: true,
+ hiveReferenceId: true,
+ id: true,
+ publishedAt: true,
+ updatedAt: true,
+ versionNumber: true,
+ },
+ take: 1,
+ },
+ founder: {
+ select: {
+ avatarUrl: true,
+ displayName: true,
+ hiveUsername: true,
+ id: true,
+ normalizedHiveUsername: true,
+ },
+ },
+ seed: true,
+} as const;
+
type WorldWithRelations = Prisma.WorldGetPayload<{
include: typeof worldInclude;
}>;
+type WorldListItemWithRelations = Prisma.WorldGetPayload<{
+ include: typeof worldListInclude;
+}>;
+
export function createSlug(title: string) {
const slug = title
.trim()
@@ -113,14 +158,19 @@ function serializeSeed(seed: NonNullable) {
};
}
-function serializeBibleVersion(version: WorldWithRelations['bibleVersions'][number] | null) {
+function serializeBibleVersion(
+ version:
+ | WorldWithRelations['bibleVersions'][number]
+ | WorldListItemWithRelations['bibleVersions'][number]
+ | null,
+) {
if (!version) {
return null;
}
return {
changeSummary: version.changeSummary,
- content: version.content,
+ ...('content' in version ? { content: version.content } : {}),
createdAt: version.createdAt.toISOString(),
creatorId: version.creatorId,
hiveReferenceId: version.hiveReferenceId,
@@ -148,6 +198,21 @@ export function serializeWorld(world: WorldWithRelations) {
export type SerializedWorld = ReturnType;
+export function serializeWorldListItem(world: WorldListItemWithRelations) {
+ return {
+ createdAt: world.createdAt.toISOString(),
+ currentBibleVersion: serializeBibleVersion(world.bibleVersions[0] ?? null),
+ description: world.description,
+ founder: world.founder,
+ founderId: world.founderId,
+ id: world.id,
+ seed: world.seed ? serializeSeed(world.seed) : null,
+ slug: world.slug,
+ title: world.title,
+ updatedAt: world.updatedAt.toISOString(),
+ };
+}
+
async function generateUniqueWorldSlug(database: Pick, title: string) {
const baseSlug = createSlug(title);
let slug = baseSlug;
@@ -169,78 +234,88 @@ async function generateUniqueWorldSlug(database: Pick, t
slug = `${baseSlug}-${suffix}`;
}
- throw new Error('Unable to create a unique world slug.');
+ throw new WorldError(409, 'Unable to create a unique world slug.');
}
export async function createWorld(database: WorldDatabase, input: CreateWorldInput) {
- const slug = await generateUniqueWorldSlug(database, input.title);
const seed = normalizeSeedInput(input.seed);
- const world = await database.$transaction(async (transaction) => {
- const createdWorld = await transaction.world.create({
- data: {
- description: input.description.trim(),
- founderId: input.creatorId,
- slug,
- title: input.title.trim(),
- },
- });
-
- await transaction.worldSeed.create({
- data: {
- firstCharacters: seed.firstCharacters,
- firstFactions: seed.firstFactions,
- firstHistoricalEvent: seed.firstHistoricalEvent,
- genre: seed.genre,
- mainConflict: seed.mainConflict,
- premise: seed.premise,
- startingLocation: seed.startingLocation,
- tone: seed.tone,
- worldId: createdWorld.id,
- },
- });
+ try {
+ const world = await database.$transaction(async (transaction) => {
+ const slug = await generateUniqueWorldSlug(transaction, input.title);
+ const createdWorld = await transaction.world.create({
+ data: {
+ description: input.description.trim(),
+ founderId: input.creatorId,
+ slug,
+ title: input.title.trim(),
+ },
+ });
- await transaction.worldBibleVersion.create({
- data: {
- changeSummary: input.bible.changeSummary?.trim() || 'Initial world bible.',
- content: input.bible.content,
- creatorId: input.creatorId,
- versionNumber: 1,
- worldId: createdWorld.id,
- },
- });
+ await transaction.worldSeed.create({
+ data: {
+ firstCharacters: seed.firstCharacters,
+ firstFactions: seed.firstFactions,
+ firstHistoricalEvent: seed.firstHistoricalEvent,
+ genre: seed.genre,
+ mainConflict: seed.mainConflict,
+ premise: seed.premise,
+ startingLocation: seed.startingLocation,
+ tone: seed.tone,
+ worldId: createdWorld.id,
+ },
+ });
- const membership = await transaction.worldMembership.create({
- data: {
- grantedById: input.creatorId,
- role: WorldRole.FOUNDER,
- userId: input.creatorId,
- worldId: createdWorld.id,
- },
- });
+ await transaction.worldBibleVersion.create({
+ data: {
+ changeSummary: input.bible.changeSummary?.trim() || 'Initial world bible.',
+ content: input.bible.content,
+ creatorId: input.creatorId,
+ versionNumber: 1,
+ worldId: createdWorld.id,
+ },
+ });
- await transaction.worldAuditLog.create({
- data: {
- action: WorldAuditAction.ROLE_ASSIGNED,
- actorId: input.creatorId,
- metadata: {
+ const membership = await transaction.worldMembership.create({
+ data: {
+ grantedById: input.creatorId,
role: WorldRole.FOUNDER,
+ userId: input.creatorId,
+ worldId: createdWorld.id,
},
- targetId: membership.id,
- targetType: 'WORLD_MEMBERSHIP',
- worldId: createdWorld.id,
- },
- });
+ });
- return transaction.world.findUniqueOrThrow({
- include: worldInclude,
- where: {
- id: createdWorld.id,
- },
+ await transaction.worldAuditLog.create({
+ data: {
+ action: WorldAuditAction.ROLE_ASSIGNED,
+ actorId: input.creatorId,
+ metadata: {
+ role: WorldRole.FOUNDER,
+ },
+ targetId: membership.id,
+ targetType: 'WORLD_MEMBERSHIP',
+ worldId: createdWorld.id,
+ },
+ });
+
+ return transaction.world.findUniqueOrThrow({
+ include: worldInclude,
+ where: {
+ id: createdWorld.id,
+ },
+ });
});
- });
- return serializeWorld(world);
+ return serializeWorld(world);
+ } catch (error) {
+ const conflict = mapWorldSlugConflict(error);
+
+ if (conflict) {
+ throw new WorldError(conflict.statusCode, conflict.error);
+ }
+
+ throw error;
+ }
}
export async function listWorlds(
@@ -297,7 +372,7 @@ export async function listWorlds(
const [worlds, total] = await Promise.all([
database.world.findMany({
- include: worldInclude,
+ include: worldListInclude,
orderBy: [
{
createdAt: 'desc',
@@ -321,7 +396,7 @@ export async function listWorlds(
pageSize,
total,
},
- worlds: worlds.map(serializeWorld),
+ worlds: worlds.map(serializeWorldListItem),
};
}
@@ -414,7 +489,7 @@ export async function updateWorld(database: WorldDatabase, input: UpdateWorldInp
});
if (!current) {
- return null;
+ throw new WorldError(404, 'World not found.');
}
if (input.title || input.description) {
@@ -433,7 +508,7 @@ export async function updateWorld(database: WorldDatabase, input: UpdateWorldInp
const currentSeed = current.seed;
if (!currentSeed) {
- throw new Error('World seed is missing.');
+ throw new WorldError(409, 'World seed is missing.');
}
const normalizedSeed = normalizeSeedInput({
@@ -472,7 +547,11 @@ export async function updateWorld(database: WorldDatabase, input: UpdateWorldInp
const currentBible = current.bibleVersions[0];
if (!currentBible) {
- throw new Error('World bible version is missing.');
+ throw new WorldError(409, 'World bible version is missing.');
+ }
+
+ if (currentBible.publishedAt || currentBible.hiveReferenceId) {
+ throw new WorldError(409, 'Published world bible versions cannot be edited in place.');
}
await transaction.worldBibleVersion.update({
@@ -484,6 +563,20 @@ export async function updateWorld(database: WorldDatabase, input: UpdateWorldInp
id: currentBible.id,
},
});
+
+ await transaction.worldAuditLog.create({
+ data: {
+ action: WorldAuditAction.WORLD_BIBLE_UPDATED,
+ actorId: input.actorId,
+ metadata: {
+ changeSummary: input.bible.changeSummary?.trim() || currentBible.changeSummary,
+ versionNumber: currentBible.versionNumber,
+ },
+ targetId: currentBible.id,
+ targetType: 'WORLD_BIBLE_VERSION',
+ worldId: input.worldId,
+ },
+ });
}
return transaction.world.findUniqueOrThrow({
@@ -494,5 +587,5 @@ export async function updateWorld(database: WorldDatabase, input: UpdateWorldInp
});
});
- return world ? serializeWorld(world) : null;
+ return serializeWorld(world);
}
diff --git a/apps/api/src/routes/auth.ts b/apps/api/src/routes/auth.ts
index 358c126..90f5efa 100644
--- a/apps/api/src/routes/auth.ts
+++ b/apps/api/src/routes/auth.ts
@@ -63,28 +63,6 @@ type RegisterAuthRoutesOptions = {
signatureVerifier?: HiveSignatureVerifier;
};
-const challengeRateLimits = new Map();
-const verifyRateLimits = new Map();
-const refreshRateLimits = new Map();
-
-function rateLimit(bucket: Map, key: string, limit: number, windowMs: number) {
- const now = Date.now();
- const recent = (bucket.get(key) ?? []).filter((timestamp) => timestamp > now - windowMs);
-
- if (recent.length >= limit) {
- return false;
- }
-
- recent.push(now);
- bucket.set(key, recent);
-
- return true;
-}
-
-function clientRateLimitKey(request: FastifyRequest) {
- return request.ip || 'unknown';
-}
-
function isTrustedBrowserOrigin(request: FastifyRequest) {
const expectedOrigin = env.CORS_ORIGIN;
const origin = request.headers.origin;
@@ -132,7 +110,7 @@ function sessionOptions() {
audience: env.AUTH_JWT_AUDIENCE,
issuer: env.AUTH_JWT_ISSUER,
jwtSecret: env.AUTH_JWT_SECRET,
- refreshSecret: env.AUTH_JWT_SECRET,
+ refreshSecret: env.AUTH_REFRESH_SECRET,
refreshTtlSeconds: env.AUTH_REFRESH_TOKEN_TTL_SECONDS,
};
}
@@ -147,190 +125,205 @@ export async function registerAuthRoutes(
options.signatureVerifier ??
new DhivePostingSignatureVerifier(new DhiveAccountClient(env.HIVE_RPC_URL));
- app.post('/auth/challenge', async (request, reply) => {
- if (!rateLimit(challengeRateLimits, clientRateLimitKey(request), 20, 60_000)) {
- return reply.code(429).send({
- error: 'Too many authentication attempts.',
- });
- }
-
- const parsed = challengeSchema.safeParse(request.body);
-
- if (!parsed.success) {
- return reply.code(400).send({
- error: 'Invalid challenge payload.',
- });
- }
-
- try {
- const normalizedHiveUsername = readSubmittedUsername(parsed.data);
- const challenge = await createAuthChallenge(challengeDatabase, {
- audience: env.HIVE_AUTH_AUDIENCE,
- hmacSecret: env.AUTH_JWT_SECRET,
- nonce: randomToken(32),
- provider: parsed.data.provider,
- ttlSeconds: env.AUTH_CHALLENGE_TTL_SECONDS,
- username: normalizedHiveUsername,
- });
+ app.post(
+ '/auth/challenge',
+ {
+ config: {
+ rateLimit: {
+ max: 20,
+ timeWindow: '1 minute',
+ },
+ },
+ },
+ async (request, reply) => {
+ const parsed = challengeSchema.safeParse(request.body);
- return {
- challengeId: challenge.challengeId,
- expiresAt: challenge.expiresAt.toISOString(),
- expiresInSeconds: env.AUTH_CHALLENGE_TTL_SECONDS,
- hiveUsername: challenge.username,
- message: challenge.message,
- provider: parsed.data.provider,
- };
- } catch (error) {
- if (error instanceof InvalidHiveUsernameError) {
+ if (!parsed.success) {
return reply.code(400).send({
- error: 'Invalid Hive username.',
+ error: 'Invalid challenge payload.',
});
}
- throw error;
- }
- });
-
- app.post('/auth/verify', async (request, reply) => {
- if (!rateLimit(verifyRateLimits, clientRateLimitKey(request), 30, 60_000)) {
- return reply.code(429).send({
- error: 'Too many authentication attempts.',
- });
- }
-
- const parsed = verifySchema.safeParse(request.body);
+ try {
+ const normalizedHiveUsername = readSubmittedUsername(parsed.data);
+ const challenge = await createAuthChallenge(challengeDatabase, {
+ audience: env.HIVE_AUTH_AUDIENCE,
+ hmacSecret: env.AUTH_JWT_SECRET,
+ nonce: randomToken(32),
+ provider: parsed.data.provider,
+ ttlSeconds: env.AUTH_CHALLENGE_TTL_SECONDS,
+ username: normalizedHiveUsername,
+ });
- if (!parsed.success) {
- return reply.code(400).send({
- error: 'Invalid verification payload.',
- });
- }
+ return {
+ challengeId: challenge.challengeId,
+ expiresAt: challenge.expiresAt.toISOString(),
+ expiresInSeconds: env.AUTH_CHALLENGE_TTL_SECONDS,
+ hiveUsername: challenge.username,
+ message: challenge.message,
+ provider: parsed.data.provider,
+ };
+ } catch (error) {
+ if (error instanceof InvalidHiveUsernameError) {
+ return reply.code(400).send({
+ error: 'Invalid Hive username.',
+ });
+ }
+
+ throw error;
+ }
+ },
+ );
- try {
- const normalizedHiveUsername = readSubmittedUsername(parsed.data);
- const isValidSignature = await signatureVerifier.verifyPostingSignature({
- message: parsed.data.message,
- publicKey: parsed.data.publicKey,
- signature: parsed.data.signature,
- username: normalizedHiveUsername,
- });
+ app.post(
+ '/auth/verify',
+ {
+ config: {
+ rateLimit: {
+ max: 30,
+ timeWindow: '1 minute',
+ },
+ },
+ },
+ async (request, reply) => {
+ const parsed = verifySchema.safeParse(request.body);
- if (!isValidSignature) {
- return reply.code(401).send({
- error: 'Authentication failed.',
+ if (!parsed.success) {
+ return reply.code(400).send({
+ error: 'Invalid verification payload.',
});
}
- const issuedSession = await database.$transaction(async (transaction) => {
- await consumeAuthChallenge(transaction, {
- challengeId: parsed.data.challengeId,
- hmacSecret: env.AUTH_JWT_SECRET,
+ try {
+ const normalizedHiveUsername = readSubmittedUsername(parsed.data);
+ const isValidSignature = await signatureVerifier.verifyPostingSignature({
message: parsed.data.message,
- provider: parsed.data.provider,
+ publicKey: parsed.data.publicKey,
+ signature: parsed.data.signature,
username: normalizedHiveUsername,
});
- const user = await upsertHiveUser(transaction, {
- normalizedHiveUsername,
+ if (!isValidSignature) {
+ return reply.code(401).send({
+ error: 'Authentication failed.',
+ });
+ }
+
+ const issuedSession = await database.$transaction(async (transaction) => {
+ await consumeAuthChallenge(transaction, {
+ challengeId: parsed.data.challengeId,
+ hmacSecret: env.AUTH_JWT_SECRET,
+ message: parsed.data.message,
+ provider: parsed.data.provider,
+ username: normalizedHiveUsername,
+ });
+
+ const user = await upsertHiveUser(transaction, {
+ normalizedHiveUsername,
+ });
+
+ return issueSession(transaction, {
+ ...sessionOptions(),
+ ipAddress: request.ip,
+ user,
+ userAgent: request.headers['user-agent'],
+ });
});
- return issueSession(transaction, {
- ...sessionOptions(),
- ipAddress: request.ip,
- user,
- userAgent: request.headers['user-agent'],
+ setRefreshCookie(reply, issuedSession.refreshToken, {
+ ...cookieOptions(),
+ maxAgeSeconds: env.AUTH_REFRESH_TOKEN_TTL_SECONDS,
});
- });
- setRefreshCookie(reply, issuedSession.refreshToken, {
- ...cookieOptions(),
- maxAgeSeconds: env.AUTH_REFRESH_TOKEN_TTL_SECONDS,
- });
+ return {
+ accessToken: issuedSession.accessToken,
+ expiresInSeconds: env.AUTH_ACCESS_TOKEN_TTL_SECONDS,
+ user: issuedSession.user,
+ };
+ } catch (error) {
+ if (error instanceof InvalidHiveUsernameError) {
+ return reply.code(400).send({
+ error: 'Invalid Hive username.',
+ });
+ }
- return {
- accessToken: issuedSession.accessToken,
- expiresInSeconds: env.AUTH_ACCESS_TOKEN_TTL_SECONDS,
- user: issuedSession.user,
- };
- } catch (error) {
- if (error instanceof InvalidHiveUsernameError) {
- return reply.code(400).send({
- error: 'Invalid Hive username.',
+ return reply.code(401).send({
+ error: 'Authentication failed.',
});
}
+ },
+ );
- return reply.code(401).send({
- error: 'Authentication failed.',
- });
- }
- });
-
- app.post('/auth/refresh', async (request, reply) => {
- if (!rateLimit(refreshRateLimits, clientRateLimitKey(request), 40, 60_000)) {
- return reply.code(429).send({
- error: 'Too many refresh attempts.',
- });
- }
-
- const parsed = refreshSchema.safeParse(request.body ?? {});
-
- if (!parsed.success) {
- return reply.code(400).send({
- error: 'Invalid refresh payload.',
- });
- }
-
- const bodyRefreshToken = parsed.data.refreshToken;
- const cookieRefreshToken = readCookie(request, getRefreshCookieName());
- const refreshToken = bodyRefreshToken ?? cookieRefreshToken;
-
- if (!refreshToken) {
- return reply.code(401).send({
- error: 'Authentication required.',
- });
- }
-
- if (!bodyRefreshToken && !isTrustedBrowserOrigin(request)) {
- return reply.code(403).send({
- error: 'Request origin is not allowed.',
- });
- }
-
- try {
- const issuedSession = await rotateRefreshSession(database, {
- ...sessionOptions(),
- refreshToken,
- });
+ app.post(
+ '/auth/refresh',
+ {
+ config: {
+ rateLimit: {
+ max: 40,
+ timeWindow: '1 minute',
+ },
+ },
+ },
+ async (request, reply) => {
+ const parsed = refreshSchema.safeParse(request.body ?? {});
- setRefreshCookie(reply, issuedSession.refreshToken, {
- ...cookieOptions(),
- maxAgeSeconds: env.AUTH_REFRESH_TOKEN_TTL_SECONDS,
- });
+ if (!parsed.success) {
+ return reply.code(400).send({
+ error: 'Invalid refresh payload.',
+ });
+ }
- return {
- accessToken: issuedSession.accessToken,
- expiresInSeconds: env.AUTH_ACCESS_TOKEN_TTL_SECONDS,
- user: issuedSession.user,
- };
- } catch (error) {
- clearRefreshCookie(reply, cookieOptions());
+ const bodyRefreshToken = parsed.data.refreshToken;
+ const cookieRefreshToken = readCookie(request, getRefreshCookieName());
+ const refreshToken = bodyRefreshToken ?? cookieRefreshToken;
- if (error instanceof RefreshTokenReuseError) {
+ if (!refreshToken) {
return reply.code(401).send({
error: 'Authentication required.',
});
}
- if (error instanceof InvalidRefreshTokenError) {
- return reply.code(401).send({
- error: 'Authentication required.',
+ if (!bodyRefreshToken && !isTrustedBrowserOrigin(request)) {
+ return reply.code(403).send({
+ error: 'Request origin is not allowed.',
});
}
- throw error;
- }
- });
+ try {
+ const issuedSession = await rotateRefreshSession(database, {
+ ...sessionOptions(),
+ refreshToken,
+ });
+
+ setRefreshCookie(reply, issuedSession.refreshToken, {
+ ...cookieOptions(),
+ maxAgeSeconds: env.AUTH_REFRESH_TOKEN_TTL_SECONDS,
+ });
+
+ return {
+ accessToken: issuedSession.accessToken,
+ expiresInSeconds: env.AUTH_ACCESS_TOKEN_TTL_SECONDS,
+ user: issuedSession.user,
+ };
+ } catch (error) {
+ clearRefreshCookie(reply, cookieOptions());
+
+ if (error instanceof RefreshTokenReuseError) {
+ return reply.code(401).send({
+ error: 'Authentication required.',
+ });
+ }
+
+ if (error instanceof InvalidRefreshTokenError) {
+ return reply.code(401).send({
+ error: 'Authentication required.',
+ });
+ }
+
+ throw error;
+ }
+ },
+ );
app.post('/auth/logout', async (request, reply) => {
const parsed = logoutSchema.safeParse(request.body ?? {});
@@ -355,7 +348,7 @@ export async function registerAuthRoutes(
if (refreshToken) {
const logoutInput = {
- refreshSecret: env.AUTH_JWT_SECRET,
+ refreshSecret: env.AUTH_REFRESH_SECRET,
refreshToken,
...(parsed.data.revokeFamily === undefined
? {}
@@ -381,13 +374,15 @@ export async function registerAuthRoutes(
{
preHandler: requireSession({
audience: env.AUTH_JWT_AUDIENCE,
+ database,
issuer: env.AUTH_JWT_ISSUER,
jwtSecret: env.AUTH_JWT_SECRET,
}),
},
async (request, reply) => {
- const authenticatedUser = authenticateRequest(request, {
+ const authenticatedUser = await authenticateRequest(request, {
audience: env.AUTH_JWT_AUDIENCE,
+ database,
issuer: env.AUTH_JWT_ISSUER,
jwtSecret: env.AUTH_JWT_SECRET,
});
diff --git a/apps/api/src/routes/contributions.test.ts b/apps/api/src/routes/contributions.test.ts
index 7d0e459..b7502a9 100644
--- a/apps/api/src/routes/contributions.test.ts
+++ b/apps/api/src/routes/contributions.test.ts
@@ -417,6 +417,19 @@ function createDatabase() {
return proposal;
},
},
+ refreshSession: {
+ async findUnique(args: { where: { id: string } }) {
+ if (!args.where.id.startsWith('session-')) {
+ return null;
+ }
+
+ return {
+ expiresAt: new Date('2026-08-11T13:00:00.000Z'),
+ revokedAt: null,
+ userId: args.where.id.replace('session-', ''),
+ };
+ },
+ },
world: {
async findUnique(args: { where: { id: string } }) {
return worlds.find((world) => world.id === args.where.id) ?? null;
diff --git a/apps/api/src/routes/contributions.ts b/apps/api/src/routes/contributions.ts
index 8229b6c..4ece80a 100644
--- a/apps/api/src/routes/contributions.ts
+++ b/apps/api/src/routes/contributions.ts
@@ -61,9 +61,10 @@ type RegisterContributionRoutesOptions = {
database?: ContributionDatabase & WorldMembershipLookup;
};
-function authOptions() {
+function authOptions(database: ContributionDatabase) {
return {
audience: env.AUTH_JWT_AUDIENCE,
+ database,
issuer: env.AUTH_JWT_ISSUER,
jwtSecret: env.AUTH_JWT_SECRET,
};
@@ -113,7 +114,7 @@ export async function registerContributionRoutes(
app.post(
'/worlds/:worldId/contributions',
{
- preHandler: requireSession(authOptions()),
+ preHandler: requireSession(authOptions(database)),
},
async (request, reply) => {
const params = routeParamsSchema.safeParse(request.params);
@@ -168,7 +169,7 @@ export async function registerContributionRoutes(
app.get(
'/worlds/:worldId/contributions',
{
- preHandler: requireSession(authOptions()),
+ preHandler: requireSession(authOptions(database)),
},
async (request, reply) => {
const params = routeParamsSchema.safeParse(request.params);
@@ -215,7 +216,7 @@ export async function registerContributionRoutes(
app.get(
'/worlds/:worldId/contributions/:contributionId',
{
- preHandler: requireSession(authOptions()),
+ preHandler: requireSession(authOptions(database)),
},
async (request, reply) => {
const params = routeParamsSchema.required().safeParse(request.params);
@@ -271,7 +272,7 @@ export async function registerContributionRoutes(
app.patch(
'/worlds/:worldId/contributions/:contributionId',
{
- preHandler: requireSession(authOptions()),
+ preHandler: requireSession(authOptions(database)),
},
async (request, reply) => {
const params = routeParamsSchema.required().safeParse(request.params);
@@ -327,7 +328,7 @@ export async function registerContributionRoutes(
app.delete(
'/worlds/:worldId/contributions/:contributionId',
{
- preHandler: requireSession(authOptions()),
+ preHandler: requireSession(authOptions(database)),
},
async (request, reply) => {
const params = routeParamsSchema.required().safeParse(request.params);
@@ -379,7 +380,7 @@ export async function registerContributionRoutes(
app.post(
'/worlds/:worldId/contributions/:contributionId/submit',
{
- preHandler: requireSession(authOptions()),
+ preHandler: requireSession(authOptions(database)),
},
async (request, reply) => {
const params = routeParamsSchema.required().safeParse(request.params);
diff --git a/apps/api/src/routes/proposals.ts b/apps/api/src/routes/proposals.ts
new file mode 100644
index 0000000..00b6cbd
--- /dev/null
+++ b/apps/api/src/routes/proposals.ts
@@ -0,0 +1,265 @@
+import type { FastifyInstance, FastifyReply } from 'fastify';
+import { z } from 'zod';
+
+import { env } from '../config/env.js';
+import { authenticateRequest, requireSession } from '../lib/auth-middleware.js';
+import {
+ CanonVotingError,
+ castCanonVote,
+ confirmCanonTransaction,
+ createCanonTransactionOperation,
+ finalizeCanonDecision,
+ getProposalDetail,
+ getVoteSummary,
+} from '../lib/canon-voting.js';
+import { HafClient } from '../lib/hive/haf-client.js';
+import { prisma } from '../lib/prisma.js';
+import { authorizeWorldPermission } from '../lib/world-authorization.js';
+import type { WorldMembershipLookup } from '../lib/world-authorization.js';
+import { WORLD_PERMISSIONS } from '../lib/world-permissions.js';
+
+const paramsSchema = z.object({
+ proposalId: z.string().min(1),
+ worldId: z.string().min(1),
+});
+
+const voteBodySchema = z
+ .object({
+ choice: z.enum(['APPROVE', 'REJECT', 'NEEDS_REVISION', 'ALTERNATE_TIMELINE']),
+ })
+ .strict();
+
+const confirmBodySchema = z
+ .object({
+ blockNumber: z.coerce.number().int().positive(),
+ operationIndex: z.coerce.number().int().nonnegative(),
+ transactionId: z.string().trim().min(1).max(128),
+ })
+ .strict();
+
+type RegisterProposalRoutesOptions = {
+ database?: typeof prisma & WorldMembershipLookup;
+ hafClient?: HafClient;
+};
+
+function authOptions(database: typeof prisma) {
+ return {
+ audience: env.AUTH_JWT_AUDIENCE,
+ database,
+ issuer: env.AUTH_JWT_ISSUER,
+ jwtSecret: env.AUTH_JWT_SECRET,
+ };
+}
+
+function handleCanonVotingError(error: unknown, reply: FastifyReply) {
+ if (error instanceof CanonVotingError) {
+ return reply.code(error.statusCode).send({
+ code: error.code,
+ error: error.message,
+ });
+ }
+
+ throw error;
+}
+
+export async function registerProposalRoutes(
+ app: FastifyInstance,
+ options: RegisterProposalRoutesOptions = {},
+) {
+ const database = options.database ?? prisma;
+ const hafClient = options.hafClient ?? new HafClient({ baseUrl: env.HAF_API_URL });
+
+ app.get('/worlds/:worldId/proposals/:proposalId', async (request, reply) => {
+ const params = paramsSchema.safeParse(request.params);
+
+ if (!params.success) {
+ return reply.code(400).send({
+ code: 'INVALID_PROPOSAL_ROUTE',
+ error: 'Invalid proposal route.',
+ });
+ }
+
+ const currentUser = await authenticateRequest(request, authOptions(database));
+
+ try {
+ const proposal = await getProposalDetail(database, {
+ currentUserId: currentUser?.id,
+ proposalId: params.data.proposalId,
+ worldId: params.data.worldId,
+ });
+
+ return {
+ proposal,
+ };
+ } catch (error) {
+ return handleCanonVotingError(error, reply);
+ }
+ });
+
+ app.get('/worlds/:worldId/proposals/:proposalId/votes/summary', async (request, reply) => {
+ const params = paramsSchema.safeParse(request.params);
+
+ if (!params.success) {
+ return reply.code(400).send({
+ code: 'INVALID_PROPOSAL_ROUTE',
+ error: 'Invalid proposal route.',
+ });
+ }
+
+ try {
+ return getVoteSummary(database, params.data);
+ } catch (error) {
+ return handleCanonVotingError(error, reply);
+ }
+ });
+
+ app.post(
+ '/worlds/:worldId/proposals/:proposalId/votes',
+ {
+ preHandler: requireSession(authOptions(database)),
+ },
+ async (request, reply) => {
+ const params = paramsSchema.safeParse(request.params);
+ const body = voteBodySchema.safeParse(request.body);
+
+ if (!params.success || !body.success) {
+ return reply.code(400).send({
+ code: 'INVALID_VOTE_PAYLOAD',
+ error: 'Invalid vote payload.',
+ });
+ }
+
+ const authorized = await authorizeWorldPermission(
+ request,
+ reply,
+ params.data.worldId,
+ WORLD_PERMISSIONS.VOTE_ON_PROPOSAL,
+ database,
+ );
+
+ if (!authorized || !request.user) {
+ return;
+ }
+
+ try {
+ return castCanonVote(database, {
+ choice: body.data.choice,
+ proposalId: params.data.proposalId,
+ voterId: request.user.id,
+ worldId: params.data.worldId,
+ });
+ } catch (error) {
+ return handleCanonVotingError(error, reply);
+ }
+ },
+ );
+
+ app.post(
+ '/worlds/:worldId/proposals/:proposalId/finalize',
+ {
+ preHandler: requireSession(authOptions(database)),
+ },
+ async (request, reply) => {
+ const params = paramsSchema.safeParse(request.params);
+
+ if (!params.success) {
+ return reply.code(400).send({
+ code: 'INVALID_PROPOSAL_ROUTE',
+ error: 'Invalid proposal route.',
+ });
+ }
+
+ const authorized = await authorizeWorldPermission(
+ request,
+ reply,
+ params.data.worldId,
+ WORLD_PERMISSIONS.EXECUTE_CANON_STATUS_AFTER_THRESHOLD,
+ database,
+ );
+
+ if (!authorized || !request.user) {
+ return;
+ }
+
+ try {
+ return finalizeCanonDecision(database, {
+ actorId: request.user.id,
+ proposalId: params.data.proposalId,
+ worldId: params.data.worldId,
+ });
+ } catch (error) {
+ return handleCanonVotingError(error, reply);
+ }
+ },
+ );
+
+ app.post(
+ '/worlds/:worldId/proposals/:proposalId/canon-transaction',
+ {
+ preHandler: requireSession(authOptions(database)),
+ },
+ async (request, reply) => {
+ const params = paramsSchema.safeParse(request.params);
+
+ if (!params.success) {
+ return reply.code(400).send({
+ code: 'INVALID_PROPOSAL_ROUTE',
+ error: 'Invalid proposal route.',
+ });
+ }
+
+ if (!request.user) {
+ return reply.code(401).send({
+ code: 'AUTHENTICATION_REQUIRED',
+ error: 'Authentication required.',
+ });
+ }
+
+ try {
+ return createCanonTransactionOperation(database, {
+ proposalId: params.data.proposalId,
+ signerId: request.user.id,
+ worldId: params.data.worldId,
+ });
+ } catch (error) {
+ return handleCanonVotingError(error, reply);
+ }
+ },
+ );
+
+ app.post(
+ '/worlds/:worldId/proposals/:proposalId/canon-transaction/confirm',
+ {
+ preHandler: requireSession(authOptions(database)),
+ },
+ async (request, reply) => {
+ const params = paramsSchema.safeParse(request.params);
+ const body = confirmBodySchema.safeParse(request.body);
+
+ if (!params.success || !body.success) {
+ return reply.code(400).send({
+ code: 'INVALID_CONFIRMATION_PAYLOAD',
+ error: 'Invalid canon transaction confirmation payload.',
+ });
+ }
+
+ if (!request.user) {
+ return reply.code(401).send({
+ code: 'AUTHENTICATION_REQUIRED',
+ error: 'Authentication required.',
+ });
+ }
+
+ try {
+ return confirmCanonTransaction(database, {
+ ...body.data,
+ hafClient,
+ proposalId: params.data.proposalId,
+ worldId: params.data.worldId,
+ });
+ } catch (error) {
+ return handleCanonVotingError(error, reply);
+ }
+ },
+ );
+}
diff --git a/apps/api/src/routes/worlds.test.ts b/apps/api/src/routes/worlds.test.ts
index 2419868..6453e37 100644
--- a/apps/api/src/routes/worlds.test.ts
+++ b/apps/api/src/routes/worlds.test.ts
@@ -12,6 +12,7 @@ import {
WorldRole,
} from '../generated/prisma/enums.js';
import { signAccessToken } from '../lib/auth-crypto.js';
+import { createWorld, updateWorld } from '../lib/worlds.js';
import { registerWorldRoutes } from './worlds.js';
const founder = {
@@ -36,7 +37,7 @@ function authHeader(userId = founder.id) {
hiveUsername: founder.hiveUsername,
normalizedHiveUsername: founder.normalizedHiveUsername,
platformRole: PlatformRole.USER,
- sid: 'session-1',
+ sid: `session-${userId}`,
sub: userId,
},
{
@@ -136,14 +137,101 @@ function createDatabase() {
worldId: string;
status: ProposalStatus;
}> = [];
+ let failNextAuditCreate = false;
+ let failNextSeedCreate = false;
+ let failNextWorldCreateWithSlugConflict = false;
+
+ function cloneState() {
+ return {
+ auditLogs: [...auditLogs],
+ bibleVersions: bibleVersions.map((version) => ({ ...version })),
+ memberships: memberships.map((membership) => ({ ...membership })),
+ seeds: seeds.map((seed) => ({ ...seed })),
+ worlds: worlds.map((world) => ({ ...world })),
+ };
+ }
+
+ function restoreState(snapshot: ReturnType) {
+ auditLogs.splice(0, auditLogs.length, ...snapshot.auditLogs);
+ bibleVersions.splice(0, bibleVersions.length, ...snapshot.bibleVersions);
+ memberships.splice(0, memberships.length, ...snapshot.memberships);
+ seeds.splice(0, seeds.length, ...snapshot.seeds);
+ worlds.splice(0, worlds.length, ...snapshot.worlds);
+ }
+
+ function matchesTextFilter(value: string, filter?: { contains?: string; equals?: string }) {
+ if (!filter) {
+ return true;
+ }
+
+ if (filter.contains) {
+ return value.toLowerCase().includes(filter.contains.toLowerCase());
+ }
+
+ if (filter.equals) {
+ return value.toLowerCase() === filter.equals.toLowerCase();
+ }
+
+ return true;
+ }
+
+ function matchesWorldWhere(
+ world: StoredWorld,
+ where?: {
+ OR?: Array<{ title?: { contains: string }; description?: { contains: string } }>;
+ seed?: { genre?: { equals: string }; tone?: { equals: string } };
+ },
+ ) {
+ if (!where) {
+ return true;
+ }
+
+ if (
+ where.OR &&
+ !where.OR.some(
+ (filter) =>
+ (filter.title ? matchesTextFilter(world.title, filter.title) : false) ||
+ (filter.description ? matchesTextFilter(world.description, filter.description) : false),
+ )
+ ) {
+ return false;
+ }
+
+ const seed = seeds.find((candidate) => candidate.worldId === world.id);
+
+ if (where.seed?.genre && !seed) {
+ return false;
+ }
+
+ if (seed && !matchesTextFilter(seed.genre, where.seed?.genre)) {
+ return false;
+ }
+
+ if (seed && !matchesTextFilter(seed.tone, where.seed?.tone)) {
+ return false;
+ }
+
+ return true;
+ }
+
+ function includeWorld(world: StoredWorld, includeContent = true) {
+ const versions = bibleVersions
+ .filter((version) => version.worldId === world.id)
+ .sort((left, right) => right.versionNumber - left.versionNumber)
+ .slice(0, 1)
+ .map((version) => {
+ if (includeContent) {
+ return version;
+ }
+
+ const summary: Partial = { ...version };
+ delete summary.content;
+ return summary;
+ });
- function includeWorld(world: StoredWorld) {
return {
...world,
- bibleVersions: bibleVersions
- .filter((version) => version.worldId === world.id)
- .sort((left, right) => right.versionNumber - left.versionNumber)
- .slice(0, 1),
+ bibleVersions: versions,
founder: world.founder,
seed: seeds.find((seed) => seed.worldId === world.id) ?? null,
};
@@ -151,7 +239,14 @@ function createDatabase() {
const database = {
async $transaction(this: unknown, callback: (transaction: never) => Promise) {
- return callback(this as never);
+ const snapshot = cloneState();
+
+ try {
+ return await callback(this as never);
+ } catch (error) {
+ restoreState(snapshot);
+ throw error;
+ }
},
loreEntry: {
async count(args: { where: { worldId: string; status?: LoreStatus } }) {
@@ -181,8 +276,8 @@ function createDatabase() {
},
},
world: {
- async count() {
- return worlds.length;
+ async count(args?: { where?: Parameters[1] }) {
+ return worlds.filter((world) => matchesWorldWhere(world, args?.where)).length;
},
async create(args: {
data: {
@@ -192,6 +287,25 @@ function createDatabase() {
title: string;
};
}) {
+ if (failNextWorldCreateWithSlugConflict) {
+ failNextWorldCreateWithSlugConflict = false;
+ throw {
+ code: 'P2002',
+ meta: {
+ target: ['slug'],
+ },
+ };
+ }
+
+ if (worlds.some((world) => world.slug === args.data.slug)) {
+ throw {
+ code: 'P2002',
+ meta: {
+ target: ['slug'],
+ },
+ };
+ }
+
const world = createWorldRecord({
description: args.data.description,
founderId: args.data.founderId,
@@ -202,8 +316,27 @@ function createDatabase() {
worlds.push(world);
return world;
},
- async findMany(args: { take: number; skip: number }) {
- return worlds.slice(args.skip, args.skip + args.take).map(includeWorld);
+ async findMany(args: {
+ include?: { bibleVersions?: { select?: Record } };
+ take: number;
+ skip: number;
+ where?: Parameters[1];
+ }) {
+ const includeContent = Boolean(args.include?.bibleVersions?.select?.content);
+
+ return worlds
+ .filter((world) => matchesWorldWhere(world, args.where))
+ .sort((left, right) => {
+ const createdAtOrder = right.createdAt.getTime() - left.createdAt.getTime();
+
+ if (createdAtOrder !== 0) {
+ return createdAtOrder;
+ }
+
+ return right.id.localeCompare(left.id);
+ })
+ .slice(args.skip, args.skip + args.take)
+ .map((world) => includeWorld(world, includeContent));
},
async findUnique(args: { where: { id?: string; slug?: string } }) {
const world = worlds.find((candidate) => {
@@ -244,6 +377,11 @@ function createDatabase() {
},
worldAuditLog: {
async create(args: { data: unknown }) {
+ if (failNextAuditCreate) {
+ failNextAuditCreate = false;
+ throw new Error('Injected audit failure.');
+ }
+
auditLogs.push(args.data);
return args.data;
},
@@ -330,6 +468,11 @@ function createDatabase() {
worldId: string;
};
}) {
+ if (failNextSeedCreate) {
+ failNextSeedCreate = false;
+ throw new Error('Injected seed failure.');
+ }
+
const seed = {
...args.data,
createdAt: new Date('2026-08-05T00:00:00.000Z'),
@@ -353,6 +496,19 @@ function createDatabase() {
return seed;
},
},
+ refreshSession: {
+ async findUnique(args: { where: { id: string } }) {
+ if (!args.where.id.startsWith('session-')) {
+ return null;
+ }
+
+ return {
+ expiresAt: new Date('2026-08-11T01:00:00.000Z'),
+ revokedAt: null,
+ userId: args.where.id.replace('session-', ''),
+ };
+ },
+ },
};
return {
@@ -363,6 +519,15 @@ function createDatabase() {
memberships,
proposals,
seeds,
+ failNextAuditCreate() {
+ failNextAuditCreate = true;
+ },
+ failNextSeedCreate() {
+ failNextSeedCreate = true;
+ },
+ failNextWorldCreateWithSlugConflict() {
+ failNextWorldCreateWithSlugConflict = true;
+ },
worlds,
};
}
@@ -448,10 +613,31 @@ describe('world routes', () => {
await app.close();
});
- test('rejects non-json world bible content', async () => {
+ test('authenticated creation succeeds and ignores client identity and role fields', async () => {
const state = createDatabase();
const app = await createApp(state.database);
const response = await app.inject({
+ headers: authHeader(),
+ method: 'POST',
+ payload: {
+ ...worldPayload,
+ founderId: contributor.id,
+ role: WorldRole.CURATOR,
+ },
+ url: '/worlds',
+ });
+
+ assert.equal(response.statusCode, 201);
+ assert.equal(response.json().world.founderId, founder.id);
+ assert.equal(state.memberships[0]?.userId, founder.id);
+ assert.equal(state.memberships[0]?.role, WorldRole.FOUNDER);
+ await app.close();
+ });
+
+ test('rejects invalid, null, oversized, and deeply nested world bible content', async () => {
+ const state = createDatabase();
+ const app = await createApp(state.database);
+ const missingContent = await app.inject({
headers: authHeader(),
method: 'POST',
payload: {
@@ -462,8 +648,52 @@ describe('world routes', () => {
},
url: '/worlds',
});
+ const nullContent = await app.inject({
+ headers: authHeader(),
+ method: 'POST',
+ payload: {
+ ...worldPayload,
+ bible: {
+ content: null,
+ },
+ },
+ url: '/worlds',
+ });
+ const oversized = await app.inject({
+ headers: authHeader(),
+ method: 'POST',
+ payload: {
+ ...worldPayload,
+ bible: {
+ content: {
+ text: 'x'.repeat(101 * 1024),
+ },
+ },
+ },
+ url: '/worlds',
+ });
+ let deeplyNested: unknown = 'bottom';
- assert.equal(response.statusCode, 400);
+ for (let depth = 0; depth < 42; depth += 1) {
+ deeplyNested = { child: deeplyNested };
+ }
+
+ const nested = await app.inject({
+ headers: authHeader(),
+ method: 'POST',
+ payload: {
+ ...worldPayload,
+ bible: {
+ content: deeplyNested,
+ },
+ },
+ url: '/worlds',
+ });
+
+ assert.equal(missingContent.statusCode, 400);
+ assert.equal(nullContent.statusCode, 400);
+ assert.equal(oversized.statusCode, 400);
+ assert.equal(nested.statusCode, 400);
assert.equal(state.worlds.length, 0);
await app.close();
});
@@ -516,7 +746,12 @@ describe('world routes', () => {
assert.equal(listResponse.statusCode, 200);
assert.equal(listResponse.json().pagination.total, 1);
+ assert.equal('content' in listResponse.json().worlds[0].currentBibleVersion, false);
assert.equal(getResponse.statusCode, 200);
+ assert.deepEqual(
+ getResponse.json().world.currentBibleVersion.content,
+ worldPayload.bible.content,
+ );
assert.equal(getResponse.json().world.seed.mainConflict, worldPayload.seed.mainConflict);
assert.equal(hubResponse.statusCode, 200);
assert.equal(hubResponse.json().stats.canonLoreCount, 1);
@@ -532,6 +767,46 @@ describe('world routes', () => {
await app.close();
});
+ test('filters worlds by q, visibility fields, pagination, and consistent counts', async () => {
+ const state = createDatabase();
+ const app = await createApp(state.database);
+ await app.inject({
+ headers: authHeader(),
+ method: 'POST',
+ payload: worldPayload,
+ url: '/worlds',
+ });
+ await app.inject({
+ headers: authHeader(),
+ method: 'POST',
+ payload: {
+ ...worldPayload,
+ description: 'Sunlit city state.',
+ seed: {
+ ...worldPayload.seed,
+ genre: 'Science Fantasy',
+ tone: 'Bright',
+ },
+ title: 'Solar Foundry',
+ },
+ url: '/worlds',
+ });
+
+ const search = await app.inject({ method: 'GET', url: '/worlds?q=moon&page=1&pageSize=10' });
+ const filtered = await app.inject({
+ method: 'GET',
+ url: '/worlds?genre=fantasy&tone=mystic%20wonder&page=1&pageSize=1',
+ });
+
+ assert.deepEqual(
+ search.json().worlds.map((world: { title: string }) => world.title),
+ ['Moon Archive'],
+ );
+ assert.deepEqual(filtered.json().pagination, { page: 1, pageSize: 1, total: 1 });
+ assert.equal(filtered.json().worlds.length, 1);
+ await app.close();
+ });
+
test('allows a founder to update mutable off-chain world seed and bible data', async () => {
const state = createDatabase();
const app = await createApp(state.database);
@@ -567,9 +842,66 @@ describe('world routes', () => {
rules: ['Keep the moon archive coherent.'],
});
assert.equal(response.json().world.currentBibleVersion.publishedAt, null);
+ assert.equal(
+ state.auditLogs.some(
+ (entry) =>
+ (entry as { action?: WorldAuditAction }).action === WorldAuditAction.WORLD_BIBLE_UPDATED,
+ ),
+ true,
+ );
await app.close();
});
+ test('rejects published and Hive-anchored bible updates without audit records', async () => {
+ const publishedState = createDatabase();
+ const publishedApp = await createApp(publishedState.database);
+ await publishedApp.inject({
+ headers: authHeader(),
+ method: 'POST',
+ payload: worldPayload,
+ url: '/worlds',
+ });
+ publishedState.bibleVersions[0]!.publishedAt = new Date('2026-08-05T00:05:00.000Z');
+
+ const publishedResponse = await publishedApp.inject({
+ headers: authHeader(),
+ method: 'PATCH',
+ payload: { bible: { content: { rules: ['No'] } } },
+ url: '/worlds/world-1',
+ });
+
+ const anchoredState = createDatabase();
+ const anchoredApp = await createApp(anchoredState.database);
+ await anchoredApp.inject({
+ headers: authHeader(),
+ method: 'POST',
+ payload: worldPayload,
+ url: '/worlds',
+ });
+ anchoredState.bibleVersions[0]!.hiveReferenceId = 'hive-1';
+
+ const anchoredResponse = await anchoredApp.inject({
+ headers: authHeader(),
+ method: 'PATCH',
+ payload: { bible: { content: { rules: ['No'] } } },
+ url: '/worlds/world-1',
+ });
+
+ assert.equal(publishedResponse.statusCode, 409);
+ assert.equal(anchoredResponse.statusCode, 409);
+ assert.deepEqual(publishedState.bibleVersions[0]!.content, worldPayload.bible.content);
+ assert.deepEqual(anchoredState.bibleVersions[0]!.content, worldPayload.bible.content);
+ assert.equal(
+ publishedState.auditLogs.some(
+ (entry) =>
+ (entry as { action?: WorldAuditAction }).action === WorldAuditAction.WORLD_BIBLE_UPDATED,
+ ),
+ false,
+ );
+ await publishedApp.close();
+ await anchoredApp.close();
+ });
+
test('rejects world updates without founder permissions', async () => {
const state = createDatabase();
const app = await createApp(state.database);
@@ -592,4 +924,115 @@ describe('world routes', () => {
assert.equal(response.statusCode, 403);
await app.close();
});
+
+ test('maps slug conflicts to 409 and rolls back failed creation steps', async () => {
+ const conflictState = createDatabase();
+ const app = await createApp(conflictState.database);
+ const [first, second] = await Promise.all([
+ app.inject({ headers: authHeader(), method: 'POST', payload: worldPayload, url: '/worlds' }),
+ app.inject({ headers: authHeader(), method: 'POST', payload: worldPayload, url: '/worlds' }),
+ ]);
+
+ assert.equal(first.statusCode, 201);
+ assert.equal(second.statusCode, 201);
+ assert.deepEqual(
+ conflictState.worlds.map((world) => world.slug),
+ ['moon-archive', 'moon-archive-2'],
+ );
+
+ const rollbackState = createDatabase();
+ rollbackState.failNextSeedCreate();
+
+ await assert.rejects(
+ createWorld(rollbackState.database as never, {
+ ...worldPayload,
+ creatorId: founder.id,
+ }),
+ );
+
+ assert.equal(rollbackState.worlds.length, 0);
+ assert.equal(rollbackState.seeds.length, 0);
+ assert.equal(rollbackState.bibleVersions.length, 0);
+ await app.close();
+ });
+
+ test('translates database world slug uniqueness conflicts to 409', async () => {
+ const state = createDatabase();
+ const app = await createApp(state.database);
+ state.failNextWorldCreateWithSlugConflict();
+
+ const response = await app.inject({
+ headers: authHeader(),
+ method: 'POST',
+ payload: worldPayload,
+ url: '/worlds',
+ });
+
+ assert.equal(response.statusCode, 409);
+ assert.equal(response.json().error, 'World slug is already in use.');
+ assert.equal(state.worlds.length, 0);
+ await app.close();
+ });
+
+ test('rolls back bible updates when audit creation fails', async () => {
+ const state = createDatabase();
+ await createWorld(state.database as never, { ...worldPayload, creatorId: founder.id });
+ state.failNextAuditCreate();
+
+ await assert.rejects(
+ updateWorld(state.database as never, {
+ actorId: founder.id,
+ bible: {
+ content: {
+ rules: ['Rollback me.'],
+ },
+ },
+ worldId: 'world-1',
+ }),
+ );
+
+ assert.deepEqual(state.bibleVersions[0]!.content, worldPayload.bible.content);
+ assert.equal(
+ state.auditLogs.some(
+ (entry) =>
+ (entry as { action?: WorldAuditAction }).action === WorldAuditAction.WORLD_BIBLE_UPDATED,
+ ),
+ false,
+ );
+ });
+
+ test('returns typed errors for worlds missing seed or bible rows', async () => {
+ const seedState = createDatabase();
+ await createWorld(seedState.database as never, { ...worldPayload, creatorId: founder.id });
+ seedState.seeds.length = 0;
+
+ await assert.rejects(
+ updateWorld(seedState.database as never, {
+ actorId: founder.id,
+ seed: {
+ tone: 'No seed',
+ },
+ worldId: 'world-1',
+ }),
+ (error: unknown) => error instanceof Error && error.message === 'World seed is missing.',
+ );
+
+ const bibleState = createDatabase();
+ await createWorld(bibleState.database as never, { ...worldPayload, creatorId: founder.id });
+ bibleState.bibleVersions.length = 0;
+
+ await assert.rejects(
+ updateWorld(bibleState.database as never, {
+ actorId: founder.id,
+ bible: {
+ content: {
+ rules: [],
+ },
+ },
+ worldId: 'world-1',
+ }),
+ (error: unknown) =>
+ error instanceof Error && error.message === 'World bible version is missing.',
+ );
+ });
});
diff --git a/apps/api/src/routes/worlds.ts b/apps/api/src/routes/worlds.ts
index 85dae37..dd442c4 100644
--- a/apps/api/src/routes/worlds.ts
+++ b/apps/api/src/routes/worlds.ts
@@ -11,6 +11,7 @@ import {
getWorldHub,
listWorlds,
updateWorld,
+ WorldError,
type WorldDatabase,
type WorldSeedInput,
} from '../lib/worlds.js';
@@ -23,40 +24,133 @@ const paramsSchema = z.object({
});
const stringListSchema = z.array(z.string().trim().min(1).max(120)).max(20);
+const WORLD_BIBLE_MAX_BYTES = 100 * 1024;
+const WORLD_BIBLE_MAX_DEPTH = 40;
-function isJsonValue(value: unknown): value is Prisma.InputJsonValue {
+type JsonValidationResult =
+ | {
+ ok: true;
+ value: Prisma.InputJsonValue;
+ }
+ | {
+ ok: false;
+ message: string;
+ };
+
+function validateJsonValue(value: unknown): JsonValidationResult {
if (value === null) {
- return true;
+ return {
+ message: 'World bible content cannot be null.',
+ ok: false,
+ };
}
- if (typeof value === 'string' || typeof value === 'boolean') {
- return true;
- }
+ const pending: Array<{ value: unknown; depth: number }> = [{ depth: 0, value }];
- if (typeof value === 'number') {
- return Number.isFinite(value);
- }
+ while (pending.length > 0) {
+ const item = pending.pop()!;
- if (Array.isArray(value)) {
- return value.every(isJsonValue);
- }
+ if (item.depth > WORLD_BIBLE_MAX_DEPTH) {
+ return {
+ message: 'World bible content exceeds the maximum nesting depth.',
+ ok: false,
+ };
+ }
+
+ if (item.value === null) {
+ continue;
+ }
- if (typeof value === 'object') {
- const prototype = Object.getPrototypeOf(value);
+ if (typeof item.value === 'string' || typeof item.value === 'boolean') {
+ continue;
+ }
+
+ if (typeof item.value === 'number') {
+ if (!Number.isFinite(item.value)) {
+ return {
+ message: 'World bible content must be valid JSON.',
+ ok: false,
+ };
+ }
- if (prototype !== Object.prototype && prototype !== null) {
- return false;
+ continue;
}
- return Object.values(value).every(isJsonValue);
+ if (Array.isArray(item.value)) {
+ for (const child of item.value) {
+ pending.push({ depth: item.depth + 1, value: child });
+ }
+
+ continue;
+ }
+
+ if (typeof item.value === 'object') {
+ const prototype = Object.getPrototypeOf(item.value);
+
+ if (prototype !== Object.prototype && prototype !== null) {
+ return {
+ message: 'World bible content must be valid JSON.',
+ ok: false,
+ };
+ }
+
+ for (const child of Object.values(item.value)) {
+ if (child === undefined) {
+ return {
+ message: 'World bible content must be valid JSON.',
+ ok: false,
+ };
+ }
+
+ pending.push({ depth: item.depth + 1, value: child });
+ }
+
+ continue;
+ }
+
+ return {
+ message: 'World bible content must be valid JSON.',
+ ok: false,
+ };
}
- return false;
+ let serialized: string;
+
+ try {
+ serialized = JSON.stringify(value);
+ } catch {
+ return {
+ message: 'World bible content must be valid JSON.',
+ ok: false,
+ };
+ }
+
+ if (!serialized || Buffer.byteLength(serialized, 'utf8') > WORLD_BIBLE_MAX_BYTES) {
+ return {
+ message: 'World bible content exceeds the 100 KB limit.',
+ ok: false,
+ };
+ }
+
+ return {
+ ok: true,
+ value: value as Prisma.InputJsonValue,
+ };
}
-const jsonValueSchema = z.custom(isJsonValue, {
- message: 'World bible content must be valid JSON.',
-});
+const jsonValueSchema = z
+ .unknown()
+ .superRefine((value, context) => {
+ const validation = validateJsonValue(value);
+
+ if (!validation.ok) {
+ context.addIssue({
+ code: 'custom',
+ message: validation.message,
+ });
+ }
+ })
+ .transform((value) => value as Prisma.InputJsonValue);
const worldSeedSchema = z.object({
firstCharacters: stringListSchema.optional(),
@@ -104,14 +198,28 @@ type RegisterWorldRoutesOptions = {
database?: WorldDatabase & WorldMembershipLookup;
};
-function authOptions() {
+function authOptions(database: WorldDatabase) {
return {
audience: env.AUTH_JWT_AUDIENCE,
+ database,
issuer: env.AUTH_JWT_ISSUER,
jwtSecret: env.AUTH_JWT_SECRET,
};
}
+function handleWorldError(
+ error: unknown,
+ reply: { code(statusCode: number): { send(body: unknown): unknown } },
+) {
+ if (error instanceof WorldError) {
+ return reply.code(error.statusCode).send({
+ error: error.message,
+ });
+ }
+
+ throw error;
+}
+
export async function registerWorldRoutes(
app: FastifyInstance,
options: RegisterWorldRoutesOptions = {},
@@ -121,7 +229,7 @@ export async function registerWorldRoutes(
app.post(
'/worlds',
{
- preHandler: requireSession(authOptions()),
+ preHandler: requireSession(authOptions(database)),
},
async (request, reply) => {
const body = createWorldSchema.safeParse(request.body);
@@ -132,7 +240,7 @@ export async function registerWorldRoutes(
});
}
- const authenticatedUser = authenticateRequest(request, authOptions());
+ const authenticatedUser = await authenticateRequest(request, authOptions(database));
if (!authenticatedUser) {
return reply.code(401).send({
@@ -140,18 +248,22 @@ export async function registerWorldRoutes(
});
}
- const world = await createWorld(database, {
- ...body.data,
- bible: {
- ...body.data.bible,
- content: body.data.bible.content,
- },
- creatorId: authenticatedUser.id,
- });
+ try {
+ const world = await createWorld(database, {
+ ...body.data,
+ bible: {
+ ...body.data.bible,
+ content: body.data.bible.content,
+ },
+ creatorId: authenticatedUser.id,
+ });
- return reply.code(201).send({
- world,
- });
+ return reply.code(201).send({
+ world,
+ });
+ } catch (error) {
+ return handleWorldError(error, reply);
+ }
},
);
@@ -212,7 +324,7 @@ export async function registerWorldRoutes(
app.patch(
'/worlds/:worldId',
{
- preHandler: requireSession(authOptions()),
+ preHandler: requireSession(authOptions(database)),
},
async (request, reply) => {
const params = paramsSchema.safeParse(request.params);
@@ -236,30 +348,35 @@ export async function registerWorldRoutes(
return;
}
- const world = await updateWorld(database, {
- ...(body.data.description ? { description: body.data.description } : {}),
- ...(body.data.seed ? { seed: body.data.seed as Partial } : {}),
- ...(body.data.title ? { title: body.data.title } : {}),
- ...(body.data.bible
- ? {
- bible: {
- ...body.data.bible,
- content: body.data.bible.content,
- },
- }
- : {}),
- worldId: params.data.worldId,
- });
-
- if (!world) {
- return reply.code(404).send({
- error: 'World not found.',
+ if (!request.user) {
+ return reply.code(401).send({
+ error: 'Authentication required.',
});
}
- return {
- world,
- };
+ try {
+ const world = await updateWorld(database, {
+ actorId: request.user.id,
+ ...(body.data.description ? { description: body.data.description } : {}),
+ ...(body.data.seed ? { seed: body.data.seed as Partial } : {}),
+ ...(body.data.title ? { title: body.data.title } : {}),
+ ...(body.data.bible
+ ? {
+ bible: {
+ ...body.data.bible,
+ content: body.data.bible.content,
+ },
+ }
+ : {}),
+ worldId: params.data.worldId,
+ });
+
+ return {
+ world,
+ };
+ } catch (error) {
+ return handleWorldError(error, reply);
+ }
},
);
}
diff --git a/apps/web/src/app/login/page.tsx b/apps/web/src/app/login/page.tsx
index 530bb3f..56adf88 100644
--- a/apps/web/src/app/login/page.tsx
+++ b/apps/web/src/app/login/page.tsx
@@ -1,5 +1,5 @@
-import { PlaceholderPage } from '@/components/layout/placeholder-page';
+import Home from '../page';
export default function LoginPage() {
- return ;
+ return ;
}
diff --git a/apps/web/src/app/page.tsx b/apps/web/src/app/page.tsx
index 49ec544..af50030 100644
--- a/apps/web/src/app/page.tsx
+++ b/apps/web/src/app/page.tsx
@@ -71,6 +71,12 @@ function signWithKeychain(username: string, message: string) {
});
}
+async function sha256Hex(value: string) {
+ const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(value));
+
+ return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, '0')).join('');
+}
+
export default function Home() {
const [challenge, setChallenge] = useState(null);
const [error, setError] = useState(null);
@@ -132,7 +138,8 @@ export default function Home() {
try {
const nextChallenge = await createAuthChallenge(username, 'keychain');
- const signature = await signWithKeychain(nextChallenge.hiveUsername, nextChallenge.message);
+ const signaturePayload = await sha256Hex(nextChallenge.message);
+ const signature = await signWithKeychain(nextChallenge.hiveUsername, signaturePayload);
await completeLogin(nextChallenge, signature.result ?? '', signature.publicKey);
} catch (nextError) {
diff --git a/apps/web/src/lib/api/proposals.ts b/apps/web/src/lib/api/proposals.ts
new file mode 100644
index 0000000..4852740
--- /dev/null
+++ b/apps/web/src/lib/api/proposals.ts
@@ -0,0 +1,165 @@
+import { apiClient } from '@/lib/api/client';
+
+export type VoteChoice = 'APPROVE' | 'REJECT' | 'NEEDS_REVISION' | 'ALTERNATE_TIMELINE';
+
+export type ProposalDecision = {
+ aiWarningAcknowledged: boolean;
+ aiWarningSummary: string | null;
+ approvalDenominator: number;
+ approvalNumerator: number;
+ approvalPercentageBps: number;
+ approveCount: number;
+ alternateTimelineCount: number;
+ blockchainTimestamp: string | null;
+ blockNumber: string | null;
+ contentHash: string;
+ customJsonId: string | null;
+ decidedAt: string;
+ decisionPayloadHash: string;
+ expectedSigner: string | null;
+ hiveEventId: string | null;
+ id: string;
+ needsRevisionCount: number;
+ operationIndex: number | null;
+ outcome:
+ | 'APPROVED_FOR_PUBLICATION'
+ | 'REJECTED'
+ | 'NEEDS_REVISION'
+ | 'ALTERNATE_TIMELINE'
+ | 'STALE_BASE_CONFLICT';
+ rejectCount: number;
+ totalVotes: number;
+ transactionId: string | null;
+};
+
+export type ProposalDetail = {
+ aiWarning: {
+ acknowledged: boolean;
+ summary: string | null;
+ };
+ author: {
+ hiveUsername: string;
+ id: string;
+ };
+ branchBaseLoreEntryId: string | null;
+ branchLabel: string | null;
+ branchParentProposalId: string | null;
+ conflictMetadata: unknown;
+ currentUserVote: {
+ choice: VoteChoice;
+ updatedAt: string;
+ } | null;
+ decision: ProposalDecision | null;
+ id: string;
+ proposedContent: unknown;
+ proposalType: string;
+ status: string;
+ submittedAt: string | null;
+ summary: string;
+ tally: {
+ alternateTimeline: number;
+ approvalDenominator: number;
+ approvalNumerator: number;
+ approvalPercentageBps: number;
+ approve: number;
+ needsRevision: number;
+ reject: number;
+ totalVotes: number;
+ };
+ title: string;
+ votingEndsAt: string | null;
+ votingStartedAt: string | null;
+ world: {
+ id: string;
+ title: string;
+ };
+ worldId: string;
+};
+
+export type CanonTransactionResponse = {
+ customJsonId: string;
+ decisionId: string;
+ operation: unknown;
+ signer: string;
+};
+
+function authHeaders(accessToken: string) {
+ return {
+ authorization: `Bearer ${accessToken}`,
+ };
+}
+
+export function getProposal(worldId: string, proposalId: string, accessToken?: string) {
+ return apiClient.get<{ proposal: ProposalDetail }>(
+ `/worlds/${worldId}/proposals/${proposalId}`,
+ accessToken
+ ? {
+ headers: authHeaders(accessToken),
+ }
+ : undefined,
+ );
+}
+
+export function castProposalVote(input: {
+ accessToken: string;
+ choice: VoteChoice;
+ proposalId: string;
+ worldId: string;
+}) {
+ return apiClient.post(
+ `/worlds/${input.worldId}/proposals/${input.proposalId}/votes`,
+ { choice: input.choice },
+ {
+ headers: authHeaders(input.accessToken),
+ },
+ );
+}
+
+export function finalizeProposal(input: {
+ accessToken: string;
+ proposalId: string;
+ worldId: string;
+}) {
+ return apiClient.post<{ decision: ProposalDecision; idempotent: boolean }>(
+ `/worlds/${input.worldId}/proposals/${input.proposalId}/finalize`,
+ {},
+ {
+ headers: authHeaders(input.accessToken),
+ },
+ );
+}
+
+export function createCanonTransaction(input: {
+ accessToken: string;
+ proposalId: string;
+ worldId: string;
+}) {
+ return apiClient.post(
+ `/worlds/${input.worldId}/proposals/${input.proposalId}/canon-transaction`,
+ {},
+ {
+ headers: authHeaders(input.accessToken),
+ },
+ );
+}
+
+export function confirmCanonTransaction(input: {
+ accessToken: string;
+ blockNumber: number;
+ operationIndex: number;
+ proposalId: string;
+ transactionId: string;
+ worldId: string;
+}) {
+ return apiClient.post<{ decision: ProposalDecision; idempotent: boolean }>(
+ `/worlds/${input.worldId}/proposals/${input.proposalId}/canon-transaction/confirm`,
+ {
+ blockNumber: input.blockNumber,
+ operationIndex: input.operationIndex,
+ transactionId: input.transactionId,
+ },
+ {
+ headers: authHeaders(input.accessToken),
+ },
+ );
+}
diff --git a/package-lock.json b/package-lock.json
index dda52d5..c4647dc 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -44,6 +44,7 @@
"version": "0.1.0",
"dependencies": {
"@fastify/cors": "^11.0.1",
+ "@fastify/rate-limit": "^11.2.0",
"@hiveio/dhive": "^1.3.6",
"@hiveio/wax": "^2.0.2",
"@prisma/adapter-pg": "^7.8.0",
@@ -1103,6 +1104,28 @@
"ipaddr.js": "^2.1.0"
}
},
+ "node_modules/@fastify/rate-limit": {
+ "version": "11.2.0",
+ "resolved": "https://registry.npmjs.org/@fastify/rate-limit/-/rate-limit-11.2.0.tgz",
+ "integrity": "sha512-X7osJd4XSvMoejYrnJkSZYYjY1eNYoBqhjlzf1RakC2204qExFqZFTKj5+T7VuzA/iUI9Z3UoSqQRkB2HpG0oQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fastify"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/fastify"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "@lukeed/ms": "^2.0.2",
+ "fastify-plugin": "^6.0.0",
+ "ip-address": "^10.2.0",
+ "toad-cache": "^3.7.0"
+ }
+ },
"node_modules/@floating-ui/core": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.8.0.tgz",
@@ -1313,6 +1336,15 @@
"devOptional": true,
"license": "MIT"
},
+ "node_modules/@lukeed/ms": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/@lukeed/ms/-/ms-2.0.2.tgz",
+ "integrity": "sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/@next/eslint-plugin-next": {
"version": "15.5.20",
"resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-15.5.20.tgz",
@@ -2417,16 +2449,14 @@
"license": "MIT",
"dependencies": {
"csstype": "^3.2.2"
- },
- "devOptional": true
+ }
},
"node_modules/@types/react-dom": {
"version": "19.2.3",
"license": "MIT",
"peerDependencies": {
"@types/react": "^19.2.0"
- },
- "devOptional": true
+ }
},
"node_modules/@types/use-sync-external-store": {
"version": "0.0.6",
@@ -3603,8 +3633,7 @@
},
"node_modules/csstype": {
"version": "3.2.3",
- "license": "MIT",
- "devOptional": true
+ "license": "MIT"
},
"node_modules/damerau-levenshtein": {
"version": "1.0.8",
@@ -5274,6 +5303,15 @@
"node": ">= 0.4"
}
},
+ "node_modules/ip-address": {
+ "version": "10.4.0",
+ "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.4.0.tgz",
+ "integrity": "sha512-oSK96Grm3aP6OrS263xVxbNDGVL7rzBtYdpGqlDG8iQdoenDoTs/nkki+DflYbAEE8Xl6o5YxhxlrKvI3nqKXQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 12"
+ }
+ },
"node_modules/ipaddr.js": {
"version": "2.4.0",
"license": "MIT",
@@ -7916,21 +7954,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
- "node_modules/string_decoder": {
- "version": "1.1.1",
- "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
- "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
- "license": "MIT",
- "dependencies": {
- "safe-buffer": "~5.1.0"
- }
- },
- "node_modules/string_decoder/node_modules/safe-buffer": {
- "version": "5.1.2",
- "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
- "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
- "license": "MIT"
- },
"node_modules/strip-ansi": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz",
From d6c5e83c58b37bb478b4c0eb6cb2900bd99940d2 Mon Sep 17 00:00:00 2001
From: Perla Imad
Date: Mon, 10 Aug 2026 10:51:22 +0300
Subject: [PATCH 02/23] fix: refactor confirmCanonTransaction to use decision
variable for clarity + feat: enhance canon voting logic and add tests for
voting policy
---
README.md | 52 ++
apps/api/prisma/schema.prisma | 8 +-
apps/api/src/lib/auth-sessions.ts | 4 +-
apps/api/src/lib/canon-voting-policy.test.ts | 153 +++++
apps/api/src/lib/canon-voting.ts | 28 +-
apps/api/src/lib/contributions.ts | 168 ++---
apps/api/src/lib/worlds.ts | 10 +-
apps/api/src/routes/auth.ts | 43 +-
apps/api/src/routes/contributions.test.ts | 13 +-
apps/api/src/routes/proposals.ts | 2 +-
.../[proposalId]/canon-vote-screen.tsx | 573 ++++++++++++++++++
.../[worldId]/proposals/[proposalId]/page.tsx | 9 +-
apps/web/src/lib/api/proposals.ts | 6 +
apps/web/src/lib/hive/canon-signing.ts | 93 +++
14 files changed, 1027 insertions(+), 135 deletions(-)
create mode 100644 apps/api/src/lib/canon-voting-policy.test.ts
create mode 100644 apps/web/src/app/worlds/[worldId]/proposals/[proposalId]/canon-vote-screen.tsx
create mode 100644 apps/web/src/lib/hive/canon-signing.ts
diff --git a/README.md b/README.md
index d556e96..57b5e1d 100644
--- a/README.md
+++ b/README.md
@@ -243,6 +243,58 @@ Default MVP:
These rules provide basic protection against Sybil attacks while keeping governance simple.
+### Canon Voting MVP Rule
+
+Submitted contributions enter `VOTING` immediately with `votingStartedAt` set by the backend and
+`votingEndsAt = votingStartedAt + 48 hours`. The voting service never accepts client-supplied
+voter identity, role, tally, result, or authoritative timestamps. Authenticated world members with
+`VOTE_ON_PROPOSAL` may cast one vote per proposal and may change that vote only before
+`votingEndsAt`.
+
+Supported choices are `APPROVE`, `REJECT`, `NEEDS_REVISION`, and `ALTERNATE_TIMELINE`.
+`totalVotes` counts all four choices. Approval arithmetic is exact integer basis points:
+`approvalNumerator = APPROVE`, `approvalDenominator = APPROVE + REJECT`, and
+`approvalPercentageBps = floor(APPROVE * 10000 / approvalDenominator)`, or `0` when the denominator
+is zero. `NEEDS_REVISION` and `ALTERNATE_TIMELINE` count toward participation but do not enter the
+approval denominator.
+
+The MVP policy is centralized in `apps/api/src/lib/canon-voting-policy.ts`:
+
+- Minimum eligible internal votes: 5.
+- Approval threshold: 7000 basis points, so exactly 70% passes.
+- Voting window: 48 hours.
+- A proposal can pass only after the full window has ended.
+- Founder or author status never bypasses the rule.
+- A major AI warning remains attached and visible; it does not automatically reject a proposal.
+
+If the approval rule does not pass after the window closes, the deterministic terminal outcome is
+the plurality among `NEEDS_REVISION`, `ALTERNATE_TIMELINE`, and `REJECT`; ties resolve in that order.
+If no failed-outcome choice has votes, the result is `REJECTED`. If the proposal's recorded base canon
+version is stale at decision time, the outcome is `STALE_BASE_CONFLICT` and the proposal is surfaced
+as needing revision instead of silently applying to newer canon.
+
+Finalization creates one immutable `ProposalDecision` snapshot with per-choice counts, threshold
+version, content hash, AI-warning state, branch/conflict metadata, and a deterministic decision
+payload hash. Passing governance sets the proposal to `APPROVED_FOR_PUBLICATION`; it does not make a
+`LoreEntry` `PUBLISHED_CANON`. Published canon still requires a separately confirmed Hive lore
+post/comment and indexed `HiveReference`.
+
+Approved decisions use a non-custodial Hive signing handoff. The API returns an unsigned posting
+authority `custom_json` operation with HiveLore's stable custom JSON id (`hivelore`), schema version,
+world ID, proposal ID, content hash, outcome, timestamps, tally, thresholds, AI-warning state, and
+branch/base-canon references. The contributor signs and broadcasts through Hive Keychain or
+HiveSigner; HiveLore never requests or stores private keys.
+
+When a client reports broadcast details, the backend retrieves the operation through the configured
+HAF/Hive indexing abstraction and verifies confirmation, operation type, custom JSON id, posting
+signer, world/proposal/decision IDs, frozen tally/payload hash, timestamps, and duplicate operation
+state before linking the result to `HiveEvent` and `ProposalDecision` idempotently.
+
+Branching metadata keeps alternate continuations readable without deleting competing branches.
+Alternate-timeline decisions are excluded from canon-only projections; canonizing one branch does not
+remove other branches, which can later be revised, archived, merged, or retained under moderation
+permissions.
+
## Hive Voting
Hive votes determine:
diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma
index c96237e..96e8463 100644
--- a/apps/api/prisma/schema.prisma
+++ b/apps/api/prisma/schema.prisma
@@ -701,16 +701,16 @@ model HiveReference {
}
model HiveEvent {
- id String @id @default(cuid())
+ id String @id @default(cuid())
blockNumber BigInt
transactionId String
operationIndex Int
eventType HiveEventType
blockchainTimestamp DateTime
payload Json
- processedAt DateTime @default(now())
- createdAt DateTime @default(now())
- updatedAt DateTime @updatedAt
+ processedAt DateTime @default(now())
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
proposalDecision ProposalDecision?
/// Rebuildable indexed Hive operation; the blockchain remains authoritative.
diff --git a/apps/api/src/lib/auth-sessions.ts b/apps/api/src/lib/auth-sessions.ts
index d8f8cf9..e4b170b 100644
--- a/apps/api/src/lib/auth-sessions.ts
+++ b/apps/api/src/lib/auth-sessions.ts
@@ -1,5 +1,5 @@
import type { PlatformRole as PlatformRoleType } from '../generated/prisma/enums.js';
-import type { PrismaClient, RefreshSession, User } from '../generated/prisma/client.js';
+import type { Prisma, PrismaClient, RefreshSession, User } from '../generated/prisma/client.js';
import { randomToken, sha256Hmac, signAccessToken } from './auth-crypto.js';
export type SafeUser = {
@@ -167,7 +167,7 @@ export async function rotateRefreshSession(
const now = input.now ?? new Date();
const tokenHash = sha256Hmac(input.refreshToken, input.refreshSecret);
- return database.$transaction(async (transaction) => {
+ return database.$transaction(async (transaction: Prisma.TransactionClient) => {
const session = await transaction.refreshSession.findUnique({
where: {
refreshTokenHash: tokenHash,
diff --git a/apps/api/src/lib/canon-voting-policy.test.ts b/apps/api/src/lib/canon-voting-policy.test.ts
new file mode 100644
index 0000000..e7083fe
--- /dev/null
+++ b/apps/api/src/lib/canon-voting-policy.test.ts
@@ -0,0 +1,153 @@
+import assert from 'node:assert/strict';
+import { describe, test } from 'node:test';
+
+import { ProposalDecisionOutcome, VoteChoice } from '../generated/prisma/enums.js';
+import {
+ addVotingWindow,
+ CANON_VOTING_RULES,
+ decideCanonOutcome,
+ getVotingWindowState,
+ hashCanonicalJson,
+ tallyCanonVotes,
+} from './canon-voting-policy.js';
+
+const startedAt = new Date('2026-08-10T00:00:00.000Z');
+const votingEndsAt = addVotingWindow(startedAt);
+const afterEnd = new Date(votingEndsAt.getTime() + 1);
+
+function votes(...choices: VoteChoice[]) {
+ return choices.map((choice) => ({ choice }));
+}
+
+describe('canon voting policy', () => {
+ test('uses a 48 hour voting window and does not decide before the end', () => {
+ assert.equal(votingEndsAt.toISOString(), '2026-08-12T00:00:00.000Z');
+ assert.equal(
+ getVotingWindowState({
+ now: new Date(votingEndsAt.getTime() - 1),
+ votingEndsAt,
+ }),
+ 'BEFORE_END',
+ );
+ assert.equal(getVotingWindowState({ now: votingEndsAt, votingEndsAt }), 'AT_OR_AFTER_END');
+ assert.equal(
+ decideCanonOutcome({
+ now: new Date(votingEndsAt.getTime() - 1),
+ tally: tallyCanonVotes(votes(VoteChoice.APPROVE, VoteChoice.APPROVE)),
+ votingEndsAt,
+ }),
+ null,
+ );
+ });
+
+ test('exactly five votes and exactly 70 percent approval passes at the window boundary', () => {
+ const tally = tallyCanonVotes(
+ votes(
+ VoteChoice.APPROVE,
+ VoteChoice.APPROVE,
+ VoteChoice.APPROVE,
+ VoteChoice.APPROVE,
+ VoteChoice.APPROVE,
+ VoteChoice.APPROVE,
+ VoteChoice.APPROVE,
+ VoteChoice.REJECT,
+ VoteChoice.REJECT,
+ VoteChoice.REJECT,
+ ),
+ );
+
+ assert.equal(tally.totalVotes, 10);
+ assert.equal(tally.approvalPercentageBps, CANON_VOTING_RULES.approvalThresholdBps);
+ assert.equal(
+ decideCanonOutcome({ now: votingEndsAt, tally, votingEndsAt }),
+ ProposalDecisionOutcome.APPROVED_FOR_PUBLICATION,
+ );
+ });
+
+ test('fewer than five votes fails even at 100 percent approval', () => {
+ const tally = tallyCanonVotes(
+ votes(VoteChoice.APPROVE, VoteChoice.APPROVE, VoteChoice.APPROVE, VoteChoice.APPROVE),
+ );
+
+ assert.equal(tally.approvalPercentageBps, 10_000);
+ assert.equal(
+ decideCanonOutcome({ now: afterEnd, tally, votingEndsAt }),
+ ProposalDecisionOutcome.REJECTED,
+ );
+ });
+
+ test('zero approve plus reject denominator produces zero percent instead of NaN', () => {
+ const tally = tallyCanonVotes(
+ votes(VoteChoice.NEEDS_REVISION, VoteChoice.NEEDS_REVISION, VoteChoice.ALTERNATE_TIMELINE),
+ );
+
+ assert.equal(tally.totalVotes, 3);
+ assert.equal(tally.approvalDenominator, 0);
+ assert.equal(tally.approvalPercentageBps, 0);
+ });
+
+ test('revision and alternate votes count toward participation but not approval denominator', () => {
+ const tally = tallyCanonVotes(
+ votes(
+ VoteChoice.APPROVE,
+ VoteChoice.REJECT,
+ VoteChoice.NEEDS_REVISION,
+ VoteChoice.ALTERNATE_TIMELINE,
+ VoteChoice.ALTERNATE_TIMELINE,
+ ),
+ );
+
+ assert.equal(tally.totalVotes, 5);
+ assert.equal(tally.approvalDenominator, 2);
+ assert.equal(tally.approvalNumerator, 1);
+ assert.equal(tally.approvalPercentageBps, 5_000);
+ });
+
+ test('uses deterministic failed-outcome plurality with stable tie order', () => {
+ assert.equal(
+ decideCanonOutcome({
+ now: afterEnd,
+ tally: tallyCanonVotes(
+ votes(VoteChoice.NEEDS_REVISION, VoteChoice.ALTERNATE_TIMELINE, VoteChoice.REJECT),
+ ),
+ votingEndsAt,
+ }),
+ ProposalDecisionOutcome.NEEDS_REVISION,
+ );
+
+ assert.equal(
+ decideCanonOutcome({
+ now: afterEnd,
+ tally: tallyCanonVotes(
+ votes(VoteChoice.ALTERNATE_TIMELINE, VoteChoice.ALTERNATE_TIMELINE, VoteChoice.REJECT),
+ ),
+ votingEndsAt,
+ }),
+ ProposalDecisionOutcome.ALTERNATE_TIMELINE,
+ );
+ });
+
+ test('stale base canon conflict is surfaced before applying a normal outcome', () => {
+ assert.equal(
+ decideCanonOutcome({
+ now: afterEnd,
+ staleBaseAtDecision: true,
+ tally: tallyCanonVotes(
+ votes(
+ VoteChoice.APPROVE,
+ VoteChoice.APPROVE,
+ VoteChoice.APPROVE,
+ VoteChoice.APPROVE,
+ VoteChoice.APPROVE,
+ ),
+ ),
+ votingEndsAt,
+ }),
+ ProposalDecisionOutcome.STALE_BASE_CONFLICT,
+ );
+ });
+
+ test('canonical json hashing is deterministic across object key order', () => {
+ assert.equal(hashCanonicalJson({ b: 2, a: 1 }), hashCanonicalJson({ a: 1, b: 2 }));
+ });
+});
diff --git a/apps/api/src/lib/canon-voting.ts b/apps/api/src/lib/canon-voting.ts
index 97428c0..5ac461e 100644
--- a/apps/api/src/lib/canon-voting.ts
+++ b/apps/api/src/lib/canon-voting.ts
@@ -124,7 +124,7 @@ export function serializeProposalDetail(
) {
const summary = tallyCanonVotes(proposal.votes);
const currentVote = currentUserId
- ? proposal.votes.find((vote) => vote.voterId === currentUserId)
+ ? proposal.votes.find((vote: { voterId: string }) => vote.voterId === currentUserId)
: null;
const warning = aiWarningSnapshot(proposal);
@@ -254,7 +254,7 @@ export async function castCanonVote(
const choice = assertVoteChoice(input.choice);
const now = input.now ?? new Date();
- return database.$transaction(async (transaction) => {
+ return database.$transaction(async (transaction: Prisma.TransactionClient) => {
const proposal = await transaction.proposal.findFirst({
select: {
decision: { select: { id: true } },
@@ -380,7 +380,7 @@ export async function finalizeCanonDecision(
) {
const now = input.now ?? new Date();
- return database.$transaction(async (transaction) => {
+ return database.$transaction(async (transaction: Prisma.TransactionClient) => {
const existing = await transaction.proposalDecision.findUnique({
where: {
proposalId: input.proposalId,
@@ -678,12 +678,14 @@ export async function confirmCanonTransaction(
);
}
+ const currentDecision = proposal.decision;
+
if (
- proposal.decision.transactionId === input.transactionId &&
- proposal.decision.operationIndex === input.operationIndex
+ currentDecision.transactionId === input.transactionId &&
+ currentDecision.operationIndex === input.operationIndex
) {
return {
- decision: serializeDecision(proposal.decision),
+ decision: serializeDecision(currentDecision),
idempotent: true,
};
}
@@ -703,7 +705,7 @@ export async function confirmCanonTransaction(
);
}
- const signer = (proposal.decision.expectedSigner ?? proposal.author.hiveUsername).toLowerCase();
+ const signer = (currentDecision.expectedSigner ?? proposal.author.hiveUsername).toLowerCase();
const verification = verifyHiveLoreOperation({
expectedSigner: signer,
operation: operation.operation,
@@ -723,10 +725,10 @@ export async function confirmCanonTransaction(
!payload ||
payload.action !== 'canon_approval' ||
payload.entityType !== 'CANON_DECISION' ||
- payload.entityId !== proposal.decision.id ||
+ payload.entityId !== currentDecision.id ||
payload.worldId !== input.worldId ||
payload.proposalId !== input.proposalId ||
- hashCanonicalJson(payload.payload) !== proposal.decision.decisionPayloadHash
+ hashCanonicalJson(payload.payload) !== currentDecision.decisionPayloadHash
) {
throw new CanonVotingError(
400,
@@ -735,7 +737,7 @@ export async function confirmCanonTransaction(
);
}
- return database.$transaction(async (transaction) => {
+ return database.$transaction(async (transaction: Prisma.TransactionClient) => {
const hiveEvent = await transaction.hiveEvent.upsert({
create: {
blockNumber: operation.blockNumber,
@@ -759,7 +761,7 @@ export async function confirmCanonTransaction(
},
});
- const decision = await transaction.proposalDecision.update({
+ const confirmedDecision = await transaction.proposalDecision.update({
data: {
blockchainTimestamp: operation.blockchainTimestamp,
blockNumber: operation.blockNumber,
@@ -770,12 +772,12 @@ export async function confirmCanonTransaction(
transactionId: operation.transactionId,
},
where: {
- id: proposal.decision.id,
+ id: currentDecision.id,
},
});
return {
- decision: serializeDecision(decision),
+ decision: serializeDecision(confirmedDecision),
idempotent: false,
};
});
diff --git a/apps/api/src/lib/contributions.ts b/apps/api/src/lib/contributions.ts
index 527f733..e502159 100644
--- a/apps/api/src/lib/contributions.ts
+++ b/apps/api/src/lib/contributions.ts
@@ -296,34 +296,36 @@ export async function createContribution(
await assertWorldExists(database, input.worldId);
await assertTargetLoreEntry(database, input.worldId, input.targetLoreEntryId);
- const contribution = await database.$transaction(async (transaction) => {
- const created = await transaction.contributionDraft.create({
- data: {
- authorId: input.authorId,
- content,
- kind: input.kind,
- summary,
- targetLoreEntryId: input.targetLoreEntryId || null,
- title,
- worldId: input.worldId,
- },
- include: contributionInclude,
- });
+ const contribution = await database.$transaction(
+ async (transaction: Prisma.TransactionClient) => {
+ const created = await transaction.contributionDraft.create({
+ data: {
+ authorId: input.authorId,
+ content,
+ kind: input.kind,
+ summary,
+ targetLoreEntryId: input.targetLoreEntryId || null,
+ title,
+ worldId: input.worldId,
+ },
+ include: contributionInclude,
+ });
- await createContributionAuditLog(transaction, {
- action: WorldAuditAction.CONTRIBUTION_CREATED,
- actorId: input.authorId,
- metadata: {
- kind: input.kind,
- targetLoreEntryId: input.targetLoreEntryId ?? null,
- },
- targetId: created.id,
- targetType: 'CONTRIBUTION_DRAFT',
- worldId: input.worldId,
- });
+ await createContributionAuditLog(transaction, {
+ action: WorldAuditAction.CONTRIBUTION_CREATED,
+ actorId: input.authorId,
+ metadata: {
+ kind: input.kind,
+ targetLoreEntryId: input.targetLoreEntryId ?? null,
+ },
+ targetId: created.id,
+ targetType: 'CONTRIBUTION_DRAFT',
+ worldId: input.worldId,
+ });
- return created;
- });
+ return created;
+ },
+ );
return serializeContribution(contribution);
}
@@ -486,70 +488,72 @@ export async function updateContribution(
data.updatedAt = new Date();
}
- const contribution = await database.$transaction(async (transaction) => {
- const updated = await transaction.contributionDraft.updateMany({
- data,
- where: {
- authorId: input.authorId,
- id: input.contributionId,
- status: ContributionStatus.DRAFT,
- worldId: input.worldId,
- },
- });
-
- if (updated.count !== 1) {
- await resolveMissingOrLockedContribution(transaction, {
- ...input,
- lockedMessage: 'Submitted contributions cannot be edited.',
+ const contribution = await database.$transaction(
+ async (transaction: Prisma.TransactionClient) => {
+ const updated = await transaction.contributionDraft.updateMany({
+ data,
+ where: {
+ authorId: input.authorId,
+ id: input.contributionId,
+ status: ContributionStatus.DRAFT,
+ worldId: input.worldId,
+ },
});
- }
- if (targetLoreEntryId !== undefined) {
- await transaction.contributionDraft.update({
- data: {
- targetLoreEntry:
- targetLoreEntryId === null
- ? {
- disconnect: true,
- }
- : {
- connect: {
- id: targetLoreEntryId,
+ if (updated.count !== 1) {
+ await resolveMissingOrLockedContribution(transaction, {
+ ...input,
+ lockedMessage: 'Submitted contributions cannot be edited.',
+ });
+ }
+
+ if (targetLoreEntryId !== undefined) {
+ await transaction.contributionDraft.update({
+ data: {
+ targetLoreEntry:
+ targetLoreEntryId === null
+ ? {
+ disconnect: true,
+ }
+ : {
+ connect: {
+ id: targetLoreEntryId,
+ },
},
- },
- },
+ },
+ where: {
+ id: input.contributionId,
+ },
+ });
+ }
+
+ const current = await transaction.contributionDraft.findFirst({
+ include: contributionInclude,
where: {
+ authorId: input.authorId,
id: input.contributionId,
+ worldId: input.worldId,
},
});
- }
- const current = await transaction.contributionDraft.findFirst({
- include: contributionInclude,
- where: {
- authorId: input.authorId,
- id: input.contributionId,
- worldId: input.worldId,
- },
- });
-
- if (!current) {
- throw new ContributionError(404, 'Contribution not found.');
- }
+ if (!current) {
+ throw new ContributionError(404, 'Contribution not found.');
+ }
- await createContributionAuditLog(transaction, {
- action: WorldAuditAction.CONTRIBUTION_UPDATED,
- actorId: input.authorId,
- metadata: {
- changedFields: changedFields.sort(),
- },
- targetId: current.id,
- targetType: 'CONTRIBUTION_DRAFT',
- worldId: input.worldId,
- });
+ await createContributionAuditLog(transaction, {
+ action: WorldAuditAction.CONTRIBUTION_UPDATED,
+ actorId: input.authorId,
+ metadata: {
+ changedFields: changedFields.sort(),
+ },
+ targetId: current.id,
+ targetType: 'CONTRIBUTION_DRAFT',
+ worldId: input.worldId,
+ });
- return current;
- });
+ return current;
+ },
+ );
return serializeContribution(contribution);
}
@@ -562,7 +566,7 @@ export async function deleteContribution(
authorId: string;
},
) {
- await database.$transaction(async (transaction) => {
+ await database.$transaction(async (transaction: Prisma.TransactionClient) => {
const existing = await transaction.contributionDraft.findFirst({
where: {
authorId: input.authorId,
@@ -617,7 +621,7 @@ export async function submitContribution(
authorId: string;
},
) {
- const result = await database.$transaction(async (transaction) => {
+ const result = await database.$transaction(async (transaction: Prisma.TransactionClient) => {
const existing = await transaction.contributionDraft.findFirst({
include: contributionInclude,
where: {
diff --git a/apps/api/src/lib/worlds.ts b/apps/api/src/lib/worlds.ts
index a1a6401..685e758 100644
--- a/apps/api/src/lib/worlds.ts
+++ b/apps/api/src/lib/worlds.ts
@@ -241,7 +241,7 @@ export async function createWorld(database: WorldDatabase, input: CreateWorldInp
const seed = normalizeSeedInput(input.seed);
try {
- const world = await database.$transaction(async (transaction) => {
+ const world = await database.$transaction(async (transaction: Prisma.TransactionClient) => {
const slug = await generateUniqueWorldSlug(transaction, input.title);
const createdWorld = await transaction.world.create({
data: {
@@ -459,7 +459,7 @@ export async function getWorldHub(database: WorldDatabase, worldId: string) {
]);
return {
- latestLoreEntries: latestLoreEntries.map((entry) => ({
+ latestLoreEntries: latestLoreEntries.map((entry: { updatedAt: Date }) => ({
...entry,
updatedAt: entry.updatedAt.toISOString(),
})),
@@ -472,7 +472,7 @@ export async function getWorldHub(database: WorldDatabase, worldId: string) {
}
export async function updateWorld(database: WorldDatabase, input: UpdateWorldInput) {
- const world = await database.$transaction(async (transaction) => {
+ const world = await database.$transaction(async (transaction: Prisma.TransactionClient) => {
const current = await transaction.world.findUnique({
include: {
bibleVersions: {
@@ -516,14 +516,14 @@ export async function updateWorld(database: WorldDatabase, input: UpdateWorldInp
input.seed.firstCharacters ??
(Array.isArray(currentSeed.firstCharacters)
? currentSeed.firstCharacters.filter(
- (value): value is string => typeof value === 'string',
+ (value: unknown): value is string => typeof value === 'string',
)
: []),
firstFactions:
input.seed.firstFactions ??
(Array.isArray(currentSeed.firstFactions)
? currentSeed.firstFactions.filter(
- (value): value is string => typeof value === 'string',
+ (value: unknown): value is string => typeof value === 'string',
)
: []),
firstHistoricalEvent:
diff --git a/apps/api/src/routes/auth.ts b/apps/api/src/routes/auth.ts
index 90f5efa..e37661a 100644
--- a/apps/api/src/routes/auth.ts
+++ b/apps/api/src/routes/auth.ts
@@ -2,6 +2,7 @@ import type { FastifyInstance, FastifyRequest } from 'fastify';
import { z } from 'zod';
import { env } from '../config/env.js';
+import type { Prisma } from '../generated/prisma/client.js';
import { prisma } from '../lib/prisma.js';
import {
createAuthChallenge,
@@ -209,26 +210,28 @@ export async function registerAuthRoutes(
});
}
- const issuedSession = await database.$transaction(async (transaction) => {
- await consumeAuthChallenge(transaction, {
- challengeId: parsed.data.challengeId,
- hmacSecret: env.AUTH_JWT_SECRET,
- message: parsed.data.message,
- provider: parsed.data.provider,
- username: normalizedHiveUsername,
- });
-
- const user = await upsertHiveUser(transaction, {
- normalizedHiveUsername,
- });
-
- return issueSession(transaction, {
- ...sessionOptions(),
- ipAddress: request.ip,
- user,
- userAgent: request.headers['user-agent'],
- });
- });
+ const issuedSession = await database.$transaction(
+ async (transaction: Prisma.TransactionClient) => {
+ await consumeAuthChallenge(transaction, {
+ challengeId: parsed.data.challengeId,
+ hmacSecret: env.AUTH_JWT_SECRET,
+ message: parsed.data.message,
+ provider: parsed.data.provider,
+ username: normalizedHiveUsername,
+ });
+
+ const user = await upsertHiveUser(transaction, {
+ normalizedHiveUsername,
+ });
+
+ return issueSession(transaction, {
+ ...sessionOptions(),
+ ipAddress: request.ip,
+ user,
+ userAgent: request.headers['user-agent'],
+ });
+ },
+ );
setRefreshCookie(reply, issuedSession.refreshToken, {
...cookieOptions(),
diff --git a/apps/api/src/routes/contributions.test.ts b/apps/api/src/routes/contributions.test.ts
index b7502a9..34b652e 100644
--- a/apps/api/src/routes/contributions.test.ts
+++ b/apps/api/src/routes/contributions.test.ts
@@ -435,6 +435,17 @@ function createDatabase() {
return worlds.find((world) => world.id === args.where.id) ?? null;
},
},
+ worldBibleVersion: {
+ async findFirst(args: { where: { worldId: string } }) {
+ if (!worlds.some((world) => world.id === args.where.worldId)) {
+ return null;
+ }
+
+ return {
+ id: `bible-${args.where.worldId}`,
+ };
+ },
+ },
worldAuditLog: {
async create(args: { data: unknown }) {
if (failNextAuditCreate) {
@@ -828,7 +839,7 @@ describe('contribution routes', () => {
assert.equal(state.contributions[0]?.status, ContributionStatus.SUBMITTED);
assert.equal(state.contributions[0]?.proposalId, 'proposal-1');
assert.equal(submitResponse.json().proposal.authorId, author.id);
- assert.equal(submitResponse.json().proposal.status, ProposalStatus.SUBMITTED);
+ assert.equal(submitResponse.json().proposal.status, ProposalStatus.VOTING);
assert.equal(submitResponse.json().proposal.proposalType, ProposalType.UPDATE_LORE);
assert.deepEqual(submitResponse.json().proposal.proposedContent, structuredDoc);
assert.equal(
diff --git a/apps/api/src/routes/proposals.ts b/apps/api/src/routes/proposals.ts
index 00b6cbd..a624032 100644
--- a/apps/api/src/routes/proposals.ts
+++ b/apps/api/src/routes/proposals.ts
@@ -83,7 +83,7 @@ export async function registerProposalRoutes(
try {
const proposal = await getProposalDetail(database, {
- currentUserId: currentUser?.id,
+ ...(currentUser ? { currentUserId: currentUser.id } : {}),
proposalId: params.data.proposalId,
worldId: params.data.worldId,
});
diff --git a/apps/web/src/app/worlds/[worldId]/proposals/[proposalId]/canon-vote-screen.tsx b/apps/web/src/app/worlds/[worldId]/proposals/[proposalId]/canon-vote-screen.tsx
new file mode 100644
index 0000000..0272e32
--- /dev/null
+++ b/apps/web/src/app/worlds/[worldId]/proposals/[proposalId]/canon-vote-screen.tsx
@@ -0,0 +1,573 @@
+'use client';
+
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
+import { useEffect, useMemo, useState } from 'react';
+
+import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
+import { Badge } from '@/components/ui/badge';
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+import { LoadingState } from '@/components/states/loading-state';
+import {
+ castProposalVote,
+ confirmCanonTransaction,
+ createCanonTransaction,
+ finalizeProposal,
+ getProposal,
+ type ProposalDetail,
+ type VoteChoice,
+} from '@/lib/api/proposals';
+import { refreshAuthSession, type SafeUser } from '@/lib/api/auth';
+import { ApiError } from '@/lib/api/errors';
+import { requestCanonDecisionSignature } from '@/lib/hive/canon-signing';
+
+const voteLabels: Record = {
+ APPROVE: 'Approve',
+ ALTERNATE_TIMELINE: 'Alternate Timeline',
+ NEEDS_REVISION: 'Needs Revision',
+ REJECT: 'Reject',
+};
+
+const voteHelp: Record = {
+ APPROVE: 'Counts for canon approval.',
+ ALTERNATE_TIMELINE: 'Counts for participation, not approval percentage.',
+ NEEDS_REVISION: 'Counts for participation, not approval percentage.',
+ REJECT: 'Counts against canon approval.',
+};
+
+function getErrorMessage(error: unknown) {
+ if (error instanceof ApiError) {
+ return error.body?.error ?? 'Request failed.';
+ }
+
+ if (error instanceof Error) {
+ return error.message;
+ }
+
+ return 'Request failed.';
+}
+
+function formatDate(value: string | null) {
+ if (!value) {
+ return 'Not scheduled';
+ }
+
+ return new Intl.DateTimeFormat(undefined, {
+ dateStyle: 'medium',
+ timeStyle: 'short',
+ }).format(new Date(value));
+}
+
+function formatCountdown(value: string | null) {
+ if (!value) {
+ return 'Voting window missing';
+ }
+
+ const remaining = new Date(value).getTime() - Date.now();
+
+ if (remaining <= 0) {
+ return 'Voting ended';
+ }
+
+ const hours = Math.floor(remaining / 3_600_000);
+ const minutes = Math.floor((remaining % 3_600_000) / 60_000);
+
+ return `${hours}h ${minutes}m remaining`;
+}
+
+function statusVariant(status: string) {
+ if (status === 'APPROVED_FOR_PUBLICATION') {
+ return 'ready-to-publish' as const;
+ }
+
+ if (status === 'REJECTED') {
+ return 'rejected' as const;
+ }
+
+ if (status === 'ALTERNATE_TIMELINE') {
+ return 'alternate-timeline' as const;
+ }
+
+ if (status === 'PUBLISHED') {
+ return 'published-on-hive' as const;
+ }
+
+ return 'proposal' as const;
+}
+
+function contentText(value: unknown) {
+ if (!value || typeof value !== 'object') {
+ return String(value ?? '');
+ }
+
+ const text: string[] = [];
+ const pending = [value];
+
+ while (pending.length > 0) {
+ const item = pending.pop();
+
+ if (!item || typeof item !== 'object') {
+ continue;
+ }
+
+ const record = item as Record;
+
+ if (typeof record.text === 'string') {
+ text.push(record.text);
+ }
+
+ if (Array.isArray(record.content)) {
+ pending.push(...record.content);
+ }
+ }
+
+ return text.join(' ').trim() || JSON.stringify(value, null, 2);
+}
+
+function ResultPanel({ proposal }: { proposal: ProposalDetail }) {
+ const decision = proposal.decision;
+
+ if (!decision && proposal.status === 'VOTING' && proposal.votingEndsAt) {
+ const ended = Date.now() >= new Date(proposal.votingEndsAt).getTime();
+
+ return (
+
+ {ended ? 'Awaiting finalization' : 'Voting open'}
+
+ {ended
+ ? 'The 48-hour voting window has ended. A curator can finalize the frozen result.'
+ : 'Votes can be changed until the deadline. No result is final before then.'}
+
+
+ );
+ }
+
+ if (!decision) {
+ return (
+
+ No decision yet
+
+ This proposal has not entered a terminal governance state.
+
+
+ );
+ }
+
+ const confirmed = Boolean(decision.hiveEventId);
+ const approved = decision.outcome === 'APPROVED_FOR_PUBLICATION';
+
+ return (
+
+
+ {confirmed
+ ? 'Canon decision confirmed on Hive'
+ : approved
+ ? 'Awaiting Hive signature'
+ : decision.outcome.replaceAll('_', ' ')}
+
+
+ {confirmed
+ ? `Verified transaction ${decision.transactionId}.`
+ : approved
+ ? 'This is approved for publication, not published canon. The author still signs a Hive custom_json decision.'
+ : 'This proposal did not become approved-for-publication under the MVP voting policy.'}
+
+
+ );
+}
+
+function ProgressBar({ label, value }: { label: string; value: number }) {
+ const safeValue = Math.max(0, Math.min(100, value));
+
+ return (
+
+
+ {label}
+ {safeValue.toFixed(0)}%
+
+
+
+ );
+}
+
+export function CanonVoteScreen({ proposalId, worldId }: { proposalId: string; worldId: string }) {
+ const queryClient = useQueryClient();
+ const [accessToken, setAccessToken] = useState(null);
+ const [currentUser, setCurrentUser] = useState(null);
+ const [sessionChecked, setSessionChecked] = useState(false);
+ const [receipt, setReceipt] = useState({
+ blockNumber: '',
+ operationIndex: '',
+ transactionId: '',
+ });
+ const [message, setMessage] = useState(null);
+
+ useEffect(() => {
+ let mounted = true;
+
+ refreshAuthSession()
+ .then((session) => {
+ if (!mounted) {
+ return;
+ }
+
+ setAccessToken(session.accessToken);
+ setCurrentUser(session.user);
+ })
+ .catch(() => {
+ if (mounted) {
+ setAccessToken(null);
+ setCurrentUser(null);
+ }
+ })
+ .finally(() => {
+ if (mounted) {
+ setSessionChecked(true);
+ }
+ });
+
+ return () => {
+ mounted = false;
+ };
+ }, []);
+
+ const proposalQuery = useQuery({
+ queryFn: () => getProposal(worldId, proposalId, accessToken ?? undefined),
+ queryKey: ['proposal', worldId, proposalId, accessToken],
+ });
+
+ const proposal = proposalQuery.data?.proposal;
+ const invalidateProposal = () =>
+ queryClient.invalidateQueries({ queryKey: ['proposal', worldId, proposalId] });
+
+ const voteMutation = useMutation({
+ mutationFn: (choice: VoteChoice) =>
+ castProposalVote({ accessToken: accessToken ?? '', choice, proposalId, worldId }),
+ onSuccess: () => {
+ setMessage('Vote saved.');
+ void invalidateProposal();
+ },
+ });
+
+ const finalizeMutation = useMutation({
+ mutationFn: () => finalizeProposal({ accessToken: accessToken ?? '', proposalId, worldId }),
+ onSuccess: () => {
+ setMessage('Decision finalized from the frozen tally.');
+ void invalidateProposal();
+ },
+ });
+
+ const signMutation = useMutation({
+ mutationFn: async () => {
+ const operation = await createCanonTransaction({
+ accessToken: accessToken ?? '',
+ proposalId,
+ worldId,
+ });
+ return requestCanonDecisionSignature(operation);
+ },
+ onSuccess: (nextReceipt) => {
+ setMessage(
+ nextReceipt.provider === 'manual'
+ ? 'custom_json copied. Broadcast it with posting authority, then enter the confirmed operation details.'
+ : 'Signature requested. Enter the confirmed Hive operation details after broadcast.',
+ );
+
+ if (nextReceipt.transactionId) {
+ setReceipt((current) => ({ ...current, transactionId: nextReceipt.transactionId ?? '' }));
+ }
+
+ void invalidateProposal();
+ },
+ });
+
+ const confirmMutation = useMutation({
+ mutationFn: () =>
+ confirmCanonTransaction({
+ accessToken: accessToken ?? '',
+ blockNumber: Number(receipt.blockNumber),
+ operationIndex: Number(receipt.operationIndex),
+ proposalId,
+ transactionId: receipt.transactionId,
+ worldId,
+ }),
+ onSuccess: () => {
+ setMessage('Hive operation verified and recorded.');
+ void invalidateProposal();
+ },
+ });
+
+ const votingClosed = useMemo(() => {
+ if (!proposal?.votingEndsAt) {
+ return true;
+ }
+
+ return Date.now() >= new Date(proposal.votingEndsAt).getTime();
+ }, [proposal?.votingEndsAt]);
+
+ if (proposalQuery.isLoading || !sessionChecked) {
+ return ;
+ }
+
+ if (proposalQuery.isError || !proposal) {
+ return (
+
+ Proposal unavailable
+ {getErrorMessage(proposalQuery.error)}
+
+ );
+ }
+
+ const approvalProgress =
+ proposal.tally.approvalDenominator > 0 ? proposal.tally.approvalPercentageBps / 100 : 0;
+ const participationProgress = (proposal.tally.totalVotes / 5) * 100;
+ const canVote = Boolean(accessToken) && proposal.status === 'VOTING' && !votingClosed;
+ const canFinalize = Boolean(accessToken) && proposal.status === 'VOTING' && votingClosed;
+ const canSign =
+ Boolean(accessToken) &&
+ proposal.decision?.outcome === 'APPROVED_FOR_PUBLICATION' &&
+ !proposal.decision.hiveEventId &&
+ currentUser?.id === proposal.author.id;
+ const canConfirm =
+ Boolean(accessToken) &&
+ Boolean(receipt.transactionId.trim()) &&
+ Number.isInteger(Number(receipt.blockNumber)) &&
+ Number.isInteger(Number(receipt.operationIndex));
+
+ return (
+
+
+
+
+ {proposal.status.replaceAll('_', ' ')}
+
+ {proposal.aiWarning.acknowledged ? (
+ Major AI warning
+ ) : null}
+ {proposal.branchLabel ? (
+ {proposal.branchLabel}
+ ) : null}
+
+
+
+ {proposal.world.title} / {proposal.proposalType.replaceAll('_', ' ')}
+
+
+ {proposal.title}
+
+
+ {proposal.summary || 'No summary provided.'}
+
+
Author: @{proposal.author.hiveUsername}
+
+
+
+
+ {message}
+
+
+ {message ? (
+
+ Status
+ {message}
+
+ ) : null}
+
+ {voteMutation.error ||
+ finalizeMutation.error ||
+ signMutation.error ||
+ confirmMutation.error ? (
+
+ Action failed
+
+ {getErrorMessage(
+ voteMutation.error ??
+ finalizeMutation.error ??
+ signMutation.error ??
+ confirmMutation.error,
+ )}
+
+
+ ) : null}
+
+
+
+
+
Proposal Content
+
+ {contentText(proposal.proposedContent)}
+
+
+
+
+
+
AI Report
+ {proposal.aiWarning.acknowledged ? (
+ Warning preserved
+ ) : null}
+
+
+ {proposal.aiWarning.summary ??
+ 'No major AI warning is attached to this proposal decision.'}
+
+
+
+
+
Branch And Conflict State
+
+
+
- Base canon version
+ - {proposal.baseCanonVersionId ?? 'Current world base at submission'}
+
+
+
- Branch base
+ -
+ {proposal.branchBaseLoreEntryId ??
+ proposal.branchParentProposalId ??
+ 'Main continuity'}
+
+
+
+ {proposal.conflictMetadata ? (
+
+ {JSON.stringify(proposal.conflictMetadata, null, 2)}
+
+ ) : null}
+
+
+
+
+
+
+ );
+}
diff --git a/apps/web/src/app/worlds/[worldId]/proposals/[proposalId]/page.tsx b/apps/web/src/app/worlds/[worldId]/proposals/[proposalId]/page.tsx
index 47517b3..1fcda40 100644
--- a/apps/web/src/app/worlds/[worldId]/proposals/[proposalId]/page.tsx
+++ b/apps/web/src/app/worlds/[worldId]/proposals/[proposalId]/page.tsx
@@ -1,4 +1,4 @@
-import { PlaceholderPage } from '@/components/layout/placeholder-page';
+import { CanonVoteScreen } from './canon-vote-screen';
type ProposalPageProps = {
params: Promise<{ proposalId: string; worldId: string }>;
@@ -7,10 +7,5 @@ type ProposalPageProps = {
export default async function ProposalPage({ params }: ProposalPageProps) {
const { proposalId, worldId } = await params;
- return (
-
- );
+ return ;
}
diff --git a/apps/web/src/lib/api/proposals.ts b/apps/web/src/lib/api/proposals.ts
index 4852740..e9919c0 100644
--- a/apps/web/src/lib/api/proposals.ts
+++ b/apps/web/src/lib/api/proposals.ts
@@ -8,6 +8,7 @@ export type ProposalDecision = {
approvalDenominator: number;
approvalNumerator: number;
approvalPercentageBps: number;
+ approvalThresholdBps: number;
approveCount: number;
alternateTimelineCount: number;
blockchainTimestamp: string | null;
@@ -15,10 +16,12 @@ export type ProposalDecision = {
contentHash: string;
customJsonId: string | null;
decidedAt: string;
+ decisionPayload: unknown;
decisionPayloadHash: string;
expectedSigner: string | null;
hiveEventId: string | null;
id: string;
+ minimumVotes: number;
needsRevisionCount: number;
operationIndex: number | null;
outcome:
@@ -28,8 +31,10 @@ export type ProposalDecision = {
| 'ALTERNATE_TIMELINE'
| 'STALE_BASE_CONFLICT';
rejectCount: number;
+ rulesVersion: string;
totalVotes: number;
transactionId: string | null;
+ votingWindowHours: number;
};
export type ProposalDetail = {
@@ -41,6 +46,7 @@ export type ProposalDetail = {
hiveUsername: string;
id: string;
};
+ baseCanonVersionId: string | null;
branchBaseLoreEntryId: string | null;
branchLabel: string | null;
branchParentProposalId: string | null;
diff --git a/apps/web/src/lib/hive/canon-signing.ts b/apps/web/src/lib/hive/canon-signing.ts
new file mode 100644
index 0000000..785e45c
--- /dev/null
+++ b/apps/web/src/lib/hive/canon-signing.ts
@@ -0,0 +1,93 @@
+'use client';
+
+import type { CanonTransactionResponse } from '@/lib/api/proposals';
+
+type HiveKeychainResponse = {
+ success: boolean;
+ error?: string;
+ message?: string;
+ result?: unknown;
+};
+
+type HiveKeychainWindow = Window & {
+ hive_keychain?: {
+ requestCustomJson?: (
+ username: string,
+ customJsonId: string,
+ keyType: 'Posting' | 'Active',
+ json: string,
+ displayName: string,
+ callback: (response: HiveKeychainResponse) => void,
+ ) => void;
+ };
+};
+
+export type HiveBroadcastReceipt = {
+ provider: 'keychain' | 'manual';
+ transactionId?: string;
+ blockNumber?: number;
+ operationIndex?: number;
+};
+
+function getCustomJsonJson(operation: CanonTransactionResponse['operation']) {
+ const customJson = (operation as { custom_json_operation?: { json?: string } })
+ .custom_json_operation;
+
+ if (!customJson?.json) {
+ throw new Error('Canon transaction operation is missing custom_json data.');
+ }
+
+ return customJson.json;
+}
+
+function getTransactionId(value: unknown) {
+ if (!value || typeof value !== 'object') {
+ return undefined;
+ }
+
+ const record = value as Record;
+ const id = record.id ?? record.tx_id ?? record.transaction_id;
+
+ return typeof id === 'string' ? id : undefined;
+}
+
+export async function requestCanonDecisionSignature(
+ operation: CanonTransactionResponse,
+): Promise {
+ const json = getCustomJsonJson(operation.operation);
+
+ const keychain = (window as unknown as HiveKeychainWindow).hive_keychain;
+
+ const requestCustomJson = keychain?.requestCustomJson;
+
+ if (!requestCustomJson) {
+ await navigator.clipboard?.writeText(json);
+
+ return {
+ provider: 'manual',
+ };
+ }
+
+ return new Promise((resolve, reject) => {
+ requestCustomJson(
+ operation.signer,
+ operation.customJsonId,
+ 'Posting',
+ json,
+ 'HiveLore canon decision',
+ (response) => {
+ if (!response.success) {
+ reject(new Error(response.error ?? response.message ?? 'Hive signing was cancelled.'));
+ return;
+ }
+
+ const transactionId = getTransactionId(response.result);
+
+ resolve({
+ provider: 'keychain',
+ ...(transactionId ? { transactionId } : {}),
+ });
+ },
+ );
+ });
+}
From f3263b8fe41b129507180d1db315da61cdbc5265 Mon Sep 17 00:00:00 2001
From: Perla Imad
Date: Mon, 10 Aug 2026 11:03:47 +0300
Subject: [PATCH 03/23] refactor: remove redundant refresh route implementation
---
apps/api/src/routes/auth.ts | 61 -------------------------------------
1 file changed, 61 deletions(-)
diff --git a/apps/api/src/routes/auth.ts b/apps/api/src/routes/auth.ts
index a98be48..e37661a 100644
--- a/apps/api/src/routes/auth.ts
+++ b/apps/api/src/routes/auth.ts
@@ -257,67 +257,6 @@ export async function registerAuthRoutes(
},
);
- app.post(
- '/auth/refresh',
- {
- config: {
- rateLimit: {
- max: 40,
- timeWindow: '1 minute',
- },
- },
- },
- async (request, reply) => {
- const parsed = refreshSchema.safeParse(request.body ?? {});
-
- if (!parsed.success) {
- return reply.code(400).send({
- error: 'Invalid refresh payload.',
- const issuedSession = await database.$transaction(async (transaction) => {
- await consumeAuthChallenge(transaction, {
- challengeId: parsed.data.challengeId,
- hmacSecret: env.AUTH_JWT_SECRET,
- message: parsed.data.message,
- provider: parsed.data.provider,
- username: normalizedHiveUsername,
- });
-
- const user = await upsertHiveUser(transaction, {
- normalizedHiveUsername,
- });
-
- return issueSession(transaction, {
- ...sessionOptions(),
- ipAddress: request.ip,
- user,
- userAgent: request.headers['user-agent'],
- });
- });
-
- setRefreshCookie(reply, issuedSession.refreshToken, {
- ...cookieOptions(),
- maxAgeSeconds: env.AUTH_REFRESH_TOKEN_TTL_SECONDS,
- });
-
- return {
- accessToken: issuedSession.accessToken,
- expiresInSeconds: env.AUTH_ACCESS_TOKEN_TTL_SECONDS,
- user: issuedSession.user,
- };
- } catch (error) {
- if (error instanceof InvalidHiveUsernameError) {
- return reply.code(400).send({
- error: 'Invalid Hive username.',
- });
- }
-
- return reply.code(401).send({
- error: 'Authentication failed.',
- });
- }
- },
- );
-
app.post(
'/auth/refresh',
{
From 04ddc1910fc16cdb37c74781cb53c47a79870005 Mon Sep 17 00:00:00 2001
From: Perla Imad
Date: Mon, 10 Aug 2026 11:22:30 +0300
Subject: [PATCH 04/23] feat: add proposal comments functionality
- Introduced endpoints for listing and creating comments on proposals.
- Added ProposalComment model to the database schema with necessary fields and constraints.
- Implemented logic for handling comment creation, including validation and authorization checks.
- Updated frontend to support displaying and posting comments on proposals.
- Created tests to ensure proper functionality and error handling for comment-related operations.
---
README.md | 8 +
.../migration.sql | 23 +
apps/api/prisma/schema.prisma | 22 +
apps/api/src/lib/proposal-comments.ts | 237 +++++++++
apps/api/src/routes/proposals.test.ts | 480 ++++++++++++++++++
apps/api/src/routes/proposals.ts | 94 ++++
.../[proposalId]/canon-vote-screen.tsx | 219 +++++++-
apps/web/src/lib/api/proposals.ts | 65 +++
8 files changed, 1145 insertions(+), 3 deletions(-)
create mode 100644 apps/api/prisma/migrations/20260810170000_add_proposal_comments/migration.sql
create mode 100644 apps/api/src/lib/proposal-comments.ts
create mode 100644 apps/api/src/routes/proposals.test.ts
diff --git a/README.md b/README.md
index 57b5e1d..afe5c90 100644
--- a/README.md
+++ b/README.md
@@ -168,6 +168,7 @@ Stores mutable application state optimized for querying.
- Drafts
- Proposals
+- Proposal comments
- AI reports
- Lore graph
- Search index
@@ -388,6 +389,13 @@ Implemented contribution endpoints:
- `DELETE /worlds/:worldId/contributions/:contributionId`: requires `EDIT_OWN_DRAFT`, deletes only draft contributions, and never deletes submitted proposals.
- `POST /worlds/:worldId/contributions/:contributionId/submit`: requires `SUBMIT_PROPOSAL`, atomically locks the draft, creates exactly one submitted proposal with an immutable `proposedContent` snapshot, and does not publish to Hive.
+Implemented proposal discussion endpoints:
+
+- `GET /worlds/:worldId/proposals/:proposalId/comments`: lists flat chronological off-chain proposal comments with cursor pagination. The proposal must belong to the route world. Deleted comments are returned as tombstones without the original body.
+- `POST /worlds/:worldId/proposals/:proposalId/comments`: requires authentication and the existing `VOTE_ON_PROPOSAL` world permission, derives the author from the verified session, trims plain-text body content, and enforces a 3,000-character maximum.
+
+Proposal comments are stored in PostgreSQL as mutable discussion records. They are not Hive comments, not immutable canon records, and never count as AppVote rows, approval totals, proposal outcomes, reputation, rewards, or canon status. Soft deletion preserves the discussion audit shape while normal API responses hide moderated comment bodies.
+
---
# Reputation & Rewards
diff --git a/apps/api/prisma/migrations/20260810170000_add_proposal_comments/migration.sql b/apps/api/prisma/migrations/20260810170000_add_proposal_comments/migration.sql
new file mode 100644
index 0000000..7046d6a
--- /dev/null
+++ b/apps/api/prisma/migrations/20260810170000_add_proposal_comments/migration.sql
@@ -0,0 +1,23 @@
+-- Proposal discussion is mutable, off-chain application state. It never determines canon.
+ALTER TYPE "ModerationTargetType" ADD VALUE 'PROPOSAL_COMMENT';
+
+CREATE TABLE "ProposalComment" (
+ "id" TEXT NOT NULL,
+ "proposalId" TEXT NOT NULL,
+ "authorId" TEXT NOT NULL,
+ "body" VARCHAR(3000) NOT NULL,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "deletedAt" TIMESTAMP(3),
+ "deletedById" TEXT,
+
+ CONSTRAINT "ProposalComment_pkey" PRIMARY KEY ("id")
+);
+
+CREATE INDEX "ProposalComment_proposalId_createdAt_id_idx" ON "ProposalComment"("proposalId", "createdAt", "id");
+CREATE INDEX "ProposalComment_authorId_createdAt_idx" ON "ProposalComment"("authorId", "createdAt");
+CREATE INDEX "ProposalComment_deletedById_idx" ON "ProposalComment"("deletedById");
+
+ALTER TABLE "ProposalComment" ADD CONSTRAINT "ProposalComment_proposalId_fkey" FOREIGN KEY ("proposalId") REFERENCES "Proposal"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
+ALTER TABLE "ProposalComment" ADD CONSTRAINT "ProposalComment_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
+ALTER TABLE "ProposalComment" ADD CONSTRAINT "ProposalComment_deletedById_fkey" FOREIGN KEY ("deletedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+ALTER TABLE "ProposalComment" ADD CONSTRAINT "ProposalComment_body_not_empty" CHECK (length(btrim("body")) > 0);
diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma
index 96e8463..4f4d6ce 100644
--- a/apps/api/prisma/schema.prisma
+++ b/apps/api/prisma/schema.prisma
@@ -92,6 +92,7 @@ enum ModerationTargetType {
LORE_ENTRY
LORE_RELATIONSHIP
PROPOSAL
+ PROPOSAL_COMMENT
APP_VOTE
AI_REPORT
HIVE_REFERENCE
@@ -198,6 +199,8 @@ model User {
bibleVersions WorldBibleVersion[] @relation("WorldBibleVersionCreator")
loreEntries LoreEntry[] @relation("LoreEntryAuthor")
proposals Proposal[] @relation("ProposalAuthor")
+ proposalComments ProposalComment[] @relation("ProposalCommentAuthor")
+ deletedProposalComments ProposalComment[] @relation("ProposalCommentDeleter")
contributionDrafts ContributionDraft[] @relation("ContributionDraftAuthor")
votes AppVote[] @relation("AppVoteVoter")
moderationReports ModerationReport[] @relation("ModerationReporter")
@@ -521,6 +524,7 @@ model Proposal {
decision ProposalDecision?
aiReports AIReport[]
moderationReports ModerationReport[]
+ comments ProposalComment[]
@@index([worldId, status])
@@index([authorId])
@@ -531,6 +535,24 @@ model Proposal {
@@index([branchBaseLoreEntryId])
}
+model ProposalComment {
+ id String @id @default(cuid())
+ proposalId String
+ proposal Proposal @relation(fields: [proposalId], references: [id], onDelete: Restrict)
+ authorId String
+ author User @relation("ProposalCommentAuthor", fields: [authorId], references: [id], onDelete: Restrict)
+ body String @db.VarChar(3000)
+ createdAt DateTime @default(now())
+ deletedAt DateTime?
+ deletedById String?
+ deletedBy User? @relation("ProposalCommentDeleter", fields: [deletedById], references: [id], onDelete: SetNull)
+ /// Off-chain plain-text discussion. Comments never count as AppVote rows or canon decisions.
+
+ @@index([proposalId, createdAt, id])
+ @@index([authorId, createdAt])
+ @@index([deletedById])
+}
+
model ContributionDraft {
id String @id @default(cuid())
worldId String
diff --git a/apps/api/src/lib/proposal-comments.ts b/apps/api/src/lib/proposal-comments.ts
new file mode 100644
index 0000000..6bc3f19
--- /dev/null
+++ b/apps/api/src/lib/proposal-comments.ts
@@ -0,0 +1,237 @@
+import type { Prisma, PrismaClient } from '../generated/prisma/client.js';
+
+export const PROPOSAL_COMMENT_MAX_LENGTH = 3000;
+export const PROPOSAL_COMMENT_DEFAULT_PAGE_SIZE = 20;
+export const PROPOSAL_COMMENT_MAX_PAGE_SIZE = 50;
+
+export class ProposalCommentError extends Error {
+ constructor(
+ public readonly statusCode: number,
+ public readonly code: string,
+ message: string,
+ ) {
+ super(message);
+ }
+}
+
+export type ProposalCommentDatabase = PrismaClient;
+
+type CommentCursor = {
+ createdAt: string;
+ id: string;
+};
+
+type CommentWithAuthor = Prisma.ProposalCommentGetPayload<{
+ include: {
+ author: {
+ select: {
+ avatarUrl: true;
+ displayName: true;
+ hiveUsername: true;
+ id: true;
+ };
+ };
+ };
+}>;
+
+function encodeCursor(cursor: CommentCursor) {
+ return Buffer.from(JSON.stringify(cursor), 'utf8').toString('base64url');
+}
+
+export function decodeProposalCommentCursor(cursor: string): CommentCursor {
+ try {
+ const decoded = JSON.parse(
+ Buffer.from(cursor, 'base64url').toString('utf8'),
+ ) as Partial;
+
+ if (
+ typeof decoded.id !== 'string' ||
+ !decoded.id ||
+ typeof decoded.createdAt !== 'string' ||
+ Number.isNaN(Date.parse(decoded.createdAt))
+ ) {
+ throw new Error('Invalid cursor payload.');
+ }
+
+ return {
+ createdAt: decoded.createdAt,
+ id: decoded.id,
+ };
+ } catch {
+ throw new ProposalCommentError(400, 'INVALID_COMMENT_CURSOR', 'Invalid comment cursor.');
+ }
+}
+
+export function normalizeProposalCommentBody(body: string) {
+ const normalized = body.trim();
+
+ if (!normalized) {
+ throw new ProposalCommentError(400, 'COMMENT_BODY_EMPTY', 'Comment body is required.');
+ }
+
+ if (normalized.length > PROPOSAL_COMMENT_MAX_LENGTH) {
+ throw new ProposalCommentError(
+ 400,
+ 'COMMENT_BODY_TOO_LONG',
+ `Comment body must be ${PROPOSAL_COMMENT_MAX_LENGTH} characters or fewer.`,
+ );
+ }
+
+ return normalized;
+}
+
+function normalizePageSize(pageSize: number | undefined) {
+ if (!pageSize) {
+ return PROPOSAL_COMMENT_DEFAULT_PAGE_SIZE;
+ }
+
+ return Math.min(pageSize, PROPOSAL_COMMENT_MAX_PAGE_SIZE);
+}
+
+function serializeComment(comment: CommentWithAuthor) {
+ const isDeleted = Boolean(comment.deletedAt);
+
+ return {
+ author: {
+ avatarUrl: comment.author.avatarUrl,
+ displayName: comment.author.displayName,
+ hiveUsername: comment.author.hiveUsername,
+ id: comment.author.id,
+ },
+ authorId: comment.authorId,
+ body: isDeleted ? null : comment.body,
+ createdAt: comment.createdAt.toISOString(),
+ deletedAt: comment.deletedAt?.toISOString() ?? null,
+ id: comment.id,
+ isDeleted,
+ proposalId: comment.proposalId,
+ };
+}
+
+async function assertProposalInWorld(
+ database: ProposalCommentDatabase,
+ input: { proposalId: string; worldId: string },
+) {
+ const proposal = await database.proposal.findFirst({
+ select: {
+ id: true,
+ },
+ where: {
+ id: input.proposalId,
+ worldId: input.worldId,
+ },
+ });
+
+ if (!proposal) {
+ throw new ProposalCommentError(404, 'PROPOSAL_NOT_FOUND', 'Proposal not found.');
+ }
+}
+
+export async function listProposalComments(
+ database: ProposalCommentDatabase,
+ input: {
+ cursor?: string | undefined;
+ pageSize?: number | undefined;
+ proposalId: string;
+ worldId: string;
+ },
+) {
+ await assertProposalInWorld(database, input);
+
+ const pageSize = normalizePageSize(input.pageSize);
+ const cursor = input.cursor ? decodeProposalCommentCursor(input.cursor) : null;
+ const cursorDate = cursor ? new Date(cursor.createdAt) : null;
+ const where: Prisma.ProposalCommentWhereInput = {
+ proposalId: input.proposalId,
+ ...(cursor && cursorDate
+ ? {
+ OR: [
+ {
+ createdAt: {
+ gt: cursorDate,
+ },
+ },
+ {
+ createdAt: cursorDate,
+ id: {
+ gt: cursor.id,
+ },
+ },
+ ],
+ }
+ : {}),
+ };
+ const [comments, commentCount] = await Promise.all([
+ database.proposalComment.findMany({
+ include: {
+ author: {
+ select: {
+ avatarUrl: true,
+ displayName: true,
+ hiveUsername: true,
+ id: true,
+ },
+ },
+ },
+ orderBy: [{ createdAt: 'asc' }, { id: 'asc' }],
+ take: pageSize + 1,
+ where,
+ }),
+ database.proposalComment.count({
+ where: {
+ deletedAt: null,
+ proposalId: input.proposalId,
+ },
+ }),
+ ]);
+
+ const page = comments.slice(0, pageSize);
+ const last = page.at(-1);
+ const hasMore = comments.length > pageSize;
+
+ return {
+ comments: page.map(serializeComment),
+ pageInfo: {
+ hasMore,
+ nextCursor:
+ hasMore && last
+ ? encodeCursor({
+ createdAt: last.createdAt.toISOString(),
+ id: last.id,
+ })
+ : null,
+ },
+ totalCount: commentCount,
+ };
+}
+
+export async function createProposalComment(
+ database: ProposalCommentDatabase,
+ input: { authorId: string; body: string; proposalId: string; worldId: string },
+) {
+ const body = normalizeProposalCommentBody(input.body);
+
+ await assertProposalInWorld(database, input);
+
+ const comment = await database.proposalComment.create({
+ data: {
+ authorId: input.authorId,
+ body,
+ proposalId: input.proposalId,
+ },
+ include: {
+ author: {
+ select: {
+ avatarUrl: true,
+ displayName: true,
+ hiveUsername: true,
+ id: true,
+ },
+ },
+ },
+ });
+
+ return {
+ comment: serializeComment(comment),
+ };
+}
diff --git a/apps/api/src/routes/proposals.test.ts b/apps/api/src/routes/proposals.test.ts
new file mode 100644
index 0000000..b892161
--- /dev/null
+++ b/apps/api/src/routes/proposals.test.ts
@@ -0,0 +1,480 @@
+import assert from 'node:assert/strict';
+import { describe, test } from 'node:test';
+
+import Fastify from 'fastify';
+
+import { env } from '../config/env.js';
+import { PlatformRole, ProposalStatus, WorldRole } from '../generated/prisma/enums.js';
+import { signAccessToken } from '../lib/auth-crypto.js';
+import { PROPOSAL_COMMENT_MAX_LENGTH } from '../lib/proposal-comments.js';
+import { registerProposalRoutes } from './proposals.js';
+
+const author = {
+ hiveUsername: 'mira-vale.dev',
+ id: 'user-author',
+ normalizedHiveUsername: 'mira-vale.dev',
+};
+
+const reader = {
+ hiveUsername: 'emberquill.dev',
+ id: 'user-reader',
+ normalizedHiveUsername: 'emberquill.dev',
+};
+
+const outsider = {
+ hiveUsername: 'stranger.dev',
+ id: 'user-outsider',
+ normalizedHiveUsername: 'stranger.dev',
+};
+
+function authHeader(user = reader) {
+ const token = signAccessToken(
+ {
+ hiveUsername: user.hiveUsername,
+ normalizedHiveUsername: user.normalizedHiveUsername,
+ platformRole: PlatformRole.USER,
+ sid: `session-${user.id}`,
+ sub: user.id,
+ },
+ {
+ audience: env.AUTH_JWT_AUDIENCE,
+ issuer: env.AUTH_JWT_ISSUER,
+ secret: env.AUTH_JWT_SECRET,
+ ttlSeconds: 900,
+ },
+ );
+
+ return {
+ authorization: `Bearer ${token}`,
+ };
+}
+
+function now(offsetMs = 0) {
+ return new Date(new Date('2026-08-10T12:00:00.000Z').getTime() + offsetMs);
+}
+
+type StoredComment = {
+ authorId: string;
+ body: string;
+ createdAt: Date;
+ deletedAt: Date | null;
+ deletedById: string | null;
+ id: string;
+ proposalId: string;
+};
+
+function encodeCursor(input: { createdAt: string; id: string }) {
+ return Buffer.from(JSON.stringify(input), 'utf8').toString('base64url');
+}
+
+function createDatabase() {
+ const users = [
+ { ...author, avatarUrl: null, displayName: 'Mira Vale' },
+ { ...reader, avatarUrl: null, displayName: null },
+ { ...outsider, avatarUrl: null, displayName: null },
+ ];
+ const proposals = [
+ {
+ authorId: author.id,
+ id: 'proposal-1',
+ status: ProposalStatus.VOTING,
+ votingEndsAt: now(48 * 60 * 60 * 1000),
+ worldId: 'world-1',
+ },
+ {
+ authorId: author.id,
+ id: 'proposal-closed',
+ status: ProposalStatus.REJECTED,
+ votingEndsAt: now(-1),
+ worldId: 'world-1',
+ },
+ {
+ authorId: author.id,
+ id: 'proposal-2',
+ status: ProposalStatus.VOTING,
+ votingEndsAt: now(48 * 60 * 60 * 1000),
+ worldId: 'world-2',
+ },
+ ];
+ const memberships = [
+ {
+ id: 'membership-reader',
+ revokedAt: null,
+ role: WorldRole.READER,
+ userId: reader.id,
+ worldId: 'world-1',
+ },
+ {
+ id: 'membership-author',
+ revokedAt: null,
+ role: WorldRole.CONTRIBUTOR,
+ userId: author.id,
+ worldId: 'world-1',
+ },
+ ];
+ const comments: StoredComment[] = [];
+ const appVotes: unknown[] = [];
+ const touchedVotes: string[] = [];
+
+ function includeAuthor(comment: StoredComment) {
+ const user = users.find((candidate) => candidate.id === comment.authorId)!;
+
+ return {
+ ...comment,
+ author: {
+ avatarUrl: user.avatarUrl,
+ displayName: user.displayName,
+ hiveUsername: user.hiveUsername,
+ id: user.id,
+ },
+ };
+ }
+
+ const database = {
+ appVote: {
+ async create() {
+ touchedVotes.push('create');
+ },
+ async update() {
+ touchedVotes.push('update');
+ },
+ async upsert() {
+ touchedVotes.push('upsert');
+ },
+ },
+ proposal: {
+ async findFirst(args: { where: { id: string; worldId: string } }) {
+ return (
+ proposals.find(
+ (proposal) => proposal.id === args.where.id && proposal.worldId === args.where.worldId,
+ ) ?? null
+ );
+ },
+ },
+ proposalComment: {
+ async count(args: { where: { deletedAt?: null; proposalId: string } }) {
+ return comments.filter(
+ (comment) =>
+ comment.proposalId === args.where.proposalId &&
+ (!('deletedAt' in args.where) || comment.deletedAt === args.where.deletedAt),
+ ).length;
+ },
+ async create(args: { data: { authorId: string; body: string; proposalId: string } }) {
+ const comment = {
+ ...args.data,
+ createdAt: now(comments.length),
+ deletedAt: null,
+ deletedById: null,
+ id: `comment-${comments.length + 1}`,
+ };
+ comments.push(comment);
+ return includeAuthor(comment);
+ },
+ async findMany(args: {
+ orderBy: unknown;
+ take: number;
+ where: {
+ OR?: Array<{
+ createdAt: Date | { gt: Date };
+ id?: { gt: string };
+ }>;
+ proposalId: string;
+ };
+ }) {
+ return comments
+ .filter((comment) => comment.proposalId === args.where.proposalId)
+ .filter((comment) => {
+ if (!args.where.OR) {
+ return true;
+ }
+
+ return args.where.OR.some((condition) => {
+ if (
+ condition.createdAt instanceof Date &&
+ condition.id &&
+ comment.createdAt.getTime() === condition.createdAt.getTime()
+ ) {
+ return comment.id > condition.id.gt;
+ }
+
+ if (
+ typeof condition.createdAt === 'object' &&
+ 'gt' in condition.createdAt &&
+ condition.createdAt.gt instanceof Date
+ ) {
+ return comment.createdAt.getTime() > condition.createdAt.gt.getTime();
+ }
+
+ return false;
+ });
+ })
+ .sort((left, right) => {
+ const createdAtOrder = left.createdAt.getTime() - right.createdAt.getTime();
+
+ if (createdAtOrder !== 0) {
+ return createdAtOrder;
+ }
+
+ return left.id.localeCompare(right.id);
+ })
+ .slice(0, args.take)
+ .map(includeAuthor);
+ },
+ },
+ refreshSession: {
+ async findUnique(args: { where: { id: string } }) {
+ if (!args.where.id.startsWith('session-')) {
+ return null;
+ }
+
+ return {
+ expiresAt: new Date('2026-08-11T13:00:00.000Z'),
+ revokedAt: null,
+ userId: args.where.id.replace('session-', ''),
+ };
+ },
+ },
+ worldMembership: {
+ async findUnique(args: {
+ where: { worldId_userId: { userId: string; worldId: string }; revokedAt: null };
+ }) {
+ return (
+ memberships.find(
+ (membership) =>
+ membership.userId === args.where.worldId_userId.userId &&
+ membership.worldId === args.where.worldId_userId.worldId &&
+ !membership.revokedAt,
+ ) ?? null
+ );
+ },
+ },
+ };
+
+ return {
+ appVotes,
+ comments,
+ database,
+ memberships,
+ touchedVotes,
+ };
+}
+
+async function createApp(database: ReturnType['database']) {
+ const app = Fastify();
+ await registerProposalRoutes(app, {
+ database: database as never,
+ });
+ return app;
+}
+
+describe('proposal comment routes', () => {
+ test('authenticated eligible user can create a trimmed comment without creating votes or trusting protected fields', async () => {
+ const state = createDatabase();
+ const app = await createApp(state.database);
+
+ const protectedFieldResponse = await app.inject({
+ headers: authHeader(),
+ method: 'POST',
+ payload: {
+ authorId: author.id,
+ body: 'Nope',
+ createdAt: '2026-08-10T00:00:00.000Z',
+ role: WorldRole.CURATOR,
+ },
+ url: '/worlds/world-1/proposals/proposal-1/comments',
+ });
+ const response = await app.inject({
+ headers: authHeader(),
+ method: 'POST',
+ payload: {
+ body: ' \nUseful note. ',
+ },
+ url: '/worlds/world-1/proposals/proposal-1/comments',
+ });
+
+ assert.equal(protectedFieldResponse.statusCode, 400);
+ assert.equal(response.statusCode, 201);
+ assert.equal(response.json().comment.authorId, reader.id);
+ assert.equal(response.json().comment.body, '\nUseful note.');
+ assert.equal(state.comments.length, 1);
+ assert.equal(state.comments[0]?.proposalId, 'proposal-1');
+ assert.equal(state.comments[0]?.authorId, reader.id);
+ assert.equal(state.touchedVotes.length, 0);
+ assert.equal(state.appVotes.length, 0);
+ await app.close();
+ });
+
+ test('rejects unauthenticated, unauthorized, empty, whitespace, oversized, unknown, and world-mismatched comment writes', async () => {
+ const state = createDatabase();
+ const app = await createApp(state.database);
+
+ const unauthenticated = await app.inject({
+ method: 'POST',
+ payload: { body: 'Hello' },
+ url: '/worlds/world-1/proposals/proposal-1/comments',
+ });
+ const forbidden = await app.inject({
+ headers: authHeader(outsider),
+ method: 'POST',
+ payload: { body: 'Hello' },
+ url: '/worlds/world-1/proposals/proposal-1/comments',
+ });
+ const empty = await app.inject({
+ headers: authHeader(),
+ method: 'POST',
+ payload: { body: '' },
+ url: '/worlds/world-1/proposals/proposal-1/comments',
+ });
+ const whitespace = await app.inject({
+ headers: authHeader(),
+ method: 'POST',
+ payload: { body: ' \n\t' },
+ url: '/worlds/world-1/proposals/proposal-1/comments',
+ });
+ const maxLength = await app.inject({
+ headers: authHeader(),
+ method: 'POST',
+ payload: { body: 'x'.repeat(PROPOSAL_COMMENT_MAX_LENGTH) },
+ url: '/worlds/world-1/proposals/proposal-1/comments',
+ });
+ const oversized = await app.inject({
+ headers: authHeader(),
+ method: 'POST',
+ payload: { body: 'x'.repeat(PROPOSAL_COMMENT_MAX_LENGTH + 1) },
+ url: '/worlds/world-1/proposals/proposal-1/comments',
+ });
+ const missingProposal = await app.inject({
+ headers: authHeader(),
+ method: 'POST',
+ payload: { body: 'Hello' },
+ url: '/worlds/world-1/proposals/missing/comments',
+ });
+ const wrongWorld = await app.inject({
+ headers: authHeader(),
+ method: 'POST',
+ payload: { body: 'Hello' },
+ url: '/worlds/world-2/proposals/proposal-1/comments',
+ });
+
+ assert.equal(unauthenticated.statusCode, 401);
+ assert.equal(forbidden.statusCode, 403);
+ assert.equal(empty.statusCode, 400);
+ assert.equal(whitespace.statusCode, 400);
+ assert.equal(maxLength.statusCode, 201);
+ assert.equal(oversized.statusCode, 400);
+ assert.equal(missingProposal.statusCode, 404);
+ assert.equal(wrongWorld.statusCode, 403);
+ assert.equal(state.comments.length, 1);
+ await app.close();
+ });
+
+ test('comments are readable in deterministic pages, scoped to the proposal, and deleted bodies are tombstoned', async () => {
+ const state = createDatabase();
+ const sharedCreatedAt = now();
+ state.comments.push(
+ {
+ authorId: reader.id,
+ body: 'First',
+ createdAt: sharedCreatedAt,
+ deletedAt: null,
+ deletedById: null,
+ id: 'comment-a',
+ proposalId: 'proposal-1',
+ },
+ {
+ authorId: author.id,
+ body: 'Hidden body',
+ createdAt: sharedCreatedAt,
+ deletedAt: now(10),
+ deletedById: author.id,
+ id: 'comment-b',
+ proposalId: 'proposal-1',
+ },
+ {
+ authorId: reader.id,
+ body: 'Other proposal',
+ createdAt: now(20),
+ deletedAt: null,
+ deletedById: null,
+ id: 'comment-c',
+ proposalId: 'proposal-2',
+ },
+ );
+ const app = await createApp(state.database);
+
+ const firstPage = await app.inject({
+ method: 'GET',
+ url: '/worlds/world-1/proposals/proposal-1/comments?pageSize=1',
+ });
+ const secondPage = await app.inject({
+ method: 'GET',
+ url: `/worlds/world-1/proposals/proposal-1/comments?pageSize=50&cursor=${firstPage.json().pageInfo.nextCursor}`,
+ });
+ const cappedPage = await app.inject({
+ method: 'GET',
+ url: '/worlds/world-1/proposals/proposal-1/comments?pageSize=500',
+ });
+
+ assert.equal(firstPage.statusCode, 200);
+ assert.deepEqual(
+ firstPage.json().comments.map((comment: { id: string }) => comment.id),
+ ['comment-a'],
+ );
+ assert.equal(firstPage.json().pageInfo.hasMore, true);
+ assert.equal(firstPage.json().totalCount, 1);
+ assert.equal(secondPage.statusCode, 200);
+ assert.deepEqual(
+ secondPage
+ .json()
+ .comments.map((comment: { body: string | null; id: string }) => [comment.id, comment.body]),
+ [['comment-b', null]],
+ );
+ assert.equal(secondPage.json().comments[0].isDeleted, true);
+ assert.equal(cappedPage.json().comments.length, 2);
+ await app.close();
+ });
+
+ test('malformed cursors and world mismatches are rejected safely while closed proposal comments remain readable and writable', async () => {
+ const state = createDatabase();
+ state.comments.push({
+ authorId: reader.id,
+ body: 'Still readable',
+ createdAt: now(),
+ deletedAt: null,
+ deletedById: null,
+ id: 'comment-closed',
+ proposalId: 'proposal-closed',
+ });
+ const app = await createApp(state.database);
+
+ const malformedCursor = await app.inject({
+ method: 'GET',
+ url: '/worlds/world-1/proposals/proposal-1/comments?cursor=not-a-cursor',
+ });
+ const mismatchedRead = await app.inject({
+ method: 'GET',
+ url: '/worlds/world-2/proposals/proposal-1/comments',
+ });
+ const closedRead = await app.inject({
+ method: 'GET',
+ url: `/worlds/world-1/proposals/proposal-closed/comments?cursor=${encodeCursor({
+ createdAt: new Date('2020-01-01T00:00:00.000Z').toISOString(),
+ id: 'comment-0',
+ })}`,
+ });
+ const closedWrite = await app.inject({
+ headers: authHeader(),
+ method: 'POST',
+ payload: { body: 'Comment after the vote window.' },
+ url: '/worlds/world-1/proposals/proposal-closed/comments',
+ });
+
+ assert.equal(malformedCursor.statusCode, 400);
+ assert.equal(mismatchedRead.statusCode, 404);
+ assert.equal(closedRead.statusCode, 200);
+ assert.equal(closedRead.json().comments[0].body, 'Still readable');
+ assert.equal(closedWrite.statusCode, 201);
+ assert.equal(state.touchedVotes.length, 0);
+ await app.close();
+ });
+});
diff --git a/apps/api/src/routes/proposals.ts b/apps/api/src/routes/proposals.ts
index a624032..76978ef 100644
--- a/apps/api/src/routes/proposals.ts
+++ b/apps/api/src/routes/proposals.ts
@@ -14,6 +14,12 @@ import {
} from '../lib/canon-voting.js';
import { HafClient } from '../lib/hive/haf-client.js';
import { prisma } from '../lib/prisma.js';
+import {
+ createProposalComment,
+ listProposalComments,
+ ProposalCommentError,
+ PROPOSAL_COMMENT_MAX_LENGTH,
+} from '../lib/proposal-comments.js';
import { authorizeWorldPermission } from '../lib/world-authorization.js';
import type { WorldMembershipLookup } from '../lib/world-authorization.js';
import { WORLD_PERMISSIONS } from '../lib/world-permissions.js';
@@ -29,6 +35,17 @@ const voteBodySchema = z
})
.strict();
+const commentsQuerySchema = z.object({
+ cursor: z.string().min(1).optional(),
+ pageSize: z.coerce.number().int().positive().optional(),
+});
+
+const commentBodySchema = z
+ .object({
+ body: z.string().max(PROPOSAL_COMMENT_MAX_LENGTH + 1),
+ })
+ .strict();
+
const confirmBodySchema = z
.object({
blockNumber: z.coerce.number().int().positive(),
@@ -62,6 +79,17 @@ function handleCanonVotingError(error: unknown, reply: FastifyReply) {
throw error;
}
+function handleProposalCommentError(error: unknown, reply: FastifyReply) {
+ if (error instanceof ProposalCommentError) {
+ return reply.code(error.statusCode).send({
+ code: error.code,
+ error: error.message,
+ });
+ }
+
+ throw error;
+}
+
export async function registerProposalRoutes(
app: FastifyInstance,
options: RegisterProposalRoutesOptions = {},
@@ -113,6 +141,72 @@ export async function registerProposalRoutes(
}
});
+ app.get('/worlds/:worldId/proposals/:proposalId/comments', async (request, reply) => {
+ const params = paramsSchema.safeParse(request.params);
+ const query = commentsQuerySchema.safeParse(request.query);
+
+ if (!params.success || !query.success) {
+ return reply.code(400).send({
+ code: 'INVALID_COMMENT_QUERY',
+ error: 'Invalid comment query.',
+ });
+ }
+
+ try {
+ return listProposalComments(database, {
+ cursor: query.data.cursor,
+ pageSize: query.data.pageSize,
+ proposalId: params.data.proposalId,
+ worldId: params.data.worldId,
+ });
+ } catch (error) {
+ return handleProposalCommentError(error, reply);
+ }
+ });
+
+ app.post(
+ '/worlds/:worldId/proposals/:proposalId/comments',
+ {
+ preHandler: requireSession(authOptions(database)),
+ },
+ async (request, reply) => {
+ const params = paramsSchema.safeParse(request.params);
+ const body = commentBodySchema.safeParse(request.body);
+
+ if (!params.success || !body.success) {
+ return reply.code(400).send({
+ code: 'INVALID_COMMENT_PAYLOAD',
+ error: 'Invalid comment payload.',
+ });
+ }
+
+ const authorized = await authorizeWorldPermission(
+ request,
+ reply,
+ params.data.worldId,
+ WORLD_PERMISSIONS.VOTE_ON_PROPOSAL,
+ database,
+ );
+
+ if (!authorized || !request.user) {
+ return;
+ }
+
+ try {
+ const result = await createProposalComment(database, {
+ authorId: request.user.id,
+ body: body.data.body,
+ proposalId: params.data.proposalId,
+ worldId: params.data.worldId,
+ });
+
+ return reply.code(201).send(result);
+ } catch (error) {
+ return handleProposalCommentError(error, reply);
+ }
+ },
+ );
+
app.post(
'/worlds/:worldId/proposals/:proposalId/votes',
{
diff --git a/apps/web/src/app/worlds/[worldId]/proposals/[proposalId]/canon-vote-screen.tsx b/apps/web/src/app/worlds/[worldId]/proposals/[proposalId]/canon-vote-screen.tsx
index 0272e32..a937e53 100644
--- a/apps/web/src/app/worlds/[worldId]/proposals/[proposalId]/canon-vote-screen.tsx
+++ b/apps/web/src/app/worlds/[worldId]/proposals/[proposalId]/canon-vote-screen.tsx
@@ -1,6 +1,12 @@
'use client';
-import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
+import {
+ type InfiniteData,
+ useInfiniteQuery,
+ useMutation,
+ useQuery,
+ useQueryClient,
+} from '@tanstack/react-query';
import { useEffect, useMemo, useState } from 'react';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
@@ -8,12 +14,17 @@ import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { LoadingState } from '@/components/states/loading-state';
+import { Textarea } from '@/components/ui/textarea';
import {
castProposalVote,
confirmCanonTransaction,
+ createProposalComment,
createCanonTransaction,
finalizeProposal,
getProposal,
+ getProposalComments,
+ PROPOSAL_COMMENT_MAX_LENGTH,
+ type ProposalCommentsResponse,
type ProposalDetail,
type VoteChoice,
} from '@/lib/api/proposals';
@@ -192,6 +203,31 @@ function ProgressBar({ label, value }: { label: string; value: number }) {
);
}
+function formatCommentTime(value: string) {
+ const date = new Date(value);
+ const diffMs = Date.now() - date.getTime();
+ const minute = 60_000;
+ const hour = 60 * minute;
+ const day = 24 * hour;
+
+ if (diffMs >= 0 && diffMs < minute) {
+ return 'Just now';
+ }
+
+ if (diffMs >= 0 && diffMs < hour) {
+ return `${Math.floor(diffMs / minute)}m ago`;
+ }
+
+ if (diffMs >= 0 && diffMs < day) {
+ return `${Math.floor(diffMs / hour)}h ago`;
+ }
+
+ return new Intl.DateTimeFormat(undefined, {
+ dateStyle: 'medium',
+ timeStyle: 'short',
+ }).format(date);
+}
+
export function CanonVoteScreen({ proposalId, worldId }: { proposalId: string; worldId: string }) {
const queryClient = useQueryClient();
const [accessToken, setAccessToken] = useState(null);
@@ -203,6 +239,7 @@ export function CanonVoteScreen({ proposalId, worldId }: { proposalId: string; w
transactionId: '',
});
const [message, setMessage] = useState(null);
+ const [commentBody, setCommentBody] = useState('');
useEffect(() => {
let mounted = true;
@@ -238,9 +275,30 @@ export function CanonVoteScreen({ proposalId, worldId }: { proposalId: string; w
queryKey: ['proposal', worldId, proposalId, accessToken],
});
+ const commentsQuery = useInfiniteQuery<
+ ProposalCommentsResponse,
+ Error,
+ InfiniteData,
+ [string, string, string],
+ string | null
+ >({
+ getNextPageParam: (lastPage) => lastPage.pageInfo.nextCursor ?? undefined,
+ initialPageParam: null as string | null,
+ queryFn: ({ pageParam }) =>
+ getProposalComments({
+ cursor: pageParam,
+ pageSize: 20,
+ proposalId,
+ worldId,
+ }),
+ queryKey: ['proposal-comments', worldId, proposalId],
+ });
+
const proposal = proposalQuery.data?.proposal;
const invalidateProposal = () =>
queryClient.invalidateQueries({ queryKey: ['proposal', worldId, proposalId] });
+ const invalidateComments = () =>
+ queryClient.invalidateQueries({ queryKey: ['proposal-comments', worldId, proposalId] });
const voteMutation = useMutation({
mutationFn: (choice: VoteChoice) =>
@@ -299,6 +357,29 @@ export function CanonVoteScreen({ proposalId, worldId }: { proposalId: string; w
},
});
+ const trimmedCommentBody = commentBody.trim();
+ const commentTooLong = commentBody.length > PROPOSAL_COMMENT_MAX_LENGTH;
+ const commentValidationMessage = !trimmedCommentBody
+ ? 'Write a comment before posting.'
+ : commentTooLong
+ ? `Comments can be ${PROPOSAL_COMMENT_MAX_LENGTH} characters or fewer.`
+ : null;
+
+ const commentMutation = useMutation({
+ mutationFn: () =>
+ createProposalComment({
+ accessToken: accessToken ?? '',
+ body: commentBody,
+ proposalId,
+ worldId,
+ }),
+ onSuccess: () => {
+ setCommentBody('');
+ setMessage('Comment posted.');
+ void invalidateComments();
+ },
+ });
+
const votingClosed = useMemo(() => {
if (!proposal?.votingEndsAt) {
return true;
@@ -378,7 +459,8 @@ export function CanonVoteScreen({ proposalId, worldId }: { proposalId: string; w
{voteMutation.error ||
finalizeMutation.error ||
signMutation.error ||
- confirmMutation.error ? (
+ confirmMutation.error ||
+ commentMutation.error ? (
Action failed
@@ -386,7 +468,8 @@ export function CanonVoteScreen({ proposalId, worldId }: { proposalId: string; w
voteMutation.error ??
finalizeMutation.error ??
signMutation.error ??
- confirmMutation.error,
+ confirmMutation.error ??
+ commentMutation.error,
)}
@@ -436,6 +519,136 @@ export function CanonVoteScreen({ proposalId, worldId }: { proposalId: string; w
) : null}
+
+
+
+
+
Discussion
+
+ {commentsQuery.data?.pages[0]?.totalCount ?? 0} comments
+
+
+ {commentsQuery.isError ? (
+
+ ) : null}
+
+
+ {commentsQuery.isLoading ? (
+
+ ) : commentsQuery.isError ? (
+
+ Comments unavailable
+ {getErrorMessage(commentsQuery.error)}
+
+ ) : null}
+
+ {commentsQuery.data &&
+ commentsQuery.data.pages.every((page) => page.comments.length === 0) ? (
+
+ No discussion yet.
+
+ ) : null}
+
+ {commentsQuery.data ? (
+
+ {commentsQuery.data.pages.flatMap((page) =>
+ page.comments.map((comment) => (
+
+
+ @{comment.author.hiveUsername}
+ {comment.author.displayName ? (
+
+ {comment.author.displayName}
+
+ ) : null}
+
+
+ {comment.isDeleted ? (
+
+ This comment was removed by moderation.
+
+ ) : (
+
+ {comment.body}
+
+ )}
+
+ )),
+ )}
+
+ ) : null}
+
+ {commentsQuery.hasNextPage ? (
+
+ ) : null}
+
+
+
) : null}
+ {isAuthor ? (
+
+ Proposal authors are excluded from the eligible vote tally.
+
+ ) : null}
Governance Actions
+