Merge remote-tracking branch 'local-osint/main'
This commit is contained in:
@@ -49,6 +49,13 @@ export function requireAdmin(req: Request, res: Response, next: NextFunction) {
|
||||
next()
|
||||
}
|
||||
|
||||
// Mint a player token (path A: GUPI is the issuer for now). Verification is
|
||||
// issuer-agnostic — a glitch.university token with the same sub verifies identically.
|
||||
export function signPlayerToken(user: { id: string; displayName: string }) {
|
||||
if (!process.env.JWT_SECRET) throw new Error('JWT_SECRET is required')
|
||||
return jwt.sign({ sub: user.id, name: user.displayName, role: 'player' }, process.env.JWT_SECRET, { expiresIn: '30d' })
|
||||
}
|
||||
|
||||
export function createDevelopmentAdminToken() {
|
||||
if (process.env.NODE_ENV === 'production') throw new Error('Development sessions are disabled in production')
|
||||
if (!process.env.JWT_SECRET) throw new Error('JWT_SECRET is required')
|
||||
|
||||
+59
-1
@@ -8,7 +8,8 @@ import { fileURLToPath } from 'node:url'
|
||||
import multer from 'multer'
|
||||
import pg from 'pg'
|
||||
import type { CaseState } from '../src/types.js'
|
||||
import { authenticateJwt, createDevelopmentAdminToken, hasAdminClaim, requireAdmin, resolvePlayerName, resolveUserId } from './auth.js'
|
||||
import { authenticateJwt, createDevelopmentAdminToken, hasAdminClaim, requireAdmin, resolvePlayerName, resolveUserId, signPlayerToken } from './auth.js'
|
||||
import { createUserRepository } from './userRepository.js'
|
||||
import { submitCaseReport } from './caseReports.js'
|
||||
import { createLevelRepository } from './levelRepository.js'
|
||||
import { createEvidenceJudgeFromEnv } from './evidenceJudge.js'
|
||||
@@ -33,6 +34,8 @@ const evidenceJudge = createEvidenceJudgeFromEnv()
|
||||
const levels = createLevelRepository(pool, editingEnabled, objectStorage, evidenceJudge)
|
||||
const narrative = createNarrativeRepository(pool, objectStorage)
|
||||
const storyGraph = createStoryGraphRepository(pool)
|
||||
const users = createUserRepository(pool)
|
||||
const AUTH_COOKIE = { httpOnly: true, sameSite: 'lax' as const, path: '/', maxAge: 30 * 24 * 60 * 60 * 1000 }
|
||||
const STORY_NODE_TYPES: StoryNodeType[] = ['cutscene', 'dialogue', 'level', 'det_gate', 'llm_gate', 'merit', 'phone']
|
||||
|
||||
function wantsEdit(req: express.Request) {
|
||||
@@ -59,6 +62,35 @@ app.get('/api/health', async (_req, res) => {
|
||||
catch { res.status(503).json({ ok: false, database: 'unavailable' }) }
|
||||
})
|
||||
app.get('/api/session', (req, res) => res.json({ authenticated: Boolean(req.authClaims), isAdmin: hasAdminClaim(req), playerName:resolvePlayerName(req) }))
|
||||
|
||||
// Player accounts (path A: GUPI issues the token). register/login set the auth_token
|
||||
// cookie; every game write then binds to this user via resolveUserId.
|
||||
app.post('/api/auth/register', async (req, res, next) => {
|
||||
try {
|
||||
const body = req.body || {}
|
||||
const result = await users.registerUser({ handle: String(body.handle || ''), password: String(body.password || ''), displayName: String(body.displayName || '') })
|
||||
if (result.error || !result.user) return res.status(result.error === 'That handle is taken' ? 409 : 400).json({ error: result.error || 'Registration failed' })
|
||||
res.cookie('auth_token', signPlayerToken(result.user), AUTH_COOKIE)
|
||||
res.status(201).json({ user: result.user })
|
||||
} catch (error) { next(error) }
|
||||
})
|
||||
app.post('/api/auth/login', async (req, res, next) => {
|
||||
try {
|
||||
const body = req.body || {}
|
||||
const user = await users.authenticateUser(String(body.handle || ''), String(body.password || ''))
|
||||
if (!user) return res.status(401).json({ error: 'Invalid handle or password' })
|
||||
res.cookie('auth_token', signPlayerToken(user), AUTH_COOKIE)
|
||||
res.json({ user })
|
||||
} catch (error) { next(error) }
|
||||
})
|
||||
app.post('/api/auth/logout', (_req, res) => { res.clearCookie('auth_token', { path: '/' }); res.json({ ok: true }) })
|
||||
app.get('/api/auth/me', async (req, res, next) => {
|
||||
try {
|
||||
const sub = req.authClaims?.sub
|
||||
const user = typeof sub === 'string' ? await users.getUser(sub) : null
|
||||
user ? res.json({ user }) : res.status(204).end()
|
||||
} catch (error) { next(error) }
|
||||
})
|
||||
if (process.env.NODE_ENV !== 'production') app.get('/api/dev/admin-session', (req, res) => {
|
||||
const requestedReturn = String(req.query.returnTo || '/')
|
||||
const returnTo = requestedReturn.startsWith('/') && !requestedReturn.startsWith('//') ? requestedReturn : '/'
|
||||
@@ -516,6 +548,32 @@ app.post('/api/playthroughs/:id/utterances/:uid/reach', async (req, res, next) =
|
||||
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) }
|
||||
})
|
||||
app.post('/api/playthroughs/:id/notebook', async (req, res, next) => {
|
||||
try {
|
||||
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 {
|
||||
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) }
|
||||
})
|
||||
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) }
|
||||
})
|
||||
// Dev teleport: jump the playthrough to an explicit story node. Powers /node/:id.
|
||||
app.post('/api/playthroughs/:id/goto', async (req, res, next) => {
|
||||
try {
|
||||
|
||||
@@ -59,6 +59,11 @@ export interface NarrativeRepository {
|
||||
listAchievements(playthroughId: string): Promise<string[] | null>
|
||||
awardAchievement(playthroughId: string, flagKey: string, nodeId?: string | null): Promise<{ ok: boolean; earned: boolean; error?: string }>
|
||||
reachUtterance(playthroughId: string, utteranceId: string): Promise<{ ok: boolean; earned?: boolean }>
|
||||
notebookPages(playthroughId: string): Promise<{ id: string; text: string; createdAt: string }[]>
|
||||
addNotebookPage(playthroughId: string, text: string, sourceUtteranceId?: string | null): Promise<{ id: string; text: string } | null>
|
||||
removeNotebookPage(playthroughId: string, pageId: string): Promise<boolean>
|
||||
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 }>
|
||||
listMysteries(): Promise<MysterySummary[]>
|
||||
listPlayableMysteries(): Promise<{ slug: string; title: string }[]>
|
||||
@@ -280,6 +285,66 @@ export function createNarrativeRepository(pool: Pool, objectStorage: ObjectStora
|
||||
return { ok: true, earned: (result.rowCount || 0) > 0 }
|
||||
},
|
||||
|
||||
// Field notebook: lines the player captured from NPCs during this playthrough.
|
||||
async notebookPages(playthroughId) {
|
||||
const rows = (await pool.query<{ id: string; text: string; created_at: Date }>(
|
||||
'SELECT id,text,created_at FROM osint.notebook_pages WHERE playthrough_id=$1 ORDER BY created_at', [playthroughId])).rows
|
||||
return rows.map(row => ({ id: row.id, text: row.text, createdAt: row.created_at.toISOString() }))
|
||||
},
|
||||
async addNotebookPage(playthroughId, text, sourceUtteranceId) {
|
||||
const clean = text.trim()
|
||||
if (!clean) return null
|
||||
if (!(await pool.query('SELECT 1 FROM osint.playthroughs WHERE id=$1', [playthroughId])).rowCount) return null
|
||||
const row = (await pool.query<{ id: string }>(
|
||||
'INSERT INTO osint.notebook_pages (playthrough_id,text,source_utterance_id) VALUES ($1,$2,$3) RETURNING id',
|
||||
[playthroughId, clean, sourceUtteranceId || null])).rows[0]
|
||||
return { id: row.id, text: clean }
|
||||
},
|
||||
async removeNotebookPage(playthroughId, pageId) {
|
||||
const result = await pool.query('DELETE FROM osint.notebook_pages WHERE id=$1 AND playthrough_id=$2', [pageId, playthroughId])
|
||||
return (result.rowCount || 0) > 0
|
||||
},
|
||||
|
||||
// The phone directory available on the player's current node: the terminals of a
|
||||
// phone node the current node is wired to. No connected phone node => nobody's listed.
|
||||
async phoneDirectory(playthroughId) {
|
||||
const pt = (await pool.query<{ current_node_id: string | null }>('SELECT current_node_id FROM osint.playthroughs WHERE id=$1', [playthroughId])).rows[0]
|
||||
if (!pt?.current_node_id) return { available: false, numbers: [] }
|
||||
const phoneNode = (await pool.query<{ id: string }>(
|
||||
`SELECT pn.id FROM osint.story_node_terminals t JOIN osint.story_nodes pn ON pn.id=t.to_node_id
|
||||
WHERE t.parent_node_id=$1 AND pn.node_type='phone' LIMIT 1`, [pt.current_node_id])).rows[0]
|
||||
if (!phoneNode) return { available: true, numbers: [] }
|
||||
const dir = (await pool.query<{ number: string; name: string }>(
|
||||
`SELECT npc.phone_number AS number, npc.name FROM osint.story_node_terminals t JOIN osint.npcs npc ON npc.id=t.npc_id
|
||||
WHERE t.parent_node_id=$1 AND npc.phone_number IS NOT NULL ORDER BY t.sort_order`, [phoneNode.id])).rows
|
||||
return { available: true, numbers: dir }
|
||||
},
|
||||
|
||||
// Resolve a dialed number: connect (advance to the wired dialogue), voicemail (a
|
||||
// known contact with no line here), or not-in-service (no such number).
|
||||
async dial(playthroughId, rawNumber) {
|
||||
const number = rawNumber.replace(/\D/g, '')
|
||||
if (!number) return { outcome: 'unknown' }
|
||||
const pt = (await pool.query<{ current_node_id: string | null }>('SELECT current_node_id FROM osint.playthroughs WHERE id=$1', [playthroughId])).rows[0]
|
||||
if (!pt?.current_node_id) return { outcome: 'unknown' }
|
||||
const phoneNode = (await pool.query<{ id: string }>(
|
||||
`SELECT pn.id FROM osint.story_node_terminals t JOIN osint.story_nodes pn ON pn.id=t.to_node_id
|
||||
WHERE t.parent_node_id=$1 AND pn.node_type='phone' LIMIT 1`, [pt.current_node_id])).rows[0]
|
||||
if (phoneNode) {
|
||||
const term = (await pool.query<{ to_node_id: string | null; name: string }>(
|
||||
`SELECT t.to_node_id, npc.name FROM osint.story_node_terminals t JOIN osint.npcs npc ON npc.id=t.npc_id
|
||||
WHERE t.parent_node_id=$1 AND regexp_replace(npc.phone_number,'\\D','','g')=$2 LIMIT 1`, [phoneNode.id, number])).rows[0]
|
||||
if (term?.to_node_id) {
|
||||
await pool.query('UPDATE osint.playthroughs SET current_node_id=$2,current_level_id=NULL,updated_at=NOW() WHERE id=$1', [playthroughId, term.to_node_id])
|
||||
const state = await stateForPlaythrough(playthroughId)
|
||||
return { outcome: 'connect', name: term.name, state: state ?? undefined }
|
||||
}
|
||||
}
|
||||
const npc = (await pool.query<{ name: string }>(
|
||||
`SELECT name FROM osint.npcs WHERE regexp_replace(phone_number,'\\D','','g')=$1 AND mystery_id IS NULL LIMIT 1`, [number])).rows[0]
|
||||
return npc ? { outcome: 'voicemail', name: npc.name } : { outcome: 'unknown' }
|
||||
},
|
||||
|
||||
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
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { randomBytes, scryptSync, timingSafeEqual } from 'node:crypto'
|
||||
import type { Pool } from 'pg'
|
||||
|
||||
export type UserDto = { id: string; handle: string; displayName: string; avatarUrl: string | null }
|
||||
|
||||
export interface UserRepository {
|
||||
registerUser(input: { handle: string; password: string; displayName: string }): Promise<{ user?: UserDto; error?: string }>
|
||||
authenticateUser(handle: string, password: string): Promise<UserDto | null>
|
||||
getUser(id: string): Promise<UserDto | null>
|
||||
}
|
||||
|
||||
const HANDLE = /^[a-z0-9_.-]{3,32}$/
|
||||
|
||||
// scrypt with a per-user random salt; stored as `salt:hash` hex. No dependency.
|
||||
function hashPassword(password: string): string {
|
||||
const salt = randomBytes(16)
|
||||
return `${salt.toString('hex')}:${scryptSync(password, salt, 64).toString('hex')}`
|
||||
}
|
||||
function verifyPassword(password: string, stored: string): boolean {
|
||||
const [saltHex, hashHex] = stored.split(':')
|
||||
if (!saltHex || !hashHex) return false
|
||||
const expected = Buffer.from(hashHex, 'hex')
|
||||
const actual = scryptSync(password, Buffer.from(saltHex, 'hex'), 64)
|
||||
return expected.length === actual.length && timingSafeEqual(expected, actual)
|
||||
}
|
||||
|
||||
export function createUserRepository(pool: Pool): UserRepository {
|
||||
const toDto = (row: { id: string; handle: string; display_name: string; avatar_url: string | null }): UserDto =>
|
||||
({ id: row.id, handle: row.handle, displayName: row.display_name, avatarUrl: row.avatar_url })
|
||||
|
||||
return {
|
||||
async registerUser({ handle: rawHandle, password, displayName: rawName }) {
|
||||
const handle = rawHandle.trim().toLowerCase()
|
||||
const displayName = rawName.trim().slice(0, 60)
|
||||
if (!HANDLE.test(handle)) return { error: 'Handle must be 3–32 chars: a–z, 0–9, . _ -' }
|
||||
if (password.length < 6) return { error: 'Password must be at least 6 characters' }
|
||||
if (!displayName) return { error: 'A display name is required' }
|
||||
const existing = await pool.query('SELECT 1 FROM osint.users WHERE handle=$1', [handle])
|
||||
if (existing.rowCount) return { error: 'That handle is taken' }
|
||||
const row = (await pool.query<{ id: string; handle: string; display_name: string; avatar_url: string | null }>(
|
||||
'INSERT INTO osint.users (handle,password_hash,display_name) VALUES ($1,$2,$3) RETURNING id,handle,display_name,avatar_url',
|
||||
[handle, hashPassword(password), displayName])).rows[0]
|
||||
return { user: toDto(row) }
|
||||
},
|
||||
|
||||
async authenticateUser(rawHandle, password) {
|
||||
const handle = rawHandle.trim().toLowerCase()
|
||||
const row = (await pool.query<{ id: string; handle: string; display_name: string; avatar_url: string | null; password_hash: string }>(
|
||||
'SELECT id,handle,display_name,avatar_url,password_hash FROM osint.users WHERE handle=$1', [handle])).rows[0]
|
||||
if (!row || !verifyPassword(password, row.password_hash)) return null
|
||||
return toDto(row)
|
||||
},
|
||||
|
||||
async getUser(id) {
|
||||
const row = (await pool.query<{ id: string; handle: string; display_name: string; avatar_url: string | null }>(
|
||||
'SELECT id,handle,display_name,avatar_url FROM osint.users WHERE id=$1', [id])).rows[0]
|
||||
return row ? toDto(row) : null
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user