Merge remote-tracking branch 'origin/main'

This commit is contained in:
2026-08-22 23:19:53 +02:00
4 changed files with 50 additions and 14 deletions
+5 -2
View File
@@ -34,9 +34,12 @@ export function hasAdminClaim(req: Request) {
* when the external handoff is wired — the `user_id` column stays the same. * when the external handoff is wired — the `user_id` column stays the same.
*/ */
export const DEVELOPMENT_TEST_USER_ID = 'osint-test-player' 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 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 { export function resolvePlayerName(req: Request): string {
+39 -11
View File
@@ -41,6 +41,18 @@ const STORY_NODE_TYPES: StoryNodeType[] = ['cutscene', 'dialogue', 'level', 'det
function wantsEdit(req: express.Request) { function wantsEdit(req: express.Request) {
return editingEnabled && req.query.edit === '1' && hasAdminClaim(req) 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<string | null> {
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) { function slug(value: unknown, fallback: string) {
return String(value || fallback).trim().toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '') 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) => { app.post('/api/levels/:id/documents/:documentId/judge', async (req, res, next) => {
try { 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' }) 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)) 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. // New Game creates a playthrough bound to the caller's identity.
app.post('/api/playthroughs', async (req, res, next) => { app.post('/api/playthroughs', async (req, res, next) => {
try { 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' }) result ? res.status(201).json(result) : res.status(404).json({ error: 'No mystery available' })
} catch (error) { next(error) } } catch (error) { next(error) }
}) })
app.get('/api/playthroughs/current', async (req, res, next) => { app.get('/api/playthroughs/current', async (req, res, next) => {
try { 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() result ? res.json(result) : res.status(204).end()
} catch (error) { next(error) } } 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). // gates; instantiates the board when entering a level node).
app.post('/api/playthroughs/:id/advance', async (req, res, next) => { app.post('/api/playthroughs/:id/advance', async (req, res, next) => {
try { 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) 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 }) .json({ error: result.error, errorCode: result.errorCode, pendingGoals: result.pendingGoals })
} catch (error) { next(error) } } 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. // stand-in until the server-side achievement rule engine drives awards from play.
app.get('/api/playthroughs/:id/achievements', async (req, res, next) => { app.get('/api/playthroughs/:id/achievements', async (req, res, next) => {
try { try {
if (!await ownsPlaythroughOr403(req, res, String(req.params.id))) return
const flags = await narrative.listAchievements(String(req.params.id)) const flags = await narrative.listAchievements(String(req.params.id))
flags ? res.json(flags) : res.status(404).json({ error: 'Playthrough not found' }) flags ? res.json(flags) : res.status(404).json({ error: 'Playthrough not found' })
} catch (error) { next(error) } } 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) => { app.post('/api/playthroughs/:id/achievements', async (req, res, next) => {
try { try {
if (process.env.NODE_ENV === 'production') return res.status(403).json({ error: 'Manual grants are disabled' }) 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' }) 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) 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 }) 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). // 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) => { app.post('/api/playthroughs/:id/utterances/:uid/reach', async (req, res, next) => {
try { try {
if (!await ownsPlaythroughOr403(req, res, String(req.params.id))) return
const result = await narrative.reachUtterance(String(req.params.id), String(req.params.uid)) 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' }) result.ok ? res.json({ earned: result.earned ?? false }) : res.status(404).json({ error: 'Not found' })
} catch (error) { next(error) } } catch (error) { next(error) }
}) })
// Field notebook: capture NPC lines during play, list them, and remove (on tear/discard). // Field notebook: capture NPC lines during play, list them, and remove (on tear/discard).
app.get('/api/playthroughs/:id/notebook', async (req, res, next) => { app.get('/api/playthroughs/:id/notebook', async (req, res, next) => {
try { res.json(await narrative.notebookPages(String(req.params.id))) } try {
catch (error) { next(error) } 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) => { app.post('/api/playthroughs/:id/notebook', async (req, res, next) => {
try { 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) 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' }) page ? res.status(201).json(page) : res.status(400).json({ error: 'Empty text or unknown playthrough' })
} catch (error) { next(error) } } catch (error) { next(error) }
}) })
app.delete('/api/playthroughs/:id/notebook/:pageId', async (req, res, next) => { app.delete('/api/playthroughs/:id/notebook/:pageId', async (req, res, next) => {
try { try {
if (!await ownsPlaythroughOr403(req, res, String(req.params.id))) return
const ok = await narrative.removeNotebookPage(String(req.params.id), String(req.params.pageId)) 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' }) ok ? res.json({ ok: true }) : res.status(404).json({ error: 'Not found' })
} catch (error) { next(error) } } catch (error) { next(error) }
}) })
// The phone tool: the directory available on the current node, and dialing a number. // The phone tool: the directory available on the current node, and dialing a number.
app.get('/api/playthroughs/:id/phone', async (req, res, next) => { app.get('/api/playthroughs/:id/phone', async (req, res, next) => {
try { res.json(await narrative.phoneDirectory(String(req.params.id))) } try {
catch (error) { next(error) } 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) => { app.post('/api/playthroughs/:id/dial', async (req, res, next) => {
try { res.json(await narrative.dial(String(req.params.id), String(req.body?.number || ''))) } try {
catch (error) { next(error) } 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. // Dev teleport: jump the playthrough to an explicit story node. Powers /node/:id.
app.post('/api/playthroughs/:id/goto', async (req, res, next) => { app.post('/api/playthroughs/:id/goto', async (req, res, next) => {
try { try {
if (process.env.NODE_ENV === 'production') return res.status(403).json({ error: 'Node teleport is disabled' }) 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' }) 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 }) 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) } } catch (error) { next(error) }
}) })
+5
View File
@@ -65,6 +65,7 @@ export interface NarrativeRepository {
phoneDirectory(playthroughId: string): Promise<{ available: boolean; numbers: { number: string; name: string }[] }> 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 }> 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 }> gotoNode(userId: string, playthroughId: string, nodeId: string): Promise<{ ok: boolean; state?: PlaythroughState; error?: string }>
ownsPlaythrough(userId: string, playthroughId: string): Promise<boolean>
listMysteries(): Promise<MysterySummary[]> listMysteries(): Promise<MysterySummary[]>
listPlayableMysteries(): Promise<{ slug: string; title: string }[]> listPlayableMysteries(): Promise<{ slug: string; title: string }[]>
deleteMystery(id: string): Promise<boolean> deleteMystery(id: string): Promise<boolean>
@@ -345,6 +346,10 @@ export function createNarrativeRepository(pool: Pool, objectStorage: ObjectStora
return npc ? { outcome: 'voicemail', name: npc.name } : { outcome: 'unknown' } 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) { async listAchievements(playthroughId) {
if (!(await pool.query('SELECT 1 FROM osint.playthroughs WHERE id=$1', [playthroughId])).rowCount) return null 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 const rows = (await pool.query<{ flag_key: string }>('SELECT flag_key FROM osint.achievements WHERE playthrough_id=$1 ORDER BY flag_key', [playthroughId])).rows
+1 -1
View File
@@ -469,7 +469,7 @@ export function App() {
if (!response.ok) { setStatus('OBJECTIVE COMPLETE · STORY ADVANCE UNAVAILABLE'); return } if (!response.ok) { setStatus('OBJECTIVE COMPLETE · STORY ADVANCE UNAVAILABLE'); return }
const next = await response.json() as PlaythroughState const next = await response.json() as PlaythroughState
if (next.node?.kind === 'level' && next.node.levelSlug) window.location.assign(`/level/${encodeURIComponent(next.node.levelSlug)}`) 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) } } finally { setAdvancing(false) }
} }