import 'dotenv/config' import cors from 'cors' import cookieParser from 'cookie-parser' import express from 'express' import fs from 'node:fs' import path from 'node:path' import { fileURLToPath } from 'node:url' import multer from 'multer' import pg from 'pg' import type { CaseState } from '../src/types.js' import { authenticateJwt, createDevelopmentAdminToken, hasAdminClaim, requireAdmin, resolvePlayerName, resolveUserId, signPlayerToken } from './auth.js' import { createUserRepository } from './userRepository.js' import { submitCaseReport } from './caseReports.js' import { createLevelRepository } from './levelRepository.js' import { createEvidenceJudgeFromEnv } from './evidenceJudge.js' import { createNarrativeRepository } from './narrativeRepository.js' import { createTextExtractorFromEnv } from './ocr.js' import { createStoryGraphRepository, type StoryNodeType } from './storyGraphRepository.js' import { createObjectStorageFromEnv } from './objectStorage.js' const { Pool } = pg const databaseUrl = process.env.DATABASE_URL if (!databaseUrl) { console.error('DATABASE_URL is required. Run PostgreSQL and execute npm run migrate:up first.') process.exit(1) } export const pool = new Pool({ connectionString: databaseUrl }) const editingEnabled = process.env.LEVEL_EDITING_ENABLED === 'true' const objectStorage = createObjectStorageFromEnv() await objectStorage.initialize() const textExtractor = createTextExtractorFromEnv() const evidenceJudge = createEvidenceJudgeFromEnv() const levels = createLevelRepository(pool, editingEnabled, objectStorage, evidenceJudge) const narrative = createNarrativeRepository(pool, objectStorage) const storyGraph = createStoryGraphRepository(pool) const users = createUserRepository(pool) const AUTH_COOKIE = { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'lax' as const, path: '/', maxAge: 30 * 24 * 60 * 60 * 1000 } const STORY_NODE_TYPES: StoryNodeType[] = ['cutscene', 'dialogue', 'level', 'det_gate', 'llm_gate', 'merit', 'phone'] function wantsEdit(req: express.Request) { return editingEnabled && req.query.edit === '1' && hasAdminClaim(req) } // Identity guards for player-scoped routes. function requireUser(req: express.Request, res: express.Response): string | null { const userId = resolveUserId(req) if (!userId) { res.status(401).json({ error: 'Sign in required' }); return null } return userId } async function ownsPlaythroughOr403(req: express.Request, res: express.Response, playthroughId: string): Promise { const userId = requireUser(req, res) if (!userId) return null if (!await narrative.ownsPlaythrough(userId, playthroughId)) { res.status(403).json({ error: 'Not your playthrough' }); return null } return userId } function slug(value: unknown, fallback: string) { return String(value || fallback).trim().toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '') } export const app = express() app.disable('x-powered-by') app.use(cors({ origin: process.env.CORS_ORIGIN || true, credentials: true })) app.use(cookieParser()) app.use(authenticateJwt) app.use(express.json({ limit: '2mb' })) const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: Number(process.env.MAX_DOCUMENT_BYTES || 25 * 1024 * 1024), files: 1 }, }) app.get('/api/health', async (_req, res) => { try { await pool.query('SELECT 1'); res.json({ ok: true, database: 'connected', objectStorage: objectStorage.provider, textExtraction: textExtractor.provider, evidenceJudge: evidenceJudge.enabled ? evidenceJudge.provider : 'disabled', schema: 'osint', editingEnabled }) } catch { res.status(503).json({ ok: false, database: 'unavailable' }) } }) app.get('/api/session', (req, res) => res.json({ authenticated: Boolean(req.authClaims), isAdmin: hasAdminClaim(req), playerName:resolvePlayerName(req) })) // Player accounts (path A: GUPI issues the token). register/login set the auth_token // cookie; every game write then binds to this user via resolveUserId. app.post('/api/auth/register', async (req, res, next) => { try { const body = req.body || {} const result = await users.registerUser({ handle: String(body.handle || ''), password: String(body.password || ''), displayName: String(body.displayName || '') }) if (result.error || !result.user) return res.status(result.error === 'That handle is taken' ? 409 : 400).json({ error: result.error || 'Registration failed' }) res.cookie('auth_token', signPlayerToken(result.user), AUTH_COOKIE) res.status(201).json({ user: result.user }) } catch (error) { next(error) } }) app.post('/api/auth/login', async (req, res, next) => { try { const body = req.body || {} const user = await users.authenticateUser(String(body.handle || ''), String(body.password || '')) if (!user) return res.status(401).json({ error: 'Invalid handle or password' }) res.cookie('auth_token', signPlayerToken(user), AUTH_COOKIE) res.json({ user }) } catch (error) { next(error) } }) app.post('/api/auth/logout', (_req, res) => { res.clearCookie('auth_token', { path: '/' }); res.json({ ok: true }) }) app.get('/api/auth/me', async (req, res, next) => { try { const sub = req.authClaims?.sub const user = typeof sub === 'string' ? await users.getUser(sub) : null user ? res.json({ user }) : res.status(204).end() } catch (error) { next(error) } }) if (process.env.NODE_ENV !== 'production') app.get('/api/dev/admin-session', (req, res) => { const requestedReturn = String(req.query.returnTo || '/') const returnTo = requestedReturn.startsWith('/') && !requestedReturn.startsWith('//') ? requestedReturn : '/' res.cookie('auth_token', createDevelopmentAdminToken(), { httpOnly: true, sameSite: 'lax', path: '/', maxAge: 7 * 24 * 60 * 60 * 1000 }) res.redirect(returnTo) }) app.get('/api/levels', async (_req, res, next) => { try { res.json(await levels.listLevels()) } catch (error) { next(error) } }) app.get('/api/templates', async (_req, res, next) => { try { res.json(await levels.listTemplates()) } catch (error) { next(error) } }) app.post('/api/templates/:slug/levels', requireAdmin, async (req, res, next) => { try { if (!wantsEdit(req)) return res.status(403).json({ error: 'Level editing is disabled' }) const title = String(req.body?.title || '').trim() || undefined const levelSlug = slug(req.body?.id, `${req.params.slug}-${Date.now()}`) const level = await levels.instantiateTemplate(String(req.params.slug), { id: levelSlug, title, version: req.body?.version }) level ? res.status(201).json(level) : res.status(404).json({ error: 'Template version not found' }) } catch (error) { next(error) } }) app.post('/api/levels', requireAdmin, async (req, res, next) => { try { if (!editingEnabled) return res.status(403).json({ error: 'Level editing is disabled' }) const title = String(req.body?.title || 'Untitled Investigation').trim() const id = slug(req.body?.id, `level-${Date.now()}`) res.status(201).json(await levels.createLevel({ id, title, subtitle: String(req.body?.subtitle || '') })) } catch (error) { next(error) } }) app.post('/api/levels/:id/templates', requireAdmin, async (req, res, next) => { try { if (!wantsEdit(req)) return res.status(403).json({ error: 'Level editing is disabled' }) const name = String(req.body?.name || 'Untitled Template').trim() const templateSlug = slug(req.body?.slug, name) const template = await levels.saveLevelAsTemplate(String(req.params.id), { slug: templateSlug, name }) template ? res.status(201).json(template) : res.status(404).json({ error: 'Level not found' }) } catch (error) { next(error) } }) app.get('/api/assets/:id', async (req, res, next) => { try { const asset = await levels.getAsset(req.params.id) if (!asset) return res.status(404).json({ error: 'Asset not found' }) const inline = asset.mimeType === 'application/pdf' || asset.mimeType.startsWith('image/') || asset.mimeType.startsWith('text/') res.setHeader('Content-Type', asset.mimeType || 'application/octet-stream') res.setHeader('Content-Length', asset.byteSize) res.setHeader('Content-Disposition', `${inline ? 'inline' : 'attachment'}; filename*=UTF-8''${encodeURIComponent(asset.originalName)}`) res.setHeader('X-Content-Type-Options', 'nosniff') asset.stream.on('error', next) asset.stream.pipe(res) } catch (error) { next(error) } }) app.post('/api/levels/:id/documents', upload.single('file'), async (req, res, next) => { try { if (!req.file) return res.status(400).json({ error: 'A file is required' }) const extraction = await textExtractor.extract(req.file) const x = Number(req.body?.x); const y = Number(req.body?.y) const placement = Number.isFinite(x) && Number.isFinite(y) ? { x, y } : undefined const document = await levels.uploadDocument(String(req.params.id), req.file, extraction, placement) document ? res.status(201).json(document) : res.status(404).json({ error: 'Level not found' }) } catch (error) { next(error) } }) app.post('/api/levels/:id/documents/:documentId/judge', async (req, res, next) => { try { const levelUser = resolveUserId(req) if (!hasAdminClaim(req) && (!levelUser || !await narrative.ownsActiveLevel(levelUser, String(req.params.id)))) { return res.status(403).json({ error: 'This level is not active for the current player' }) } const result = await levels.judgeDocument(String(req.params.id), String(req.params.documentId)) result ? res.json(result) : res.status(404).json({ error: 'Level or document not found' }) } catch (error) { next(error) } }) app.post('/api/levels/:id/reveals/seen', async (req, res, next) => { try { const ids = Array.isArray(req.body?.documentIds) ? req.body.documentIds.map(String) : [] const acknowledged = await levels.acknowledgeRevealedDocuments(String(req.params.id), ids) acknowledged === null ? res.status(404).json({ error: 'Level not found' }) : res.json({ acknowledged }) } catch (error) { next(error) } }) app.get('/api/levels/:id/flags', requireAdmin, async (req, res, next) => { try { const flags = await levels.listFlags(String(req.params.id)) flags ? res.json(flags) : res.status(404).json({ error: 'Level not found' }) } catch (error) { next(error) } }) app.put('/api/levels/:id/flags/:key', requireAdmin, async (req, res, next) => { try { const updated = await levels.setFlag(String(req.params.id), String(req.params.key), true) updated ? res.json({ ok: true }) : res.status(404).json({ error: 'Level not found' }) } catch (error) { next(error) } }) app.delete('/api/levels/:id/flags/:key', requireAdmin, async (req, res, next) => { try { const updated = await levels.setFlag(String(req.params.id), String(req.params.key), false) updated ? res.json({ ok: true }) : res.status(404).json({ error: 'Level not found' }) } catch (error) { next(error) } }) app.get('/api/levels/:id', async (req, res, next) => { try { const level = await levels.getLevel(req.params.id, wantsEdit(req)) level ? res.json(level) : res.status(404).json({ error: 'Level not found' }) } catch (error) { next(error) } }) app.put('/api/levels/:id', async (req, res, next) => { const state = req.body as CaseState if (!state || state.id !== req.params.id || !Array.isArray(state.exhibits) || !Array.isArray(state.views) || !Array.isArray(state.connections)) return res.status(400).json({ error: 'Invalid level state' }) try { const authorMode = wantsEdit(req) await levels.saveLevel(state, authorMode) res.json({ ok: true, mode: authorMode ? 'author' : 'play' }) } catch (error) { next(error) } }) app.post('/api/levels/:id/reset', async (req, res, next) => { try { const level = await levels.resetLevel(req.params.id) level ? res.json(level) : res.status(404).json({ error: 'Level not found' }) } catch (error) { next(error) } }) app.post('/api/levels/:id/report/submissions', async (req, res, next) => { try { const report = await submitCaseReport(pool,String(req.params.id),{ investigatorName:String(req.body?.investigatorName || resolvePlayerName(req)), }) report ? res.status(201).json(report) : res.status(404).json({ error:'Level not found' }) } catch (error) { next(error) } }) // Admin authoring panel: NPC template library and mystery listing. Reads require an // admin claim; writes additionally require editing to be enabled on this deployment. function requireEditing(res: express.Response) { if (!editingEnabled) { res.status(403).json({ error: 'Level editing is disabled' }); return false } return true } app.get('/api/levels/:id/evidence-match-rules', requireAdmin, async (req, res, next) => { try { const rules = await levels.listEvidenceMatchRules(String(req.params.id)) rules ? res.json(rules) : res.status(404).json({ error: 'Level not found' }) } catch (error) { next(error) } }) app.post('/api/levels/:id/evidence-match-rules', requireAdmin, async (req, res, next) => { try { if (!requireEditing(res)) return const rule = await levels.createEvidenceMatchRule(String(req.params.id), req.body) rule ? res.status(201).json(rule) : res.status(404).json({ error: 'Level not found' }) } catch (error) { next(error) } }) app.put('/api/levels/:id/evidence-match-rules/:ruleId', requireAdmin, async (req, res, next) => { try { if (!requireEditing(res)) return const rule = await levels.updateEvidenceMatchRule(String(req.params.id), String(req.params.ruleId), req.body) rule ? res.json(rule) : res.status(404).json({ error: 'Level or evidence match rule not found' }) } catch (error) { next(error) } }) app.delete('/api/levels/:id/evidence-match-rules/:ruleId', requireAdmin, async (req, res, next) => { try { if (!requireEditing(res)) return const removed = await levels.deleteEvidenceMatchRule(String(req.params.id), String(req.params.ruleId)) if (removed === null) return res.status(404).json({ error: 'Level not found' }) removed ? res.json({ ok: true }) : res.status(404).json({ error: 'Evidence match rule not found' }) } catch (error) { next(error) } }) app.get('/api/levels/:id/goals', requireAdmin, async (req, res, next) => { try { const goals = await levels.listGoals(String(req.params.id)) goals ? res.json(goals) : res.status(404).json({ error: 'Level not found' }) } catch (error) { next(error) } }) app.post('/api/levels/:id/goals', requireAdmin, async (req, res, next) => { try { if (!requireEditing(res)) return const goal = await levels.createGoal(String(req.params.id), req.body) goal ? res.status(201).json(goal) : res.status(404).json({ error: 'Level not found' }) } catch (error) { next(error) } }) app.put('/api/levels/:id/goals/:goalId', requireAdmin, async (req, res, next) => { try { if (!requireEditing(res)) return const goal = await levels.updateGoal(String(req.params.id), String(req.params.goalId), req.body) goal ? res.json(goal) : res.status(404).json({ error: 'Level or goal not found' }) } catch (error) { next(error) } }) app.delete('/api/levels/:id/goals/:goalId', requireAdmin, async (req, res, next) => { try { if (!requireEditing(res)) return const removed = await levels.deleteGoal(String(req.params.id), String(req.params.goalId)) if (removed === null) return res.status(404).json({ error: 'Level not found' }) removed ? res.json({ ok: true }) : res.status(404).json({ error: 'Goal not found' }) } catch (error) { next(error) } }) app.get('/api/levels/:id/evidence-semantic-rules', requireAdmin, async (req, res, next) => { try { const rules = await levels.listEvidenceSemanticRules(String(req.params.id)) rules ? res.json(rules) : res.status(404).json({ error: 'Level not found' }) } catch (error) { next(error) } }) app.post('/api/levels/:id/evidence-semantic-rules', requireAdmin, async (req, res, next) => { try { if (!requireEditing(res)) return const rule = await levels.createEvidenceSemanticRule(String(req.params.id), req.body) rule ? res.status(201).json(rule) : res.status(404).json({ error: 'Level not found' }) } catch (error) { next(error) } }) app.put('/api/levels/:id/evidence-semantic-rules/:ruleId', requireAdmin, async (req, res, next) => { try { if (!requireEditing(res)) return const rule = await levels.updateEvidenceSemanticRule(String(req.params.id), String(req.params.ruleId), req.body) rule ? res.json(rule) : res.status(404).json({ error: 'Level or semantic rule not found' }) } catch (error) { next(error) } }) app.delete('/api/levels/:id/evidence-semantic-rules/:ruleId', requireAdmin, async (req, res, next) => { try { if (!requireEditing(res)) return const removed = await levels.deleteEvidenceSemanticRule(String(req.params.id), String(req.params.ruleId)) if (removed === null) return res.status(404).json({ error: 'Level not found' }) removed ? res.json({ ok: true }) : res.status(404).json({ error: 'Semantic rule not found' }) } catch (error) { next(error) } }) app.get('/api/admin/mysteries', requireAdmin, async (_req, res, next) => { try { res.json(await narrative.listMysteries()) } catch (error) { next(error) } }) app.delete('/api/admin/mysteries/:id', requireAdmin, async (req, res, next) => { try { if (!requireEditing(res)) return const ok = await narrative.deleteMystery(String(req.params.id)) ok ? res.json({ ok: true }) : res.status(404).json({ error: 'Mystery not found' }) } catch (error) { next(error) } }) // Shared asset library (images, audio, PDFs) — reuses the immutable, deduplicated // osint.assets store; bytes served via GET /api/assets/:id. app.get('/api/admin/assets', requireAdmin, async (_req, res, next) => { try { res.json(await narrative.listAssets()) } catch (error) { next(error) } }) app.post('/api/admin/assets', requireAdmin, upload.single('file'), async (req, res, next) => { try { if (!requireEditing(res)) return if (!req.file) return res.status(400).json({ error: 'A file is required' }) res.status(201).json(await narrative.uploadAsset(req.file)) } catch (error) { next(error) } }) app.delete('/api/admin/assets/:id', requireAdmin, async (req, res, next) => { try { if (!requireEditing(res)) return const outcome = await narrative.deleteAsset(String(req.params.id)) if (outcome === 'deleted') return res.json({ ok: true }) res.status(outcome === 'in_use' ? 409 : 404).json({ error: outcome === 'in_use' ? 'Asset is in use' : 'Asset not found' }) } catch (error) { next(error) } }) app.get('/api/admin/npcs', requireAdmin, async (_req, res, next) => { try { res.json(await narrative.listNpcs()) } catch (error) { next(error) } }) app.post('/api/admin/npcs', requireAdmin, async (req, res, next) => { try { if (!requireEditing(res)) return if (!req.body?.key) return res.status(400).json({ error: 'An NPC key is required' }) res.status(201).json(await narrative.createNpc({ key: String(req.body.key), name: String(req.body.name || ''), role: String(req.body.role || ''), defaultPose: req.body.defaultPose || null, phoneNumber: req.body.phoneNumber ?? null, email: req.body.email ?? null })) } catch (error) { next(error) } }) app.patch('/api/admin/npcs/:id', requireAdmin, async (req, res, next) => { try { if (!requireEditing(res)) return const npc = await narrative.updateNpc(String(req.params.id), { name: req.body?.name, role: req.body?.role, defaultPose: req.body?.defaultPose, phoneNumber: req.body?.phoneNumber, email: req.body?.email }) npc ? res.json(npc) : res.status(404).json({ error: 'NPC not found' }) } catch (error) { next(error) } }) app.delete('/api/admin/npcs/:id', requireAdmin, async (req, res, next) => { try { if (!requireEditing(res)) return const outcome = await narrative.deleteNpc(String(req.params.id)) if (outcome === 'deleted') return res.json({ ok: true }) res.status(outcome === 'in_use' ? 409 : 404).json({ error: outcome === 'in_use' ? 'NPC is used by a cutscene' : 'NPC not found' }) } catch (error) { next(error) } }) app.post('/api/admin/npcs/:id/poses', requireAdmin, upload.single('file'), async (req, res, next) => { try { if (!requireEditing(res)) return if (!req.file) return res.status(400).json({ error: 'An image file is required' }) if (!req.body?.poseKey) return res.status(400).json({ error: 'A pose key is required' }) const npc = await narrative.addPose(String(req.params.id), String(req.body.poseKey), req.file) npc ? res.status(201).json(npc) : res.status(404).json({ error: 'NPC not found' }) } catch (error) { next(error) } }) app.delete('/api/admin/npcs/:id/poses/:poseKey', requireAdmin, async (req, res, next) => { try { if (!requireEditing(res)) return const npc = await narrative.deletePose(String(req.params.id), String(req.params.poseKey)) npc ? res.json(npc) : res.status(404).json({ error: 'NPC not found' }) } catch (error) { next(error) } }) // Story flow graph authoring (Phase 1): nodes, terminals, wiring, entrypoint. app.get('/api/admin/level-templates', requireAdmin, async (_req, res, next) => { try { res.json(await storyGraph.listLevelTemplates()) } catch (error) { next(error) } }) app.get('/api/admin/mysteries/:id/graph', requireAdmin, async (req, res, next) => { try { const graph = await storyGraph.getGraph(String(req.params.id)) graph ? res.json(graph) : res.status(404).json({ error: 'Mystery not found' }) } catch (error) { next(error) } }) app.post('/api/admin/mysteries/:id/nodes', requireAdmin, async (req, res, next) => { try { if (!requireEditing(res)) return const nodeType = String(req.body?.nodeType) as StoryNodeType if (!STORY_NODE_TYPES.includes(nodeType)) return res.status(400).json({ error: 'Unknown node type' }) const node = await storyGraph.createNode(String(req.params.id), { nodeType, xpos: Number(req.body?.xpos) || 0, ypos: Number(req.body?.ypos) || 0, label: req.body?.label }) node ? res.status(201).json(node) : res.status(404).json({ error: 'Mystery not found' }) } catch (error) { next(error) } }) app.patch('/api/admin/story-nodes/:id', requireAdmin, async (req, res, next) => { try { if (!requireEditing(res)) return const node = await storyGraph.updateNode(String(req.params.id), req.body || {}) node ? res.json(node) : res.status(404).json({ error: 'Node not found' }) } catch (error) { next(error) } }) app.delete('/api/admin/story-nodes/:id', requireAdmin, async (req, res, next) => { try { if (!requireEditing(res)) return const ok = await storyGraph.deleteNode(String(req.params.id)) ok ? res.json({ ok: true }) : res.status(404).json({ error: 'Node not found' }) } catch (error) { next(error) } }) app.post('/api/admin/story-nodes/:id/terminals', requireAdmin, async (req, res, next) => { try { if (!requireEditing(res)) return if (!req.body?.terminalKey) return res.status(400).json({ error: 'A terminal key is required' }) const node = await storyGraph.addTerminal(String(req.params.id), { terminalKey: String(req.body.terminalKey), label: req.body?.label }) node ? res.status(201).json(node) : res.status(404).json({ error: 'Node not found' }) } catch (error) { next(error) } }) app.patch('/api/admin/story-terminals/:id', requireAdmin, async (req, res, next) => { try { if (!requireEditing(res)) return const result = await storyGraph.updateTerminal(String(req.params.id), req.body || {}) result.ok ? res.json({ ok: true }) : res.status(result.error === 'Terminal not found' ? 404 : 400).json({ error: result.error }) } catch (error) { next(error) } }) app.delete('/api/admin/story-terminals/:id', requireAdmin, async (req, res, next) => { try { if (!requireEditing(res)) return const ok = await storyGraph.deleteTerminal(String(req.params.id)) ok ? res.json({ ok: true }) : res.status(404).json({ error: 'Terminal not found' }) } catch (error) { next(error) } }) app.put('/api/admin/mysteries/:id/entry', requireAdmin, async (req, res, next) => { try { if (!requireEditing(res)) return const result = await storyGraph.setEntryNode(String(req.params.id), req.body?.nodeId ?? null) result.ok ? res.json({ ok: true }) : res.status(400).json({ error: result.error }) } catch (error) { next(error) } }) app.post('/api/admin/mysteries/:id/graph', requireAdmin, async (req, res, next) => { try { if (!requireEditing(res)) return if (!req.body?.entry || !Array.isArray(req.body?.nodes)) return res.status(400).json({ error: 'A graph spec needs entry and nodes' }) const result = await storyGraph.authorGraph(String(req.params.id), req.body) result ? res.status(201).json(result) : res.status(404).json({ error: 'Mystery not found' }) } catch (error) { next(error) } }) // Utterance sub-graph (dialogue crafter). app.get('/api/admin/story-nodes/:id/utterances', requireAdmin, async (req, res, next) => { try { res.json(await storyGraph.listUtterances(String(req.params.id))) } catch (error) { next(error) } }) // Resolved runtime dialogue tree for the editor's live preview (same resolver as play). app.get('/api/admin/story-nodes/:id/dialogue', requireAdmin, async (req, res, next) => { try { res.json(await narrative.resolveDialogue(String(req.params.id))) } catch (error) { next(error) } }) app.post('/api/admin/story-nodes/:id/utterances', requireAdmin, async (req, res, next) => { try { if (!requireEditing(res)) return const utterer = req.body?.utterer === 'player' ? 'player' : 'npc' const utterance = await storyGraph.createUtterance(String(req.params.id), { utterer, xpos: Number(req.body?.xpos) || 0, ypos: Number(req.body?.ypos) || 0, text: req.body?.text }) utterance ? res.status(201).json(utterance) : res.status(404).json({ error: 'Node not found' }) } catch (error) { next(error) } }) app.patch('/api/admin/utterances/:id', requireAdmin, async (req, res, next) => { try { if (!requireEditing(res)) return const result = await storyGraph.updateUtterance(String(req.params.id), req.body || {}) result.ok ? res.json({ ok: true }) : res.status(result.error === 'Utterance not found' ? 404 : 400).json({ error: result.error }) } catch (error) { next(error) } }) app.delete('/api/admin/utterances/:id', requireAdmin, async (req, res, next) => { try { if (!requireEditing(res)) return const ok = await storyGraph.deleteUtterance(String(req.params.id)) ok ? res.json({ ok: true }) : res.status(404).json({ error: 'Utterance not found' }) } catch (error) { next(error) } }) // Play mode: the launchable case list for the splash picker (entrypoint required). app.get('/api/mysteries', async (_req, res, next) => { try { res.json(await narrative.listPlayableMysteries()) } catch (error) { next(error) } }) // Narrative authoring: create a mystery and its NPC cast. The flow (cutscenes, // dialogue, levels) lives in the story graph, seeded separately. app.post('/api/mysteries', requireAdmin, async (req, res, next) => { try { if (!wantsEdit(req)) return res.status(403).json({ error: 'Level editing is disabled' }) const body = req.body || {} if (!body.slug || !body.title) return res.status(400).json({ error: 'A mystery requires slug and title' }) const created = await narrative.authorMystery({ slug: slug(body.slug, body.slug), title: String(body.title), cast: Array.isArray(body.cast) ? body.cast : [] }) res.status(201).json(created) } catch (error) { next(error) } }) // New Game creates a playthrough bound to the caller's identity. app.post('/api/playthroughs', async (req, res, next) => { try { const userId = requireUser(req, res); if (!userId) return const result = await narrative.createPlaythrough(userId, req.body?.mystery ? slug(req.body.mystery, req.body.mystery) : undefined) result ? res.status(201).json(result) : res.status(404).json({ error: 'No mystery available' }) } catch (error) { next(error) } }) app.get('/api/playthroughs/current', async (req, res, next) => { try { const userId = resolveUserId(req) const result = userId ? await narrative.getCurrentPlaythrough(userId) : null result ? res.json(result) : res.status(204).end() } catch (error) { next(error) } }) // Advance the playthrough through the story graph (follows a terminal; auto-skips // gates; instantiates the board when entering a level node). app.post('/api/playthroughs/:id/advance', async (req, res, next) => { try { const userId = requireUser(req, res); if (!userId) return const result = await narrative.advancePlaythrough(userId, String(req.params.id), req.body?.terminalKey) result.ok ? res.json(result.state ?? null) : res.status(result.error === 'Playthrough not found' ? 404 : result.errorCode ? 409 : 400) .json({ error: result.error, errorCode: result.errorCode, pendingGoals: result.pendingGoals }) } catch (error) { next(error) } }) // The playthrough case-state (achievements). Read is open; granting is a dev-only // stand-in until the server-side achievement rule engine drives awards from play. app.get('/api/playthroughs/:id/achievements', async (req, res, next) => { try { if (!await ownsPlaythroughOr403(req, res, String(req.params.id))) return const flags = await narrative.listAchievements(String(req.params.id)) flags ? res.json(flags) : res.status(404).json({ error: 'Playthrough not found' }) } catch (error) { next(error) } }) app.post('/api/playthroughs/:id/achievements', async (req, res, next) => { try { if (process.env.NODE_ENV === 'production') return res.status(403).json({ error: 'Manual grants are disabled' }) if (!await ownsPlaythroughOr403(req, res, String(req.params.id))) return if (!req.body?.flagKey) return res.status(400).json({ error: 'A flagKey is required' }) const result = await narrative.awardAchievement(String(req.params.id), String(req.body.flagKey), req.body.nodeId ? String(req.body.nodeId) : null) result.ok ? res.json({ earned: result.earned }) : res.status(result.error === 'Playthrough not found' ? 404 : 400).json({ error: result.error }) } catch (error) { next(error) } }) // A dialogue line was reached in play — grant its authored achievement (validated // server-side against the player's current node, so players can't forge flags). app.post('/api/playthroughs/:id/utterances/:uid/reach', async (req, res, next) => { try { if (!await ownsPlaythroughOr403(req, res, String(req.params.id))) return const result = await narrative.reachUtterance(String(req.params.id), String(req.params.uid)) result.ok ? res.json({ earned: result.earned ?? false }) : res.status(404).json({ error: 'Not found' }) } catch (error) { next(error) } }) // Field notebook: capture NPC lines during play, list them, and remove (on tear/discard). app.get('/api/playthroughs/:id/notebook', async (req, res, next) => { try { if (!await ownsPlaythroughOr403(req, res, String(req.params.id))) return res.json(await narrative.notebookPages(String(req.params.id))) } catch (error) { next(error) } }) app.post('/api/playthroughs/:id/notebook', async (req, res, next) => { try { if (!await ownsPlaythroughOr403(req, res, String(req.params.id))) return const page = await narrative.addNotebookPage(String(req.params.id), String(req.body?.text || ''), req.body?.utteranceId ? String(req.body.utteranceId) : null) page ? res.status(201).json(page) : res.status(400).json({ error: 'Empty text or unknown playthrough' }) } catch (error) { next(error) } }) app.delete('/api/playthroughs/:id/notebook/:pageId', async (req, res, next) => { try { if (!await ownsPlaythroughOr403(req, res, String(req.params.id))) return const ok = await narrative.removeNotebookPage(String(req.params.id), String(req.params.pageId)) ok ? res.json({ ok: true }) : res.status(404).json({ error: 'Not found' }) } catch (error) { next(error) } }) // The phone tool: the directory available on the current node, and dialing a number. app.get('/api/playthroughs/:id/phone', async (req, res, next) => { try { if (!await ownsPlaythroughOr403(req, res, String(req.params.id))) return res.json(await narrative.phoneDirectory(String(req.params.id))) } catch (error) { next(error) } }) app.post('/api/playthroughs/:id/dial', async (req, res, next) => { try { if (!await ownsPlaythroughOr403(req, res, String(req.params.id))) return res.json(await narrative.dial(String(req.params.id), String(req.body?.number || ''))) } catch (error) { next(error) } }) // Dev teleport: jump the playthrough to an explicit story node. Powers /node/:id. app.post('/api/playthroughs/:id/goto', async (req, res, next) => { try { if (process.env.NODE_ENV === 'production') return res.status(403).json({ error: 'Node teleport is disabled' }) const userId = requireUser(req, res); if (!userId) return if (!req.body?.nodeId) return res.status(400).json({ error: 'A nodeId is required' }) const result = await narrative.gotoNode(userId, String(req.params.id), String(req.body.nodeId)) result.ok ? res.json(result.state ?? null) : res.status(result.error === 'Playthrough not found' || result.error === 'Node not found' ? 404 : 400).json({ error: result.error }) } catch (error) { next(error) } }) app.use((error: unknown, _req: express.Request, res: express.Response, _next: express.NextFunction) => { if (error instanceof multer.MulterError) { return res.status(error.code === 'LIMIT_FILE_SIZE' ? 413 : 400).json({ error: error.code === 'LIMIT_FILE_SIZE' ? 'Document exceeds the upload limit' : error.message }) } console.error(error); res.status(500).json({ error: 'Internal server error' }) }) const here = path.dirname(fileURLToPath(import.meta.url)); const dist = path.resolve(here, '..', 'dist') if (fs.existsSync(dist)) { app.use(express.static(dist)); app.get('*splat', (_req, res) => res.sendFile(path.join(dist, 'index.html'))) } const port = Number(process.env.PORT || 8787) export const server = app.listen(port, '0.0.0.0', () => console.log(`GUPI OSINT Board listening on http://localhost:${port}`)) async function shutdown() { server.close(); await pool.end(); process.exit(0) } if (!process.env.VITEST && process.env.OSINT_MANAGED_SERVER !== 'true') { process.on('SIGTERM', shutdown) process.on('SIGINT', shutdown) }