2026-08-14 12:43:11 +02:00
import 'dotenv/config'
import cors from 'cors'
2026-08-15 09:29:08 +02:00
import cookieParser from 'cookie-parser'
2026-08-14 12:43:11 +02:00
import express from 'express'
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import multer from 'multer'
2026-08-14 13:57:51 +02:00
import pg from 'pg'
import type { CaseState } from '../src/types.js'
2026-08-18 15:37:46 +02:00
import { authenticateJwt , createDevelopmentAdminToken , hasAdminClaim , requireAdmin , resolveUserId } from './auth.js'
2026-08-14 14:09:24 +02:00
import { createLevelRepository } from './levelRepository.js'
2026-08-22 16:02:10 +02:00
import { createEvidenceJudgeFromEnv } from './evidenceJudge.js'
2026-08-18 15:37:46 +02:00
import { createNarrativeRepository } from './narrativeRepository.js'
2026-08-22 14:53:23 +02:00
import { createTextExtractorFromEnv } from './ocr.js'
2026-08-18 15:37:46 +02:00
import { createStoryGraphRepository , type StoryNodeType } from './storyGraphRepository.js'
2026-08-17 09:24:53 +02:00
import { createObjectStorageFromEnv } from './objectStorage.js'
2026-08-14 12:43:11 +02:00
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 )
}
2026-08-14 12:57:06 +02:00
export const pool = new Pool ({ connectionString : databaseUrl })
2026-08-14 12:43:11 +02:00
const editingEnabled = process . env . LEVEL_EDITING_ENABLED === 'true'
2026-08-17 09:24:53 +02:00
const objectStorage = createObjectStorageFromEnv ()
await objectStorage . initialize ()
2026-08-22 14:53:23 +02:00
const textExtractor = createTextExtractorFromEnv ()
2026-08-22 16:02:10 +02:00
const evidenceJudge = createEvidenceJudgeFromEnv ()
const levels = createLevelRepository ( pool , editingEnabled , objectStorage , evidenceJudge )
2026-08-18 15:37:46 +02:00
const narrative = createNarrativeRepository ( pool , objectStorage )
const storyGraph = createStoryGraphRepository ( pool )
const STORY_NODE_TYPES : StoryNodeType [] = [ 'cutscene' , 'dialogue' , 'level' , 'det_gate' , 'llm_gate' ]
2026-08-14 12:43:11 +02:00
function wantsEdit ( req : express.Request ) {
2026-08-15 09:29:08 +02:00
return editingEnabled && req . query . edit === '1' && hasAdminClaim ( req )
2026-08-14 12:43:11 +02:00
}
2026-08-14 14:17:54 +02:00
function slug ( value : unknown , fallback : string ) {
return String ( value || fallback ). trim (). toLowerCase (). replace ( /[^a-z0-9-]+/g , '-' ). replace ( /^-+|-+$/g , '' )
}
2026-08-14 12:43:11 +02:00
2026-08-14 12:57:06 +02:00
export const app = express ()
2026-08-14 12:43:11 +02:00
app . disable ( 'x-powered-by' )
2026-08-15 09:29:08 +02:00
app . use ( cors ({ origin : process.env.CORS_ORIGIN || true , credentials : true }))
app . use ( cookieParser ())
app . use ( authenticateJwt )
2026-08-14 12:43:11 +02:00
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 ) => {
2026-08-22 16:02:10 +02:00
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 }) }
2026-08-14 12:43:11 +02:00
catch { res . status ( 503 ). json ({ ok : false , database : 'unavailable' }) }
})
2026-08-15 09:29:08 +02:00
app . get ( '/api/session' , ( req , res ) => res . json ({ authenticated : Boolean ( req . authClaims ), isAdmin : hasAdminClaim ( req ) }))
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 )
})
2026-08-14 12:43:11 +02:00
app . get ( '/api/levels' , async ( _req , res , next ) => {
2026-08-14 13:57:51 +02:00
try { res . json ( await levels . listLevels ()) }
2026-08-14 12:43:11 +02:00
catch ( error ) { next ( error ) }
})
2026-08-14 14:17:54 +02:00
app . get ( '/api/templates' , async ( _req , res , next ) => {
try { res . json ( await levels . listTemplates ()) }
catch ( error ) { next ( error ) }
})
2026-08-15 09:29:08 +02:00
app . post ( '/api/templates/:slug/levels' , requireAdmin , async ( req , res , next ) => {
2026-08-14 14:17:54 +02:00
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 () } ` )
2026-08-15 09:29:08 +02:00
const level = await levels . instantiateTemplate ( String ( req . params . slug ), { id : levelSlug , title , version : req.body?.version })
2026-08-14 14:17:54 +02:00
level ? res . status ( 201 ). json ( level ) : res . status ( 404 ). json ({ error : 'Template version not found' })
} catch ( error ) { next ( error ) }
})
2026-08-15 09:29:08 +02:00
app . post ( '/api/levels' , requireAdmin , async ( req , res , next ) => {
2026-08-14 12:43:11 +02:00
try {
if ( ! editingEnabled ) return res . status ( 403 ). json ({ error : 'Level editing is disabled' })
const title = String ( req . body ? . title || 'Untitled Investigation' ). trim ()
2026-08-14 14:17:54 +02:00
const id = slug ( req . body ? . id , `level- ${ Date . now () } ` )
2026-08-14 13:57:51 +02:00
res . status ( 201 ). json ( await levels . createLevel ({ id , title , subtitle : String ( req . body ? . subtitle || '' ) }))
2026-08-14 12:43:11 +02:00
} catch ( error ) { next ( error ) }
})
2026-08-15 09:29:08 +02:00
app . post ( '/api/levels/:id/templates' , requireAdmin , async ( req , res , next ) => {
2026-08-14 14:17:54 +02:00
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 )
2026-08-15 09:29:08 +02:00
const template = await levels . saveLevelAsTemplate ( String ( req . params . id ), { slug : templateSlug , name })
2026-08-14 14:17:54 +02:00
template ? res . status ( 201 ). json ( template ) : res . status ( 404 ). json ({ error : 'Level not found' })
} catch ( error ) { next ( error ) }
})
2026-08-14 12:43:11 +02:00
app . get ( '/api/assets/:id' , async ( req , res , next ) => {
try {
2026-08-14 13:57:51 +02:00
const asset = await levels . getAsset ( req . params . id )
2026-08-14 12:43:11 +02:00
if ( ! asset ) return res . status ( 404 ). json ({ error : 'Asset not found' })
2026-08-17 09:24:53 +02:00
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 ) } ` )
2026-08-14 12:43:11 +02:00
res . setHeader ( 'X-Content-Type-Options' , 'nosniff' )
2026-08-17 09:24:53 +02:00
asset . stream . on ( 'error' , next )
asset . stream . pipe ( res )
2026-08-14 12:43:11 +02:00
} catch ( error ) { next ( error ) }
})
2026-08-22 14:53:23 +02:00
app . post ( '/api/levels/:id/documents' , upload . single ( 'file' ), async ( req , res , next ) => {
2026-08-14 12:43:11 +02:00
try {
2026-08-14 13:57:51 +02:00
if ( ! req . file ) return res . status ( 400 ). json ({ error : 'A file is required' })
2026-08-22 14:53:23 +02:00
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 )
2026-08-14 13:57:51 +02:00
document ? res . status ( 201 ). json ( document ) : res . status ( 404 ). json ({ error : 'Level not found' })
} catch ( error ) { next ( error ) }
2026-08-14 12:43:11 +02:00
})
2026-08-22 16:02:10 +02:00
app . post ( '/api/levels/:id/documents/:documentId/judge' , async ( req , res , next ) => {
try {
if ( ! hasAdminClaim ( req ) && ! await narrative . ownsActiveLevel ( resolveUserId ( req ), 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 ) }
})
2026-08-22 14:53:23 +02:00
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 ) }
})
2026-08-14 12:43:11 +02:00
app . get ( '/api/levels/:id' , async ( req , res , next ) => {
2026-08-14 13:57:51 +02:00
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 ) }
2026-08-14 12:43:11 +02:00
})
app . put ( '/api/levels/:id' , async ( req , res , next ) => {
const state = req . body as CaseState
2026-08-17 09:24:53 +02:00
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' })
2026-08-14 12:43:11 +02:00
try {
2026-08-14 13:57:51 +02:00
const authorMode = wantsEdit ( req )
await levels . saveLevel ( state , authorMode )
res . json ({ ok : true , mode : authorMode ? 'author' : 'play' })
} catch ( error ) { next ( error ) }
2026-08-14 12:43:11 +02:00
})
app . post ( '/api/levels/:id/reset' , async ( req , res , next ) => {
try {
2026-08-14 13:57:51 +02:00
const level = await levels . resetLevel ( req . params . id )
level ? res . json ( level ) : res . status ( 404 ). json ({ error : 'Level not found' })
} catch ( error ) { next ( error ) }
2026-08-14 12:43:11 +02:00
})
2026-08-18 15:37:46 +02:00
// 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
}
2026-08-22 14:53:23 +02:00
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 ) }
})
2026-08-22 16:02:10 +02:00
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 ) }
})
2026-08-18 15:37:46 +02:00
app . get ( '/api/admin/mysteries' , requireAdmin , async ( _req , res , next ) => {
try { res . json ( await narrative . listMysteries ()) } catch ( error ) { next ( error ) }
})
2026-08-18 19:05:29 +02:00
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 ) }
})
2026-08-18 15:37:46 +02:00
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 }))
} 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 })
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 ) }
})
2026-08-18 19:24:03 +02:00
// 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 ) }
})
2026-08-18 15:37:46 +02:00
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 ) }
})
// 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 result = await narrative . createPlaythrough ( resolveUserId ( req ), 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 result = await narrative . getCurrentPlaythrough ( resolveUserId ( req ))
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 result = await narrative . advancePlaythrough ( resolveUserId ( req ), String ( req . params . id ), req . body ? . terminalKey )
2026-08-22 16:02:10 +02:00
result . ok ? res . json ( result . state ?? null ) : res . status ( result . error === 'Playthrough not found' ? 404 : result.errorCode === 'goals_incomplete' ? 409 : 400 )
. json ({ error : result.error , errorCode : result.errorCode , pendingGoals : result.pendingGoals })
2026-08-18 15:37:46 +02:00
} catch ( error ) { next ( error ) }
})
2026-08-22 14:53:23 +02:00
// 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 {
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 ( ! 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 ) }
})
2026-08-22 15:12:56 +02:00
// 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' })
if ( ! req . body ? . nodeId ) return res . status ( 400 ). json ({ error : 'A nodeId is required' })
const result = await narrative . gotoNode ( resolveUserId ( req ), 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 ) }
})
2026-08-22 14:53:23 +02:00
2026-08-14 12:43:11 +02:00
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 )
2026-08-14 12:57:06 +02:00
export const server = app . listen ( port , '0.0.0.0' , () => console . log ( `GUPI OSINT Board listening on http://localhost: ${ port } ` ))
2026-08-14 12:43:11 +02:00
async function shutdown() { server . close (); await pool . end (); process . exit ( 0 ) }
2026-08-14 13:43:37 +02:00
if ( ! process . env . VITEST && process . env . OSINT_MANAGED_SERVER !== 'true' ) {
2026-08-14 12:57:06 +02:00
process . on ( 'SIGTERM' , shutdown )
process . on ( 'SIGINT' , shutdown )
}