diff --git a/server/auth.ts b/server/auth.ts index bdc4b17..edfe185 100644 --- a/server/auth.ts +++ b/server/auth.ts @@ -34,9 +34,12 @@ export function hasAdminClaim(req: Request) { * when the external handoff is wired — the `user_id` column stays the same. */ export const DEVELOPMENT_TEST_USER_ID = 'osint-test-player' -export function resolveUserId(req: Request): string { +export function resolveUserId(req: Request): string | null { const sub = req.authClaims?.sub - return typeof sub === 'string' && sub.length > 0 ? sub : DEVELOPMENT_TEST_USER_ID + if (typeof sub === 'string' && sub.length > 0) return sub + // In production an absent token is anonymous (no shared identity); locally it + // resolves to a single dev user so the game is playable without an issuer. + return process.env.NODE_ENV === 'production' ? null : DEVELOPMENT_TEST_USER_ID } export function resolvePlayerName(req: Request): string { diff --git a/server/index.ts b/server/index.ts index e9a29e2..c0e4431 100644 --- a/server/index.ts +++ b/server/index.ts @@ -41,6 +41,18 @@ const STORY_NODE_TYPES: StoryNodeType[] = ['cutscene', 'dialogue', 'level', 'det 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, '') } @@ -156,7 +168,8 @@ app.post('/api/levels/:id/documents', upload.single('file'), async (req, res, ne }) 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))) { + 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)) @@ -503,13 +516,15 @@ app.post('/api/mysteries', requireAdmin, async (req, res, next) => { // 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) + 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 result = await narrative.getCurrentPlaythrough(resolveUserId(req)) + const userId = resolveUserId(req) + const result = userId ? await narrative.getCurrentPlaythrough(userId) : null result ? res.json(result) : res.status(204).end() } catch (error) { next(error) } }) @@ -517,7 +532,8 @@ app.get('/api/playthroughs/current', async (req, res, next) => { // 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) + 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) } @@ -527,6 +543,7 @@ app.post('/api/playthroughs/:id/advance', async (req, res, next) => { // 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) } @@ -534,6 +551,7 @@ app.get('/api/playthroughs/:id/achievements', async (req, res, next) => { 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 }) @@ -543,42 +561,52 @@ app.post('/api/playthroughs/:id/achievements', async (req, res, next) => { // 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 { res.json(await narrative.notebookPages(String(req.params.id))) } - catch (error) { next(error) } + 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 { res.json(await narrative.phoneDirectory(String(req.params.id))) } - catch (error) { next(error) } + 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 { res.json(await narrative.dial(String(req.params.id), String(req.body?.number || ''))) } - catch (error) { next(error) } + 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(resolveUserId(req), String(req.params.id), String(req.body.nodeId)) + 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) } }) diff --git a/server/narrativeRepository.ts b/server/narrativeRepository.ts index e8dfa60..74ffde7 100644 --- a/server/narrativeRepository.ts +++ b/server/narrativeRepository.ts @@ -65,6 +65,7 @@ export interface NarrativeRepository { phoneDirectory(playthroughId: string): Promise<{ available: boolean; numbers: { number: string; name: string }[] }> dial(playthroughId: string, number: string): Promise<{ outcome: 'connect' | 'voicemail' | 'unknown'; name?: string; state?: PlaythroughState }> gotoNode(userId: string, playthroughId: string, nodeId: string): Promise<{ ok: boolean; state?: PlaythroughState; error?: string }> + ownsPlaythrough(userId: string, playthroughId: string): Promise listMysteries(): Promise listPlayableMysteries(): Promise<{ slug: string; title: string }[]> deleteMystery(id: string): Promise @@ -345,6 +346,10 @@ export function createNarrativeRepository(pool: Pool, objectStorage: ObjectStora return npc ? { outcome: 'voicemail', name: npc.name } : { outcome: 'unknown' } }, + async ownsPlaythrough(userId, playthroughId) { + return ((await pool.query('SELECT 1 FROM osint.playthroughs WHERE id=$1 AND user_id=$2', [playthroughId, userId])).rowCount || 0) > 0 + }, + async listAchievements(playthroughId) { if (!(await pool.query('SELECT 1 FROM osint.playthroughs WHERE id=$1', [playthroughId])).rowCount) return null const rows = (await pool.query<{ flag_key: string }>('SELECT flag_key FROM osint.achievements WHERE playthrough_id=$1 ORDER BY flag_key', [playthroughId])).rows diff --git a/src/App.tsx b/src/App.tsx index e7c8d8a..7ef7265 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -469,7 +469,7 @@ export function App() { if (!response.ok) { setStatus('OBJECTIVE COMPLETE · STORY ADVANCE UNAVAILABLE'); return } const next = await response.json() as PlaythroughState if (next.node?.kind === 'level' && next.node.levelSlug) window.location.assign(`/level/${encodeURIComponent(next.node.levelSlug)}`) - else window.location.assign('/') + else window.location.assign('/?resume=1') // resume straight into the next node, not the splash } finally { setAdvancing(false) } }