Add reversible administrator room archiving

Organize closed rooms with archive filters, live-authorized atomic receipts and exact saved retries. Preserve saved games, timestamps, credentials, discussion and AI deadlines. Unarchive leaves play closed; reopen requires unarchive, and removal/restoration preserves both flags.

Verified types, lint, 5379 offline tests, 52 HTTP tests and build. Twenty dedicated administrator HTTP groups and independent privacy/persistence review passed. Desktop/mobile browser QA confirmed a single archive after a lost response and refresh, readable recovery text, mobile unarchive and separate reopening, and saved-seat restoration. All 2,049 baseline rooms, 4,348 seats and related records preserved. Authenticated production admin acceptance awaits authorized QA access; full admin and rules gates remain open.
This commit is contained in:
Quintine committed 2026-09-22 12:55:44 +10:00
1 parent 98b1b3261b
commit e918e2e0c0
35 files changed
+3624 -38

No files matched your search

+8 -6
View File
@@ -9,11 +9,11 @@ const text = (value: unknown, max = 80) => typeof value === 'string' ? value.sli
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. */
type DirectoryRow = { code: string; version: number; updated_at: number; state: string; removed?: number; control_closed?: number; control_paused?: number; control_join_locked?: number; control_revision?: number; control_updated_at?: number | null };
type DirectoryRow = { code: string; version: number; updated_at: number; state: string; removed?: number; archived?: number; control_closed?: number; control_paused?: number; control_join_locked?: number; control_revision?: number; control_updated_at?: number | null };
export function projectAdminRoom(row: DirectoryRow): AdminRoom {
const base: AdminRoom = {
code: row.code, version: row.version, updatedAt: row.updated_at,
removed: row.removed === 1,
removed: row.removed === 1, archived: row.archived === 1,
status: 'unreadable', host: null, players: [], advanced: false, preview: false,
expansions: [], modules: [], turn: null, phase: 'Unavailable',
pending: { label: 'Saved room needs inspection', owners: [] },
@@ -50,7 +50,7 @@ export function projectAdminRoom(row: DirectoryRow): AdminRoom {
}
export async function readAdminDirectory(database: D1Database, identity: AdminIdentity, search: URLSearchParams, now = Date.now()): Promise<AdminDirectory> {
const allowed = ['q', 'status', 'rules', 'sort', 'page', 'availability', 'removal'];
const allowed = ['q', 'status', 'rules', 'sort', 'page', 'availability', 'removal', 'archive'];
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';
@@ -59,8 +59,9 @@ export async function readAdminDirectory(database: D1Database, identity: AdminId
const rawPage = search.get('page') ?? '1';
const availability = search.get('availability') ?? 'all';
const removal = search.get('removal') ?? 'active';
const archive = search.get('archive') ?? 'unarchived';
if (q.length > 80 || !['all', ...statuses].includes(status) || !['all', 'basic', 'advanced'].includes(rules) ||
!['recent', 'oldest', 'code'].includes(sort) || !['all', 'running', 'paused', 'locked', 'closed'].includes(availability) || !['active', 'removed', 'all'].includes(removal) || !/^[1-9][0-9]{0,5}$/.test(rawPage)) throw new AdminError('Invalid room filters.', 400);
!['recent', 'oldest', 'code'].includes(sort) || !['all', 'running', 'paused', 'locked', 'closed'].includes(availability) || !['active', 'removed', 'all'].includes(removal) || !['unarchived', 'archived', 'all'].includes(archive) || !/^[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";
@@ -86,13 +87,14 @@ export async function readAdminDirectory(database: D1Database, identity: AdminId
if (availability === 'closed') where.push('cl.closed = 1');
if (availability === 'locked') where.push('c.join_locked = 1');
if (removal !== 'all') { where.push('COALESCE(m.removed,0) = ?'); args.push(removal === 'removed' ? 1 : 0); }
if (archive !== 'all') { where.push('COALESCE(ar.archived,0) = ?'); args.push(archive === 'archived' ? 1 : 0); }
const predicate = where.length ? 'WHERE ' + where.join(' AND ') : '';
const order = sort === 'code' ? 'r.code ASC' : `r.updated_at ${sort === 'oldest' ? 'ASC' : 'DESC'}, r.code ASC`;
const source = 'FROM rooms r LEFT JOIN room_controls c ON c.room_code = r.code LEFT JOIN room_removals m ON m.room_code = r.code LEFT JOIN room_closures cl ON cl.room_code = r.code';
const source = 'FROM rooms r LEFT JOIN room_controls c ON c.room_code = r.code LEFT JOIN room_removals m ON m.room_code = r.code LEFT JOIN room_closures cl ON cl.room_code = r.code LEFT JOIN room_archives ar ON ar.room_code = r.code';
const results = await database.batch([
database.prepare(authority).bind(...credentials),
database.prepare(`SELECT COUNT(*) AS total ${source} ${predicate}`).bind(...args),
database.prepare(`SELECT r.code,r.version,r.updated_at,r.state,COALESCE(m.removed,0) AS removed,COALESCE(cl.closed,0) AS control_closed,COALESCE(c.paused,0) AS control_paused,COALESCE(c.join_locked,0) AS control_join_locked,COALESCE(c.revision,0) AS control_revision,c.updated_at AS control_updated_at ${source} ${predicate} ORDER BY ${order} LIMIT ? OFFSET ?`).bind(...args, pageSize, (page - 1) * pageSize),
database.prepare(`SELECT r.code,r.version,r.updated_at,r.state,COALESCE(ar.archived,0) AS archived,COALESCE(m.removed,0) AS removed,COALESCE(cl.closed,0) AS control_closed,COALESCE(c.paused,0) AS control_paused,COALESCE(c.join_locked,0) AS control_join_locked,COALESCE(c.revision,0) AS control_revision,c.updated_at AS control_updated_at ${source} ${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);