Files
Dune/db/admin-directory.ts
T
Quintine c2d392e155 Add administrator sign-in and private-safe room directory
Connect personal operator-provisioned admin keys, persisted roles and eight-hour sessions, current/all-session revocation and a filtered room directory. Additive schema protects final owner, revokes disabled accounts and records safe audit history. No game state changes; remaining administration scope stays explicit.

Verified: typecheck, lint, 5196 offline tests, 52 HTTP tests, production build; authenticated local HTTP and desktop/mobile browser checks. Independent privacy/security review fixed malformed nested roster searches and read-time session revocation. Preserved 1,671 prior rooms and 3,538 seats. Container/deployed acceptance follows publication.
2026-09-22 09:36:11 +10:00

91 lines
7.0 KiB
TypeScript

import type { Game } from '../game/engine';
import type { AdminDirectory, AdminRoom } from '../lib/admin-directory';
import { AdminError, type AdminIdentity } from './admin-access';
const phases = ['Storm', 'Spice Blow', 'CHOAM Charity', 'Bidding', 'Revival', 'Shipment & Movement', 'Battle', 'Spice Collection', 'Mentat Pause'];
const statuses = ['lobby', 'setup', 'playing', 'finished'] as const;
const expansionIds = ['ix', 'choam', 'ecaz'];
const text = (value: unknown, max = 80) => typeof value === 'string' ? value.slice(0, max) : '';
const integer = (value: unknown) => Number.isSafeInteger(value) && Number(value) >= 0 ? Number(value) : null;
/** Only emit allowlisted fields. Never call viewGame with an administrator's or host's seat. */
export function projectAdminRoom(row: { code: string; version: number; updated_at: number; state: string }): AdminRoom {
const base: AdminRoom = {
code: row.code, version: row.version, updatedAt: row.updated_at,
status: 'unreadable', host: null, players: [], advanced: false, preview: false,
expansions: [], modules: [], turn: null, phase: 'Unavailable',
pending: { label: 'Saved room needs inspection', owners: [] },
};
try {
const g = JSON.parse(row.state) as Game;
if (!g || !statuses.includes(g.status) || !Array.isArray(g.players) ||
g.players.length > 6 || g.players.some(p => !p || typeof p.id !== 'string' || p.id.length > 80 || typeof p.name !== 'string')) return base;
const players: AdminRoom['players'] = g.players.map(p => ({
id: text(p.id), name: text(p.name), faction: text(p.faction, 30),
control: p.bot ? 'ai' : p.autopilot ? 'autopilot' : 'human',
}));
const seated = (id: unknown): id is string => typeof id === 'string' && players.some(p => p.id === id);
let pending: AdminRoom['pending'] = { label: 'Table decision', owners: [] };
if (g.status === 'finished') pending = { label: 'Game finished', owners: [] };
else if (g.status === 'lobby') pending = { label: 'Lobby readiness', owners: g.players.filter(p => !p.ready).map(p => p.id) };
else if (g.status === 'setup') pending = { label: 'Starting choices', owners: [] };
else if (g.pendingTreacheryDiscard) pending = { label: 'Automatic continuation', owners: [] };
else if (g.response || g.phaseOpening) pending = { label: 'Shared response window', owners: [] };
else if (g.truthtrance) pending = { label: 'Truthtrance window', owners: [] };
else if (g.decision) pending = { label: 'Special decision', owners: seated(g.decision.player) ? [g.decision.player] : [] };
else if (g.battle) pending = { label: 'Battle decisions', owners: [g.battle.attacker, g.battle.defender].filter(seated) };
else if (g.nexus) pending = { label: 'Nexus negotiations', owners: [] };
else if (g.phase === 0 && g.stormDialers?.length) pending = { label: 'Storm dials', owners: g.stormDialers.filter(id => g.stormDials?.[id] === undefined).filter(seated) };
else if (g.auction) pending = { label: 'Auction decision', owners: seated(g.auction.active) ? [g.auction.active] : [] };
else if (seated(g.active)) pending = { label: 'Turn decision', owners: [g.active] };
return { ...base, status: g.status, host: seated(g.host) ? g.host : null, players,
advanced: g.advanced === true, preview: g.advancedPreview === true,
expansions: expansionIds.filter(id => Array.isArray(g.expansions) && g.expansions.includes(id)),
modules: [g.techTokens && 'Tech Tokens', g.strongholdCards && 'Stronghold Cards', g.leaderSkills && 'Leader Skills', g.homeworlds && 'Homeworlds', g.nexusCards && 'Nexus Cards', g.discoveryEnabled && 'Discoveries', g.ecazTreachery && 'Ecaz Treachery'].filter((s): s is string => typeof s === 'string'),
turn: integer(g.turn), phase: g.status === 'lobby' ? 'Lobby' : g.status === 'setup' ? 'Setup' : phases[g.phase] ?? 'Unknown phase', pending };
} catch { return base; }
}
export async function readAdminDirectory(database: D1Database, identity: AdminIdentity, search: URLSearchParams, now = Date.now()): Promise<AdminDirectory> {
const allowed = ['q', 'status', 'rules', 'sort', 'page'];
if ([...search.keys()].some(k => !allowed.includes(k) || search.getAll(k).length !== 1)) throw new AdminError('Invalid room filters.', 400);
const q = (search.get('q') ?? '').trim();
const status = search.get('status') ?? 'all';
const rules = search.get('rules') ?? 'all';
const sort = search.get('sort') ?? 'recent';
const rawPage = search.get('page') ?? '1';
if (q.length > 80 || !['all', ...statuses].includes(status) || !['all', 'basic', 'advanced'].includes(rules) ||
!['recent', 'oldest', 'code'].includes(sort) || !/^[1-9][0-9]{0,5}$/.test(rawPage)) throw new AdminError('Invalid room filters.', 400);
const page = Number(rawPage), pageSize = 25;
// json_valid protects the rest of the directory if a legacy save is malformed.
const state = "CASE WHEN json_valid(state) THEN state ELSE '{}' END";
// Each read is fenced again inside the transaction: prior authentication is
// not authority to read after another browser revokes this session.
const authority = `SELECT 1 FROM admin_sessions s JOIN admin_accounts a ON a.id = s.admin_id
WHERE s.token_hash = ? AND a.id = ? AND a.enabled = 1 AND s.revoked_at IS NULL
AND s.expires_at > ? AND s.generation = a.session_generation
AND a.role IN ('owner','operator','viewer')`;
const credentials = [identity.sessionHash, identity.id, now];
const where: string[] = [`EXISTS (${authority})`];
const args: (string | number)[] = [...credentials];
if (q) {
const players = `CASE WHEN json_type(${state}, '$.players') = 'array' THEN json_extract(${state}, '$.players') ELSE '[]' END`;
where.push(`(code LIKE ? ESCAPE '\\' OR EXISTS (SELECT 1 FROM json_each(${players}) p WHERE json_extract(CASE WHEN p.type = 'object' THEN p.value ELSE '{}' END, '$.name') LIKE ? ESCAPE '\\'))`);
const pattern = `%${q.replace(/[\\%_]/g, '\\$&')}%`;
args.push(pattern, pattern);
}
if (status !== 'all') { where.push(`json_extract(${state}, '$.status') = ?`); args.push(status); }
if (rules !== 'all') { where.push(`json_extract(${state}, '$.advanced') = ?`); args.push(rules === 'advanced' ? 1 : 0); }
const predicate = where.length ? 'WHERE ' + where.join(' AND ') : '';
const order = sort === 'code' ? 'code ASC' : `updated_at ${sort === 'oldest' ? 'ASC' : 'DESC'}, code ASC`;
const results = await database.batch([
database.prepare(authority).bind(...credentials),
database.prepare(`SELECT COUNT(*) AS total FROM rooms ${predicate}`).bind(...args),
database.prepare(`SELECT code,version,updated_at,state FROM rooms ${predicate} ORDER BY ${order} LIMIT ? OFFSET ?`).bind(...args, pageSize, (page - 1) * pageSize),
]);
if (!results[0].results.length) throw new AdminError('Administrator sign-in required.', 401);
const total = Number((results[1].results[0] as { total: number }).total);
const rows = results[2].results as { code: string; version: number; updated_at: number; state: string }[];
return { rooms: rows.map(projectAdminRoom), total, page, pageSize };
}