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.
*/
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 {
+39 -11
View File
@@ -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<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) {
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) }
})
+5
View File
@@ -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<boolean>
listMysteries(): Promise<MysterySummary[]>
listPlayableMysteries(): Promise<{ slug: string; title: string }[]>
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' }
},
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