diff --git a/README.md b/README.md index 2b691ca..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 @@ -243,6 +244,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: @@ -319,6 +372,30 @@ Auth challenge, verification, and refresh endpoints use Fastify rate limiting ba 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: + +- `POST /worlds`: requires authentication, creates a mutable off-chain World Seed and initial `WorldBibleVersion` in one transaction, and assigns the authenticated user an active `FOUNDER` membership. The route never accepts a client-supplied founder or role and does not publish to Hive. +- `GET /worlds`: browses discoverable worlds with pagination and basic title/description, genre, and tone filters. +- `GET /worlds/:worldId`: returns one world with its founder, seed, and current latest World Bible version. +- `GET /worlds/:worldId/hub`: returns the World Hub aggregate for the frontend, including world seed, current bible preview, simple canon/proposal stats, and latest canon lore entry summaries. +- `PATCH /worlds/:worldId`: requires `EDIT_INITIAL_CANON` world permission and updates mutable off-chain world title/description, seed, and latest initial bible content only. It does not create proposals, canonize content, or publish to Hive. + +Implemented contribution endpoints: + +- `POST /worlds/:worldId/contributions`: requires authentication and `CREATE_LORE_DRAFT` world permission, creates an author-owned structured contribution draft for lore or story content, and optionally links it to a lore entry in the same world. +- `GET /worlds/:worldId/contributions`: requires authentication and lists only the authenticated writer's contributions in the world with page/pageSize pagination and optional status/kind filters. +- `GET /worlds/:worldId/contributions/:contributionId`: returns an authenticated writer's own contribution draft or submitted contribution without exposing other writers' private drafts. +- `PATCH /worlds/:worldId/contributions/:contributionId`: requires `EDIT_OWN_DRAFT`, updates only mutable draft fields, and rejects submitted contributions. +- `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/20260805123000_add_world_seed/migration.sql b/apps/api/prisma/migrations/20260805123000_add_world_seed/migration.sql new file mode 100644 index 0000000..d69a67b --- /dev/null +++ b/apps/api/prisma/migrations/20260805123000_add_world_seed/migration.sql @@ -0,0 +1,22 @@ +CREATE TABLE "WorldSeed" ( + "id" TEXT NOT NULL, + "worldId" TEXT NOT NULL, + "premise" TEXT NOT NULL, + "genre" TEXT NOT NULL, + "tone" TEXT NOT NULL, + "mainConflict" TEXT NOT NULL, + "startingLocation" TEXT, + "firstCharacters" JSONB, + "firstFactions" JSONB, + "firstHistoricalEvent" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "WorldSeed_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "WorldSeed_worldId_key" ON "WorldSeed"("worldId"); +CREATE INDEX "WorldSeed_genre_idx" ON "WorldSeed"("genre"); +CREATE INDEX "WorldSeed_tone_idx" ON "WorldSeed"("tone"); + +ALTER TABLE "WorldSeed" ADD CONSTRAINT "WorldSeed_worldId_fkey" FOREIGN KEY ("worldId") REFERENCES "World"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/apps/api/prisma/migrations/20260805170000_add_contribution_drafts/migration.sql b/apps/api/prisma/migrations/20260805170000_add_contribution_drafts/migration.sql new file mode 100644 index 0000000..2f95fad --- /dev/null +++ b/apps/api/prisma/migrations/20260805170000_add_contribution_drafts/migration.sql @@ -0,0 +1,70 @@ +-- CreateEnum +CREATE TYPE "ContributionKind" AS ENUM ('LORE', 'STORY'); + +-- CreateEnum +CREATE TYPE "ContributionStatus" AS ENUM ('DRAFT', 'SUBMITTED'); + +-- AlterEnum +ALTER TYPE "ProposalType" ADD VALUE 'ADD_STORY'; + +-- AlterEnum +ALTER TYPE "WorldAuditAction" ADD VALUE 'CONTRIBUTION_CREATED'; +ALTER TYPE "WorldAuditAction" ADD VALUE 'CONTRIBUTION_UPDATED'; +ALTER TYPE "WorldAuditAction" ADD VALUE 'CONTRIBUTION_DELETED'; +ALTER TYPE "WorldAuditAction" ADD VALUE 'CONTRIBUTION_SUBMITTED'; +ALTER TYPE "WorldAuditAction" ADD VALUE 'PROPOSAL_CREATED'; + +-- AlterTable +ALTER TABLE "Proposal" ADD COLUMN "contributionKind" "ContributionKind"; + +-- CreateTable +CREATE TABLE "ContributionDraft" ( + "id" TEXT NOT NULL, + "worldId" TEXT NOT NULL, + "authorId" TEXT NOT NULL, + "targetLoreEntryId" TEXT, + "kind" "ContributionKind" NOT NULL, + "title" TEXT NOT NULL, + "summary" TEXT, + "content" JSONB NOT NULL, + "status" "ContributionStatus" NOT NULL DEFAULT 'DRAFT', + "proposalId" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "submittedAt" TIMESTAMP(3), + + CONSTRAINT "ContributionDraft_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "ContributionDraft_proposalId_key" ON "ContributionDraft"("proposalId"); + +-- CreateIndex +CREATE INDEX "ContributionDraft_worldId_idx" ON "ContributionDraft"("worldId"); + +-- CreateIndex +CREATE INDEX "ContributionDraft_authorId_idx" ON "ContributionDraft"("authorId"); + +-- CreateIndex +CREATE INDEX "ContributionDraft_worldId_status_idx" ON "ContributionDraft"("worldId", "status"); + +-- CreateIndex +CREATE INDEX "ContributionDraft_worldId_authorId_status_idx" ON "ContributionDraft"("worldId", "authorId", "status"); + +-- CreateIndex +CREATE INDEX "ContributionDraft_targetLoreEntryId_idx" ON "ContributionDraft"("targetLoreEntryId"); + +-- CreateIndex +CREATE INDEX "Proposal_worldId_contributionKind_idx" ON "Proposal"("worldId", "contributionKind"); + +-- AddForeignKey +ALTER TABLE "ContributionDraft" ADD CONSTRAINT "ContributionDraft_worldId_fkey" FOREIGN KEY ("worldId") REFERENCES "World"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ContributionDraft" ADD CONSTRAINT "ContributionDraft_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ContributionDraft" ADD CONSTRAINT "ContributionDraft_targetLoreEntryId_fkey" FOREIGN KEY ("targetLoreEntryId") REFERENCES "LoreEntry"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ContributionDraft" ADD CONSTRAINT "ContributionDraft_proposalId_fkey" FOREIGN KEY ("proposalId") REFERENCES "Proposal"("id") ON DELETE SET NULL ON UPDATE CASCADE; 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/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 46e369c..4f4d6ce 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -29,16 +29,29 @@ enum LoreStatus { enum ProposalType { ADD_LORE + ADD_STORY UPDATE_LORE UPDATE_WORLD_BIBLE } +enum ContributionKind { + LORE + STORY +} + +enum ContributionStatus { + DRAFT + SUBMITTED +} + enum ProposalStatus { DRAFT SUBMITTED VOTING APPROVED_FOR_PUBLICATION REJECTED + NEEDS_REVISION + ALTERNATE_TIMELINE PUBLISHED WITHDRAWN } @@ -46,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 @@ -69,6 +92,7 @@ enum ModerationTargetType { LORE_ENTRY LORE_RELATIONSHIP PROPOSAL + PROPOSAL_COMMENT APP_VOTE AI_REPORT HIVE_REFERENCE @@ -129,6 +153,13 @@ enum WorldAuditAction { MODERATION_ACTION CANON_STATUS_EXECUTED AI_WARNING_RESOLVED + CONTRIBUTION_CREATED + CONTRIBUTION_UPDATED + CONTRIBUTION_DELETED + CONTRIBUTION_SUBMITTED + PROPOSAL_CREATED + CANON_DECISION_FINALIZED + WORLD_BIBLE_UPDATED } enum AuthProvider { @@ -168,6 +199,9 @@ 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") moderationReviews ModerationReport[] @relation("ModerationReviewer") @@ -296,27 +330,49 @@ model RcDelegation { } model World { - id String @id @default(cuid()) - slug String @unique - title String - description String - founderId String - founder User @relation("WorldFounder", fields: [founderId], references: [id], onDelete: Restrict) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - bibleVersions WorldBibleVersion[] - loreEntries LoreEntry[] - loreRelationships LoreRelationship[] - proposals Proposal[] - moderationReports ModerationReport[] - searchIndexes SearchIndex[] - memberships WorldMembership[] - auditLogs WorldAuditLog[] + id String @id @default(cuid()) + slug String @unique + title String + description String + founderId String + founder User @relation("WorldFounder", fields: [founderId], references: [id], onDelete: Restrict) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + seed WorldSeed? + bibleVersions WorldBibleVersion[] + loreEntries LoreEntry[] + loreRelationships LoreRelationship[] + proposals Proposal[] + contributionDrafts ContributionDraft[] + moderationReports ModerationReport[] + searchIndexes SearchIndex[] + memberships WorldMembership[] + auditLogs WorldAuditLog[] @@index([founderId]) @@index([createdAt]) } +model WorldSeed { + id String @id @default(cuid()) + worldId String @unique + world World @relation(fields: [worldId], references: [id], onDelete: Restrict) + premise String + genre String + tone String + mainConflict String + startingLocation String? + firstCharacters Json? + firstFactions Json? + firstHistoricalEvent String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + /// Mutable off-chain world seed. Hive publication requires a future approved publication flow. + + @@index([genre]) + @@index([tone]) +} + model WorldMembership { id String @id @default(cuid()) worldId String @@ -380,26 +436,27 @@ model WorldBibleVersion { } model LoreEntry { - id String @id @default(cuid()) - worldId String - world World @relation(fields: [worldId], references: [id], onDelete: Restrict) - authorId String - author User @relation("LoreEntryAuthor", fields: [authorId], references: [id], onDelete: Restrict) - title String - slug String - loreType LoreType - content Json - status LoreStatus @default(DRAFT) - publishedAt DateTime? - hiveReferenceId String? @unique + id String @id @default(cuid()) + worldId String + world World @relation(fields: [worldId], references: [id], onDelete: Restrict) + authorId String + author User @relation("LoreEntryAuthor", fields: [authorId], references: [id], onDelete: Restrict) + title String + slug String + loreType LoreType + content Json + status LoreStatus @default(DRAFT) + publishedAt DateTime? + hiveReferenceId String? @unique /// Published canon must be confirmed through an indexed HiveReference, not only this mutable row. - hiveReference HiveReference? @relation(fields: [hiveReferenceId], references: [id], onDelete: SetNull) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - outgoingRelations LoreRelationship[] @relation("LoreRelationshipSource") - incomingRelations LoreRelationship[] @relation("LoreRelationshipTarget") - targetProposals Proposal[] @relation("ProposalTargetLoreEntry") - resultingProposals Proposal[] @relation("ProposalResultingLoreEntry") + hiveReference HiveReference? @relation(fields: [hiveReferenceId], references: [id], onDelete: SetNull) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + outgoingRelations LoreRelationship[] @relation("LoreRelationshipSource") + incomingRelations LoreRelationship[] @relation("LoreRelationshipTarget") + targetProposals Proposal[] @relation("ProposalTargetLoreEntry") + targetContributionDrafts ContributionDraft[] @relation("ContributionDraftTargetLoreEntry") + resultingProposals Proposal[] @relation("ProposalResultingLoreEntry") @@unique([worldId, slug]) @@index([worldId, status]) @@ -434,6 +491,7 @@ model Proposal { authorId String author User @relation("ProposalAuthor", fields: [authorId], references: [id], onDelete: Restrict) proposalType ProposalType + contributionKind ContributionKind? status ProposalStatus @default(DRAFT) title String summary String @@ -446,17 +504,79 @@ 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[] + comments ProposalComment[] @@index([worldId, status]) @@index([authorId]) + @@index([worldId, contributionKind]) + @@index([targetLoreEntryId]) + @@index([votingEndsAt]) + @@index([baseCanonVersionId]) + @@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 + world World @relation(fields: [worldId], references: [id], onDelete: Cascade) + authorId String + author User @relation("ContributionDraftAuthor", fields: [authorId], references: [id], onDelete: Cascade) + targetLoreEntryId String? + targetLoreEntry LoreEntry? @relation("ContributionDraftTargetLoreEntry", fields: [targetLoreEntryId], references: [id], onDelete: SetNull) + kind ContributionKind + title String + summary String? + content Json + status ContributionStatus @default(DRAFT) + proposalId String? @unique + /// Submitted drafts are locked; Proposal.proposedContent stores the immutable reviewer snapshot. + proposal Proposal? @relation(fields: [proposalId], references: [id], onDelete: SetNull) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + submittedAt DateTime? + + @@index([worldId]) + @@index([authorId]) + @@index([worldId, status]) + @@index([worldId, authorId, status]) @@index([targetLoreEntryId]) } @@ -478,6 +598,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 @@ -554,16 +723,17 @@ 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. @@unique([transactionId, operationIndex]) diff --git a/apps/api/prisma/seed.ts b/apps/api/prisma/seed.ts index 2da8d6a..d999c72 100644 --- a/apps/api/prisma/seed.ts +++ b/apps/api/prisma/seed.ts @@ -3,6 +3,7 @@ import 'dotenv/config'; import { PrismaPg } from '@prisma/adapter-pg'; import { PrismaClient } from '../src/generated/prisma/client.js'; +import { WorldRole } from '../src/generated/prisma/enums.js'; import { ensureActiveFounderMembership } from '../src/lib/founder-memberships.js'; const databaseUrl = process.env.DATABASE_URL; @@ -75,6 +76,51 @@ async function main() { userId: founder.id, }); + await prisma.worldMembership.upsert({ + where: { + worldId_userId: { + worldId: world.id, + userId: contributor.id, + }, + }, + update: { + role: WorldRole.CONTRIBUTOR, + grantedById: founder.id, + revokedAt: null, + }, + create: { + worldId: world.id, + userId: contributor.id, + role: WorldRole.CONTRIBUTOR, + grantedById: founder.id, + }, + }); + + await prisma.worldSeed.upsert({ + where: { + worldId: world.id, + }, + update: { + genre: 'Fantasy', + mainConflict: 'Weather, memory, and craft compete to define what the islands remember.', + premise: + 'A fictional chain of floating islands where memory, weather, and craft are tightly intertwined.', + tone: 'Luminous mystery', + }, + create: { + worldId: world.id, + premise: + 'A fictional chain of floating islands where memory, weather, and craft are tightly intertwined.', + genre: 'Fantasy', + tone: 'Luminous mystery', + mainConflict: 'Weather, memory, and craft compete to define what the islands remember.', + startingLocation: 'Mirror Lighthouse', + firstCharacters: ['Ember Quill', 'Mira Vale'], + firstFactions: ['Cartographers Guild'], + firstHistoricalEvent: 'The first reflected storm appears before its clouds arrive.', + }, + }); + await prisma.worldBibleVersion.upsert({ where: { worldId_versionNumber: { @@ -245,6 +291,68 @@ async function main() { }, }); + await prisma.contributionDraft.upsert({ + where: { + id: 'dev-contribution-glass-archipelago-001', + }, + update: { + kind: 'LORE', + title: 'Draft: Lighthouse Keeper Oath', + summary: 'Development-only structured contribution draft.', + content: { + type: 'doc', + content: [ + { + type: 'heading', + attrs: { level: 1 }, + content: [{ type: 'text', text: 'Lighthouse Keeper Oath' }], + }, + { + type: 'paragraph', + content: [ + { + type: 'text', + text: 'Keepers swear to report reflected storms before dawn bells sound.', + }, + ], + }, + ], + }, + status: 'DRAFT', + proposalId: null, + submittedAt: null, + targetLoreEntryId: lighthouse.id, + }, + create: { + id: 'dev-contribution-glass-archipelago-001', + worldId: world.id, + authorId: contributor.id, + kind: 'LORE', + title: 'Draft: Lighthouse Keeper Oath', + summary: 'Development-only structured contribution draft.', + content: { + type: 'doc', + content: [ + { + type: 'heading', + attrs: { level: 1 }, + content: [{ type: 'text', text: 'Lighthouse Keeper Oath' }], + }, + { + type: 'paragraph', + content: [ + { + type: 'text', + text: 'Keepers swear to report reflected storms before dawn bells sound.', + }, + ], + }, + ], + }, + targetLoreEntryId: lighthouse.id, + }, + }); + await prisma.aIReport.upsert({ where: { id: 'dev-ai-report-glass-archipelago-001' }, update: { diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index bc2bfbe..2df0cc3 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -6,7 +6,10 @@ 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() { const app = Fastify({ @@ -41,6 +44,9 @@ export async function buildApp() { await registerHealthRoute(app); await registerAuthRoutes(app); + await registerWorldRoutes(app); + await registerContributionRoutes(app); + await registerProposalRoutes(app); return app; } 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-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.test.ts b/apps/api/src/lib/canon-voting.test.ts new file mode 100644 index 0000000..ea9ef80 --- /dev/null +++ b/apps/api/src/lib/canon-voting.test.ts @@ -0,0 +1,226 @@ +import assert from 'node:assert/strict'; +import { describe, test } from 'node:test'; + +import { ProposalDecisionOutcome } from '../generated/prisma/enums.js'; +import { HIVELORE_CUSTOM_JSON_ID } from './hive/constants.js'; +import { buildHiveLoreCustomJsonOperation } from './hive/operations.js'; +import { CanonVotingError, confirmCanonTransaction } from './canon-voting.js'; +import { hashCanonicalJson } from './canon-voting-policy.js'; + +const decisionPayload = { + eventType: 'canon_decision', + proposalId: 'proposal-1', + worldId: 'world-1', + outcome: ProposalDecisionOutcome.APPROVED_FOR_PUBLICATION, + counts: { + alternateTimeline: 0, + approve: 7, + needsRevision: 0, + reject: 3, + total: 10, + }, +}; + +function createDecision(overrides: Record = {}) { + return { + aiWarningAcknowledged: false, + aiWarningSummary: null, + alternateTimelineCount: 0, + approvalDenominator: 10, + approvalNumerator: 7, + approvalPercentageBps: 7000, + approvalThresholdBps: 7000, + approveCount: 7, + baseCanonVersionId: null, + blockchainTimestamp: null, + blockNumber: null, + branchBaseLoreEntryId: null, + branchLabel: null, + branchParentProposalId: null, + conflictMetadata: null, + contentHash: 'content-hash', + createdAt: new Date('2026-08-10T12:00:00.000Z'), + customJsonId: HIVELORE_CUSTOM_JSON_ID, + decidedAt: new Date('2026-08-12T12:00:00.000Z'), + decisionPayload, + decisionPayloadHash: hashCanonicalJson(decisionPayload), + expectedSigner: 'mira-vale.dev', + hiveEventId: null, + id: 'decision-1', + minimumVotes: 5, + needsRevisionCount: 0, + operationIndex: null, + outcome: ProposalDecisionOutcome.APPROVED_FOR_PUBLICATION, + payloadSchemaVersion: 1, + proposalId: 'proposal-1', + rejectCount: 3, + rulesVersion: 'canon-voting-mvp-2026-08-10', + staleBaseAtDecision: false, + totalVotes: 10, + transactionId: null, + updatedAt: new Date('2026-08-12T12:00:00.000Z'), + votingWindowHours: 48, + ...overrides, + }; +} + +function createOperation(input: { + authority?: 'active' | 'posting'; + proposalId?: string; + signer?: string; + transactionId?: string; +}) { + return { + block_num: 100, + operation_id: 0, + operation: buildHiveLoreCustomJsonOperation({ + action: 'canon_approval', + authority: input.authority ?? 'posting', + entityId: 'decision-1', + entityType: 'CANON_DECISION', + payload: decisionPayload, + proposalId: input.proposalId ?? 'proposal-1', + signer: input.signer ?? 'mira-vale.dev', + worldId: 'world-1', + }), + timestamp: '2026-08-12T12:05:00.000Z', + transaction_id: input.transactionId ?? 'tx-valid', + }; +} + +function createDatabase(decision = createDecision()) { + const events: unknown[] = []; + const proposal = { + author: { + hiveUsername: 'mira-vale.dev', + }, + decision, + id: 'proposal-1', + worldId: 'world-1', + }; + + const database = { + hiveEvent: { + async upsert(args: { + create: { id?: string; operationIndex: number; transactionId: string }; + }) { + const event = { + id: `event-${events.length + 1}`, + ...args.create, + }; + events.push(event); + return event; + }, + }, + proposal: { + async findFirst(args: { where: { id: string; worldId: string } }) { + if (args.where.id !== proposal.id || args.where.worldId !== proposal.worldId) { + return null; + } + + return proposal; + }, + }, + proposalDecision: { + async update(args: { data: Record }) { + Object.assign(decision, args.data); + return decision; + }, + }, + $transaction(callback: (transaction: unknown) => Promise) { + return callback(database); + }, + }; + + return { + database, + decision, + events, + }; +} + +function createHafClient(row: unknown) { + return { + async searchBlocks() { + return { + operations: [row], + }; + }, + }; +} + +describe('canon voting Hive confirmation', () => { + test('confirms a matching posting-authority custom_json and is idempotent for the same operation', async () => { + const state = createDatabase(); + const hafClient = createHafClient(createOperation({ transactionId: 'tx-valid' })); + + const confirmed = await confirmCanonTransaction(state.database as never, { + blockNumber: 100, + hafClient: hafClient as never, + operationIndex: 0, + proposalId: 'proposal-1', + transactionId: 'tx-valid', + worldId: 'world-1', + }); + const repeated = await confirmCanonTransaction(state.database as never, { + blockNumber: 100, + hafClient: hafClient as never, + operationIndex: 0, + proposalId: 'proposal-1', + transactionId: 'tx-valid', + worldId: 'world-1', + }); + + assert.equal(confirmed.idempotent, false); + assert.ok(confirmed.decision); + assert.equal(confirmed.decision.transactionId, 'tx-valid'); + assert.equal(repeated.idempotent, true); + assert.equal(state.events.length, 1); + }); + + test('rejects a different operation once the decision already has a Hive confirmation', async () => { + const state = createDatabase( + createDecision({ + operationIndex: 0, + transactionId: 'tx-original', + }), + ); + + await assert.rejects( + confirmCanonTransaction(state.database as never, { + blockNumber: 101, + hafClient: createHafClient(createOperation({ transactionId: 'tx-other' })) as never, + operationIndex: 0, + proposalId: 'proposal-1', + transactionId: 'tx-other', + worldId: 'world-1', + }), + (error: unknown) => + error instanceof CanonVotingError && error.code === 'DECISION_ALREADY_CONFIRMED', + ); + + assert.equal(state.decision.transactionId, 'tx-original'); + assert.equal(state.events.length, 0); + }); + + test('rejects active-authority custom_json for canon decision confirmation', async () => { + const state = createDatabase(); + + await assert.rejects( + confirmCanonTransaction(state.database as never, { + blockNumber: 100, + hafClient: createHafClient( + createOperation({ authority: 'active', transactionId: 'tx-active' }), + ) as never, + operationIndex: 0, + proposalId: 'proposal-1', + transactionId: 'tx-active', + worldId: 'world-1', + }), + (error: unknown) => + error instanceof CanonVotingError && error.code === 'HIVE_OPERATION_INVALID_AUTHORITY', + ); + + assert.equal(state.events.length, 0); + }); +}); diff --git a/apps/api/src/lib/canon-voting.ts b/apps/api/src/lib/canon-voting.ts new file mode 100644 index 0000000..66340d1 --- /dev/null +++ b/apps/api/src/lib/canon-voting.ts @@ -0,0 +1,817 @@ +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: { voterId: string }) => 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: Prisma.TransactionClient) => { + 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: Prisma.TransactionClient) => { + 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.', + ); + } + + const currentDecision = proposal.decision; + + if ( + currentDecision.transactionId === input.transactionId && + currentDecision.operationIndex === input.operationIndex + ) { + return { + decision: serializeDecision(currentDecision), + idempotent: true, + }; + } + + if (currentDecision.transactionId || currentDecision.operationIndex !== null) { + throw new CanonVotingError( + 409, + 'DECISION_ALREADY_CONFIRMED', + 'Proposal decision is already linked to a different Hive operation.', + ); + } + + 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 = (currentDecision.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 customJsonOperation = operation.operation.custom_json_operation; + + if ( + !customJsonOperation || + customJsonOperation.required_auths.length > 0 || + !customJsonOperation.required_posting_auths.includes(signer) + ) { + throw new CanonVotingError( + 400, + 'HIVE_OPERATION_INVALID_AUTHORITY', + 'Canon decisions must be signed with posting authority by the expected signer.', + ); + } + + const payload = parseHiveLoreCustomJsonPayload(operation.operation); + + if ( + !payload || + payload.action !== 'canon_approval' || + payload.entityType !== 'CANON_DECISION' || + payload.entityId !== currentDecision.id || + payload.worldId !== input.worldId || + payload.proposalId !== input.proposalId || + hashCanonicalJson(payload.payload) !== currentDecision.decisionPayloadHash + ) { + throw new CanonVotingError( + 400, + 'HIVE_OPERATION_MISMATCH', + 'Hive operation does not match the frozen decision.', + ); + } + + return database.$transaction(async (transaction: Prisma.TransactionClient) => { + 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 confirmedDecision = 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: currentDecision.id, + }, + }); + + return { + decision: serializeDecision(confirmedDecision), + 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 new file mode 100644 index 0000000..e502159 --- /dev/null +++ b/apps/api/src/lib/contributions.ts @@ -0,0 +1,780 @@ +import type { Prisma, PrismaClient } from '../generated/prisma/client.js'; +import { + ContributionKind, + ContributionStatus, + ProposalStatus, + ProposalType, + WorldAuditAction, +} from '../generated/prisma/enums.js'; +import { prepareSubmittedProposalVotingFields } from './canon-voting.js'; + +const STRUCTURED_DOCUMENT_MAX_BYTES = 100 * 1024; + +export class ContributionError extends Error { + constructor( + public readonly statusCode: number, + message: string, + ) { + super(message); + } +} + +export type ContributionDatabase = Pick< + PrismaClient, + | '$transaction' + | 'contributionDraft' + | 'loreEntry' + | 'proposal' + | 'refreshSession' + | 'world' + | 'worldAuditLog' + | 'worldMembership' +>; + +export type StructuredDocument = Prisma.InputJsonObject; + +export type CreateContributionInput = { + worldId: string; + authorId: string; + kind: ContributionKind; + title: string; + summary?: string | undefined; + targetLoreEntryId?: string | undefined; + content: unknown; +}; + +export type UpdateContributionInput = { + worldId: string; + contributionId: string; + authorId: string; + kind?: ContributionKind | undefined; + title?: string | undefined; + summary?: string | null | undefined; + targetLoreEntryId?: string | null | undefined; + content?: unknown; +}; + +export type ListContributionInput = { + worldId: string; + authorId: string; + status?: ContributionStatus | undefined; + kind?: ContributionKind | undefined; + page?: number | undefined; + pageSize?: number | undefined; +}; + +const contributionInclude = { + proposal: true, + targetLoreEntry: { + select: { + id: true, + loreType: true, + slug: true, + title: true, + worldId: true, + }, + }, +} as const; + +type ContributionWithRelations = Prisma.ContributionDraftGetPayload<{ + include: typeof contributionInclude; +}>; + +type ProposalRecord = Prisma.ProposalGetPayload>; + +function serializeDate(value: Date | null) { + return value?.toISOString() ?? null; +} + +export function serializeProposal(proposal: ProposalRecord | null) { + if (!proposal) { + return null; + } + + return { + approvedAt: serializeDate(proposal.approvedAt), + authorId: proposal.authorId, + contributionKind: proposal.contributionKind, + createdAt: proposal.createdAt.toISOString(), + id: proposal.id, + proposedContent: proposal.proposedContent, + proposalType: proposal.proposalType, + publishedAt: serializeDate(proposal.publishedAt), + rejectedAt: serializeDate(proposal.rejectedAt), + resultingBibleVersionId: proposal.resultingBibleVersionId, + resultingLoreEntryId: proposal.resultingLoreEntryId, + status: proposal.status, + submittedAt: serializeDate(proposal.submittedAt), + summary: proposal.summary, + targetLoreEntryId: proposal.targetLoreEntryId, + title: proposal.title, + updatedAt: proposal.updatedAt.toISOString(), + votingStartedAt: serializeDate(proposal.votingStartedAt), + worldId: proposal.worldId, + }; +} + +export function serializeContribution(contribution: ContributionWithRelations) { + return { + authorId: contribution.authorId, + content: contribution.content, + createdAt: contribution.createdAt.toISOString(), + id: contribution.id, + kind: contribution.kind, + proposal: serializeProposal(contribution.proposal), + proposalId: contribution.proposalId, + status: contribution.status, + submittedAt: serializeDate(contribution.submittedAt), + summary: contribution.summary, + targetLoreEntry: contribution.targetLoreEntry, + targetLoreEntryId: contribution.targetLoreEntryId, + title: contribution.title, + updatedAt: contribution.updatedAt.toISOString(), + worldId: contribution.worldId, + }; +} + +function isPlainObject(value: unknown): value is Record { + return ( + typeof value === 'object' && + value !== null && + !Array.isArray(value) && + (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null) + ); +} + +function assertJsonByteLimit(value: unknown) { + const serialized = JSON.stringify(value); + + if (!serialized || Buffer.byteLength(serialized, 'utf8') > STRUCTURED_DOCUMENT_MAX_BYTES) { + throw new ContributionError(400, 'Structured content exceeds the 100 KB limit.'); + } +} + +export function validateStructuredDocument(value: unknown): StructuredDocument { + assertJsonByteLimit(value); + + if (!isPlainObject(value)) { + throw new ContributionError(400, 'Structured content must be a JSON object.'); + } + + if (value.type !== 'doc') { + throw new ContributionError(400, 'Structured content root type must be "doc".'); + } + + if ('content' in value && !Array.isArray(value.content)) { + throw new ContributionError(400, 'Structured content root content must be an array.'); + } + + return value as StructuredDocument; +} + +export function hasMeaningfulText(value: unknown): boolean { + if (Array.isArray(value)) { + return value.some(hasMeaningfulText); + } + + if (!isPlainObject(value)) { + return false; + } + + if (typeof value.text === 'string' && value.text.trim().length > 0) { + return true; + } + + return Object.values(value).some(hasMeaningfulText); +} + +function normalizeTitle(title: string) { + const normalized = title.trim(); + + if (!normalized) { + throw new ContributionError(400, 'Contribution title is required.'); + } + + if (normalized.length > 200) { + throw new ContributionError(400, 'Contribution title must be at most 200 characters.'); + } + + return normalized; +} + +function normalizeSummary(summary: string | null | undefined) { + const normalized = summary?.trim() || null; + + if (normalized && normalized.length > 1_000) { + throw new ContributionError(400, 'Contribution summary must be at most 1000 characters.'); + } + + return normalized; +} + +async function assertWorldExists(database: Pick, worldId: string) { + const world = await database.world.findUnique({ + select: { + id: true, + }, + where: { + id: worldId, + }, + }); + + if (!world) { + throw new ContributionError(404, 'World not found.'); + } +} + +async function assertTargetLoreEntry( + database: Pick, + worldId: string, + targetLoreEntryId: string | null | undefined, +) { + if (!targetLoreEntryId) { + return null; + } + + const target = await database.loreEntry.findUnique({ + select: { + id: true, + worldId: true, + }, + where: { + id: targetLoreEntryId, + }, + }); + + if (!target) { + throw new ContributionError(404, 'Target lore entry not found.'); + } + + if (target.worldId !== worldId) { + throw new ContributionError(400, 'Target lore entry must belong to the same world.'); + } + + return target; +} + +function proposalTypeForContribution(kind: ContributionKind, hasTargetLoreEntry: boolean) { + if (kind === ContributionKind.STORY) { + return ProposalType.ADD_STORY; + } + + return hasTargetLoreEntry ? ProposalType.UPDATE_LORE : ProposalType.ADD_LORE; +} + +async function createContributionAuditLog( + database: Pick, + input: { + action: (typeof WorldAuditAction)[keyof typeof WorldAuditAction]; + actorId: string; + worldId: string; + targetId: string; + targetType: string; + metadata?: Prisma.InputJsonObject | undefined; + }, +) { + await database.worldAuditLog.create({ + data: { + action: input.action, + actorId: input.actorId, + metadata: input.metadata ?? {}, + targetId: input.targetId, + targetType: input.targetType, + worldId: input.worldId, + }, + }); +} + +export async function createContribution( + database: ContributionDatabase, + input: CreateContributionInput, +) { + const title = normalizeTitle(input.title); + const summary = normalizeSummary(input.summary); + const content = validateStructuredDocument(input.content); + + await assertWorldExists(database, input.worldId); + await assertTargetLoreEntry(database, input.worldId, input.targetLoreEntryId); + + 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, + }); + + return created; + }, + ); + + return serializeContribution(contribution); +} + +export async function listOwnedContributions( + database: ContributionDatabase, + input: ListContributionInput, +) { + const page = input.page ?? 1; + const pageSize = input.pageSize ?? 20; + const where: Prisma.ContributionDraftWhereInput = { + authorId: input.authorId, + ...(input.kind ? { kind: input.kind } : {}), + ...(input.status ? { status: input.status } : {}), + worldId: input.worldId, + }; + + const [contributions, total] = await Promise.all([ + database.contributionDraft.findMany({ + include: contributionInclude, + orderBy: [ + { + updatedAt: 'desc', + }, + { + id: 'desc', + }, + ], + skip: (page - 1) * pageSize, + take: pageSize, + where, + }), + database.contributionDraft.count({ + where, + }), + ]); + + return { + contributions: contributions.map(serializeContribution), + pagination: { + page, + pageSize, + total, + }, + }; +} + +async function resolveMissingOrLockedContribution( + database: Pick, + input: { + worldId: string; + contributionId: string; + authorId: string; + lockedMessage: string; + }, +) { + const current = await database.contributionDraft.findFirst({ + select: { + status: true, + }, + where: { + authorId: input.authorId, + id: input.contributionId, + worldId: input.worldId, + }, + }); + + if (!current) { + throw new ContributionError(404, 'Contribution not found.'); + } + + if (current.status !== ContributionStatus.DRAFT) { + throw new ContributionError(409, input.lockedMessage); + } + + throw new ContributionError(409, 'Concurrent contribution update conflict.'); +} + +async function findOwnedContributionOrThrow( + database: Pick, + input: { + worldId: string; + contributionId: string; + authorId: string; + }, +) { + const contribution = await database.contributionDraft.findFirst({ + include: contributionInclude, + where: { + authorId: input.authorId, + id: input.contributionId, + worldId: input.worldId, + }, + }); + + if (!contribution) { + throw new ContributionError(404, 'Contribution not found.'); + } + + return contribution; +} + +export async function getOwnedContribution( + database: ContributionDatabase, + input: { + worldId: string; + contributionId: string; + authorId: string; + }, +) { + const contribution = await database.contributionDraft.findFirst({ + include: contributionInclude, + where: { + authorId: input.authorId, + id: input.contributionId, + worldId: input.worldId, + }, + }); + + return contribution ? serializeContribution(contribution) : null; +} + +export async function updateContribution( + database: ContributionDatabase, + input: UpdateContributionInput, +) { + await findOwnedContributionOrThrow(database, input); + + const data: Prisma.ContributionDraftUpdateManyMutationInput = {}; + const changedFields: string[] = []; + let targetLoreEntryId: string | null | undefined; + + if (input.kind) { + data.kind = input.kind; + changedFields.push('kind'); + } + + if (input.title !== undefined) { + data.title = normalizeTitle(input.title); + changedFields.push('title'); + } + + if (input.summary !== undefined) { + data.summary = normalizeSummary(input.summary); + changedFields.push('summary'); + } + + if (input.content !== undefined) { + data.content = validateStructuredDocument(input.content); + changedFields.push('content'); + } + + if (input.targetLoreEntryId !== undefined) { + await assertTargetLoreEntry(database, input.worldId, input.targetLoreEntryId); + targetLoreEntryId = input.targetLoreEntryId; + changedFields.push('targetLoreEntryId'); + } + + if (Object.keys(data).length === 0) { + data.updatedAt = new Date(); + } + + 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 (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, + }, + }); + + 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, + }); + + return current; + }, + ); + + return serializeContribution(contribution); +} + +export async function deleteContribution( + database: ContributionDatabase, + input: { + worldId: string; + contributionId: string; + authorId: string; + }, +) { + await database.$transaction(async (transaction: Prisma.TransactionClient) => { + const existing = await transaction.contributionDraft.findFirst({ + where: { + authorId: input.authorId, + id: input.contributionId, + worldId: input.worldId, + }, + }); + + if (!existing) { + throw new ContributionError(404, 'Contribution not found.'); + } + + if (existing.status !== ContributionStatus.DRAFT) { + throw new ContributionError(409, 'Submitted contributions cannot be deleted.'); + } + + const deleted = await transaction.contributionDraft.deleteMany({ + where: { + authorId: input.authorId, + id: input.contributionId, + status: ContributionStatus.DRAFT, + worldId: input.worldId, + }, + }); + + if (deleted.count !== 1) { + await resolveMissingOrLockedContribution(transaction, { + ...input, + lockedMessage: 'Submitted contributions cannot be deleted.', + }); + } + + await createContributionAuditLog(transaction, { + action: WorldAuditAction.CONTRIBUTION_DELETED, + actorId: input.authorId, + metadata: { + kind: existing.kind, + targetLoreEntryId: existing.targetLoreEntryId, + }, + targetId: existing.id, + targetType: 'CONTRIBUTION_DRAFT', + worldId: input.worldId, + }); + }); +} + +export async function submitContribution( + database: ContributionDatabase, + input: { + worldId: string; + contributionId: string; + authorId: string; + }, +) { + const result = await database.$transaction(async (transaction: Prisma.TransactionClient) => { + const existing = await transaction.contributionDraft.findFirst({ + include: contributionInclude, + where: { + authorId: input.authorId, + id: input.contributionId, + worldId: input.worldId, + }, + }); + + if (!existing) { + throw new ContributionError(404, 'Contribution not found.'); + } + + if (existing.status !== ContributionStatus.DRAFT) { + if (existing.proposal) { + return { + alreadySubmitted: true, + contribution: existing, + proposal: existing.proposal, + }; + } + + throw new ContributionError(409, 'Contribution already submitted.'); + } + + normalizeTitle(existing.title); + const structuredContent = validateStructuredDocument(existing.content); + + if (!hasMeaningfulText(existing.content)) { + throw new ContributionError(400, 'Contribution content must include meaningful text.'); + } + + await assertWorldExists(transaction, input.worldId); + 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, + submittedAt, + }, + where: { + authorId: input.authorId, + id: input.contributionId, + status: ContributionStatus.DRAFT, + worldId: input.worldId, + }, + }); + + if (transition.count !== 1) { + const submitted = await transaction.contributionDraft.findFirst({ + include: contributionInclude, + where: { + authorId: input.authorId, + id: input.contributionId, + worldId: input.worldId, + }, + }); + + if (submitted?.proposal) { + return { + alreadySubmitted: true, + contribution: submitted, + proposal: submitted.proposal, + }; + } + + throw new ContributionError(409, 'Concurrent submission conflict.'); + } + + // Proposal.proposedContent is the immutable reviewer snapshot; submitted + // drafts are locked so draft endpoints cannot mutate active voting content. + 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.VOTING, + submittedAt, + summary: existing.summary ?? '', + targetLoreEntryId: existing.targetLoreEntryId, + title: existing.title, + votingEndsAt: votingFields.votingEndsAt, + votingStartedAt: votingFields.votingStartedAt, + worldId: input.worldId, + }, + }); + + const contribution = await transaction.contributionDraft.update({ + data: { + proposalId: proposal.id, + }, + include: contributionInclude, + where: { + id: existing.id, + }, + }); + + await createContributionAuditLog(transaction, { + action: WorldAuditAction.PROPOSAL_CREATED, + actorId: input.authorId, + metadata: { + contributionId: contribution.id, + contributionKind: contribution.kind, + proposalType: proposal.proposalType, + }, + targetId: proposal.id, + targetType: 'PROPOSAL', + worldId: input.worldId, + }); + + await createContributionAuditLog(transaction, { + action: WorldAuditAction.CONTRIBUTION_SUBMITTED, + actorId: input.authorId, + metadata: { + proposalId: proposal.id, + }, + targetId: contribution.id, + targetType: 'CONTRIBUTION_DRAFT', + worldId: input.worldId, + }); + + return { + alreadySubmitted: false, + contribution, + proposal, + }; + }); + + return { + alreadySubmitted: result.alreadySubmitted, + contribution: serializeContribution(result.contribution), + proposal: serializeProposal(result.proposal), + }; +} 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/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/lib/worlds.ts b/apps/api/src/lib/worlds.ts new file mode 100644 index 0000000..685e758 --- /dev/null +++ b/apps/api/src/lib/worlds.ts @@ -0,0 +1,591 @@ +import type { Prisma, PrismaClient } from '../generated/prisma/client.js'; +import { + LoreStatus, + ProposalStatus, + WorldAuditAction, + WorldRole, +} from '../generated/prisma/enums.js'; +import { mapWorldSlugConflict } from './prisma-conflicts.js'; + +export type WorldSeedInput = { + premise: string; + genre: string; + tone: string; + mainConflict: string; + startingLocation?: string | undefined; + firstCharacters?: string[] | undefined; + firstFactions?: string[] | undefined; + firstHistoricalEvent?: string | undefined; +}; + +export type WorldBibleInput = { + content: Prisma.InputJsonValue; + changeSummary?: string | undefined; +}; + +export type CreateWorldInput = { + creatorId: string; + title: string; + description: string; + seed: WorldSeedInput; + bible: WorldBibleInput; +}; + +export type UpdateWorldInput = { + worldId: string; + actorId: string; + title?: string | undefined; + description?: string | undefined; + seed?: Partial | undefined; + bible?: WorldBibleInput | undefined; +}; + +export type WorldDatabase = Pick< + PrismaClient, + | '$transaction' + | 'loreEntry' + | 'proposal' + | 'refreshSession' + | 'world' + | 'worldAuditLog' + | 'worldBibleVersion' + | 'worldMembership' + | 'worldSeed' +>; + +export class WorldError extends Error { + constructor( + public readonly statusCode: number, + message: string, + ) { + super(message); + } +} + +const worldInclude = { + bibleVersions: { + orderBy: { + versionNumber: 'desc', + }, + take: 1, + }, + founder: { + select: { + avatarUrl: true, + displayName: true, + hiveUsername: true, + id: true, + normalizedHiveUsername: true, + }, + }, + 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() + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 80); + + return slug || 'world'; +} + +function normalizeSeedInput(seed: WorldSeedInput) { + return { + firstCharacters: seed.firstCharacters?.map((value) => value.trim()).filter(Boolean) ?? [], + firstFactions: seed.firstFactions?.map((value) => value.trim()).filter(Boolean) ?? [], + firstHistoricalEvent: seed.firstHistoricalEvent?.trim() || null, + genre: seed.genre.trim(), + mainConflict: seed.mainConflict.trim(), + premise: seed.premise.trim(), + startingLocation: seed.startingLocation?.trim() || null, + tone: seed.tone.trim(), + }; +} + +function serializeSeed(seed: NonNullable) { + return { + createdAt: seed.createdAt.toISOString(), + firstCharacters: Array.isArray(seed.firstCharacters) ? seed.firstCharacters : [], + firstFactions: Array.isArray(seed.firstFactions) ? seed.firstFactions : [], + firstHistoricalEvent: seed.firstHistoricalEvent, + genre: seed.genre, + id: seed.id, + mainConflict: seed.mainConflict, + premise: seed.premise, + startingLocation: seed.startingLocation, + tone: seed.tone, + updatedAt: seed.updatedAt.toISOString(), + }; +} + +function serializeBibleVersion( + version: + | WorldWithRelations['bibleVersions'][number] + | WorldListItemWithRelations['bibleVersions'][number] + | null, +) { + if (!version) { + return null; + } + + return { + changeSummary: version.changeSummary, + ...('content' in version ? { content: version.content } : {}), + createdAt: version.createdAt.toISOString(), + creatorId: version.creatorId, + hiveReferenceId: version.hiveReferenceId, + id: version.id, + publishedAt: version.publishedAt?.toISOString() ?? null, + updatedAt: version.updatedAt.toISOString(), + versionNumber: version.versionNumber, + }; +} + +export function serializeWorld(world: WorldWithRelations) { + 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(), + }; +} + +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; + + for (let suffix = 2; suffix <= 50; suffix += 1) { + const existing = await database.world.findUnique({ + where: { + slug, + }, + select: { + id: true, + }, + }); + + if (!existing) { + return slug; + } + + slug = `${baseSlug}-${suffix}`; + } + + throw new WorldError(409, 'Unable to create a unique world slug.'); +} + +export async function createWorld(database: WorldDatabase, input: CreateWorldInput) { + const seed = normalizeSeedInput(input.seed); + + try { + const world = await database.$transaction(async (transaction: Prisma.TransactionClient) => { + 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.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, + }, + }); + + 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, + }, + }); + + const membership = await transaction.worldMembership.create({ + data: { + grantedById: input.creatorId, + role: WorldRole.FOUNDER, + userId: input.creatorId, + worldId: 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); + } catch (error) { + const conflict = mapWorldSlugConflict(error); + + if (conflict) { + throw new WorldError(conflict.statusCode, conflict.error); + } + + throw error; + } +} + +export async function listWorlds( + database: WorldDatabase, + input: { + genre?: string | undefined; + page?: number | undefined; + pageSize?: number | undefined; + q?: string | undefined; + tone?: string | undefined; + }, +) { + const page = input.page ?? 1; + const pageSize = input.pageSize ?? 20; + const where: Prisma.WorldWhereInput = {}; + + if (input.q) { + where.OR = [ + { + title: { + contains: input.q, + mode: 'insensitive', + }, + }, + { + description: { + contains: input.q, + mode: 'insensitive', + }, + }, + ]; + } + + if (input.genre || input.tone) { + where.seed = { + ...(input.genre + ? { + genre: { + equals: input.genre, + mode: 'insensitive', + }, + } + : {}), + ...(input.tone + ? { + tone: { + equals: input.tone, + mode: 'insensitive', + }, + } + : {}), + }; + } + + const [worlds, total] = await Promise.all([ + database.world.findMany({ + include: worldListInclude, + orderBy: [ + { + createdAt: 'desc', + }, + { + id: 'desc', + }, + ], + skip: (page - 1) * pageSize, + take: pageSize, + where, + }), + database.world.count({ + where, + }), + ]); + + return { + pagination: { + page, + pageSize, + total, + }, + worlds: worlds.map(serializeWorldListItem), + }; +} + +export async function getWorld(database: WorldDatabase, worldId: string) { + const world = await database.world.findUnique({ + include: worldInclude, + where: { + id: worldId, + }, + }); + + return world ? serializeWorld(world) : null; +} + +export async function getWorldHub(database: WorldDatabase, worldId: string) { + const world = await getWorld(database, worldId); + + if (!world) { + return null; + } + + const [canonLoreCount, activeProposalCount, latestLoreEntries] = await Promise.all([ + database.loreEntry.count({ + where: { + status: LoreStatus.PUBLISHED_CANON, + worldId, + }, + }), + database.proposal.count({ + where: { + status: { + in: [ProposalStatus.SUBMITTED, ProposalStatus.VOTING], + }, + worldId, + }, + }), + database.loreEntry.findMany({ + orderBy: [ + { + updatedAt: 'desc', + }, + { + id: 'desc', + }, + ], + select: { + id: true, + loreType: true, + slug: true, + status: true, + title: true, + updatedAt: true, + }, + take: 6, + where: { + status: LoreStatus.PUBLISHED_CANON, + worldId, + }, + }), + ]); + + return { + latestLoreEntries: latestLoreEntries.map((entry: { updatedAt: Date }) => ({ + ...entry, + updatedAt: entry.updatedAt.toISOString(), + })), + stats: { + activeProposalCount, + canonLoreCount, + }, + world, + }; +} + +export async function updateWorld(database: WorldDatabase, input: UpdateWorldInput) { + const world = await database.$transaction(async (transaction: Prisma.TransactionClient) => { + const current = await transaction.world.findUnique({ + include: { + bibleVersions: { + orderBy: { + versionNumber: 'desc', + }, + take: 1, + }, + seed: true, + }, + where: { + id: input.worldId, + }, + }); + + if (!current) { + throw new WorldError(404, 'World not found.'); + } + + if (input.title || input.description) { + await transaction.world.update({ + data: { + ...(input.description ? { description: input.description.trim() } : {}), + ...(input.title ? { title: input.title.trim() } : {}), + }, + where: { + id: input.worldId, + }, + }); + } + + if (input.seed) { + const currentSeed = current.seed; + + if (!currentSeed) { + throw new WorldError(409, 'World seed is missing.'); + } + + const normalizedSeed = normalizeSeedInput({ + firstCharacters: + input.seed.firstCharacters ?? + (Array.isArray(currentSeed.firstCharacters) + ? currentSeed.firstCharacters.filter( + (value: unknown): value is string => typeof value === 'string', + ) + : []), + firstFactions: + input.seed.firstFactions ?? + (Array.isArray(currentSeed.firstFactions) + ? currentSeed.firstFactions.filter( + (value: unknown): value is string => typeof value === 'string', + ) + : []), + firstHistoricalEvent: + input.seed.firstHistoricalEvent ?? currentSeed.firstHistoricalEvent ?? undefined, + genre: input.seed.genre ?? currentSeed.genre, + mainConflict: input.seed.mainConflict ?? currentSeed.mainConflict, + premise: input.seed.premise ?? currentSeed.premise, + startingLocation: input.seed.startingLocation ?? currentSeed.startingLocation ?? undefined, + tone: input.seed.tone ?? currentSeed.tone, + }); + + await transaction.worldSeed.update({ + data: normalizedSeed, + where: { + worldId: input.worldId, + }, + }); + } + + if (input.bible) { + const currentBible = current.bibleVersions[0]; + + if (!currentBible) { + 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({ + data: { + changeSummary: input.bible.changeSummary?.trim() || currentBible.changeSummary, + content: input.bible.content, + }, + where: { + 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({ + include: worldInclude, + where: { + id: input.worldId, + }, + }); + }); + + return serializeWorld(world); +} 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 new file mode 100644 index 0000000..34b652e --- /dev/null +++ b/apps/api/src/routes/contributions.test.ts @@ -0,0 +1,966 @@ +import assert from 'node:assert/strict'; +import { describe, test } from 'node:test'; + +import Fastify from 'fastify'; + +import { env } from '../config/env.js'; +import { + ContributionKind, + ContributionStatus, + PlatformRole, + ProposalStatus, + ProposalType, + WorldAuditAction, + WorldRole, +} from '../generated/prisma/enums.js'; +import { signAccessToken } from '../lib/auth-crypto.js'; +import { + createContribution, + deleteContribution, + submitContribution, + updateContribution, +} from '../lib/contributions.js'; +import { registerContributionRoutes } from './contributions.js'; + +const author = { + hiveUsername: 'mira-vale.dev', + id: 'user-author', + normalizedHiveUsername: 'mira-vale.dev', +}; + +const otherUser = { + hiveUsername: 'emberquill.dev', + id: 'user-other', + normalizedHiveUsername: 'emberquill.dev', +}; + +function authHeader(user = author) { + 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}`, + }; +} + +const structuredDoc = { + type: 'doc', + content: [ + { + type: 'paragraph', + content: [{ type: 'text', text: 'The northern gate fell during the third winter.' }], + }, + ], +}; + +const emptyDoc = { + type: 'doc', + content: [ + { + type: 'paragraph', + content: [{ type: 'text', text: ' ' }], + }, + ], +}; + +type StoredContribution = { + id: string; + worldId: string; + authorId: string; + targetLoreEntryId: string | null; + kind: ContributionKind; + title: string; + summary: string | null; + content: unknown; + status: ContributionStatus; + proposalId: string | null; + createdAt: Date; + updatedAt: Date; + submittedAt: Date | null; +}; + +type StoredProposal = { + id: string; + worldId: string; + authorId: string; + proposalType: ProposalType; + contributionKind: ContributionKind; + status: ProposalStatus; + title: string; + summary: string; + proposedContent: unknown; + targetLoreEntryId: string | null; + resultingLoreEntryId: string | null; + resultingBibleVersionId: string | null; + submittedAt: Date | null; + votingStartedAt: Date | null; + approvedAt: Date | null; + rejectedAt: Date | null; + publishedAt: Date | null; + createdAt: Date; + updatedAt: Date; +}; + +function now() { + return new Date('2026-08-05T12:00:00.000Z'); +} + +function createContributionRecord(input: Partial = {}): StoredContribution { + return { + authorId: author.id, + content: structuredDoc, + createdAt: now(), + id: `contribution-${Math.random().toString(36).slice(2)}`, + kind: ContributionKind.LORE, + proposalId: null, + status: ContributionStatus.DRAFT, + submittedAt: null, + summary: 'A short summary.', + targetLoreEntryId: 'lore-1', + title: 'The Fall of the Northern Gate', + updatedAt: now(), + worldId: 'world-1', + ...input, + }; +} + +function createDatabase() { + const worlds = [{ id: 'world-1' }, { id: 'world-2' }]; + const loreEntries = [ + { + id: 'lore-1', + loreType: 'LOCATION', + slug: 'north-gate', + title: 'North Gate', + worldId: 'world-1', + }, + { + id: 'lore-2', + loreType: 'EVENT', + slug: 'other-world-event', + title: 'Other Event', + worldId: 'world-2', + }, + ]; + const memberships = [ + { + id: 'membership-author', + revokedAt: null, + role: WorldRole.CONTRIBUTOR, + userId: author.id, + worldId: 'world-1', + }, + { + id: 'membership-other', + revokedAt: null, + role: WorldRole.READER, + userId: otherUser.id, + worldId: 'world-1', + }, + ]; + const contributions: StoredContribution[] = []; + const proposals: StoredProposal[] = []; + const auditLogs: unknown[] = []; + let failNextProposalCreate = false; + let failNextAuditCreate = false; + let submitBeforeNextDeleteMany = false; + let submitBeforeNextUpdateMany = false; + let transactionQueue = Promise.resolve(); + const externallySubmittedContributionIds = new Set(); + + function includeContribution(contribution: StoredContribution) { + return { + ...contribution, + proposal: contribution.proposalId + ? (proposals.find((proposal) => proposal.id === contribution.proposalId) ?? null) + : null, + targetLoreEntry: contribution.targetLoreEntryId + ? (loreEntries.find((entry) => entry.id === contribution.targetLoreEntryId) ?? null) + : null, + }; + } + + const database: Record = {}; + + Object.assign(database, { + async $transaction(callback: (transaction: unknown) => Promise) { + const previousTransaction = transactionQueue; + let releaseTransaction!: () => void; + transactionQueue = new Promise((resolve) => { + releaseTransaction = resolve; + }); + + await previousTransaction; + + const contributionSnapshot = contributions.map((contribution) => ({ ...contribution })); + const proposalSnapshot = proposals.map((proposal) => ({ ...proposal })); + const auditSnapshot = [...auditLogs]; + + try { + return await callback(database); + } catch (error) { + contributions.splice(0, contributions.length, ...contributionSnapshot); + proposals.splice(0, proposals.length, ...proposalSnapshot); + auditLogs.splice(0, auditLogs.length, ...auditSnapshot); + + for (const contributionId of externallySubmittedContributionIds) { + const contribution = contributions.find((candidate) => candidate.id === contributionId); + + if (contribution) { + contribution.status = ContributionStatus.SUBMITTED; + contribution.submittedAt = now(); + } + } + + externallySubmittedContributionIds.clear(); + throw error; + } finally { + releaseTransaction(); + } + }, + contributionDraft: { + async create(args: { data: Partial; include: unknown }) { + const contribution = createContributionRecord({ + id: `contribution-${contributions.length + 1}`, + proposalId: null, + status: ContributionStatus.DRAFT, + submittedAt: null, + ...args.data, + }); + contributions.push(contribution); + return includeContribution(contribution); + }, + async delete(args: { where: { id: string } }) { + const index = contributions.findIndex((contribution) => contribution.id === args.where.id); + const [deleted] = contributions.splice(index, 1); + return deleted; + }, + async deleteMany(args: { where: Partial }) { + if (submitBeforeNextDeleteMany) { + const contribution = contributions.find( + (candidate) => + candidate.id === args.where.id && candidate.authorId === args.where.authorId, + ); + + if (contribution) { + contribution.status = ContributionStatus.SUBMITTED; + contribution.submittedAt = now(); + externallySubmittedContributionIds.add(contribution.id); + } + + submitBeforeNextDeleteMany = false; + } + + const before = contributions.length; + + for (let index = contributions.length - 1; index >= 0; index -= 1) { + const contribution = contributions[index]!; + + if ( + Object.entries(args.where).every( + ([key, value]) => contribution[key as keyof StoredContribution] === value, + ) + ) { + contributions.splice(index, 1); + } + } + + return { count: before - contributions.length }; + }, + async findFirst(args: { where: Partial; include?: unknown }) { + const contribution = + contributions.find((candidate) => + Object.entries(args.where).every(([key, value]) => { + if (value && typeof value === 'object' && 'lt' in value) { + return String(candidate[key as keyof StoredContribution]) < String(value.lt); + } + + return candidate[key as keyof StoredContribution] === value; + }), + ) ?? null; + + return contribution ? includeContribution(contribution) : null; + }, + async findMany(args: { + where: Partial & { id?: { lt: string } }; + skip?: number; + take: number; + }) { + return contributions + .filter((candidate) => + Object.entries(args.where).every(([key, value]) => { + if (key === 'id' && value && typeof value === 'object' && 'lt' in value) { + return candidate.id < String(value.lt); + } + + return candidate[key as keyof StoredContribution] === value; + }), + ) + .sort((left, right) => { + const updatedAtOrder = right.updatedAt.getTime() - left.updatedAt.getTime(); + + if (updatedAtOrder !== 0) { + return updatedAtOrder; + } + + return right.id.localeCompare(left.id); + }) + .slice(args.skip ?? 0, (args.skip ?? 0) + args.take) + .map(includeContribution); + }, + async count(args: { where: Partial }) { + return contributions.filter((candidate) => + Object.entries(args.where).every( + ([key, value]) => candidate[key as keyof StoredContribution] === value, + ), + ).length; + }, + async update(args: { + data: Partial; + where: { id: string }; + include?: unknown; + }) { + const contribution = contributions.find((candidate) => candidate.id === args.where.id); + + if (!contribution) { + throw new Error('Contribution missing.'); + } + + if ('targetLoreEntry' in args.data) { + const relation = args.data.targetLoreEntry as + { connect: { id: string } } | { disconnect: true }; + contribution.targetLoreEntryId = 'disconnect' in relation ? null : relation.connect.id; + delete args.data.targetLoreEntry; + } + + Object.assign(contribution, args.data, { + updatedAt: now(), + }); + + return includeContribution(contribution); + }, + async updateMany(args: { + data: Partial; + where: Partial; + }) { + if (submitBeforeNextUpdateMany) { + const contribution = contributions.find( + (candidate) => + candidate.id === args.where.id && candidate.authorId === args.where.authorId, + ); + + if (contribution) { + contribution.status = ContributionStatus.SUBMITTED; + contribution.submittedAt = now(); + externallySubmittedContributionIds.add(contribution.id); + } + + submitBeforeNextUpdateMany = false; + } + + let count = 0; + + for (const contribution of contributions) { + if ( + Object.entries(args.where).every( + ([key, value]) => contribution[key as keyof StoredContribution] === value, + ) + ) { + Object.assign(contribution, args.data, { + updatedAt: now(), + }); + count += 1; + } + } + + return { count }; + }, + }, + loreEntry: { + async findUnique(args: { where: { id: string } }) { + return loreEntries.find((entry) => entry.id === args.where.id) ?? null; + }, + }, + proposal: { + async create(args: { data: Omit }) { + if (failNextProposalCreate) { + failNextProposalCreate = false; + throw new Error('Injected proposal create failure.'); + } + + const proposal: StoredProposal = { + ...args.data, + approvedAt: args.data.approvedAt ?? null, + createdAt: now(), + id: `proposal-${proposals.length + 1}`, + publishedAt: args.data.publishedAt ?? null, + rejectedAt: args.data.rejectedAt ?? null, + resultingBibleVersionId: args.data.resultingBibleVersionId ?? null, + resultingLoreEntryId: args.data.resultingLoreEntryId ?? null, + updatedAt: now(), + votingStartedAt: args.data.votingStartedAt ?? null, + }; + proposals.push(proposal); + 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; + }, + }, + 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) { + failNextAuditCreate = false; + throw new Error('Injected audit failure.'); + } + + auditLogs.push(args.data); + return args.data; + }, + }, + worldMembership: { + async findUnique(args: { + where: { worldId_userId: { worldId: string; userId: string }; revokedAt: null }; + }) { + return ( + memberships.find( + (membership) => + membership.worldId === args.where.worldId_userId.worldId && + membership.userId === args.where.worldId_userId.userId && + !membership.revokedAt, + ) ?? null + ); + }, + }, + }); + + return { + auditLogs, + contributions, + database, + failNextProposalCreate() { + failNextProposalCreate = true; + }, + failNextAuditCreate() { + failNextAuditCreate = true; + }, + loreEntries, + memberships, + proposals, + submitBeforeNextDeleteMany() { + submitBeforeNextDeleteMany = true; + }, + submitBeforeNextUpdateMany() { + submitBeforeNextUpdateMany = true; + }, + }; +} + +async function createApp(database: ReturnType['database']) { + const app = Fastify(); + await registerContributionRoutes(app, { + database: database as never, + }); + return app; +} + +describe('contribution routes', () => { + test('authenticated contributor can create lore and story drafts with session-derived author', async () => { + const state = createDatabase(); + const app = await createApp(state.database); + + const loreResponse = await app.inject({ + headers: authHeader(), + method: 'POST', + payload: { + content: structuredDoc, + kind: ContributionKind.LORE, + targetLoreEntryId: 'lore-1', + title: ' The Fall of the Northern Gate ', + }, + url: '/worlds/world-1/contributions', + }); + const storyResponse = await app.inject({ + headers: authHeader(), + method: 'POST', + payload: { + content: structuredDoc, + kind: ContributionKind.STORY, + title: 'A Winter Road', + }, + url: '/worlds/world-1/contributions', + }); + const protectedFieldResponse = await app.inject({ + headers: authHeader(), + method: 'POST', + payload: { + authorId: otherUser.id, + content: structuredDoc, + kind: ContributionKind.STORY, + proposalId: 'proposal-injected', + title: 'A Winter Road', + }, + url: '/worlds/world-1/contributions', + }); + + assert.equal(loreResponse.statusCode, 201); + assert.equal(loreResponse.json().contribution.authorId, author.id); + assert.equal(loreResponse.json().contribution.title, 'The Fall of the Northern Gate'); + assert.equal(storyResponse.statusCode, 201); + assert.equal(storyResponse.json().contribution.kind, ContributionKind.STORY); + assert.equal(protectedFieldResponse.statusCode, 400); + assert.equal(state.contributions.length, 2); + await app.close(); + }); + + test('rejects unauthenticated users, users without world permission, invalid docs, oversized docs, missing content, missing worlds, and mismatched targets', async () => { + const state = createDatabase(); + const app = await createApp(state.database); + const oversizedDoc = { + type: 'doc', + content: [ + { + type: 'paragraph', + content: [{ type: 'text', text: 'x'.repeat(101 * 1024) }], + }, + ], + }; + + const unauthenticated = await app.inject({ + method: 'POST', + payload: { content: structuredDoc, kind: ContributionKind.LORE, title: 'Gate' }, + url: '/worlds/world-1/contributions', + }); + const forbidden = await app.inject({ + headers: authHeader(otherUser), + method: 'POST', + payload: { content: structuredDoc, kind: ContributionKind.LORE, title: 'Gate' }, + url: '/worlds/world-1/contributions', + }); + const invalidDoc = await app.inject({ + headers: authHeader(), + method: 'POST', + payload: { + content: { type: 'html', content: '

