import { randomBytes, randomUUID, scryptSync, timingSafeEqual } from 'node:crypto'; import { getGameDb, persist, type GameDb } from '@/lib/db/client'; import type { PlayerRow } from './db/schema'; // v0.71.0 — session auth for the admin panel (and later multiplayer). Sessions are opaque // UUIDs in a DB table; the client stores one in localStorage ('flight.session') and sends it // as the X-Flight-Session header on every authenticated request. No JWT, no cookies: this is // a single-origin dev tool surface, not a public auth system. export const SESSION_HEADER = 'x-flight-session'; export const SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days from creation // Fixed digest compared against when the username is unknown, so every failed login runs one // full scrypt and the timing shape stays uniform (no user-existence oracle). export const DUMMY_HASH_FOR_UNKNOWN_USER = '0123456789abcdef0123456789abcdef$b9813a9a95c67878bd5b2c703bce6d57416b5d7e48496dfbf603bc4c6d59137f'; export interface AuthUser { id: string; username: string; admin: boolean; } // Verify a plaintext password against a stored "salt$hex" scrypt digest (16-byte hex salt, // 32-byte hash). Malformed digests never match. export function verifyPassword(password: string, storedHash: string): boolean { const [saltHex, hashHex] = storedHash.split('$'); if (!saltHex || !hashHex || hashHex.length !== 64) return false; const computed = scryptSync(password, saltHex, 32).toString('hex'); try { return timingSafeEqual(Buffer.from(computed, 'hex'), Buffer.from(hashHex, 'hex')); } catch { return false; } } // Hash a plaintext password into the "salt$hex" scrypt digest verifyPassword() checks — the same // call shape as the seeded admin's digest (v0.72.0): 16-byte hex salt passed to scryptSync as a // plain string, 32-byte hash. Used by /api/register and the test mint hook so every stored hash // verifies through verifyPassword(). export function hashPassword(password: string): string { const salt = randomBytes(16).toString('hex'); return `${salt}$${scryptSync(password, salt, 32).toString('hex')}`; } // Mint a session row for the user and lazily prune expired sessions. Persists so a restart // doesn't silently log everyone out mid-session. export async function createSession(inst: GameDb, userId: string): Promise { const id = randomUUID(); inst.raw.run('DELETE FROM sessions WHERE expires_at < ?', [Date.now()]); inst.raw.run('INSERT INTO sessions (id, user_id, expires_at) VALUES (?, ?, ?)', [ id, userId, Date.now() + SESSION_TTL_MS, ]); await persist(inst); return id; } // Resolve a session id to a live (non-expired) user, or null. async function resolveSession(inst: GameDb, sessionId: string): Promise { const res = inst.raw.exec( 'SELECT u.id, u.username, u.admin FROM sessions s JOIN users u ON u.id = s.user_id WHERE s.id = ? AND s.expires_at > ?', [sessionId, Date.now()], ); const row = res[0]?.values?.[0]; if (!row) return null; return { id: String(row[0]), username: String(row[1]), admin: Number(row[2]) === 1 }; } // Resolve the X-Flight-Session header to a live (non-expired) user, or null. export async function currentUser(req: Request): Promise { const sessionId = req.headers.get(SESSION_HEADER); if (!sessionId) return null; return resolveSession(await getGameDb(), sessionId); } export type AuthGuard = | { ok: true; inst: GameDb; user: AuthUser; player: PlayerRow } | { ok: false; response: Response }; // v1.1.0 (ISS-107): guard for every game route — resolves the session to its user AND that // user's players row (joined on username, so the seeded kamilkrzywda maps to id 'local'). // 401 when the session is missing/unknown/expired; 500 if the account has no player row // (register and the mint hook always create both together — a miss means broken data). async function finishAuth(user: AuthUser | null): Promise { if (!user) return { ok: false, response: Response.json({ error: 'authentication required' }, { status: 401 }) }; const inst = await getGameDb(); const res = inst.raw.exec('SELECT id, provider, username, cash, reputation FROM players WHERE username = ?', [user.username]); const row = res[0]?.values?.[0]; if (!row) return { ok: false, response: Response.json({ error: 'player row missing for this account' }, { status: 500 }) }; return { ok: true, inst, user, player: { id: String(row[0]), provider: String(row[1]), username: String(row[2]), cash: Number(row[3]), reputation: Number(row[4]) }, }; } export async function requireAuth(req: Request): Promise { return finishAuth(await currentUser(req)); } // /api/events only — EventSource cannot set headers, so the SSE route also accepts the session as a // ?session= query parameter (private infra; every other game route is header-only). export async function requireAuthSse(req: Request): Promise { const sessionId = req.headers.get(SESSION_HEADER) ?? new URL(req.url).searchParams.get('session'); if (!sessionId) return finishAuth(null); return finishAuth(await resolveSession(await getGameDb(), sessionId)); } export type AdminGuard = { ok: true; inst: GameDb; user: AuthUser } | { ok: false; response: Response }; // Guard for every /api/admin/* route — runs BEFORE any user input is touched. // No/expired session → 401, valid non-admin session → 403. export async function requireAdmin(req: Request): Promise { const user = await currentUser(req); if (!user) return { ok: false, response: Response.json({ error: 'authentication required' }, { status: 401 }) }; if (!user.admin) { return { ok: false, response: Response.json({ error: 'admin access required' }, { status: 403 }) }; } return { ok: true, inst: await getGameDb(), user }; }