diff --git a/README.md b/README.md index 2b691ca..d556e96 100644 --- a/README.md +++ b/README.md @@ -319,6 +319,23 @@ Auth challenge, verification, and refresh endpoints use Fastify rate limiting ba Google auth routes are not registered as a default login path. With `GOOGLE_AUTH_ENABLED=false`, `/auth/google/*` returns a not-enabled response. Enabling Google currently exposes the guarded boundary only; Google OAuth account linking and automatic Hive provisioning remain deferred until secure non-custodial account creation and key delivery are specified. +Implemented world endpoints: + +- `POST /worlds`: requires authentication, creates a mutable off-chain World Seed and initial `WorldBibleVersion` in one transaction, and assigns the authenticated user an active `FOUNDER` membership. The route never accepts a client-supplied founder or role and does not publish to Hive. +- `GET /worlds`: browses discoverable worlds with pagination and basic title/description, genre, and tone filters. +- `GET /worlds/:worldId`: returns one world with its founder, seed, and current latest World Bible version. +- `GET /worlds/:worldId/hub`: returns the World Hub aggregate for the frontend, including world seed, current bible preview, simple canon/proposal stats, and latest canon lore entry summaries. +- `PATCH /worlds/:worldId`: requires `EDIT_INITIAL_CANON` world permission and updates mutable off-chain world title/description, seed, and latest initial bible content only. It does not create proposals, canonize content, or publish to Hive. + +Implemented contribution endpoints: + +- `POST /worlds/:worldId/contributions`: requires authentication and `CREATE_LORE_DRAFT` world permission, creates an author-owned structured contribution draft for lore or story content, and optionally links it to a lore entry in the same world. +- `GET /worlds/:worldId/contributions`: requires authentication and lists only the authenticated writer's contributions in the world with page/pageSize pagination and optional status/kind filters. +- `GET /worlds/:worldId/contributions/:contributionId`: returns an authenticated writer's own contribution draft or submitted contribution without exposing other writers' private drafts. +- `PATCH /worlds/:worldId/contributions/:contributionId`: requires `EDIT_OWN_DRAFT`, updates only mutable draft fields, and rejects submitted contributions. +- `DELETE /worlds/:worldId/contributions/:contributionId`: requires `EDIT_OWN_DRAFT`, deletes only draft contributions, and never deletes submitted proposals. +- `POST /worlds/:worldId/contributions/:contributionId/submit`: requires `SUBMIT_PROPOSAL`, atomically locks the draft, creates exactly one submitted proposal with an immutable `proposedContent` snapshot, and does not publish to Hive. + --- # Reputation & Rewards diff --git a/apps/api/prisma/migrations/20260805123000_add_world_seed/migration.sql b/apps/api/prisma/migrations/20260805123000_add_world_seed/migration.sql new file mode 100644 index 0000000..d69a67b --- /dev/null +++ b/apps/api/prisma/migrations/20260805123000_add_world_seed/migration.sql @@ -0,0 +1,22 @@ +CREATE TABLE "WorldSeed" ( + "id" TEXT NOT NULL, + "worldId" TEXT NOT NULL, + "premise" TEXT NOT NULL, + "genre" TEXT NOT NULL, + "tone" TEXT NOT NULL, + "mainConflict" TEXT NOT NULL, + "startingLocation" TEXT, + "firstCharacters" JSONB, + "firstFactions" JSONB, + "firstHistoricalEvent" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "WorldSeed_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "WorldSeed_worldId_key" ON "WorldSeed"("worldId"); +CREATE INDEX "WorldSeed_genre_idx" ON "WorldSeed"("genre"); +CREATE INDEX "WorldSeed_tone_idx" ON "WorldSeed"("tone"); + +ALTER TABLE "WorldSeed" ADD CONSTRAINT "WorldSeed_worldId_fkey" FOREIGN KEY ("worldId") REFERENCES "World"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/apps/api/prisma/migrations/20260805170000_add_contribution_drafts/migration.sql b/apps/api/prisma/migrations/20260805170000_add_contribution_drafts/migration.sql new file mode 100644 index 0000000..2f95fad --- /dev/null +++ b/apps/api/prisma/migrations/20260805170000_add_contribution_drafts/migration.sql @@ -0,0 +1,70 @@ +-- CreateEnum +CREATE TYPE "ContributionKind" AS ENUM ('LORE', 'STORY'); + +-- CreateEnum +CREATE TYPE "ContributionStatus" AS ENUM ('DRAFT', 'SUBMITTED'); + +-- AlterEnum +ALTER TYPE "ProposalType" ADD VALUE 'ADD_STORY'; + +-- AlterEnum +ALTER TYPE "WorldAuditAction" ADD VALUE 'CONTRIBUTION_CREATED'; +ALTER TYPE "WorldAuditAction" ADD VALUE 'CONTRIBUTION_UPDATED'; +ALTER TYPE "WorldAuditAction" ADD VALUE 'CONTRIBUTION_DELETED'; +ALTER TYPE "WorldAuditAction" ADD VALUE 'CONTRIBUTION_SUBMITTED'; +ALTER TYPE "WorldAuditAction" ADD VALUE 'PROPOSAL_CREATED'; + +-- AlterTable +ALTER TABLE "Proposal" ADD COLUMN "contributionKind" "ContributionKind"; + +-- CreateTable +CREATE TABLE "ContributionDraft" ( + "id" TEXT NOT NULL, + "worldId" TEXT NOT NULL, + "authorId" TEXT NOT NULL, + "targetLoreEntryId" TEXT, + "kind" "ContributionKind" NOT NULL, + "title" TEXT NOT NULL, + "summary" TEXT, + "content" JSONB NOT NULL, + "status" "ContributionStatus" NOT NULL DEFAULT 'DRAFT', + "proposalId" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "submittedAt" TIMESTAMP(3), + + CONSTRAINT "ContributionDraft_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "ContributionDraft_proposalId_key" ON "ContributionDraft"("proposalId"); + +-- CreateIndex +CREATE INDEX "ContributionDraft_worldId_idx" ON "ContributionDraft"("worldId"); + +-- CreateIndex +CREATE INDEX "ContributionDraft_authorId_idx" ON "ContributionDraft"("authorId"); + +-- CreateIndex +CREATE INDEX "ContributionDraft_worldId_status_idx" ON "ContributionDraft"("worldId", "status"); + +-- CreateIndex +CREATE INDEX "ContributionDraft_worldId_authorId_status_idx" ON "ContributionDraft"("worldId", "authorId", "status"); + +-- CreateIndex +CREATE INDEX "ContributionDraft_targetLoreEntryId_idx" ON "ContributionDraft"("targetLoreEntryId"); + +-- CreateIndex +CREATE INDEX "Proposal_worldId_contributionKind_idx" ON "Proposal"("worldId", "contributionKind"); + +-- AddForeignKey +ALTER TABLE "ContributionDraft" ADD CONSTRAINT "ContributionDraft_worldId_fkey" FOREIGN KEY ("worldId") REFERENCES "World"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ContributionDraft" ADD CONSTRAINT "ContributionDraft_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ContributionDraft" ADD CONSTRAINT "ContributionDraft_targetLoreEntryId_fkey" FOREIGN KEY ("targetLoreEntryId") REFERENCES "LoreEntry"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ContributionDraft" ADD CONSTRAINT "ContributionDraft_proposalId_fkey" FOREIGN KEY ("proposalId") REFERENCES "Proposal"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/apps/api/prisma/migrations/20260810133000_publish_initial_world_bibles/migration.sql b/apps/api/prisma/migrations/20260810133000_publish_initial_world_bibles/migration.sql new file mode 100644 index 0000000..7851ea0 --- /dev/null +++ b/apps/api/prisma/migrations/20260810133000_publish_initial_world_bibles/migration.sql @@ -0,0 +1,4 @@ +UPDATE "WorldBibleVersion" +SET "publishedAt" = COALESCE("publishedAt", "createdAt") +WHERE "versionNumber" = 1 + AND "publishedAt" IS NULL; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 46e369c..7e33a9b 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -29,10 +29,21 @@ enum LoreStatus { enum ProposalType { ADD_LORE + ADD_STORY UPDATE_LORE UPDATE_WORLD_BIBLE } +enum ContributionKind { + LORE + STORY +} + +enum ContributionStatus { + DRAFT + SUBMITTED +} + enum ProposalStatus { DRAFT SUBMITTED @@ -129,6 +140,11 @@ enum WorldAuditAction { MODERATION_ACTION CANON_STATUS_EXECUTED AI_WARNING_RESOLVED + CONTRIBUTION_CREATED + CONTRIBUTION_UPDATED + CONTRIBUTION_DELETED + CONTRIBUTION_SUBMITTED + PROPOSAL_CREATED } enum AuthProvider { @@ -168,6 +184,7 @@ model User { bibleVersions WorldBibleVersion[] @relation("WorldBibleVersionCreator") loreEntries LoreEntry[] @relation("LoreEntryAuthor") proposals Proposal[] @relation("ProposalAuthor") + contributionDrafts ContributionDraft[] @relation("ContributionDraftAuthor") votes AppVote[] @relation("AppVoteVoter") moderationReports ModerationReport[] @relation("ModerationReporter") moderationReviews ModerationReport[] @relation("ModerationReviewer") @@ -296,27 +313,49 @@ model RcDelegation { } model World { - id String @id @default(cuid()) - slug String @unique - title String - description String - founderId String - founder User @relation("WorldFounder", fields: [founderId], references: [id], onDelete: Restrict) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - bibleVersions WorldBibleVersion[] - loreEntries LoreEntry[] - loreRelationships LoreRelationship[] - proposals Proposal[] - moderationReports ModerationReport[] - searchIndexes SearchIndex[] - memberships WorldMembership[] - auditLogs WorldAuditLog[] + id String @id @default(cuid()) + slug String @unique + title String + description String + founderId String + founder User @relation("WorldFounder", fields: [founderId], references: [id], onDelete: Restrict) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + seed WorldSeed? + bibleVersions WorldBibleVersion[] + loreEntries LoreEntry[] + loreRelationships LoreRelationship[] + proposals Proposal[] + contributionDrafts ContributionDraft[] + moderationReports ModerationReport[] + searchIndexes SearchIndex[] + memberships WorldMembership[] + auditLogs WorldAuditLog[] @@index([founderId]) @@index([createdAt]) } +model WorldSeed { + id String @id @default(cuid()) + worldId String @unique + world World @relation(fields: [worldId], references: [id], onDelete: Restrict) + premise String + genre String + tone String + mainConflict String + startingLocation String? + firstCharacters Json? + firstFactions Json? + firstHistoricalEvent String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + /// Mutable off-chain world seed. Hive publication requires a future approved publication flow. + + @@index([genre]) + @@index([tone]) +} + model WorldMembership { id String @id @default(cuid()) worldId String @@ -380,26 +419,27 @@ model WorldBibleVersion { } model LoreEntry { - id String @id @default(cuid()) - worldId String - world World @relation(fields: [worldId], references: [id], onDelete: Restrict) - authorId String - author User @relation("LoreEntryAuthor", fields: [authorId], references: [id], onDelete: Restrict) - title String - slug String - loreType LoreType - content Json - status LoreStatus @default(DRAFT) - publishedAt DateTime? - hiveReferenceId String? @unique + id String @id @default(cuid()) + worldId String + world World @relation(fields: [worldId], references: [id], onDelete: Restrict) + authorId String + author User @relation("LoreEntryAuthor", fields: [authorId], references: [id], onDelete: Restrict) + title String + slug String + loreType LoreType + content Json + status LoreStatus @default(DRAFT) + publishedAt DateTime? + hiveReferenceId String? @unique /// Published canon must be confirmed through an indexed HiveReference, not only this mutable row. - hiveReference HiveReference? @relation(fields: [hiveReferenceId], references: [id], onDelete: SetNull) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - outgoingRelations LoreRelationship[] @relation("LoreRelationshipSource") - incomingRelations LoreRelationship[] @relation("LoreRelationshipTarget") - targetProposals Proposal[] @relation("ProposalTargetLoreEntry") - resultingProposals Proposal[] @relation("ProposalResultingLoreEntry") + hiveReference HiveReference? @relation(fields: [hiveReferenceId], references: [id], onDelete: SetNull) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + outgoingRelations LoreRelationship[] @relation("LoreRelationshipSource") + incomingRelations LoreRelationship[] @relation("LoreRelationshipTarget") + targetProposals Proposal[] @relation("ProposalTargetLoreEntry") + targetContributionDrafts ContributionDraft[] @relation("ContributionDraftTargetLoreEntry") + resultingProposals Proposal[] @relation("ProposalResultingLoreEntry") @@unique([worldId, slug]) @@index([worldId, status]) @@ -434,6 +474,7 @@ model Proposal { authorId String author User @relation("ProposalAuthor", fields: [authorId], references: [id], onDelete: Restrict) proposalType ProposalType + contributionKind ContributionKind? status ProposalStatus @default(DRAFT) title String summary String @@ -451,12 +492,41 @@ model Proposal { publishedAt DateTime? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt + contributionDraft ContributionDraft? votes AppVote[] aiReports AIReport[] moderationReports ModerationReport[] @@index([worldId, status]) @@index([authorId]) + @@index([worldId, contributionKind]) + @@index([targetLoreEntryId]) +} + +model ContributionDraft { + id String @id @default(cuid()) + worldId String + world World @relation(fields: [worldId], references: [id], onDelete: Cascade) + authorId String + author User @relation("ContributionDraftAuthor", fields: [authorId], references: [id], onDelete: Cascade) + targetLoreEntryId String? + targetLoreEntry LoreEntry? @relation("ContributionDraftTargetLoreEntry", fields: [targetLoreEntryId], references: [id], onDelete: SetNull) + kind ContributionKind + title String + summary String? + content Json + status ContributionStatus @default(DRAFT) + proposalId String? @unique + /// Submitted drafts are locked; Proposal.proposedContent stores the immutable reviewer snapshot. + proposal Proposal? @relation(fields: [proposalId], references: [id], onDelete: SetNull) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + submittedAt DateTime? + + @@index([worldId]) + @@index([authorId]) + @@index([worldId, status]) + @@index([worldId, authorId, status]) @@index([targetLoreEntryId]) } diff --git a/apps/api/prisma/seed.ts b/apps/api/prisma/seed.ts index 2da8d6a..b01a451 100644 --- a/apps/api/prisma/seed.ts +++ b/apps/api/prisma/seed.ts @@ -3,6 +3,7 @@ import 'dotenv/config'; import { PrismaPg } from '@prisma/adapter-pg'; import { PrismaClient } from '../src/generated/prisma/client.js'; +import { WorldRole } from '../src/generated/prisma/enums.js'; import { ensureActiveFounderMembership } from '../src/lib/founder-memberships.js'; const databaseUrl = process.env.DATABASE_URL; @@ -75,6 +76,51 @@ async function main() { userId: founder.id, }); + await prisma.worldMembership.upsert({ + where: { + worldId_userId: { + worldId: world.id, + userId: contributor.id, + }, + }, + update: { + role: WorldRole.CONTRIBUTOR, + grantedById: founder.id, + revokedAt: null, + }, + create: { + worldId: world.id, + userId: contributor.id, + role: WorldRole.CONTRIBUTOR, + grantedById: founder.id, + }, + }); + + await prisma.worldSeed.upsert({ + where: { + worldId: world.id, + }, + update: { + genre: 'Fantasy', + mainConflict: 'Weather, memory, and craft compete to define what the islands remember.', + premise: + 'A fictional chain of floating islands where memory, weather, and craft are tightly intertwined.', + tone: 'Luminous mystery', + }, + create: { + worldId: world.id, + premise: + 'A fictional chain of floating islands where memory, weather, and craft are tightly intertwined.', + genre: 'Fantasy', + tone: 'Luminous mystery', + mainConflict: 'Weather, memory, and craft compete to define what the islands remember.', + startingLocation: 'Mirror Lighthouse', + firstCharacters: ['Ember Quill', 'Mira Vale'], + firstFactions: ['Cartographers Guild'], + firstHistoricalEvent: 'The first reflected storm appears before its clouds arrive.', + }, + }); + await prisma.worldBibleVersion.upsert({ where: { worldId_versionNumber: { @@ -98,6 +144,7 @@ async function main() { }, ], }, + publishedAt: new Date(), }, create: { worldId: world.id, @@ -118,6 +165,7 @@ async function main() { }, ], }, + publishedAt: new Date(), }, }); @@ -245,6 +293,68 @@ async function main() { }, }); + await prisma.contributionDraft.upsert({ + where: { + id: 'dev-contribution-glass-archipelago-001', + }, + update: { + kind: 'LORE', + title: 'Draft: Lighthouse Keeper Oath', + summary: 'Development-only structured contribution draft.', + content: { + type: 'doc', + content: [ + { + type: 'heading', + attrs: { level: 1 }, + content: [{ type: 'text', text: 'Lighthouse Keeper Oath' }], + }, + { + type: 'paragraph', + content: [ + { + type: 'text', + text: 'Keepers swear to report reflected storms before dawn bells sound.', + }, + ], + }, + ], + }, + status: 'DRAFT', + proposalId: null, + submittedAt: null, + targetLoreEntryId: lighthouse.id, + }, + create: { + id: 'dev-contribution-glass-archipelago-001', + worldId: world.id, + authorId: contributor.id, + kind: 'LORE', + title: 'Draft: Lighthouse Keeper Oath', + summary: 'Development-only structured contribution draft.', + content: { + type: 'doc', + content: [ + { + type: 'heading', + attrs: { level: 1 }, + content: [{ type: 'text', text: 'Lighthouse Keeper Oath' }], + }, + { + type: 'paragraph', + content: [ + { + type: 'text', + text: 'Keepers swear to report reflected storms before dawn bells sound.', + }, + ], + }, + ], + }, + targetLoreEntryId: lighthouse.id, + }, + }); + await prisma.aIReport.upsert({ where: { id: 'dev-ai-report-glass-archipelago-001' }, update: { diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index bc2bfbe..4745945 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -6,7 +6,9 @@ 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 { registerWorldRoutes } from './routes/worlds.js'; export async function buildApp() { const app = Fastify({ @@ -41,6 +43,8 @@ export async function buildApp() { await registerHealthRoute(app); await registerAuthRoutes(app); + await registerWorldRoutes(app); + await registerContributionRoutes(app); return app; } diff --git a/apps/api/src/lib/contributions.ts b/apps/api/src/lib/contributions.ts new file mode 100644 index 0000000..675109b --- /dev/null +++ b/apps/api/src/lib/contributions.ts @@ -0,0 +1,755 @@ +import type { Prisma, PrismaClient } from '../generated/prisma/client.js'; +import { + ContributionKind, + ContributionStatus, + ProposalStatus, + ProposalType, + WorldAuditAction, +} from '../generated/prisma/enums.js'; + +const STRUCTURED_DOCUMENT_MAX_BYTES = 100 * 1024; + +export class ContributionError extends Error { + constructor( + public readonly statusCode: number, + message: string, + ) { + super(message); + } +} + +export type ContributionDatabase = Pick< + PrismaClient, + | '$transaction' + | 'contributionDraft' + | 'loreEntry' + | 'proposal' + | 'world' + | 'worldAuditLog' + | 'worldMembership' +>; + +export type StructuredDocument = Prisma.InputJsonObject; + +export type CreateContributionInput = { + worldId: string; + authorId: string; + kind: ContributionKind; + title: string; + summary?: string | undefined; + targetLoreEntryId?: string | undefined; + content: unknown; +}; + +export type UpdateContributionInput = { + worldId: string; + contributionId: string; + authorId: string; + kind?: ContributionKind | undefined; + title?: string | undefined; + summary?: string | null | undefined; + targetLoreEntryId?: string | null | undefined; + content?: unknown; +}; + +export type ListContributionInput = { + worldId: string; + authorId: string; + status?: ContributionStatus | undefined; + kind?: ContributionKind | undefined; + page?: number | undefined; + pageSize?: number | undefined; +}; + +const contributionInclude = { + proposal: true, + targetLoreEntry: { + select: { + id: true, + loreType: true, + slug: true, + title: true, + worldId: true, + }, + }, +} as const; + +type ContributionWithRelations = Prisma.ContributionDraftGetPayload<{ + include: typeof contributionInclude; +}>; + +type ProposalRecord = Prisma.ProposalGetPayload>; + +function serializeDate(value: Date | null) { + return value?.toISOString() ?? null; +} + +export function serializeProposal(proposal: ProposalRecord | null) { + if (!proposal) { + return null; + } + + return { + approvedAt: serializeDate(proposal.approvedAt), + authorId: proposal.authorId, + contributionKind: proposal.contributionKind, + createdAt: proposal.createdAt.toISOString(), + id: proposal.id, + proposedContent: proposal.proposedContent, + proposalType: proposal.proposalType, + publishedAt: serializeDate(proposal.publishedAt), + rejectedAt: serializeDate(proposal.rejectedAt), + resultingBibleVersionId: proposal.resultingBibleVersionId, + resultingLoreEntryId: proposal.resultingLoreEntryId, + status: proposal.status, + submittedAt: serializeDate(proposal.submittedAt), + summary: proposal.summary, + targetLoreEntryId: proposal.targetLoreEntryId, + title: proposal.title, + updatedAt: proposal.updatedAt.toISOString(), + votingStartedAt: serializeDate(proposal.votingStartedAt), + worldId: proposal.worldId, + }; +} + +export function serializeContribution(contribution: ContributionWithRelations) { + return { + authorId: contribution.authorId, + content: contribution.content, + createdAt: contribution.createdAt.toISOString(), + id: contribution.id, + kind: contribution.kind, + proposal: serializeProposal(contribution.proposal), + proposalId: contribution.proposalId, + status: contribution.status, + submittedAt: serializeDate(contribution.submittedAt), + summary: contribution.summary, + targetLoreEntry: contribution.targetLoreEntry, + targetLoreEntryId: contribution.targetLoreEntryId, + title: contribution.title, + updatedAt: contribution.updatedAt.toISOString(), + worldId: contribution.worldId, + }; +} + +function isPlainObject(value: unknown): value is Record { + return ( + typeof value === 'object' && + value !== null && + !Array.isArray(value) && + (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null) + ); +} + +function assertJsonByteLimit(value: unknown) { + const serialized = JSON.stringify(value); + + if (!serialized || Buffer.byteLength(serialized, 'utf8') > STRUCTURED_DOCUMENT_MAX_BYTES) { + throw new ContributionError(400, 'Structured content exceeds the 100 KB limit.'); + } +} + +export function validateStructuredDocument(value: unknown): StructuredDocument { + assertJsonByteLimit(value); + + if (!isPlainObject(value)) { + throw new ContributionError(400, 'Structured content must be a JSON object.'); + } + + if (value.type !== 'doc') { + throw new ContributionError(400, 'Structured content root type must be "doc".'); + } + + if ('content' in value && !Array.isArray(value.content)) { + throw new ContributionError(400, 'Structured content root content must be an array.'); + } + + return value as StructuredDocument; +} + +export function hasMeaningfulText(value: unknown): boolean { + if (Array.isArray(value)) { + return value.some(hasMeaningfulText); + } + + if (!isPlainObject(value)) { + return false; + } + + if (typeof value.text === 'string' && value.text.trim().length > 0) { + return true; + } + + return Object.values(value).some(hasMeaningfulText); +} + +function normalizeTitle(title: string) { + const normalized = title.trim(); + + if (!normalized) { + throw new ContributionError(400, 'Contribution title is required.'); + } + + if (normalized.length > 200) { + throw new ContributionError(400, 'Contribution title must be at most 200 characters.'); + } + + return normalized; +} + +function normalizeSummary(summary: string | null | undefined) { + const normalized = summary?.trim() || null; + + if (normalized && normalized.length > 1_000) { + throw new ContributionError(400, 'Contribution summary must be at most 1000 characters.'); + } + + return normalized; +} + +async function assertWorldExists(database: Pick, worldId: string) { + const world = await database.world.findUnique({ + select: { + id: true, + }, + where: { + id: worldId, + }, + }); + + if (!world) { + throw new ContributionError(404, 'World not found.'); + } +} + +async function assertTargetLoreEntry( + database: Pick, + worldId: string, + targetLoreEntryId: string | null | undefined, +) { + if (!targetLoreEntryId) { + return null; + } + + const target = await database.loreEntry.findUnique({ + select: { + id: true, + worldId: true, + }, + where: { + id: targetLoreEntryId, + }, + }); + + if (!target) { + throw new ContributionError(404, 'Target lore entry not found.'); + } + + if (target.worldId !== worldId) { + throw new ContributionError(400, 'Target lore entry must belong to the same world.'); + } + + return target; +} + +function proposalTypeForContribution(kind: ContributionKind, hasTargetLoreEntry: boolean) { + if (kind === ContributionKind.STORY) { + return ProposalType.ADD_STORY; + } + + return hasTargetLoreEntry ? ProposalType.UPDATE_LORE : ProposalType.ADD_LORE; +} + +async function createContributionAuditLog( + database: Pick, + input: { + action: (typeof WorldAuditAction)[keyof typeof WorldAuditAction]; + actorId: string; + worldId: string; + targetId: string; + targetType: string; + metadata?: Prisma.InputJsonObject | undefined; + }, +) { + await database.worldAuditLog.create({ + data: { + action: input.action, + actorId: input.actorId, + metadata: input.metadata ?? {}, + targetId: input.targetId, + targetType: input.targetType, + worldId: input.worldId, + }, + }); +} + +export async function createContribution( + database: ContributionDatabase, + input: CreateContributionInput, +) { + const title = normalizeTitle(input.title); + const summary = normalizeSummary(input.summary); + const content = validateStructuredDocument(input.content); + + await assertWorldExists(database, input.worldId); + await assertTargetLoreEntry(database, input.worldId, input.targetLoreEntryId); + + const contribution = await database.$transaction(async (transaction) => { + const created = await transaction.contributionDraft.create({ + data: { + authorId: input.authorId, + content, + kind: input.kind, + summary, + targetLoreEntryId: input.targetLoreEntryId || null, + title, + worldId: input.worldId, + }, + include: contributionInclude, + }); + + await createContributionAuditLog(transaction, { + action: WorldAuditAction.CONTRIBUTION_CREATED, + actorId: input.authorId, + metadata: { + kind: input.kind, + targetLoreEntryId: input.targetLoreEntryId ?? null, + }, + targetId: created.id, + targetType: 'CONTRIBUTION_DRAFT', + worldId: input.worldId, + }); + + return created; + }); + + return serializeContribution(contribution); +} + +export async function listOwnedContributions( + database: ContributionDatabase, + input: ListContributionInput, +) { + const page = input.page ?? 1; + const pageSize = input.pageSize ?? 20; + const where: Prisma.ContributionDraftWhereInput = { + authorId: input.authorId, + ...(input.kind ? { kind: input.kind } : {}), + ...(input.status ? { status: input.status } : {}), + worldId: input.worldId, + }; + + const [contributions, total] = await Promise.all([ + database.contributionDraft.findMany({ + include: contributionInclude, + orderBy: [ + { + updatedAt: 'desc', + }, + { + id: 'desc', + }, + ], + skip: (page - 1) * pageSize, + take: pageSize, + where, + }), + database.contributionDraft.count({ + where, + }), + ]); + + return { + contributions: contributions.map(serializeContribution), + pagination: { + page, + pageSize, + total, + }, + }; +} + +async function resolveMissingOrLockedContribution( + database: Pick, + input: { + worldId: string; + contributionId: string; + authorId: string; + lockedMessage: string; + }, +) { + const current = await database.contributionDraft.findFirst({ + select: { + status: true, + }, + where: { + authorId: input.authorId, + id: input.contributionId, + worldId: input.worldId, + }, + }); + + if (!current) { + throw new ContributionError(404, 'Contribution not found.'); + } + + if (current.status !== ContributionStatus.DRAFT) { + throw new ContributionError(409, input.lockedMessage); + } + + throw new ContributionError(409, 'Concurrent contribution update conflict.'); +} + +async function findOwnedContributionOrThrow( + database: Pick, + input: { + worldId: string; + contributionId: string; + authorId: string; + }, +) { + const contribution = await database.contributionDraft.findFirst({ + include: contributionInclude, + where: { + authorId: input.authorId, + id: input.contributionId, + worldId: input.worldId, + }, + }); + + if (!contribution) { + throw new ContributionError(404, 'Contribution not found.'); + } + + return contribution; +} + +export async function getOwnedContribution( + database: ContributionDatabase, + input: { + worldId: string; + contributionId: string; + authorId: string; + }, +) { + const contribution = await database.contributionDraft.findFirst({ + include: contributionInclude, + where: { + authorId: input.authorId, + id: input.contributionId, + worldId: input.worldId, + }, + }); + + return contribution ? serializeContribution(contribution) : null; +} + +export async function updateContribution( + database: ContributionDatabase, + input: UpdateContributionInput, +) { + await findOwnedContributionOrThrow(database, input); + + const data: Prisma.ContributionDraftUpdateManyMutationInput = {}; + const changedFields: string[] = []; + let targetLoreEntryId: string | null | undefined; + + if (input.kind) { + data.kind = input.kind; + changedFields.push('kind'); + } + + if (input.title !== undefined) { + data.title = normalizeTitle(input.title); + changedFields.push('title'); + } + + if (input.summary !== undefined) { + data.summary = normalizeSummary(input.summary); + changedFields.push('summary'); + } + + if (input.content !== undefined) { + data.content = validateStructuredDocument(input.content); + changedFields.push('content'); + } + + if (input.targetLoreEntryId !== undefined) { + await assertTargetLoreEntry(database, input.worldId, input.targetLoreEntryId); + targetLoreEntryId = input.targetLoreEntryId; + changedFields.push('targetLoreEntryId'); + } + + if (Object.keys(data).length === 0) { + data.updatedAt = new Date(); + } + + const contribution = await database.$transaction(async (transaction) => { + const updated = await transaction.contributionDraft.updateMany({ + data, + where: { + authorId: input.authorId, + id: input.contributionId, + status: ContributionStatus.DRAFT, + worldId: input.worldId, + }, + }); + + if (updated.count !== 1) { + await resolveMissingOrLockedContribution(transaction, { + ...input, + lockedMessage: 'Submitted contributions cannot be edited.', + }); + } + + if (targetLoreEntryId !== undefined) { + await transaction.contributionDraft.update({ + data: { + targetLoreEntry: + targetLoreEntryId === null + ? { + disconnect: true, + } + : { + connect: { + id: targetLoreEntryId, + }, + }, + }, + where: { + id: input.contributionId, + }, + }); + } + + const current = await transaction.contributionDraft.findFirst({ + include: contributionInclude, + where: { + authorId: input.authorId, + id: input.contributionId, + worldId: input.worldId, + }, + }); + + if (!current) { + throw new ContributionError(404, 'Contribution not found.'); + } + + await createContributionAuditLog(transaction, { + action: WorldAuditAction.CONTRIBUTION_UPDATED, + actorId: input.authorId, + metadata: { + changedFields: changedFields.sort(), + }, + targetId: current.id, + targetType: 'CONTRIBUTION_DRAFT', + worldId: input.worldId, + }); + + return current; + }); + + return serializeContribution(contribution); +} + +export async function deleteContribution( + database: ContributionDatabase, + input: { + worldId: string; + contributionId: string; + authorId: string; + }, +) { + await database.$transaction(async (transaction) => { + const existing = await transaction.contributionDraft.findFirst({ + where: { + authorId: input.authorId, + id: input.contributionId, + worldId: input.worldId, + }, + }); + + if (!existing) { + throw new ContributionError(404, 'Contribution not found.'); + } + + if (existing.status !== ContributionStatus.DRAFT) { + throw new ContributionError(409, 'Submitted contributions cannot be deleted.'); + } + + const deleted = await transaction.contributionDraft.deleteMany({ + where: { + authorId: input.authorId, + id: input.contributionId, + status: ContributionStatus.DRAFT, + worldId: input.worldId, + }, + }); + + if (deleted.count !== 1) { + await resolveMissingOrLockedContribution(transaction, { + ...input, + lockedMessage: 'Submitted contributions cannot be deleted.', + }); + } + + await createContributionAuditLog(transaction, { + action: WorldAuditAction.CONTRIBUTION_DELETED, + actorId: input.authorId, + metadata: { + kind: existing.kind, + targetLoreEntryId: existing.targetLoreEntryId, + }, + targetId: existing.id, + targetType: 'CONTRIBUTION_DRAFT', + worldId: input.worldId, + }); + }); +} + +export async function submitContribution( + database: ContributionDatabase, + input: { + worldId: string; + contributionId: string; + authorId: string; + }, +) { + const result = await database.$transaction(async (transaction) => { + const existing = await transaction.contributionDraft.findFirst({ + include: contributionInclude, + where: { + authorId: input.authorId, + id: input.contributionId, + worldId: input.worldId, + }, + }); + + if (!existing) { + throw new ContributionError(404, 'Contribution not found.'); + } + + if (existing.status !== ContributionStatus.DRAFT) { + if (existing.proposal) { + return { + alreadySubmitted: true, + contribution: existing, + proposal: existing.proposal, + }; + } + + throw new ContributionError(409, 'Contribution already submitted.'); + } + + normalizeTitle(existing.title); + const structuredContent = validateStructuredDocument(existing.content); + + if (!hasMeaningfulText(existing.content)) { + throw new ContributionError(400, 'Contribution content must include meaningful text.'); + } + + await assertWorldExists(transaction, input.worldId); + await assertTargetLoreEntry(transaction, input.worldId, existing.targetLoreEntryId); + + const submittedAt = new Date(); + const transition = await transaction.contributionDraft.updateMany({ + data: { + status: ContributionStatus.SUBMITTED, + submittedAt, + }, + where: { + authorId: input.authorId, + id: input.contributionId, + status: ContributionStatus.DRAFT, + worldId: input.worldId, + }, + }); + + if (transition.count !== 1) { + const submitted = await transaction.contributionDraft.findFirst({ + include: contributionInclude, + where: { + authorId: input.authorId, + id: input.contributionId, + worldId: input.worldId, + }, + }); + + if (submitted?.proposal) { + return { + alreadySubmitted: true, + contribution: submitted, + proposal: submitted.proposal, + }; + } + + throw new ContributionError(409, 'Concurrent submission conflict.'); + } + + // Proposal.proposedContent is the immutable reviewer snapshot; submitted + // drafts are locked so draft endpoints cannot mutate active voting content. + const proposal = await transaction.proposal.create({ + data: { + authorId: input.authorId, + contributionKind: existing.kind, + proposedContent: structuredContent, + proposalType: proposalTypeForContribution( + existing.kind, + Boolean(existing.targetLoreEntryId), + ), + status: ProposalStatus.SUBMITTED, + submittedAt, + summary: existing.summary ?? '', + targetLoreEntryId: existing.targetLoreEntryId, + title: existing.title, + worldId: input.worldId, + }, + }); + + const contribution = await transaction.contributionDraft.update({ + data: { + proposalId: proposal.id, + }, + include: contributionInclude, + where: { + id: existing.id, + }, + }); + + await createContributionAuditLog(transaction, { + action: WorldAuditAction.PROPOSAL_CREATED, + actorId: input.authorId, + metadata: { + contributionId: contribution.id, + contributionKind: contribution.kind, + proposalType: proposal.proposalType, + }, + targetId: proposal.id, + targetType: 'PROPOSAL', + worldId: input.worldId, + }); + + await createContributionAuditLog(transaction, { + action: WorldAuditAction.CONTRIBUTION_SUBMITTED, + actorId: input.authorId, + metadata: { + proposalId: proposal.id, + }, + targetId: contribution.id, + targetType: 'CONTRIBUTION_DRAFT', + worldId: input.worldId, + }); + + return { + alreadySubmitted: false, + contribution, + proposal, + }; + }); + + return { + alreadySubmitted: result.alreadySubmitted, + contribution: serializeContribution(result.contribution), + proposal: serializeProposal(result.proposal), + }; +} diff --git a/apps/api/src/lib/worlds.ts b/apps/api/src/lib/worlds.ts new file mode 100644 index 0000000..c2f5449 --- /dev/null +++ b/apps/api/src/lib/worlds.ts @@ -0,0 +1,555 @@ +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; + +const publicWorldInclude = { + ...worldInclude, + bibleVersions: { + orderBy: { + versionNumber: 'desc', + }, + take: 1, + where: { + publishedAt: { + not: null, + }, + }, + }, +} as const; + +type WorldWithRelations = { + bibleVersions: Array<{ + changeSummary: string | null; + content: Prisma.JsonValue; + createdAt: Date; + creatorId: string; + hiveReferenceId: string | null; + id: string; + publishedAt: Date | null; + updatedAt: Date; + versionNumber: number; + worldId: string; + }>; + createdAt: Date; + description: string; + founder: { + avatarUrl: string | null; + displayName: string | null; + hiveUsername: string; + id: string; + normalizedHiveUsername: string; + }; + founderId: string; + id: string; + seed: { + createdAt: Date; + firstCharacters: Prisma.JsonValue; + firstFactions: Prisma.JsonValue; + firstHistoricalEvent: string | null; + genre: string; + id: string; + mainConflict: string; + premise: string; + startingLocation: string | null; + tone: string; + updatedAt: Date; + worldId: string; + } | null; + slug: string; + title: string; + updatedAt: Date; +}; + +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 now = new Date(); + + 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, + publishedAt: now, + 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: publicWorldInclude, + 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: NonNullable['where']> = {}; + + 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: publicWorldInclude, + 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: publicWorldInclude, + 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, + publishedAt: currentBible.publishedAt ?? new Date(), + }, + where: { + id: currentBible.id, + }, + }); + } + + return transaction.world.findUniqueOrThrow({ + include: publicWorldInclude, + where: { + id: input.worldId, + }, + }); + }); + + return world ? serializeWorld(world) : null; +} diff --git a/apps/api/src/routes/contributions.test.ts b/apps/api/src/routes/contributions.test.ts new file mode 100644 index 0000000..a431811 --- /dev/null +++ b/apps/api/src/routes/contributions.test.ts @@ -0,0 +1,966 @@ +import assert from 'node:assert/strict'; +import { describe, test } from 'node:test'; + +import Fastify from 'fastify'; + +import { env } from '../config/env.js'; +import { + ContributionKind, + ContributionStatus, + PlatformRole, + ProposalStatus, + ProposalType, + WorldAuditAction, + WorldRole, +} from '../generated/prisma/enums.js'; +import { signAccessToken } from '../lib/auth-crypto.js'; +import { + createContribution, + deleteContribution, + submitContribution, + updateContribution, +} from '../lib/contributions.js'; +import { registerContributionRoutes } from './contributions.js'; + +const author = { + hiveUsername: 'mira-vale.dev', + id: 'user-author', + normalizedHiveUsername: 'mira-vale.dev', +}; + +const otherUser = { + hiveUsername: 'emberquill.dev', + id: 'user-other', + normalizedHiveUsername: 'emberquill.dev', +}; + +function authHeader(user = author) { + const token = signAccessToken( + { + hiveUsername: user.hiveUsername, + normalizedHiveUsername: user.normalizedHiveUsername, + platformRole: PlatformRole.USER, + sid: `session-${user.id}`, + sub: user.id, + }, + { + audience: env.AUTH_JWT_AUDIENCE, + issuer: env.AUTH_JWT_ISSUER, + secret: env.AUTH_JWT_SECRET, + ttlSeconds: 900, + }, + ); + + return { + authorization: `Bearer ${token}`, + }; +} + +const structuredDoc = { + type: 'doc', + content: [ + { + type: 'paragraph', + content: [{ type: 'text', text: 'The northern gate fell during the third winter.' }], + }, + ], +}; + +const emptyDoc = { + type: 'doc', + content: [ + { + type: 'paragraph', + content: [{ type: 'text', text: ' ' }], + }, + ], +}; + +type StoredContribution = { + id: string; + worldId: string; + authorId: string; + targetLoreEntryId: string | null; + kind: ContributionKind; + title: string; + summary: string | null; + content: unknown; + status: ContributionStatus; + proposalId: string | null; + createdAt: Date; + updatedAt: Date; + submittedAt: Date | null; +}; + +type StoredProposal = { + id: string; + worldId: string; + authorId: string; + proposalType: ProposalType; + contributionKind: ContributionKind; + status: ProposalStatus; + title: string; + summary: string; + proposedContent: unknown; + targetLoreEntryId: string | null; + resultingLoreEntryId: string | null; + resultingBibleVersionId: string | null; + submittedAt: Date | null; + votingStartedAt: Date | null; + approvedAt: Date | null; + rejectedAt: Date | null; + publishedAt: Date | null; + createdAt: Date; + updatedAt: Date; +}; + +function now() { + return new Date('2026-08-05T12:00:00.000Z'); +} + +function createContributionRecord(input: Partial = {}): StoredContribution { + return { + authorId: author.id, + content: structuredDoc, + createdAt: now(), + id: `contribution-${Math.random().toString(36).slice(2)}`, + kind: ContributionKind.LORE, + proposalId: null, + status: ContributionStatus.DRAFT, + submittedAt: null, + summary: 'A short summary.', + targetLoreEntryId: 'lore-1', + title: 'The Fall of the Northern Gate', + updatedAt: now(), + worldId: 'world-1', + ...input, + }; +} + +function createDatabase() { + const worlds = [{ id: 'world-1' }, { id: 'world-2' }]; + const loreEntries = [ + { + id: 'lore-1', + loreType: 'LOCATION', + slug: 'north-gate', + title: 'North Gate', + worldId: 'world-1', + }, + { + id: 'lore-2', + loreType: 'EVENT', + slug: 'other-world-event', + title: 'Other Event', + worldId: 'world-2', + }, + ]; + const memberships = [ + { + id: 'membership-author', + revokedAt: null, + role: WorldRole.CONTRIBUTOR, + userId: author.id, + worldId: 'world-1', + }, + { + id: 'membership-other', + revokedAt: null, + role: WorldRole.READER, + userId: otherUser.id, + worldId: 'world-1', + }, + ]; + const contributions: StoredContribution[] = []; + const proposals: StoredProposal[] = []; + const auditLogs: unknown[] = []; + let failNextProposalCreate = false; + let failNextAuditCreate = false; + let submitBeforeNextDeleteMany = false; + let submitBeforeNextUpdateMany = false; + let transactionQueue = Promise.resolve(); + const externallySubmittedContributionIds = new Set(); + + function includeContribution(contribution: StoredContribution) { + return { + ...contribution, + proposal: contribution.proposalId + ? (proposals.find((proposal) => proposal.id === contribution.proposalId) ?? null) + : null, + targetLoreEntry: contribution.targetLoreEntryId + ? (loreEntries.find((entry) => entry.id === contribution.targetLoreEntryId) ?? null) + : null, + }; + } + + const database: Record = {}; + + Object.assign(database, { + async $transaction(callback: (transaction: unknown) => Promise) { + const previousTransaction = transactionQueue; + let releaseTransaction!: () => void; + transactionQueue = new Promise((resolve) => { + releaseTransaction = resolve; + }); + + await previousTransaction; + + const contributionSnapshot = contributions.map((contribution) => ({ ...contribution })); + const proposalSnapshot = proposals.map((proposal) => ({ ...proposal })); + const auditSnapshot = [...auditLogs]; + + try { + return await callback(database); + } catch (error) { + contributions.splice(0, contributions.length, ...contributionSnapshot); + proposals.splice(0, proposals.length, ...proposalSnapshot); + auditLogs.splice(0, auditLogs.length, ...auditSnapshot); + + for (const contributionId of externallySubmittedContributionIds) { + const contribution = contributions.find((candidate) => candidate.id === contributionId); + + if (contribution) { + contribution.status = ContributionStatus.SUBMITTED; + contribution.submittedAt = now(); + } + } + + externallySubmittedContributionIds.clear(); + throw error; + } finally { + releaseTransaction(); + } + }, + refreshSession: { + async findUnique(args: { + select: { + expiresAt: true; + revokedAt: true; + userId: true; + }; + where: { + id: string; + }; + }) { + const userId = args.where.id.replace(/^session-/, ''); + + if (userId !== author.id && userId !== otherUser.id) { + return null; + } + + return { + expiresAt: new Date('2099-01-01T00:00:00.000Z'), + revokedAt: null, + userId, + }; + }, + }, + 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; + }, + }, + world: { + async findUnique(args: { where: { id: string } }) { + return worlds.find((world) => world.id === args.where.id) ?? null; + }, + }, + worldAuditLog: { + async create(args: { data: unknown }) { + if (failNextAuditCreate) { + failNextAuditCreate = false; + throw new Error('Injected audit failure.'); + } + + auditLogs.push(args.data); + return args.data; + }, + }, + worldMembership: { + async findUnique(args: { + where: { worldId_userId: { worldId: string; userId: string }; revokedAt: null }; + }) { + return ( + memberships.find( + (membership) => + membership.worldId === args.where.worldId_userId.worldId && + membership.userId === args.where.worldId_userId.userId && + !membership.revokedAt, + ) ?? null + ); + }, + }, + }); + + return { + auditLogs, + contributions, + database, + failNextProposalCreate() { + failNextProposalCreate = true; + }, + failNextAuditCreate() { + failNextAuditCreate = true; + }, + loreEntries, + memberships, + proposals, + submitBeforeNextDeleteMany() { + submitBeforeNextDeleteMany = true; + }, + submitBeforeNextUpdateMany() { + submitBeforeNextUpdateMany = true; + }, + }; +} + +async function createApp(database: ReturnType['database']) { + const app = Fastify(); + await registerContributionRoutes(app, { + database: database as never, + }); + return app; +} + +describe('contribution routes', () => { + test('authenticated contributor can create lore and story drafts with session-derived author', async () => { + const state = createDatabase(); + const app = await createApp(state.database); + + const loreResponse = await app.inject({ + headers: authHeader(), + method: 'POST', + payload: { + content: structuredDoc, + kind: ContributionKind.LORE, + targetLoreEntryId: 'lore-1', + title: ' The Fall of the Northern Gate ', + }, + url: '/worlds/world-1/contributions', + }); + const storyResponse = await app.inject({ + headers: authHeader(), + method: 'POST', + payload: { + content: structuredDoc, + kind: ContributionKind.STORY, + title: 'A Winter Road', + }, + url: '/worlds/world-1/contributions', + }); + const protectedFieldResponse = await app.inject({ + headers: authHeader(), + method: 'POST', + payload: { + authorId: otherUser.id, + content: structuredDoc, + kind: ContributionKind.STORY, + proposalId: 'proposal-injected', + title: 'A Winter Road', + }, + url: '/worlds/world-1/contributions', + }); + + assert.equal(loreResponse.statusCode, 201); + assert.equal(loreResponse.json().contribution.authorId, author.id); + assert.equal(loreResponse.json().contribution.title, 'The Fall of the Northern Gate'); + assert.equal(storyResponse.statusCode, 201); + assert.equal(storyResponse.json().contribution.kind, ContributionKind.STORY); + assert.equal(protectedFieldResponse.statusCode, 400); + assert.equal(state.contributions.length, 2); + await app.close(); + }); + + test('rejects unauthenticated users, users without world permission, invalid docs, oversized docs, missing content, missing worlds, and mismatched targets', async () => { + const state = createDatabase(); + const app = await createApp(state.database); + const oversizedDoc = { + type: 'doc', + content: [ + { + type: 'paragraph', + content: [{ type: 'text', text: 'x'.repeat(101 * 1024) }], + }, + ], + }; + + const unauthenticated = await app.inject({ + method: 'POST', + payload: { content: structuredDoc, kind: ContributionKind.LORE, title: 'Gate' }, + url: '/worlds/world-1/contributions', + }); + const forbidden = await app.inject({ + headers: authHeader(otherUser), + method: 'POST', + payload: { content: structuredDoc, kind: ContributionKind.LORE, title: 'Gate' }, + url: '/worlds/world-1/contributions', + }); + const invalidDoc = await app.inject({ + headers: authHeader(), + method: 'POST', + payload: { + content: { type: 'html', content: '

