Add recoverable administrator room removal and restoration

Preserve room JSON, seats, credentials and messages while removed rooms deny ordinary reads and writes. Add deliberate room-scoped controls, removed-directory filters, persistent player notices and live-authorized atomic version/revision audit receipts. Retain uncertain room/seat requests and hidden recovery drafts through removal; restore the prior pause/join settings without reviving revoked credentials.

Fix AI readiness after a joining human or human faction change so real lobby starts remain legal.

Verified types, lint, 5314 offline tests, 52 HTTP tests and build. Fourteen authenticated HTTP groups and independent privacy/persistence review passed. Desktop/mobile browser QA confirmed a saved removal after a lost response and refresh, one audit on exact retry, persistent notices and recovery draft continuity. All 1,945 baseline rooms, 4,121 seats and related records preserved. Positive authenticated production admin acceptance remains pending authorized QA operator access; full admin and rules gates remain open.
This commit is contained in:
Quintine committed 2026-09-22 12:01:22 +10:00
1 parent c0cc4de9e1
commit 238de3860d
44 files changed
+4626 -90

No files matched your search

+8 -5
View File
@@ -9,10 +9,11 @@ const text = (value: unknown, max = 80) => typeof value === 'string' ? value.sli
const integer = (value: unknown) => Number.isSafeInteger(value) && Number(value) >= 0 ? Number(value) : null;
/** Only emit allowlisted fields. Never call viewGame with an administrator's or host's seat. */
type DirectoryRow = { code: string; version: number; updated_at: number; state: string; 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_paused?: number; control_join_locked?: number; control_revision?: number; control_updated_at?: number | null };
export function projectAdminRoom(row: DirectoryRow): AdminRoom {
const base: AdminRoom = {
code: row.code, version: row.version, updatedAt: row.updated_at,
removed: row.removed === 1,
status: 'unreadable', host: null, players: [], advanced: false, preview: false,
expansions: [], modules: [], turn: null, phase: 'Unavailable',
pending: { label: 'Saved room needs inspection', owners: [] },
@@ -49,7 +50,7 @@ export function projectAdminRoom(row: DirectoryRow): AdminRoom {
}
export async function readAdminDirectory(database: D1Database, identity: AdminIdentity, search: URLSearchParams, now = Date.now()): Promise<AdminDirectory> {
const allowed = ['q', 'status', 'rules', 'sort', 'page', 'availability'];
const allowed = ['q', 'status', 'rules', 'sort', 'page', 'availability', 'removal'];
if ([...search.keys()].some(k => !allowed.includes(k) || search.getAll(k).length !== 1)) throw new AdminError('Invalid room filters.', 400);
const q = (search.get('q') ?? '').trim();
const status = search.get('status') ?? 'all';
@@ -57,8 +58,9 @@ export async function readAdminDirectory(database: D1Database, identity: AdminId
const sort = search.get('sort') ?? 'recent';
const rawPage = search.get('page') ?? '1';
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) || !/^[1-9][0-9]{0,5}$/.test(rawPage)) throw new AdminError('Invalid room filters.', 400);
!['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);
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,13 +84,14 @@ export async function readAdminDirectory(database: D1Database, identity: AdminId
if (availability === 'paused') where.push('c.paused = 1');
if (availability === 'running') where.push('COALESCE(c.paused, 0) = 0');
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';
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 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(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(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);
+1
View File
@@ -92,6 +92,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_removals WHERE room_code = r.code AND removed = 1)
AND NOT EXISTS (SELECT 1 FROM admin_room_audit WHERE operation_id = ?)`)
.bind(
input.operationId,
+16 -1
View File
@@ -18,6 +18,7 @@ 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 roomSelect = `SELECT r.code,r.state,r.version,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
FROM rooms r LEFT JOIN room_controls c ON c.room_code = r.code WHERE r.code = ? AND EXISTS (${authority})`;
@@ -27,6 +28,7 @@ type Row = {
code: string;
state: string;
version: number;
removed: number;
paused: number;
join_locked: number;
control_revision: number;
@@ -80,10 +82,22 @@ function decode(row: Row) {
revision: row.control_revision,
updatedAt: row.control_updated_at,
};
const view = projectAdminLobby(
row.code,
row.version,
game,
activeHumans,
control,
);
if (row.removed === 1) {
view.editable = false;
view.blockedReason =
'Restore this removed room before configuring its lobby.';
}
return {
game: game as Game,
activeHumans,
view: projectAdminLobby(row.code, row.version, game, activeHumans, control),
view,
};
}
function missing(code: string, version: number): AdminLobbyView {
@@ -246,6 +260,7 @@ export async function configureAdminLobby(
.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_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 = ?)
AND (? = '' OR EXISTS (SELECT 1 FROM seats WHERE room_code = rooms.code AND player_id = ? AND revoked = 0))`)
.bind(
+221
View File
@@ -0,0 +1,221 @@
import { AdminError, type AdminIdentity } from './admin-access';
import {
validAdminRemovalInput,
type AdminRemovalInput,
type AdminRemovalResult,
type AdminRemovalView,
} from '../lib/admin-removal';
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,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 = {
code: string;
version: number;
removed: number;
revision: number;
removed_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): AdminRemovalView => ({
code: row.code,
version: row.version,
removed: row.removed === 1,
revision: row.revision,
removedAt: row.removed_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 removed and unreadable saves. */
export async function readAdminRemoval(
database: D1Database,
identity: AdminIdentity,
code: string,
now = Date.now(),
): Promise<AdminRemovalView> {
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 removal transition commit together.
* Neither transition replaces state or credentials; restore only refreshes AI pacing. */
export async function applyAdminRemoval(
database: D1Database,
identity: AdminIdentity,
code: string,
input: AdminRemovalInput,
now = Date.now(),
): Promise<AdminRemovalResult> {
const args = credentials(identity, code, now);
if (!validAdminRemovalInput(input))
throw new AdminError(
'Provide the room version, removal revision, requested state, a fresh operation identifier and a reason of 1–300 characters.',
400,
);
const { operationId, expectedVersion, expectedRevision, removed } = input;
const actorId = identity.id,
reason = input.reason.trim();
const digest = await crypto.subtle.digest(
'SHA-256',
new TextEncoder().encode(
JSON.stringify({ expectedVersion, expectedRevision, removed, 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_removals
(operation_id,actor_admin_id,room_code,request_hash,expected_version,expected_revision,
applied_version,applied_revision,removed,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,'removed',json(CASE WHEN m.removed = 1 THEN 'true' ELSE 'false' END),
'revision',COALESCE(m.revision,0),'removedAt',m.removed_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,'removed',json(CASE WHEN ? = 1 THEN 'true' ELSE 'false' END),
'revision',COALESCE(m.revision,0) + 1,'removedAt',?,'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_removals 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.removed,0) != ?
AND EXISTS (${mutationAuthority})
AND NOT EXISTS (SELECT 1 FROM admin_room_removals WHERE operation_id = ?)`)
.bind(
operationId,
actorId,
requestHash,
expectedVersion,
expectedRevision,
Number(removed),
reason,
Number(removed),
removed ? now : null,
now,
now,
code,
expectedVersion,
expectedRevision,
Number(removed),
...args,
operationId,
),
// 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 (
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(removed), now + 1500, code),
database
.prepare(`INSERT INTO room_removals(room_code,removed,revision,removed_at,updated_at)
SELECT room_code,removed,applied_revision,CASE WHEN removed = 1 THEN created_at ELSE NULL END,created_at
FROM admin_room_removals WHERE operation_id = ? AND changes() = 1
ON CONFLICT(room_code) DO UPDATE SET removed = excluded.removed,revision = excluded.revision,
removed_at = excluded.removed_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_removals 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 removal 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.version !== expectedVersion || row.revision !== expectedRevision)
throw new AdminError(
'The room or removal settings changed. Refresh before choosing a new operation.',
409,
);
throw new AdminError(
removed
? 'This room is already removed.'
: 'This room is already available.',
409,
);
}
+4
View File
@@ -137,8 +137,10 @@ export async function createAdminRoom(
),
database
.prepare(`SELECT c.operation_id,c.actor_admin_id,c.room_code,c.host_id,c.request_hash,c.session_hash,
EXISTS (SELECT 1 FROM room_removals WHERE room_code = c.room_code AND removed = 1) AS room_removed,
EXISTS (SELECT 1 FROM seats s JOIN rooms r ON r.code = s.room_code
WHERE s.token_hash = c.session_hash AND s.room_code = c.room_code AND s.player_id = c.host_id AND s.revoked = 0
AND NOT EXISTS (SELECT 1 FROM room_removals WHERE room_code = r.code AND removed = 1)
AND CASE WHEN json_valid(r.state) THEN EXISTS (
SELECT 1 FROM json_each(CASE WHEN json_type(r.state, '$.players') = 'array' THEN json_extract(r.state, '$.players') ELSE '[]' END) p
WHERE json_extract(CASE WHEN p.type = 'object' THEN p.value ELSE '{}' END, '$.id') = c.host_id
@@ -168,6 +170,7 @@ export async function createAdminRoom(
request_hash: string;
session_hash: string;
host_access: number;
room_removed: number;
}
| undefined;
if (receipt) {
@@ -186,6 +189,7 @@ export async function createAdminRoom(
hostId: receipt.host_id,
replayed: results[1].meta.changes !== 1,
hostAccess: receipt.host_access === 1,
...(receipt.room_removed === 1 ? { roomRemoved: true as const } : {}),
};
}
const conflict = results[5].results[0] as {
+112 -34
View File
@@ -66,6 +66,25 @@ export class RoomEntryError extends RuleError {
super(message);
}
}
/** Temporary room unavailability must never invalidate a saved seat or retry proof. */
export class RoomRemovedError extends RoomEntryError {
constructor() {
super(
'An administrator recoverably removed this room. Keep your saved seat and retry details; access returns if the room is restored.',
'ROOM_REMOVED',
410,
);
}
}
async function requireRoomNotRemoved(code: string) {
const removed = await db()
.prepare(
'SELECT 1 AS removed FROM room_removals WHERE room_code = ? AND removed = 1',
)
.bind(code)
.first();
if (removed) throw new RoomRemovedError();
}
type PreparedEntry = {
operationId: string;
operationHash: string;
@@ -150,7 +169,8 @@ async function replayEntry(
entryReceipt: { operationId: entry.operationId, replayed: true },
};
} catch (error) {
if (!(error instanceof RuleError)) throw error;
if (error instanceof RoomRemovedError || !(error instanceof RuleError))
throw error;
throw new RoomEntryError(
'This room-entry session is no longer active. Use the saved seat recovery kit.',
'ENTRY_RECEIPT_INVALID',
@@ -258,20 +278,28 @@ function attachRoomControl(view: GameView, row: RoomControlRow) {
async function readRoomSnapshot(code: string) {
const row = await db()
.prepare(`SELECT rooms.state, rooms.version, ${roomControlProjection}
FROM rooms LEFT JOIN room_controls c ON c.room_code = rooms.code WHERE rooms.code = ?`)
FROM rooms LEFT JOIN room_controls c ON c.room_code = rooms.code WHERE rooms.code = ? AND NOT EXISTS (SELECT 1 FROM room_removals WHERE room_code = rooms.code AND removed = 1)`)
.bind(code)
.first<{ state: string; version: number } & RoomControlRow>();
if (!row) throw new RuleError('Room not found. Check your invite code.');
if (!row) {
await requireRoomNotRemoved(code);
throw new RuleError('Room not found. Check your invite code.');
}
const g: Game = JSON.parse(row.state);
g.version = row.version;
return { game: g, control: publicRoomControl(row) };
}
export async function readRoom(code: string) {
const row = await db()
.prepare('SELECT state,version FROM rooms WHERE code = ?')
.prepare(
'SELECT state,version FROM rooms WHERE code = ? AND NOT EXISTS (SELECT 1 FROM room_removals WHERE room_code = rooms.code AND removed = 1)',
)
.bind(code)
.first<{ state: string; version: number }>();
if (!row) throw new RuleError('Room not found. Check your invite code.');
if (!row) {
await requireRoomNotRemoved(code);
throw new RuleError('Room not found. Check your invite code.');
}
const game: Game = JSON.parse(row.state);
game.version = row.version;
return game;
@@ -287,15 +315,21 @@ export async function authenticate(
code: string,
token: string,
): Promise<SeatAuth> {
if (!token) throw new RuleError('Join this room first.');
if (!token) {
await requireRoomNotRemoved(code);
throw new RuleError('Join this room first.');
}
const tokenHash = await hash(token);
const seat = await db()
.prepare(
'SELECT player_id FROM seats WHERE token_hash = ? AND room_code = ? AND revoked = 0',
'SELECT player_id FROM seats WHERE token_hash = ? AND room_code = ? AND revoked = 0 AND NOT EXISTS (SELECT 1 FROM room_removals WHERE room_code = seats.room_code AND removed = 1)',
)
.bind(tokenHash, code)
.first<{ player_id: string }>();
if (!seat) throw new RuleError('Your seat could not be verified.');
if (!seat) {
await requireRoomNotRemoved(code);
throw new RuleError('Your seat could not be verified.');
}
return { playerId: seat.player_id, tokenHash };
}
export async function joinRoom(
@@ -324,7 +358,8 @@ export async function joinRoom(
alreadySeated: true,
};
} catch (error) {
if (!(error instanceof RuleError)) throw error;
if (error instanceof RoomRemovedError || !(error instanceof RuleError))
throw error;
}
}
await requireUnusedEntryToken(entry);
@@ -345,7 +380,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_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_controls WHERE room_code = rooms.code AND (paused = 1 OR join_locked = 1))',
)
.bind(JSON.stringify(g), Date.now(), code, old),
db()
@@ -369,11 +404,13 @@ export async function joinRoom(
]
: []),
]);
if (results[0].meta.changes !== 1)
if (results[0].meta.changes !== 1) {
await requireRoomNotRemoved(code);
throw new RoomEntryError(
'The table changed. Retry the same room-entry request.',
'ENTRY_CONFLICT',
);
}
return {
token,
view: await readSeatView(code, { playerId: id, tokenHash }),
@@ -419,7 +456,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 (? = 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 (? = 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),
@@ -442,10 +479,12 @@ export async function act(
.bind(now, code, auth.playerId, auth.tokenHash),
])
: [await update.run()];
if (results[0].meta.changes !== 1)
if (results[0].meta.changes !== 1) {
await requireRoomNotRemoved(code);
throw new RuleError(
'Another action arrived first. Your view has refreshed; try again.',
);
}
return readSeatView(code, auth);
}
@@ -484,7 +523,7 @@ export async function readSeatView(code: string, auth: SeatAuth) {
AND delegate_session.token_hash = delegations.delegate_session_hash
AND delegate_session.revoked = 0
)
WHERE rooms.code = ? AND viewer.player_id = ?
WHERE rooms.code = ? AND NOT EXISTS (SELECT 1 FROM room_removals WHERE room_code = rooms.code AND removed = 1) AND viewer.player_id = ?
AND viewer.token_hash = ? AND viewer.revoked = 0
GROUP BY rooms.code, rooms.state, rooms.version`,
)
@@ -496,7 +535,10 @@ export async function readSeatView(code: string, auth: SeatAuth) {
seat_ai_delegations: string | null;
}
>();
if (!row) throw new RuleError('Your seat could not be verified.');
if (!row) {
await requireRoomNotRemoved(code);
throw new RuleError('Your seat could not be verified.');
}
const g: Game = JSON.parse(row.state);
g.version = row.version;
const view = viewGame(g, auth.playerId) as GameView;
@@ -540,13 +582,24 @@ export function needsAutomaticRoomRecovery(
);
}
async function readWorkerRoomSnapshot(code: string) {
try {
return await readRoomSnapshot(code);
} catch (error) {
if (error instanceof RoomRemovedError) return null;
throw error;
}
}
/** Persist an old automatic continuation through the same version fence as current actions. */
export async function continueRoomAutomatic(
code: string,
clock: RoomsClock = roomsClock,
) {
for (let attempt = 0; attempt < 4; attempt++) {
const { game: current, control } = await readRoomSnapshot(code);
const snapshot = await readWorkerRoomSnapshot(code);
if (!snapshot) return;
const { game: current, control } = snapshot;
if (control.paused) return;
if (
!needsAutomaticRoomRecovery(current) ||
@@ -562,7 +615,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_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_controls WHERE room_code = rooms.code AND paused = 1)',
)
.bind(JSON.stringify(next), clock.now(), code, current.version)
.run();
@@ -580,7 +633,8 @@ export async function continueRoomBots(
clock: RoomsClock = roomsClock,
) {
for (let batch = 0; batch < batches; batch++) {
let snapshot = await readRoomSnapshot(code);
let snapshot = await readWorkerRoomSnapshot(code);
if (!snapshot) return;
let current = snapshot.game;
if (
snapshot.control.paused ||
@@ -592,7 +646,8 @@ export async function continueRoomBots(
if (delay > 0) {
await clock.sleep(Math.min(delay, ONLINE_BOT_INTERVAL_MS));
// A human may have taken control or another worker committed while asleep.
snapshot = await readRoomSnapshot(code);
snapshot = await readWorkerRoomSnapshot(code);
if (!snapshot) return;
current = snapshot.game;
if (
snapshot.control.paused ||
@@ -611,7 +666,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_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_controls WHERE room_code = rooms.code AND paused = 1)',
)
.bind(JSON.stringify(next), clock.now(), code, current.version)
.run();
@@ -703,6 +758,7 @@ export async function setSeatAiDelegate(
input: SetSeatAiDelegateInput,
clock: ControlClock = roomsClock,
) {
await requireRoomNotRemoved(code);
controlVersion(version);
if (!validSetSeatAiDelegateInput(input))
throw invalidDelegation(
@@ -769,7 +825,7 @@ export async function setSeatAiDelegate(
db()
.prepare(
`UPDATE rooms SET version = version + 1, updated_at = ?
WHERE code = ? AND version = ?
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)
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)
@@ -820,6 +876,7 @@ export async function setSeatAiDelegate(
.bind(now, code, owner.id, input.grantId, code, owner.id, input.grantId),
]);
if (results[0].meta.changes !== 1 || results[1].meta.changes !== 1) {
await requireRoomNotRemoved(code);
const won = await seatAiDelegation(input.grantId);
if (
won &&
@@ -847,6 +904,7 @@ export async function revokeSeatAiDelegate(
grantId: unknown,
clock: ControlClock = roomsClock,
) {
await requireRoomNotRemoved(code);
controlVersion(version);
if (!validSeatAiGrantId(grantId))
throw invalidDelegation('Choose the current AI delegation identifier.');
@@ -878,7 +936,7 @@ export async function revokeSeatAiDelegate(
db()
.prepare(
`UPDATE rooms SET version = version + 1, updated_at = ?
WHERE code = ? AND version = ?
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_ai_delegations AS consent
@@ -913,11 +971,13 @@ export async function revokeSeatAiDelegate(
)
.bind(now, code, auth.playerId, grantId, auth.tokenHash),
]);
if (results[0].meta.changes !== 1 || results[1].meta.changes !== 1)
if (results[0].meta.changes !== 1 || results[1].meta.changes !== 1) {
await requireRoomNotRemoved(code);
throw invalidDelegation(
'The table, owner, delegate or consent changed. Reconnect before revoking it.',
'STALE_VERSION',
);
}
return { view: await readSeatView(code, auth), replayed: false };
}
@@ -928,6 +988,7 @@ export async function useSeatAiDelegate(
input: UseSeatAiDelegateInput,
clock: RoomsClock = roomsClock,
): Promise<{ view: GameView; replayed?: boolean }> {
await requireRoomNotRemoved(code);
controlVersion(version);
if (!validUseSeatAiDelegateInput(input))
throw invalidDelegation('Choose the current owner and AI delegation.');
@@ -999,7 +1060,7 @@ export async function useSeatAiDelegate(
db()
.prepare(
`UPDATE rooms SET state = ?, version = version + 1, updated_at = ?
WHERE code = ? AND version = ?
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)
AND EXISTS (
SELECT 1 FROM seat_ai_delegations AS consent
@@ -1053,6 +1114,7 @@ export async function useSeatAiDelegate(
),
]);
if (results[0].meta.changes !== 1 || results[1].meta.changes !== 1) {
await requireRoomNotRemoved(code);
const won = await seatAiDelegation(input.grantId);
if (
won?.used_at !== null &&
@@ -1077,6 +1139,7 @@ export async function setRecoveryKey(
version: unknown,
secret: unknown,
) {
await requireRoomNotRemoved(code);
controlVersion(version);
const recoveryHash = await secretHash(secret);
if (recoveryHash === auth.tokenHash)
@@ -1088,7 +1151,7 @@ export async function setRecoveryKey(
const results = await db().batch([
db()
.prepare(
'UPDATE rooms SET version = version + 1, updated_at = ? WHERE code = ? AND version = ? 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 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()
@@ -1097,11 +1160,13 @@ export async function setRecoveryKey(
)
.bind(code, auth.playerId, recoveryHash),
]);
if (results[0].meta.changes !== 1)
if (results[0].meta.changes !== 1) {
await requireRoomNotRemoved(code);
throw new SeatControlError(
'The table or seat changed. Reconnect before saving the recovery key.',
'STALE_VERSION',
);
}
return {
view: await readSeatView(code, auth),
recoveryConfigured: true as const,
@@ -1117,6 +1182,7 @@ export async function recoverSeat(
newSessionToken: unknown;
},
) {
await requireRoomNotRemoved(code);
if (
typeof input.playerId !== 'string' ||
!input.playerId ||
@@ -1169,15 +1235,17 @@ export async function recoverSeat(
// A replaced key or later rotation invalidates the receipt, including races after the first lookup.
const row = await db()
.prepare(
`SELECT rooms.state, rooms.version, ${roomControlProjection} FROM rooms LEFT JOIN room_controls c ON c.room_code = rooms.code JOIN seats ON seats.room_code = rooms.code JOIN seat_recovery_keys AS keys ON keys.room_code = seats.room_code AND keys.player_id = seats.player_id WHERE rooms.code = ? AND seats.player_id = ? AND seats.token_hash = ? AND seats.revoked = 0 AND keys.recovery_hash = ? AND keys.current_operation_hash = ?`,
`SELECT rooms.state, rooms.version, ${roomControlProjection} FROM rooms LEFT JOIN room_controls c ON c.room_code = rooms.code JOIN seats ON seats.room_code = rooms.code JOIN seat_recovery_keys AS keys ON keys.room_code = seats.room_code AND keys.player_id = seats.player_id WHERE rooms.code = ? AND NOT EXISTS (SELECT 1 FROM room_removals WHERE room_code = rooms.code AND removed = 1) AND seats.player_id = ? AND seats.token_hash = ? AND seats.revoked = 0 AND keys.recovery_hash = ? AND keys.current_operation_hash = ?`,
)
.bind(code, playerId, sessionHash, recoveryHash, operationHash)
.first<{ state: string; version: number } & RoomControlRow>();
if (!row)
if (!row) {
await requireRoomNotRemoved(code);
throw new SeatControlError(
'This recovery receipt is no longer active.',
'RECOVERY_RECEIPT_INVALID',
);
}
const g: Game = JSON.parse(row.state);
g.version = row.version;
const view = viewGame(g, playerId) as GameView;
@@ -1203,7 +1271,7 @@ export async function recoverSeat(
const results = await db().batch([
db()
.prepare(
'UPDATE rooms SET version = version + 1, updated_at = ? WHERE code = ? AND version = ? 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 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(),
@@ -1241,6 +1309,7 @@ export async function recoverSeat(
.bind(operationHash, code, playerId),
]);
if (results[0].meta.changes !== 1) {
await requireRoomNotRemoved(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.',
@@ -1318,6 +1387,7 @@ export async function createSeatHandover(
input: { offerId: unknown; handoverSecret: unknown },
clock: HandoverClock = roomsClock,
) {
await requireRoomNotRemoved(code);
controlVersion(version);
const offerId = controlUuid(
input.offerId,
@@ -1347,7 +1417,7 @@ export async function createSeatHandover(
const results = await db().batch([
db()
.prepare(
'UPDATE rooms SET version = version + 1, updated_at = ? WHERE code = ? AND version = ? 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 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,
@@ -1374,6 +1444,7 @@ export async function createSeatHandover(
),
]);
if (results[0].meta.changes !== 1) {
await requireRoomNotRemoved(code);
const won = await replayCreatedHandover(
code,
auth,
@@ -1412,6 +1483,7 @@ export async function revokeSeatHandover(
offerIdValue: unknown,
clock: HandoverClock = roomsClock,
) {
await requireRoomNotRemoved(code);
controlVersion(version);
const offerHash = await hash(
controlUuid(offerIdValue, 'Choose the handover offer to revoke.'),
@@ -1432,7 +1504,7 @@ export async function revokeSeatHandover(
const results = await db().batch([
db()
.prepare(
'UPDATE rooms SET version = version + 1, updated_at = ? WHERE code = ? AND version = ? 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 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(),
@@ -1452,6 +1524,7 @@ export async function revokeSeatHandover(
.bind(code, auth.playerId, offerHash),
]);
if (results[0].meta.changes !== 1) {
await requireRoomNotRemoved(code);
const current = await handoverOffer(code, auth.playerId);
if (!current)
return {
@@ -1499,7 +1572,7 @@ async function replayClaimedHandover(
) {
const row = await db()
.prepare(
`SELECT rooms.state,rooms.version, ${roomControlProjection} FROM rooms LEFT JOIN room_controls c ON c.room_code = rooms.code JOIN seat_handover_claim_receipts AS receipts ON receipts.room_code = rooms.code JOIN seats ON seats.room_code = receipts.room_code AND seats.player_id = receipts.player_id WHERE rooms.code = ? AND receipts.player_id = ? AND receipts.offer_hash = ? AND receipts.secret_hash = ? AND receipts.operation_hash = ? AND receipts.session_hash = ? AND seats.token_hash = ? AND seats.revoked = 0`,
`SELECT rooms.state,rooms.version, ${roomControlProjection} FROM rooms LEFT JOIN room_controls c ON c.room_code = rooms.code JOIN seat_handover_claim_receipts AS receipts ON receipts.room_code = rooms.code JOIN seats ON seats.room_code = receipts.room_code AND seats.player_id = receipts.player_id WHERE rooms.code = ? AND NOT EXISTS (SELECT 1 FROM room_removals WHERE room_code = rooms.code AND removed = 1) AND receipts.player_id = ? AND receipts.offer_hash = ? AND receipts.secret_hash = ? AND receipts.operation_hash = ? AND receipts.session_hash = ? AND seats.token_hash = ? AND seats.revoked = 0`,
)
.bind(
code,
@@ -1511,7 +1584,10 @@ async function replayClaimedHandover(
sessionHash,
)
.first<{ state: string; version: number } & RoomControlRow>();
if (!row) return null;
if (!row) {
await requireRoomNotRemoved(code);
return null;
}
const game: Game = JSON.parse(row.state);
game.version = row.version;
const view = viewGame(game, playerId) as GameView;
@@ -1535,6 +1611,7 @@ export async function claimSeatHandover(
},
clock: HandoverClock = roomsClock,
) {
await requireRoomNotRemoved(code);
if (
typeof input.playerId !== 'string' ||
!input.playerId ||
@@ -1639,7 +1716,7 @@ export async function claimSeatHandover(
const results = await db().batch([
db()
.prepare(
'UPDATE rooms SET version = version + 1, updated_at = ? WHERE code = ? AND version = ? 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 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,
@@ -1708,6 +1785,7 @@ export async function claimSeatHandover(
.bind(code, playerId, code, playerId, claimFence),
]);
if (results[0].meta.changes !== 1) {
await requireRoomNotRemoved(code);
const won = await replayClaimedHandover(
code,
playerId,
+45
View File
@@ -46,6 +46,51 @@ export const roomControls = sqliteTable(
],
);
// Removal is an access boundary, independent of saved game state and pause settings.
export const roomRemovals = sqliteTable(
'room_removals',
{
roomCode: text('room_code')
.primaryKey()
.references(() => rooms.code, { onDelete: 'cascade' }),
removed: integer('removed').notNull().default(0),
revision: integer('revision').notNull().default(0),
removedAt: integer('removed_at'),
updatedAt: integer('updated_at').notNull(),
},
(table) => [
check('room_removals_removed', sql`${table.removed} IN (0, 1)`),
check('room_removals_revision', sql`${table.revision} >= 0`),
check(
'room_removals_time',
sql`(${table.removed} = 1 AND ${table.removedAt} IS NOT NULL) OR (${table.removed} = 0 AND ${table.removedAt} IS NULL)`,
),
],
);
// Exact operation receipts also retain safe audit evidence independently of rooms/accounts.
export const adminRoomRemovals = sqliteTable(
'admin_room_removals',
{
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(),
removed: integer('removed').notNull(),
reason: text('reason').notNull(),
beforeMetadata: text('before_metadata').notNull(),
afterMetadata: text('after_metadata').notNull(),
createdAt: integer('created_at').notNull(),
},
(table) => [
index('admin_room_removals_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(
+23 -2
View File
@@ -13,6 +13,7 @@ export class TableTalkError extends Error {
constructor(
message: string,
readonly status = 409,
readonly code?: string,
) {
super(message);
}
@@ -21,11 +22,26 @@ function database() {
if (!env.DB) throw new Error('Game storage is unavailable.');
return env.DB;
}
async function requireRoomNotRemoved(code: string) {
const removed = await database()
.prepare(
'SELECT 1 AS removed FROM room_removals WHERE room_code = ? AND removed = 1',
)
.bind(code)
.first();
if (removed)
throw new TableTalkError(
'An administrator recoverably removed this room. Keep your saved seat and retry details; access returns if the room is restored.',
410,
'ROOM_REMOVED',
);
}
// Every read and write checks the exact active session AND room membership.
// A separate message table keeps private activity out of game versions/logs.
const viewer = `FROM rooms r JOIN seats s ON s.room_code = r.code
JOIN json_each(r.state, '$.players') p ON json_extract(p.value, '$.id') = s.player_id
WHERE r.code = ? AND s.player_id = ? AND s.token_hash = ? AND s.revoked = 0
AND NOT EXISTS (SELECT 1 FROM room_removals WHERE room_code = r.code AND removed = 1)
AND json_extract(p.value, '$.bot') IS NULL`;
const channel = `(m.recipient_id IS NULL AND ? IS NULL) OR
(? IS NOT NULL AND ((m.sender_id = s.player_id AND m.recipient_id = ?) OR
@@ -71,7 +87,10 @@ export async function readTableTalk(
auth.tokenHash,
)
.first<{ messages: string }>();
if (!row) throw new TableTalkError('Your seat could not be verified.');
if (!row) {
await requireRoomNotRemoved(code);
throw new TableTalkError('Your seat could not be verified.');
}
const newestFirst: TalkMessage[] = JSON.parse(row.messages);
const messages = newestFirst.slice(0, TALK_PAGE_SIZE).reverse();
return {
@@ -134,9 +153,11 @@ export async function sendTableTalk(
draft.text,
)
.first<{ accepted: number }>();
if (!receipt)
if (!receipt) {
await requireRoomNotRemoved(code);
throw new TableTalkError(
'Message not confirmed. Check your seat and recipient, wait a second, then try again.',
);
}
return { id: draft.id, replayed: result.meta.changes === 0 };
}