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:
1 parent
238de3860d
commit
98b1b3261b
49 files changed
+4255
-86
No files matched your search
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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.',
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user