Nope

' }, + kind: ContributionKind.LORE, + title: 'Gate', + }, + url: '/worlds/world-1/contributions', + }); + const oversized = await app.inject({ + headers: authHeader(), + method: 'POST', + payload: { + content: oversizedDoc, + kind: ContributionKind.LORE, + title: 'Gate', + }, + url: '/worlds/world-1/contributions', + }); + const missingContent = await app.inject({ + headers: authHeader(), + method: 'POST', + payload: { + kind: ContributionKind.LORE, + title: 'Gate', + }, + url: '/worlds/world-1/contributions', + }); + const missingWorld = await app.inject({ + headers: authHeader(), + method: 'POST', + payload: { + content: structuredDoc, + kind: ContributionKind.LORE, + title: 'Gate', + }, + url: '/worlds/world-missing/contributions', + }); + const mismatchedTarget = await app.inject({ + headers: authHeader(), + method: 'POST', + payload: { + content: structuredDoc, + kind: ContributionKind.LORE, + targetLoreEntryId: 'lore-2', + title: 'Gate', + }, + url: '/worlds/world-1/contributions', + }); + + assert.equal(unauthenticated.statusCode, 401); + assert.equal(forbidden.statusCode, 403); + assert.equal(invalidDoc.statusCode, 400); + assert.equal(oversized.statusCode, 400); + assert.equal(missingContent.statusCode, 400); + assert.equal(missingWorld.statusCode, 404); + assert.equal(mismatchedTarget.statusCode, 400); + await app.close(); + }); + + test('author can list and read their draft while unauthorized routes do not reveal private data', async () => { + const state = createDatabase(); + state.memberships.find((membership) => membership.userId === otherUser.id)!.role = + WorldRole.CONTRIBUTOR; + state.memberships.push({ + id: 'membership-author-world-2', + revokedAt: null, + role: WorldRole.CONTRIBUTOR, + userId: author.id, + worldId: 'world-2', + }); + state.contributions.push(createContributionRecord({ id: 'draft-1' })); + state.contributions.push(createContributionRecord({ authorId: otherUser.id, id: 'draft-2' })); + const app = await createApp(state.database); + + const listResponse = await app.inject({ + headers: authHeader(), + method: 'GET', + url: '/worlds/world-1/contributions?status=DRAFT&kind=LORE&page=1&pageSize=1', + }); + const readResponse = await app.inject({ + headers: authHeader(), + method: 'GET', + url: '/worlds/world-1/contributions/draft-1', + }); + const otherReadResponse = await app.inject({ + headers: authHeader(otherUser), + method: 'GET', + url: '/worlds/world-1/contributions/draft-1', + }); + const wrongWorldResponse = await app.inject({ + headers: authHeader(), + method: 'GET', + url: '/worlds/world-2/contributions/draft-1', + }); + + assert.equal(listResponse.statusCode, 200); + assert.deepEqual(listResponse.json().pagination, { + page: 1, + pageSize: 1, + total: 1, + }); + assert.deepEqual( + listResponse.json().contributions.map((contribution: { id: string }) => contribution.id), + ['draft-1'], + ); + assert.equal(readResponse.statusCode, 200); + assert.equal(otherReadResponse.statusCode, 404); + assert.equal(wrongWorldResponse.statusCode, 404); + await app.close(); + }); + + test('author can update and delete draft, but protected fields and submitted rows are rejected', async () => { + const state = createDatabase(); + state.contributions.push(createContributionRecord({ id: 'draft-1' })); + state.contributions.push( + createContributionRecord({ + id: 'submitted-1', + proposalId: 'proposal-1', + status: ContributionStatus.SUBMITTED, + submittedAt: now(), + }), + ); + const app = await createApp(state.database); + + const updateResponse = await app.inject({ + headers: authHeader(), + method: 'PATCH', + payload: { + content: structuredDoc, + summary: 'Updated.', + targetLoreEntryId: null, + title: 'Updated Gate', + }, + url: '/worlds/world-1/contributions/draft-1', + }); + const protectedFieldResponse = await app.inject({ + headers: authHeader(), + method: 'PATCH', + payload: { + authorId: otherUser.id, + title: 'No', + }, + url: '/worlds/world-1/contributions/draft-1', + }); + const submittedUpdateResponse = await app.inject({ + headers: authHeader(), + method: 'PATCH', + payload: { title: 'No' }, + url: '/worlds/world-1/contributions/submitted-1', + }); + const invalidContentResponse = await app.inject({ + headers: authHeader(), + method: 'PATCH', + payload: { content: { type: 'doc', content: {} } }, + url: '/worlds/world-1/contributions/draft-1', + }); + const deleteResponse = await app.inject({ + headers: authHeader(), + method: 'DELETE', + url: '/worlds/world-1/contributions/draft-1', + }); + const submittedDeleteResponse = await app.inject({ + headers: authHeader(), + method: 'DELETE', + url: '/worlds/world-1/contributions/submitted-1', + }); + + assert.equal(updateResponse.statusCode, 200); + assert.equal(updateResponse.json().contribution.title, 'Updated Gate'); + assert.equal(updateResponse.json().contribution.targetLoreEntryId, null); + assert.equal(protectedFieldResponse.statusCode, 400); + assert.equal(submittedUpdateResponse.statusCode, 409); + assert.equal(invalidContentResponse.statusCode, 400); + assert.equal(deleteResponse.statusCode, 204); + assert.equal(submittedDeleteResponse.statusCode, 409); + await app.close(); + }); + + test('update and delete reject drafts that are submitted by a concurrent operation', async () => { + const updateState = createDatabase(); + updateState.contributions.push(createContributionRecord({ id: 'draft-update-race' })); + updateState.submitBeforeNextUpdateMany(); + + await assert.rejects( + updateContribution(updateState.database as never, { + authorId: author.id, + contributionId: 'draft-update-race', + title: 'Should Not Land', + worldId: 'world-1', + }), + (error: unknown) => + error instanceof Error && error.message === 'Submitted contributions cannot be edited.', + ); + + assert.equal(updateState.contributions[0]?.title, 'The Fall of the Northern Gate'); + assert.equal(updateState.contributions[0]?.status, ContributionStatus.SUBMITTED); + + const deleteState = createDatabase(); + deleteState.contributions.push(createContributionRecord({ id: 'draft-delete-race' })); + deleteState.submitBeforeNextDeleteMany(); + + await assert.rejects( + deleteContribution(deleteState.database as never, { + authorId: author.id, + contributionId: 'draft-delete-race', + worldId: 'world-1', + }), + (error: unknown) => + error instanceof Error && error.message === 'Submitted contributions cannot be deleted.', + ); + + assert.equal(deleteState.contributions.length, 1); + assert.equal(deleteState.contributions[0]?.status, ContributionStatus.SUBMITTED); + }); + + test('another user cannot update or delete an author private draft', async () => { + const state = createDatabase(); + state.memberships.find((membership) => membership.userId === otherUser.id)!.role = + WorldRole.CONTRIBUTOR; + state.contributions.push(createContributionRecord({ id: 'draft-1' })); + const app = await createApp(state.database); + + const updateResponse = await app.inject({ + headers: authHeader(otherUser), + method: 'PATCH', + payload: { title: 'No' }, + url: '/worlds/world-1/contributions/draft-1', + }); + const deleteResponse = await app.inject({ + headers: authHeader(otherUser), + method: 'DELETE', + url: '/worlds/world-1/contributions/draft-1', + }); + + assert.equal(updateResponse.statusCode, 404); + assert.equal(deleteResponse.statusCode, 404); + await app.close(); + }); + + test('valid draft submission creates exactly one proposal and locks immutable proposed content', async () => { + const state = createDatabase(); + state.contributions.push(createContributionRecord({ id: 'draft-1' })); + const app = await createApp(state.database); + + const submitResponse = await app.inject({ + headers: authHeader(), + method: 'POST', + url: '/worlds/world-1/contributions/draft-1/submit', + }); + const duplicateResponse = await app.inject({ + headers: authHeader(), + method: 'POST', + url: '/worlds/world-1/contributions/draft-1/submit', + }); + + assert.equal(submitResponse.statusCode, 201); + assert.equal(duplicateResponse.statusCode, 200); + assert.equal(state.proposals.length, 1); + 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.VOTING); + assert.equal(submitResponse.json().proposal.proposalType, ProposalType.UPDATE_LORE); + assert.deepEqual(submitResponse.json().proposal.proposedContent, structuredDoc); + assert.equal( + state.auditLogs.some( + (entry) => + (entry as { action?: WorldAuditAction }).action === + WorldAuditAction.CONTRIBUTION_SUBMITTED, + ), + true, + ); + await app.close(); + }); + + test('create rollback leaves no contribution when audit logging fails', async () => { + const state = createDatabase(); + state.failNextAuditCreate(); + + await assert.rejects( + createContribution(state.database as never, { + authorId: author.id, + content: structuredDoc, + kind: ContributionKind.LORE, + title: 'Rollback Draft', + worldId: 'world-1', + }), + ); + + assert.equal(state.contributions.length, 0); + assert.equal(state.auditLogs.length, 0); + }); + + test('story draft submission creates an ADD_STORY proposal', async () => { + const state = createDatabase(); + state.contributions.push( + createContributionRecord({ + id: 'story-1', + kind: ContributionKind.STORY, + targetLoreEntryId: null, + }), + ); + const app = await createApp(state.database); + + const response = await app.inject({ + headers: authHeader(), + method: 'POST', + url: '/worlds/world-1/contributions/story-1/submit', + }); + + assert.equal(response.statusCode, 201); + assert.equal(response.json().proposal.proposalType, ProposalType.ADD_STORY); + assert.equal(response.json().proposal.contributionKind, ContributionKind.STORY); + await app.close(); + }); + + test('submission rejects empty content, missing submit permission, and mismatched target lore', async () => { + const state = createDatabase(); + state.contributions.push(createContributionRecord({ content: emptyDoc, id: 'empty-1' })); + state.contributions.push( + createContributionRecord({ id: 'mismatch-1', targetLoreEntryId: 'lore-2' }), + ); + const app = await createApp(state.database); + + const emptyResponse = await app.inject({ + headers: authHeader(), + method: 'POST', + url: '/worlds/world-1/contributions/empty-1/submit', + }); + const forbiddenResponse = await app.inject({ + headers: authHeader(otherUser), + method: 'POST', + url: '/worlds/world-1/contributions/empty-1/submit', + }); + const mismatchResponse = await app.inject({ + headers: authHeader(), + method: 'POST', + url: '/worlds/world-1/contributions/mismatch-1/submit', + }); + + assert.equal(emptyResponse.statusCode, 400); + assert.equal(forbiddenResponse.statusCode, 403); + assert.equal(mismatchResponse.statusCode, 400); + assert.equal(state.proposals.length, 0); + await app.close(); + }); + + test('concurrent submissions and rollback do not create duplicate or partial proposal state', async () => { + const concurrentState = createDatabase(); + concurrentState.contributions.push(createContributionRecord({ id: 'draft-1' })); + const [first, second] = await Promise.allSettled([ + submitContribution(concurrentState.database as never, { + authorId: author.id, + contributionId: 'draft-1', + worldId: 'world-1', + }), + submitContribution(concurrentState.database as never, { + authorId: author.id, + contributionId: 'draft-1', + worldId: 'world-1', + }), + ]); + + assert.equal(concurrentState.proposals.length, 1); + assert.equal( + [first, second].filter((result) => result.status === 'fulfilled').length >= 1, + true, + ); + + const rollbackState = createDatabase(); + rollbackState.contributions.push(createContributionRecord({ id: 'draft-rollback' })); + rollbackState.failNextProposalCreate(); + + await assert.rejects( + submitContribution(rollbackState.database as never, { + authorId: author.id, + contributionId: 'draft-rollback', + worldId: 'world-1', + }), + ); + + assert.equal(rollbackState.proposals.length, 0); + assert.equal(rollbackState.contributions[0]?.status, ContributionStatus.DRAFT); + assert.equal(rollbackState.contributions[0]?.proposalId, null); + }); +}); diff --git a/apps/api/src/routes/contributions.ts b/apps/api/src/routes/contributions.ts new file mode 100644 index 0000000..4ece80a --- /dev/null +++ b/apps/api/src/routes/contributions.ts @@ -0,0 +1,431 @@ +import type { FastifyInstance, FastifyReply } from 'fastify'; +import { z } from 'zod'; + +import { env } from '../config/env.js'; +import { ContributionKind, ContributionStatus } from '../generated/prisma/enums.js'; +import { requireSession } from '../lib/auth-middleware.js'; +import { + ContributionError, + createContribution, + deleteContribution, + getOwnedContribution, + listOwnedContributions, + submitContribution, + updateContribution, + type ContributionDatabase, +} from '../lib/contributions.js'; +import { prisma } from '../lib/prisma.js'; +import { authorizeWorldPermission } from '../lib/world-authorization.js'; +import { WORLD_PERMISSIONS } from '../lib/world-permissions.js'; +import type { WorldMembershipLookup } from '../lib/world-authorization.js'; + +const routeParamsSchema = z.object({ + contributionId: z.string().min(1).optional(), + worldId: z.string().min(1), +}); + +const contributionKindSchema = z.enum([ContributionKind.LORE, ContributionKind.STORY]); +const contributionStatusSchema = z.enum([ContributionStatus.DRAFT, ContributionStatus.SUBMITTED]); + +const createContributionBodySchema = z + .object({ + content: z.unknown(), + kind: contributionKindSchema, + summary: z.string().trim().max(1_000).optional(), + targetLoreEntryId: z.string().trim().min(1).optional(), + title: z.string().trim().min(1).max(200), + }) + .strict(); + +const updateContributionBodySchema = z + .object({ + content: z.unknown().optional(), + kind: contributionKindSchema.optional(), + summary: z.string().trim().max(1_000).nullable().optional(), + targetLoreEntryId: z.string().trim().min(1).nullable().optional(), + title: z.string().trim().min(1).max(200).optional(), + }) + .strict() + .refine((value) => Object.keys(value).length > 0, { + message: 'At least one editable contribution field is required.', + }); + +const listContributionsQuerySchema = z.object({ + kind: contributionKindSchema.optional(), + page: z.coerce.number().int().positive().default(1), + pageSize: z.coerce.number().int().positive().max(100).default(20), + status: contributionStatusSchema.optional(), +}); + +type RegisterContributionRoutesOptions = { + database?: ContributionDatabase & WorldMembershipLookup; +}; + +function authOptions(database: ContributionDatabase) { + return { + audience: env.AUTH_JWT_AUDIENCE, + database, + issuer: env.AUTH_JWT_ISSUER, + jwtSecret: env.AUTH_JWT_SECRET, + }; +} + +function handleContributionError(error: unknown, reply: FastifyReply) { + if (error instanceof ContributionError) { + return reply.code(error.statusCode).send({ + error: error.message, + }); + } + + throw error; +} + +async function ensureWorldExists( + database: Pick, + worldId: string, + reply: FastifyReply, +) { + const world = await database.world.findUnique({ + select: { + id: true, + }, + where: { + id: worldId, + }, + }); + + if (!world) { + await reply.code(404).send({ + error: 'World not found.', + }); + + return false; + } + + return true; +} + +export async function registerContributionRoutes( + app: FastifyInstance, + options: RegisterContributionRoutesOptions = {}, +) { + const database = options.database ?? prisma; + + app.post( + '/worlds/:worldId/contributions', + { + preHandler: requireSession(authOptions(database)), + }, + async (request, reply) => { + const params = routeParamsSchema.safeParse(request.params); + const body = createContributionBodySchema.safeParse(request.body); + + if (!params.success || !body.success) { + return reply.code(400).send({ + error: 'Invalid contribution payload.', + }); + } + + const worldExists = await ensureWorldExists(database, params.data.worldId, reply); + + if (!worldExists) { + return; + } + + const authorized = await authorizeWorldPermission( + request, + reply, + params.data.worldId, + WORLD_PERMISSIONS.CREATE_LORE_DRAFT, + database, + ); + + if (!authorized) { + return; + } + + if (!request.user) { + return reply.code(401).send({ + error: 'Authentication required.', + }); + } + + try { + const contribution = await createContribution(database, { + ...body.data, + authorId: request.user.id, + worldId: params.data.worldId, + }); + + return reply.code(201).send({ + contribution, + }); + } catch (error) { + return handleContributionError(error, reply); + } + }, + ); + + app.get( + '/worlds/:worldId/contributions', + { + preHandler: requireSession(authOptions(database)), + }, + async (request, reply) => { + const params = routeParamsSchema.safeParse(request.params); + const query = listContributionsQuerySchema.safeParse(request.query); + + if (!params.success || !query.success) { + return reply.code(400).send({ + error: 'Invalid contributions query.', + }); + } + + const worldExists = await ensureWorldExists(database, params.data.worldId, reply); + + if (!worldExists) { + return; + } + + const authorized = await authorizeWorldPermission( + request, + reply, + params.data.worldId, + WORLD_PERMISSIONS.CREATE_LORE_DRAFT, + database, + ); + + if (!authorized) { + return; + } + + if (!request.user) { + return reply.code(401).send({ + error: 'Authentication required.', + }); + } + + return listOwnedContributions(database, { + ...query.data, + authorId: request.user.id, + worldId: params.data.worldId, + }); + }, + ); + + app.get( + '/worlds/:worldId/contributions/:contributionId', + { + preHandler: requireSession(authOptions(database)), + }, + async (request, reply) => { + const params = routeParamsSchema.required().safeParse(request.params); + + if (!params.success) { + return reply.code(400).send({ + error: 'Invalid contribution route.', + }); + } + + const worldExists = await ensureWorldExists(database, params.data.worldId, reply); + + if (!worldExists) { + return; + } + + const authorized = await authorizeWorldPermission( + request, + reply, + params.data.worldId, + WORLD_PERMISSIONS.CREATE_LORE_DRAFT, + database, + ); + + if (!authorized) { + return; + } + + if (!request.user) { + return reply.code(401).send({ + error: 'Authentication required.', + }); + } + + const contribution = await getOwnedContribution(database, { + authorId: request.user.id, + contributionId: params.data.contributionId, + worldId: params.data.worldId, + }); + + if (!contribution) { + return reply.code(404).send({ + error: 'Contribution not found.', + }); + } + + return { + contribution, + }; + }, + ); + + app.patch( + '/worlds/:worldId/contributions/:contributionId', + { + preHandler: requireSession(authOptions(database)), + }, + async (request, reply) => { + const params = routeParamsSchema.required().safeParse(request.params); + const body = updateContributionBodySchema.safeParse(request.body); + + if (!params.success || !body.success) { + return reply.code(400).send({ + error: 'Invalid contribution payload.', + }); + } + + const worldExists = await ensureWorldExists(database, params.data.worldId, reply); + + if (!worldExists) { + return; + } + + const authorized = await authorizeWorldPermission( + request, + reply, + params.data.worldId, + WORLD_PERMISSIONS.EDIT_OWN_DRAFT, + database, + ); + + if (!authorized) { + return; + } + + if (!request.user) { + return reply.code(401).send({ + error: 'Authentication required.', + }); + } + + try { + const contribution = await updateContribution(database, { + ...body.data, + authorId: request.user.id, + contributionId: params.data.contributionId, + worldId: params.data.worldId, + }); + + return { + contribution, + }; + } catch (error) { + return handleContributionError(error, reply); + } + }, + ); + + app.delete( + '/worlds/:worldId/contributions/:contributionId', + { + preHandler: requireSession(authOptions(database)), + }, + async (request, reply) => { + const params = routeParamsSchema.required().safeParse(request.params); + + if (!params.success) { + return reply.code(400).send({ + error: 'Invalid contribution route.', + }); + } + + const worldExists = await ensureWorldExists(database, params.data.worldId, reply); + + if (!worldExists) { + return; + } + + const authorized = await authorizeWorldPermission( + request, + reply, + params.data.worldId, + WORLD_PERMISSIONS.EDIT_OWN_DRAFT, + database, + ); + + if (!authorized) { + return; + } + + if (!request.user) { + return reply.code(401).send({ + error: 'Authentication required.', + }); + } + + try { + await deleteContribution(database, { + authorId: request.user.id, + contributionId: params.data.contributionId, + worldId: params.data.worldId, + }); + + return reply.code(204).send(); + } catch (error) { + return handleContributionError(error, reply); + } + }, + ); + + app.post( + '/worlds/:worldId/contributions/:contributionId/submit', + { + preHandler: requireSession(authOptions(database)), + }, + async (request, reply) => { + const params = routeParamsSchema.required().safeParse(request.params); + + if (!params.success) { + return reply.code(400).send({ + error: 'Invalid contribution route.', + }); + } + + const worldExists = await ensureWorldExists(database, params.data.worldId, reply); + + if (!worldExists) { + return; + } + + const authorized = await authorizeWorldPermission( + request, + reply, + params.data.worldId, + WORLD_PERMISSIONS.SUBMIT_PROPOSAL, + database, + ); + + if (!authorized) { + return; + } + + if (!request.user) { + return reply.code(401).send({ + error: 'Authentication required.', + }); + } + + try { + const result = await submitContribution(database, { + authorId: request.user.id, + contributionId: params.data.contributionId, + worldId: params.data.worldId, + }); + + return reply.code(result.alreadySubmitted ? 200 : 201).send(result); + } catch (error) { + return handleContributionError(error, reply); + } + }, + ); +} 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 new file mode 100644 index 0000000..76978ef --- /dev/null +++ b/apps/api/src/routes/proposals.ts @@ -0,0 +1,359 @@ +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 { + 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'; + +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 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(), + 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; +} + +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 = {}, +) { + 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, { + ...(currentUser ? { 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.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', + { + 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 new file mode 100644 index 0000000..6453e37 --- /dev/null +++ b/apps/api/src/routes/worlds.test.ts @@ -0,0 +1,1038 @@ +import assert from 'node:assert/strict'; +import { describe, test } from 'node:test'; + +import Fastify from 'fastify'; + +import { env } from '../config/env.js'; +import { + LoreStatus, + PlatformRole, + ProposalStatus, + WorldAuditAction, + 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 = { + avatarUrl: null, + displayName: 'Ember Quill', + hiveUsername: 'emberquill.dev', + id: 'user-1', + normalizedHiveUsername: 'emberquill.dev', +}; + +const contributor = { + avatarUrl: null, + displayName: 'Mira Vale', + hiveUsername: 'mira-vale.dev', + id: 'user-2', + normalizedHiveUsername: 'mira-vale.dev', +}; + +function authHeader(userId = founder.id) { + const token = signAccessToken( + { + hiveUsername: founder.hiveUsername, + normalizedHiveUsername: founder.normalizedHiveUsername, + platformRole: PlatformRole.USER, + sid: `session-${userId}`, + sub: userId, + }, + { + audience: env.AUTH_JWT_AUDIENCE, + issuer: env.AUTH_JWT_ISSUER, + secret: env.AUTH_JWT_SECRET, + ttlSeconds: 900, + }, + ); + + return { + authorization: `Bearer ${token}`, + }; +} + +type StoredWorld = { + id: string; + slug: string; + title: string; + description: string; + founderId: string; + founder: typeof founder; + createdAt: Date; + updatedAt: Date; +}; + +type StoredWorldSeed = { + id: string; + worldId: string; + premise: string; + genre: string; + tone: string; + mainConflict: string; + startingLocation: string | null; + firstCharacters: unknown; + firstFactions: unknown; + firstHistoricalEvent: string | null; + createdAt: Date; + updatedAt: Date; +}; + +type StoredBibleVersion = { + id: string; + worldId: string; + versionNumber: number; + creatorId: string; + content: unknown; + changeSummary: string | null; + publishedAt: Date | null; + hiveReferenceId: string | null; + createdAt: Date; + updatedAt: Date; +}; + +function createWorldRecord(input: { + id: string; + slug: string; + title: string; + description: string; + founderId?: string; +}): StoredWorld { + return { + createdAt: new Date('2026-08-05T00:00:00.000Z'), + description: input.description, + founder: input.founderId === contributor.id ? contributor : founder, + founderId: input.founderId ?? founder.id, + id: input.id, + slug: input.slug, + title: input.title, + updatedAt: new Date('2026-08-05T00:00:00.000Z'), + }; +} + +function createDatabase() { + const worlds: StoredWorld[] = []; + const seeds: StoredWorldSeed[] = []; + const bibleVersions: StoredBibleVersion[] = []; + const memberships: Array<{ + id: string; + worldId: string; + userId: string; + role: WorldRole; + revokedAt: Date | null; + }> = []; + const auditLogs: unknown[] = []; + const loreEntries: Array<{ + id: string; + worldId: string; + title: string; + slug: string; + loreType: string; + status: LoreStatus; + updatedAt: Date; + }> = []; + const proposals: Array<{ + id: string; + 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; + }); + + return { + ...world, + bibleVersions: versions, + founder: world.founder, + seed: seeds.find((seed) => seed.worldId === world.id) ?? null, + }; + } + + const database = { + async $transaction(this: unknown, callback: (transaction: never) => Promise) { + 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 } }) { + return loreEntries.filter( + (entry) => + entry.worldId === args.where.worldId && + (!args.where.status || entry.status === args.where.status), + ).length; + }, + async findMany(args: { where: { worldId: string; status?: LoreStatus }; take: number }) { + return loreEntries + .filter( + (entry) => + entry.worldId === args.where.worldId && + (!('status' in args.where) || entry.status === args.where.status), + ) + .slice(0, args.take); + }, + }, + proposal: { + async count(args: { where: { worldId: string; status: { in: ProposalStatus[] } } }) { + return proposals.filter( + (proposal) => + proposal.worldId === args.where.worldId && + args.where.status.in.includes(proposal.status), + ).length; + }, + }, + world: { + async count(args?: { where?: Parameters[1] }) { + return worlds.filter((world) => matchesWorldWhere(world, args?.where)).length; + }, + async create(args: { + data: { + description: string; + founderId: string; + slug: string; + 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, + id: `world-${worlds.length + 1}`, + slug: args.data.slug, + title: args.data.title, + }); + worlds.push(world); + return world; + }, + 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) => { + if (args.where.id) { + return candidate.id === args.where.id; + } + + return candidate.slug === args.where.slug; + }); + + return world ? includeWorld(world) : null; + }, + async findUniqueOrThrow(args: { where: { id: string } }) { + const world = worlds.find((candidate) => candidate.id === args.where.id); + + if (!world) { + throw new Error('Missing world.'); + } + + return includeWorld(world); + }, + async update(args: { + data: Partial>; + where: { id: string }; + }) { + const world = worlds.find((candidate) => candidate.id === args.where.id); + + if (!world) { + throw new Error('Missing world.'); + } + + Object.assign(world, args.data, { + updatedAt: new Date('2026-08-05T00:01:00.000Z'), + }); + + return world; + }, + }, + worldAuditLog: { + async create(args: { data: unknown }) { + if (failNextAuditCreate) { + failNextAuditCreate = false; + throw new Error('Injected audit failure.'); + } + + auditLogs.push(args.data); + return args.data; + }, + }, + worldBibleVersion: { + async create(args: { + data: { + changeSummary: string; + content: unknown; + creatorId: string; + versionNumber: number; + worldId: string; + }; + }) { + const version = { + ...args.data, + createdAt: new Date('2026-08-05T00:00:00.000Z'), + hiveReferenceId: null, + id: `bible-${bibleVersions.length + 1}`, + publishedAt: null, + updatedAt: new Date('2026-08-05T00:00:00.000Z'), + }; + bibleVersions.push(version); + return version; + }, + async update(args: { + data: Partial>; + where: { id: string }; + }) { + const version = bibleVersions.find((candidate) => candidate.id === args.where.id); + + if (!version) { + throw new Error('Missing bible version.'); + } + + Object.assign(version, args.data, { + updatedAt: new Date('2026-08-05T00:01:00.000Z'), + }); + + return version; + }, + }, + worldMembership: { + async create(args: { + data: { + grantedById: string; + role: WorldRole; + userId: string; + worldId: string; + }; + }) { + const membership = { + id: `membership-${memberships.length + 1}`, + revokedAt: null, + ...args.data, + }; + memberships.push(membership); + return membership; + }, + async findUnique(args: { + where: { worldId_userId: { worldId: string; userId: string }; revokedAt?: null }; + }) { + return ( + memberships.find( + (membership) => + membership.worldId === args.where.worldId_userId.worldId && + membership.userId === args.where.worldId_userId.userId && + !membership.revokedAt, + ) ?? null + ); + }, + }, + worldSeed: { + async create(args: { + data: { + firstCharacters: string[]; + firstFactions: string[]; + firstHistoricalEvent: string | null; + genre: string; + mainConflict: string; + premise: string; + startingLocation: string | null; + tone: string; + 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'), + id: `seed-${seeds.length + 1}`, + updatedAt: new Date('2026-08-05T00:00:00.000Z'), + }; + seeds.push(seed); + return seed; + }, + async update(args: { data: Partial; where: { worldId: string } }) { + const seed = seeds.find((candidate) => candidate.worldId === args.where.worldId); + + if (!seed) { + throw new Error('Missing seed.'); + } + + Object.assign(seed, args.data, { + updatedAt: new Date('2026-08-05T00:01:00.000Z'), + }); + + 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 { + auditLogs, + bibleVersions, + database, + loreEntries, + memberships, + proposals, + seeds, + failNextAuditCreate() { + failNextAuditCreate = true; + }, + failNextSeedCreate() { + failNextSeedCreate = true; + }, + failNextWorldCreateWithSlugConflict() { + failNextWorldCreateWithSlugConflict = true; + }, + worlds, + }; +} + +async function createApp(database: ReturnType['database']) { + const app = Fastify(); + await registerWorldRoutes(app, { + database: database as never, + }); + return app; +} + +const worldPayload = { + bible: { + changeSummary: 'Initial bible.', + content: { + rules: ['No false canon.'], + style: 'Mythic', + }, + }, + description: 'A realm of moonlit archives.', + seed: { + firstCharacters: ['Archivist Lume'], + firstFactions: ['Moon Scribes'], + firstHistoricalEvent: 'The first archive opens.', + genre: 'Fantasy', + mainConflict: 'Memory fades whenever the moons align.', + premise: 'A world where moonlight writes history before people can.', + startingLocation: 'The Glass Archive', + tone: 'Mystic wonder', + }, + title: 'Moon Archive', +}; + +describe('world routes', () => { + test('creates a world atomically with seed, initial bible, founder membership, and audit log', async () => { + const state = createDatabase(); + const app = await createApp(state.database); + const response = await app.inject({ + headers: authHeader(), + method: 'POST', + payload: worldPayload, + url: '/worlds', + }); + + assert.equal(response.statusCode, 201); + assert.equal(response.json().world.title, 'Moon Archive'); + assert.equal(response.json().world.seed.genre, 'Fantasy'); + assert.equal(response.json().world.currentBibleVersion.versionNumber, 1); + assert.equal(response.json().world.currentBibleVersion.publishedAt, null); + assert.equal(state.worlds.length, 1); + assert.equal(state.seeds.length, 1); + assert.equal(state.bibleVersions.length, 1); + assert.equal(state.memberships[0]?.role, WorldRole.FOUNDER); + assert.deepEqual(state.auditLogs[0], { + action: WorldAuditAction.ROLE_ASSIGNED, + actorId: founder.id, + metadata: { + role: WorldRole.FOUNDER, + }, + targetId: 'membership-1', + targetType: 'WORLD_MEMBERSHIP', + worldId: 'world-1', + }); + await app.close(); + }); + + test('requires authentication to create a world and ignores client founder role data', async () => { + const state = createDatabase(); + const app = await createApp(state.database); + const response = await app.inject({ + method: 'POST', + payload: { + ...worldPayload, + founderId: contributor.id, + role: WorldRole.CURATOR, + }, + url: '/worlds', + }); + + assert.equal(response.statusCode, 401); + assert.equal(state.worlds.length, 0); + await app.close(); + }); + + 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: { + ...worldPayload, + bible: { + changeSummary: 'Missing content should not pass as undefined.', + }, + }, + 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'; + + 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(); + }); + + test('browses, retrieves, and opens a world hub response', async () => { + const state = createDatabase(); + const app = await createApp(state.database); + await app.inject({ + headers: authHeader(), + method: 'POST', + payload: worldPayload, + url: '/worlds', + }); + state.loreEntries.push({ + id: 'lore-1', + loreType: 'CHARACTER', + slug: 'archivist-lume', + status: LoreStatus.PUBLISHED_CANON, + title: 'Archivist Lume', + updatedAt: new Date('2026-08-05T00:02:00.000Z'), + worldId: 'world-1', + }); + state.loreEntries.push({ + id: 'draft-lore', + loreType: 'FACTION', + slug: 'hidden-draft', + status: LoreStatus.DRAFT, + title: 'Hidden Draft', + updatedAt: new Date('2026-08-05T00:03:00.000Z'), + worldId: 'world-1', + }); + state.proposals.push({ + id: 'proposal-1', + status: ProposalStatus.SUBMITTED, + worldId: 'world-1', + }); + + const listResponse = await app.inject({ + method: 'GET', + url: '/worlds?q=moon&page=1&pageSize=10', + }); + const getResponse = await app.inject({ + method: 'GET', + url: '/worlds/world-1', + }); + const hubResponse = await app.inject({ + method: 'GET', + url: '/worlds/world-1/hub', + }); + + 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); + assert.equal(hubResponse.json().stats.activeProposalCount, 1); + assert.equal('draftLoreCount' in hubResponse.json().stats, false); + assert.equal(hubResponse.json().latestLoreEntries[0].id, 'lore-1'); + assert.equal( + hubResponse + .json() + .latestLoreEntries.some((entry: { id: string }) => entry.id === 'draft-lore'), + false, + ); + 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); + await app.inject({ + headers: authHeader(), + method: 'POST', + payload: worldPayload, + url: '/worlds', + }); + + const response = await app.inject({ + headers: authHeader(), + method: 'PATCH', + payload: { + bible: { + changeSummary: 'Refined initial bible.', + content: { + rules: ['Keep the moon archive coherent.'], + }, + }, + seed: { + tone: 'Quiet awe', + }, + title: 'Moon Archive Revised', + }, + url: '/worlds/world-1', + }); + + assert.equal(response.statusCode, 200); + assert.equal(response.json().world.title, 'Moon Archive Revised'); + assert.equal(response.json().world.seed.tone, 'Quiet awe'); + assert.deepEqual(response.json().world.currentBibleVersion.content, { + 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); + await app.inject({ + headers: authHeader(), + method: 'POST', + payload: worldPayload, + url: '/worlds', + }); + + const response = await app.inject({ + headers: authHeader(contributor.id), + method: 'PATCH', + payload: { + title: 'Unauthorized', + }, + url: '/worlds/world-1', + }); + + 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 new file mode 100644 index 0000000..dd442c4 --- /dev/null +++ b/apps/api/src/routes/worlds.ts @@ -0,0 +1,382 @@ +import type { FastifyInstance } from 'fastify'; +import { z } from 'zod'; + +import { env } from '../config/env.js'; +import type { Prisma } from '../generated/prisma/client.js'; +import { authenticateRequest, requireSession } from '../lib/auth-middleware.js'; +import { prisma } from '../lib/prisma.js'; +import { + createWorld, + getWorld, + getWorldHub, + listWorlds, + updateWorld, + WorldError, + type WorldDatabase, + type WorldSeedInput, +} from '../lib/worlds.js'; +import { WORLD_PERMISSIONS } from '../lib/world-permissions.js'; +import { authorizeWorldPermission } from '../lib/world-authorization.js'; +import type { WorldMembershipLookup } from '../lib/world-authorization.js'; + +const paramsSchema = z.object({ + worldId: z.string().min(1), +}); + +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; + +type JsonValidationResult = + | { + ok: true; + value: Prisma.InputJsonValue; + } + | { + ok: false; + message: string; + }; + +function validateJsonValue(value: unknown): JsonValidationResult { + if (value === null) { + return { + message: 'World bible content cannot be null.', + ok: false, + }; + } + + const pending: Array<{ value: unknown; depth: number }> = [{ depth: 0, value }]; + + while (pending.length > 0) { + const item = pending.pop()!; + + 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 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, + }; + } + + continue; + } + + 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, + }; + } + + 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 + .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(), + firstFactions: stringListSchema.optional(), + firstHistoricalEvent: z.string().trim().min(1).max(1_000).optional(), + genre: z.string().trim().min(1).max(80), + mainConflict: z.string().trim().min(1).max(2_000), + premise: z.string().trim().min(1).max(3_000), + startingLocation: z.string().trim().min(1).max(500).optional(), + tone: z.string().trim().min(1).max(80), +}); + +const worldBibleSchema = z.object({ + changeSummary: z.string().trim().min(1).max(500).optional(), + content: jsonValueSchema, +}); + +const createWorldSchema = z.object({ + bible: worldBibleSchema, + description: z.string().trim().min(1).max(1_000), + seed: worldSeedSchema, + title: z.string().trim().min(1).max(160), +}); + +const updateWorldSchema = z + .object({ + bible: worldBibleSchema.optional(), + description: z.string().trim().min(1).max(1_000).optional(), + seed: worldSeedSchema.partial().optional(), + title: z.string().trim().min(1).max(160).optional(), + }) + .refine((value) => Object.keys(value).length > 0, { + message: 'At least one editable world field is required.', + }); + +const listWorldsSchema = z.object({ + genre: z.string().trim().min(1).max(80).optional(), + page: z.coerce.number().int().positive().default(1), + pageSize: z.coerce.number().int().positive().max(100).default(20), + q: z.string().trim().min(1).max(120).optional(), + tone: z.string().trim().min(1).max(80).optional(), +}); + +type RegisterWorldRoutesOptions = { + database?: WorldDatabase & WorldMembershipLookup; +}; + +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 = {}, +) { + const database = options.database ?? prisma; + + app.post( + '/worlds', + { + preHandler: requireSession(authOptions(database)), + }, + async (request, reply) => { + const body = createWorldSchema.safeParse(request.body); + + if (!body.success) { + return reply.code(400).send({ + error: 'Invalid world payload.', + }); + } + + const authenticatedUser = await authenticateRequest(request, authOptions(database)); + + if (!authenticatedUser) { + return reply.code(401).send({ + error: 'Authentication required.', + }); + } + + 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, + }); + } catch (error) { + return handleWorldError(error, reply); + } + }, + ); + + app.get('/worlds', async (request, reply) => { + const query = listWorldsSchema.safeParse(request.query); + + if (!query.success) { + return reply.code(400).send({ + error: 'Invalid worlds query.', + }); + } + + return listWorlds(database, query.data); + }); + + app.get('/worlds/:worldId', async (request, reply) => { + const params = paramsSchema.safeParse(request.params); + + if (!params.success) { + return reply.code(400).send({ + error: 'Invalid world route.', + }); + } + + const world = await getWorld(database, params.data.worldId); + + if (!world) { + return reply.code(404).send({ + error: 'World not found.', + }); + } + + return { + world, + }; + }); + + app.get('/worlds/:worldId/hub', async (request, reply) => { + const params = paramsSchema.safeParse(request.params); + + if (!params.success) { + return reply.code(400).send({ + error: 'Invalid world route.', + }); + } + + const hub = await getWorldHub(database, params.data.worldId); + + if (!hub) { + return reply.code(404).send({ + error: 'World not found.', + }); + } + + return hub; + }); + + app.patch( + '/worlds/:worldId', + { + preHandler: requireSession(authOptions(database)), + }, + async (request, reply) => { + const params = paramsSchema.safeParse(request.params); + const body = updateWorldSchema.safeParse(request.body); + + if (!params.success || !body.success) { + return reply.code(400).send({ + error: 'Invalid world payload.', + }); + } + + const authorized = await authorizeWorldPermission( + request, + reply, + params.data.worldId, + WORLD_PERMISSIONS.EDIT_INITIAL_CANON, + database, + ); + + if (!authorized) { + return; + } + + if (!request.user) { + return reply.code(401).send({ + error: 'Authentication required.', + }); + } + + 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/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..a937e53 --- /dev/null +++ b/apps/web/src/app/worlds/[worldId]/proposals/[proposalId]/canon-vote-screen.tsx @@ -0,0 +1,786 @@ +'use client'; + +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'; +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'; +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)}% +
+
+
+
+
+ ); +} + +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); + const [currentUser, setCurrentUser] = useState(null); + const [sessionChecked, setSessionChecked] = useState(false); + const [receipt, setReceipt] = useState({ + blockNumber: '', + operationIndex: '', + transactionId: '', + }); + const [message, setMessage] = useState(null); + const [commentBody, setCommentBody] = useState(''); + + 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 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) => + 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 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; + } + + 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 || + commentMutation.error ? ( + + Action failed + + {getErrorMessage( + voteMutation.error ?? + finalizeMutation.error ?? + signMutation.error ?? + confirmMutation.error ?? + commentMutation.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} +
+ +
+
+
+

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} + +
{ + event.preventDefault(); + + if (!accessToken || commentValidationMessage || commentMutation.isPending) { + return; + } + + commentMutation.mutate(); + }} + > + +