Files
Dune/db/admin-archive.ts
T
Quintine e918e2e0c0 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.
2026-09-22 12:55:44 +10:00

218 lines
8.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
);
}