Nope

' }, + kind: ContributionKind.LORE, + title: 'Gate', + }, + url: '/worlds/world-1/contributions', + }); + const oversized = await app.inject({ + headers: authHeader(), + method: 'POST', + payload: { + content: oversizedDoc, + kind: ContributionKind.LORE, + title: 'Gate', + }, + url: '/worlds/world-1/contributions', + }); + const missingContent = await app.inject({ + headers: authHeader(), + method: 'POST', + payload: { + kind: ContributionKind.LORE, + title: 'Gate', + }, + url: '/worlds/world-1/contributions', + }); + const missingWorld = await app.inject({ + headers: authHeader(), + method: 'POST', + payload: { + content: structuredDoc, + kind: ContributionKind.LORE, + title: 'Gate', + }, + url: '/worlds/world-missing/contributions', + }); + const mismatchedTarget = await app.inject({ + headers: authHeader(), + method: 'POST', + payload: { + content: structuredDoc, + kind: ContributionKind.LORE, + targetLoreEntryId: 'lore-2', + title: 'Gate', + }, + url: '/worlds/world-1/contributions', + }); + + assert.equal(unauthenticated.statusCode, 401); + assert.equal(forbidden.statusCode, 403); + assert.equal(invalidDoc.statusCode, 400); + assert.equal(oversized.statusCode, 400); + assert.equal(missingContent.statusCode, 400); + assert.equal(missingWorld.statusCode, 404); + assert.equal(mismatchedTarget.statusCode, 400); + await app.close(); + }); + + test('author can list and read their draft while unauthorized routes do not reveal private data', async () => { + const state = createDatabase(); + state.memberships.find((membership) => membership.userId === otherUser.id)!.role = + WorldRole.CONTRIBUTOR; + state.memberships.push({ + id: 'membership-author-world-2', + revokedAt: null, + role: WorldRole.CONTRIBUTOR, + userId: author.id, + worldId: 'world-2', + }); + state.contributions.push(createContributionRecord({ id: 'draft-1' })); + state.contributions.push(createContributionRecord({ authorId: otherUser.id, id: 'draft-2' })); + const app = await createApp(state.database); + + const listResponse = await app.inject({ + headers: authHeader(), + method: 'GET', + url: '/worlds/world-1/contributions?status=DRAFT&kind=LORE&page=1&pageSize=1', + }); + const readResponse = await app.inject({ + headers: authHeader(), + method: 'GET', + url: '/worlds/world-1/contributions/draft-1', + }); + const otherReadResponse = await app.inject({ + headers: authHeader(otherUser), + method: 'GET', + url: '/worlds/world-1/contributions/draft-1', + }); + const wrongWorldResponse = await app.inject({ + headers: authHeader(), + method: 'GET', + url: '/worlds/world-2/contributions/draft-1', + }); + + assert.equal(listResponse.statusCode, 200); + assert.deepEqual(listResponse.json().pagination, { + page: 1, + pageSize: 1, + total: 1, + }); + assert.deepEqual( + listResponse.json().contributions.map((contribution: { id: string }) => contribution.id), + ['draft-1'], + ); + assert.equal(readResponse.statusCode, 200); + assert.equal(otherReadResponse.statusCode, 404); + assert.equal(wrongWorldResponse.statusCode, 404); + await app.close(); + }); + + test('author can update and delete draft, but protected fields and submitted rows are rejected', async () => { + const state = createDatabase(); + state.contributions.push(createContributionRecord({ id: 'draft-1' })); + state.contributions.push( + createContributionRecord({ + id: 'submitted-1', + proposalId: 'proposal-1', + status: ContributionStatus.SUBMITTED, + submittedAt: now(), + }), + ); + const app = await createApp(state.database); + + const updateResponse = await app.inject({ + headers: authHeader(), + method: 'PATCH', + payload: { + content: structuredDoc, + summary: 'Updated.', + targetLoreEntryId: null, + title: 'Updated Gate', + }, + url: '/worlds/world-1/contributions/draft-1', + }); + const protectedFieldResponse = await app.inject({ + headers: authHeader(), + method: 'PATCH', + payload: { + authorId: otherUser.id, + title: 'No', + }, + url: '/worlds/world-1/contributions/draft-1', + }); + const submittedUpdateResponse = await app.inject({ + headers: authHeader(), + method: 'PATCH', + payload: { title: 'No' }, + url: '/worlds/world-1/contributions/submitted-1', + }); + const invalidContentResponse = await app.inject({ + headers: authHeader(), + method: 'PATCH', + payload: { content: { type: 'doc', content: {} } }, + url: '/worlds/world-1/contributions/draft-1', + }); + const deleteResponse = await app.inject({ + headers: authHeader(), + method: 'DELETE', + url: '/worlds/world-1/contributions/draft-1', + }); + const submittedDeleteResponse = await app.inject({ + headers: authHeader(), + method: 'DELETE', + url: '/worlds/world-1/contributions/submitted-1', + }); + + assert.equal(updateResponse.statusCode, 200); + assert.equal(updateResponse.json().contribution.title, 'Updated Gate'); + assert.equal(updateResponse.json().contribution.targetLoreEntryId, null); + assert.equal(protectedFieldResponse.statusCode, 400); + assert.equal(submittedUpdateResponse.statusCode, 409); + assert.equal(invalidContentResponse.statusCode, 400); + assert.equal(deleteResponse.statusCode, 204); + assert.equal(submittedDeleteResponse.statusCode, 409); + await app.close(); + }); + + test('update and delete reject drafts that are submitted by a concurrent operation', async () => { + const updateState = createDatabase(); + updateState.contributions.push(createContributionRecord({ id: 'draft-update-race' })); + updateState.submitBeforeNextUpdateMany(); + + await assert.rejects( + updateContribution(updateState.database as never, { + authorId: author.id, + contributionId: 'draft-update-race', + title: 'Should Not Land', + worldId: 'world-1', + }), + (error: unknown) => + error instanceof Error && error.message === 'Submitted contributions cannot be edited.', + ); + + assert.equal(updateState.contributions[0]?.title, 'The Fall of the Northern Gate'); + assert.equal(updateState.contributions[0]?.status, ContributionStatus.SUBMITTED); + + const deleteState = createDatabase(); + deleteState.contributions.push(createContributionRecord({ id: 'draft-delete-race' })); + deleteState.submitBeforeNextDeleteMany(); + + await assert.rejects( + deleteContribution(deleteState.database as never, { + authorId: author.id, + contributionId: 'draft-delete-race', + worldId: 'world-1', + }), + (error: unknown) => + error instanceof Error && error.message === 'Submitted contributions cannot be deleted.', + ); + + assert.equal(deleteState.contributions.length, 1); + assert.equal(deleteState.contributions[0]?.status, ContributionStatus.SUBMITTED); + }); + + test('another user cannot update or delete an author private draft', async () => { + const state = createDatabase(); + state.memberships.find((membership) => membership.userId === otherUser.id)!.role = + WorldRole.CONTRIBUTOR; + state.contributions.push(createContributionRecord({ id: 'draft-1' })); + const app = await createApp(state.database); + + const updateResponse = await app.inject({ + headers: authHeader(otherUser), + method: 'PATCH', + payload: { title: 'No' }, + url: '/worlds/world-1/contributions/draft-1', + }); + const deleteResponse = await app.inject({ + headers: authHeader(otherUser), + method: 'DELETE', + url: '/worlds/world-1/contributions/draft-1', + }); + + assert.equal(updateResponse.statusCode, 404); + assert.equal(deleteResponse.statusCode, 404); + await app.close(); + }); + + test('valid draft submission creates exactly one proposal and locks immutable proposed content', async () => { + const state = createDatabase(); + state.contributions.push(createContributionRecord({ id: 'draft-1' })); + const app = await createApp(state.database); + + const submitResponse = await app.inject({ + headers: authHeader(), + method: 'POST', + url: '/worlds/world-1/contributions/draft-1/submit', + }); + const duplicateResponse = await app.inject({ + headers: authHeader(), + method: 'POST', + url: '/worlds/world-1/contributions/draft-1/submit', + }); + + assert.equal(submitResponse.statusCode, 201); + assert.equal(duplicateResponse.statusCode, 200); + assert.equal(state.proposals.length, 1); + assert.equal(state.contributions[0]?.status, ContributionStatus.SUBMITTED); + assert.equal(state.contributions[0]?.proposalId, 'proposal-1'); + assert.equal(submitResponse.json().proposal.authorId, author.id); + assert.equal(submitResponse.json().proposal.status, ProposalStatus.SUBMITTED); + assert.equal(submitResponse.json().proposal.proposalType, ProposalType.UPDATE_LORE); + assert.deepEqual(submitResponse.json().proposal.proposedContent, structuredDoc); + assert.equal( + state.auditLogs.some( + (entry) => + (entry as { action?: WorldAuditAction }).action === + WorldAuditAction.CONTRIBUTION_SUBMITTED, + ), + true, + ); + await app.close(); + }); + + test('create rollback leaves no contribution when audit logging fails', async () => { + const state = createDatabase(); + state.failNextAuditCreate(); + + await assert.rejects( + createContribution(state.database as never, { + authorId: author.id, + content: structuredDoc, + kind: ContributionKind.LORE, + title: 'Rollback Draft', + worldId: 'world-1', + }), + ); + + assert.equal(state.contributions.length, 0); + assert.equal(state.auditLogs.length, 0); + }); + + test('story draft submission creates an ADD_STORY proposal', async () => { + const state = createDatabase(); + state.contributions.push( + createContributionRecord({ + id: 'story-1', + kind: ContributionKind.STORY, + targetLoreEntryId: null, + }), + ); + const app = await createApp(state.database); + + const response = await app.inject({ + headers: authHeader(), + method: 'POST', + url: '/worlds/world-1/contributions/story-1/submit', + }); + + assert.equal(response.statusCode, 201); + assert.equal(response.json().proposal.proposalType, ProposalType.ADD_STORY); + assert.equal(response.json().proposal.contributionKind, ContributionKind.STORY); + await app.close(); + }); + + test('submission rejects empty content, missing submit permission, and mismatched target lore', async () => { + const state = createDatabase(); + state.contributions.push(createContributionRecord({ content: emptyDoc, id: 'empty-1' })); + state.contributions.push( + createContributionRecord({ id: 'mismatch-1', targetLoreEntryId: 'lore-2' }), + ); + const app = await createApp(state.database); + + const emptyResponse = await app.inject({ + headers: authHeader(), + method: 'POST', + url: '/worlds/world-1/contributions/empty-1/submit', + }); + const forbiddenResponse = await app.inject({ + headers: authHeader(otherUser), + method: 'POST', + url: '/worlds/world-1/contributions/empty-1/submit', + }); + const mismatchResponse = await app.inject({ + headers: authHeader(), + method: 'POST', + url: '/worlds/world-1/contributions/mismatch-1/submit', + }); + + assert.equal(emptyResponse.statusCode, 400); + assert.equal(forbiddenResponse.statusCode, 403); + assert.equal(mismatchResponse.statusCode, 400); + assert.equal(state.proposals.length, 0); + await app.close(); + }); + + test('concurrent submissions and rollback do not create duplicate or partial proposal state', async () => { + const concurrentState = createDatabase(); + concurrentState.contributions.push(createContributionRecord({ id: 'draft-1' })); + const [first, second] = await Promise.allSettled([ + submitContribution(concurrentState.database as never, { + authorId: author.id, + contributionId: 'draft-1', + worldId: 'world-1', + }), + submitContribution(concurrentState.database as never, { + authorId: author.id, + contributionId: 'draft-1', + worldId: 'world-1', + }), + ]); + + assert.equal(concurrentState.proposals.length, 1); + assert.equal( + [first, second].filter((result) => result.status === 'fulfilled').length >= 1, + true, + ); + + const rollbackState = createDatabase(); + rollbackState.contributions.push(createContributionRecord({ id: 'draft-rollback' })); + rollbackState.failNextProposalCreate(); + + await assert.rejects( + submitContribution(rollbackState.database as never, { + authorId: author.id, + contributionId: 'draft-rollback', + worldId: 'world-1', + }), + ); + + assert.equal(rollbackState.proposals.length, 0); + assert.equal(rollbackState.contributions[0]?.status, ContributionStatus.DRAFT); + assert.equal(rollbackState.contributions[0]?.proposalId, null); + }); +}); diff --git a/apps/api/src/routes/contributions.ts b/apps/api/src/routes/contributions.ts new file mode 100644 index 0000000..dc1cab1 --- /dev/null +++ b/apps/api/src/routes/contributions.ts @@ -0,0 +1,432 @@ +import type { FastifyInstance, FastifyReply } from 'fastify'; +import { z } from 'zod'; + +import { env } from '../config/env.js'; +import { ContributionKind, ContributionStatus } from '../generated/prisma/enums.js'; +import { requireSession } from '../lib/auth-middleware.js'; +import type { SessionVerificationDatabase } from '../lib/auth-sessions.js'; +import { + ContributionError, + createContribution, + deleteContribution, + getOwnedContribution, + listOwnedContributions, + submitContribution, + updateContribution, + type ContributionDatabase, +} from '../lib/contributions.js'; +import { prisma } from '../lib/prisma.js'; +import { authorizeWorldPermission } from '../lib/world-authorization.js'; +import { WORLD_PERMISSIONS } from '../lib/world-permissions.js'; +import type { WorldMembershipLookup } from '../lib/world-authorization.js'; + +const routeParamsSchema = z.object({ + contributionId: z.string().min(1).optional(), + worldId: z.string().min(1), +}); + +const contributionKindSchema = z.enum([ContributionKind.LORE, ContributionKind.STORY]); +const contributionStatusSchema = z.enum([ContributionStatus.DRAFT, ContributionStatus.SUBMITTED]); + +const createContributionBodySchema = z + .object({ + content: z.unknown(), + kind: contributionKindSchema, + summary: z.string().trim().max(1_000).optional(), + targetLoreEntryId: z.string().trim().min(1).optional(), + title: z.string().trim().min(1).max(200), + }) + .strict(); + +const updateContributionBodySchema = z + .object({ + content: z.unknown().optional(), + kind: contributionKindSchema.optional(), + summary: z.string().trim().max(1_000).nullable().optional(), + targetLoreEntryId: z.string().trim().min(1).nullable().optional(), + title: z.string().trim().min(1).max(200).optional(), + }) + .strict() + .refine((value) => Object.keys(value).length > 0, { + message: 'At least one editable contribution field is required.', + }); + +const listContributionsQuerySchema = z.object({ + kind: contributionKindSchema.optional(), + page: z.coerce.number().int().positive().default(1), + pageSize: z.coerce.number().int().positive().max(100).default(20), + status: contributionStatusSchema.optional(), +}); + +type RegisterContributionRoutesOptions = { + database?: ContributionDatabase & WorldMembershipLookup & SessionVerificationDatabase; +}; + +function authOptions(database: SessionVerificationDatabase) { + return { + audience: env.AUTH_JWT_AUDIENCE, + database, + issuer: env.AUTH_JWT_ISSUER, + jwtSecret: env.AUTH_JWT_SECRET, + }; +} + +function handleContributionError(error: unknown, reply: FastifyReply) { + if (error instanceof ContributionError) { + return reply.code(error.statusCode).send({ + error: error.message, + }); + } + + throw error; +} + +async function ensureWorldExists( + database: Pick, + worldId: string, + reply: FastifyReply, +) { + const world = await database.world.findUnique({ + select: { + id: true, + }, + where: { + id: worldId, + }, + }); + + if (!world) { + await reply.code(404).send({ + error: 'World not found.', + }); + + return false; + } + + return true; +} + +export async function registerContributionRoutes( + app: FastifyInstance, + options: RegisterContributionRoutesOptions = {}, +) { + const database = options.database ?? prisma; + + app.post( + '/worlds/:worldId/contributions', + { + preHandler: requireSession(authOptions(database)), + }, + async (request, reply) => { + const params = routeParamsSchema.safeParse(request.params); + const body = createContributionBodySchema.safeParse(request.body); + + if (!params.success || !body.success) { + return reply.code(400).send({ + error: 'Invalid contribution payload.', + }); + } + + const worldExists = await ensureWorldExists(database, params.data.worldId, reply); + + if (!worldExists) { + return; + } + + const authorized = await authorizeWorldPermission( + request, + reply, + params.data.worldId, + WORLD_PERMISSIONS.CREATE_LORE_DRAFT, + database, + ); + + if (!authorized) { + return; + } + + if (!request.user) { + return reply.code(401).send({ + error: 'Authentication required.', + }); + } + + try { + const contribution = await createContribution(database, { + ...body.data, + authorId: request.user.id, + worldId: params.data.worldId, + }); + + return reply.code(201).send({ + contribution, + }); + } catch (error) { + return handleContributionError(error, reply); + } + }, + ); + + app.get( + '/worlds/:worldId/contributions', + { + preHandler: requireSession(authOptions(database)), + }, + async (request, reply) => { + const params = routeParamsSchema.safeParse(request.params); + const query = listContributionsQuerySchema.safeParse(request.query); + + if (!params.success || !query.success) { + return reply.code(400).send({ + error: 'Invalid contributions query.', + }); + } + + const worldExists = await ensureWorldExists(database, params.data.worldId, reply); + + if (!worldExists) { + return; + } + + const authorized = await authorizeWorldPermission( + request, + reply, + params.data.worldId, + WORLD_PERMISSIONS.CREATE_LORE_DRAFT, + database, + ); + + if (!authorized) { + return; + } + + if (!request.user) { + return reply.code(401).send({ + error: 'Authentication required.', + }); + } + + return listOwnedContributions(database, { + ...query.data, + authorId: request.user.id, + worldId: params.data.worldId, + }); + }, + ); + + app.get( + '/worlds/:worldId/contributions/:contributionId', + { + preHandler: requireSession(authOptions(database)), + }, + async (request, reply) => { + const params = routeParamsSchema.required().safeParse(request.params); + + if (!params.success) { + return reply.code(400).send({ + error: 'Invalid contribution route.', + }); + } + + const worldExists = await ensureWorldExists(database, params.data.worldId, reply); + + if (!worldExists) { + return; + } + + const authorized = await authorizeWorldPermission( + request, + reply, + params.data.worldId, + WORLD_PERMISSIONS.CREATE_LORE_DRAFT, + database, + ); + + if (!authorized) { + return; + } + + if (!request.user) { + return reply.code(401).send({ + error: 'Authentication required.', + }); + } + + const contribution = await getOwnedContribution(database, { + authorId: request.user.id, + contributionId: params.data.contributionId, + worldId: params.data.worldId, + }); + + if (!contribution) { + return reply.code(404).send({ + error: 'Contribution not found.', + }); + } + + return { + contribution, + }; + }, + ); + + app.patch( + '/worlds/:worldId/contributions/:contributionId', + { + preHandler: requireSession(authOptions(database)), + }, + async (request, reply) => { + const params = routeParamsSchema.required().safeParse(request.params); + const body = updateContributionBodySchema.safeParse(request.body); + + if (!params.success || !body.success) { + return reply.code(400).send({ + error: 'Invalid contribution payload.', + }); + } + + const worldExists = await ensureWorldExists(database, params.data.worldId, reply); + + if (!worldExists) { + return; + } + + const authorized = await authorizeWorldPermission( + request, + reply, + params.data.worldId, + WORLD_PERMISSIONS.EDIT_OWN_DRAFT, + database, + ); + + if (!authorized) { + return; + } + + if (!request.user) { + return reply.code(401).send({ + error: 'Authentication required.', + }); + } + + try { + const contribution = await updateContribution(database, { + ...body.data, + authorId: request.user.id, + contributionId: params.data.contributionId, + worldId: params.data.worldId, + }); + + return { + contribution, + }; + } catch (error) { + return handleContributionError(error, reply); + } + }, + ); + + app.delete( + '/worlds/:worldId/contributions/:contributionId', + { + preHandler: requireSession(authOptions(database)), + }, + async (request, reply) => { + const params = routeParamsSchema.required().safeParse(request.params); + + if (!params.success) { + return reply.code(400).send({ + error: 'Invalid contribution route.', + }); + } + + const worldExists = await ensureWorldExists(database, params.data.worldId, reply); + + if (!worldExists) { + return; + } + + const authorized = await authorizeWorldPermission( + request, + reply, + params.data.worldId, + WORLD_PERMISSIONS.EDIT_OWN_DRAFT, + database, + ); + + if (!authorized) { + return; + } + + if (!request.user) { + return reply.code(401).send({ + error: 'Authentication required.', + }); + } + + try { + await deleteContribution(database, { + authorId: request.user.id, + contributionId: params.data.contributionId, + worldId: params.data.worldId, + }); + + return reply.code(204).send(); + } catch (error) { + return handleContributionError(error, reply); + } + }, + ); + + app.post( + '/worlds/:worldId/contributions/:contributionId/submit', + { + preHandler: requireSession(authOptions(database)), + }, + async (request, reply) => { + const params = routeParamsSchema.required().safeParse(request.params); + + if (!params.success) { + return reply.code(400).send({ + error: 'Invalid contribution route.', + }); + } + + const worldExists = await ensureWorldExists(database, params.data.worldId, reply); + + if (!worldExists) { + return; + } + + const authorized = await authorizeWorldPermission( + request, + reply, + params.data.worldId, + WORLD_PERMISSIONS.SUBMIT_PROPOSAL, + database, + ); + + if (!authorized) { + return; + } + + if (!request.user) { + return reply.code(401).send({ + error: 'Authentication required.', + }); + } + + try { + const result = await submitContribution(database, { + authorId: request.user.id, + contributionId: params.data.contributionId, + worldId: params.data.worldId, + }); + + return reply.code(result.alreadySubmitted ? 200 : 201).send(result); + } catch (error) { + return handleContributionError(error, reply); + } + }, + ); +} diff --git a/apps/api/src/routes/worlds.test.ts b/apps/api/src/routes/worlds.test.ts new file mode 100644 index 0000000..760a72e --- /dev/null +++ b/apps/api/src/routes/worlds.test.ts @@ -0,0 +1,696 @@ +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, + args: { include?: { bibleVersions?: { where?: { publishedAt?: { not?: null } } } } } = {}, + ) { + const publishedOnly = args.include?.bibleVersions?.where?.publishedAt?.not === null; + + return { + ...world, + bibleVersions: bibleVersions + .filter( + (version) => + version.worldId === world.id && (!publishedOnly || version.publishedAt !== null), + ) + .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: { + select: { + expiresAt: true; + revokedAt: true; + userId: true; + }; + where: { + id: string; + }; + }) { + const userId = args.where.id.replace(/^session-/, ''); + + if (userId !== founder.id && userId !== contributor.id) { + return null; + } + + return { + expiresAt: new Date('2099-01-01T00:00:00.000Z'), + 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: { + include?: { bibleVersions?: { where?: { publishedAt?: { not?: null } } } }; + take: number; + skip: number; + }) { + return worlds + .slice(args.skip, args.skip + args.take) + .map((world) => includeWorld(world, args)); + }, + async findUnique(args: { + include?: { bibleVersions?: { where?: { publishedAt?: { not?: null } } } }; + 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, args) : null; + }, + async findUniqueOrThrow(args: { + include?: { bibleVersions?: { where?: { publishedAt?: { not?: null } } } }; + where: { id: string }; + }) { + const world = worlds.find((candidate) => candidate.id === args.where.id); + + if (!world) { + throw new Error('Missing world.'); + } + + return includeWorld(world, args); + }, + 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; + publishedAt?: Date; + 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: args.data.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(typeof response.json().world.currentBibleVersion.publishedAt, 'string'); + assert.equal(state.worlds.length, 1); + assert.equal(state.seeds.length, 1); + assert.equal(state.bibleVersions.length, 1); + assert.notEqual(state.bibleVersions[0]?.publishedAt, null); + 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(listResponse.json().worlds[0].currentBibleVersion.versionNumber, 1); + assert.equal(getResponse.statusCode, 200); + assert.equal(getResponse.json().world.seed.mainConflict, worldPayload.seed.mainConflict); + assert.equal(getResponse.json().world.currentBibleVersion.versionNumber, 1); + assert.equal(hubResponse.statusCode, 200); + assert.equal(hubResponse.json().world.currentBibleVersion.versionNumber, 1); + 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('public world responses expose only published bible versions', async () => { + const state = createDatabase(); + const app = await createApp(state.database); + await app.inject({ + headers: authHeader(), + method: 'POST', + payload: worldPayload, + url: '/worlds', + }); + state.bibleVersions.push({ + changeSummary: 'Published bible.', + content: { + rules: ['Published canon only.'], + }, + createdAt: new Date('2026-08-05T00:04:00.000Z'), + creatorId: founder.id, + hiveReferenceId: 'hive-ref-1', + id: 'bible-2', + publishedAt: new Date('2026-08-05T00:05:00.000Z'), + updatedAt: new Date('2026-08-05T00:05:00.000Z'), + versionNumber: 2, + worldId: 'world-1', + }); + state.bibleVersions.push({ + changeSummary: 'Private draft.', + content: { + rules: ['Do not expose this draft.'], + }, + createdAt: new Date('2026-08-05T00:06:00.000Z'), + creatorId: founder.id, + hiveReferenceId: null, + id: 'bible-3', + publishedAt: null, + updatedAt: new Date('2026-08-05T00:06:00.000Z'), + versionNumber: 3, + worldId: 'world-1', + }); + + const hubResponse = await app.inject({ + method: 'GET', + url: '/worlds/world-1/hub', + }); + + assert.equal(hubResponse.statusCode, 200); + assert.equal(hubResponse.json().world.currentBibleVersion.versionNumber, 2); + assert.deepEqual(hubResponse.json().world.currentBibleVersion.content, { + rules: ['Published canon only.'], + }); + + 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(typeof response.json().world.currentBibleVersion.publishedAt, 'string'); + await app.close(); + }); + + test('rejects world updates without founder permissions', async () => { + const state = createDatabase(); + const app = await createApp(state.database); + await app.inject({ + headers: authHeader(), + method: 'POST', + payload: worldPayload, + url: '/worlds', + }); + + const response = await app.inject({ + headers: authHeader(contributor.id), + method: 'PATCH', + payload: { + title: 'Unauthorized', + }, + url: '/worlds/world-1', + }); + + assert.equal(response.statusCode, 403); + await app.close(); + }); +}); diff --git a/apps/api/src/routes/worlds.ts b/apps/api/src/routes/worlds.ts new file mode 100644 index 0000000..8244c2e --- /dev/null +++ b/apps/api/src/routes/worlds.ts @@ -0,0 +1,265 @@ +import type { FastifyInstance } from 'fastify'; +import { z } from 'zod'; + +import { env } from '../config/env.js'; +import type { Prisma } from '../generated/prisma/client.js'; +import { 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.', + }); + } + + if (!request.user) { + 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: request.user.id, + }); + + return reply.code(201).send({ + world, + }); + }, + ); + + app.get('/worlds', async (request, reply) => { + const query = listWorldsSchema.safeParse(request.query); + + if (!query.success) { + return reply.code(400).send({ + error: 'Invalid worlds query.', + }); + } + + return listWorlds(database, query.data); + }); + + app.get('/worlds/:worldId', async (request, reply) => { + const params = paramsSchema.safeParse(request.params); + + if (!params.success) { + return reply.code(400).send({ + error: 'Invalid world route.', + }); + } + + const world = await getWorld(database, params.data.worldId); + + if (!world) { + return reply.code(404).send({ + error: 'World not found.', + }); + } + + return { + world, + }; + }); + + app.get('/worlds/:worldId/hub', async (request, reply) => { + const params = paramsSchema.safeParse(request.params); + + if (!params.success) { + return reply.code(400).send({ + error: 'Invalid world route.', + }); + } + + const hub = await getWorldHub(database, params.data.worldId); + + if (!hub) { + return reply.code(404).send({ + error: 'World not found.', + }); + } + + return hub; + }); + + app.patch( + '/worlds/:worldId', + { + preHandler: requireSession(authOptions(database)), + }, + async (request, reply) => { + const params = paramsSchema.safeParse(request.params); + const body = updateWorldSchema.safeParse(request.body); + + if (!params.success || !body.success) { + return reply.code(400).send({ + error: 'Invalid world payload.', + }); + } + + const authorized = await authorizeWorldPermission( + request, + reply, + params.data.worldId, + WORLD_PERMISSIONS.EDIT_INITIAL_CANON, + database, + ); + + if (!authorized) { + return; + } + + const world = await updateWorld(database, { + ...(body.data.description ? { description: body.data.description } : {}), + ...(body.data.seed ? { seed: body.data.seed as Partial } : {}), + ...(body.data.title ? { title: body.data.title } : {}), + ...(body.data.bible + ? { + bible: { + ...body.data.bible, + content: body.data.bible.content, + }, + } + : {}), + worldId: params.data.worldId, + }); + + if (!world) { + return reply.code(404).send({ + error: 'World not found.', + }); + } + + return { + world, + }; + }, + ); +} diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css index 5d71e19..6fe585e 100644 --- a/apps/web/src/app/globals.css +++ b/apps/web/src/app/globals.css @@ -166,27 +166,99 @@ textarea { line-height: var(--leading-prose); } -.theme-switcher { - display: flex; +.theme-toggle { + display: inline-flex; + min-height: 2.5rem; align-items: center; - gap: 0.5rem; + justify-content: center; + gap: 0.125rem; + border: 1px solid var(--border); + border-radius: var(--radius-control); + background: var(--surface); + padding: 0.125rem; + box-shadow: var(--shadow-soft); } -.theme-switcher__label { - color: var(--muted-foreground); - font-size: 0.75rem; - font-weight: 700; - letter-spacing: var(--tracking-label); - text-transform: uppercase; +.theme-toggle__button { + display: inline-flex; + width: 2.125rem; + height: 2.125rem; + align-items: center; + justify-content: center; + border: 1px solid transparent; + border-radius: calc(var(--radius-control) - 0.125rem); + background: transparent; + color: var(--foreground); + transition: + background-color 160ms ease, + border-color 160ms ease, + color 160ms ease; } -.theme-switcher__select { - min-height: 2.25rem; - border: 1px solid var(--input-border); - border-radius: var(--radius-control); - background: var(--surface); - color: var(--foreground); - padding: 0 2rem 0 0.75rem; +.theme-toggle__button:hover { + background: var(--muted); +} + +.theme-toggle__button[aria-pressed='true'] { + border-color: var(--border); + background: var(--primary); + color: var(--primary-foreground); +} + +.theme-toggle__icon { + width: 1.25rem; + height: 1.25rem; +} + +.mobile-navigation-backdrop { + position: fixed; + inset: 4rem 0 0; + z-index: 70; + border: 0; + background: + linear-gradient(rgb(0 0 0 / 0.92), rgb(0 0 0 / 0.92)), + color-mix(in srgb, var(--background) 35%, black); + backdrop-filter: blur(8px); + cursor: default; + padding: 0; +} + +.mobile-navigation-panel { + position: fixed; + top: 4.75rem; + right: 1rem; + z-index: 80; + display: flex; + width: calc(100vw - 2rem); + max-width: 24rem; + transform-origin: top right; + flex-direction: column; + gap: 1.25rem; + border: 1px solid var(--border); + border-radius: var(--radius-panel); + background: color-mix(in srgb, var(--surface) 98%, transparent); + box-shadow: var(--shadow-elevated); + padding: 1rem; +} + +@keyframes mobile-overlay-in { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@keyframes mobile-menu-in { + from { + opacity: 0; + transform: translateY(-0.5rem) scale(0.98); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } } .hive-brand { @@ -221,3 +293,407 @@ textarea { padding: 0.4rem 0.7rem; text-transform: uppercase; } + +.auth-atlas-page { + position: relative; + width: 100vw; + height: calc(100svh - 8rem); + max-height: calc(100svh - 8rem); + margin-inline: calc(50% - 50vw); + overflow: hidden; + isolation: isolate; +} + +.auth-atlas-page__content { + position: relative; + z-index: 1; + height: 100%; + max-width: 72rem; + margin-inline: auto; + padding-inline: 1rem; + padding-block: clamp(1rem, 3vh, 2.25rem); +} + +@media (min-width: 640px) { + .auth-atlas-page__content { + padding-inline: 1.5rem; + } +} + +@media (min-width: 1024px) { + .auth-atlas-page__content { + padding-inline: 2rem; + } +} + +.auth-atlas-page__hero { + position: relative; + text-shadow: 0 1px 28px color-mix(in srgb, var(--background) 86%, transparent); +} + +.auth-atlas-page__form { + position: relative; + width: 100%; + max-width: 22rem; + justify-self: end; + background: linear-gradient( + 135deg, + color-mix(in srgb, var(--surface) 96%, transparent), + color-mix(in srgb, var(--surface-elevated) 88%, transparent) + ); + box-shadow: + 0 16px 38px rgb(0 0 0 / 0.18), + 0 0 0 1px color-mix(in srgb, var(--hive-red) 5%, transparent); +} + +.lore-atlas-background { + --atlas-parallax-x: 0; + --atlas-parallax-y: 0; + position: absolute; + inset: 0; + z-index: 0; + overflow: hidden; + pointer-events: none; + background: + radial-gradient( + ellipse at 27% 43%, + color-mix(in srgb, var(--hive-red) 12%, transparent) 0%, + transparent 31% + ), + radial-gradient(ellipse at 49% 34%, rgb(61 71 117 / 0.11) 0%, transparent 34%), + radial-gradient(ellipse at 78% 58%, rgb(24 42 78 / 0.12) 0%, transparent 35%), + linear-gradient( + 90deg, + color-mix(in srgb, var(--background) 90%, #07060a) 0%, + color-mix(in srgb, var(--background) 96%, #0b0d18) 54%, + var(--background) 100% + ); +} + +.lore-atlas-background::before, +.lore-atlas-background::after { + position: absolute; + inset: 0; + content: ''; +} + +.lore-atlas-background::before { + background: + linear-gradient( + 90deg, + transparent 0%, + transparent 50%, + color-mix(in srgb, var(--background) 58%, transparent) 73%, + color-mix(in srgb, var(--background) 88%, transparent) 100% + ), + radial-gradient(ellipse at center, transparent 44%, rgb(0 0 0 / 0.34) 100%); +} + +.lore-atlas-background::after { + background-image: + linear-gradient(color-mix(in srgb, var(--foreground) 5%, transparent) 1px, transparent 1px), + linear-gradient( + 90deg, + color-mix(in srgb, var(--foreground) 5%, transparent) 1px, + transparent 1px + ); + background-position: + calc(var(--atlas-parallax-x) * -10px) calc(var(--atlas-parallax-y) * -10px), + calc(var(--atlas-parallax-x) * -10px) calc(var(--atlas-parallax-y) * -10px); + background-size: 64px 64px; + mask-image: radial-gradient(circle at 32% 48%, black 0%, transparent 58%); + opacity: 0.16; +} + +:root[data-theme='dark'] .lore-atlas-background { + background: + radial-gradient(ellipse at 27% 43%, rgb(133 22 45 / 0.2) 0%, transparent 32%), + radial-gradient(ellipse at 49% 34%, rgb(46 55 103 / 0.16) 0%, transparent 34%), + radial-gradient(ellipse at 78% 58%, rgb(15 30 61 / 0.18) 0%, transparent 35%), + linear-gradient(90deg, #0a090d 0%, #111014 52%, #08080c 100%); +} + +.lore-atlas-background__hub { + position: absolute; + left: clamp(-3rem, 4vw, 4rem); + top: 16%; + width: clamp(17rem, 33vw, 31rem); + aspect-ratio: 1; + border: 1px solid color-mix(in srgb, var(--hive-red) 20%, transparent); + border-radius: 999px; + background: + radial-gradient( + circle, + color-mix(in srgb, var(--hive-red) 7%, transparent) 0%, + transparent 50% + ), + repeating-radial-gradient( + circle, + color-mix(in srgb, var(--foreground) 5%, transparent) 0 1px, + transparent 1px 2.4rem + ); + filter: blur(0.2px); + opacity: 0.44; + transform: translate( + calc(var(--atlas-parallax-x) * -18px), + calc(var(--atlas-parallax-y) * -12px) + ); +} + +.lore-atlas-background__map { + position: absolute; + inset: -15% auto auto -4rem; + width: min(82rem, 104vw); + height: 130%; + opacity: 0.62; + transform: translate(calc(var(--atlas-parallax-x) * 16px), calc(var(--atlas-parallax-y) * 10px)); +} + +.lore-atlas-background__contours path { + fill: none; + stroke: color-mix(in srgb, var(--foreground) 9%, transparent); + stroke-dasharray: 0.45 1.85; + stroke-linecap: round; + stroke-width: 0.18; +} + +.lore-atlas-background__coastlines path { + fill: color-mix(in srgb, #192132 12%, transparent); + stroke: color-mix(in srgb, var(--foreground) 13%, transparent); + stroke-linejoin: round; + stroke-width: 0.2; +} + +.lore-atlas-background__connections path { + fill: none; + stroke: color-mix(in srgb, var(--foreground) 15%, transparent); + stroke-linecap: round; + stroke-width: 0.18; +} + +.lore-atlas-background__signals path { + animation: lore-signal 13s linear infinite; + fill: none; + stroke: color-mix(in srgb, var(--hive-red) 36%, var(--foreground) 20%); + stroke-dasharray: 0.01 13; + stroke-linecap: round; + stroke-width: 0.28; +} + +.lore-atlas-background__node-glow { + fill: url('#atlas-node-glow'); + opacity: 0.18; +} + +.lore-atlas-background__node-core { + animation: lore-node-pulse 9s ease-in-out infinite; + fill: color-mix(in srgb, var(--hive-red) 58%, var(--foreground) 18%); + stroke: color-mix(in srgb, var(--surface) 72%, transparent); + stroke-width: 0.14; +} + +.lore-atlas-background__node.is-region .lore-atlas-background__node-glow { + fill: url('#atlas-cool-glow'); + opacity: 0.16; +} + +.lore-atlas-background__node.is-region .lore-atlas-background__node-core { + fill: color-mix(in srgb, #8ab4ff 52%, var(--foreground) 12%); +} + +.lore-atlas-background__node.is-lore .lore-atlas-background__node-core { + fill: color-mix(in srgb, var(--foreground) 62%, var(--hive-red) 13%); + opacity: 0.7; +} + +.lore-atlas-background__particles { + position: absolute; + inset: 0; + transform: translate(calc(var(--atlas-parallax-x) * -8px), calc(var(--atlas-parallax-y) * -6px)); +} + +.lore-atlas-background__particles span { + position: absolute; + width: 2px; + height: 2px; + animation: lore-particle-drift 18s ease-in-out infinite; + border-radius: 999px; + background: color-mix(in srgb, var(--foreground) 46%, transparent); + box-shadow: 0 0 8px color-mix(in srgb, #8ab4ff 16%, transparent); +} + +.lore-atlas-background__veil { + position: absolute; + inset: 0; + background: + linear-gradient( + 90deg, + transparent 0%, + transparent 50%, + color-mix(in srgb, var(--background) 88%, transparent) 74%, + var(--background) 100% + ), + linear-gradient( + 180deg, + var(--background) 0%, + transparent 12%, + transparent 78%, + var(--background) 100% + ); +} + +@keyframes lore-node-pulse { + 0%, + 100% { + opacity: 0.62; + transform: scale(1); + } + + 45% { + opacity: 0.9; + transform: scale(1.08); + } +} + +@keyframes lore-signal { + from { + stroke-dashoffset: 11; + opacity: 0; + } + + 20%, + 74% { + opacity: 0.72; + } + + to { + stroke-dashoffset: 0; + opacity: 0; + } +} + +@keyframes lore-particle-drift { + 0%, + 100% { + opacity: 0.18; + transform: translate3d(0, 0, 0); + } + + 50% { + opacity: 0.55; + transform: translate3d(var(--particle-distance), -18px, 0); + } +} + +@media (max-width: 1024px) { + .lore-atlas-background__map { + width: 74rem; + opacity: 0.48; + } + + .lore-atlas-background__particles span:nth-child(n + 6), + .lore-atlas-background__signals path:nth-child(n + 5) { + display: none; + } +} + +@media (max-height: 720px) and (min-width: 768px) { + .auth-atlas-page__content { + padding-block: 0.75rem; + } + + .auth-atlas-page__hero h1 { + font-size: clamp(2.35rem, 4.4vw, 3.25rem); + } + + .auth-atlas-page__hero .prose-text { + margin-top: 1rem; + } + + .auth-atlas-page__hero p:last-child { + margin-top: 1rem; + } + + .auth-atlas-page__form { + padding: 1rem; + } +} + +@media (max-width: 767px) { + .auth-atlas-page { + height: calc(100svh - 7rem); + max-height: calc(100svh - 7rem); + overflow: hidden; + } + + .auth-atlas-page__form { + max-width: none; + justify-self: stretch; + } + + .auth-atlas-page__content { + padding-block: 1.5rem; + } + + .lore-atlas-background { + inset: 0; + } + + .lore-atlas-background::before { + background: + linear-gradient(180deg, transparent 0%, rgb(0 0 0 / 0.38) 48%, rgb(0 0 0 / 0.66) 100%), + radial-gradient(ellipse at center, transparent 36%, rgb(0 0 0 / 0.45) 100%); + } + + .lore-atlas-background__hub { + left: -7rem; + top: 4%; + width: 22rem; + opacity: 0.26; + } + + .lore-atlas-background__map { + left: -17rem; + top: -1rem; + width: 58rem; + height: 115%; + opacity: 0.34; + } + + .lore-atlas-background__particles span:nth-child(n + 4), + .lore-atlas-background__signals path:nth-child(n + 3), + .lore-atlas-background__contours path:nth-child(n + 4) { + display: none; + } + + .lore-atlas-background__veil { + background: + linear-gradient( + 180deg, + transparent 0%, + color-mix(in srgb, var(--background) 76%, transparent) 44%, + var(--background) 100% + ), + radial-gradient(circle at 28% 24%, transparent 0%, var(--background) 72%); + } +} + +@media (prefers-reduced-motion: reduce) { + .lore-atlas-background, + .lore-atlas-background__hub, + .lore-atlas-background__map, + .lore-atlas-background__particles { + transform: none; + } + + .lore-atlas-background__node-core, + .lore-atlas-background__signals path, + .lore-atlas-background__particles span { + animation: none; + } + + .lore-atlas-background__signals path { + opacity: 0.16; + stroke-dasharray: 1 9; + } +} diff --git a/apps/web/src/app/login/page.tsx b/apps/web/src/app/login/page.tsx index 56adf88..9064205 100644 --- a/apps/web/src/app/login/page.tsx +++ b/apps/web/src/app/login/page.tsx @@ -1,4 +1,4 @@ -import Home from '../page'; +import Home from '@/app/page'; export default function LoginPage() { return ; diff --git a/apps/web/src/app/page.tsx b/apps/web/src/app/page.tsx index af50030..3cd3462 100644 --- a/apps/web/src/app/page.tsx +++ b/apps/web/src/app/page.tsx @@ -1,24 +1,21 @@ 'use client'; -import { useEffect, useMemo, useState } from 'react'; +import { useMemo, useState } from 'react'; -import { HiveBrand } from '@/components/hive-brand'; -import { ThemeSwitcher } from '@/components/theme-switcher'; +import { LoreAtlasBackground } from '@/components/auth/lore-atlas-background'; import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { createAuthChallenge, getMe, - logoutAuthSession, - refreshAuthSession, verifyAuthChallenge, type AuthChallengeResponse, type AuthProvider, - type SafeUser, } from '@/lib/api/auth'; import { ApiError } from '@/lib/api/errors'; import { env } from '@/lib/env'; +import { useAuthSession } from '@/providers/auth-session-provider'; type HiveKeychainResponse = { success: boolean; @@ -77,6 +74,29 @@ async function sha256Hex(value: string) { return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, '0')).join(''); } +function GoogleIcon() { + return ( + + ); +} + export default function Home() { const [challenge, setChallenge] = useState(null); const [error, setError] = useState(null); @@ -84,32 +104,13 @@ export default function Home() { const [manualSignature, setManualSignature] = useState(''); const [manualPublicKey, setManualPublicKey] = useState(''); const [provider, setProvider] = useState('keychain'); - const [user, setUser] = useState(null); const [username, setUsername] = useState(''); + const { clearSession, isSessionLoading, refreshSession, setSession, user } = useAuthSession(); - const canSubmit = useMemo(() => username.trim().length > 0 && !isLoading, [isLoading, username]); - - useEffect(() => { - let isMounted = true; - - refreshAuthSession() - .then((session) => { - if (!isMounted) { - return; - } - - setUser(session.user); - }) - .catch(() => { - if (isMounted) { - setUser(null); - } - }); - - return () => { - isMounted = false; - }; - }, []); + const canSubmit = useMemo( + () => username.trim().length > 0 && !isLoading && !isSessionLoading, + [isLoading, isSessionLoading, username], + ); async function completeLogin( nextChallenge: AuthChallengeResponse, @@ -125,7 +126,7 @@ export default function Home() { ...(publicKey ? { publicKey } : {}), }); - setUser(session.user); + setSession(session); setChallenge(null); setManualSignature(''); setManualPublicKey(''); @@ -185,11 +186,9 @@ export default function Home() { setIsLoading(true); try { - const session = await refreshAuthSession(); - setUser(session.user); + const session = await refreshSession(); await getMe(session.accessToken); } catch (nextError) { - setUser(null); setError(getErrorMessage(nextError)); } finally { setIsLoading(false); @@ -201,8 +200,7 @@ export default function Home() { setIsLoading(true); try { - await logoutAuthSession(); - setUser(null); + await clearSession(); } catch (nextError) { setError(getErrorMessage(nextError)); } finally { @@ -211,140 +209,144 @@ export default function Home() { } return ( -
-
-
-
- - HiveLore -
- -
- -
-
-

- Welcome back to HiveLore. -

-

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

-

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

-
- -
- {user ? ( -
-
-

Signed in as

-

@{user.hiveUsername}

-
-
- - -
+
+ +
+
+

+ Welcome back to HiveLore. +

+

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

+

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

+
+ +
+ {user ? ( +
+
+

Signed in as

+

@{user.hiveUsername}

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

- No password is stored by HiveLore. -

- )} - - {challenge ? ( -
-