import { randomUUID, scryptSync, timingSafeEqual } from 'node:crypto'; import { getGameDb, persist, type GameDb } from '@/lib/db/client'; // 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; } } // 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 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; const inst = await getGameDb(); 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 }; } 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 }; }