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

+9 -1
View File
@@ -13,7 +13,7 @@ void test('admin HTTP denies anonymous, forged admin, room-host and spoofed iden
assert.ok(cookie);
const room = (await created.json() as { code: string }).code;
for (const headers of [{}, { cookie }, { cookie: 'dune_admin_session=' + 'a'.repeat(64) }, { 'oai-authenticated-user-id': 'owner', 'oai-authenticated-user-email': 'owner@example.test' }] as Record<string, string>[]) {
for (const path of ['/api/admin/session', '/api/admin/rooms', `/api/admin/rooms/${room}/control`, `/api/admin/rooms/${room}/lobby`]) {
for (const path of ['/api/admin/session', '/api/admin/rooms', `/api/admin/rooms/${room}/control`, `/api/admin/rooms/${room}/lobby`, `/api/admin/rooms/${room}/removal`]) {
const result = await fetch(base + path, { headers, signal: AbortSignal.timeout(15000) });
assert.equal(result.status, 401, path);
assert.equal(result.headers.get('cache-control'), 'no-store');
@@ -34,6 +34,14 @@ void test('admin HTTP denies anonymous, forged admin, room-host and spoofed iden
assert.equal(lobby.status, 401);
assert.equal(lobby.headers.get('set-cookie'), null);
assert.deepEqual(Object.keys(await lobby.json()), ['error']);
const removal = await fetch(`${base}/api/admin/rooms/${room}/removal`, {
method: 'POST', headers: { ...headers, origin: base, 'Content-Type': 'application/json' },
body: JSON.stringify({ operationId: crypto.randomUUID(), expectedVersion: 0, expectedRevision: 0, removed: true, reason: 'Denied removal QA' }),
signal: AbortSignal.timeout(15000),
});
assert.equal(removal.status, 401);
assert.equal(removal.headers.get('set-cookie'), null);
assert.deepEqual(Object.keys(await removal.json()), ['error']);
const create = await fetch(base + '/api/admin/rooms', {
method: 'POST', headers: { ...headers, origin: base, 'Content-Type': 'application/json' },
body: JSON.stringify({ operationId: crypto.randomUUID(), sessionToken: 'b'.repeat(64), name: 'Denied creation QA', faction: 'atreides', advanced: false, techTokens: false, strongholdCards: false, bots: [], reason: 'Must not create a room' }),
+18
View File
@@ -12,6 +12,24 @@ const state = (name = 'Directory QA', status = 'lobby', advanced = false) => ({
log: ['SECRET_HISTORY'], unknownFuturePrivateField: 'SECRET_NEW_FIELD',
});
const insert = (sqlite: DatabaseSync, code: string, value: unknown, updated = 100) => sqlite.prepare('INSERT INTO rooms (code,state,version,updated_at) VALUES (?,?,0,?)').run(code, typeof value === 'string' ? value : JSON.stringify(value), updated);
void test('recoverably removed rooms leave the default directory and remain explicitly searchable for restoration', async () => {
const f = await fixture();
try {
insert(f.sqlite, 'ACTIVEAA', state('Active QA'));
insert(f.sqlite, 'REMOVEDA', state('Removed QA'));
f.sqlite.prepare('INSERT INTO room_removals(room_code,removed,revision,removed_at,updated_at) VALUES (?,1,1,100,100)').run('REMOVEDA');
const read = (query = '') => readAdminDirectory(f.database, f.identity, new URLSearchParams(query));
const active = await read(); assert.equal(active.total, 1); assert.equal(active.rooms[0].code, 'ACTIVEAA'); assert.equal(active.rooms[0].removed, false);
assert.equal((await read('q=Removed')).total, 0);
const removed = await read('removal=removed&q=Removed'); assert.equal(removed.total, 1); assert.equal(removed.rooms[0].removed, true);
assert.equal(JSON.stringify(removed).includes('SECRET'), false);
assert.equal((await read('removal=all')).total, 2);
await assert.rejects(read('removal=invalid'), (e: unknown) => e instanceof AdminError && e.status === 400);
f.sqlite.prepare('UPDATE room_removals SET removed=0,revision=2,removed_at=NULL WHERE room_code=?').run('REMOVEDA');
assert.equal((await read()).total, 2); assert.equal((await read('removal=removed')).total, 0);
} finally { f.sqlite.close(); }
});
async function fixture() {
const store = adminStore();
const account = store.provision('viewer');
+83
View File
@@ -0,0 +1,83 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { adminRemovalConfirmation, adminRemovalResponse, clearAdminRemovalRequest, newAdminRemovalRequest, readAdminRemovalRequest, saveAdminRemovalRequest } from '../lib/admin-removal-client';
import { retainAdminRoomRequest } from '../lib/admin-room-control-client';
import { ClientRequestError } from '../lib/client-request';
import type { AdminRemovalView } from '../lib/admin-removal';
const view = (): AdminRemovalView => ({ code: 'ABCD2345', version: 7, removed: false, revision: 2, removedAt: null, updatedAt: null, paused: false, joinLocked: true });
function storage() {
const values = new Map<string, string>();
return { values, getItem: (key: string) => values.get(key) ?? null, setItem: (key: string, value: string) => { values.set(key, value); }, removeItem: (key: string) => { values.delete(key); } };
}
void test('removal retry preserves exact intent/version/revision/reason across refresh and isolates account and room', () => {
const tab = storage(), room = view(), input = newAdminRemovalRequest(room, true, ' QA removal ');
saveAdminRemovalRequest(tab, 'a', room.code, input);
assert.deepEqual(readAdminRemovalRequest(tab, 'a', room.code), input);
assert.equal(input.expectedVersion, 7); assert.equal(input.expectedRevision, 2); assert.equal(input.reason, 'QA removal');
assert.equal(readAdminRemovalRequest(tab, 'b', room.code), null);
assert.equal(readAdminRemovalRequest(tab, 'a', 'DIFF2345'), null);
for (const change of [{ operationId: crypto.randomUUID() }, { expectedVersion: 8 }, { expectedRevision: 3 }, { reason: 'Different' }, { removed: false }])
assert.throws(() => saveAdminRemovalRequest(tab, 'a', room.code, { ...input, ...change }), /Resolve the saved/);
saveAdminRemovalRequest(tab, 'a', room.code, input);
clearAdminRemovalRequest(tab, 'a', room.code);
assert.equal(readAdminRemovalRequest(tab, 'a', room.code), null);
const restore = newAdminRemovalRequest({ ...room, removed: true, version: 8, revision: 3 }, false, 'Restore');
saveAdminRemovalRequest(tab, 'a', room.code, restore);
assert.deepEqual(readAdminRemovalRequest(tab, 'a', room.code), restore);
});
void test('corrupt or unwritable removal retry storage fails closed and never replaces the saved record', () => {
const tab = storage(), room = view(), input = newAdminRemovalRequest(room, true, 'QA');
assert.throws(() => saveAdminRemovalRequest({ ...tab, setItem() {} }, 'a', room.code, input), /Nothing was sent/);
saveAdminRemovalRequest(tab, 'a', room.code, input);
const key = [...tab.values.keys()][0];
for (const raw of ['{broken', JSON.stringify({ ...input, sessionToken: 'secret' }), JSON.stringify({ ...input, expectedRevision: -1 }), ' '.repeat(2049)]) {
tab.values.set(key, raw);
assert.throws(() => readAdminRemovalRequest(tab, 'a', room.code), /unreadable/);
assert.throws(() => saveAdminRemovalRequest(tab, 'a', room.code, input), /unreadable/);
assert.equal(tab.values.get(key), raw);
}
assert.throws(() => clearAdminRemovalRequest({ ...tab, removeItem() {} }, 'a', room.code), /could not be cleared/);
clearAdminRemovalRequest(tab, 'a', room.code);
for (const reason of [' ', 'a'.repeat(301), 'two\nlines', 'bad\u007freason'])
assert.throws(() => newAdminRemovalRequest(room, true, reason));
assert.throws(() => newAdminRemovalRequest(room, false, 'No change'));
assert.throws(() => newAdminRemovalRequest({ ...room, revision: Number.MAX_SAFE_INTEGER }, true, 'Overflow'));
});
void test('confirmation binds exact operation and both applied versions while replay accepts later current availability', () => {
const room = view(), input = newAdminRemovalRequest(room, true, 'QA');
const current = { ...room, version: 12, revision: 4, removed: false, removedAt: 100, updatedAt: 200, paused: true };
const receipt = { operationId: input.operationId, appliedVersion: 8, appliedRevision: 3, replayed: true, room: current };
assert.deepEqual(adminRemovalConfirmation(receipt, room.code, input), receipt);
const fresh = { ...receipt, replayed: false, room: { ...room, version: 8, revision: 3, removed: true, removedAt: 100, updatedAt: 100 } };
assert.deepEqual(adminRemovalConfirmation(fresh, room.code, input), fresh);
for (const invalid of [{ ...receipt, operationId: crypto.randomUUID() }, { ...receipt, appliedVersion: 9 }, { ...receipt, appliedRevision: 4 },
{ ...receipt, room: { ...current, version: 7 } }, { ...receipt, room: { ...current, revision: 2 } },
{ ...receipt, room: { ...current, code: 'DIFF2345' } }, { ...receipt, sessionToken: 'secret' }, { ...receipt, replayed: undefined }])
assert.throws(() => adminRemovalConfirmation(invalid, room.code, input));
});
void test('room availability projection excludes private data and rejects malformed metadata', () => {
const room = view();
assert.deepEqual(adminRemovalResponse(room, room.code), room);
assert.doesNotThrow(() => adminRemovalResponse({ ...room, removed: true, removedAt: 100, updatedAt: 100 }, room.code));
for (const invalid of [null, [], { ...room, state: {} }, { ...room, reason: 'Private audit' }, { ...room, seats: [] },
{ ...room, version: 1.5 }, { ...room, revision: -1 }, { ...room, code: 'bad code' }, { ...room, removed: 'true' },
{ ...room, removedAt: -1 }, { ...room, removedAt: Number.MAX_SAFE_INTEGER }, { ...room, updatedAt: '100' },
{ ...room, paused: undefined }, { ...room, joinLocked: null }])
assert.throws(() => adminRemovalResponse(invalid, room.code));
});
void test('uncertain removal and changed-access responses keep the exact saved request', () => {
const tab = storage(), room = view(), input = newAdminRemovalRequest(room, true, 'QA');
saveAdminRemovalRequest(tab, 'a', room.code, input);
for (const error of [new ClientRequestError('Offline', 'network'), new ClientRequestError('Timeout', 'timeout'),
new ClientRequestError('Unreadable', 'invalid-response', 200), ...[401, 403, 408, 500, 502, 503].map(status => new ClientRequestError('Unavailable', 'http', status)), new Error('Wrong confirmation')]) {
if (!retainAdminRoomRequest(error)) clearAdminRemovalRequest(tab, 'a', room.code);
assert.deepEqual(readAdminRemovalRequest(tab, 'a', room.code), input);
}
for (const status of [400, 404, 409, 422]) assert.equal(retainAdminRoomRequest(new ClientRequestError('Rejected', 'http', status)), false);
});
+887
View File
@@ -0,0 +1,887 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { randomBytes, randomUUID } from 'node:crypto';
import { mkdtempSync, readFileSync, readdirSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { adminStore, pauseAdminBatch, sha256 } from './admin-access-fixture';
import {
AdminError,
adminLogin,
requireAdmin,
type AdminRole,
} from '../db/admin-access';
import { applyAdminRemoval, readAdminRemoval } from '../db/admin-removal';
import {
validAdminRemovalInput,
type AdminRemovalInput,
} from '../lib/admin-removal';
import {
applyAdminRoomControl,
readAdminRoomControl,
} from '../db/admin-lifecycle';
import {
configureAdminLobby,
readAdminLobby,
} from '../db/admin-lobby-configuration';
import { createAdminRoom } from '../db/admin-room-creation';
import { createGame, newPlayer } from '../game/engine';
const now = 10000;
const secret = () => randomBytes(32).toString('hex');
const status = (value: number) => (error: unknown) =>
error instanceof AdminError && error.status === value;
type Store = ReturnType<typeof adminStore>;
const rows = (store: Store, table: string) =>
store.sqlite.prepare(`SELECT * FROM ${table}`).all();
const secureTables = [
'seats',
'seat_recovery_keys',
'seat_recovery_receipts',
'seat_handover_offers',
'seat_handover_claim_receipts',
'seat_ai_delegations',
'room_entry_receipts',
'room_messages',
];
const secureRows = (store: Store) =>
secureTables.map((table) => rows(store, table));
const change = (
expectedVersion = 0,
expectedRevision = 0,
removed = true,
): AdminRemovalInput => ({
operationId: randomUUID(),
expectedVersion,
expectedRevision,
removed,
reason: 'Dedicated QA room removal',
});
function seed(store: Store, code = 'REMOVALS') {
const host = newPlayer(randomUUID(), 'Private player name', 'atreides');
const game = createGame(code, host, false);
const token = secret(),
tokenHash = sha256(token),
revokedHash = sha256(secret());
store.sqlite
.prepare('INSERT INTO rooms(code,state,version,updated_at) VALUES(?,?,?,?)')
.run(code, JSON.stringify(game), 0, 1000);
store.sqlite
.prepare(
'INSERT INTO seats(token_hash,room_code,player_id,revoked) VALUES(?,?,?,?)',
)
.run(tokenHash, code, host.id, 0);
store.sqlite
.prepare(
'INSERT INTO seats(token_hash,room_code,player_id,revoked) VALUES(?,?,?,?)',
)
.run(revokedHash, code, host.id, 1);
return { host, game, token, tokenHash, revokedHash, code };
}
function seedDependents(store: Store, room: ReturnType<typeof seed>) {
const { code, host, tokenHash } = room;
store.sqlite
.prepare(
'INSERT INTO room_entry_receipts(operation_hash,request_hash,session_hash,room_code,player_id) VALUES(?,?,?,?,?)',
)
.run(sha256(secret()), sha256(secret()), tokenHash, code, host.id);
store.sqlite
.prepare(
'INSERT INTO seat_recovery_keys(room_code,player_id,recovery_hash,current_operation_hash) VALUES(?,?,?,?)',
)
.run(code, host.id, sha256(secret()), sha256(secret()));
store.sqlite
.prepare(
'INSERT INTO seat_recovery_receipts(room_code,player_id,operation_hash,session_hash,recovery_hash) VALUES(?,?,?,?,?)',
)
.run(code, host.id, sha256(secret()), tokenHash, sha256(secret()));
store.sqlite
.prepare(
'INSERT INTO seat_handover_offers(room_code,player_id,offer_hash,secret_hash,issuer_session_hash,expires_at) VALUES(?,?,?,?,?,?)',
)
.run(code, host.id, sha256(secret()), sha256(secret()), tokenHash, now + 1);
store.sqlite
.prepare(
'INSERT INTO seat_handover_claim_receipts(room_code,player_id,operation_hash,offer_hash,secret_hash,session_hash,claim_fence,claimed_at) VALUES(?,?,?,?,?,?,?,?)',
)
.run(
code,
host.id,
sha256(secret()),
sha256(secret()),
sha256(secret()),
tokenHash,
sha256(secret()),
now,
);
store.sqlite
.prepare(
'INSERT INTO seat_ai_delegations(room_code,owner_id,grant_id,delegate_id,difficulty,owner_session_hash,delegate_session_hash,expires_at,revoked_at) VALUES(?,?,?,?,?,?,?,?,?)',
)
.run(
code,
host.id,
randomUUID(),
randomUUID(),
'Hard',
tokenHash,
sha256(secret()),
now + 1,
now,
);
store.sqlite
.prepare(
'INSERT INTO room_messages(room_code,id,sender_id,sender_session_hash,sender_name,sender_faction,recipient_id,recipient_name,body,created_at) VALUES(?,?,?,?,?,?,?,?,?,?)',
)
.run(
code,
randomUUID(),
host.id,
tokenHash,
host.name,
host.faction,
randomUUID(),
'Private recipient',
'Private saved discussion',
now,
);
}
async function fixture(role: AdminRole = 'operator') {
const store = adminStore();
const account = store.provision(role, 'Private administrator');
const login = await adminLogin(store.database, account.key, now);
const identity = await requireAdmin(
store.database,
login.token,
undefined,
now,
);
const room = seed(store);
const row = () =>
store.sqlite.prepare('SELECT * FROM rooms WHERE code=?').get(room.code)!;
const version = () => Number(row().version);
const read = () => readAdminRemoval(store.database, identity, room.code, now);
const apply = (input = change(version()), at = now) =>
applyAdminRemoval(store.database, identity, room.code, input, at);
return {
...store,
...room,
account,
login,
identity,
row,
version,
read,
apply,
};
}
function gateBatch(store: Store, number = 1) {
let enter!: () => void, release!: () => void;
const waiting = new Promise<void>((resolve) => {
enter = resolve;
});
const gate = new Promise<void>((resolve) => {
release = resolve;
});
store.hooks.beforeBatch = async () => {
if (--number) return;
delete store.hooks.beforeBatch;
enter();
await gate;
};
return { waiting, release };
}
void test('removal migration is additive and preserves all existing rooms, credentials and audit records', () => {
const store = adminStore(':memory:', false);
try {
for (const file of readdirSync(new URL('../drizzle/', import.meta.url))
.filter((file) => file.endsWith('.sql') && file < '0012')
.sort())
store.sqlite.exec(
readFileSync(new URL('../drizzle/' + file, import.meta.url), 'utf8'),
);
const room = seed(store);
seedDependents(store, room);
store.provision();
const tables = [
'rooms',
...secureTables,
'room_controls',
'admin_accounts',
'admin_sessions',
'admin_audit',
'admin_room_audit',
'admin_room_creations',
'admin_lobby_operations',
];
const before = tables.map((table) => rows(store, table));
store.sqlite.exec(
readFileSync(
new URL('../drizzle/0012_admin_room_removal.sql', import.meta.url),
'utf8',
),
);
assert.deepEqual(
tables.map((table) => rows(store, table)),
before,
);
assert.deepEqual(rows(store, 'room_removals'), []);
assert.deepEqual(rows(store, 'admin_room_removals'), []);
assert.throws(
() =>
store.sqlite
.prepare(
'INSERT INTO room_removals(room_code,removed,revision,updated_at) VALUES(?,1,0,?)',
)
.run(room.code, now),
/CHECK/,
);
} finally {
store.sqlite.close();
}
});
void test('removal inputs require exact bounded fields and both safe version counters', () => {
const valid = change();
assert.equal(validAdminRemovalInput(valid), true);
for (const invalid of [
null,
[],
{},
{ ...valid, extra: true },
{ ...valid, removed: 1 },
...['', ' ', 'x'.repeat(301), 'line\n', 'a\u007f'].map((reason) => ({
...valid,
reason,
})),
...['', valid.operationId + '\n', valid.operationId.toUpperCase()].map(
(operationId) => ({ ...valid, operationId }),
),
...[-1, 0.5, Infinity, NaN, Number.MAX_SAFE_INTEGER, '0'].flatMap(
(value) => [
{ ...valid, expectedVersion: value },
{ ...valid, expectedRevision: value },
],
),
Object.assign(Object.create({ removed: true }), {
operationId: valid.operationId,
expectedVersion: 0,
expectedRevision: 0,
reason: 'Reason',
other: true,
}),
])
assert.equal(validAdminRemovalInput(invalid), false);
});
void test('live roles can read only operational defaults without backfilling or disclosing room and seat contents', async () => {
for (const role of ['owner', 'operator', 'viewer'] as const) {
const f = await fixture(role);
try {
const before = f.row();
assert.deepEqual(await f.read(), {
code: f.code,
version: 0,
removed: false,
revision: 0,
removedAt: null,
updatedAt: null,
paused: false,
joinLocked: false,
});
assert.deepEqual(f.row(), before);
assert.deepEqual(rows(f, 'room_removals'), []);
if (role === 'viewer') await assert.rejects(f.apply(), status(403));
else assert.equal((await f.apply()).room.removed, true);
await assert.rejects(
readAdminRemoval(f.database, f.identity, f.code + '\n', now),
status(400),
);
await assert.rejects(
readAdminRemoval(f.database, f.identity, 'ABSENTAA', now),
status(404),
);
await assert.rejects(
readAdminRemoval(
f.database,
{ ...f.identity, sessionHash: f.tokenHash },
f.code,
now,
),
status(401),
);
} finally {
f.sqlite.close();
}
}
});
void test('remove and restore preserve saved JSON, every dependent record, revoked access and unrelated rooms', async () => {
const f = await fixture();
try {
seedDependents(f, f);
const other = seed(f, 'KEEPSAFE');
const old = f.row(),
secure = secureRows(f);
const untouched = f.sqlite
.prepare('SELECT * FROM rooms WHERE code=?')
.get(other.code);
const result = await f.apply();
assert.equal(result.replayed, false);
assert.equal(result.appliedVersion, 1);
assert.equal(result.appliedRevision, 1);
assert.deepEqual(result.room, {
code: f.code,
version: 1,
removed: true,
revision: 1,
removedAt: now,
updatedAt: now,
paused: false,
joinLocked: false,
});
assert.equal(f.row().state, old.state);
const restored = await f.apply(change(1, 1, false), now + 5000);
assert.deepEqual(restored.room, {
...result.room,
version: 2,
removed: false,
revision: 2,
removedAt: null,
updatedAt: now + 5000,
});
assert.equal(f.row().state, old.state);
assert.deepEqual(secureRows(f), secure);
assert.deepEqual(
f.sqlite.prepare('SELECT * FROM rooms WHERE code=?').get(other.code),
untouched,
);
assert.equal(rows(f, 'room_removals').length, 1);
const audit = rows(f, 'admin_room_removals');
assert.equal(audit.length, 2);
for (const row of audit) {
const metadata = JSON.stringify([
JSON.parse(String(row.before_metadata)),
JSON.parse(String(row.after_metadata)),
]);
for (const privateValue of [
f.host.id,
f.host.name,
f.token,
f.tokenHash,
'Private saved discussion',
'spice',
'deck',
f.account.name,
])
assert.equal(metadata.includes(privateValue), false);
assert.equal(row.actor_admin_id, f.identity.id);
assert.equal(row.reason, change().reason);
}
} finally {
f.sqlite.close();
}
});
void test('removal and restoration tolerate unreadable saves without parsing diagnostics or repairing JSON', async () => {
for (const state of [
'{invalid private save',
'null',
'[]',
'{"status":"playing","players":[null,7,"private"]}',
]) {
const f = await fixture();
try {
f.sqlite
.prepare('UPDATE rooms SET state=? WHERE code=?')
.run(state, f.code);
await f.apply();
assert.equal((await f.read()).removed, true);
await f.apply(change(1, 1, false));
assert.equal(f.row().state, state);
} finally {
f.sqlite.close();
}
}
});
void test('restoration refreshes existing AI pacing only when unpaused and preserves pause and joining flags', async () => {
for (const paused of [false, true])
for (const controller of ['bot', 'autopilot', 'human'] as const) {
const f = await fixture();
try {
const game = structuredClone(f.game);
game.status = 'playing';
if (controller !== 'human') game.players[0][controller] = 'Hard';
game.botsPending = controller !== 'human';
game.botNextActionAt = 5;
f.sqlite
.prepare('UPDATE rooms SET state=? WHERE code=?')
.run(JSON.stringify(game), f.code);
const controls = {
operationId: randomUUID(),
expectedRevision: 0,
paused,
joinLocked: true,
reason: 'QA controls',
};
await applyAdminRoomControl(
f.database,
f.identity,
f.code,
controls,
now,
);
const state = String(f.row().state),
beforeControls = rows(f, 'room_controls');
await f.apply(change(f.version()));
assert.equal(f.row().state, state);
const restored = await f.apply(
change(f.version(), 1, false),
now + 3000,
);
assert.equal(restored.room.paused, paused);
assert.equal(restored.room.joinLocked, true);
const expected = JSON.parse(state);
if (!paused && controller !== 'human') {
expected.botsPending = true;
expected.botNextActionAt = now + 4500;
} else assert.equal(f.row().state, state);
assert.deepEqual(JSON.parse(String(f.row().state)), expected);
assert.deepEqual(rows(f, 'room_controls'), beforeControls);
} finally {
f.sqlite.close();
}
}
});
void test('exact receipts bind actor, room and normalized intent and report current state after later transitions', async () => {
const f = await fixture();
try {
const input = { ...change(), reason: ' Exact QA reason ' };
const first = await f.apply(input);
const restore = await f.apply(change(1, 1, false), now + 1);
const replay = await f.apply(
{ ...input, reason: 'Exact QA reason' },
now + 2,
);
assert.deepEqual(replay, { ...first, replayed: true, room: restore.room });
const before = f.row();
for (const mismatch of [
{ ...input, expectedVersion: 1 },
{ ...input, expectedRevision: 1 },
{ ...input, removed: false },
{ ...input, reason: 'Other reason' },
])
await assert.rejects(f.apply(mismatch), status(409));
const account = f.provision('operator');
const login = await adminLogin(f.database, account.key, now);
const identity = await requireAdmin(
f.database,
login.token,
undefined,
now,
);
await assert.rejects(
applyAdminRemoval(f.database, identity, f.code, input, now),
status(409),
);
seed(f, 'KEEPSAFE');
await assert.rejects(
applyAdminRemoval(f.database, f.identity, 'KEEPSAFE', input, now),
status(409),
);
assert.deepEqual(f.row(), before);
assert.equal(rows(f, 'admin_room_removals').length, 2);
} finally {
f.sqlite.close();
}
});
void test('new no-op and stale requests reject without writes; both room version and removal revision fence races', async () => {
const f = await fixture();
try {
const before = f.row();
for (const input of [change(0, 0, false), change(1), change(0, 1)])
await assert.rejects(f.apply(input), status(409));
assert.deepEqual(f.row(), before);
assert.deepEqual(rows(f, 'admin_room_removals'), []);
const pending = change();
const gate = pauseAdminBatch(f);
const result = f.apply(pending).then(
() => null,
(error: unknown) => error,
);
await gate.waiting;
f.sqlite
.prepare('UPDATE rooms SET version=version+1 WHERE code=?')
.run(f.code);
gate.release();
assert.ok(status(409)(await result));
await f.apply(change(1));
await assert.rejects(f.apply(change(2, 1, true)), status(409));
await assert.rejects(f.apply(change(2, 0, false)), status(409));
assert.equal(rows(f, 'admin_room_removals').length, 1);
} finally {
f.sqlite.close();
}
});
void test('live session, account, role, expiry and generation authority fences reads, writes and exact replay', async () => {
for (const operation of ['read', 'write', 'replay'] as const)
for (const revoke of [
'disabled',
'role',
'session',
'generation',
'expiry',
] as const) {
const f = await fixture();
try {
const input = change();
if (operation === 'replay') await f.apply(input);
const before = f.row(),
audit = rows(f, 'admin_room_removals');
const gate = pauseAdminBatch(f);
const pending = (operation === 'read' ? f.read() : f.apply(input)).then(
() => null,
(error: unknown) => error,
);
await gate.waiting;
if (revoke === 'disabled')
f.sqlite
.prepare('UPDATE admin_accounts SET enabled=0 WHERE id=?')
.run(f.identity.id);
if (revoke === 'role')
f.sqlite
.prepare("UPDATE admin_accounts SET role='viewer' WHERE id=?")
.run(f.identity.id);
if (revoke === 'session')
f.sqlite
.prepare(
'UPDATE admin_sessions SET revoked_at=? WHERE token_hash=?',
)
.run(now, f.identity.sessionHash);
if (revoke === 'generation')
f.sqlite
.prepare(
'UPDATE admin_accounts SET session_generation=session_generation+1 WHERE id=?',
)
.run(f.identity.id);
if (revoke === 'expiry')
f.sqlite
.prepare(
'UPDATE admin_sessions SET expires_at=? WHERE token_hash=?',
)
.run(now, f.identity.sessionHash);
gate.release();
const result = await pending;
if (revoke === 'role' && operation === 'read')
assert.equal(result, null);
else assert.ok(status(revoke === 'role' ? 403 : 401)(result));
assert.deepEqual(f.row(), before);
assert.deepEqual(rows(f, 'admin_room_removals'), audit);
} finally {
f.sqlite.close();
}
}
});
void test('queued identical requests commit once while competing operations cannot reuse the old version', async () => {
for (const same of [true, false]) {
const f = await fixture();
try {
const input = change(),
gate = pauseAdminBatch(f);
const pending = f.apply(input).then(
(value) => value,
(error: unknown) => error,
);
await gate.waiting;
const first = await f.apply(same ? input : change());
gate.release();
const second = await pending;
if (same) assert.deepEqual(second, { ...first, replayed: true });
else assert.ok(status(409)(second));
assert.equal(f.version(), 1);
assert.equal(rows(f, 'admin_room_removals').length, 1);
} finally {
f.sqlite.close();
}
}
});
void test('audit, game fence and removal metadata roll back together at every failed persistence stage', async () => {
for (const table of ['admin_room_removals', 'rooms', 'room_removals']) {
const f = await fixture();
try {
const before = f.row(),
secure = secureRows(f);
f.sqlite.exec(
`CREATE TRIGGER qa_failure BEFORE ${table === 'rooms' ? 'UPDATE' : 'INSERT'} ON ${table} BEGIN SELECT RAISE(ABORT,'QA removal failure'); END;`,
);
await assert.rejects(f.apply(), /QA removal failure/);
assert.deepEqual(f.row(), before);
assert.deepEqual(secureRows(f), secure);
assert.deepEqual(rows(f, 'admin_room_removals'), []);
assert.deepEqual(rows(f, 'room_removals'), []);
} finally {
f.sqlite.close();
}
}
});
void test('removed rooms keep public admin reads and old receipts while new lobby and pause mutations reject', async () => {
const f = await fixture();
try {
const lobbyInput = {
operationId: randomUUID(),
expectedVersion: 0,
action: { type: 'rules' as const, advanced: true },
reason: 'QA rules',
};
const lobby = await configureAdminLobby(
f.database,
f.identity,
f.code,
lobbyInput,
now,
);
const controls = {
operationId: randomUUID(),
expectedRevision: 0,
paused: false,
joinLocked: true,
reason: 'QA controls',
};
await applyAdminRoomControl(f.database, f.identity, f.code, controls, now);
await f.apply(change(f.version()));
const before = f.row();
assert.equal(
(await readAdminLobby(f.database, f.identity, f.code, now)).editable,
false,
);
assert.equal(
(await readAdminRoomControl(f.database, f.identity, f.code, now))
.joinLocked,
true,
);
const receipt = await configureAdminLobby(
f.database,
f.identity,
f.code,
lobbyInput,
now,
);
assert.equal(receipt.replayed, true);
assert.equal(receipt.appliedVersion, lobby.appliedVersion);
assert.equal(receipt.lobby.editable, false);
assert.equal(
(
await applyAdminRoomControl(
f.database,
f.identity,
f.code,
controls,
now,
)
).replayed,
true,
);
await assert.rejects(
configureAdminLobby(
f.database,
f.identity,
f.code,
{
...lobbyInput,
operationId: randomUUID(),
expectedVersion: f.version(),
},
now,
),
status(409),
);
await assert.rejects(
applyAdminRoomControl(
f.database,
f.identity,
f.code,
{
...controls,
operationId: randomUUID(),
expectedRevision: 1,
paused: true,
},
now,
),
status(409),
);
assert.deepEqual(f.row(), before);
} finally {
f.sqlite.close();
}
});
void test('prepared admin lobby and pause writes lose their transaction race to removal', async () => {
for (const operation of ['lobby', 'pause'] as const) {
const f = await fixture();
try {
const gate = gateBatch(f, operation === 'lobby' ? 2 : 1);
const pending = (
operation === 'lobby'
? configureAdminLobby(
f.database,
f.identity,
f.code,
{
operationId: randomUUID(),
expectedVersion: 0,
action: { type: 'rules', advanced: true },
reason: 'QA rules',
},
now,
)
: applyAdminRoomControl(
f.database,
f.identity,
f.code,
{
operationId: randomUUID(),
expectedRevision: 0,
paused: true,
joinLocked: true,
reason: 'QA pause',
},
now,
)
).then(
() => null,
(error: unknown) => error,
);
await gate.waiting;
await f.apply();
const before = f.row();
gate.release();
assert.ok(status(409)(await pending));
assert.deepEqual(f.row(), before);
assert.deepEqual(rows(f, 'admin_lobby_operations'), []);
assert.deepEqual(rows(f, 'admin_room_audit'), []);
} finally {
f.sqlite.close();
}
}
});
void test('creation receipts report temporary removal without granting access and recover only still-active original seats', async () => {
const f = await fixture();
try {
const input = {
operationId: randomUUID(),
sessionToken: secret(),
name: 'New host',
faction: 'atreides' as const,
advanced: false,
techTokens: false,
strongholdCards: false,
bots: [],
reason: 'QA creation',
};
const created = await createAdminRoom(f.database, f.identity, input, now);
await applyAdminRemoval(
f.database,
f.identity,
created.code,
change(),
now,
);
assert.deepEqual(
await createAdminRoom(f.database, f.identity, input, now),
{ ...created, replayed: true, hostAccess: false, roomRemoved: true },
);
await applyAdminRemoval(
f.database,
f.identity,
created.code,
change(1, 1, false),
now,
);
assert.deepEqual(
await createAdminRoom(f.database, f.identity, input, now),
{ ...created, replayed: true },
);
await applyAdminRemoval(
f.database,
f.identity,
created.code,
change(2, 2),
now,
);
f.sqlite
.prepare('UPDATE seats SET revoked=1 WHERE room_code=?')
.run(created.code);
await applyAdminRemoval(
f.database,
f.identity,
created.code,
change(3, 3, false),
now,
);
assert.deepEqual(
await createAdminRoom(f.database, f.identity, input, now),
{ ...created, replayed: true, hostAccess: false },
);
assert.equal(rows(f, 'admin_room_creations').length, 1);
} finally {
f.sqlite.close();
}
});
void test('removed games and exact receipts survive database reopen without restoring historical credentials', async () => {
const dir = mkdtempSync(join(tmpdir(), 'dune-admin-removal-'));
let store = adminStore(join(dir, 'rooms.sqlite'));
try {
const account = store.provision('operator'),
login = await adminLogin(store.database, account.key, now);
const identity = await requireAdmin(
store.database,
login.token,
undefined,
now,
),
room = seed(store);
seedDependents(store, room);
const secure = secureRows(store),
state = rows(store, 'rooms')[0].state;
const input = change();
const removed = await applyAdminRemoval(
store.database,
identity,
room.code,
input,
now,
);
store.sqlite.close();
store = adminStore(join(dir, 'rooms.sqlite'), false);
assert.deepEqual(
await applyAdminRemoval(store.database, identity, room.code, input, now),
{ ...removed, replayed: true },
);
await applyAdminRemoval(
store.database,
identity,
room.code,
change(1, 1, false),
now + 10000,
);
assert.equal(rows(store, 'rooms')[0].state, state);
assert.deepEqual(secureRows(store), secure);
const audit = rows(store, 'admin_room_removals');
store.sqlite.prepare('DELETE FROM rooms WHERE code=?').run(room.code);
assert.deepEqual(rows(store, 'admin_room_removals'), audit);
} finally {
store.sqlite.close();
rmSync(dir, { recursive: true, force: true });
}
});
+14
View File
@@ -11,6 +11,20 @@ function storage() {
}
const receipt = (operationId: string): AdminRoomCreationResult => ({ operationId, code: 'ABCD2345', hostId: crypto.randomUUID(), replayed: true, hostAccess: true });
void test('a removed room confirmation cannot erase its only creation proof before restoration', () => {
const tab = storage(), input = newAdminRoomCreationRequest(fields());
saveAdminRoomCreation(tab, 'a', input);
const result = { ...receipt(input.operationId), hostAccess: false, roomRemoved: true as const };
assert.equal(validAdminRoomCreationResult(result), true);
assert.equal(validAdminRoomCreationResult({ ...result, hostAccess: true }), false);
assert.equal(validAdminRoomCreationResult({ ...result, roomRemoved: false }), false);
assert.throws(() => completeAdminRoomCreation(tab, 'a', input, result), /temporarily removed/);
assert.deepEqual(readAdminRoomCreation(tab, 'a'), { kind: 'pending', input });
const restored = receipt(input.operationId);
completeAdminRoomCreation(tab, 'a', input, restored);
assert.deepEqual(readAdminRoomCreation(tab, 'a'), { kind: 'completed', result: restored });
});
void test('creation retry preserves one exact private host proof and roster order in its original administrator scope', () => {
const tab = storage(), input = newAdminRoomCreationRequest(fields());
assert.equal(input.name, 'Host QA'); assert.equal(input.reason, 'Dedicated QA lobby');
+27
View File
@@ -4,11 +4,38 @@ import {
ClientRequestError,
requestJson,
requestMayHaveCompleted,
isRoomRemoved,
subscribeRoomRemoval,
} from '../lib/client-request';
const never = () => new Promise<never>(() => {});
const pause = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
void test('typed removal retains post-commit proofs and notifies only the affected public room', async () => {
const rooms: string[] = [];
const stop = subscribeRoomRemoval(room => rooms.push(room));
const fetcher: typeof fetch = async () => Response.json({ code: 'ROOM_REMOVED', error: 'Room removed', private: 'NOT_FOR_LISTENERS' }, { status: 410 });
try {
for (const path of ['/api/rooms/TEST2345', '/api/rooms/TEST2345/control', '/api/rooms/TEST2345/messages', '/api/rooms'])
await assert.rejects(requestJson(path, { method: 'POST' }, { fetcher }), error => isRoomRemoved(error) && error.status === 410 && requestMayHaveCompleted(error));
assert.deepEqual(rooms, ['TEST2345', 'TEST2345', 'TEST2345']);
} finally { stop(); }
await assert.rejects(requestJson('/api/rooms/TEST2345', {}, { fetcher }));
assert.equal(rooms.length, 3, 'Unmounted controllers no longer receive removal events');
assert.equal(requestMayHaveCompleted(new ClientRequestError('Ordinary rejection', 'http', 410)), false);
});
void test('removal observers cannot swallow the typed error or trigger automatic request retries', async () => {
let calls = 0;
const stop = subscribeRoomRemoval(() => { throw new Error('Controller failed'); });
try {
await assert.rejects(requestJson('/api/rooms/TEST2345', {}, { fetcher: async () => {
calls++; return Response.json({ code: 'ROOM_REMOVED', error: 'Removed' }, { status: 410 });
} }), isRoomRemoved);
assert.equal(calls, 1);
} finally { stop(); }
});
void test('successful JSON preserves mutation body, version and credential options, with one fetch', async () => {
let calls = 0;
let signal: AbortSignal | null | undefined;
+20
View File
@@ -0,0 +1,20 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { applyAction, createGame, joinGame, newPlayer, type Game } from '../game/engine';
for (const change of ['join', 'faction'] as const) {
void test(`human ${change} clears human readiness while AI remains ready through saved start`, () => {
let game = createGame('READY234', newPlayer('host', 'Host', 'atreides'), false);
game = applyAction(game, 'host', { type: 'addBot', faction: 'harkonnen', difficulty: 'Easy' });
game = applyAction(game, 'host', { type: 'ready' });
if (change === 'join') joinGame(game, newPlayer('guest', 'Guest', 'emperor'));
else game = applyAction(game, 'host', { type: 'faction', faction: 'emperor' });
assert.ok(game.players.filter(p => !p.bot).every(p => !p.ready));
assert.ok(game.players.filter(p => p.bot).every(p => p.ready));
game = JSON.parse(JSON.stringify(game)) as Game;
for (const player of game.players.filter(p => !p.bot)) game = applyAction(game, player.id, { type: 'ready' });
const started = applyAction(game, 'host', { type: 'start' });
assert.equal(started.status, 'setup');
assert.equal(started.players.length, change === 'join' ? 3 : 2);
});
}
+7
View File
@@ -23,6 +23,13 @@ const fields = {
advanced: false,
};
const playerId = '93c66e7c-2d3b-4845-aedf-5d1f06cf3371';
void test('temporary removal cannot clear a first creation or join proof that may already have committed', () => {
const removed = new ClientRequestError('Room removed', 'http', 410, 'ROOM_REMOVED');
assert.equal(roomEntryRejectedBeforeCommit(true, removed), false);
assert.equal(roomEntryRejectedBeforeCommit(false, removed), false);
assert.equal(roomEntryRejectedBeforeCommit(true, new ClientRequestError('Invalid input', 'http', 400)), true);
});
const memoryStorage = () => {
const data = new Map<string, string>();
return {
+918
View File
@@ -0,0 +1,918 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { randomBytes, randomUUID, webcrypto } from 'node:crypto';
import { readFileSync } from 'node:fs';
import { DatabaseSync } from 'node:sqlite';
import { runInNewContext } from 'node:vm';
import ts from 'typescript';
import { adminStore } from './admin-access-fixture';
import { adminLogin, requireAdmin } from '../db/admin-access';
import { applyAdminRemoval, readAdminRemoval } from '../db/admin-removal';
import * as engine from '../game/engine';
import * as bots from '../game/bots';
import { baseDeck } from '../game/cards';
import * as delegation from '../lib/seat-ai-delegation';
import * as talkContract from '../lib/table-talk';
import type * as Rooms from '../db/rooms';
import type * as Talk from '../db/table-talk';
const secret = () => randomBytes(32).toString('hex');
const entry = () => ({ operationId: randomUUID(), sessionToken: secret() });
const removed = (error: unknown) =>
!!error &&
typeof error === 'object' &&
'code' in error &&
error.code === 'ROOM_REMOVED' &&
'status' in error &&
error.status === 410;
const draft = (recipientId: string | null = null) => ({
id: randomUUID(),
recipientId,
text: 'Retained discussion for the same saved seat.',
});
type Values = (string | number | null)[];
async function fixture(started = false) {
const store = adminStore();
assert.ok(store.sqlite instanceof DatabaseSync);
const account = store.provision('operator');
const login = await adminLogin(store.database, account.key);
const identity = await requireAdmin(store.database, login.token);
const calls = { bots: 0, normalization: 0, views: 0 };
const hooks: {
beforeFirst?: (sql: string, values: Values) => void;
beforeRun?: (sql: string, values: Values) => void;
} = {};
const database = {
batch: (statements: D1PreparedStatement[]) =>
store.database.batch(statements),
prepare(sql: string) {
const statement = store.database.prepare(sql);
const first = statement.first.bind(statement);
const run = statement.run.bind(statement);
const bind = statement.bind.bind(statement);
let values: Values = [];
statement.bind = (...input: Values) => {
values = input;
return bind(...input);
};
statement.first = async <T>() => {
hooks.beforeFirst?.(sql, values);
return first<T>();
};
statement.run = async <T>() => {
hooks.beforeRun?.(sql, values);
return run<T>();
};
return statement;
},
} as D1Database;
function load<T>(file: string) {
const exports = {};
runInNewContext(
ts.transpileModule(readFileSync(new URL(file, import.meta.url), 'utf8'), {
compilerOptions: {
module: ts.ModuleKind.CommonJS,
target: ts.ScriptTarget.ES2022,
},
}).outputText,
{
exports,
crypto: webcrypto,
TextEncoder,
structuredClone,
JSON,
require: (name: string) => {
if (name === 'cloudflare:workers') return { env: { DB: database } };
if (name === '@/game/engine')
return {
...engine,
viewGame: (...args: Parameters<typeof engine.viewGame>) => {
calls.views++;
return engine.viewGame(...args);
},
normalizeAutomaticGame: (
...args: Parameters<typeof engine.normalizeAutomaticGame>
) => {
calls.normalization++;
return engine.normalizeAutomaticGame(...args);
},
};
if (name === '@/game/bots')
return {
...bots,
runBots: (...args: Parameters<typeof bots.runBots>) => {
calls.bots++;
return bots.runBots(...args);
},
};
if (name === '@/lib/seat-ai-delegation') return delegation;
if (name === '../lib/table-talk') return talkContract;
throw new Error('Unexpected dependency ' + name);
},
},
);
return exports as T;
}
const rooms = load<typeof Rooms>('../db/rooms.ts');
const talk = load<typeof Talk>('../db/table-talk.ts');
const ownerEntry = entry(),
guestEntry = entry();
const owner = await rooms.createRoom(
'Removal owner',
'atreides',
false,
[],
ownerEntry,
);
const code = owner.view.code;
const guest = await rooms.joinRoom(
code,
'Removal guest',
'emperor',
guestEntry,
);
const auth = await rooms.authenticate(code, owner.token);
const guestAuth = await rooms.authenticate(code, guest.token!);
let now = 10000;
const clock = {
now: () => now,
advance: (ms: number) => {
now += ms;
},
sleep: async (ms: number) => {
now += ms;
},
};
const version = () =>
Number(
store.sqlite
.prepare('SELECT version FROM rooms WHERE code = ?')
.get(code)!.version,
);
if (started) {
for (const seat of [auth, guestAuth])
await rooms.act(code, seat, version(), { type: 'ready' }, clock);
await rooms.act(code, auth, version(), { type: 'start' }, clock);
}
const transition = async (removed: boolean, roomCode = code) => {
const view = await readAdminRemoval(database, identity, roomCode, now);
return applyAdminRemoval(
database,
identity,
roomCode,
{
operationId: randomUUID(),
expectedVersion: view.version,
expectedRevision: view.revision,
removed,
reason: 'Dedicated recoverable removal runtime QA',
},
now,
);
};
// Deliberately retain the old game version to prove the SQL removal predicate itself,
// independently of the additional version bump made by the real admin transaction.
const flagRemoved = (roomCode = code) =>
store.sqlite
.prepare(
'INSERT INTO room_removals(room_code,removed,revision,removed_at,updated_at) VALUES (?,1,1,10000,10000)',
)
.run(roomCode);
const snapshot = () =>
Object.fromEntries(
[
'rooms',
'seats',
'room_entry_receipts',
'seat_recovery_keys',
'seat_recovery_receipts',
'seat_handover_offers',
'seat_handover_claim_receipts',
'seat_ai_delegations',
'room_messages',
'room_controls',
'room_removals',
].map((table) => [
table,
store.sqlite
.prepare('SELECT * FROM ' + table + ' ORDER BY rowid')
.all(),
]),
);
return {
...store,
rooms,
talk,
hooks,
calls,
code,
owner,
ownerEntry,
guest,
guestEntry,
auth,
guestAuth,
clock,
version,
transition,
flagRemoved,
snapshot,
restart: () => load<typeof Rooms>('../db/rooms.ts'),
};
}
type Fixture = Awaited<ReturnType<typeof fixture>>;
const grant = (f: Fixture) => ({
grantId: randomUUID(),
delegateId: f.guestAuth.playerId,
difficulty: 'Hard' as const,
});
const offer = () => ({ offerId: randomUUID(), handoverSecret: secret() });
const recovery = (f: Fixture, recoverySecret: string) => ({
playerId: f.auth.playerId,
recoverySecret,
operationId: randomUUID(),
newSessionToken: secret(),
});
void test('an invitation without a saved cookie reports removal and retains the ordinary active-room join error', async () => {
const f = await fixture();
try {
await assert.rejects(
f.rooms.authenticate(f.code, ''),
/Join this room first\./,
);
await f.transition(true);
const before = f.snapshot(),
calls = { ...f.calls };
await assert.rejects(f.rooms.authenticate(f.code, ''), removed);
assert.deepEqual(f.snapshot(), before);
assert.deepEqual(f.calls, calls);
await f.transition(false);
await assert.rejects(
f.rooms.authenticate(f.code, ''),
/Join this room first\./,
);
} finally {
f.sqlite.close();
}
});
void test('removed rooms deny private access, entry receipts, every seat control and discussion without invalidating proofs', async () => {
const f = await fixture(true);
try {
const consent = grant(f),
handover = offer(),
recoverySecret = secret();
await f.rooms.setSeatAiDelegate(
f.code,
f.auth,
f.version(),
consent,
f.clock,
);
await f.rooms.setRecoveryKey(f.code, f.auth, f.version(), recoverySecret);
await f.rooms.createSeatHandover(
f.code,
f.auth,
f.version(),
handover,
f.clock,
);
const message = draft(f.guestAuth.playerId);
await f.talk.sendTableTalk(f.code, f.auth, message, 1000);
await f.transition(true);
const before = f.snapshot(),
views = f.calls.views;
const attempts = [
() => f.rooms.authenticate(f.code, f.owner.token),
() => f.rooms.readRoom(f.code),
() => f.rooms.readSeatView(f.code, f.auth),
() =>
f.rooms.createRoom(
'Removal owner',
'atreides',
false,
[],
f.ownerEntry,
),
() => f.rooms.joinRoom(f.code, 'Removal guest', 'emperor', f.guestEntry),
() => f.rooms.joinRoom(f.code, 'New name', 'guild', undefined, f.auth),
() => f.rooms.joinRoom(f.code, 'New guest', 'guild', entry()),
() =>
f.rooms.act(
f.code,
f.auth,
f.version(),
{ type: 'setAutopilot', difficulty: null },
f.clock,
),
() =>
f.rooms.act(
f.code,
f.auth,
f.version(),
{ type: 'advanceBots' },
f.clock,
),
() =>
f.rooms.setSeatAiDelegate(
f.code,
f.auth,
f.version(),
consent,
f.clock,
),
() =>
f.rooms.revokeSeatAiDelegate(
f.code,
f.auth,
f.version(),
consent.grantId,
f.clock,
),
() =>
f.rooms.useSeatAiDelegate(
f.code,
f.guestAuth,
f.version(),
{ ownerId: f.auth.playerId, grantId: consent.grantId },
f.clock,
),
() => f.rooms.setRecoveryKey(f.code, f.auth, f.version(), secret()),
() => f.rooms.recoverSeat(f.code, recovery(f, recoverySecret)),
() =>
f.rooms.createSeatHandover(
f.code,
f.auth,
f.version(),
handover,
f.clock,
),
() =>
f.rooms.revokeSeatHandover(
f.code,
f.auth,
f.version(),
handover.offerId,
f.clock,
),
() =>
f.rooms.claimSeatHandover(
f.code,
{
playerId: f.auth.playerId,
...handover,
operationId: randomUUID(),
newSessionToken: secret(),
},
f.clock,
),
() => f.talk.readTableTalk(f.code, f.auth, f.guestAuth.playerId),
() => f.talk.sendTableTalk(f.code, f.auth, message, 1000),
() => f.talk.sendTableTalk(f.code, f.auth, draft(), 3000),
];
for (const attempt of attempts) await assert.rejects(attempt(), removed);
assert.deepEqual(f.snapshot(), before);
assert.equal(f.calls.views, views);
await f.transition(false);
assert.equal(
(
await f.rooms.createRoom(
'Removal owner',
'atreides',
false,
[],
f.ownerEntry,
)
).entryReceipt?.replayed,
true,
);
assert.equal(
(await f.rooms.joinRoom(f.code, 'Removal guest', 'emperor', f.guestEntry))
.entryReceipt?.replayed,
true,
);
assert.equal(
(
await f.rooms.createSeatHandover(
f.code,
f.auth,
f.version(),
handover,
f.clock,
)
).replayed,
true,
);
assert.equal(
(await f.talk.sendTableTalk(f.code, f.auth, message, 1000)).replayed,
true,
);
assert.equal(
(await f.talk.readTableTalk(f.code, f.auth, f.guestAuth.playerId))
.messages[0].text,
message.text,
);
} finally {
f.sqlite.close();
}
});
void test('removed malformed game JSON is never parsed or computed by private reads and restarted workers', async () => {
const f = await fixture();
try {
await f.transition(true);
f.sqlite
.prepare('UPDATE rooms SET state = ? WHERE code = ?')
.run('not game JSON', f.code);
const before = f.snapshot(),
calls = { ...f.calls };
for (const operation of [
() => f.rooms.readRoom(f.code),
() => f.rooms.readSeatView(f.code, f.auth),
() => f.talk.readTableTalk(f.code, f.auth, null),
() => f.talk.sendTableTalk(f.code, f.auth, draft(), 1000),
])
await assert.rejects(operation(), removed);
const noSleep = {
now: f.clock.now,
sleep: async () => {
assert.fail('A removed room must not sleep.');
},
};
await f.restart().continueRoomBots(f.code, 4, noSleep);
await f.restart().continueRoomAutomatic(f.code, noSleep);
assert.deepEqual(f.snapshot(), before);
assert.deepEqual(f.calls, calls);
} finally {
f.sqlite.close();
}
});
for (const kind of [
'authentication',
'raw state',
'seat view',
'discussion read',
'discussion receipt',
] as const) {
void test(`${kind} SQL excludes a removal racing the last private query`, async () => {
const f = await fixture();
try {
const message = draft();
await f.talk.sendTableTalk(f.code, f.auth, message, 1000);
const match = {
authentication: 'SELECT player_id FROM seats',
'raw state': 'SELECT state,version',
'seat view': 'SELECT rooms.state',
'discussion read': 'SELECT (',
'discussion receipt': 'SELECT 1 AS accepted',
}[kind];
let expected: ReturnType<typeof f.snapshot> | undefined;
f.hooks.beforeFirst = (sql) => {
if (!sql.startsWith(match)) return;
delete f.hooks.beforeFirst;
f.flagRemoved();
expected = f.snapshot();
};
const operations = {
authentication: () => f.rooms.authenticate(f.code, f.owner.token),
'raw state': () => f.rooms.readRoom(f.code),
'seat view': () => f.rooms.readSeatView(f.code, f.auth),
'discussion read': () => f.talk.readTableTalk(f.code, f.auth, null),
'discussion receipt': () =>
f.talk.sendTableTalk(f.code, f.auth, message, 1000),
};
const views = f.calls.views;
await assert.rejects(operations[kind](), removed);
assert.ok(expected);
assert.deepEqual(f.snapshot(), expected);
assert.equal(f.calls.views, views);
} finally {
f.sqlite.close();
}
});
}
for (const kind of [
'join',
'action',
'takeback',
'grant',
'revoke grant',
'activate grant',
'recovery key',
'recovery',
'create handover',
'revoke handover',
'claim handover',
'discussion send',
] as const) {
void test(`${kind} commit fence rejects removal without changing state, credentials or receipts`, async () => {
const f = await fixture(kind !== 'join' && kind !== 'action');
try {
const consent = grant(f),
handover = offer(),
recoverySecret = secret();
if (kind === 'revoke grant' || kind === 'activate grant')
await f.rooms.setSeatAiDelegate(
f.code,
f.auth,
f.version(),
consent,
f.clock,
);
if (kind === 'recovery')
await f.rooms.setRecoveryKey(
f.code,
f.auth,
f.version(),
recoverySecret,
);
if (kind === 'revoke handover' || kind === 'claim handover')
await f.rooms.createSeatHandover(
f.code,
f.auth,
f.version(),
handover,
f.clock,
);
if (kind === 'takeback')
await f.rooms.act(
f.code,
f.auth,
f.version(),
{ type: 'setAutopilot', difficulty: 'Hard' },
f.clock,
);
let expected: ReturnType<typeof f.snapshot> | undefined;
f.hooks.beforeRun = (sql) => {
if (
!sql.startsWith(
kind === 'discussion send'
? 'INSERT INTO room_messages'
: 'UPDATE rooms',
)
)
return;
delete f.hooks.beforeRun;
f.flagRemoved();
expected = f.snapshot();
};
const operations = {
join: () => f.rooms.joinRoom(f.code, 'Racing guest', 'guild', entry()),
action: () =>
f.rooms.act(f.code, f.auth, f.version(), { type: 'ready' }, f.clock),
takeback: () =>
f.rooms.act(
f.code,
f.auth,
f.version(),
{ type: 'setAutopilot', difficulty: null },
f.clock,
),
grant: () =>
f.rooms.setSeatAiDelegate(
f.code,
f.auth,
f.version(),
consent,
f.clock,
),
'revoke grant': () =>
f.rooms.revokeSeatAiDelegate(
f.code,
f.auth,
f.version(),
consent.grantId,
f.clock,
),
'activate grant': () =>
f.rooms.useSeatAiDelegate(
f.code,
f.guestAuth,
f.version(),
{ ownerId: f.auth.playerId, grantId: consent.grantId },
f.clock,
),
'recovery key': () =>
f.rooms.setRecoveryKey(f.code, f.auth, f.version(), recoverySecret),
recovery: () =>
f.rooms.recoverSeat(f.code, recovery(f, recoverySecret)),
'create handover': () =>
f.rooms.createSeatHandover(
f.code,
f.auth,
f.version(),
handover,
f.clock,
),
'revoke handover': () =>
f.rooms.revokeSeatHandover(
f.code,
f.auth,
f.version(),
handover.offerId,
f.clock,
),
'claim handover': () =>
f.rooms.claimSeatHandover(
f.code,
{
playerId: f.auth.playerId,
...handover,
operationId: randomUUID(),
newSessionToken: secret(),
},
f.clock,
),
'discussion send': () =>
f.talk.sendTableTalk(f.code, f.auth, draft(), 1000),
};
await assert.rejects(operations[kind](), removed);
assert.ok(expected);
assert.deepEqual(f.snapshot(), expected);
} finally {
f.sqlite.close();
}
});
}
void test('completed recovery and handover receipts survive removal, but restore never revives revoked sessions or keys', async () => {
const f = await fixture(true);
try {
const consent = grant(f),
ownerKey = secret(),
guestKey = secret(),
handover = offer();
await f.rooms.setSeatAiDelegate(
f.code,
f.auth,
f.version(),
consent,
f.clock,
);
await f.rooms.setRecoveryKey(f.code, f.auth, f.version(), ownerKey);
await f.rooms.setRecoveryKey(f.code, f.guestAuth, f.version(), guestKey);
const recovering = {
...recovery(f, guestKey),
playerId: f.guestAuth.playerId,
};
const recovered = await f.rooms.recoverSeat(f.code, recovering);
await f.rooms.createSeatHandover(
f.code,
f.auth,
f.version(),
handover,
f.clock,
);
const claiming = {
playerId: f.auth.playerId,
...handover,
operationId: randomUUID(),
newSessionToken: secret(),
};
const claimed = await f.rooms.claimSeatHandover(f.code, claiming, f.clock);
const ownerAuth = await f.rooms.authenticate(f.code, claimed.token);
const guestAuth = await f.rooms.authenticate(f.code, recovered.token);
const message = draft(guestAuth.playerId);
await f.talk.sendTableTalk(f.code, ownerAuth, message, 1000);
const baseline = f.snapshot();
await f.transition(true);
for (const operation of [
() => f.rooms.recoverSeat(f.code, recovering),
() => f.rooms.claimSeatHandover(f.code, claiming, f.clock),
])
await assert.rejects(operation(), removed);
await f.transition(false);
const after = f.snapshot();
for (const key of Object.keys(baseline).filter(
(key) => key !== 'rooms' && key !== 'room_removals',
))
assert.deepEqual(after[key], baseline[key]);
assert.equal(
(await f.rooms.recoverSeat(f.code, recovering)).replayed,
true,
);
assert.equal(
(await f.rooms.claimSeatHandover(f.code, claiming, f.clock)).replayed,
true,
);
assert.equal(
(await f.talk.sendTableTalk(f.code, ownerAuth, message, 1000)).replayed,
true,
);
for (const old of [f.owner, f.guest])
await assert.rejects(
f.rooms.authenticate(f.code, old.token!),
/could not be verified/,
);
await assert.rejects(
f.rooms.recoverSeat(f.code, recovery(f, ownerKey)),
/proof could not be verified/,
);
await assert.rejects(
f.rooms.useSeatAiDelegate(
f.code,
guestAuth,
f.version(),
{ ownerId: ownerAuth.playerId, grantId: consent.grantId },
f.clock,
),
);
assert.equal(
(await f.rooms.readSeatView(f.code, ownerAuth)).seatAiDelegations,
undefined,
);
} finally {
f.sqlite.close();
}
});
for (const kind of ['recovery', 'handover'] as const) {
void test(`${kind} exact receipt query fences removal after its preliminary proof checks`, async () => {
const f = await fixture();
try {
const recoverySecret = secret(),
handover = offer();
await f.rooms.setRecoveryKey(f.code, f.auth, f.version(), recoverySecret);
const recovering = recovery(f, recoverySecret);
const claiming = {
playerId: f.auth.playerId,
...handover,
operationId: randomUUID(),
newSessionToken: secret(),
};
if (kind === 'recovery') await f.rooms.recoverSeat(f.code, recovering);
else {
await f.rooms.createSeatHandover(
f.code,
f.auth,
f.version(),
handover,
f.clock,
);
await f.rooms.claimSeatHandover(f.code, claiming, f.clock);
}
let expected: ReturnType<typeof f.snapshot> | undefined;
f.hooks.beforeFirst = (sql) => {
if (!sql.startsWith('SELECT rooms.state')) return;
delete f.hooks.beforeFirst;
f.flagRemoved();
expected = f.snapshot();
};
const views = f.calls.views;
await assert.rejects(
kind === 'recovery'
? f.rooms.recoverSeat(f.code, recovering)
: f.rooms.claimSeatHandover(f.code, claiming, f.clock),
removed,
);
assert.ok(expected);
assert.deepEqual(f.snapshot(), expected);
assert.equal(f.calls.views, views);
} finally {
f.sqlite.close();
}
});
}
void test('entry committed before removal keeps its exact retry proof through temporary rejection and restoration', async () => {
const f = await fixture();
try {
const pendingEntry = entry();
let code: string | undefined;
f.hooks.beforeFirst = (sql, values) => {
if (!sql.startsWith('SELECT rooms.state')) return;
delete f.hooks.beforeFirst;
code = String(values[0]);
f.flagRemoved(code);
};
await assert.rejects(
f.rooms.createRoom(
'Uncertain creation',
'guild',
false,
[],
pendingEntry,
),
removed,
);
assert.ok(code);
assert.equal(
f.sqlite.prepare('SELECT COUNT(*) AS n FROM room_entry_receipts').get()!
.n,
3,
);
await f.transition(false, code);
const replay = await f.rooms.createRoom(
'Uncertain creation',
'guild',
false,
[],
pendingEntry,
);
assert.equal(replay.view.code, code);
assert.equal(replay.entryReceipt?.replayed, true);
assert.equal(
f.sqlite.prepare('SELECT COUNT(*) AS n FROM rooms').get()!.n,
2,
);
} finally {
f.sqlite.close();
}
});
void test('sleeping AI rechecks removal before computing and restored AI retains normal pacing', async () => {
const f = await fixture(true);
try {
await f.rooms.act(
f.code,
f.auth,
f.version(),
{ type: 'setAutopilot', difficulty: 'Hard' },
f.clock,
);
const clock = {
now: f.clock.now,
sleep: async (ms: number) => {
await f.transition(true);
f.clock.advance(ms);
},
};
await f.rooms.continueRoomBots(f.code, 1, clock);
assert.equal(f.calls.bots, 0);
const before = f.snapshot();
await f.rooms.continueRoomAutomatic(f.code, f.clock);
await f.restart().continueRoomBots(f.code, 4, f.clock);
assert.deepEqual(f.snapshot(), before);
f.clock.advance(50000);
await f.transition(false);
assert.equal(
(await f.rooms.readRoom(f.code)).botNextActionAt,
f.clock.now() + 1500,
);
await f.rooms.continueRoomBots(f.code, 1, f.clock);
assert.equal(f.calls.bots, 1);
} finally {
f.sqlite.close();
}
});
for (const kind of ['AI', 'automatic normalization'] as const) {
void test(`${kind} discards computed work when removal wins its final SQL fence`, async () => {
const f = await fixture(kind === 'AI');
try {
if (kind === 'AI') {
await f.rooms.act(
f.code,
f.auth,
f.version(),
{ type: 'setAutopilot', difficulty: 'Hard' },
f.clock,
);
f.clock.advance(1500);
} else {
const game = await f.rooms.readRoom(f.code);
game.players[0] = engine.newPlayer(f.auth.playerId, 'CHOAM', 'choam');
game.players.push(engine.newPlayer('guild', 'Guild', 'guild'));
Object.assign(game, {
status: 'playing',
phase: 3,
turn: 2,
order: game.players.map((p) => p.id),
active: f.auth.playerId,
auction: null,
response: null,
deck: baseDeck(),
discard: [],
choamMarket: { owner: f.auth.playerId, resume: 'phase', blocked: [] },
decision: { kind: 'choamMarket', player: f.auth.playerId },
aid: { [f.auth.playerId]: { recipient: 'emperor', amount: 2 } },
});
for (const player of game.players) {
player.hand = [];
player.spice = 10;
}
f.sqlite
.prepare('UPDATE rooms SET state = ? WHERE code = ?')
.run(JSON.stringify(game), f.code);
}
let expected: ReturnType<typeof f.snapshot> | undefined;
f.hooks.beforeRun = (sql) => {
if (!sql.startsWith('UPDATE rooms')) return;
delete f.hooks.beforeRun;
f.flagRemoved();
expected = f.snapshot();
};
if (kind === 'AI') await f.rooms.continueRoomBots(f.code, 4, f.clock);
else await f.rooms.continueRoomAutomatic(f.code, f.clock);
assert.ok(expected);
assert.deepEqual(f.snapshot(), expected);
assert.equal(kind === 'AI' ? f.calls.bots : f.calls.normalization, 1);
} finally {
f.sqlite.close();
}
});
}