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

+217
View File
@@ -0,0 +1,217 @@
import { AdminError, type AdminIdentity } from './admin-access';
import {
validAdminArchiveInput,
type AdminArchiveInput,
type AdminArchiveResult,
type AdminArchiveView,
} from '../lib/admin-archive';
const authority = `SELECT a.role 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 mutationAuthority = authority + " AND a.role IN ('owner','operator')";
const roomSelect = `SELECT r.code,r.version,EXISTS (SELECT 1 FROM room_closures WHERE room_code = r.code AND closed = 1) AS closed,EXISTS (SELECT 1 FROM room_removals WHERE room_code = r.code AND removed = 1) AS removed,COALESCE(m.archived,0) AS archived,
COALESCE(m.revision,0) AS revision,m.archived_at,m.updated_at,
COALESCE(c.paused,0) AS paused,COALESCE(c.join_locked,0) AS join_locked
FROM rooms r LEFT JOIN room_archives m ON m.room_code = r.code
LEFT JOIN room_controls c ON c.room_code = r.code
WHERE r.code = ? AND EXISTS (${authority})`;
type Row = {
closed: number;
removed: number;
code: string;
version: number;
archived: number;
revision: number;
archived_at: number | null;
updated_at: number | null;
paused: number;
join_locked: number;
};
type Receipt = {
operation_id: string;
actor_admin_id: string;
room_code: string;
request_hash: string;
applied_version: number;
applied_revision: number;
};
const project = (row: Row): AdminArchiveView => ({
code: row.code,
closed: row.closed === 1,
removed: row.removed === 1,
version: row.version,
archived: row.archived === 1,
revision: row.revision,
archivedAt: row.archived_at,
updatedAt: row.updated_at,
paused: row.paused === 1,
joinLocked: row.join_locked === 1,
});
function credentials(identity: AdminIdentity, code: string, now: number) {
if (
!identity ||
typeof identity.id !== 'string' ||
typeof identity.sessionHash !== 'string' ||
identity.sessionHash.length !== 64 ||
!/^[0-9a-f]{64}$/.test(identity.sessionHash)
)
throw new AdminError('Administrator sign-in required.', 401);
if (
typeof code !== 'string' ||
code.length !== 8 ||
!/^[A-Z0-9]{8}$/.test(code)
)
throw new AdminError('Use an eight-character room code.', 400);
return [identity.sessionHash, identity.id, now];
}
function authorize(result: D1Result, mutation = false) {
const role = (result.results[0] as { role: string } | undefined)?.role;
if (!role) throw new AdminError('Administrator sign-in required.', 401);
if (mutation && role === 'viewer')
throw new AdminError(
'Your administrator role does not permit this action.',
403,
);
}
/** Public operational metadata remains available for archived and unreadable saves. */
export async function readAdminArchive(
database: D1Database,
identity: AdminIdentity,
code: string,
now = Date.now(),
): Promise<AdminArchiveView> {
const args = credentials(identity, code, now);
const results = await database.batch([
database.prepare(authority).bind(...args),
database.prepare(roomSelect).bind(code, ...args),
]);
authorize(results[0]);
const row = results[1].results[0] as Row | undefined;
if (!row) throw new AdminError('Room not found.', 404);
return project(row);
}
/** An exact receipt, room-version fence and archive transition commit together.
* Neither transition changes saved state, credentials, closure or AI pacing. */
export async function applyAdminArchive(
database: D1Database,
identity: AdminIdentity,
code: string,
input: AdminArchiveInput,
now = Date.now(),
): Promise<AdminArchiveResult> {
const args = credentials(identity, code, now);
if (!validAdminArchiveInput(input))
throw new AdminError(
'Provide the room version, archive revision, requested state, a fresh operation identifier and a reason of 1–300 characters.',
400,
);
const { operationId, expectedVersion, expectedRevision, archived } = input;
const actorId = identity.id,
reason = input.reason.trim();
const digest = await crypto.subtle.digest(
'SHA-256',
new TextEncoder().encode(
JSON.stringify({ expectedVersion, expectedRevision, archived, reason }),
),
);
const requestHash = Array.from(new Uint8Array(digest), (byte) =>
byte.toString(16).padStart(2, '0'),
).join('');
const results = await database.batch([
database.prepare(authority).bind(...args),
// Existing receipt IDs cannot enter this write, regardless of later room state.
database
.prepare(`INSERT INTO admin_room_archives
(operation_id,actor_admin_id,room_code,request_hash,expected_version,expected_revision,
applied_version,applied_revision,archived,reason,before_metadata,after_metadata,created_at)
SELECT ?,?,r.code,?,?,?,r.version + 1,COALESCE(m.revision,0) + 1,?,?,
json_object('closed',json('true'),'removed',json('false'),'code',r.code,'version',r.version,'archived',json(CASE WHEN m.archived = 1 THEN 'true' ELSE 'false' END),
'revision',COALESCE(m.revision,0),'archivedAt',m.archived_at,'updatedAt',m.updated_at,
'paused',json(CASE WHEN c.paused = 1 THEN 'true' ELSE 'false' END),
'joinLocked',json(CASE WHEN c.join_locked = 1 THEN 'true' ELSE 'false' END)),
json_object('closed',json('true'),'removed',json('false'),'code',r.code,'version',r.version + 1,'archived',json(CASE WHEN ? = 1 THEN 'true' ELSE 'false' END),
'revision',COALESCE(m.revision,0) + 1,'archivedAt',?,'updatedAt',?,
'paused',json(CASE WHEN c.paused = 1 THEN 'true' ELSE 'false' END),
'joinLocked',json(CASE WHEN c.join_locked = 1 THEN 'true' ELSE 'false' END)),?
FROM rooms r LEFT JOIN room_archives m ON m.room_code = r.code
LEFT JOIN room_controls c ON c.room_code = r.code
WHERE r.code = ? AND r.version = ? AND COALESCE(m.revision,0) = ? AND COALESCE(m.archived,0) != ?
AND EXISTS (${mutationAuthority})
AND EXISTS (SELECT 1 FROM room_closures WHERE room_code = r.code AND closed = 1)
AND NOT EXISTS (SELECT 1 FROM room_removals WHERE room_code = r.code AND removed = 1)
AND NOT EXISTS (SELECT 1 FROM admin_room_archives WHERE operation_id = ?)`)
.bind(
operationId,
actorId,
requestHash,
expectedVersion,
expectedRevision,
Number(archived),
reason,
Number(archived),
archived ? now : null,
now,
now,
code,
expectedVersion,
expectedRevision,
Number(archived),
...args,
operationId,
),
// Archive is directory metadata only. Preserve the entire saved JSON and its timestamps.
database.prepare('UPDATE rooms SET version = version + 1 WHERE code = ? AND changes() = 1').bind(code),
database
.prepare(`INSERT INTO room_archives(room_code,archived,revision,archived_at,updated_at)
SELECT room_code,archived,applied_revision,CASE WHEN archived = 1 THEN created_at ELSE NULL END,created_at
FROM admin_room_archives WHERE operation_id = ? AND changes() = 1
ON CONFLICT(room_code) DO UPDATE SET archived = excluded.archived,revision = excluded.revision,
archived_at = excluded.archived_at,updated_at = excluded.updated_at`)
.bind(operationId),
database
.prepare(`SELECT operation_id,actor_admin_id,room_code,request_hash,applied_version,applied_revision
FROM admin_room_archives WHERE operation_id = ? AND EXISTS (${mutationAuthority})`)
.bind(operationId, ...args),
database.prepare(roomSelect).bind(code, ...args),
]);
authorize(results[0], true);
const receipt = results[4].results[0] as Receipt | undefined;
const row = results[5].results[0] as Row | undefined;
if (receipt) {
if (
receipt.actor_admin_id !== actorId ||
receipt.room_code !== code ||
receipt.request_hash !== requestHash
)
throw new AdminError(
'This archive operation is bound to a different administrator or request.',
409,
);
if (!row) throw new AdminError('Room not found.', 404);
return {
operationId,
replayed: results[1].meta.changes !== 1,
appliedRevision: receipt.applied_revision,
appliedVersion: receipt.applied_version,
room: project(row),
};
}
if (!row) throw new AdminError('Room not found.', 404);
if (row.removed) throw new AdminError('Restore this removed room before changing its archive.', 409);
if (!row.closed) throw new AdminError('Close this room before changing its archive state.', 409);
if (row.version !== expectedVersion || row.revision !== expectedRevision)
throw new AdminError(
'The room or archive settings changed. Refresh before choosing a new operation.',
409,
);
throw new AdminError(
archived
? 'This room is already archived.'
: 'This room is already unarchived.',
409,
);
}
+5 -1
View File
@@ -11,13 +11,14 @@ const authority = `SELECT a.role FROM admin_sessions s JOIN admin_accounts a ON
AND s.expires_at > ? AND s.generation = a.session_generation
AND a.role IN ('owner','operator','viewer')`;
const mutationAuthority = authority + " AND a.role IN ('owner','operator')";
const roomSelect = `SELECT r.code,r.version,EXISTS (SELECT 1 FROM room_removals WHERE room_code = r.code AND removed = 1) AS removed,COALESCE(m.closed,0) AS closed,
const roomSelect = `SELECT r.code,r.version,EXISTS (SELECT 1 FROM room_archives WHERE room_code = r.code AND archived = 1) AS archived,EXISTS (SELECT 1 FROM room_removals WHERE room_code = r.code AND removed = 1) AS removed,COALESCE(m.closed,0) AS closed,
COALESCE(m.revision,0) AS revision,m.closed_at,m.updated_at,
COALESCE(c.paused,0) AS paused,COALESCE(c.join_locked,0) AS join_locked
FROM rooms r LEFT JOIN room_closures m ON m.room_code = r.code
LEFT JOIN room_controls c ON c.room_code = r.code
WHERE r.code = ? AND EXISTS (${authority})`;
type Row = {
archived: number;
removed: number;
code: string;
version: number;
@@ -38,6 +39,7 @@ type Receipt = {
};
const project = (row: Row): AdminClosureView => ({
code: row.code,
...(row.archived === 1 ? { archived: true as const } : {}),
removed: row.removed === 1,
version: row.version,
closed: row.closed === 1,
@@ -139,6 +141,7 @@ export async function applyAdminClosure(
LEFT JOIN room_controls c ON c.room_code = r.code
WHERE r.code = ? AND r.version = ? AND COALESCE(m.revision,0) = ? AND COALESCE(m.closed,0) != ?
AND EXISTS (${mutationAuthority})
AND NOT EXISTS (SELECT 1 FROM room_archives WHERE room_code = r.code AND archived = 1)
AND NOT EXISTS (SELECT 1 FROM room_removals WHERE room_code = r.code AND removed = 1)
AND NOT EXISTS (SELECT 1 FROM admin_room_closures WHERE operation_id = ?)`)
.bind(
@@ -211,6 +214,7 @@ export async function applyAdminClosure(
}
if (!row) throw new AdminError('Room not found.', 404);
if (row.removed) throw new AdminError('Restore this removed room before changing its closure.', 409);
if (row.archived) throw new AdminError('Unarchive this room before reopening it.', 409);
if (row.version !== expectedVersion || row.revision !== expectedRevision)
throw new AdminError(
'The room or closure settings changed. Refresh before choosing a new operation.',
+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);
+3 -1
View File
@@ -11,13 +11,14 @@ const authority = `SELECT a.role FROM admin_sessions s JOIN admin_accounts a ON
AND s.expires_at > ? AND s.generation = a.session_generation
AND a.role IN ('owner','operator','viewer')`;
const mutationAuthority = authority + " AND a.role IN ('owner','operator')";
const roomSelect = `SELECT r.code,r.version,EXISTS (SELECT 1 FROM room_closures WHERE room_code = r.code AND closed = 1) AS closed,COALESCE(m.removed,0) AS removed,
const roomSelect = `SELECT r.code,r.version,EXISTS (SELECT 1 FROM room_archives WHERE room_code = r.code AND archived = 1) AS archived,EXISTS (SELECT 1 FROM room_closures WHERE room_code = r.code AND closed = 1) AS closed,COALESCE(m.removed,0) AS removed,
COALESCE(m.revision,0) AS revision,m.removed_at,m.updated_at,
COALESCE(c.paused,0) AS paused,COALESCE(c.join_locked,0) AS join_locked
FROM rooms r LEFT JOIN room_removals m ON m.room_code = r.code
LEFT JOIN room_controls c ON c.room_code = r.code
WHERE r.code = ? AND EXISTS (${authority})`;
type Row = {
archived: number;
closed: number;
code: string;
version: number;
@@ -38,6 +39,7 @@ type Receipt = {
};
const project = (row: Row): AdminRemovalView => ({
code: row.code,
...(row.archived === 1 ? { archived: true as const } : {}),
...(row.closed === 1 ? { closed: true as const } : {}),
version: row.version,
removed: row.removed === 1,
+45
View File
@@ -136,6 +136,51 @@ export const adminRoomClosures = sqliteTable(
],
);
// Archive organizes closed rooms without changing saved games or credentials.
export const roomArchives = sqliteTable(
'room_archives',
{
roomCode: text('room_code')
.primaryKey()
.references(() => rooms.code, { onDelete: 'cascade' }),
archived: integer('archived').notNull().default(0),
revision: integer('revision').notNull().default(0),
archivedAt: integer('archived_at'),
updatedAt: integer('updated_at').notNull(),
},
(table) => [
check('room_archives_archived', sql`${table.archived} IN (0, 1)`),
check('room_archives_revision', sql`${table.revision} >= 0`),
check(
'room_archives_time',
sql`(${table.archived} = 1 AND ${table.archivedAt} IS NOT NULL) OR (${table.archived} = 0 AND ${table.archivedAt} IS NULL)`,
),
],
);
// Exact operation receipts also retain safe audit evidence independently of rooms/accounts.
export const adminRoomArchives = sqliteTable(
'admin_room_archives',
{
operationId: text('operation_id').primaryKey(),
actorAdminId: text('actor_admin_id').notNull(),
roomCode: text('room_code').notNull(),
requestHash: text('request_hash').notNull(),
expectedVersion: integer('expected_version').notNull(),
expectedRevision: integer('expected_revision').notNull(),
appliedVersion: integer('applied_version').notNull(),
appliedRevision: integer('applied_revision').notNull(),
archived: integer('archived').notNull(),
reason: text('reason').notNull(),
beforeMetadata: text('before_metadata').notNull(),
afterMetadata: text('after_metadata').notNull(),
createdAt: integer('created_at').notNull(),
},
(table) => [
index('admin_room_archives_room').on(table.roomCode, table.createdAt),
],
);
// Durable receipts are also the room operations audit. They intentionally survive
// account/room removal, and contain no credentials, game state or private cards.
export const adminRoomAudit = sqliteTable(