From fb5975add0056f8a20cde167977f086913ce2de3 Mon Sep 17 00:00:00 2001 From: Kareem Naous <62202089+KareemN1936@users.noreply.github.com> Date: Tue, 11 Aug 2026 19:17:45 +0300 Subject: [PATCH] Revert "Feature/m2 relationship lore graph kareem" This reverts commit a64edb4a6dbb1f7dfca82d6eceefdde1ac34d6e4, reversing changes made to 457fffefb54054542ff078f961ee14484be56c2b. --- README.md | 17 - .../migration.sql | 22 - .../migration.sql | 70 - .../migration.sql | 2 - .../migration.sql | 5 - .../migration.sql | 11 - .../migration.sql | 4 - .../migration.sql | 50 - apps/api/prisma/schema.prisma | 150 +- apps/api/prisma/seed.ts | 108 -- apps/api/src/app.ts | 6 - apps/api/src/lib/contributions.ts | 755 -------- apps/api/src/lib/worlds.ts | 498 ----- apps/api/src/routes/contributions.test.ts | 953 ---------- apps/api/src/routes/contributions.ts | 432 ----- apps/api/src/routes/lore.test.ts | 1210 ------------ apps/api/src/routes/lore.ts | 1621 ----------------- apps/api/src/routes/worlds.test.ts | 606 ------ apps/api/src/routes/worlds.ts | 267 --- apps/web/src/app/globals.css | 490 +---- apps/web/src/app/login/page.tsx | 2 +- apps/web/src/app/page.tsx | 332 ++-- .../app/worlds/[worldId]/contribute/page.tsx | 21 +- .../worlds/[worldId]/lore/[entryId]/page.tsx | 23 +- apps/web/src/app/worlds/[worldId]/page.tsx | 296 +-- apps/web/src/app/worlds/new/page.tsx | 377 +--- apps/web/src/app/worlds/page.tsx | 284 +-- .../components/auth/lore-atlas-background.tsx | 179 -- .../components/layout/mobile-navigation.tsx | 90 +- .../web/src/components/layout/site-header.tsx | 4 +- .../lore/lore-contribute-client.tsx | 123 -- .../src/components/lore/lore-entity-tabs.tsx | 251 --- .../lore/lore-entry-detail-client.tsx | 342 ---- .../src/components/lore/lore-entry-form.tsx | 563 ------ apps/web/src/components/lore/lore-utils.ts | 139 -- apps/web/src/components/theme-switcher.tsx | 60 +- apps/web/src/lib/api/lore.ts | 186 -- apps/web/src/lib/api/worlds.ts | 127 -- apps/web/src/lib/worlds/constants.ts | 72 - apps/web/src/providers/app-providers.tsx | 5 +- .../src/providers/auth-session-provider.tsx | 199 -- 41 files changed, 300 insertions(+), 10652 deletions(-) delete mode 100644 apps/api/prisma/migrations/20260805123000_add_world_seed/migration.sql delete mode 100644 apps/api/prisma/migrations/20260805170000_add_contribution_drafts/migration.sql delete mode 100644 apps/api/prisma/migrations/20260808120000_add_quest_story_lore_types/migration.sql delete mode 100644 apps/api/prisma/migrations/20260809120000_add_lore_relationship_audit_actions/migration.sql delete mode 100644 apps/api/prisma/migrations/20260810134000_backfill_quest_story_lore_types/migration.sql delete mode 100644 apps/api/prisma/migrations/20260810162000_add_lore_ordering_indexes/migration.sql delete mode 100644 apps/api/prisma/migrations/20260810190000_repair_published_canon_quest_story_lore_types/migration.sql delete mode 100644 apps/api/src/lib/contributions.ts delete mode 100644 apps/api/src/lib/worlds.ts delete mode 100644 apps/api/src/routes/contributions.test.ts delete mode 100644 apps/api/src/routes/contributions.ts delete mode 100644 apps/api/src/routes/lore.test.ts delete mode 100644 apps/api/src/routes/lore.ts delete mode 100644 apps/api/src/routes/worlds.test.ts delete mode 100644 apps/api/src/routes/worlds.ts delete mode 100644 apps/web/src/components/auth/lore-atlas-background.tsx delete mode 100644 apps/web/src/components/lore/lore-contribute-client.tsx delete mode 100644 apps/web/src/components/lore/lore-entity-tabs.tsx delete mode 100644 apps/web/src/components/lore/lore-entry-detail-client.tsx delete mode 100644 apps/web/src/components/lore/lore-entry-form.tsx delete mode 100644 apps/web/src/components/lore/lore-utils.ts delete mode 100644 apps/web/src/lib/api/lore.ts delete mode 100644 apps/web/src/lib/api/worlds.ts delete mode 100644 apps/web/src/lib/worlds/constants.ts delete mode 100644 apps/web/src/providers/auth-session-provider.tsx diff --git a/README.md b/README.md index d556e96..2b691ca 100644 --- a/README.md +++ b/README.md @@ -319,23 +319,6 @@ 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 deleted file mode 100644 index d69a67b..0000000 --- a/apps/api/prisma/migrations/20260805123000_add_world_seed/migration.sql +++ /dev/null @@ -1,22 +0,0 @@ -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 deleted file mode 100644 index 2f95fad..0000000 --- a/apps/api/prisma/migrations/20260805170000_add_contribution_drafts/migration.sql +++ /dev/null @@ -1,70 +0,0 @@ --- 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 deleted file mode 100644 index 65812f7..0000000 --- a/apps/api/prisma/migrations/20260808120000_add_quest_story_lore_types/migration.sql +++ /dev/null @@ -1,2 +0,0 @@ -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 deleted file mode 100644 index ec40412..0000000 --- a/apps/api/prisma/migrations/20260809120000_add_lore_relationship_audit_actions/migration.sql +++ /dev/null @@ -1,5 +0,0 @@ -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 deleted file mode 100644 index efe0ebb..0000000 --- a/apps/api/prisma/migrations/20260810134000_backfill_quest_story_lore_types/migration.sql +++ /dev/null @@ -1,11 +0,0 @@ -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 deleted file mode 100644 index 6b6d476..0000000 --- a/apps/api/prisma/migrations/20260810162000_add_lore_ordering_indexes/migration.sql +++ /dev/null @@ -1,4 +0,0 @@ --- 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 deleted file mode 100644 index ea76c68..0000000 --- a/apps/api/prisma/migrations/20260810190000_repair_published_canon_quest_story_lore_types/migration.sql +++ /dev/null @@ -1,50 +0,0 @@ -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 040ef6f..46e369c 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -12,9 +12,7 @@ enum LoreType { CHARACTER LOCATION FACTION - QUEST EVENT - STORY ARTIFACT HISTORY RULE @@ -31,21 +29,10 @@ 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 @@ -142,16 +129,6 @@ 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 { @@ -191,7 +168,6 @@ 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") @@ -320,49 +296,27 @@ 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 - seed WorldSeed? - bibleVersions WorldBibleVersion[] - loreEntries LoreEntry[] - loreRelationships LoreRelationship[] - proposals Proposal[] - contributionDrafts ContributionDraft[] - 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 + bibleVersions WorldBibleVersion[] + loreEntries LoreEntry[] + loreRelationships LoreRelationship[] + proposals Proposal[] + 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 @@ -426,32 +380,30 @@ 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") - targetContributionDrafts ContributionDraft[] @relation("ContributionDraftTargetLoreEntry") - 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") + resultingProposals Proposal[] @relation("ProposalResultingLoreEntry") @@unique([worldId, slug]) @@index([worldId, status]) @@index([worldId, loreType]) - @@index([worldId, updatedAt, id]) @@index([authorId]) } @@ -473,8 +425,6 @@ model LoreRelationship { @@index([worldId]) @@index([sourceId]) @@index([targetId]) - @@index([sourceId, updatedAt, id]) - @@index([targetId, updatedAt, id]) } model Proposal { @@ -484,7 +434,6 @@ 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 @@ -502,41 +451,12 @@ 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 d999c72..2da8d6a 100644 --- a/apps/api/prisma/seed.ts +++ b/apps/api/prisma/seed.ts @@ -3,7 +3,6 @@ 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; @@ -76,51 +75,6 @@ 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: { @@ -291,68 +245,6 @@ 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 e3e2fea..bc2bfbe 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -6,10 +6,7 @@ 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({ @@ -44,9 +41,6 @@ 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 deleted file mode 100644 index 675109b..0000000 --- a/apps/api/src/lib/contributions.ts +++ /dev/null @@ -1,755 +0,0 @@ -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 deleted file mode 100644 index a6bfcc1..0000000 --- a/apps/api/src/lib/worlds.ts +++ /dev/null @@ -1,498 +0,0 @@ -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 deleted file mode 100644 index c922f38..0000000 --- a/apps/api/src/routes/contributions.test.ts +++ /dev/null @@ -1,953 +0,0 @@ -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 deleted file mode 100644 index dc1cab1..0000000 --- a/apps/api/src/routes/contributions.ts +++ /dev/null @@ -1,432 +0,0 @@ -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 deleted file mode 100644 index 9158338..0000000 --- a/apps/api/src/routes/lore.test.ts +++ /dev/null @@ -1,1210 +0,0 @@ -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 deleted file mode 100644 index 8163dba..0000000 --- a/apps/api/src/routes/lore.ts +++ /dev/null @@ -1,1621 +0,0 @@ -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 deleted file mode 100644 index ec4e19a..0000000 --- a/apps/api/src/routes/worlds.test.ts +++ /dev/null @@ -1,606 +0,0 @@ -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 deleted file mode 100644 index 056a394..0000000 --- a/apps/api/src/routes/worlds.ts +++ /dev/null @@ -1,267 +0,0 @@ -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 75c71c3..5d71e19 100644 --- a/apps/web/src/app/globals.css +++ b/apps/web/src/app/globals.css @@ -166,81 +166,27 @@ textarea { line-height: var(--leading-prose); } -.theme-toggle { - display: inline-flex; - min-height: 2.5rem; - width: 2.5rem; - align-items: center; - 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-toggle:hover { - background: var(--muted); -} - -.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; +.theme-switcher { 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; + align-items: center; + gap: 0.5rem; } -@keyframes mobile-overlay-in { - from { - opacity: 0; - } - to { - opacity: 1; - } +.theme-switcher__label { + color: var(--muted-foreground); + font-size: 0.75rem; + font-weight: 700; + letter-spacing: var(--tracking-label); + text-transform: uppercase; } -@keyframes mobile-menu-in { - from { - opacity: 0; - transform: translateY(-0.5rem) scale(0.98); - } - to { - opacity: 1; - transform: translateY(0) scale(1); - } +.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; } .hive-brand { @@ -275,407 +221,3 @@ 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 9064205..56adf88 100644 --- a/apps/web/src/app/login/page.tsx +++ b/apps/web/src/app/login/page.tsx @@ -1,4 +1,4 @@ -import Home from '@/app/page'; +import Home from '../page'; export default function LoginPage() { return ; diff --git a/apps/web/src/app/page.tsx b/apps/web/src/app/page.tsx index 3cd3462..af50030 100644 --- a/apps/web/src/app/page.tsx +++ b/apps/web/src/app/page.tsx @@ -1,21 +1,24 @@ 'use client'; -import { useMemo, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; -import { LoreAtlasBackground } from '@/components/auth/lore-atlas-background'; +import { HiveBrand } from '@/components/hive-brand'; +import { ThemeSwitcher } from '@/components/theme-switcher'; 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; @@ -74,29 +77,6 @@ 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); @@ -104,13 +84,32 @@ 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 && !isSessionLoading, - [isLoading, isSessionLoading, username], - ); + 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; + }; + }, []); async function completeLogin( nextChallenge: AuthChallengeResponse, @@ -126,7 +125,7 @@ export default function Home() { ...(publicKey ? { publicKey } : {}), }); - setSession(session); + setUser(session.user); setChallenge(null); setManualSignature(''); setManualPublicKey(''); @@ -186,9 +185,11 @@ export default function Home() { setIsLoading(true); try { - const session = await refreshSession(); + const session = await refreshAuthSession(); + setUser(session.user); await getMe(session.accessToken); } catch (nextError) { + setUser(null); setError(getErrorMessage(nextError)); } finally { setIsLoading(false); @@ -200,7 +201,8 @@ export default function Home() { setIsLoading(true); try { - await clearSession(); + await logoutAuthSession(); + setUser(null); } catch (nextError) { setError(getErrorMessage(nextError)); } finally { @@ -209,144 +211,140 @@ export default function Home() { } return ( -
- -
-
-

- 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}

+
+
+
+
+ + 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}

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

+ No password is stored by HiveLore. +

-
- ) : ( -
- - - - -
- - or - + )} + + {challenge ? ( +
+