Files
Dune/tests/seat-recovery.test.ts
T
Quintine b21b2bc378 Add one-use consent for a named player to start seat AI
Let a human owner authorize another human in the started room to start a fixed difficulty once within 24 hours. The owner retains the seat, cookie, private hand, recovery setup and takeback control. Consent is explicit even while online; neither host status nor inactivity authorizes a takeover. Activation enters the existing paced private-view AI path without strategic changes.

Add additive migration 0006, both exact session fences, owner/delegate-only metadata in the authenticated room snapshot, atomic CAS activation and consumption, expiry and terminal retry receipts. Replacement hides older consent; own AI changes invalidate unused consent. Recovery or handover of either bound seat invalidates the grant. Future-version guards prevent stale computed state from overwriting a racing action. Exact browser retry records retain the original operation/version across refresh and cannot rearm old consent. Shared HTTP continuation preserves coalescing across ordinary and control routes.

Independent review found no material remaining issue. Revoke messaging accurately handles activation winning the race. Browser QA exercised grant/revoke, preserved consent after refresh, reciprocal fixed-profile activation, consumed receipts, keyboard takeback and private ownership. The panel now remains expanded after success and action buttons are at least 44px. Responsive screenshot acceptance remains open because the browser viewport override targeted a failed secondary-origin tab; desktop controls and no-overflow width were verified. The QA room is stopped at a genuine human setup decision with both seats taken back.

Types, lint, all 4906 offline tests, production build and all 45 HTTP tests pass on the stable source fingerprint. Seven new offline cases cover client consent/retry validation and real SQLite privacy, restart, one-use races, future versions, takeback, expiry and both-seat recovery/handover; two new HTTP cases cover authentication, injection, fixed-profile continuation and takeback. The first broad run exposed 68 legacy VM fixtures missing the new production dependency; their mappings were updated and the real shared continuation remains exercised. The historical 0005 migration test verifies backfill before applying 0006 for current code.

All 1174 original rooms, 2456 seats and every prior recovery/entry/handover/migration row remain unchanged. SQLite integrity and foreign keys pass; migration and QA added 88 rooms and 188 seats. No reset, server restart, usage check or automation was performed.

The current Sites integration has no supported durable Cron/DO provisioning path. Document this platform blocker rather than adding an inert scheduled export; unattended resumption and broader network-failure acceptance remain unfinished. Full rules, component, AI strength and publication gates remain closed.

Verified source tree: 2d86fc611e9ef4f5a10db10f96687a31db1ec238908ecf1e9333c50fac9ccaa8
2026-09-20 16:30:37 +10:00

733 lines
24 KiB
TypeScript

