Add administrator pause and joining-lock controls

Provide authenticated owner/operator room controls with live account binding, durable audit and exact saved retries. Pause gates human, bot and automatic writes while preserving recovery, discussion and taking back control. Resume keeps decisions and restores AI pacing. Add directory availability filters and player notices.

Verified types, lint, 5223 offline tests, 52 HTTP tests and build. Independent persistence/privacy review and authenticated HTTP acceptance passed. Desktop/mobile browser QA confirmed lost-response refresh/retry makes one audit row and resume restores controls. Existing games preserved. Authenticated deployed admin acceptance remains pending explicit QA access confirmation.
This commit is contained in:
Quintine committed 2026-09-22 10:41:09 +10:00
1 parent f29d05620c
commit 8c922b446b
39 files changed
+3196 -104

No files matched your search

+14 -7
View File
@@ -9,12 +9,14 @@ 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. */
export function projectAdminRoom(row: { code: string; version: number; updated_at: number; state: string }): AdminRoom {
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 };
export function projectAdminRoom(row: DirectoryRow): AdminRoom {
const base: AdminRoom = {
code: row.code, version: row.version, updatedAt: row.updated_at,
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 },
};
try {
const g = JSON.parse(row.state) as Game;
@@ -47,15 +49,16 @@ export function projectAdminRoom(row: { code: string; version: number; updated_a
}
export async function readAdminDirectory(database: D1Database, identity: AdminIdentity, search: URLSearchParams, now = Date.now()): Promise<AdminDirectory> {
const allowed = ['q', 'status', 'rules', 'sort', 'page'];
const allowed = ['q', 'status', 'rules', 'sort', 'page', 'availability'];
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';
const rules = search.get('rules') ?? 'all';
const sort = search.get('sort') ?? 'recent';
const rawPage = search.get('page') ?? '1';
const availability = search.get('availability') ?? 'all';
if (q.length > 80 || !['all', ...statuses].includes(status) || !['all', 'basic', 'advanced'].includes(rules) ||
!['recent', 'oldest', 'code'].includes(sort) || !/^[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) || !/^[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";
@@ -76,15 +79,19 @@ 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 === 'locked') where.push('c.join_locked = 1');
const predicate = where.length ? 'WHERE ' + where.join(' AND ') : '';
const order = sort === 'code' ? 'code ASC' : `updated_at ${sort === 'oldest' ? 'ASC' : 'DESC'}, code ASC`;
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 results = await database.batch([
database.prepare(authority).bind(...credentials),
database.prepare(`SELECT COUNT(*) AS total FROM rooms ${predicate}`).bind(...args),
database.prepare(`SELECT code,version,updated_at,state FROM rooms ${predicate} ORDER BY ${order} LIMIT ? OFFSET ?`).bind(...args, pageSize, (page - 1) * pageSize),
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),
]);
if (!results[0].results.length) throw new AdminError('Administrator sign-in required.', 401);
const total = Number((results[1].results[0] as { total: number }).total);
const rows = results[2].results as { code: string; version: number; updated_at: number; state: string }[];
const rows = results[2].results as DirectoryRow[];
return { rooms: rows.map(projectAdminRoom), total, page, pageSize };
}
+177
View File
@@ -0,0 +1,177 @@
import { AdminError, type AdminIdentity } from './admin-access';
import {
validAdminRoomControlInput,
type AdminRoomControlInput,
type RoomControl,
} from '../lib/room-control';
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 fields = `COALESCE(c.paused, 0) AS paused, COALESCE(c.join_locked, 0) AS join_locked,
COALESCE(c.revision, 0) AS revision, c.updated_at`;
type ControlRow = {
paused: number;
join_locked: number;
revision: number;
updated_at: number | null;
};
const project = (row: ControlRow): RoomControl => ({
paused: row.paused === 1,
joinLocked: row.join_locked === 1,
revision: row.revision,
updatedAt: row.updated_at,
});
function credentials(identity: AdminIdentity, now: number) {
if (
!identity ||
typeof identity.id !== 'string' ||
typeof identity.sessionHash !== 'string' ||
!/^[0-9a-f]{64}$/.test(identity.sessionHash)
)
throw new AdminError('Administrator sign-in required.', 401);
return [identity.sessionHash, identity.id, now];
}
function checkCode(code: string) {
if (
typeof code !== 'string' ||
code.length !== 8 ||
!/^[A-Z0-9]{8}$/.test(code)
)
throw new AdminError('Use an eight-character room code.', 400);
}
/** Read live room operations in the same transaction as administrator authority. */
export async function readAdminRoomControl(
database: D1Database,
identity: AdminIdentity,
code: string,
now = Date.now(),
): Promise<RoomControl> {
const args = credentials(identity, now);
checkCode(code);
const results = await database.batch([
database.prepare(authority).bind(...args),
database
.prepare(`SELECT ${fields} FROM rooms r LEFT JOIN room_controls c ON c.room_code = r.code
WHERE r.code = ? AND EXISTS (${authority})`)
.bind(code, ...args),
]);
if (!results[0].results.length)
throw new AdminError('Administrator sign-in required.', 401);
const row = results[1].results[0] as ControlRow | undefined;
if (!row) throw new AdminError('Room not found.', 404);
return project(row);
}
/** Receipt, audit, game-version fence and room flags commit or roll back together. */
export async function applyAdminRoomControl(
database: D1Database,
identity: AdminIdentity,
code: string,
input: AdminRoomControlInput,
now = Date.now(),
): Promise<RoomControl & { replayed: boolean }> {
const args = credentials(identity, now);
checkCode(code);
if (!validAdminRoomControlInput(input))
throw new AdminError(
'Provide room settings, a revision, a fresh operation identifier and a reason of 1–300 characters.',
400,
);
const reason = input.reason.trim();
const paused = Number(input.paused),
joinLocked = Number(input.joinLocked);
const results = await database.batch([
database.prepare(authority).bind(...args),
database
.prepare(`INSERT INTO admin_room_audit
(operation_id,actor_admin_id,room_code,reason,expected_revision,before_paused,before_join_locked,after_paused,after_join_locked,created_at)
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 admin_room_audit WHERE operation_id = ?)`)
.bind(
input.operationId,
identity.id,
reason,
input.expectedRevision,
paused,
joinLocked,
now,
code,
input.expectedRevision,
...args,
input.operationId,
),
// Pausing and join locks leave saved game JSON untouched. Resume only refreshes
// pacing for existing AI-controlled seats; it executes no game action.
database
.prepare(`UPDATE rooms SET version = version + 1,
state = CASE WHEN ? = 0 AND EXISTS (
SELECT 1 FROM admin_room_audit WHERE operation_id = ? AND before_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(paused, input.operationId, now + 1500, code),
database
.prepare(`INSERT INTO room_controls(room_code,paused,join_locked,revision,updated_at)
SELECT room_code,after_paused,after_join_locked,expected_revision + 1,created_at
FROM admin_room_audit WHERE operation_id = ? AND changes() = 1
ON CONFLICT(room_code) DO UPDATE SET paused = excluded.paused, join_locked = excluded.join_locked,
revision = excluded.revision, updated_at = excluded.updated_at`)
.bind(input.operationId),
database
.prepare(`SELECT ${fields}, a.room_code, a.expected_revision, a.after_paused, a.after_join_locked, a.reason
FROM admin_room_audit a JOIN rooms r ON r.code = a.room_code
LEFT JOIN room_controls c ON c.room_code = r.code
WHERE a.operation_id = ? AND a.actor_admin_id = ? AND EXISTS (${mutationAuthority})`)
.bind(input.operationId, identity.id, ...args),
database
.prepare(
`SELECT 1 FROM rooms WHERE code = ? AND EXISTS (${mutationAuthority})`,
)
.bind(code, ...args),
]);
const role = (results[0].results[0] as { role: string } | undefined)?.role;
if (!role) throw new AdminError('Administrator sign-in required.', 401);
if (role === 'viewer')
throw new AdminError(
'Your administrator role does not permit this action.',
403,
);
const row = results[4].results[0] as
| (ControlRow & {
room_code: string;
expected_revision: number;
after_paused: number;
after_join_locked: number;
reason: string;
})
| undefined;
if (row) {
if (
row.room_code !== code ||
row.expected_revision !== input.expectedRevision ||
row.after_paused !== paused ||
row.after_join_locked !== joinLocked ||
row.reason !== reason
)
throw new AdminError(
'This operation identifier is bound to different room settings.',
409,
);
return { ...project(row), replayed: results[1].meta.changes !== 1 };
}
if (!results[5].results.length) throw new AdminError('Room not found.', 404);
throw new AdminError(
'Room settings changed or the operation identifier was already used. Refresh before a new request.',
409,
);
}
+99 -29
View File
@@ -14,6 +14,7 @@ import {
import type { FactionId } from '@/game/catalog';
import type { Difficulty } from '@/game/bot-profiles';
import { runBots } from '@/game/bots';
import type { RoomControl } from '@/lib/room-control';
import {
validSeatAiGrantId,
validSetSeatAiDelegateInput,
@@ -74,7 +75,7 @@ type PreparedEntry = {
};
type EntryResult = {
token?: string;
view: ReturnType<typeof viewGame>;
view: GameView;
entryReceipt?: { operationId: string; replayed: boolean };
alreadySeated?: true;
};
@@ -238,15 +239,48 @@ export async function createRoom(
throw error;
}
}
type RoomControlRow = { room_control: string };
// SQLite constructs an explicit public allowlist, consistent with delegation views.
const roomControlProjection = `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`;
function publicRoomControl(row: RoomControlRow): RoomControl {
return JSON.parse(row.room_control) as RoomControl;
}
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;
}
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 = ?`)
.bind(code)
.first<{ state: string; version: number } & RoomControlRow>();
if (!row) 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 = ?')
.bind(code)
.first<{ state: string; version: number }>();
if (!row) throw new RuleError('Room not found. Check your invite code.');
const g: Game = JSON.parse(row.state);
g.version = row.version;
return g;
const game: Game = JSON.parse(row.state);
game.version = row.version;
return game;
}
function requireUnpaused(control: RoomControl) {
if (control.paused)
throw new RuleError(
'An administrator paused this room. Game decisions and AI will continue after it resumes.',
);
}
export type SeatAuth = { playerId: string; tokenHash: string };
export async function authenticate(
@@ -294,7 +328,14 @@ export async function joinRoom(
}
}
await requireUnusedEntryToken(entry);
const g = await readRoom(code);
const { game: g, control } = await readRoomSnapshot(code);
if (control.paused || control.joinLocked)
throw new RoomEntryError(
control.paused
? 'An administrator paused this room. New players may join after it resumes.'
: 'New joins are locked by an administrator.',
control.paused ? 'ROOM_PAUSED' : 'ROOM_JOIN_LOCKED',
);
joinGame(g, player);
const old = g.version;
g.version++;
@@ -304,7 +345,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 = ?',
'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))',
)
.bind(JSON.stringify(g), Date.now(), code, old),
db()
@@ -354,7 +395,10 @@ export async function act(
action: Action,
clock: RoomsClock = roomsClock,
) {
const current = await readRoom(code);
const { game: current, control } = await readRoomSnapshot(code);
const takingBackControl =
action?.type === 'setAutopilot' && action.difficulty === null;
if (!takingBackControl) requireUnpaused(control);
if (current.version !== version)
throw new RuleError(
'The table changed. Your view has refreshed; try again.',
@@ -375,13 +419,14 @@ 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 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 (? = 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),
now,
code,
version,
Number(takingBackControl),
code,
auth.playerId,
auth.tokenHash,
@@ -408,7 +453,7 @@ export async function act(
export async function readSeatView(code: string, auth: SeatAuth) {
const row = await db()
.prepare(
`SELECT rooms.state, rooms.version,
`SELECT rooms.state, rooms.version, ${roomControlProjection},
CASE WHEN COUNT(delegations.grant_id) = 0 THEN NULL ELSE
json_group_array(json_object(
'grantId', delegations.grant_id,
@@ -419,6 +464,7 @@ export async function readSeatView(code: string, auth: SeatAuth) {
'usedAt', delegations.used_at
)) END AS seat_ai_delegations
FROM rooms
LEFT JOIN room_controls c ON c.room_code = rooms.code
JOIN seats AS viewer ON viewer.room_code = rooms.code
LEFT JOIN seat_ai_delegations AS delegations
ON delegations.room_code = rooms.code
@@ -443,15 +489,18 @@ export async function readSeatView(code: string, auth: SeatAuth) {
GROUP BY rooms.code, rooms.state, rooms.version`,
)
.bind(code, auth.playerId, auth.tokenHash)
.first<{
state: string;
version: number;
seat_ai_delegations: string | null;
}>();
.first<
RoomControlRow & {
state: string;
version: number;
seat_ai_delegations: string | null;
}
>();
if (!row) 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;
attachRoomControl(view, row);
if (row.seat_ai_delegations) {
const delegations = JSON.parse(
row.seat_ai_delegations,
@@ -497,7 +546,8 @@ export async function continueRoomAutomatic(
clock: RoomsClock = roomsClock,
) {
for (let attempt = 0; attempt < 4; attempt++) {
const current = await readRoom(code);
const { game: current, control } = await readRoomSnapshot(code);
if (control.paused) return;
if (
!needsAutomaticRoomRecovery(current) ||
(!current.pendingTreacheryDiscard &&
@@ -512,7 +562,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 = ?',
'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)',
)
.bind(JSON.stringify(next), clock.now(), code, current.version)
.run();
@@ -530,14 +580,26 @@ export async function continueRoomBots(
clock: RoomsClock = roomsClock,
) {
for (let batch = 0; batch < batches; batch++) {
let current = await readRoom(code);
if (!current.botsPending || current.status === 'finished') return;
let snapshot = await readRoomSnapshot(code);
let current = snapshot.game;
if (
snapshot.control.paused ||
!current.botsPending ||
current.status === 'finished'
)
return;
const delay = (current.botNextActionAt ?? clock.now()) - clock.now();
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.
current = await readRoom(code);
if (!current.botsPending || current.status === 'finished') return;
snapshot = await readRoomSnapshot(code);
current = snapshot.game;
if (
snapshot.control.paused ||
!current.botsPending ||
current.status === 'finished'
)
return;
if ((current.botNextActionAt ?? 0) > clock.now()) continue;
}
// Offline simulations retain runBots' fast default. Online persistence makes
@@ -549,7 +611,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 = ?',
'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)',
)
.bind(JSON.stringify(next), clock.now(), code, current.version)
.run();
@@ -666,7 +728,8 @@ export async function setSeatAiDelegate(
);
return { view: await readSeatView(code, auth), replayed: true };
}
const current = await readRoom(code);
const { game: current, control } = await readRoomSnapshot(code);
requireUnpaused(control);
if (current.version !== version)
throw invalidDelegation(
'The table changed. Reconnect before saving this consent.',
@@ -707,6 +770,7 @@ export async function setSeatAiDelegate(
.prepare(
`UPDATE rooms SET 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)
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)
AND NOT EXISTS (SELECT 1 FROM seat_ai_delegations WHERE grant_id = ?)`,
@@ -897,7 +961,8 @@ export async function useSeatAiDelegate(
'This AI delegation has expired.',
'AI_DELEGATION_EXPIRED',
);
const current = await readRoom(code);
const { game: current, control } = await readRoomSnapshot(code);
requireUnpaused(control);
if (current.version !== version)
throw invalidDelegation(
'The table changed. Reconnect before activating this consent.',
@@ -935,6 +1000,7 @@ export async function useSeatAiDelegate(
.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)
AND EXISTS (
SELECT 1 FROM seat_ai_delegations AS consent
JOIN seats AS owner_session
@@ -1103,10 +1169,10 @@ 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 FROM rooms 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 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 }>();
.first<{ state: string; version: number } & RoomControlRow>();
if (!row)
throw new SeatControlError(
'This recovery receipt is no longer active.',
@@ -1114,8 +1180,10 @@ export async function recoverSeat(
);
const g: Game = JSON.parse(row.state);
g.version = row.version;
const view = viewGame(g, playerId) as GameView;
attachRoomControl(view, row);
return {
view: viewGame(g, playerId),
view,
token: input.newSessionToken as string,
recovered: true as const,
replayed: true,
@@ -1431,7 +1499,7 @@ async function replayClaimedHandover(
) {
const row = await db()
.prepare(
'SELECT rooms.state,rooms.version FROM rooms 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 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,
@@ -1442,12 +1510,14 @@ async function replayClaimedHandover(
sessionHash,
sessionHash,
)
.first<{ state: string; version: number }>();
.first<{ state: string; version: number } & RoomControlRow>();
if (!row) return null;
const game: Game = JSON.parse(row.state);
game.version = row.version;
const view = viewGame(game, playerId) as GameView;
attachRoomControl(view, row);
return {
view: viewGame(game, playerId),
view,
token: newSessionToken as string,
transferred: true as const,
replayed: true,
+40
View File
@@ -28,6 +28,46 @@ export const seats = sqliteTable('seats', {
playerId: text('player_id').notNull(),
});
export const roomControls = sqliteTable(
'room_controls',
{
roomCode: text('room_code')
.primaryKey()
.references(() => rooms.code, { onDelete: 'cascade' }),
paused: integer('paused').notNull().default(0),
joinLocked: integer('join_locked').notNull().default(0),
revision: integer('revision').notNull().default(0),
updatedAt: integer('updated_at').notNull(),
},
(table) => [
check('room_controls_paused', sql`${table.paused} IN (0, 1)`),
check('room_controls_join_locked', sql`${table.joinLocked} IN (0, 1)`),
check('room_controls_revision', sql`${table.revision} >= 0`),
],
);
// 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(
'admin_room_audit',
{
operationId: text('operation_id').primaryKey(),
actorAdminId: text('actor_admin_id').notNull(),
roomCode: text('room_code').notNull(),
action: text('action').notNull().default('room_control'),
reason: text('reason').notNull(),
expectedRevision: integer('expected_revision').notNull(),
beforePaused: integer('before_paused').notNull(),
beforeJoinLocked: integer('before_join_locked').notNull(),
afterPaused: integer('after_paused').notNull(),
afterJoinLocked: integer('after_join_locked').notNull(),
createdAt: integer('created_at').notNull(),
},
(table) => [
index('admin_room_audit_room').on(table.roomCode, table.createdAt),
],
);
export const seatRecoveryKeys = sqliteTable(
'seat_recovery_keys',
{