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

+225
View File
@@ -0,0 +1,225 @@
import { AdminError, type AdminIdentity } from './admin-access';
import {
validAdminClosureInput,
type AdminClosureInput,
type AdminClosureResult,
type AdminClosureView,
} from '../lib/admin-closure';
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_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 = {
removed: number;
code: string;
version: number;
closed: number;
revision: number;
closed_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): AdminClosureView => ({
code: row.code,
removed: row.removed === 1,
version: row.version,
closed: row.closed === 1,
revision: row.revision,
closedAt: row.closed_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 closed and unreadable saves. */
export async function readAdminClosure(
database: D1Database,
identity: AdminIdentity,
code: string,
now = Date.now(),
): Promise<AdminClosureView> {
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 closure transition commit together.
* Neither transition replaces state or credentials; reopen only refreshes AI pacing. */
export async function applyAdminClosure(
database: D1Database,
identity: AdminIdentity,
code: string,
input: AdminClosureInput,
now = Date.now(),
): Promise<AdminClosureResult> {
const args = credentials(identity, code, now);
if (!validAdminClosureInput(input))
throw new AdminError(
'Provide the room version, closure revision, requested state, a fresh operation identifier and a reason of 1–300 characters.',
400,
);
const { operationId, expectedVersion, expectedRevision, closed } = input;
const actorId = identity.id,
reason = input.reason.trim();
const digest = await crypto.subtle.digest(
'SHA-256',
new TextEncoder().encode(
JSON.stringify({ expectedVersion, expectedRevision, closed, 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_closures
(operation_id,actor_admin_id,room_code,request_hash,expected_version,expected_revision,
applied_version,applied_revision,closed,reason,before_metadata,after_metadata,created_at)
SELECT ?,?,r.code,?,?,?,r.version + 1,COALESCE(m.revision,0) + 1,?,?,
json_object('code',r.code,'version',r.version,'closed',json(CASE WHEN m.closed = 1 THEN 'true' ELSE 'false' END),
'revision',COALESCE(m.revision,0),'closedAt',m.closed_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('code',r.code,'version',r.version + 1,'closed',json(CASE WHEN ? = 1 THEN 'true' ELSE 'false' END),
'revision',COALESCE(m.revision,0) + 1,'closedAt',?,'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_closures 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.closed,0) != ?
AND EXISTS (${mutationAuthority})
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(
operationId,
actorId,
requestHash,
expectedVersion,
expectedRevision,
Number(closed),
reason,
Number(closed),
closed ? now : null,
now,
now,
code,
expectedVersion,
expectedRevision,
Number(closed),
...args,
operationId,
),
// Closing never parses or changes JSON. Reopen tolerates malformed saved state.
database
.prepare(`UPDATE rooms SET version = version + 1,
state = CASE WHEN ? = 0 AND NOT EXISTS (
SELECT 1 FROM room_controls WHERE room_code = rooms.code AND paused = 1
) THEN CASE WHEN json_valid(state) THEN CASE WHEN
json_extract(state, '$.status') IN ('setup','playing') AND
EXISTS (SELECT 1 FROM json_each(CASE WHEN json_type(state, '$.players') = 'array' THEN json_extract(state, '$.players') ELSE '[]' END) p
WHERE json_extract(CASE WHEN p.type = 'object' THEN p.value ELSE '{}' END, '$.bot') IS NOT NULL
OR json_extract(CASE WHEN p.type = 'object' THEN p.value ELSE '{}' END, '$.autopilot') IS NOT NULL)
THEN json_set(state, '$.botsPending', json('true'), '$.botNextActionAt', ?)
ELSE state END ELSE state END ELSE state END
WHERE code = ? AND changes() = 1`)
.bind(Number(closed), now + 1500, code),
database
.prepare(`INSERT INTO room_closures(room_code,closed,revision,closed_at,updated_at)
SELECT room_code,closed,applied_revision,CASE WHEN closed = 1 THEN created_at ELSE NULL END,created_at
FROM admin_room_closures WHERE operation_id = ? AND changes() = 1
ON CONFLICT(room_code) DO UPDATE SET closed = excluded.closed,revision = excluded.revision,
closed_at = excluded.closed_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_closures 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 closure 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 closure.', 409);
if (row.version !== expectedVersion || row.revision !== expectedRevision)
throw new AdminError(
'The room or closure settings changed. Refresh before choosing a new operation.',
409,
);
throw new AdminError(
closed
? 'This room is already closed.'
: 'This room is already open.',
409,
);
}
+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);
+4 -1
View File
@@ -11,14 +11,16 @@ const authority = `SELECT a.role FROM admin_sessions s JOIN admin_accounts a ON
AND a.role IN ('owner','operator','viewer')`;
const mutationAuthority = authority + " AND a.role IN ('owner','operator')";
const fields = `COALESCE(c.paused, 0) AS paused, COALESCE(c.join_locked, 0) AS join_locked,
COALESCE(c.revision, 0) AS revision, c.updated_at`;
COALESCE(c.revision, 0) AS revision, c.updated_at, EXISTS (SELECT 1 FROM room_closures WHERE room_code = r.code AND closed = 1) AS closed`;
type ControlRow = {
closed: number;
paused: number;
join_locked: number;
revision: number;
updated_at: number | null;
};
const project = (row: ControlRow): RoomControl => ({
...(row.closed === 1 ? { closed: true as const } : {}),
paused: row.paused === 1,
joinLocked: row.join_locked === 1,
revision: row.revision,
@@ -92,6 +94,7 @@ export async function applyAdminRoomControl(
SELECT ?,?,r.code,?,?,COALESCE(c.paused,0),COALESCE(c.join_locked,0),?,?,?
FROM rooms r LEFT JOIN room_controls c ON c.room_code = r.code
WHERE r.code = ? AND COALESCE(c.revision,0) = ? AND EXISTS (${mutationAuthority})
AND NOT 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_audit WHERE operation_id = ?)`)
.bind(
+5 -1
View File
@@ -17,7 +17,7 @@ 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.state,r.version,COALESCE(c.paused,0) AS paused,
const roomSelect = `SELECT r.code,r.state,r.version,EXISTS (SELECT 1 FROM room_closures WHERE room_code = r.code AND closed = 1) AS closed,COALESCE(c.paused,0) AS paused,
EXISTS (SELECT 1 FROM room_removals WHERE room_code = r.code AND removed = 1) AS removed,
COALESCE(c.join_locked,0) AS join_locked,COALESCE(c.revision,0) AS control_revision,c.updated_at AS control_updated_at,
(SELECT json_group_array(DISTINCT s.player_id) FROM seats s WHERE s.room_code = r.code AND s.revoked = 0) AS active_humans
@@ -29,6 +29,7 @@ type Row = {
state: string;
version: number;
removed: number;
closed: number;
paused: number;
join_locked: number;
control_revision: number;
@@ -77,6 +78,7 @@ function decode(row: Row) {
}
const activeHumans = new Set<string>(JSON.parse(row.active_humans));
const control = {
...(row.closed === 1 ? { closed: true as const } : {}),
paused: row.paused === 1,
joinLocked: row.join_locked === 1,
revision: row.control_revision,
@@ -89,6 +91,7 @@ function decode(row: Row) {
activeHumans,
control,
);
if (row.closed === 1) { view.editable = false; view.blockedReason = 'Reopen this closed room before configuring its lobby.'; }
if (row.removed === 1) {
view.editable = false;
view.blockedReason =
@@ -259,6 +262,7 @@ export async function configureAdminLobby(
database
.prepare(`UPDATE rooms SET state = ?, version = version + 1, updated_at = ? WHERE code = ? AND version = ?
AND json_extract(state, '$.status') = 'lobby' AND EXISTS (${mutationAuthority})
AND NOT EXISTS (SELECT 1 FROM room_closures WHERE room_code = rooms.code AND closed = 1)
AND NOT EXISTS (SELECT 1 FROM room_controls WHERE room_code = rooms.code AND paused = 1)
AND NOT EXISTS (SELECT 1 FROM room_removals WHERE room_code = rooms.code AND removed = 1)
AND NOT EXISTS (SELECT 1 FROM admin_lobby_operations WHERE operation_id = ?)
+4 -2
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,COALESCE(m.removed,0) AS removed,
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,
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 = {
closed: number;
code: string;
version: number;
removed: number;
@@ -37,6 +38,7 @@ type Receipt = {
};
const project = (row: Row): AdminRemovalView => ({
code: row.code,
...(row.closed === 1 ? { closed: true as const } : {}),
version: row.version,
removed: row.removed === 1,
revision: row.revision,
@@ -160,7 +162,7 @@ export async function applyAdminRemoval(
// Removing never parses or changes JSON. Restore tolerates malformed saved state.
database
.prepare(`UPDATE rooms SET version = version + 1,
state = CASE WHEN ? = 0 AND NOT EXISTS (
state = CASE WHEN ? = 0 AND NOT EXISTS (SELECT 1 FROM room_closures WHERE room_code = rooms.code AND closed = 1) AND NOT EXISTS (
SELECT 1 FROM room_controls WHERE room_code = rooms.code AND paused = 1
) THEN CASE WHEN json_valid(state) THEN CASE WHEN
json_extract(state, '$.status') IN ('setup','playing') AND
+45 -19
View File
@@ -85,6 +85,13 @@ async function requireRoomNotRemoved(code: string) {
.first();
if (removed) throw new RoomRemovedError();
}
export class RoomClosedError extends RoomEntryError {
constructor() { super('An administrator closed this room. The saved table remains readable; new play and seat changes are available after it is reopened.', 'ROOM_CLOSED', 409); }
}
async function requireRoomNotClosed(code: string) {
const closed = await db().prepare('SELECT 1 AS closed FROM room_closures WHERE room_code = ? AND closed = 1').bind(code).first();
if (closed) throw new RoomClosedError();
}
type PreparedEntry = {
operationId: string;
operationHash: string;
@@ -261,11 +268,11 @@ export async function createRoom(
}
type RoomControlRow = { room_control: string };
// SQLite constructs an explicit public allowlist, consistent with delegation views.
const roomControlProjection = `json_object(
const roomControlProjection = `json_patch(json_object(
'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),
'revision', COALESCE(c.revision, 0), 'updatedAt', c.updated_at
) AS room_control`;
), CASE WHEN EXISTS (SELECT 1 FROM room_closures WHERE room_code = rooms.code AND closed = 1) THEN '{"closed":true}' ELSE '{}' END) AS room_control`;
function publicRoomControl(row: RoomControlRow): RoomControl {
return JSON.parse(row.room_control) as RoomControl;
}
@@ -273,7 +280,7 @@ function attachRoomControl(view: GameView, row: RoomControlRow) {
const control = publicRoomControl(row);
// Untouched rooms retain the ordinary game-view shape. Once an administrator
// changes controls, keep the revision visible even after both flags are cleared.
if (control.revision > 0) view.roomControl = control;
if (control.revision > 0 || control.closed) view.roomControl = control;
}
async function readRoomSnapshot(code: string) {
const row = await db()
@@ -305,6 +312,7 @@ export async function readRoom(code: string) {
return game;
}
function requireUnpaused(control: RoomControl) {
if (control.closed) throw new RoomClosedError();
if (control.paused)
throw new RuleError(
'An administrator paused this room. Game decisions and AI will continue after it resumes.',
@@ -364,6 +372,7 @@ export async function joinRoom(
}
await requireUnusedEntryToken(entry);
const { game: g, control } = await readRoomSnapshot(code);
if (control.closed) throw new RoomClosedError();
if (control.paused || control.joinLocked)
throw new RoomEntryError(
control.paused
@@ -380,7 +389,7 @@ export async function joinRoom(
const results = await db().batch([
db()
.prepare(
'UPDATE rooms SET state = ?, version = version + 1, updated_at = ? WHERE code = ? AND version = ? AND NOT EXISTS (SELECT 1 FROM room_removals WHERE room_code = rooms.code AND removed = 1) AND NOT EXISTS (SELECT 1 FROM room_controls WHERE room_code = rooms.code AND (paused = 1 OR join_locked = 1))',
'UPDATE rooms SET state = ?, version = version + 1, updated_at = ? WHERE code = ? AND version = ? AND NOT EXISTS (SELECT 1 FROM room_removals WHERE room_code = rooms.code AND removed = 1) AND NOT EXISTS (SELECT 1 FROM room_closures WHERE room_code = rooms.code AND closed = 1) AND NOT EXISTS (SELECT 1 FROM room_controls WHERE room_code = rooms.code AND (paused = 1 OR join_locked = 1))',
)
.bind(JSON.stringify(g), Date.now(), code, old),
db()
@@ -406,6 +415,7 @@ export async function joinRoom(
]);
if (results[0].meta.changes !== 1) {
await requireRoomNotRemoved(code);
await requireRoomNotClosed(code);
throw new RoomEntryError(
'The table changed. Retry the same room-entry request.',
'ENTRY_CONFLICT',
@@ -435,6 +445,7 @@ export async function act(
const { game: current, control } = await readRoomSnapshot(code);
const takingBackControl =
action?.type === 'setAutopilot' && action.difficulty === null;
if (control.closed) throw new RoomClosedError();
if (!takingBackControl) requireUnpaused(control);
if (current.version !== version)
throw new RuleError(
@@ -456,7 +467,7 @@ export async function act(
const now = clock.now();
const update = db()
.prepare(
'UPDATE rooms SET state = ?, version = version + 1, updated_at = ? WHERE code = ? AND version = ? AND NOT EXISTS (SELECT 1 FROM room_removals WHERE room_code = rooms.code AND removed = 1) AND (? = 1 OR NOT EXISTS (SELECT 1 FROM room_controls WHERE room_code = rooms.code AND paused = 1)) AND EXISTS (SELECT 1 FROM seats WHERE room_code = ? AND player_id = ? AND token_hash = ? AND revoked = 0)',
'UPDATE rooms SET state = ?, version = version + 1, updated_at = ? WHERE code = ? AND version = ? AND NOT EXISTS (SELECT 1 FROM room_removals WHERE room_code = rooms.code AND removed = 1) AND NOT EXISTS (SELECT 1 FROM room_closures WHERE room_code = rooms.code AND closed = 1) AND (? = 1 OR NOT EXISTS (SELECT 1 FROM room_controls WHERE room_code = rooms.code AND paused = 1)) AND EXISTS (SELECT 1 FROM seats WHERE room_code = ? AND player_id = ? AND token_hash = ? AND revoked = 0)',
)
.bind(
JSON.stringify(next),
@@ -481,6 +492,7 @@ export async function act(
: [await update.run()];
if (results[0].meta.changes !== 1) {
await requireRoomNotRemoved(code);
await requireRoomNotClosed(code);
throw new RuleError(
'Another action arrived first. Your view has refreshed; try again.',
);
@@ -600,7 +612,7 @@ export async function continueRoomAutomatic(
const snapshot = await readWorkerRoomSnapshot(code);
if (!snapshot) return;
const { game: current, control } = snapshot;
if (control.paused) return;
if (control.paused || control.closed) return;
if (
!needsAutomaticRoomRecovery(current) ||
(!current.pendingTreacheryDiscard &&
@@ -615,7 +627,7 @@ export async function continueRoomAutomatic(
next.version = current.version + 1;
const result = await db()
.prepare(
'UPDATE rooms SET state = ?, version = version + 1, updated_at = ? WHERE code = ? AND version = ? AND NOT EXISTS (SELECT 1 FROM room_removals WHERE room_code = rooms.code AND removed = 1) AND NOT EXISTS (SELECT 1 FROM room_controls WHERE room_code = rooms.code AND paused = 1)',
'UPDATE rooms SET state = ?, version = version + 1, updated_at = ? WHERE code = ? AND version = ? AND NOT EXISTS (SELECT 1 FROM room_removals WHERE room_code = rooms.code AND removed = 1) AND NOT EXISTS (SELECT 1 FROM room_closures WHERE room_code = rooms.code AND closed = 1) AND NOT EXISTS (SELECT 1 FROM room_controls WHERE room_code = rooms.code AND paused = 1)',
)
.bind(JSON.stringify(next), clock.now(), code, current.version)
.run();
@@ -637,7 +649,7 @@ export async function continueRoomBots(
if (!snapshot) return;
let current = snapshot.game;
if (
snapshot.control.paused ||
snapshot.control.paused || snapshot.control.closed ||
!current.botsPending ||
current.status === 'finished'
)
@@ -650,7 +662,7 @@ export async function continueRoomBots(
if (!snapshot) return;
current = snapshot.game;
if (
snapshot.control.paused ||
snapshot.control.paused || snapshot.control.closed ||
!current.botsPending ||
current.status === 'finished'
)
@@ -666,7 +678,7 @@ export async function continueRoomBots(
next.version = current.version + 1;
const result = await db()
.prepare(
'UPDATE rooms SET state = ?, version = version + 1, updated_at = ? WHERE code = ? AND version = ? AND NOT EXISTS (SELECT 1 FROM room_removals WHERE room_code = rooms.code AND removed = 1) AND NOT EXISTS (SELECT 1 FROM room_controls WHERE room_code = rooms.code AND paused = 1)',
'UPDATE rooms SET state = ?, version = version + 1, updated_at = ? WHERE code = ? AND version = ? AND NOT EXISTS (SELECT 1 FROM room_removals WHERE room_code = rooms.code AND removed = 1) AND NOT EXISTS (SELECT 1 FROM room_closures WHERE room_code = rooms.code AND closed = 1) AND NOT EXISTS (SELECT 1 FROM room_controls WHERE room_code = rooms.code AND paused = 1)',
)
.bind(JSON.stringify(next), clock.now(), code, current.version)
.run();
@@ -825,7 +837,7 @@ export async function setSeatAiDelegate(
db()
.prepare(
`UPDATE rooms SET version = version + 1, updated_at = ?
WHERE code = ? AND version = ? AND NOT EXISTS (SELECT 1 FROM room_removals WHERE room_code = rooms.code AND removed = 1)
WHERE code = ? AND version = ? AND NOT EXISTS (SELECT 1 FROM room_removals WHERE room_code = rooms.code AND removed = 1) AND NOT EXISTS (SELECT 1 FROM room_closures WHERE room_code = rooms.code AND closed = 1)
AND NOT EXISTS (SELECT 1 FROM room_controls WHERE room_code = rooms.code AND paused = 1)
AND EXISTS (SELECT 1 FROM seats WHERE room_code = ? AND player_id = ? AND token_hash = ? AND revoked = 0)
AND EXISTS (SELECT 1 FROM seats WHERE room_code = ? AND player_id = ? AND token_hash = ? AND revoked = 0)
@@ -889,6 +901,7 @@ export async function setSeatAiDelegate(
(await delegationSessionsActive(won))
)
return { view: await readSeatView(code, auth), replayed: true };
await requireRoomNotClosed(code);
throw invalidDelegation(
'The table, owner or delegate changed. Reconnect before saving this consent.',
'STALE_VERSION',
@@ -932,11 +945,12 @@ export async function revokeSeatAiDelegate(
'STALE_VERSION',
);
const now = clock.now();
await requireRoomNotClosed(code);
const results = await db().batch([
db()
.prepare(
`UPDATE rooms SET version = version + 1, updated_at = ?
WHERE code = ? AND version = ? AND NOT EXISTS (SELECT 1 FROM room_removals WHERE room_code = rooms.code AND removed = 1)
WHERE code = ? AND version = ? AND NOT EXISTS (SELECT 1 FROM room_removals WHERE room_code = rooms.code AND removed = 1) AND NOT EXISTS (SELECT 1 FROM room_closures WHERE room_code = rooms.code AND closed = 1)
AND EXISTS (SELECT 1 FROM seats WHERE room_code = ? AND player_id = ? AND token_hash = ? AND revoked = 0)
AND EXISTS (
SELECT 1 FROM seat_ai_delegations AS consent
@@ -973,6 +987,7 @@ export async function revokeSeatAiDelegate(
]);
if (results[0].meta.changes !== 1 || results[1].meta.changes !== 1) {
await requireRoomNotRemoved(code);
await requireRoomNotClosed(code);
throw invalidDelegation(
'The table, owner, delegate or consent changed. Reconnect before revoking it.',
'STALE_VERSION',
@@ -1060,7 +1075,7 @@ export async function useSeatAiDelegate(
db()
.prepare(
`UPDATE rooms SET state = ?, version = version + 1, updated_at = ?
WHERE code = ? AND version = ? AND NOT EXISTS (SELECT 1 FROM room_removals WHERE room_code = rooms.code AND removed = 1)
WHERE code = ? AND version = ? AND NOT EXISTS (SELECT 1 FROM room_removals WHERE room_code = rooms.code AND removed = 1) AND NOT EXISTS (SELECT 1 FROM room_closures WHERE room_code = rooms.code AND closed = 1)
AND NOT EXISTS (SELECT 1 FROM room_controls WHERE room_code = rooms.code AND paused = 1)
AND EXISTS (
SELECT 1 FROM seat_ai_delegations AS consent
@@ -1125,6 +1140,7 @@ export async function useSeatAiDelegate(
(await delegationSessionsActive(won))
)
return { view: await readSeatView(code, auth), replayed: true };
await requireRoomNotClosed(code);
throw invalidDelegation(
'The table, owner, delegate or consent changed. Retry the exact activation after reconnecting.',
'STALE_VERSION',
@@ -1148,10 +1164,11 @@ export async function setRecoveryKey(
'INVALID_CONTROL_REQUEST',
400,
);
await requireRoomNotClosed(code);
const results = await db().batch([
db()
.prepare(
'UPDATE rooms SET version = version + 1, updated_at = ? WHERE code = ? AND version = ? AND NOT EXISTS (SELECT 1 FROM room_removals WHERE room_code = rooms.code AND removed = 1) AND EXISTS (SELECT 1 FROM seats WHERE room_code = ? AND player_id = ? AND token_hash = ? AND revoked = 0)',
'UPDATE rooms SET version = version + 1, updated_at = ? WHERE code = ? AND version = ? AND NOT EXISTS (SELECT 1 FROM room_removals WHERE room_code = rooms.code AND removed = 1) AND NOT EXISTS (SELECT 1 FROM room_closures WHERE room_code = rooms.code AND closed = 1) AND EXISTS (SELECT 1 FROM seats WHERE room_code = ? AND player_id = ? AND token_hash = ? AND revoked = 0)',
)
.bind(Date.now(), code, version, code, auth.playerId, auth.tokenHash),
db()
@@ -1162,6 +1179,7 @@ export async function setRecoveryKey(
]);
if (results[0].meta.changes !== 1) {
await requireRoomNotRemoved(code);
await requireRoomNotClosed(code);
throw new SeatControlError(
'The table or seat changed. Reconnect before saving the recovery key.',
'STALE_VERSION',
@@ -1268,10 +1286,11 @@ export async function recoverSeat(
'INVALID_SESSION_TOKEN',
);
const current = await readRoom(code);
await requireRoomNotClosed(code);
const results = await db().batch([
db()
.prepare(
'UPDATE rooms SET version = version + 1, updated_at = ? WHERE code = ? AND version = ? AND NOT EXISTS (SELECT 1 FROM room_removals WHERE room_code = rooms.code AND removed = 1) AND EXISTS (SELECT 1 FROM seat_recovery_keys WHERE room_code = ? AND player_id = ? AND recovery_hash = ?) AND EXISTS (SELECT 1 FROM seats WHERE room_code = ? AND player_id = ? AND revoked = 0) AND NOT EXISTS (SELECT 1 FROM seat_recovery_receipts WHERE room_code = ? AND player_id = ? AND operation_hash = ?) AND NOT EXISTS (SELECT 1 FROM seats WHERE token_hash = ?)',
'UPDATE rooms SET version = version + 1, updated_at = ? WHERE code = ? AND version = ? AND NOT EXISTS (SELECT 1 FROM room_removals WHERE room_code = rooms.code AND removed = 1) AND NOT EXISTS (SELECT 1 FROM room_closures WHERE room_code = rooms.code AND closed = 1) AND EXISTS (SELECT 1 FROM seat_recovery_keys WHERE room_code = ? AND player_id = ? AND recovery_hash = ?) AND EXISTS (SELECT 1 FROM seats WHERE room_code = ? AND player_id = ? AND revoked = 0) AND NOT EXISTS (SELECT 1 FROM seat_recovery_receipts WHERE room_code = ? AND player_id = ? AND operation_hash = ?) AND NOT EXISTS (SELECT 1 FROM seats WHERE token_hash = ?)',
)
.bind(
Date.now(),
@@ -1310,6 +1329,7 @@ export async function recoverSeat(
]);
if (results[0].meta.changes !== 1) {
await requireRoomNotRemoved(code);
await requireRoomNotClosed(code);
// A concurrent exact request may have completed: retrying the same proof is safe.
throw new SeatControlError(
'The table or recovery operation changed. Retry the exact recovery request.',
@@ -1414,10 +1434,11 @@ export async function createSeatHandover(
if (replay) return replay;
const expiresAt = now + SEAT_HANDOVER_TTL_MS;
try {
const results = await db().batch([
await requireRoomNotClosed(code);
const results = await db().batch([
db()
.prepare(
'UPDATE rooms SET version = version + 1, updated_at = ? WHERE code = ? AND version = ? AND NOT EXISTS (SELECT 1 FROM room_removals WHERE room_code = rooms.code AND removed = 1) AND EXISTS (SELECT 1 FROM seats WHERE room_code = ? AND player_id = ? AND token_hash = ? AND revoked = 0) AND NOT EXISTS (SELECT 1 FROM seat_handover_offers WHERE offer_hash = ? AND (room_code != ? OR player_id != ?))',
'UPDATE rooms SET version = version + 1, updated_at = ? WHERE code = ? AND version = ? AND NOT EXISTS (SELECT 1 FROM room_removals WHERE room_code = rooms.code AND removed = 1) AND NOT EXISTS (SELECT 1 FROM room_closures WHERE room_code = rooms.code AND closed = 1) AND EXISTS (SELECT 1 FROM seats WHERE room_code = ? AND player_id = ? AND token_hash = ? AND revoked = 0) AND NOT EXISTS (SELECT 1 FROM seat_handover_offers WHERE offer_hash = ? AND (room_code != ? OR player_id != ?))',
)
.bind(
now,
@@ -1453,6 +1474,7 @@ export async function createSeatHandover(
now,
);
if (won) return won;
await requireRoomNotClosed(code);
throw new SeatControlError(
'The table, seat, or handover offer changed. Reconnect before creating the offer.',
'STALE_VERSION',
@@ -1501,10 +1523,11 @@ export async function revokeSeatHandover(
'A different handover offer is now current for this seat.',
'HANDOVER_CHANGED',
);
await requireRoomNotClosed(code);
const results = await db().batch([
db()
.prepare(
'UPDATE rooms SET version = version + 1, updated_at = ? WHERE code = ? AND version = ? AND NOT EXISTS (SELECT 1 FROM room_removals WHERE room_code = rooms.code AND removed = 1) AND EXISTS (SELECT 1 FROM seats WHERE room_code = ? AND player_id = ? AND token_hash = ? AND revoked = 0) AND EXISTS (SELECT 1 FROM seat_handover_offers WHERE room_code = ? AND player_id = ? AND offer_hash = ? AND claim_operation_hash IS NULL)',
'UPDATE rooms SET version = version + 1, updated_at = ? WHERE code = ? AND version = ? AND NOT EXISTS (SELECT 1 FROM room_removals WHERE room_code = rooms.code AND removed = 1) AND NOT EXISTS (SELECT 1 FROM room_closures WHERE room_code = rooms.code AND closed = 1) AND EXISTS (SELECT 1 FROM seats WHERE room_code = ? AND player_id = ? AND token_hash = ? AND revoked = 0) AND EXISTS (SELECT 1 FROM seat_handover_offers WHERE room_code = ? AND player_id = ? AND offer_hash = ? AND claim_operation_hash IS NULL)',
)
.bind(
clock.now(),
@@ -1525,6 +1548,7 @@ export async function revokeSeatHandover(
]);
if (results[0].meta.changes !== 1) {
await requireRoomNotRemoved(code);
await requireRoomNotClosed(code);
const current = await handoverOffer(code, auth.playerId);
if (!current)
return {
@@ -1713,10 +1737,11 @@ export async function claimSeatHandover(
);
const current = await readRoom(code);
const claimFence = await hash(crypto.randomUUID());
await requireRoomNotClosed(code);
const results = await db().batch([
db()
.prepare(
'UPDATE rooms SET version = version + 1, updated_at = ? WHERE code = ? AND version = ? AND NOT EXISTS (SELECT 1 FROM room_removals WHERE room_code = rooms.code AND removed = 1) AND EXISTS (SELECT 1 FROM seat_handover_offers WHERE room_code = ? AND player_id = ? AND offer_hash = ? AND secret_hash = ? AND issuer_session_hash = ? AND expires_at > ? AND claim_operation_hash IS NULL) AND EXISTS (SELECT 1 FROM seats WHERE room_code = ? AND player_id = ? AND token_hash = ? AND revoked = 0) AND NOT EXISTS (SELECT 1 FROM seats WHERE token_hash = ?) AND NOT EXISTS (SELECT 1 FROM seat_handover_claim_receipts WHERE room_code = ? AND player_id = ? AND operation_hash = ?)',
'UPDATE rooms SET version = version + 1, updated_at = ? WHERE code = ? AND version = ? AND NOT EXISTS (SELECT 1 FROM room_removals WHERE room_code = rooms.code AND removed = 1) AND NOT EXISTS (SELECT 1 FROM room_closures WHERE room_code = rooms.code AND closed = 1) AND EXISTS (SELECT 1 FROM seat_handover_offers WHERE room_code = ? AND player_id = ? AND offer_hash = ? AND secret_hash = ? AND issuer_session_hash = ? AND expires_at > ? AND claim_operation_hash IS NULL) AND EXISTS (SELECT 1 FROM seats WHERE room_code = ? AND player_id = ? AND token_hash = ? AND revoked = 0) AND NOT EXISTS (SELECT 1 FROM seats WHERE token_hash = ?) AND NOT EXISTS (SELECT 1 FROM seat_handover_claim_receipts WHERE room_code = ? AND player_id = ? AND operation_hash = ?)',
)
.bind(
now,
@@ -1786,6 +1811,7 @@ export async function claimSeatHandover(
]);
if (results[0].meta.changes !== 1) {
await requireRoomNotRemoved(code);
await requireRoomNotClosed(code);
const won = await replayClaimedHandover(
code,
playerId,
+45
View File
@@ -91,6 +91,51 @@ export const adminRoomRemovals = sqliteTable(
],
);
// Closure freezes mutations while retaining private read access, independent of saved game state and pause settings.
export const roomClosures = sqliteTable(
'room_closures',
{
roomCode: text('room_code')
.primaryKey()
.references(() => rooms.code, { onDelete: 'cascade' }),
closed: integer('closed').notNull().default(0),
revision: integer('revision').notNull().default(0),
closedAt: integer('closed_at'),
updatedAt: integer('updated_at').notNull(),
},
(table) => [
check('room_closures_closed', sql`${table.closed} IN (0, 1)`),
check('room_closures_revision', sql`${table.revision} >= 0`),
check(
'room_closures_time',
sql`(${table.closed} = 1 AND ${table.closedAt} IS NOT NULL) OR (${table.closed} = 0 AND ${table.closedAt} IS NULL)`,
),
],
);
// Exact operation receipts also retain safe audit evidence independently of rooms/accounts.
export const adminRoomClosures = sqliteTable(
'admin_room_closures',
{
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(),
closed: integer('closed').notNull(),
reason: text('reason').notNull(),
beforeMetadata: text('before_metadata').notNull(),
afterMetadata: text('after_metadata').notNull(),
createdAt: integer('created_at').notNull(),
},
(table) => [
index('admin_room_closures_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(
+3
View File
@@ -116,6 +116,7 @@ export async function sendTableTalk(
SELECT r.code, ?, s.player_id, s.token_hash, json_extract(p.value, '$.name'), json_extract(p.value, '$.faction'), ?,
(SELECT json_extract(target.value, '$.name') FROM json_each(r.state, '$.players') target WHERE json_extract(target.value, '$.id') = ?), ?, ?
${viewer}
AND NOT EXISTS (SELECT 1 FROM room_closures WHERE room_code = r.code AND closed = 1)
AND (? IS NULL OR EXISTS (SELECT 1 FROM json_each(r.state, '$.players') target
WHERE json_extract(target.value, '$.id') = ? AND json_extract(target.value, '$.bot') IS NULL))
AND NOT EXISTS (SELECT 1 FROM room_messages recent WHERE recent.room_code = r.code
@@ -155,6 +156,8 @@ export async function sendTableTalk(
.first<{ accepted: number }>();
if (!receipt) {
await requireRoomNotRemoved(code);
if (await database().prepare('SELECT 1 FROM room_closures WHERE room_code = ? AND closed = 1').bind(code).first())
throw new TableTalkError('An administrator closed this room. Discussion history remains readable; new messages are available after reopening.', 409, 'ROOM_CLOSED');
throw new TableTalkError(
'Message not confirmed. Check your seat and recipient, wait a second, then try again.',
);