// @dune-suite integration
import test from 'node:test';
import assert from 'node:assert/strict';
import { randomBytes, randomUUID, webcrypto } from 'node:crypto';
import { readFileSync, readdirSync } from 'node:fs';
import { runInNewContext } from 'node:vm';
import { DatabaseSync } from 'node:sqlite';
import ts from 'typescript';
import * as engine from '../game/engine';
import * as bots from '../game/bots';
import * as seatAiDelegation from '../lib/seat-ai-delegation';
import type * as Rooms from '../db/rooms';
const base = process.env.DUNE_TEST_URL ?? 'http://localhost:3000';
const secret = () => randomBytes(32).toString('hex');
async function request(
path: string,
body?: unknown,
cookie?: string,
origin?: string,
) {
const response = await fetch(base + path, {
method: body === undefined ? 'GET' : 'POST',
headers: {
...(body === undefined ? {} : { 'Content-Type': 'application/json' }),
...(cookie ? { cookie } : {}),
...(origin ? { origin } : {}),
},
...(body === undefined ? {} : { body: JSON.stringify(body) }),
});
const raw = await response.text();
let data: unknown;
try {
data = JSON.parse(raw);
} catch {
data = { error: raw };
}
return {
status: response.status,
data: data as engine.GameView & {
view: engine.GameView;
error?: string;
replayed?: boolean;
recoveryConfigured?: boolean;
},
cookie: response.headers.get('set-cookie'),
cache: response.headers.get('cache-control'),
};
}
async function create() {
const r = await request('/api/rooms', {
name: 'Seat recovery audit',
faction: 'atreides',
advanced: false,
expansions: [],
});
assert.equal(r.status, 201);
assert.ok(r.cookie);
return {
...r,
cookie: r.cookie.split(';')[0],
path: `/api/rooms/${r.data.code}`,
};
}
const sameGame = (a: engine.GameView, b: engine.GameView) =>
assert.deepEqual({ ...a, version: 0 }, { ...b, version: 0 });
const claim = (playerId: string, recoverySecret: string) => ({
type: 'recoverSeat',
playerId,
recoverySecret,
operationId: randomUUID(),
newSessionToken: secret(),
});
void test('legacy cookie can configure recovery; recovery preserves private setup and invalidates the old seat cookie', async () => {
const a = await create();
const b = await request(a.path, {
type: 'join',
name: 'Second seat',
faction: 'harkonnen',
});
assert.equal(b.status, 200);
assert.ok(b.cookie);
let view = b.data;
for (const cookie of [a.cookie, b.cookie]) {
const ready = await request(
a.path,
{ version: view.version, action: { type: 'ready' } },
cookie,
);
assert.equal(ready.status, 200);
view = ready.data;
}
const start = await request(
a.path,
{ version: view.version, action: { type: 'start' } },
a.cookie,
);
assert.equal(start.status, 200);
assert.equal(start.data.status, 'setup');
const recoverySecret = secret();
const saved = await request(
a.path + '/control',
{ type: 'setRecoveryKey', version: start.data.version, recoverySecret },
a.cookie,
);
assert.equal(saved.status, 200, JSON.stringify(saved.data));
assert.equal(saved.data.recoveryConfigured, true);
sameGame(saved.data.view, start.data);
const body = claim(a.data.me, recoverySecret);
const restored = await request(a.path + '/control', body);
assert.equal(restored.status, 200, JSON.stringify(restored.data));
assert.equal(restored.data.replayed, false);
sameGame(restored.data.view, saved.data.view);
assert.equal(restored.data.view.version, saved.data.view.version + 1);
assert.equal(restored.cache, 'no-store');
assert.ok(restored.cookie?.includes('HttpOnly'));
assert.ok(restored.cookie?.includes('SameSite=Strict'));
assert.ok(restored.cookie?.includes(`Path=${a.path}`));
assert.equal((await request(a.path, undefined, a.cookie)).status, 409);
assert.equal(
(
await request(
a.path,
{ version: restored.data.view.version, action: { type: 'ready' } },
a.cookie,
)
).status,
409,
);
const other = await request(a.path, undefined, b.cookie);
assert.equal(other.status, 200);
assert.equal(
other.data.players.find((p) => p.id === a.data.me)!.hand,
undefined,
);
assert.ok(!JSON.stringify(other.data).includes(recoverySecret));
assert.ok(!JSON.stringify(restored.data).includes(body.newSessionToken));
});
void test('lost recovery response replays once; altered payloads and older rotation receipts cannot rotate again', async () => {
const a = await create(),
recoverySecret = secret();
const saved = await request(
a.path + '/control',
{ type: 'setRecoveryKey', version: a.data.version, recoverySecret },
a.cookie,
);
assert.equal(saved.status, 200);
const firstBody = claim(a.data.me, recoverySecret);
const first = await request(a.path + '/control', firstBody);
assert.equal(first.status, 200);
const replay = await request(a.path + '/control', firstBody);
assert.equal(replay.status, 200);
assert.equal(replay.data.replayed, true);
assert.deepEqual(replay.data.view, first.data.view);
assert.equal(replay.cookie, first.cookie);
const altered = await request(a.path + '/control', {
...firstBody,
newSessionToken: secret(),
});
assert.equal(altered.status, 409);
assert.equal(altered.data.code, 'RECOVERY_RECEIPT_INVALID');
const second = await request(
a.path + '/control',
claim(a.data.me, recoverySecret),
);
assert.equal(second.status, 200);
const old = await request(a.path + '/control', firstBody);
assert.equal(old.status, 409);
assert.equal(old.data.code, 'RECOVERY_RECEIPT_INVALID');
assert.equal((await request(a.path, undefined, first.cookie!)).status, 409);
const reused = await request(a.path + '/control', {
...claim(a.data.me, recoverySecret),
newSessionToken: firstBody.newSessionToken,
});
assert.equal(reused.status, 409);
assert.equal(reused.data.code, 'INVALID_SESSION_TOKEN');
const final = await request(a.path, undefined, second.cookie!);
assert.deepEqual(final.data, second.data.view);
});
void test('stale key setup is atomic; key replacement invalidates old proof and even same-key prior receipts', async () => {
const a = await create(),
key = secret(),
replacement = secret();
const saved = await request(
a.path + '/control',
{ type: 'setRecoveryKey', version: a.data.version, recoverySecret: key },
a.cookie,
);
const stale = await request(
a.path + '/control',
{
type: 'setRecoveryKey',
version: a.data.version,
recoverySecret: replacement,
},
a.cookie,
);
assert.equal(stale.status, 409);
assert.equal(stale.data.code, 'STALE_VERSION');
assert.equal(
(await request(a.path + '/control', claim(a.data.me, replacement))).status,
409,
);
const body = claim(a.data.me, key);
const recovered = await request(a.path + '/control', body);
assert.equal(recovered.status, 200);
const resaved = await request(
a.path + '/control',
{
type: 'setRecoveryKey',
version: recovered.data.view.version,
recoverySecret: key,
},
recovered.cookie!,
);
assert.equal(resaved.status, 200);
assert.equal(
(await request(a.path + '/control', body)).data.code,
'RECOVERY_RECEIPT_INVALID',
);
const changed = await request(
a.path + '/control',
{
type: 'setRecoveryKey',
version: resaved.data.view.version,
recoverySecret: replacement,
},
recovered.cookie!,
);
assert.equal(changed.status, 200);
assert.equal(
(await request(a.path + '/control', claim(a.data.me, key))).data.code,
'INVALID_RECOVERY_PROOF',
);
assert.equal(
(await request(a.path + '/control', claim(a.data.me, replacement))).status,
200,
);
assert.equal(saved.data.view.version, a.data.version + 1);
});
void test('recovery rejects other-player proofs, malformed credentials and cross-origin writes without disclosure', async () => {
const a = await create(),
key = secret();
const saved = await request(
a.path + '/control',
{ type: 'setRecoveryKey', version: a.data.version, recoverySecret: key },
a.cookie,
);
assert.equal(saved.status, 200);
for (const body of [claim(randomUUID(), key), claim(a.data.me, secret())]) {
const r = await request(a.path + '/control', body);
assert.equal(r.status, 409);
assert.equal(r.data.code, 'INVALID_RECOVERY_PROOF');
assert.deepEqual(Object.keys(r.data).sort(), ['code', 'error']);
assert.equal(r.cookie, null);
}
assert.equal(
(
await request(a.path + '/control', {
...claim(a.data.me, key),
newSessionToken: 'weak',
})
).status,
400,
);
assert.equal(
(
await request(
a.path + '/control',
claim(a.data.me, key),
undefined,
'https://unrelated.invalid',
)
).status,
403,
);
assert.equal((await request(a.path + '/control', null)).status, 400);
const after = await request(a.path, undefined, a.cookie);
assert.deepEqual(after.data, saved.data.view);
});
/** Run the actual room module against SQLite to retain an auth context across rotation. */
function unitStore() {
const hooks: {
beforeRead?: (sql: string) => Promise<void>;
beforeBatch?: () => Promise<void>;
} = {};
const sqlite = new DatabaseSync(':memory:');
for (const file of readdirSync(new URL('../drizzle/', import.meta.url))
.filter((f) => f.endsWith('.sql'))
.sort())
sqlite.exec(
readFileSync(new URL('../drizzle/' + file, import.meta.url), 'utf8'),
);
class Statement {
values: (string | number | null)[] = [];
constructor(readonly sql: string) {}
bind(...values: (string | number | null)[]) {
this.values = values;
return this;
}
async first() {
await hooks.beforeRead?.(this.sql);
return sqlite.prepare(this.sql).get(...this.values) ?? null;
}
async run() {
return {
meta: {
changes: Number(sqlite.prepare(this.sql).run(...this.values).changes),
},
};
}
}
const database = {
prepare: (sql: string) => new Statement(sql),
batch: async (statements: Statement[]) => {
await hooks.beforeBatch?.();
sqlite.exec('BEGIN');
try {
const rows = [];
for (const statement of statements) rows.push(await statement.run());
sqlite.exec('COMMIT');
return rows;
} catch (error) {
sqlite.exec('ROLLBACK');
throw error;
}
},
};
const source = readFileSync(
new URL('../db/rooms.ts', import.meta.url),
'utf8',
);
const compiled = ts.transpileModule(source, {
compilerOptions: {
module: ts.ModuleKind.CommonJS,
target: ts.ScriptTarget.ES2022,
},
}).outputText;
const exports = {};
runInNewContext(compiled, {
exports,
crypto: webcrypto,
TextEncoder,
structuredClone,
require: (name: string) => {
if (name === 'cloudflare:workers') return { env: { DB: database } };
if (name === '@/game/engine') return engine;
if (name === '@/game/bots') return bots;
if (name === '@/lib/seat-ai-delegation') return seatAiDelegation;
throw new Error('Unexpected module ' + name);
},
});
return { rooms: exports as typeof Rooms, sqlite, hooks };
}
void test('already-authenticated old actions and reads are fenced even at the latest room version', async () => {
const { rooms, sqlite } = unitStore();
try {
const created = await rooms.createRoom(
'Fenced owner',
'atreides',
false,
[],
);
const code = created.view.code;
const oldAuth = await rooms.authenticate(code, created.token);
const key = secret();
await rooms.setRecoveryKey(code, oldAuth, created.view.version, key);
const body = claim(created.view.me, key);
const recovered = await rooms.recoverSeat(code, body);
// Deliberately supply the fresh version: version protection alone must not pass this request.
await assert.rejects(
rooms.act(code, oldAuth, recovered.view.version, { type: 'ready' }),
/Another action/,
);
await assert.rejects(rooms.readSeatView(code, oldAuth), /verified/);
const newAuth = await rooms.authenticate(code, body.newSessionToken);
const after = await rooms.readSeatView(code, newAuth);
assert.deepEqual(after, recovered.view);
const advanced = await rooms.act(code, newAuth, after.version, {
type: 'ready',
});
assert.equal(advanced.players[0].ready, true);
const stored = sqlite.prepare('SELECT * FROM seat_recovery_keys').get()!;
assert.notEqual(stored.recovery_hash, key);
assert.equal(typeof stored.recovery_hash, 'string');
assert.equal(String(stored.recovery_hash).length, 64);
assert.equal(
sqlite.prepare('SELECT count(*) AS n FROM seats WHERE revoked = 0').get()!
.n,
1,
);
} finally {
sqlite.close();
}
});
void test('simultaneous identical recovery claims commit one rotation and can both recover the same receipt', async () => {
const a = await create(),
key = secret();
const saved = await request(
a.path + '/control',
{ type: 'setRecoveryKey', version: a.data.version, recoverySecret: key },
a.cookie,
);
assert.equal(saved.status, 200);
const body = claim(a.data.me, key);
const attempts = await Promise.all([
request(a.path + '/control', body),
request(a.path + '/control', body),
]);
assert.ok(attempts.some((r) => r.status === 200));
assert.ok(attempts.every((r) => [200, 409].includes(r.status)));
const receipt = await request(a.path + '/control', body);
assert.equal(receipt.status, 200);
assert.equal(receipt.data.replayed, true);
assert.equal(receipt.data.view.version, saved.data.view.version + 1);
assert.equal((await request(a.path, undefined, a.cookie)).status, 409);
});
void test('recovery retains the exact persisted sealed battle and does not reveal it to an opponent', async () => {
const { rooms, sqlite } = unitStore();
try {
const created = await rooms.createRoom(
'Battle recovery',
'atreides',
false,
[],
);
const code = created.view.code,
id = created.view.me;
let g = await rooms.readRoom(code);
engine.joinGame(g, engine.newPlayer('opponent', 'Opponent', 'emperor'));
g.players.forEach((p) => {
p.ready = true;
});
g = engine.applyAction(g, id, { type: 'start' });
for (const p of g.players) {
if (p.traitorChoices.length)
g = engine.applyAction(g, p.id, {
type: 'traitor',
leader: p.traitorChoices[0],
});
}
g.phase = 6;
g.storm = 18;
g.active = id;
g.order = [id, 'opponent'];
for (const p of g.players) {
p.forces = { 'arrakeen:10': 5 };
p.reserves = 15;
p.spice = 10;
}
g = engine.applyAction(g, id, {
type: 'chooseBattle',
territory: 'arrakeen',
target: 'opponent',
});
for (
let step = 0;
step < 20 && (g.response || g.battle?.preparation);
step++
) {
if (g.response)
g = engine.applyAction(
g,
g.players.find((p) => !g.response!.passed.includes(p.id))!.id,
{ type: 'passResponse' },
);
else
g = engine.applyAction(g, g.battle!.preparation!.owner, {
type: 'declineBattlePower',
});
}
g = engine.applyAction(g, id, {
type: 'battlePlan',
dial: 2,
leader: 'atreides-0',
});
assert.ok(g.battle?.plans[id]);
assert.equal(g.battle.revealed, false);
const state = JSON.stringify(g);
sqlite
.prepare('UPDATE rooms SET state = ? WHERE code = ?')
.run(state, code);
const oldAuth = await rooms.authenticate(code, created.token),
key = secret();
const saved = await rooms.setRecoveryKey(code, oldAuth, g.version, key);
const recovered = await rooms.recoverSeat(code, claim(id, key));
sameGame(recovered.view, saved.view);
assert.equal(
sqlite.prepare('SELECT state FROM rooms WHERE code = ?').get(code)!.state,
state,
);
const opponent = engine.viewGame(await rooms.readRoom(code), 'opponent');
assert.equal(opponent.battle!.plans[id], undefined);
assert.ok(recovered.view.battle!.plans[id]);
assert.equal(recovered.view.battle!.revealed, false);
} finally {
sqlite.close();
}
});
async function recoveryUnitFixture() {
const store = unitStore();
const created = await store.rooms.createRoom(
'Interleaving recovery',
'atreides',
false,
[],
);
const code = created.view.code,
auth = await store.rooms.authenticate(code, created.token),
key = secret();
await store.rooms.setRecoveryKey(code, auth, created.view.version, key);
return { ...store, created, code, auth, key };
}
function custodySnapshot(sqlite: DatabaseSync) {
return JSON.stringify(
['rooms', 'seats', 'seat_recovery_keys', 'seat_recovery_receipts'].map(
(table) =>
sqlite.prepare('SELECT * FROM ' + table + ' ORDER BY rowid').all(),
),
);
}
void test('SQLite recovery rolls back all custody changes when replacement credential insertion fails', async () => {
const f = await recoveryUnitFixture();
try {
const before = custodySnapshot(f.sqlite);
f.sqlite.exec(
"CREATE TRIGGER reject_replacement BEFORE INSERT ON seats BEGIN SELECT RAISE(ABORT, 'injected insertion failure'); END;",
);
await assert.rejects(
f.rooms.recoverSeat(f.code, claim(f.created.view.me, f.key)),
/injected insertion failure/,
);
assert.equal(custodySnapshot(f.sqlite), before);
await f.rooms.readSeatView(f.code, f.auth);
} finally {
f.sqlite.close();
}
});
void test('SQLite recovery rechecks a replaced proof between preflight and its atomic write', async () => {
const f = await recoveryUnitFixture();
try {
let afterReplacement = '';
f.hooks.beforeRead = async (sql) => {
if (!sql.startsWith('SELECT state,version FROM rooms')) return;
f.hooks.beforeRead = undefined;
const current = await f.rooms.readRoom(f.code);
await f.rooms.setRecoveryKey(f.code, f.auth, current.version, secret());
afterReplacement = custodySnapshot(f.sqlite);
};
await assert.rejects(
f.rooms.recoverSeat(f.code, claim(f.created.view.me, f.key)),
/changed/,
);
assert.ok(afterReplacement);
assert.equal(custodySnapshot(f.sqlite), afterReplacement);
await f.rooms.readSeatView(f.code, f.auth);
} finally {
f.sqlite.close();
}
});
void test('SQLite recovery receipt cannot disclose state if another rotation wins after receipt preflight', async () => {
const f = await recoveryUnitFixture();
try {
const original = claim(f.created.view.me, f.key);
await f.rooms.recoverSeat(f.code, original);
const replacement = claim(f.created.view.me, f.key);
let afterRotation = '';
f.hooks.beforeRead = async (sql) => {
if (!sql.includes('JOIN seat_recovery_keys AS keys')) return;
f.hooks.beforeRead = undefined;
await f.rooms.recoverSeat(f.code, replacement);
afterRotation = custodySnapshot(f.sqlite);
};
await assert.rejects(
f.rooms.recoverSeat(f.code, original),
/no longer active/,
);
assert.ok(afterRotation);
assert.equal(custodySnapshot(f.sqlite), afterRotation);
await assert.rejects(
f.rooms.authenticate(f.code, original.newSessionToken),
/verified/,
);
await f.rooms.authenticate(f.code, replacement.newSessionToken);
} finally {
f.sqlite.close();
}
});
void test('SQLite recovery same-key replacement invalidates a receipt already being verified', async () => {
const f = await recoveryUnitFixture();
try {
const body = claim(f.created.view.me, f.key);
const recovered = await f.rooms.recoverSeat(f.code, body);
const auth = await f.rooms.authenticate(f.code, body.newSessionToken);
let afterReplacement = '';
f.hooks.beforeRead = async (sql) => {
if (!sql.includes('JOIN seat_recovery_keys AS keys')) return;
f.hooks.beforeRead = undefined;
await f.rooms.setRecoveryKey(f.code, auth, recovered.view.version, f.key);
afterReplacement = custodySnapshot(f.sqlite);
};
await assert.rejects(f.rooms.recoverSeat(f.code, body), /no longer active/);
assert.ok(afterReplacement);
assert.equal(custodySnapshot(f.sqlite), afterReplacement);
await f.rooms.readSeatView(f.code, auth);
} finally {
f.sqlite.close();
}
});
void test('SQLite recovery stale version makes every conditional batch statement a no-op', async () => {
const f = await recoveryUnitFixture();
try {
let afterAction = '';
f.hooks.beforeBatch = async () => {
f.hooks.beforeBatch = undefined;
const current = await f.rooms.readRoom(f.code);
await f.rooms.act(f.code, f.auth, current.version, { type: 'ready' });
afterAction = custodySnapshot(f.sqlite);
};
await assert.rejects(
f.rooms.recoverSeat(f.code, claim(f.created.view.me, f.key)),
/changed/,
);
assert.ok(afterAction);
assert.equal(custodySnapshot(f.sqlite), afterAction);
const view = await f.rooms.readSeatView(f.code, f.auth);
assert.equal(view.players[0].ready, true);
} finally {
f.sqlite.close();
}
});
function barrierGate() {
let release!: () => void;
const promise = new Promise<void>((resolve) => {
release = resolve;
});
return { promise, release };
}
void test('SQLite recovery distinct concurrent claims fence the loser and never resurrect a superseded receipt', async () => {
const f = await recoveryUnitFixture();
const gates = [barrierGate(), barrierGate()],
bothReady = barrierGate(),
firstFinished = barrierGate();
try {
const initialVersion = (await f.rooms.readRoom(f.code)).version;
const bodies = [
claim(f.created.view.me, f.key),
claim(f.created.view.me, f.key),
];
let arrivals = 0;
f.hooks.beforeBatch = async () => {
const index = arrivals++;
assert.ok(index < 2);
if (arrivals === 2) bothReady.release();
await gates[index].promise;
};
const attempts = bodies.map((body) =>
f.rooms.recoverSeat(f.code, body).then(
(value) => {
firstFinished.release();
return { status: 'fulfilled' as const, value };
},
(reason: unknown) => ({ status: 'rejected' as const, reason }),
),
);
// Both claims have read the same room version, but neither has begun its transaction.
await bothReady.promise;
gates[0].release();
await firstFinished.promise;
gates[1].release();
const outcomes = await Promise.all(attempts);
f.hooks.beforeBatch = undefined;
assert.equal(outcomes.filter((r) => r.status === 'fulfilled').length, 1);
assert.equal(outcomes.filter((r) => r.status === 'rejected').length, 1);
const winner = outcomes.findIndex((r) => r.status === 'fulfilled'),
loser = 1 - winner;
assert.equal((await f.rooms.readRoom(f.code)).version, initialVersion + 1);
assert.equal(
f.sqlite
.prepare('SELECT count(*) AS n FROM seat_recovery_receipts')
.get()!.n,
1,
);
await assert.rejects(
f.rooms.authenticate(f.code, bodies[loser].newSessionToken),
/verified/,
);
const winningAuth = await f.rooms.authenticate(
f.code,
bodies[winner].newSessionToken,
);
// The failed operation has no receipt; an explicit retry with valid owner proof may now perform a NEW rotation.
const later = await f.rooms.recoverSeat(f.code, bodies[loser]);
assert.equal(later.replayed, false);
assert.equal(later.view.version, initialVersion + 2);
const afterLater = custodySnapshot(f.sqlite);
await assert.rejects(
f.rooms.recoverSeat(f.code, bodies[winner]),
/no longer active/,
);
await assert.rejects(
f.rooms.act(f.code, winningAuth, later.view.version, { type: 'ready' }),
/Another action/,
);
assert.equal(custodySnapshot(f.sqlite), afterLater);
assert.equal(
f.sqlite
.prepare('SELECT count(*) AS n FROM seats WHERE revoked = 0')
.get()!.n,
1,
);
await f.rooms.authenticate(f.code, bodies[loser].newSessionToken);
} finally {
for (const gate of gates) gate.release();
f.sqlite.close();
}
});