diff --git a/index.html b/index.html index f40471a..83ce924 100644 --- a/index.html +++ b/index.html @@ -4,6 +4,9 @@ + + + GUPI OSINT Board diff --git a/migrations/035_notebook.sql b/migrations/035_notebook.sql new file mode 100644 index 0000000..39f7fcc --- /dev/null +++ b/migrations/035_notebook.sql @@ -0,0 +1,10 @@ +-- The player's field notebook: lines captured from NPCs during play, per playthrough. +-- A page can later be torn onto the board as a note exhibit. +CREATE TABLE osint.notebook_pages ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + playthrough_id UUID NOT NULL REFERENCES osint.playthroughs(id) ON DELETE CASCADE, + text TEXT NOT NULL, + source_utterance_id UUID REFERENCES osint.utterances(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE INDEX notebook_pages_playthrough_idx ON osint.notebook_pages (playthrough_id, created_at); diff --git a/migrations/036_users.sql b/migrations/036_users.sql new file mode 100644 index 0000000..d82a477 --- /dev/null +++ b/migrations/036_users.sql @@ -0,0 +1,12 @@ +-- Locally-issued player accounts. GUPI mints the JWT for now; external_id is +-- reserved so a glitch.university key-exchange can later link/migrate an account +-- without changing how playthroughs bind (they key off the JWT sub = users.id). +CREATE TABLE osint.users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + handle TEXT NOT NULL UNIQUE CHECK (handle ~ '^[a-z0-9_.-]{3,32}$'), + password_hash TEXT NOT NULL, + display_name TEXT NOT NULL, + avatar_url TEXT, + external_id TEXT UNIQUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); diff --git a/mysteries/barricelli-files/mystery.json b/mysteries/barricelli-files/mystery.json new file mode 100644 index 0000000..235e63e --- /dev/null +++ b/mysteries/barricelli-files/mystery.json @@ -0,0 +1,359 @@ +{ + "slug": "barricelli-files", + "title": "The Barricelli Files", + "narrative": { + "cast": [ + { + "key": "dobby", + "name": "Dobby", + "role": "Glitch University · Student Counsellor", + "defaultPose": "neutral" + }, + { + "key": "glitch-hunter", + "name": "Glitch Hunter", + "role": "Glitch University · Cosmotologist", + "defaultPose": "neutral", + "phoneNumber": "5550100", + "email": "hunter@glitch.university" + } + ], + "graph": { + "entry": "dobby-intro", + "nodes": [ + { + "key": "dobby-intro", + "type": "dialogue", + "label": "Scene 5 · Dobby", + "x": 200, + "y": 60, + "terminals": [ + { + "key": "continue", + "label": "Continue", + "to": "dobby-tasks" + } + ], + "utterances": [ + { + "npc": "dobby", + "text": "Oh — you're the new Principal Investigator. I'm Dobby, student counsellor. Welcome to the Glitch University PI programme." + }, + { + "npc": "dobby", + "text": "You'll want the GUPI course over at glitch.university — Open Source Intelligence, basics for citizen scientists. If you can't be bothered, there's a video that covers the absolute minimum." + }, + { + "npc": "dobby", + "text": "It's remarkably simple, provided you have sufficient intelligence. Find the evidence on the internet, screenshot it, paste it in here. Fill out the source, date and URL so it can be verified — then connect it to the claim with a red thread and submit the report to the professor. You'll hear back within five earth-seconds." + } + ] + }, + { + "key": "dobby-tasks", + "type": "dialogue", + "label": "Scene 6.1 · Dobby tasks", + "x": 200, + "y": 240, + "terminals": [ + { + "key": "continue", + "label": "Find a phone", + "to": "note-board" + } + ], + "utterances": [ + { + "key": "d0", + "npc": "dobby", + "text": "Back already? Well — we do have something. Our Cosmotologist is investigating somebody. Some old mathematician, it seems. You should call him up." + }, + { + "key": "d-name", + "parent": "d0", + "utterer": "player", + "text": "Who's the mathematician?", + "awardsFlag": "dobby.knows_barricelli_name" + }, + { + "key": "d-name-a", + "parent": "d-name", + "npc": "dobby", + "text": "Niels Aall Barricelli. There — don't say I never give you anything." + }, + { + "key": "d-more", + "parent": "d-name-a", + "utterer": "player", + "text": "Tell me more about him.", + "awardsFlag": "dobby.knows_barricelli" + }, + { + "key": "d-more-a", + "parent": "d-more", + "npc": "dobby", + "text": "Italian-Norwegian mathematician. Niels — spelled with an 'e'. That's all you're getting from me." + }, + { + "key": "d-call2", + "parent": "d-more-a", + "utterer": "player", + "text": "Right. I'll call him.", + "terminal": "continue" + }, + { + "key": "d-call1", + "parent": "d-name-a", + "utterer": "player", + "text": "I'll call him.", + "terminal": "continue" + }, + { + "key": "d-reach", + "parent": "d0", + "utterer": "player", + "text": "How do I reach him?" + }, + { + "key": "d-reach-a", + "parent": "d-reach", + "npc": "dobby", + "text": "There's a number floating about. Find a phone. You're an investigator — investigate." + }, + { + "key": "d-reach-go", + "parent": "d-reach-a", + "utterer": "player", + "text": "On it.", + "terminal": "continue" + } + ] + }, + { + "key": "note-board", + "type": "level", + "label": "Scene 6 · Note board", + "x": 200, + "y": 420, + "templateSlug": "barricelli-phone-note", + "terminals": [ + { + "key": "report_back", + "label": "Call Glitch Hunter", + "to": "hunter-intro" + }, + { + "key": "phone", + "label": "Phone", + "to": "phone" + } + ] + }, + { + "key": "phone", + "type": "phone", + "label": "Phone (dial Glitch Hunter)", + "x": 470, + "y": 420, + "terminals": [ + { + "key": "call-hunter", + "label": "Glitch Hunter", + "npc": "glitch-hunter", + "to": "hunter-intro" + } + ] + }, + { + "key": "hunter-intro", + "type": "dialogue", + "label": "Scene 6.2 · Glitch Hunter", + "x": 200, + "y": 600, + "terminals": [ + { + "key": "proceed", + "label": "Take the task", + "to": "hunter-correct" + }, + { + "key": "decline", + "label": "Back", + "to": "note-board" + } + ], + "utterances": [ + { + "key": "h0", + "npc": "glitch-hunter", + "text": "Right — Dobby said you were up for some research tasks. I want a junior investigator. Interesting stuff, piling up everywhere. Question is: do you want in, or do you want out?" + }, + { + "key": "h-out", + "parent": "h0", + "utterer": "player", + "text": "Out.", + "terminal": "decline" + }, + { + "key": "h-in", + "parent": "h0", + "utterer": "player", + "text": "I'm in." + }, + { + "key": "h-in-a", + "parent": "h-in", + "npc": "glitch-hunter", + "text": "Good. I think we just found the entrance to a rabbit hole. I'll give you the name: Niels Aall Barricelli. Does it ring a bell?" + }, + { + "key": "h-noidea", + "parent": "h-in-a", + "utterer": "player", + "text": "No — no idea who that is.", + "terminal": "proceed" + }, + { + "key": "h-knows", + "parent": "h-in-a", + "utterer": "player", + "text": "Niels Aall Barricelli — a Norwegian-Italian mathematician.", + "requiresFlag": "dobby.knows_barricelli", + "terminal": "proceed" + }, + { + "key": "h-name", + "parent": "h-in-a", + "utterer": "player", + "text": "Niels Aall Barricelli.", + "requiresFlag": "dobby.knows_barricelli_name", + "terminal": "proceed" + } + ] + }, + { + "key": "hunter-correct", + "type": "dialogue", + "label": "Scene 6.2 · The task", + "x": 200, + "y": 780, + "terminals": [ + { + "key": "continue", + "label": "To the board", + "to": "scene7" + } + ], + "utterances": [ + { + "npc": "glitch-hunter", + "text": "Ah — impressive. He's not very well known. But you got the first name wrong there. It's Nils, not Niels. Easy mistake to make. Might come in handy to remember that." + }, + { + "npc": "glitch-hunter", + "text": "Anyway — the task is simple. Nils Aall Barricelli. He wasn't just a brilliant mathematician; he was an inventor. Prove that to me, using open sources, and I'll take you on board." + } + ] + }, + { + "key": "scene7", + "type": "level", + "label": "Scene 7 · Prove he was an inventor", + "x": 200, + "y": 960, + "templateSlug": "barricelli-inventor-proof", + "terminals": [ + { + "key": "report_back", + "label": "Report back", + "to": "hunter-drawer" + } + ] + }, + { + "key": "hunter-drawer", + "type": "dialogue", + "label": "Scene 8 · Glitch Hunter", + "x": 200, + "y": 1140, + "terminals": [ + { + "key": "continue", + "label": "Continue", + "to": "luggage" + } + ], + "utterances": [ + { + "npc": "glitch-hunter", + "text": "Ha — there it is. Using a suitcase as drawers. You can just take the drawer with you. Told you he was a genius." + } + ] + }, + { + "key": "luggage", + "type": "merit", + "label": "Scene 9 · The Barricelli Luggage", + "x": 200, + "y": 1320, + "awardsFlag": "barricelli_luggage", + "terminals": [ + { + "key": "continue", + "label": "Continue", + "to": "hunter-deepweb" + } + ] + }, + { + "key": "hunter-deepweb", + "type": "dialogue", + "label": "Scene 10 · The deep web", + "x": 200, + "y": 1500, + "terminals": [ + { + "key": "done", + "label": "End", + "to": null + } + ], + "utterances": [ + { + "key": "w0", + "npc": "glitch-hunter", + "text": "Well done — you've mastered Open Source Intelligence. Barricelli was an unusual man. Not only an inventor and a mathematician. A genius. He saved his family too, you know." + }, + { + "key": "w-who", + "parent": "w0", + "utterer": "player", + "text": "Who are you, exactly?" + }, + { + "key": "w-who-a", + "parent": "w-who", + "npc": "glitch-hunter", + "text": "Me? I study Philosophical Cosmology. There's a video series and a book over at Glitch University, if you're curious.", + "terminal": "done" + }, + { + "key": "w-family", + "parent": "w0", + "utterer": "player", + "text": "Saved his family?" + }, + { + "key": "w-family-a", + "parent": "w-family", + "npc": "glitch-hunter", + "text": "A remarkable character. Yes, he did. But perhaps I shouldn't tell you — it's a fitting task, one that would prove some real skill. If you can tell me how old he was when he saved his mother and father, and had a newspaper write about it — you'll need the deep web. Historical archives, national libraries. Figure that out, and you're ready for something deeper.", + "terminal": "done" + } + ] + } + ] + } + } +} diff --git a/server/auth.ts b/server/auth.ts index 7982c84..bdc4b17 100644 --- a/server/auth.ts +++ b/server/auth.ts @@ -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') diff --git a/server/index.ts b/server/index.ts index 670b186..8c4fc0e 100644 --- a/server/index.ts +++ b/server/index.ts @@ -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 { diff --git a/server/narrativeRepository.ts b/server/narrativeRepository.ts index e21d9a1..e8dfa60 100644 --- a/server/narrativeRepository.ts +++ b/server/narrativeRepository.ts @@ -59,6 +59,11 @@ export interface NarrativeRepository { listAchievements(playthroughId: string): Promise 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 + 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 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 diff --git a/server/userRepository.ts b/server/userRepository.ts new file mode 100644 index 0000000..8f5ed92 --- /dev/null +++ b/server/userRepository.ts @@ -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 + getUser(id: string): Promise +} + +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 + }, + } +} diff --git a/src/App.tsx b/src/App.tsx index 25a1dfc..a5650cf 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' +import { lazy, Suspense, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import { BookOpen, Building2, CalendarClock, Camera, Check, ChevronRight, CircleHelp, ClipboardCheck, FileText, FolderOpen, Hand, Image as ImageIcon, Images, Info, Link2, Minus, MousePointer2, Network, Newspaper, NotebookPen, Pencil, Plus, RotateCcw, Search, Trash2, Upload, UserRound, X, ZoomIn, ZoomOut } from 'lucide-react' import type { BriefConcept, CaseDocument, CaseReport, CaseReportSubmissionInput, CaseState, Connection, DocumentCaptureKind, DocumentSemanticAnalysis, EventExhibit, Evidence, EvidenceMatchRuleDefinition, Exhibit, ExhibitRelation, FolderExhibit, LevelBrief, LevelFlag, LevelGoal, OrganizationKind, PartyExhibit, PartyKind, SourceFileType, TimelineRange, TimelineView, UploadedCaseDocument } from './types' import { AdminPanel } from './admin' @@ -7,6 +7,9 @@ import { clampBoardZoom, containedIds, dateValue, discardExhibit, folderIsOpen, import { defaultConnectionLabel, documentCapture, documentCaptureRegistry, documentExhibits, documentWidget, evidenceExhibits, exhibitWidget, mugshotIdentification, type ExhibitWidgetContext, type WidgetCommand } from './exhibitRegistry' import type { PlaythroughState } from './narrative' +// three.js stays out of the board bundle until the player opens the inventory. +const Inventory = lazy(() => import('./inventory').then(m => ({ default: m.Inventory }))) + const BOARD_W = 2400 const BOARD_H = 1500 const SOURCE_FILE_TYPES: { value: SourceFileType; label: string }[] = [ @@ -72,6 +75,7 @@ export function App() { const [recentlyCreatedConnectionId, setRecentlyCreatedConnectionId] = useState(null) const [threadDraft, setThreadDraft] = useState(null) const [flagsOpen, setFlagsOpen] = useState(false) + const [inventoryOpen, setInventoryOpen] = useState(false) const [matchRulesOpen, setMatchRulesOpen] = useState(false) const [arrivingExhibitIds, setArrivingExhibitIds] = useState([]) const [activePlaythroughId, setActivePlaythroughId] = useState(null) @@ -210,8 +214,8 @@ export function App() { setOpenDoc(null); setSelected(ev.id); setRecentlyCreatedExhibitId(ev.id); setStatus('EVIDENCE EXTRACTED · PROVENANCE ATTACHED') } - const addNote = () => { - const content = window.prompt('What do you think this evidence means?')?.trim() + const addNote = (preset?: string) => { + const content = typeof preset === 'string' ? preset : window.prompt('What do you think this evidence means?')?.trim() if (!content || !caseState) return const { viewport } = caseState const position = nextOpenBoardPosition(caseState.exhibits, { x: Math.max(100, (500 - viewport.x) / viewport.zoom), y: Math.max(100, (330 - viewport.y) / viewport.zoom) }, { width: 108 }) @@ -538,6 +542,10 @@ export function App() { }) const timelineView = caseState.views.find((view): view is TimelineView => view.type === 'timeline') return
+ {inventoryOpen && activePlaythroughId && + window.location.assign('/?resume=1') }} + onTearToBoard={text => { addNote(text); setInventoryOpen(false) }} onClose={() => setInventoryOpen(false)} /> + }
GUOSINT BOARD / {requestedEditMode && caseState.editingAllowed ? 'LEVEL EDITOR' : 'CASE TERMINAL'}