Merge remote-tracking branch 'origin/main'
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.
|
||||
*/
|
||||
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
@@ -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) }
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user