Auth slice 3: dev-only fallback + playthrough owner checks
resolveUserId now returns null for an anonymous request in production (no shared identity); the fixed dev user only applies locally. Player-scoped routes (create/advance/goto, achievements, reach, notebook, phone, dial) require a user and verify ownership via ownsPlaythrough — a player can no longer read or act on another's playthrough. Verified: dev anon still plays; cross-user access returns 403/404. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+5
-2
@@ -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
@@ -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))
|
||||||
@@ -504,13 +517,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) }
|
||||||
})
|
})
|
||||||
@@ -518,7 +533,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) }
|
||||||
@@ -528,6 +544,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) }
|
||||||
@@ -535,6 +552,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 })
|
||||||
@@ -544,42 +562,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) }
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user