diff --git a/README.md b/README.md index 2b691ca..d556e96 100644 --- a/README.md +++ b/README.md @@ -319,6 +319,23 @@ 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. + --- # 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/20260808120000_add_quest_story_lore_types/migration.sql b/apps/api/prisma/migrations/20260808120000_add_quest_story_lore_types/migration.sql new file mode 100644 index 0000000..65812f7 --- /dev/null +++ b/apps/api/prisma/migrations/20260808120000_add_quest_story_lore_types/migration.sql @@ -0,0 +1,2 @@ +ALTER TYPE "LoreType" ADD VALUE IF NOT EXISTS 'QUEST'; +ALTER TYPE "LoreType" ADD VALUE IF NOT EXISTS 'STORY'; diff --git a/apps/api/prisma/migrations/20260809120000_add_lore_relationship_audit_actions/migration.sql b/apps/api/prisma/migrations/20260809120000_add_lore_relationship_audit_actions/migration.sql new file mode 100644 index 0000000..ec40412 --- /dev/null +++ b/apps/api/prisma/migrations/20260809120000_add_lore_relationship_audit_actions/migration.sql @@ -0,0 +1,5 @@ +ALTER TYPE "WorldAuditAction" ADD VALUE IF NOT EXISTS 'LORE_ENTRY_CREATED'; +ALTER TYPE "WorldAuditAction" ADD VALUE IF NOT EXISTS 'LORE_ENTRY_UPDATED'; +ALTER TYPE "WorldAuditAction" ADD VALUE IF NOT EXISTS 'LORE_ENTRY_DELETED'; +ALTER TYPE "WorldAuditAction" ADD VALUE IF NOT EXISTS 'LORE_RELATIONSHIP_CREATED'; +ALTER TYPE "WorldAuditAction" ADD VALUE IF NOT EXISTS 'LORE_RELATIONSHIP_DELETED'; diff --git a/apps/api/prisma/migrations/20260810134000_backfill_quest_story_lore_types/migration.sql b/apps/api/prisma/migrations/20260810134000_backfill_quest_story_lore_types/migration.sql new file mode 100644 index 0000000..efe0ebb --- /dev/null +++ b/apps/api/prisma/migrations/20260810134000_backfill_quest_story_lore_types/migration.sql @@ -0,0 +1,11 @@ +UPDATE "LoreEntry" +SET "loreType" = 'QUEST' +WHERE "loreType" = 'OTHER' + AND "status" <> 'PUBLISHED_CANON' + AND "content"->>'entityType' = 'QUEST'; + +UPDATE "LoreEntry" +SET "loreType" = 'STORY' +WHERE "loreType" = 'OTHER' + AND "status" <> 'PUBLISHED_CANON' + AND "content"->>'entityType' = 'STORY_CONTRIBUTION'; diff --git a/apps/api/prisma/migrations/20260810162000_add_lore_ordering_indexes/migration.sql b/apps/api/prisma/migrations/20260810162000_add_lore_ordering_indexes/migration.sql new file mode 100644 index 0000000..6b6d476 --- /dev/null +++ b/apps/api/prisma/migrations/20260810162000_add_lore_ordering_indexes/migration.sql @@ -0,0 +1,4 @@ +-- Support deterministic lore list and relation projections. +CREATE INDEX "LoreEntry_worldId_updatedAt_id_idx" ON "LoreEntry"("worldId", "updatedAt", "id"); +CREATE INDEX "LoreRelationship_sourceId_updatedAt_id_idx" ON "LoreRelationship"("sourceId", "updatedAt", "id"); +CREATE INDEX "LoreRelationship_targetId_updatedAt_id_idx" ON "LoreRelationship"("targetId", "updatedAt", "id"); diff --git a/apps/api/prisma/migrations/20260810190000_repair_published_canon_quest_story_lore_types/migration.sql b/apps/api/prisma/migrations/20260810190000_repair_published_canon_quest_story_lore_types/migration.sql new file mode 100644 index 0000000..ea76c68 --- /dev/null +++ b/apps/api/prisma/migrations/20260810190000_repair_published_canon_quest_story_lore_types/migration.sql @@ -0,0 +1,50 @@ +WITH repaired AS ( + UPDATE "LoreEntry" + SET "loreType" = CASE + WHEN "content"->>'entityType' = 'QUEST' THEN 'QUEST'::"LoreType" + WHEN "content"->>'entityType' = 'STORY_CONTRIBUTION' THEN 'STORY'::"LoreType" + ELSE "loreType" + END + WHERE "status" = 'PUBLISHED_CANON' + AND "loreType" = 'OTHER' + AND "content"->>'entityType' IN ('QUEST', 'STORY_CONTRIBUTION') + RETURNING + "id", + "worldId", + "loreType", + "content"->>'entityType' AS "entityType" +) +INSERT INTO "WorldAuditLog" ( + "id", + "worldId", + "actorId", + "action", + "targetType", + "targetId", + "metadata", + "createdAt" +) +SELECT + gen_random_uuid()::text, + repaired."worldId", + NULL, + 'LORE_ENTRY_UPDATED', + 'LORE_ENTRY', + repaired."id", + jsonb_build_object( + 'reason', 'Corrected a published canon lore type that still used OTHER after the QUEST/STORY type split.', + 'migration', '20260810190000_repair_published_canon_quest_story_lore_types', + 'loreType', repaired."loreType", + 'entityType', repaired."entityType" + ), + CURRENT_TIMESTAMP +FROM repaired +WHERE NOT EXISTS ( + SELECT 1 + FROM "WorldAuditLog" + WHERE "WorldAuditLog"."targetType" = 'LORE_ENTRY' + AND "WorldAuditLog"."targetId" = repaired."id" + AND "WorldAuditLog"."action" = 'LORE_ENTRY_UPDATED' + AND "WorldAuditLog"."metadata"->>'migration' = + '20260810190000_repair_published_canon_quest_story_lore_types' +); diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 46e369c..040ef6f 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -12,7 +12,9 @@ enum LoreType { CHARACTER LOCATION FACTION + QUEST EVENT + STORY ARTIFACT HISTORY RULE @@ -29,10 +31,21 @@ 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 @@ -129,6 +142,16 @@ enum WorldAuditAction { MODERATION_ACTION CANON_STATUS_EXECUTED AI_WARNING_RESOLVED + CONTRIBUTION_CREATED + CONTRIBUTION_UPDATED + CONTRIBUTION_DELETED + CONTRIBUTION_SUBMITTED + PROPOSAL_CREATED + LORE_ENTRY_CREATED + LORE_ENTRY_UPDATED + LORE_ENTRY_DELETED + LORE_RELATIONSHIP_CREATED + LORE_RELATIONSHIP_DELETED } enum AuthProvider { @@ -168,6 +191,7 @@ model User { bibleVersions WorldBibleVersion[] @relation("WorldBibleVersionCreator") loreEntries LoreEntry[] @relation("LoreEntryAuthor") proposals Proposal[] @relation("ProposalAuthor") + contributionDrafts ContributionDraft[] @relation("ContributionDraftAuthor") votes AppVote[] @relation("AppVoteVoter") moderationReports ModerationReport[] @relation("ModerationReporter") moderationReviews ModerationReport[] @relation("ModerationReviewer") @@ -296,27 +320,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,30 +426,32 @@ 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]) @@index([worldId, loreType]) + @@index([worldId, updatedAt, id]) @@index([authorId]) } @@ -425,6 +473,8 @@ model LoreRelationship { @@index([worldId]) @@index([sourceId]) @@index([targetId]) + @@index([sourceId, updatedAt, id]) + @@index([targetId, updatedAt, id]) } model Proposal { @@ -434,6 +484,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 @@ -451,12 +502,41 @@ model Proposal { publishedAt DateTime? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt + contributionDraft ContributionDraft? votes AppVote[] aiReports AIReport[] moderationReports ModerationReport[] @@index([worldId, status]) @@index([authorId]) + @@index([worldId, contributionKind]) + @@index([targetLoreEntryId]) +} + +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]) } 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..e3e2fea 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 { registerLoreRoutes } from './routes/lore.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 registerLoreRoutes(app); + await registerContributionRoutes(app); return app; } diff --git a/apps/api/src/lib/contributions.ts b/apps/api/src/lib/contributions.ts new file mode 100644 index 0000000..675109b --- /dev/null +++ b/apps/api/src/lib/contributions.ts @@ -0,0 +1,755 @@ +import type { Prisma, PrismaClient } from '../generated/prisma/client.js'; +import { + ContributionKind, + ContributionStatus, + ProposalStatus, + ProposalType, + WorldAuditAction, +} from '../generated/prisma/enums.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' + | '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) => { + 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) => { + 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) => { + 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) => { + 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 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, + contributionKind: existing.kind, + proposedContent: structuredContent, + proposalType: proposalTypeForContribution( + existing.kind, + Boolean(existing.targetLoreEntryId), + ), + status: ProposalStatus.SUBMITTED, + submittedAt, + summary: existing.summary ?? '', + targetLoreEntryId: existing.targetLoreEntryId, + title: existing.title, + 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/worlds.ts b/apps/api/src/lib/worlds.ts new file mode 100644 index 0000000..a6bfcc1 --- /dev/null +++ b/apps/api/src/lib/worlds.ts @@ -0,0 +1,498 @@ +import type { Prisma, PrismaClient } from '../generated/prisma/client.js'; +import { + LoreStatus, + ProposalStatus, + WorldAuditAction, + WorldRole, +} from '../generated/prisma/enums.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; + title?: string | undefined; + description?: string | undefined; + seed?: Partial | undefined; + bible?: WorldBibleInput | undefined; +}; + +export type WorldDatabase = Pick< + PrismaClient, + | '$transaction' + | 'loreEntry' + | 'proposal' + | 'world' + | 'worldAuditLog' + | 'worldBibleVersion' + | 'worldMembership' + | 'worldSeed' +>; + +const worldInclude = { + bibleVersions: { + orderBy: { + versionNumber: 'desc', + }, + 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; +}>; + +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] | null) { + if (!version) { + return null; + } + + return { + changeSummary: version.changeSummary, + 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; + +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 Error('Unable to create a unique world slug.'); +} + +export async function createWorld(database: WorldDatabase, input: CreateWorldInput) { + const slug = await generateUniqueWorldSlug(database, input.title); + const seed = normalizeSeedInput(input.seed); + + const world = await database.$transaction(async (transaction) => { + const createdWorld = await transaction.world.create({ + data: { + description: input.description.trim(), + founderId: input.creatorId, + slug, + title: input.title.trim(), + }, + }); + + await transaction.worldSeed.create({ + data: { + firstCharacters: seed.firstCharacters, + firstFactions: seed.firstFactions, + firstHistoricalEvent: seed.firstHistoricalEvent, + genre: seed.genre, + mainConflict: seed.mainConflict, + premise: seed.premise, + startingLocation: seed.startingLocation, + tone: seed.tone, + worldId: createdWorld.id, + }, + }); + + 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); +} + +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: worldInclude, + orderBy: [ + { + createdAt: 'desc', + }, + { + id: 'desc', + }, + ], + skip: (page - 1) * pageSize, + take: pageSize, + where, + }), + database.world.count({ + where, + }), + ]); + + return { + pagination: { + page, + pageSize, + total, + }, + worlds: worlds.map(serializeWorld), + }; +} + +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) => ({ + ...entry, + updatedAt: entry.updatedAt.toISOString(), + })), + stats: { + activeProposalCount, + canonLoreCount, + }, + world, + }; +} + +export async function updateWorld(database: WorldDatabase, input: UpdateWorldInput) { + const world = await database.$transaction(async (transaction) => { + const current = await transaction.world.findUnique({ + include: { + bibleVersions: { + orderBy: { + versionNumber: 'desc', + }, + take: 1, + }, + seed: true, + }, + where: { + id: input.worldId, + }, + }); + + if (!current) { + return null; + } + + 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 Error('World seed is missing.'); + } + + const normalizedSeed = normalizeSeedInput({ + firstCharacters: + input.seed.firstCharacters ?? + (Array.isArray(currentSeed.firstCharacters) + ? currentSeed.firstCharacters.filter( + (value): value is string => typeof value === 'string', + ) + : []), + firstFactions: + input.seed.firstFactions ?? + (Array.isArray(currentSeed.firstFactions) + ? currentSeed.firstFactions.filter( + (value): 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 Error('World bible version is missing.'); + } + + await transaction.worldBibleVersion.update({ + data: { + changeSummary: input.bible.changeSummary?.trim() || currentBible.changeSummary, + content: input.bible.content, + }, + where: { + id: currentBible.id, + }, + }); + } + + return transaction.world.findUniqueOrThrow({ + include: worldInclude, + where: { + id: input.worldId, + }, + }); + }); + + return world ? serializeWorld(world) : null; +} diff --git a/apps/api/src/routes/contributions.test.ts b/apps/api/src/routes/contributions.test.ts new file mode 100644 index 0000000..c922f38 --- /dev/null +++ b/apps/api/src/routes/contributions.test.ts @@ -0,0 +1,953 @@ +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 } }) { + const userId = args.where.id.replace(/^session-/, ''); + + return { + expiresAt: new Date(Date.now() + 60 * 60 * 1000), + revokedAt: null, + userId, + }; + }, + }, + world: { + async findUnique(args: { where: { id: string } }) { + return worlds.find((world) => world.id === args.where.id) ?? null; + }, + }, + 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.SUBMITTED); + 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..dc1cab1 --- /dev/null +++ b/apps/api/src/routes/contributions.ts @@ -0,0 +1,432 @@ +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 type { SessionVerificationDatabase } from '../lib/auth-sessions.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 & SessionVerificationDatabase; +}; + +function authOptions(database: SessionVerificationDatabase) { + 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/lore.test.ts b/apps/api/src/routes/lore.test.ts new file mode 100644 index 0000000..9158338 --- /dev/null +++ b/apps/api/src/routes/lore.test.ts @@ -0,0 +1,1210 @@ +import assert from 'node:assert/strict'; +import { describe, test } from 'node:test'; + +import Fastify from 'fastify'; + +import { env } from '../config/env.js'; +import { LoreStatus, LoreType, PlatformRole, WorldRole } from '../generated/prisma/enums.js'; +import { signAccessToken } from '../lib/auth-crypto.js'; +import { registerLoreRoutes } from './lore.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', +}; + +const curator = { + hiveUsername: 'curator.dev', + id: 'user-curator', + normalizedHiveUsername: 'curator.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}`, + }; +} + +function now() { + return new Date('2026-08-08T12:00:00.000Z'); +} + +type StoredLoreEntry = { + id: string; + worldId: string; + authorId: string; + title: string; + slug: string; + loreType: LoreType; + content: unknown; + status: LoreStatus; + publishedAt: Date | null; + hiveReferenceId: string | null; + createdAt: Date; + updatedAt: Date; +}; + +type StoredLoreRelationship = { + id: string; + worldId: string; + sourceId: string; + targetId: string; + relationType: string; + metadata: unknown; + createdAt: Date; + updatedAt: Date; +}; + +function createLoreRecord(input: Partial = {}): StoredLoreEntry { + return { + authorId: author.id, + content: { + body: 'A safe body.', + summary: 'A short summary.', + }, + createdAt: now(), + hiveReferenceId: null, + id: `lore-${Math.random().toString(36).slice(2)}`, + loreType: LoreType.CHARACTER, + publishedAt: null, + slug: 'safe-body', + status: LoreStatus.DRAFT, + title: 'Safe Body', + updatedAt: now(), + worldId: 'world-1', + ...input, + }; +} + +function createDatabase() { + const worlds = [{ id: 'world-1' }, { id: 'world-2' }]; + const users = [author, otherUser, curator]; + const loreEntries: StoredLoreEntry[] = []; + const loreRelationships: StoredLoreRelationship[] = []; + const worldAuditLogs: unknown[] = []; + 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', + }, + { + id: 'membership-curator', + revokedAt: null, + role: WorldRole.CURATOR, + userId: curator.id, + worldId: 'world-1', + }, + ]; + + function includeLore(entry: StoredLoreEntry) { + const user = users.find((candidate) => candidate.id === entry.authorId) ?? author; + const selectRelatedEntry = (entryId: string) => + loreEntries.find((candidate) => candidate.id === entryId)!; + + return { + ...entry, + author: { + avatarUrl: null, + displayName: null, + hiveUsername: user.hiveUsername, + id: user.id, + }, + incomingRelations: loreRelationships + .filter((relationship) => relationship.targetId === entry.id) + .map((relationship) => ({ + id: relationship.id, + relationType: relationship.relationType, + source: includeRelatedEntry(selectRelatedEntry(relationship.sourceId)), + })), + outgoingRelations: loreRelationships + .filter((relationship) => relationship.sourceId === entry.id) + .map((relationship) => ({ + id: relationship.id, + relationType: relationship.relationType, + target: includeRelatedEntry(selectRelatedEntry(relationship.targetId)), + })), + }; + } + + function includeRelatedEntry(entry: StoredLoreEntry) { + return { + authorId: entry.authorId, + id: entry.id, + loreType: entry.loreType, + slug: entry.slug, + status: entry.status, + title: entry.title, + }; + } + + function matchesWhere(entry: StoredLoreEntry, where: Record): boolean { + return Object.entries(where).every(([key, value]) => { + if (key === 'OR' && Array.isArray(value)) { + return value.some((condition): boolean => + matchesWhere(entry, condition as Record), + ); + } + + if (key === 'title' && value && typeof value === 'object' && 'contains' in value) { + return entry.title + .toLowerCase() + .includes(String((value as { contains: string }).contains).toLowerCase()); + } + + return entry[key as keyof StoredLoreEntry] === value; + }); + } + + const database = { + async $transaction(callback: (transaction: unknown) => Promise, options?: unknown) { + void options; + return callback(database); + }, + loreEntry: { + async count(args: { where: Record }) { + return loreEntries.filter((entry) => matchesWhere(entry, args.where)).length; + }, + async create(args: { data: Partial }) { + const entry = createLoreRecord({ + id: `lore-${loreEntries.length + 1}`, + ...args.data, + }); + loreEntries.push(entry); + return includeLore(entry); + }, + async delete(args: { where: { id: string } }) { + const index = loreEntries.findIndex((entry) => entry.id === args.where.id); + const [deleted] = loreEntries.splice(index, 1); + return deleted; + }, + async findFirst(args: { where: Record }) { + const entry = loreEntries.find((candidate) => matchesWhere(candidate, args.where)) ?? null; + return entry ? includeLore(entry) : null; + }, + async findMany(args: { skip: number; take: number; where: Record }) { + return loreEntries + .filter((entry) => matchesWhere(entry, args.where)) + .sort((left, right) => right.updatedAt.getTime() - left.updatedAt.getTime()) + .slice(args.skip, args.skip + args.take) + .map(includeLore); + }, + async update(args: { data: Partial; where: { id: string } }) { + const entry = loreEntries.find((candidate) => candidate.id === args.where.id); + + if (!entry) { + throw new Error('Lore entry missing.'); + } + + Object.assign(entry, args.data, { + updatedAt: now(), + }); + + return includeLore(entry); + }, + }, + loreRelationship: { + async count(args: { where: Record }) { + return loreRelationships.filter((relationship) => + Object.entries(args.where).every(([key, value]) => { + if (key === 'OR' && Array.isArray(value)) { + return value.some((condition) => + Object.entries(condition as Record).every( + ([nestedKey, nestedValue]) => + relationship[nestedKey as keyof StoredLoreRelationship] === nestedValue, + ), + ); + } + + if (key === 'source' && value && typeof value === 'object' && 'authorId' in value) { + const source = loreEntries.find((entry) => entry.id === relationship.sourceId); + + return source?.authorId === (value as { authorId: string }).authorId; + } + + if (key === 'target' && value && typeof value === 'object' && 'authorId' in value) { + const target = loreEntries.find((entry) => entry.id === relationship.targetId); + const authorFilter = (value as { authorId: string | { not: string } }).authorId; + + if (typeof authorFilter === 'object' && 'not' in authorFilter) { + return target?.authorId !== authorFilter.not; + } + + return target?.authorId === authorFilter; + } + + return relationship[key as keyof StoredLoreRelationship] === value; + }), + ).length; + }, + async create(args: { data: Partial; select?: unknown }) { + if ( + loreRelationships.some( + (relationship) => + relationship.worldId === args.data.worldId && + relationship.sourceId === args.data.sourceId && + relationship.targetId === args.data.targetId && + relationship.relationType === args.data.relationType, + ) + ) { + throw Object.assign(new Error('Duplicate relationship.'), { code: 'P2002' }); + } + + const relationship: StoredLoreRelationship = { + createdAt: now(), + id: `relationship-${loreRelationships.length + 1}`, + metadata: null, + relationType: '', + sourceId: '', + targetId: '', + updatedAt: now(), + worldId: '', + ...args.data, + }; + loreRelationships.push(relationship); + + return { + id: relationship.id, + relationType: relationship.relationType, + target: includeRelatedEntry( + loreEntries.find((entry) => entry.id === relationship.targetId)!, + ), + }; + }, + async delete(args: { where: { id: string } }) { + const index = loreRelationships.findIndex( + (relationship) => relationship.id === args.where.id, + ); + const [deleted] = loreRelationships.splice(index, 1); + return deleted; + }, + async deleteMany(args: { where: { OR?: Array>; worldId: string } }) { + const before = loreRelationships.length; + for (let index = loreRelationships.length - 1; index >= 0; index -= 1) { + const relationship = loreRelationships[index]!; + const matchesWorld = relationship.worldId === args.where.worldId; + const matchesRelation = args.where.OR?.some((condition) => + Object.entries(condition).every( + ([key, value]) => relationship[key as keyof StoredLoreRelationship] === value, + ), + ); + + if (matchesWorld && matchesRelation) { + loreRelationships.splice(index, 1); + } + } + + return { count: before - loreRelationships.length }; + }, + async findFirst(args: { where: Record }) { + const relationship = + loreRelationships.find((candidate) => + Object.entries(args.where).every( + ([key, value]) => candidate[key as keyof StoredLoreRelationship] === value, + ), + ) ?? null; + + if (!relationship) { + return null; + } + + return { + ...relationship, + source: { + authorId: loreEntries.find((entry) => entry.id === relationship.sourceId)!.authorId, + status: loreEntries.find((entry) => entry.id === relationship.sourceId)!.status, + }, + target: { + status: loreEntries.find((entry) => entry.id === relationship.targetId)!.status, + }, + }; + }, + }, + refreshSession: { + async findUnique(args: { where: { id: string } }) { + const userId = args.where.id.replace(/^session-/, ''); + + return { + expiresAt: new Date(Date.now() + 60 * 60 * 1000), + revokedAt: null, + userId, + }; + }, + }, + world: { + async findUnique(args: { where: { id: string } }) { + return worlds.find((world) => world.id === args.where.id) ?? null; + }, + }, + worldAuditLog: { + async create(args: { data: unknown }) { + worldAuditLogs.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 { + database, + loreEntries, + loreRelationships, + memberships, + worldAuditLogs, + }; +} + +async function createApp(database: ReturnType['database']) { + const app = Fastify(); + await registerLoreRoutes(app, { + database: database as never, + }); + return app; +} + +describe('lore routes', () => { + test('public list and read expose only published canon by default', async () => { + const state = createDatabase(); + state.loreEntries.push( + createLoreRecord({ + id: 'published-1', + publishedAt: now(), + status: LoreStatus.PUBLISHED_CANON, + title: 'Published Canon', + }), + ); + state.loreEntries.push(createLoreRecord({ id: 'draft-1', title: 'Private Draft' })); + const app = await createApp(state.database); + + const listResponse = await app.inject({ + method: 'GET', + url: '/worlds/world-1/lore', + }); + const draftReadResponse = await app.inject({ + method: 'GET', + url: '/worlds/world-1/lore/draft-1', + }); + + assert.equal(listResponse.statusCode, 200); + assert.deepEqual( + listResponse.json().entries.map((entry: { id: string }) => entry.id), + ['published-1'], + ); + assert.equal(draftReadResponse.statusCode, 404); + await app.close(); + }); + + test('draft lists require auth and scope contributors to their own drafts', async () => { + const state = createDatabase(); + state.memberships.find((membership) => membership.userId === otherUser.id)!.role = + WorldRole.CONTRIBUTOR; + state.loreEntries.push(createLoreRecord({ id: 'own-draft' })); + state.loreEntries.push(createLoreRecord({ authorId: otherUser.id, id: 'other-draft' })); + state.loreRelationships.push({ + createdAt: now(), + id: 'relationship-private', + metadata: null, + relationType: 'knows', + sourceId: 'own-draft', + targetId: 'other-draft', + updatedAt: now(), + worldId: 'world-1', + }); + const app = await createApp(state.database); + + const anonymousResponse = await app.inject({ + method: 'GET', + url: '/worlds/world-1/lore?status=DRAFT', + }); + const authorResponse = await app.inject({ + headers: authHeader(), + method: 'GET', + url: '/worlds/world-1/lore?status=DRAFT', + }); + const curatorResponse = await app.inject({ + headers: authHeader(curator), + method: 'GET', + url: '/worlds/world-1/lore?status=DRAFT', + }); + + assert.equal(anonymousResponse.statusCode, 401); + assert.deepEqual( + authorResponse.json().entries.map((entry: { id: string }) => entry.id), + ['own-draft'], + ); + assert.equal('outgoingRelations' in authorResponse.json().entries[0], false); + assert.deepEqual( + curatorResponse + .json() + .entries.map((entry: { id: string }) => entry.id) + .sort(), + ['other-draft', 'own-draft'], + ); + assert.equal( + 'incomingRelations' in + curatorResponse.json().entries.find((entry: { id: string }) => entry.id === 'own-draft'), + false, + ); + await app.close(); + }); + + test('create is draft-only, permission gated, validates caps, and stores JSON verbatim', async () => { + const state = createDatabase(); + const app = await createApp(state.database); + + const unauthenticated = await app.inject({ + method: 'POST', + payload: { + content: { body: 'A body.', summary: 'A summary.' }, + loreType: LoreType.QUEST, + title: 'Quest', + }, + url: '/worlds/world-1/lore', + }); + const forbidden = await app.inject({ + headers: authHeader(otherUser), + method: 'POST', + payload: { + content: { body: 'A body.', summary: 'A summary.' }, + loreType: LoreType.QUEST, + title: 'Quest', + }, + url: '/worlds/world-1/lore', + }); + const selfPublish = await app.inject({ + headers: authHeader(), + method: 'POST', + payload: { + content: { body: 'A body.', summary: 'A summary.' }, + loreType: LoreType.QUEST, + status: LoreStatus.PUBLISHED_CANON, + title: 'Quest', + }, + url: '/worlds/world-1/lore', + }); + const oversizedSummary = await app.inject({ + headers: authHeader(), + method: 'POST', + payload: { + content: { body: 'A body.', summary: 'x'.repeat(1_001) }, + loreType: LoreType.QUEST, + title: 'Quest', + }, + url: '/worlds/world-1/lore', + }); + const mismatchedEntityType = await app.inject({ + headers: authHeader(), + method: 'POST', + payload: { + content: { body: 'A body.', entityType: 'QUEST', summary: 'A summary.' }, + loreType: LoreType.OTHER, + title: 'Mismatched entity type', + }, + url: '/worlds/world-1/lore', + }); + const created = await app.inject({ + headers: authHeader(), + method: 'POST', + payload: { + content: { + body: '', + fields: { + hook: '', + }, + summary: 'A summary.', + }, + loreType: LoreType.QUEST, + title: ' The First Quest ', + }, + url: '/worlds/world-1/lore', + }); + + assert.equal(unauthenticated.statusCode, 401); + assert.equal(forbidden.statusCode, 403); + assert.equal(selfPublish.statusCode, 400); + assert.equal(oversizedSummary.statusCode, 400); + assert.equal(mismatchedEntityType.statusCode, 400); + assert.equal(created.statusCode, 201); + assert.equal(created.json().entry.status, LoreStatus.DRAFT); + assert.equal(created.json().entry.loreType, LoreType.QUEST); + assert.equal(created.json().entry.title, 'The First Quest'); + assert.equal(created.json().entry.content.body, ''); + await app.close(); + }); + + test('update and delete are limited to editable drafts and never mutate canon', async () => { + const state = createDatabase(); + state.memberships.find((membership) => membership.userId === otherUser.id)!.role = + WorldRole.CONTRIBUTOR; + state.loreEntries.push(createLoreRecord({ id: 'draft-1' })); + state.loreEntries.push( + createLoreRecord({ + id: 'canon-1', + publishedAt: now(), + status: LoreStatus.PUBLISHED_CANON, + }), + ); + const app = await createApp(state.database); + + const otherUpdate = await app.inject({ + headers: authHeader(otherUser), + method: 'PATCH', + payload: { title: 'Nope' }, + url: '/worlds/world-1/lore/draft-1', + }); + const selfPublishUpdate = await app.inject({ + headers: authHeader(), + method: 'PATCH', + payload: { status: LoreStatus.PUBLISHED_CANON }, + url: '/worlds/world-1/lore/draft-1', + }); + const update = await app.inject({ + headers: authHeader(), + method: 'PATCH', + payload: { + content: { body: 'Updated.', summary: 'Updated summary.' }, + loreType: LoreType.STORY, + title: 'Updated Draft', + }, + url: '/worlds/world-1/lore/draft-1', + }); + const mismatchedEntityType = await app.inject({ + headers: authHeader(), + method: 'PATCH', + payload: { + content: { body: 'Updated.', entityType: 'QUEST', summary: 'Updated summary.' }, + loreType: LoreType.OTHER, + }, + url: '/worlds/world-1/lore/draft-1', + }); + const canonDelete = await app.inject({ + headers: authHeader(curator), + method: 'DELETE', + url: '/worlds/world-1/lore/canon-1', + }); + const deleteResponse = await app.inject({ + headers: authHeader(), + method: 'DELETE', + url: '/worlds/world-1/lore/draft-1', + }); + + assert.equal(otherUpdate.statusCode, 403); + assert.equal(selfPublishUpdate.statusCode, 400); + assert.equal(update.statusCode, 200); + assert.equal(update.json().entry.loreType, LoreType.STORY); + assert.equal(mismatchedEntityType.statusCode, 400); + assert.equal(canonDelete.statusCode, 409); + assert.equal(deleteResponse.statusCode, 200); + assert.equal(deleteResponse.json().ok, true); + assert.equal( + state.loreEntries.some((entry) => entry.id === 'draft-1'), + false, + ); + assert.equal( + state.loreEntries.some((entry) => entry.id === 'canon-1'), + true, + ); + await app.close(); + }); + + test('quest and story are distinct filterable lore types', async () => { + const state = createDatabase(); + state.loreEntries.push( + createLoreRecord({ + id: 'quest-1', + loreType: LoreType.QUEST, + publishedAt: now(), + status: LoreStatus.PUBLISHED_CANON, + }), + ); + state.loreEntries.push( + createLoreRecord({ + id: 'story-1', + loreType: LoreType.STORY, + publishedAt: now(), + status: LoreStatus.PUBLISHED_CANON, + }), + ); + const app = await createApp(state.database); + + const questResponse = await app.inject({ + method: 'GET', + url: '/worlds/world-1/lore?loreType=QUEST', + }); + const storyResponse = await app.inject({ + method: 'GET', + url: '/worlds/world-1/lore?loreType=STORY', + }); + + assert.deepEqual( + questResponse.json().entries.map((entry: { id: string }) => entry.id), + ['quest-1'], + ); + assert.deepEqual( + storyResponse.json().entries.map((entry: { id: string }) => entry.id), + ['story-1'], + ); + await app.close(); + }); + + test('relationships can be created, read from entry pages, deduplicated, and deleted', async () => { + const state = createDatabase(); + state.loreEntries.push(createLoreRecord({ id: 'source-1', title: 'Source' })); + state.loreEntries.push(createLoreRecord({ id: 'target-1', title: 'Target' })); + const app = await createApp(state.database); + + const selfLink = await app.inject({ + headers: authHeader(), + method: 'POST', + payload: { + relationType: 'member_of', + targetId: 'source-1', + }, + url: '/worlds/world-1/lore/source-1/relationships', + }); + const created = await app.inject({ + headers: authHeader(), + method: 'POST', + payload: { + metadata: { certainty: 'high' }, + relationType: 'member_of', + targetId: 'target-1', + }, + url: '/worlds/world-1/lore/source-1/relationships', + }); + const duplicate = await app.inject({ + headers: authHeader(), + method: 'POST', + payload: { + relationType: 'member_of', + targetId: 'target-1', + }, + url: '/worlds/world-1/lore/source-1/relationships', + }); + const read = await app.inject({ + headers: authHeader(), + method: 'GET', + url: '/worlds/world-1/lore/source-1', + }); + const deleted = await app.inject({ + headers: authHeader(), + method: 'DELETE', + url: `/worlds/world-1/lore/source-1/relationships/${created.json().relationship.id}`, + }); + + assert.equal(selfLink.statusCode, 400); + assert.equal(created.statusCode, 201); + assert.equal(created.json().relationship.relationType, 'member_of'); + assert.equal(duplicate.statusCode, 409); + assert.equal(read.statusCode, 200); + assert.equal(read.json().entry.outgoingRelations.length, 1); + assert.equal(read.json().entry.outgoingRelations[0].target.id, 'target-1'); + assert.equal(deleted.statusCode, 204); + assert.equal(state.loreRelationships.length, 0); + assert.equal(state.worldAuditLogs.length >= 2, true); + await app.close(); + }); + + test('relationships cannot mutate canon entries without proposal governance', async () => { + const state = createDatabase(); + state.loreEntries.push(createLoreRecord({ id: 'draft-source', title: 'Draft Source' })); + state.loreEntries.push( + createLoreRecord({ + id: 'canon-source', + publishedAt: now(), + status: LoreStatus.PUBLISHED_CANON, + title: 'Canon Source', + }), + ); + state.loreEntries.push( + createLoreRecord({ + id: 'canon-target', + publishedAt: now(), + status: LoreStatus.PUBLISHED_CANON, + title: 'Canon Target', + }), + ); + state.loreRelationships.push({ + createdAt: now(), + id: 'canon-relationship', + metadata: null, + relationType: 'enemy_of', + sourceId: 'canon-source', + targetId: 'canon-target', + updatedAt: now(), + worldId: 'world-1', + }); + const app = await createApp(state.database); + + const draftToCanon = await app.inject({ + headers: authHeader(), + method: 'POST', + payload: { + relationType: 'allied_with', + targetId: 'canon-target', + }, + url: '/worlds/world-1/lore/draft-source/relationships', + }); + const created = await app.inject({ + headers: authHeader(), + method: 'POST', + payload: { + relationType: 'allied_with', + targetId: 'canon-target', + }, + url: '/worlds/world-1/lore/canon-source/relationships', + }); + const deleted = await app.inject({ + headers: authHeader(curator), + method: 'DELETE', + url: '/worlds/world-1/lore/canon-source/relationships/canon-relationship', + }); + + assert.equal(draftToCanon.statusCode, 201); + assert.equal(created.statusCode, 409); + assert.equal( + created.json().error, + 'Draft lore can only link to draft or published canon entries here.', + ); + assert.equal(deleted.statusCode, 409); + assert.equal( + deleted.json().error, + 'Only relationships owned by draft lore can be deleted here.', + ); + assert.equal(state.loreRelationships.length, 2); + await app.close(); + }); + + test('relationship creation hides unauthorized sources before revealing status', async () => { + const state = createDatabase(); + state.memberships.find((membership) => membership.userId === otherUser.id)!.role = + WorldRole.CONTRIBUTOR; + state.loreEntries.push( + createLoreRecord({ + id: 'other-canon-source', + publishedAt: now(), + status: LoreStatus.PUBLISHED_CANON, + title: 'Other Canon Source', + }), + ); + state.loreEntries.push( + createLoreRecord({ + id: 'canon-target', + publishedAt: now(), + status: LoreStatus.PUBLISHED_CANON, + title: 'Canon Target', + }), + ); + const app = await createApp(state.database); + + const response = await app.inject({ + headers: authHeader(otherUser), + method: 'POST', + payload: { + relationType: 'allied_with', + targetId: 'canon-target', + }, + url: '/worlds/world-1/lore/other-canon-source/relationships', + }); + + assert.equal(response.statusCode, 404); + assert.equal(response.json().error, 'Lore entry not found.'); + assert.equal(state.loreRelationships.length, 0); + await app.close(); + }); + + test('relationship creation does not reveal unreadable draft targets', async () => { + const state = createDatabase(); + state.loreEntries.push(createLoreRecord({ id: 'attacker-draft', title: 'Attacker Draft' })); + state.loreEntries.push( + createLoreRecord({ + authorId: otherUser.id, + id: 'victim-draft', + title: 'Secret Plot Twist', + }), + ); + const app = await createApp(state.database); + + const response = await app.inject({ + headers: authHeader(), + method: 'POST', + payload: { + relationType: 'related_to', + targetId: 'victim-draft', + }, + url: '/worlds/world-1/lore/attacker-draft/relationships', + }); + + assert.equal(response.statusCode, 404); + assert.equal(response.json().error, 'Related lore entry not found.'); + assert.equal(JSON.stringify(response.json()).includes('Secret Plot Twist'), false); + assert.equal(state.loreRelationships.length, 0); + await app.close(); + }); + + test('cross-author relationship cap limits abusive inbound edges per source author', async () => { + const state = createDatabase(); + state.memberships.find((membership) => membership.userId === otherUser.id)!.role = + WorldRole.CONTRIBUTOR; + state.loreEntries.push( + createLoreRecord({ + authorId: otherUser.id, + id: 'draft-source', + title: 'Draft Source', + }), + ); + state.loreEntries.push( + createLoreRecord({ + id: 'popular-target', + publishedAt: now(), + status: LoreStatus.PUBLISHED_CANON, + title: 'Popular Target', + }), + ); + + for (let index = 0; index < 50; index += 1) { + const sourceId = `existing-source-${index}`; + state.loreEntries.push( + createLoreRecord({ + authorId: otherUser.id, + id: sourceId, + title: `Existing Source ${index}`, + }), + ); + state.loreRelationships.push({ + createdAt: now(), + id: `existing-relationship-${index}`, + metadata: null, + relationType: 'related_to', + sourceId, + targetId: 'popular-target', + updatedAt: now(), + worldId: 'world-1', + }); + } + + const app = await createApp(state.database); + + const response = await app.inject({ + headers: authHeader(otherUser), + method: 'POST', + payload: { + relationType: 'allied_with', + targetId: 'popular-target', + }, + url: '/worlds/world-1/lore/draft-source/relationships', + }); + + assert.equal(response.statusCode, 409); + assert.equal( + response.json().error, + 'This author has reached the cross-author relationship limit for that entry.', + ); + assert.equal(state.loreRelationships.length, 50); + await app.close(); + }); + + test('same-author hubs can collect more than ten incoming links', async () => { + const state = createDatabase(); + state.loreEntries.push(createLoreRecord({ id: 'faction-hub', title: 'Faction Hub' })); + + for (let index = 0; index < 10; index += 1) { + const sourceId = `member-${index}`; + state.loreEntries.push(createLoreRecord({ id: sourceId, title: `Member ${index}` })); + state.loreRelationships.push({ + createdAt: now(), + id: `existing-relationship-${index}`, + metadata: null, + relationType: 'member_of', + sourceId, + targetId: 'faction-hub', + updatedAt: now(), + worldId: 'world-1', + }); + } + + state.loreEntries.push(createLoreRecord({ id: 'member-10', title: 'Member 10' })); + const app = await createApp(state.database); + + const response = await app.inject({ + headers: authHeader(), + method: 'POST', + payload: { + relationType: 'member_of', + targetId: 'faction-hub', + }, + url: '/worlds/world-1/lore/member-10/relationships', + }); + + assert.equal(response.statusCode, 201); + assert.equal(state.loreRelationships.length, 11); + await app.close(); + }); + + test('relationship target total does not let one author freeze another author out', async () => { + const state = createDatabase(); + state.loreEntries.push(createLoreRecord({ id: 'fresh-source', title: 'Fresh Source' })); + state.loreEntries.push( + createLoreRecord({ + id: 'popular-target', + publishedAt: now(), + status: LoreStatus.PUBLISHED_CANON, + title: 'Popular Target', + }), + ); + + for (let index = 0; index < 100; index += 1) { + const sourceId = `other-source-${index}`; + state.loreEntries.push( + createLoreRecord({ + authorId: otherUser.id, + id: sourceId, + title: `Other Source ${index}`, + }), + ); + state.loreRelationships.push({ + createdAt: now(), + id: `existing-relationship-${index}`, + metadata: null, + relationType: 'related_to', + sourceId, + targetId: 'popular-target', + updatedAt: now(), + worldId: 'world-1', + }); + } + + const app = await createApp(state.database); + + const response = await app.inject({ + headers: authHeader(), + method: 'POST', + payload: { + relationType: 'allied_with', + targetId: 'popular-target', + }, + url: '/worlds/world-1/lore/fresh-source/relationships', + }); + + assert.equal(response.statusCode, 201); + assert.equal(state.loreRelationships.length, 101); + await app.close(); + }); + + test('relationship creation retries driver-adapter transaction conflicts', async () => { + const state = createDatabase(); + state.loreEntries.push(createLoreRecord({ id: 'source-1', title: 'Source' })); + state.loreEntries.push(createLoreRecord({ id: 'target-1', title: 'Target' })); + const originalTransaction = state.database.$transaction; + let attempts = 0; + + state.database.$transaction = async ( + callback: (transaction: unknown) => Promise, + options?: unknown, + ) => { + attempts += 1; + + if (attempts < 9) { + throw Object.assign(new Error('TransactionWriteConflict'), { + cause: { + kind: 'TransactionWriteConflict', + }, + name: 'DriverAdapterError', + }); + } + + return originalTransaction(callback, options); + }; + + const app = await createApp(state.database); + + const response = await app.inject({ + headers: authHeader(), + method: 'POST', + payload: { + relationType: 'member_of', + targetId: 'target-1', + }, + url: '/worlds/world-1/lore/source-1/relationships', + }); + + assert.equal(response.statusCode, 201); + assert.equal(attempts, 9); + assert.equal(state.loreRelationships.length, 1); + await app.close(); + }); + + test('relationship type accepts only supported values', async () => { + const state = createDatabase(); + state.loreEntries.push(createLoreRecord({ id: 'source-1', title: 'Source' })); + state.loreEntries.push(createLoreRecord({ id: 'target-1', title: 'Target' })); + const app = await createApp(state.database); + + const response = await app.inject({ + headers: authHeader(), + method: 'POST', + payload: { + relationType: '', + targetId: 'target-1', + }, + url: '/worlds/world-1/lore/source-1/relationships', + }); + + assert.equal(response.statusCode, 400); + assert.equal(response.json().error, 'Invalid lore relationship payload.'); + assert.equal(state.loreRelationships.length, 0); + await app.close(); + }); + + test('curator relation projection ranks canon edges before non-canon edges', async () => { + const state = createDatabase(); + state.loreEntries.push( + createLoreRecord({ + id: 'canon-target', + publishedAt: now(), + status: LoreStatus.PUBLISHED_CANON, + title: 'Canon Target', + }), + ); + state.loreEntries.push( + createLoreRecord({ + id: 'archived-source', + publishedAt: null, + status: LoreStatus.ARCHIVED, + title: 'Archived Source', + }), + ); + state.loreEntries.push( + createLoreRecord({ + id: 'canon-source', + publishedAt: now(), + status: LoreStatus.PUBLISHED_CANON, + title: 'Canon Source', + }), + ); + state.loreRelationships.push( + { + createdAt: now(), + id: 'archived-relationship', + metadata: null, + relationType: 'related_to', + sourceId: 'archived-source', + targetId: 'canon-target', + updatedAt: now(), + worldId: 'world-1', + }, + { + createdAt: now(), + id: 'canon-relationship', + metadata: null, + relationType: 'related_to', + sourceId: 'canon-source', + targetId: 'canon-target', + updatedAt: now(), + worldId: 'world-1', + }, + ); + const app = await createApp(state.database); + + const response = await app.inject({ + headers: authHeader(curator), + method: 'GET', + url: '/worlds/world-1/lore/canon-target', + }); + + assert.equal(response.statusCode, 200); + assert.deepEqual( + response + .json() + .entry.incomingRelations.map( + (relationship: { source: { id: string } }) => relationship.source.id, + ), + ['canon-source', 'archived-source'], + ); + await app.close(); + }); + + test('public relation projection hides draft related entries', async () => { + const state = createDatabase(); + state.loreEntries.push( + createLoreRecord({ + id: 'published-source', + publishedAt: now(), + status: LoreStatus.PUBLISHED_CANON, + }), + ); + state.loreEntries.push(createLoreRecord({ id: 'private-target', title: 'Private Target' })); + state.loreRelationships.push({ + createdAt: now(), + id: 'relationship-1', + metadata: null, + relationType: 'knows', + sourceId: 'published-source', + targetId: 'private-target', + updatedAt: now(), + worldId: 'world-1', + }); + const app = await createApp(state.database); + + const publicRead = await app.inject({ + method: 'GET', + url: '/worlds/world-1/lore/published-source', + }); + const authorRead = await app.inject({ + headers: authHeader(), + method: 'GET', + url: '/worlds/world-1/lore/published-source', + }); + + assert.equal(publicRead.statusCode, 200); + assert.equal(publicRead.json().entry.outgoingRelations.length, 0); + assert.equal(authorRead.statusCode, 200); + assert.equal(authorRead.json().entry.outgoingRelations.length, 1); + await app.close(); + }); +}); diff --git a/apps/api/src/routes/lore.ts b/apps/api/src/routes/lore.ts new file mode 100644 index 0000000..8163dba --- /dev/null +++ b/apps/api/src/routes/lore.ts @@ -0,0 +1,1621 @@ +import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; +import { z } from 'zod'; + +import { env } from '../config/env.js'; +import { Prisma } from '../generated/prisma/client.js'; +import type { PrismaClient } from '../generated/prisma/client.js'; +import { LoreStatus, LoreType, WorldAuditAction } from '../generated/prisma/enums.js'; +import { authenticateRequest, requireSession } from '../lib/auth-middleware.js'; +import type { SessionVerificationDatabase } from '../lib/auth-sessions.js'; +import { prisma } from '../lib/prisma.js'; +import { + authorizeWorldPermission, + resolveWorldMembership, + type WorldMembershipLookup, +} from '../lib/world-authorization.js'; +import { roleHasWorldPermission, WORLD_PERMISSIONS } from '../lib/world-permissions.js'; + +const STRUCTURED_CONTENT_MAX_BYTES = 100 * 1024; +const STRUCTURED_CONTENT_MAX_DEPTH = 32; +const RELATIONSHIP_METADATA_MAX_BYTES = 16 * 1024; +const MAX_RELATIONSHIPS_PER_ENTRY = 100; +const MAX_CROSS_AUTHOR_INBOUND_RELATIONSHIPS_PER_AUTHOR_PER_ENTRY = 50; +const RELATIONSHIP_TRANSACTION_RETRIES = 10; +const RELATIONSHIP_TYPES = [ + 'allied_with', + 'enemy_of', + 'member_of', + 'rules', + 'located_in', + 'involved_in', + 'created_by', + 'related_to', +] as const; +const CONTENT_ENTITY_TYPE_BY_LORE_TYPE: Record = { + [LoreType.ARTIFACT]: 'ARTIFACT', + [LoreType.CHARACTER]: 'CHARACTER', + [LoreType.EVENT]: 'HISTORICAL_EVENT', + [LoreType.FACTION]: 'FACTION', + [LoreType.HISTORY]: 'HISTORY', + [LoreType.LOCATION]: 'CITY_KINGDOM', + [LoreType.OTHER]: 'OTHER', + [LoreType.QUEST]: 'QUEST', + [LoreType.RULE]: 'RULE', + [LoreType.STORY]: 'STORY_CONTRIBUTION', +}; + +type LoreDatabase = Pick< + PrismaClient, + '$transaction' | 'loreEntry' | 'loreRelationship' | 'world' | 'worldAuditLog' | 'worldMembership' +>; + +type RegisterLoreRoutesOptions = { + database?: LoreDatabase & WorldMembershipLookup & SessionVerificationDatabase; +}; + +const routeParamsSchema = z.object({ + entryId: z.string().min(1).optional(), + worldId: z.string().min(1), +}); + +const loreTypeSchema = z.enum([ + LoreType.CHARACTER, + LoreType.LOCATION, + LoreType.FACTION, + LoreType.QUEST, + LoreType.EVENT, + LoreType.STORY, + LoreType.ARTIFACT, + LoreType.HISTORY, + LoreType.RULE, + LoreType.OTHER, +]); + +const loreStatusSchema = z.enum([ + LoreStatus.DRAFT, + LoreStatus.SUBMITTED, + LoreStatus.APPROVED_FOR_PUBLICATION, + LoreStatus.PUBLISHED_CANON, + LoreStatus.ARCHIVED, +]); + +const listLoreQuerySchema = z.object({ + loreType: loreTypeSchema.optional(), + page: z.coerce.number().int().positive().max(100).default(1), + pageSize: z.coerce.number().int().positive().max(100).default(20), + q: z.string().trim().max(200).optional(), + status: loreStatusSchema.optional(), +}); + +const createLoreBodySchema = z + .object({ + content: z.unknown(), + loreType: loreTypeSchema, + status: z.literal(LoreStatus.DRAFT).optional(), + title: z.string().trim().min(1).max(200), + }) + .strict(); + +const updateLoreBodySchema = z + .object({ + content: z.unknown().optional(), + loreType: loreTypeSchema.optional(), + title: z.string().trim().min(1).max(200).optional(), + }) + .strict() + .refine((value) => Object.keys(value).length > 0, { + message: 'At least one editable lore field is required.', + }); + +const createRelationshipBodySchema = z + .object({ + metadata: z.unknown().optional(), + relationType: z.enum(RELATIONSHIP_TYPES), + targetId: z.string().trim().min(1), + }) + .strict(); + +function visibleRelatedEntryWhere( + side: 'source' | 'target', + options: { + includeAllNonPublicRelations?: boolean; + viewerId?: string | undefined; + }, +): Prisma.LoreRelationshipWhereInput { + if (options.includeAllNonPublicRelations) { + return {}; + } + + const relatedEntryWhere: Prisma.LoreEntryWhereInput = { + OR: [ + { + status: LoreStatus.PUBLISHED_CANON, + }, + ...(options.viewerId + ? [ + { + authorId: options.viewerId, + }, + ] + : []), + ], + }; + + return side === 'source' ? { source: relatedEntryWhere } : { target: relatedEntryWhere }; +} + +function loreEntryInclude( + options: { + includeAllNonPublicRelations?: boolean; + viewerId?: string | undefined; + } = {}, +) { + const incomingRelationOrderBy: Prisma.LoreRelationshipOrderByWithRelationInput[] = [ + { + source: { + publishedAt: { + sort: 'desc', + nulls: 'last', + }, + }, + }, + { + updatedAt: 'desc', + }, + { + id: 'desc', + }, + ]; + const outgoingRelationOrderBy: Prisma.LoreRelationshipOrderByWithRelationInput[] = [ + { + target: { + publishedAt: { + sort: 'desc', + nulls: 'last', + }, + }, + }, + { + updatedAt: 'desc', + }, + { + id: 'desc', + }, + ]; + + return { + author: { + select: { + avatarUrl: true, + displayName: true, + hiveUsername: true, + id: true, + }, + }, + incomingRelations: { + orderBy: incomingRelationOrderBy, + select: { + id: true, + relationType: true, + source: { + select: { + authorId: true, + id: true, + loreType: true, + slug: true, + status: true, + title: true, + }, + }, + }, + take: MAX_RELATIONSHIPS_PER_ENTRY, + where: visibleRelatedEntryWhere('source', options), + }, + outgoingRelations: { + orderBy: outgoingRelationOrderBy, + select: { + id: true, + relationType: true, + target: { + select: { + authorId: true, + id: true, + loreType: true, + slug: true, + status: true, + title: true, + }, + }, + }, + take: MAX_RELATIONSHIPS_PER_ENTRY, + where: visibleRelatedEntryWhere('target', options), + }, + } satisfies Prisma.LoreEntryInclude; +} + +type LoreEntryWithRelations = Prisma.LoreEntryGetPayload<{ + include: ReturnType; +}>; + +function loreEntryListInclude() { + return { + author: { + select: { + avatarUrl: true, + displayName: true, + hiveUsername: true, + id: true, + }, + }, + } satisfies Prisma.LoreEntryInclude; +} + +type LoreEntryListItem = Prisma.LoreEntryGetPayload<{ + include: ReturnType; +}>; + +class LoreRouteError extends Error { + constructor( + public readonly statusCode: number, + message: string, + ) { + super(message); + } +} + +function authOptions(database: SessionVerificationDatabase) { + return { + audience: env.AUTH_JWT_AUDIENCE, + database, + issuer: env.AUTH_JWT_ISSUER, + jwtSecret: env.AUTH_JWT_SECRET, + }; +} + +function serializeDate(value: Date | null) { + return value?.toISOString() ?? null; +} + +function serializeLoreEntry( + entry: LoreEntryWithRelations, + options: { + includeAllNonPublicRelations?: boolean; + viewerId?: string | undefined; + } = {}, +) { + const canShowRelatedEntry = (relatedEntry: { authorId?: string; status: LoreStatus }) => + relatedEntry.status === LoreStatus.PUBLISHED_CANON || + options.includeAllNonPublicRelations || + ('authorId' in relatedEntry && relatedEntry.authorId === options.viewerId); + const relationshipStatusRank = (status: LoreStatus) => + status === LoreStatus.PUBLISHED_CANON ? 0 : 1; + + return { + author: entry.author, + authorId: entry.authorId, + content: entry.content, + createdAt: entry.createdAt.toISOString(), + hiveReferenceId: entry.hiveReferenceId, + id: entry.id, + incomingRelations: entry.incomingRelations + .filter((relation) => canShowRelatedEntry(relation.source)) + .sort( + (left, right) => + relationshipStatusRank(left.source.status) - relationshipStatusRank(right.source.status), + ) + .map((relation) => ({ + id: relation.id, + relationType: relation.relationType, + source: { + id: relation.source.id, + loreType: relation.source.loreType, + slug: relation.source.slug, + status: relation.source.status, + title: relation.source.title, + }, + })), + loreType: entry.loreType, + outgoingRelations: entry.outgoingRelations + .filter((relation) => canShowRelatedEntry(relation.target)) + .sort( + (left, right) => + relationshipStatusRank(left.target.status) - relationshipStatusRank(right.target.status), + ) + .map((relation) => ({ + id: relation.id, + relationType: relation.relationType, + target: { + id: relation.target.id, + loreType: relation.target.loreType, + slug: relation.target.slug, + status: relation.target.status, + title: relation.target.title, + }, + })), + publishedAt: serializeDate(entry.publishedAt), + slug: entry.slug, + status: entry.status, + title: entry.title, + updatedAt: entry.updatedAt.toISOString(), + worldId: entry.worldId, + }; +} + +function serializeLoreListEntry(entry: LoreEntryListItem) { + return { + author: entry.author, + authorId: entry.authorId, + content: entry.content, + createdAt: entry.createdAt.toISOString(), + hiveReferenceId: entry.hiveReferenceId, + id: entry.id, + loreType: entry.loreType, + publishedAt: serializeDate(entry.publishedAt), + slug: entry.slug, + status: entry.status, + title: entry.title, + updatedAt: entry.updatedAt.toISOString(), + worldId: entry.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 validateJsonValue(value: unknown, depth = 0): Prisma.InputJsonValue { + if (depth > STRUCTURED_CONTENT_MAX_DEPTH) { + throw new LoreRouteError(400, 'Lore content nesting is too deep.'); + } + + if ( + value === null || + typeof value === 'string' || + typeof value === 'boolean' || + (typeof value === 'number' && Number.isFinite(value)) + ) { + return value as Prisma.InputJsonValue; + } + + if (Array.isArray(value)) { + return value.map((item) => validateJsonValue(item, depth + 1)) as Prisma.InputJsonArray; + } + + if (isPlainObject(value)) { + return Object.fromEntries( + Object.entries(value).map(([key, nestedValue]) => [ + key, + validateJsonValue(nestedValue, depth + 1), + ]), + ) as Prisma.InputJsonObject; + } + + throw new LoreRouteError(400, 'Lore content must be JSON-serializable.'); +} + +function validateLoreContent(value: unknown, loreType: LoreType): Prisma.InputJsonValue { + if (!isPlainObject(value)) { + throw new LoreRouteError(400, 'Lore content must be a JSON object.'); + } + + const serialized = JSON.stringify(value); + + if (!serialized || Buffer.byteLength(serialized, 'utf8') > STRUCTURED_CONTENT_MAX_BYTES) { + throw new LoreRouteError(400, 'Lore content exceeds the 100 KB limit.'); + } + + const summary = value.summary; + + if (summary !== undefined && (typeof summary !== 'string' || summary.trim().length > 1_000)) { + throw new LoreRouteError(400, 'Lore summary must be at most 1000 characters.'); + } + + const body = value.body; + + if (body !== undefined && typeof body !== 'string') { + throw new LoreRouteError(400, 'Lore body must be a string.'); + } + + const entityType = value.entityType; + const expectedEntityType = CONTENT_ENTITY_TYPE_BY_LORE_TYPE[loreType]; + + if (entityType !== undefined) { + if (typeof entityType !== 'string') { + throw new LoreRouteError(400, 'Lore entity type must be a string.'); + } + + if (entityType !== expectedEntityType) { + throw new LoreRouteError(400, 'Lore entity type must match the lore type.'); + } + } + + return validateJsonValue(value); +} + +function validateRelationshipMetadata(value: unknown): Prisma.InputJsonValue { + const serialized = JSON.stringify(value); + + if (!serialized || Buffer.byteLength(serialized, 'utf8') > RELATIONSHIP_METADATA_MAX_BYTES) { + throw new LoreRouteError(400, 'Relationship metadata exceeds the 16 KB limit.'); + } + + return validateJsonValue(value); +} + +function normalizeTitle(title: string) { + const normalized = title.trim(); + + if (!normalized) { + throw new LoreRouteError(400, 'Lore title is required.'); + } + + if (normalized.length > 200) { + throw new LoreRouteError(400, 'Lore title must be at most 200 characters.'); + } + + return normalized; +} + +function slugify(title: string) { + const slug = title + .toLowerCase() + .normalize('NFKD') + .replace(/[\u0300-\u036f]/g, '') + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 80); + + return slug || 'lore-entry'; +} + +async function createUniqueSlug( + database: Pick, + worldId: string, + title: string, + excludeEntryId?: string, +) { + const baseSlug = slugify(title); + + for (let suffix = 0; suffix < 100; suffix += 1) { + const slug = suffix === 0 ? baseSlug : `${baseSlug}-${suffix + 1}`; + const existing = await database.loreEntry.findFirst({ + select: { + id: true, + }, + where: { + slug, + worldId, + }, + }); + + if (!existing || existing.id === excludeEntryId) { + return slug; + } + } + + throw new LoreRouteError(409, 'Unable to generate a unique lore slug.'); +} + +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; +} + +function sendLoreError(error: unknown, reply: FastifyReply) { + if (error instanceof LoreRouteError) { + return reply.code(error.statusCode).send({ + error: error.message, + }); + } + + throw error; +} + +function isPrismaUniqueConflict(error: unknown) { + return typeof error === 'object' && error !== null && 'code' in error && error.code === 'P2002'; +} + +function isPrismaTransactionConflict(error: unknown) { + if (typeof error !== 'object' || error === null) { + return false; + } + + const record = error as { + cause?: { code?: unknown; kind?: unknown }; + code?: unknown; + }; + + return ( + record.code === 'P2034' || + record.code === '40001' || + record.code === '40P01' || + record.cause?.kind === 'TransactionWriteConflict' || + record.cause?.code === '40001' || + record.cause?.code === '40P01' + ); +} + +function delay(ms: number) { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} + +function relationshipRetryDelay(attempt: number) { + return 25 * attempt + Math.floor(Math.random() * 25); +} + +async function createLoreAuditLog( + database: Pick, + input: { + action: (typeof WorldAuditAction)[keyof typeof WorldAuditAction]; + actorId: string; + metadata?: Prisma.InputJsonObject | undefined; + targetId: string; + targetType: string; + worldId: string; + }, +) { + await database.worldAuditLog.create({ + data: { + action: input.action, + actorId: input.actorId, + metadata: input.metadata ?? {}, + targetId: input.targetId, + targetType: input.targetType, + worldId: input.worldId, + }, + }); +} + +async function authenticateOptional( + request: FastifyRequest, + database: SessionVerificationDatabase, +) { + return authenticateRequest(request, authOptions(database)); +} + +async function canReadNonPublicLore( + request: FastifyRequest, + database: LoreDatabase & WorldMembershipLookup, + worldId: string, + authorId?: string, +) { + const user = request.user; + + if (!user) { + return false; + } + + const membership = await resolveWorldMembership(request, worldId, database); + + if (!membership) { + return false; + } + + if (roleHasWorldPermission(membership.role, WORLD_PERMISSIONS.EDIT_ANY_DRAFT)) { + return true; + } + + return ( + authorId === user.id && + roleHasWorldPermission(membership.role, WORLD_PERMISSIONS.EDIT_OWN_DRAFT) + ); +} + +async function scopeNonPublicLoreList( + request: FastifyRequest, + database: LoreDatabase & WorldMembershipLookup, + worldId: string, +) { + const user = request.user; + + if (!user) { + return { + allowed: false as const, + statusCode: 401 as const, + where: {}, + }; + } + + const membership = await resolveWorldMembership(request, worldId, database); + + if (!membership) { + return { + allowed: false as const, + statusCode: 403 as const, + where: {}, + }; + } + + if (roleHasWorldPermission(membership.role, WORLD_PERMISSIONS.EDIT_ANY_DRAFT)) { + return { + allowed: true as const, + includeAllNonPublicRelations: true, + where: {}, + }; + } + + if (roleHasWorldPermission(membership.role, WORLD_PERMISSIONS.EDIT_OWN_DRAFT)) { + return { + allowed: true as const, + includeAllNonPublicRelations: false, + where: { + authorId: user.id, + }, + }; + } + + return { + allowed: false as const, + statusCode: 403 as const, + where: {}, + }; +} + +async function canMutateDraft( + request: FastifyRequest, + database: LoreDatabase & WorldMembershipLookup, + worldId: string, + authorId: string, +) { + const user = request.user; + + if (!user) { + return false; + } + + const membership = await resolveWorldMembership(request, worldId, database); + + if (!membership) { + return false; + } + + if (roleHasWorldPermission(membership.role, WORLD_PERMISSIONS.EDIT_ANY_DRAFT)) { + return true; + } + + return ( + authorId === user.id && + roleHasWorldPermission(membership.role, WORLD_PERMISSIONS.EDIT_OWN_DRAFT) + ); +} + +async function canAttemptDraftMutation( + request: FastifyRequest, + database: LoreDatabase & WorldMembershipLookup, + worldId: string, +) { + const user = request.user; + + if (!user) { + return false; + } + + const membership = await resolveWorldMembership(request, worldId, database); + + return Boolean( + membership && + (roleHasWorldPermission(membership.role, WORLD_PERMISSIONS.EDIT_ANY_DRAFT) || + roleHasWorldPermission(membership.role, WORLD_PERMISSIONS.EDIT_OWN_DRAFT)), + ); +} + +async function canViewAllNonPublicLore( + request: FastifyRequest, + database: LoreDatabase & WorldMembershipLookup, + worldId: string, +) { + if (!request.user) { + return false; + } + + const membership = await resolveWorldMembership(request, worldId, database); + + return Boolean( + membership && roleHasWorldPermission(membership.role, WORLD_PERMISSIONS.EDIT_ANY_DRAFT), + ); +} + +export async function registerLoreRoutes( + app: FastifyInstance, + options: RegisterLoreRoutesOptions = {}, +) { + const database = options.database ?? prisma; + const routeAuthOptions = authOptions(database); + + app.get( + '/worlds/:worldId/lore', + { + config: { + rateLimit: { + max: 120, + timeWindow: '1 minute', + }, + }, + }, + async (request, reply) => { + const params = routeParamsSchema.safeParse(request.params); + const query = listLoreQuerySchema.safeParse(request.query); + + if (!params.success || !query.success) { + return reply.code(400).send({ + error: 'Invalid lore query.', + }); + } + + const worldExists = await ensureWorldExists(database, params.data.worldId, reply); + + if (!worldExists) { + return; + } + + await authenticateOptional(request, database); + + const requestedStatus = query.data.status ?? LoreStatus.PUBLISHED_CANON; + const page = query.data.page; + const pageSize = query.data.pageSize; + const where: Prisma.LoreEntryWhereInput = { + ...(query.data.loreType ? { loreType: query.data.loreType } : {}), + ...(query.data.q + ? { + OR: [ + { + title: { + contains: query.data.q, + mode: 'insensitive', + }, + }, + ], + } + : {}), + status: requestedStatus, + worldId: params.data.worldId, + }; + + if (requestedStatus !== LoreStatus.PUBLISHED_CANON) { + const scope = await scopeNonPublicLoreList(request, database, params.data.worldId); + + if (!scope.allowed) { + return reply.code(scope.statusCode).send({ + error: + scope.statusCode === 401 + ? 'Authentication required.' + : 'Insufficient world permissions.', + }); + } + + Object.assign(where, scope.where); + } + + const [entries, total] = await Promise.all([ + database.loreEntry.findMany({ + include: loreEntryListInclude(), + orderBy: [ + { + updatedAt: 'desc', + }, + { + id: 'desc', + }, + ], + skip: (page - 1) * pageSize, + take: pageSize, + where, + }), + database.loreEntry.count({ + where, + }), + ]); + + return { + entries: entries.map((entry) => serializeLoreListEntry(entry)), + pagination: { + page, + pageSize, + total, + }, + }; + }, + ); + + app.get( + '/worlds/:worldId/lore/:entryId', + { + config: { + rateLimit: { + max: 120, + timeWindow: '1 minute', + }, + }, + }, + async (request, reply) => { + const params = routeParamsSchema.required().safeParse(request.params); + + if (!params.success) { + return reply.code(400).send({ + error: 'Invalid lore route.', + }); + } + + const worldExists = await ensureWorldExists(database, params.data.worldId, reply); + + if (!worldExists) { + return; + } + + await authenticateOptional(request, database); + const includeAllNonPublicRelations = await canViewAllNonPublicLore( + request, + database, + params.data.worldId, + ); + + const entry = await database.loreEntry.findFirst({ + include: loreEntryInclude({ + includeAllNonPublicRelations, + viewerId: request.user?.id, + }), + where: { + id: params.data.entryId, + worldId: params.data.worldId, + }, + }); + + if (!entry) { + return reply.code(404).send({ + error: 'Lore entry not found.', + }); + } + + const canReadEntry = await canReadNonPublicLore( + request, + database, + params.data.worldId, + entry.authorId, + ); + + if (entry.status !== LoreStatus.PUBLISHED_CANON) { + if (!canReadEntry) { + return reply.code(404).send({ + error: 'Lore entry not found.', + }); + } + } + + return { + entry: serializeLoreEntry(entry, { + includeAllNonPublicRelations, + viewerId: request.user?.id, + }), + }; + }, + ); + + app.post( + '/worlds/:worldId/lore/:entryId/relationships', + { + config: { + rateLimit: { + max: 30, + timeWindow: '1 minute', + }, + }, + preHandler: requireSession(routeAuthOptions), + }, + async (request, reply) => { + const params = routeParamsSchema.required().safeParse(request.params); + const body = createRelationshipBodySchema.safeParse(request.body); + + if (!params.success || !body.success) { + return reply.code(400).send({ + error: 'Invalid lore relationship payload.', + }); + } + + const worldExists = await ensureWorldExists(database, params.data.worldId, reply); + + if (!worldExists || !request.user) { + return; + } + + const canAttemptMutation = await canAttemptDraftMutation( + request, + database, + params.data.worldId, + ); + + if (!canAttemptMutation) { + return reply.code(403).send({ + error: 'Insufficient world permissions.', + }); + } + + if (params.data.entryId === body.data.targetId) { + return reply.code(400).send({ + error: 'A lore entry cannot be related to itself.', + }); + } + + const [source, target] = await Promise.all([ + database.loreEntry.findFirst({ + select: { + authorId: true, + id: true, + status: true, + }, + where: { + id: params.data.entryId, + worldId: params.data.worldId, + }, + }), + database.loreEntry.findFirst({ + select: { + authorId: true, + id: true, + status: true, + }, + where: { + id: body.data.targetId, + worldId: params.data.worldId, + }, + }), + ]); + + if (!source || !target) { + return reply.code(404).send({ + error: 'Related lore entry not found.', + }); + } + + const canReadTarget = + target.status === LoreStatus.PUBLISHED_CANON || + (await canReadNonPublicLore(request, database, params.data.worldId, target.authorId)); + + if (!canReadTarget) { + return reply.code(404).send({ + error: 'Related lore entry not found.', + }); + } + + const permitted = await canMutateDraft( + request, + database, + params.data.worldId, + source.authorId, + ); + + if (!permitted) { + return reply.code(404).send({ + error: 'Lore entry not found.', + }); + } + + if ( + source.status !== LoreStatus.DRAFT || + (target.status !== LoreStatus.DRAFT && target.status !== LoreStatus.PUBLISHED_CANON) + ) { + return reply.code(409).send({ + error: 'Draft lore can only link to draft or published canon entries here.', + }); + } + + for (let attempt = 1; attempt <= RELATIONSHIP_TRANSACTION_RETRIES; attempt += 1) { + try { + const relationship = await database.$transaction( + async (transaction) => { + const sourceRelationshipCount = await transaction.loreRelationship.count({ + where: { + OR: [{ sourceId: source.id }, { targetId: source.id }], + worldId: params.data.worldId, + }, + }); + + if (sourceRelationshipCount >= MAX_RELATIONSHIPS_PER_ENTRY) { + throw new LoreRouteError(409, 'A lore entry has reached the relationship limit.'); + } + + if (source.authorId !== target.authorId) { + const crossAuthorInboundCount = await transaction.loreRelationship.count({ + where: { + source: { + authorId: source.authorId, + }, + target: { + authorId: { + not: source.authorId, + }, + }, + targetId: target.id, + worldId: params.data.worldId, + }, + }); + + if ( + crossAuthorInboundCount >= + MAX_CROSS_AUTHOR_INBOUND_RELATIONSHIPS_PER_AUTHOR_PER_ENTRY + ) { + throw new LoreRouteError( + 409, + 'This author has reached the cross-author relationship limit for that entry.', + ); + } + } + + const created = await transaction.loreRelationship.create({ + data: { + ...(body.data.metadata === undefined + ? {} + : { metadata: validateRelationshipMetadata(body.data.metadata) }), + relationType: body.data.relationType, + sourceId: source.id, + targetId: target.id, + worldId: params.data.worldId, + }, + select: { + id: true, + relationType: true, + target: { + select: { + id: true, + loreType: true, + slug: true, + status: true, + title: true, + }, + }, + }, + }); + + await transaction.worldAuditLog.create({ + data: { + action: WorldAuditAction.LORE_RELATIONSHIP_CREATED, + actorId: request.user!.id, + metadata: { + relationType: created.relationType, + sourceId: source.id, + targetId: target.id, + }, + targetId: created.id, + targetType: 'LORE_RELATIONSHIP', + worldId: params.data.worldId, + }, + }); + + return created; + }, + { + isolationLevel: Prisma.TransactionIsolationLevel.Serializable, + }, + ); + + return reply.code(201).send({ + relationship, + }); + } catch (error) { + if (isPrismaTransactionConflict(error) && attempt < RELATIONSHIP_TRANSACTION_RETRIES) { + await delay(relationshipRetryDelay(attempt)); + continue; + } + + if (isPrismaTransactionConflict(error)) { + return reply.code(409).send({ + error: 'Relationship write conflicted. Please retry.', + }); + } + + if (isPrismaUniqueConflict(error)) { + return reply.code(409).send({ + error: 'This lore relationship already exists.', + }); + } + + return sendLoreError(error, reply); + } + } + }, + ); + + app.delete( + '/worlds/:worldId/lore/:entryId/relationships/:relationshipId', + { + config: { + rateLimit: { + max: 60, + timeWindow: '1 minute', + }, + }, + preHandler: requireSession(routeAuthOptions), + }, + async (request, reply) => { + const params = z + .object({ + entryId: z.string().min(1), + relationshipId: z.string().min(1), + worldId: z.string().min(1), + }) + .safeParse(request.params); + + if (!params.success) { + return reply.code(400).send({ + error: 'Invalid lore relationship route.', + }); + } + + const worldExists = await ensureWorldExists(database, params.data.worldId, reply); + + if (!worldExists || !request.user) { + return; + } + + const canAttemptMutation = await canAttemptDraftMutation( + request, + database, + params.data.worldId, + ); + + if (!canAttemptMutation) { + return reply.code(403).send({ + error: 'Insufficient world permissions.', + }); + } + + const relationship = await database.loreRelationship.findFirst({ + include: { + source: { + select: { + authorId: true, + status: true, + }, + }, + target: { + select: { + status: true, + }, + }, + }, + where: { + id: params.data.relationshipId, + sourceId: params.data.entryId, + worldId: params.data.worldId, + }, + }); + + if (!relationship) { + return reply.code(404).send({ + error: 'Lore relationship not found.', + }); + } + + const permitted = await canMutateDraft( + request, + database, + params.data.worldId, + relationship.source.authorId, + ); + + if (!permitted) { + return reply.code(403).send({ + error: 'Insufficient world permissions.', + }); + } + + if (relationship.source.status !== LoreStatus.DRAFT) { + return reply.code(409).send({ + error: 'Only relationships owned by draft lore can be deleted here.', + }); + } + + await database.$transaction(async (transaction) => { + await transaction.loreRelationship.delete({ + where: { + id: relationship.id, + }, + }); + + await transaction.worldAuditLog.create({ + data: { + action: WorldAuditAction.LORE_RELATIONSHIP_DELETED, + actorId: request.user!.id, + metadata: { + relationType: relationship.relationType, + sourceId: relationship.sourceId, + targetId: relationship.targetId, + }, + targetId: relationship.id, + targetType: 'LORE_RELATIONSHIP', + worldId: params.data.worldId, + }, + }); + }); + + return reply.code(204).send(); + }, + ); + + app.post( + '/worlds/:worldId/lore', + { + config: { + rateLimit: { + max: 30, + timeWindow: '1 minute', + }, + }, + preHandler: requireSession(routeAuthOptions), + }, + async (request, reply) => { + const params = routeParamsSchema.safeParse(request.params); + const body = createLoreBodySchema.safeParse(request.body); + + if (!params.success || !body.success) { + return reply.code(400).send({ + error: 'Invalid lore 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 || !request.user) { + return; + } + + const includeAllNonPublicRelations = await canViewAllNonPublicLore( + request, + database, + params.data.worldId, + ); + + try { + const title = normalizeTitle(body.data.title); + const content = validateLoreContent(body.data.content, body.data.loreType); + const slug = await createUniqueSlug(database, params.data.worldId, title); + const entry = await database.$transaction(async (transaction) => { + const created = await transaction.loreEntry.create({ + data: { + authorId: request.user!.id, + content, + loreType: body.data.loreType, + publishedAt: null, + slug, + status: LoreStatus.DRAFT, + title, + worldId: params.data.worldId, + }, + include: loreEntryInclude({ + includeAllNonPublicRelations, + viewerId: request.user!.id, + }), + }); + + await createLoreAuditLog(transaction, { + action: WorldAuditAction.LORE_ENTRY_CREATED, + actorId: request.user!.id, + metadata: { + loreType: created.loreType, + status: created.status, + }, + targetId: created.id, + targetType: 'LORE_ENTRY', + worldId: params.data.worldId, + }); + + return created; + }); + + return reply.code(201).send({ + entry: serializeLoreEntry(entry, { + includeAllNonPublicRelations, + viewerId: request.user.id, + }), + }); + } catch (error) { + if (isPrismaUniqueConflict(error)) { + return reply.code(409).send({ + error: 'A lore entry with this title already exists.', + }); + } + + return sendLoreError(error, reply); + } + }, + ); + + app.patch( + '/worlds/:worldId/lore/:entryId', + { + config: { + rateLimit: { + max: 60, + timeWindow: '1 minute', + }, + }, + preHandler: requireSession(routeAuthOptions), + }, + async (request, reply) => { + const params = routeParamsSchema.required().safeParse(request.params); + const body = updateLoreBodySchema.safeParse(request.body); + + if (!params.success || !body.success) { + return reply.code(400).send({ + error: 'Invalid lore payload.', + }); + } + + const worldExists = await ensureWorldExists(database, params.data.worldId, reply); + + if (!worldExists || !request.user) { + return; + } + + const canAttemptMutation = await canAttemptDraftMutation( + request, + database, + params.data.worldId, + ); + + if (!canAttemptMutation) { + return reply.code(403).send({ + error: 'Insufficient world permissions.', + }); + } + + const existing = await database.loreEntry.findFirst({ + select: { + authorId: true, + content: true, + id: true, + loreType: true, + status: true, + }, + where: { + id: params.data.entryId, + worldId: params.data.worldId, + }, + }); + + if (!existing) { + return reply.code(404).send({ + error: 'Lore entry not found.', + }); + } + + const permitted = await canMutateDraft( + request, + database, + params.data.worldId, + existing.authorId, + ); + + if (!permitted) { + return reply.code(403).send({ + error: 'Insufficient world permissions.', + }); + } + + if (existing.status !== LoreStatus.DRAFT) { + return reply.code(409).send({ + error: 'Only draft lore entries can be edited here.', + }); + } + + const includeAllNonPublicRelations = await canViewAllNonPublicLore( + request, + database, + params.data.worldId, + ); + + try { + const data: Prisma.LoreEntryUpdateInput = {}; + + if (body.data.title !== undefined) { + const title = normalizeTitle(body.data.title); + data.title = title; + data.slug = await createUniqueSlug(database, params.data.worldId, title, existing.id); + } + + if (body.data.loreType !== undefined) { + data.loreType = body.data.loreType; + } + + if (body.data.content !== undefined || body.data.loreType !== undefined) { + const nextLoreType = body.data.loreType ?? existing.loreType; + const nextContent = body.data.content ?? existing.content; + const validatedContent = validateLoreContent(nextContent, nextLoreType); + + if (body.data.content !== undefined) { + data.content = validatedContent; + } + } + + const entry = await database.$transaction(async (transaction) => { + const updated = await transaction.loreEntry.update({ + data, + include: loreEntryInclude({ + includeAllNonPublicRelations, + viewerId: request.user!.id, + }), + where: { + id: existing.id, + }, + }); + + await createLoreAuditLog(transaction, { + action: WorldAuditAction.LORE_ENTRY_UPDATED, + actorId: request.user!.id, + metadata: { + changedFields: Object.keys(data).sort(), + }, + targetId: updated.id, + targetType: 'LORE_ENTRY', + worldId: params.data.worldId, + }); + + return updated; + }); + + return { + entry: serializeLoreEntry(entry, { + includeAllNonPublicRelations, + viewerId: request.user.id, + }), + }; + } catch (error) { + if (isPrismaUniqueConflict(error)) { + return reply.code(409).send({ + error: 'A lore entry with this title already exists.', + }); + } + + return sendLoreError(error, reply); + } + }, + ); + + app.delete( + '/worlds/:worldId/lore/:entryId', + { + config: { + rateLimit: { + max: 30, + timeWindow: '1 minute', + }, + }, + preHandler: requireSession(routeAuthOptions), + }, + async (request, reply) => { + const params = routeParamsSchema.required().safeParse(request.params); + + if (!params.success) { + return reply.code(400).send({ + error: 'Invalid lore route.', + }); + } + + const worldExists = await ensureWorldExists(database, params.data.worldId, reply); + + if (!worldExists || !request.user) { + return; + } + + const canAttemptMutation = await canAttemptDraftMutation( + request, + database, + params.data.worldId, + ); + + if (!canAttemptMutation) { + return reply.code(403).send({ + error: 'Insufficient world permissions.', + }); + } + + const existing = await database.loreEntry.findFirst({ + select: { + authorId: true, + id: true, + loreType: true, + status: true, + title: true, + }, + where: { + id: params.data.entryId, + worldId: params.data.worldId, + }, + }); + + if (!existing) { + return reply.code(404).send({ + error: 'Lore entry not found.', + }); + } + + const permitted = await canMutateDraft( + request, + database, + params.data.worldId, + existing.authorId, + ); + + if (!permitted) { + return reply.code(403).send({ + error: 'Insufficient world permissions.', + }); + } + + if (existing.status !== LoreStatus.DRAFT) { + return reply.code(409).send({ + error: 'Only draft lore entries can be deleted here.', + }); + } + + await database.$transaction(async (transaction) => { + await transaction.loreRelationship.deleteMany({ + where: { + OR: [{ sourceId: existing.id }, { targetId: existing.id }], + worldId: params.data.worldId, + }, + }); + + await transaction.loreEntry.delete({ + where: { + id: existing.id, + }, + }); + + await createLoreAuditLog(transaction, { + action: WorldAuditAction.LORE_ENTRY_DELETED, + actorId: request.user!.id, + metadata: { + loreType: existing.loreType, + title: existing.title, + }, + targetId: existing.id, + targetType: 'LORE_ENTRY', + worldId: params.data.worldId, + }); + }); + + return { + ok: true, + }; + }, + ); +} diff --git a/apps/api/src/routes/worlds.test.ts b/apps/api/src/routes/worlds.test.ts new file mode 100644 index 0000000..ec4e19a --- /dev/null +++ b/apps/api/src/routes/worlds.test.ts @@ -0,0 +1,606 @@ +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 { 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; + }> = []; + + function includeWorld(world: StoredWorld) { + return { + ...world, + bibleVersions: bibleVersions + .filter((version) => version.worldId === world.id) + .sort((left, right) => right.versionNumber - left.versionNumber) + .slice(0, 1), + founder: world.founder, + seed: seeds.find((seed) => seed.worldId === world.id) ?? null, + }; + } + + const database = { + async $transaction(this: unknown, callback: (transaction: never) => Promise) { + return callback(this as never); + }, + 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; + }, + }, + refreshSession: { + async findUnique(args: { where: { id: string } }) { + const userId = args.where.id.replace(/^session-/, ''); + + return { + expiresAt: new Date(Date.now() + 60 * 60 * 1000), + revokedAt: null, + userId, + }; + }, + }, + world: { + async count() { + return worlds.length; + }, + async create(args: { + data: { + description: string; + founderId: string; + slug: string; + title: string; + }; + }) { + 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: { take: number; skip: number }) { + return worlds.slice(args.skip, args.skip + args.take).map(includeWorld); + }, + 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 }) { + 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; + }; + }) { + 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; + }, + }, + }; + + return { + auditLogs, + bibleVersions, + database, + loreEntries, + memberships, + proposals, + seeds, + 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('rejects non-json world bible content', async () => { + const state = createDatabase(); + const app = await createApp(state.database); + const response = await app.inject({ + headers: authHeader(), + method: 'POST', + payload: { + ...worldPayload, + bible: { + changeSummary: 'Missing content should not pass as undefined.', + }, + }, + url: '/worlds', + }); + + assert.equal(response.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(getResponse.statusCode, 200); + 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('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); + await app.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(); + }); +}); diff --git a/apps/api/src/routes/worlds.ts b/apps/api/src/routes/worlds.ts new file mode 100644 index 0000000..056a394 --- /dev/null +++ b/apps/api/src/routes/worlds.ts @@ -0,0 +1,267 @@ +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 type { SessionVerificationDatabase } from '../lib/auth-sessions.js'; +import { prisma } from '../lib/prisma.js'; +import { + createWorld, + getWorld, + getWorldHub, + listWorlds, + updateWorld, + 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); + +function isJsonValue(value: unknown): value is Prisma.InputJsonValue { + if (value === null) { + return true; + } + + if (typeof value === 'string' || typeof value === 'boolean') { + return true; + } + + if (typeof value === 'number') { + return Number.isFinite(value); + } + + if (Array.isArray(value)) { + return value.every(isJsonValue); + } + + if (typeof value === 'object') { + const prototype = Object.getPrototypeOf(value); + + if (prototype !== Object.prototype && prototype !== null) { + return false; + } + + return Object.values(value).every(isJsonValue); + } + + return false; +} + +const jsonValueSchema = z.custom(isJsonValue, { + message: 'World bible content must be valid JSON.', +}); + +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 & SessionVerificationDatabase; +}; + +function authOptions(database: SessionVerificationDatabase) { + return { + audience: env.AUTH_JWT_AUDIENCE, + database, + issuer: env.AUTH_JWT_ISSUER, + jwtSecret: env.AUTH_JWT_SECRET, + }; +} + +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.', + }); + } + + 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, + }); + }, + ); + + 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; + } + + const world = await updateWorld(database, { + ...(body.data.description ? { description: body.data.description } : {}), + ...(body.data.seed ? { seed: body.data.seed as Partial } : {}), + ...(body.data.title ? { title: body.data.title } : {}), + ...(body.data.bible + ? { + bible: { + ...body.data.bible, + content: body.data.bible.content, + }, + } + : {}), + worldId: params.data.worldId, + }); + + if (!world) { + return reply.code(404).send({ + error: 'World not found.', + }); + } + + return { + world, + }; + }, + ); +} diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css index 5d71e19..75c71c3 100644 --- a/apps/web/src/app/globals.css +++ b/apps/web/src/app/globals.css @@ -166,27 +166,81 @@ textarea { line-height: var(--leading-prose); } -.theme-switcher { - display: flex; +.theme-toggle { + display: inline-flex; + min-height: 2.5rem; + width: 2.5rem; align-items: center; - gap: 0.5rem; + justify-content: center; + border: 1px solid var(--border); + border-radius: var(--radius-control); + background: var(--surface); + color: var(--foreground); + box-shadow: var(--shadow-soft); + transition: + background-color 160ms ease, + border-color 160ms ease, + color 160ms ease; } -.theme-switcher__label { - color: var(--muted-foreground); - font-size: 0.75rem; - font-weight: 700; - letter-spacing: var(--tracking-label); - text-transform: uppercase; +.theme-toggle:hover { + background: var(--muted); } -.theme-switcher__select { - min-height: 2.25rem; - border: 1px solid var(--input-border); - border-radius: var(--radius-control); - background: var(--surface); - color: var(--foreground); - padding: 0 2rem 0 0.75rem; +.theme-toggle__icon { + width: 1.25rem; + height: 1.25rem; +} + +.mobile-navigation-backdrop { + position: fixed; + inset: 4rem 0 0; + z-index: 70; + border: 0; + background: + linear-gradient(rgb(0 0 0 / 0.92), rgb(0 0 0 / 0.92)), + color-mix(in srgb, var(--background) 35%, black); + backdrop-filter: blur(8px); + cursor: default; + padding: 0; +} + +.mobile-navigation-panel { + position: fixed; + top: 4.75rem; + right: 1rem; + z-index: 80; + display: flex; + width: calc(100vw - 2rem); + max-width: 24rem; + transform-origin: top right; + flex-direction: column; + gap: 1.25rem; + border: 1px solid var(--border); + border-radius: var(--radius-panel); + background: color-mix(in srgb, var(--surface) 98%, transparent); + box-shadow: var(--shadow-elevated); + padding: 1rem; +} + +@keyframes mobile-overlay-in { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@keyframes mobile-menu-in { + from { + opacity: 0; + transform: translateY(-0.5rem) scale(0.98); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } } .hive-brand { @@ -221,3 +275,407 @@ textarea { padding: 0.4rem 0.7rem; text-transform: uppercase; } + +.auth-atlas-page { + position: relative; + width: 100vw; + height: calc(100svh - 8rem); + max-height: calc(100svh - 8rem); + margin-inline: calc(50% - 50vw); + overflow: hidden; + isolation: isolate; +} + +.auth-atlas-page__content { + position: relative; + z-index: 1; + height: 100%; + max-width: 72rem; + margin-inline: auto; + padding-inline: 1rem; + padding-block: clamp(1rem, 3vh, 2.25rem); +} + +@media (min-width: 640px) { + .auth-atlas-page__content { + padding-inline: 1.5rem; + } +} + +@media (min-width: 1024px) { + .auth-atlas-page__content { + padding-inline: 2rem; + } +} + +.auth-atlas-page__hero { + position: relative; + text-shadow: 0 1px 28px color-mix(in srgb, var(--background) 86%, transparent); +} + +.auth-atlas-page__form { + position: relative; + width: 100%; + max-width: 22rem; + justify-self: end; + background: linear-gradient( + 135deg, + color-mix(in srgb, var(--surface) 96%, transparent), + color-mix(in srgb, var(--surface-elevated) 88%, transparent) + ); + box-shadow: + 0 16px 38px rgb(0 0 0 / 0.18), + 0 0 0 1px color-mix(in srgb, var(--hive-red) 5%, transparent); +} + +.lore-atlas-background { + --atlas-parallax-x: 0; + --atlas-parallax-y: 0; + position: absolute; + inset: 0; + z-index: 0; + overflow: hidden; + pointer-events: none; + background: + radial-gradient( + ellipse at 27% 43%, + color-mix(in srgb, var(--hive-red) 12%, transparent) 0%, + transparent 31% + ), + radial-gradient(ellipse at 49% 34%, rgb(61 71 117 / 0.11) 0%, transparent 34%), + radial-gradient(ellipse at 78% 58%, rgb(24 42 78 / 0.12) 0%, transparent 35%), + linear-gradient( + 90deg, + color-mix(in srgb, var(--background) 90%, #07060a) 0%, + color-mix(in srgb, var(--background) 96%, #0b0d18) 54%, + var(--background) 100% + ); +} + +.lore-atlas-background::before, +.lore-atlas-background::after { + position: absolute; + inset: 0; + content: ''; +} + +.lore-atlas-background::before { + background: + linear-gradient( + 90deg, + transparent 0%, + transparent 50%, + color-mix(in srgb, var(--background) 58%, transparent) 73%, + color-mix(in srgb, var(--background) 88%, transparent) 100% + ), + radial-gradient(ellipse at center, transparent 44%, rgb(0 0 0 / 0.34) 100%); +} + +.lore-atlas-background::after { + background-image: + linear-gradient(color-mix(in srgb, var(--foreground) 5%, transparent) 1px, transparent 1px), + linear-gradient( + 90deg, + color-mix(in srgb, var(--foreground) 5%, transparent) 1px, + transparent 1px + ); + background-position: + calc(var(--atlas-parallax-x) * -10px) calc(var(--atlas-parallax-y) * -10px), + calc(var(--atlas-parallax-x) * -10px) calc(var(--atlas-parallax-y) * -10px); + background-size: 64px 64px; + mask-image: radial-gradient(circle at 32% 48%, black 0%, transparent 58%); + opacity: 0.16; +} + +:root[data-theme='dark'] .lore-atlas-background { + background: + radial-gradient(ellipse at 27% 43%, rgb(133 22 45 / 0.2) 0%, transparent 32%), + radial-gradient(ellipse at 49% 34%, rgb(46 55 103 / 0.16) 0%, transparent 34%), + radial-gradient(ellipse at 78% 58%, rgb(15 30 61 / 0.18) 0%, transparent 35%), + linear-gradient(90deg, #0a090d 0%, #111014 52%, #08080c 100%); +} + +.lore-atlas-background__hub { + position: absolute; + left: clamp(-3rem, 4vw, 4rem); + top: 16%; + width: clamp(17rem, 33vw, 31rem); + aspect-ratio: 1; + border: 1px solid color-mix(in srgb, var(--hive-red) 20%, transparent); + border-radius: 999px; + background: + radial-gradient( + circle, + color-mix(in srgb, var(--hive-red) 7%, transparent) 0%, + transparent 50% + ), + repeating-radial-gradient( + circle, + color-mix(in srgb, var(--foreground) 5%, transparent) 0 1px, + transparent 1px 2.4rem + ); + filter: blur(0.2px); + opacity: 0.44; + transform: translate( + calc(var(--atlas-parallax-x) * -18px), + calc(var(--atlas-parallax-y) * -12px) + ); +} + +.lore-atlas-background__map { + position: absolute; + inset: -15% auto auto -4rem; + width: min(82rem, 104vw); + height: 130%; + opacity: 0.62; + transform: translate(calc(var(--atlas-parallax-x) * 16px), calc(var(--atlas-parallax-y) * 10px)); +} + +.lore-atlas-background__contours path { + fill: none; + stroke: color-mix(in srgb, var(--foreground) 9%, transparent); + stroke-dasharray: 0.45 1.85; + stroke-linecap: round; + stroke-width: 0.18; +} + +.lore-atlas-background__coastlines path { + fill: color-mix(in srgb, #192132 12%, transparent); + stroke: color-mix(in srgb, var(--foreground) 13%, transparent); + stroke-linejoin: round; + stroke-width: 0.2; +} + +.lore-atlas-background__connections path { + fill: none; + stroke: color-mix(in srgb, var(--foreground) 15%, transparent); + stroke-linecap: round; + stroke-width: 0.18; +} + +.lore-atlas-background__signals path { + animation: lore-signal 13s linear infinite; + fill: none; + stroke: color-mix(in srgb, var(--hive-red) 36%, var(--foreground) 20%); + stroke-dasharray: 0.01 13; + stroke-linecap: round; + stroke-width: 0.28; +} + +.lore-atlas-background__node-glow { + fill: url('#atlas-node-glow'); + opacity: 0.18; +} + +.lore-atlas-background__node-core { + animation: lore-node-pulse 9s ease-in-out infinite; + fill: color-mix(in srgb, var(--hive-red) 58%, var(--foreground) 18%); + stroke: color-mix(in srgb, var(--surface) 72%, transparent); + stroke-width: 0.14; +} + +.lore-atlas-background__node.is-region .lore-atlas-background__node-glow { + fill: url('#atlas-cool-glow'); + opacity: 0.16; +} + +.lore-atlas-background__node.is-region .lore-atlas-background__node-core { + fill: color-mix(in srgb, #8ab4ff 52%, var(--foreground) 12%); +} + +.lore-atlas-background__node.is-lore .lore-atlas-background__node-core { + fill: color-mix(in srgb, var(--foreground) 62%, var(--hive-red) 13%); + opacity: 0.7; +} + +.lore-atlas-background__particles { + position: absolute; + inset: 0; + transform: translate(calc(var(--atlas-parallax-x) * -8px), calc(var(--atlas-parallax-y) * -6px)); +} + +.lore-atlas-background__particles span { + position: absolute; + width: 2px; + height: 2px; + animation: lore-particle-drift 18s ease-in-out infinite; + border-radius: 999px; + background: color-mix(in srgb, var(--foreground) 46%, transparent); + box-shadow: 0 0 8px color-mix(in srgb, #8ab4ff 16%, transparent); +} + +.lore-atlas-background__veil { + position: absolute; + inset: 0; + background: + linear-gradient( + 90deg, + transparent 0%, + transparent 50%, + color-mix(in srgb, var(--background) 88%, transparent) 74%, + var(--background) 100% + ), + linear-gradient( + 180deg, + var(--background) 0%, + transparent 12%, + transparent 78%, + var(--background) 100% + ); +} + +@keyframes lore-node-pulse { + 0%, + 100% { + opacity: 0.62; + transform: scale(1); + } + + 45% { + opacity: 0.9; + transform: scale(1.08); + } +} + +@keyframes lore-signal { + from { + stroke-dashoffset: 11; + opacity: 0; + } + + 20%, + 74% { + opacity: 0.72; + } + + to { + stroke-dashoffset: 0; + opacity: 0; + } +} + +@keyframes lore-particle-drift { + 0%, + 100% { + opacity: 0.18; + transform: translate3d(0, 0, 0); + } + + 50% { + opacity: 0.55; + transform: translate3d(var(--particle-distance), -18px, 0); + } +} + +@media (max-width: 1024px) { + .lore-atlas-background__map { + width: 74rem; + opacity: 0.48; + } + + .lore-atlas-background__particles span:nth-child(n + 6), + .lore-atlas-background__signals path:nth-child(n + 5) { + display: none; + } +} + +@media (max-height: 720px) and (min-width: 768px) { + .auth-atlas-page__content { + padding-block: 0.75rem; + } + + .auth-atlas-page__hero h1 { + font-size: clamp(2.35rem, 4.4vw, 3.25rem); + } + + .auth-atlas-page__hero .prose-text { + margin-top: 1rem; + } + + .auth-atlas-page__hero p:last-child { + margin-top: 1rem; + } + + .auth-atlas-page__form { + padding: 1rem; + } +} + +@media (max-width: 767px) { + .auth-atlas-page { + height: calc(100svh - 7rem); + max-height: calc(100svh - 7rem); + overflow: hidden; + } + + .auth-atlas-page__form { + max-width: none; + justify-self: stretch; + } + + .auth-atlas-page__content { + padding-block: 1.5rem; + } + + .lore-atlas-background { + inset: 0; + } + + .lore-atlas-background::before { + background: + linear-gradient(180deg, transparent 0%, rgb(0 0 0 / 0.38) 48%, rgb(0 0 0 / 0.66) 100%), + radial-gradient(ellipse at center, transparent 36%, rgb(0 0 0 / 0.45) 100%); + } + + .lore-atlas-background__hub { + left: -7rem; + top: 4%; + width: 22rem; + opacity: 0.26; + } + + .lore-atlas-background__map { + left: -17rem; + top: -1rem; + width: 58rem; + height: 115%; + opacity: 0.34; + } + + .lore-atlas-background__particles span:nth-child(n + 4), + .lore-atlas-background__signals path:nth-child(n + 3), + .lore-atlas-background__contours path:nth-child(n + 4) { + display: none; + } + + .lore-atlas-background__veil { + background: + linear-gradient( + 180deg, + transparent 0%, + color-mix(in srgb, var(--background) 76%, transparent) 44%, + var(--background) 100% + ), + radial-gradient(circle at 28% 24%, transparent 0%, var(--background) 72%); + } +} + +@media (prefers-reduced-motion: reduce) { + .lore-atlas-background, + .lore-atlas-background__hub, + .lore-atlas-background__map, + .lore-atlas-background__particles { + transform: none; + } + + .lore-atlas-background__node-core, + .lore-atlas-background__signals path, + .lore-atlas-background__particles span { + animation: none; + } + + .lore-atlas-background__signals path { + opacity: 0.16; + stroke-dasharray: 1 9; + } +} diff --git a/apps/web/src/app/login/page.tsx b/apps/web/src/app/login/page.tsx index 56adf88..9064205 100644 --- a/apps/web/src/app/login/page.tsx +++ b/apps/web/src/app/login/page.tsx @@ -1,4 +1,4 @@ -import Home from '../page'; +import Home from '@/app/page'; export default function LoginPage() { return ; diff --git a/apps/web/src/app/page.tsx b/apps/web/src/app/page.tsx index af50030..3cd3462 100644 --- a/apps/web/src/app/page.tsx +++ b/apps/web/src/app/page.tsx @@ -1,24 +1,21 @@ 'use client'; -import { useEffect, useMemo, useState } from 'react'; +import { useMemo, useState } from 'react'; -import { HiveBrand } from '@/components/hive-brand'; -import { ThemeSwitcher } from '@/components/theme-switcher'; +import { LoreAtlasBackground } from '@/components/auth/lore-atlas-background'; import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { createAuthChallenge, getMe, - logoutAuthSession, - refreshAuthSession, verifyAuthChallenge, type AuthChallengeResponse, type AuthProvider, - type SafeUser, } from '@/lib/api/auth'; import { ApiError } from '@/lib/api/errors'; import { env } from '@/lib/env'; +import { useAuthSession } from '@/providers/auth-session-provider'; type HiveKeychainResponse = { success: boolean; @@ -77,6 +74,29 @@ async function sha256Hex(value: string) { return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, '0')).join(''); } +function GoogleIcon() { + return ( + + ); +} + export default function Home() { const [challenge, setChallenge] = useState(null); const [error, setError] = useState(null); @@ -84,32 +104,13 @@ export default function Home() { const [manualSignature, setManualSignature] = useState(''); const [manualPublicKey, setManualPublicKey] = useState(''); const [provider, setProvider] = useState('keychain'); - const [user, setUser] = useState(null); const [username, setUsername] = useState(''); + const { clearSession, isSessionLoading, refreshSession, setSession, user } = useAuthSession(); - const canSubmit = useMemo(() => username.trim().length > 0 && !isLoading, [isLoading, username]); - - useEffect(() => { - let isMounted = true; - - refreshAuthSession() - .then((session) => { - if (!isMounted) { - return; - } - - setUser(session.user); - }) - .catch(() => { - if (isMounted) { - setUser(null); - } - }); - - return () => { - isMounted = false; - }; - }, []); + const canSubmit = useMemo( + () => username.trim().length > 0 && !isLoading && !isSessionLoading, + [isLoading, isSessionLoading, username], + ); async function completeLogin( nextChallenge: AuthChallengeResponse, @@ -125,7 +126,7 @@ export default function Home() { ...(publicKey ? { publicKey } : {}), }); - setUser(session.user); + setSession(session); setChallenge(null); setManualSignature(''); setManualPublicKey(''); @@ -185,11 +186,9 @@ export default function Home() { setIsLoading(true); try { - const session = await refreshAuthSession(); - setUser(session.user); + const session = await refreshSession(); await getMe(session.accessToken); } catch (nextError) { - setUser(null); setError(getErrorMessage(nextError)); } finally { setIsLoading(false); @@ -201,8 +200,7 @@ export default function Home() { setIsLoading(true); try { - await logoutAuthSession(); - setUser(null); + await clearSession(); } catch (nextError) { setError(getErrorMessage(nextError)); } finally { @@ -211,140 +209,144 @@ export default function Home() { } return ( -
-
-
-
- - HiveLore -
- -
- -
-
-

- Welcome back to HiveLore. -

-

- Sign in to contribute, vote, and build trusted Hive knowledge. -

-

- Hive accounts are the canonical identity. Signing a challenge proves account control - and never gives HiveLore permission to publish, transfer, or hold private keys. -

-
- -
- {user ? ( -
-
-

Signed in as

-

@{user.hiveUsername}

-
-
- - -
+
+ +
+
+

+ Welcome back to HiveLore. +

+

+ Sign in to contribute, vote, and build trusted Hive knowledge. +

+

+ Hive accounts are the canonical identity. Signing a challenge proves account control and + never gives HiveLore permission to publish, transfer, or hold private keys. +

+
+ +
+ {user ? ( +
+
+

Signed in as

+

@{user.hiveUsername}

- ) : ( -
- - - - -
- - or - -
- - - - {env.googleAuthEnabled ? ( - - ) : null} - -

- No password is stored by HiveLore. -

- )} - - {challenge ? ( -
-