Add administrative room close and reopen controls

Preserve readable private tables, discussion and pending games while closure blocks new play, joins, messages, AI and seat mutations. Add live-authorized atomic closure audits, room-version/revision fences, dedicated controls and exact saved retries. Reopen with the saved pause/join settings and current credentials; restoring a removed but closed room keeps its AI stopped.

Verified types, lint, 5356 offline tests, 52 HTTP tests and build. Seventeen dedicated authenticated HTTP groups and independent privacy/persistence review passed. Desktop/mobile browser QA confirmed one close after a lost response and refresh, persistent readable notices, retained private draft, mobile reopening and saved-seat refresh. All 2,001 baseline rooms, 4,243 seats and related records preserved. Positive 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:31:02 +10:00
1 parent 238de3860d
commit 98b1b3261b
49 files changed
+4255 -86

No files matched your search

+7 -6
View File
@@ -9,7 +9,7 @@ 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_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; 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,
@@ -17,7 +17,7 @@ export function projectAdminRoom(row: DirectoryRow): AdminRoom {
status: 'unreadable', host: null, players: [], advanced: false, preview: false,
expansions: [], modules: [], turn: null, phase: 'Unavailable',
pending: { label: 'Saved room needs inspection', owners: [] },
control: { paused: row.control_paused === 1, joinLocked: row.control_join_locked === 1, revision: row.control_revision ?? 0, updatedAt: row.control_updated_at ?? null },
control: { ...(row.control_closed === 1 ? { closed: true as const } : {}), paused: row.control_paused === 1, joinLocked: row.control_join_locked === 1, revision: row.control_revision ?? 0, updatedAt: row.control_updated_at ?? null },
};
try {
const g = JSON.parse(row.state) as Game;
@@ -60,7 +60,7 @@ export async function readAdminDirectory(database: D1Database, identity: AdminId
const availability = search.get('availability') ?? 'all';
const removal = search.get('removal') ?? 'active';
if (q.length > 80 || !['all', ...statuses].includes(status) || !['all', 'basic', 'advanced'].includes(rules) ||
!['recent', 'oldest', 'code'].includes(sort) || !['all', 'running', 'paused', 'locked'].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) || !/^[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";
@@ -82,16 +82,17 @@ export async function readAdminDirectory(database: D1Database, identity: AdminId
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); }
if (availability === 'paused') where.push('c.paused = 1');
if (availability === 'running') where.push('COALESCE(c.paused, 0) = 0');
if (availability === 'running') where.push('COALESCE(c.paused, 0) = 0 AND COALESCE(cl.closed, 0) = 0 AND COALESCE(m.removed, 0) = 0');
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); }
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';
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 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(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(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);