Prototype voluntary seat handover with durable private retries
Let a current owner issue, cancel or replace a private 24-hour one-time offer. Recipients accept the same faction and saved game while old browser sessions, recovery keys and entry receipts lose authority. Preserve exact claim retries across refresh, restart and later offers, with private tab-scoped continuation. Fence every claim mutation to its winning transaction. Add durable receipts and additive backfill for already-issued offers without changing game JSON or existing seat authority. Block competing navigation during unresolved owner operations; remove invalidated owner kits on confirmed recovery. Record the pending CHOAM Nexus after-victory secrecy/window decision without inventing the unresolved gameplay or opening mode/publication gates. Validation: types/lint and 4,364 offline tests passed; production build passed; 43 HTTP tests passed. Added 19 offline and 3 HTTP regressions. Independent privacy/persistence review passed, including deterministic CAS interleavings, rollback, legacy migration and exact receipt restoration. Browser room 2EMYG4CY retained private setup choices after handover/refresh and continued into Storm; follow-up recovery removed its invalid offer with cards/forces/spice unchanged. All 218 opening rooms preserved (291 after QA and both broad runs). The opening 456 seat, 49 recovery-key, 56 recovery-receipt and 106 entry-receipt rows were also verified unchanged. Server reused; no restart or recurring automation. Final private report: ~/.local/share/dune/checkpoints/20260913-seat-handover/ verification-final/report.json. Source fingerprint: 8949cdf58eb8bc8bd33f2657c6b21352b3ee5e17e2a556c684a89073015632f4. Check/build/HTTP duration: 140565/6689/24835 ms. The earlier broad run passed; a final recovery-UI review fix required the second source-bound run. Goal aggregate counter 64,301,976 -> 65,031,751 before Git (729,775) for this bounded capability and its concurrency/recovery review; full goal remains active.
This commit is contained in:
1 parent
f0d3d9c464
commit
7dfd6b3048
24 files changed
+3570
-24
No files matched your search
@@ -8,12 +8,16 @@ import * as bots from '../game/bots';
|
||||
import type * as Rooms from '../db/rooms';
|
||||
|
||||
/** Execute the production room module and SQL, with a hook immediately before its CAS. */
|
||||
export function unitStore(runBots = bots.runBots) {
|
||||
export function unitStore(runBots = bots.runBots, migrationThrough?: string) {
|
||||
const sqlite = new DatabaseSync(':memory:');
|
||||
const hooks: { beforeWrite?: () => Promise<void> } = {};
|
||||
const hooks: {
|
||||
beforeWrite?: () => Promise<void>;
|
||||
beforeBatch?: () => Promise<void>;
|
||||
} = {};
|
||||
const writes: { expected: number; changes: number }[] = [];
|
||||
for (const file of readdirSync(new URL('../drizzle/', import.meta.url))
|
||||
.filter((f) => f.endsWith('.sql'))
|
||||
.filter((f) => !migrationThrough || f <= migrationThrough)
|
||||
.sort())
|
||||
sqlite.exec(
|
||||
readFileSync(new URL('../drizzle/' + file, import.meta.url), 'utf8'),
|
||||
@@ -42,6 +46,7 @@ export function unitStore(runBots = bots.runBots) {
|
||||
const database = {
|
||||
prepare: (sql: string) => new Statement(sql),
|
||||
batch: async (statements: Statement[]) => {
|
||||
await hooks.beforeBatch?.();
|
||||
sqlite.exec('BEGIN');
|
||||
try {
|
||||
const results = [];
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
HANDOVER_CLAIM_STORAGE_KEY,
|
||||
clearHandoverClaim,
|
||||
clearHandoverOwner,
|
||||
createHandoverClaim,
|
||||
createHandoverKit,
|
||||
handoverClaimConfirmed,
|
||||
handoverOwnerStorageKey,
|
||||
parseHandoverClaim,
|
||||
parseHandoverKit,
|
||||
saveHandoverClaim,
|
||||
serializeHandoverKit,
|
||||
} from '../lib/seat-handover';
|
||||
const player = '93c66e7c-2d3b-4845-aedf-5d1f06cf3371';
|
||||
const kit = () => createHandoverKit('ABCD2345', player);
|
||||
const storage = () => {
|
||||
const entries = new Map<string, string>();
|
||||
return {
|
||||
getItem: (key: string) => entries.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => {
|
||||
entries.set(key, value);
|
||||
},
|
||||
removeItem: (key: string) => {
|
||||
entries.delete(key);
|
||||
},
|
||||
};
|
||||
};
|
||||
void test('confirmed recovery removes only the invalidated owner offer and verifies cleanup before restoring the UI', () => {
|
||||
const saved = storage(),
|
||||
key = handoverOwnerStorageKey('ABCD2345', player);
|
||||
const other = handoverOwnerStorageKey('BCDE2345', player);
|
||||
saved.setItem(key, 'old private offer');
|
||||
saved.setItem(other, 'another room offer');
|
||||
saved.setItem(HANDOVER_CLAIM_STORAGE_KEY, 'unrelated pending claim');
|
||||
clearHandoverOwner(saved, 'ABCD2345', player);
|
||||
assert.equal(saved.getItem(key), null);
|
||||
assert.equal(saved.getItem(other), 'another room offer');
|
||||
assert.equal(
|
||||
saved.getItem(HANDOVER_CLAIM_STORAGE_KEY),
|
||||
'unrelated pending claim',
|
||||
);
|
||||
assert.throws(() =>
|
||||
clearHandoverOwner(
|
||||
{ getItem: () => 'old private offer', removeItem() {} },
|
||||
'ABCD2345',
|
||||
player,
|
||||
),
|
||||
);
|
||||
});
|
||||
void test('handover kits have fresh secure one-time offer identities and strict round-trip format', () => {
|
||||
const a = kit(),
|
||||
b = kit();
|
||||
assert.deepEqual(parseHandoverKit(serializeHandoverKit(a)), a);
|
||||
assert.notEqual(a.offerId, b.offerId);
|
||||
assert.notEqual(a.handoverSecret, b.handoverSecret);
|
||||
assert.match(a.handoverSecret, /^[0-9a-f]{64}$/);
|
||||
assert.ok(Object.isFrozen(a));
|
||||
});
|
||||
void test('kits reject substituted recovery formats, secret-bearing URLs and injected fields without echoing inputs', () => {
|
||||
const a = kit();
|
||||
for (const text of [
|
||||
'null',
|
||||
'[]',
|
||||
'{',
|
||||
'x'.repeat(4097),
|
||||
...[
|
||||
{ ...a, format: 'dune-seat-recovery' },
|
||||
{ ...a, roomCode: 'https://evil.test' },
|
||||
{ ...a, playerId: 'host' },
|
||||
{ ...a, handoverSecret: 'secret-to-not-echo' },
|
||||
{ ...a, newSessionToken: 'ff'.repeat(32) },
|
||||
{ ...a, version: 2 },
|
||||
].map((value) => JSON.stringify(value)),
|
||||
]) {
|
||||
assert.throws(
|
||||
() => parseHandoverKit(text),
|
||||
(error) =>
|
||||
error instanceof Error && !error.message.includes('secret-to-not-echo'),
|
||||
);
|
||||
}
|
||||
});
|
||||
void test('refresh restores exactly the same claim proof and prevents a second claim from replacing it', () => {
|
||||
const a = createHandoverClaim(kit()),
|
||||
saved = storage();
|
||||
saveHandoverClaim(saved, a);
|
||||
const restored = parseHandoverClaim(
|
||||
saved.getItem(HANDOVER_CLAIM_STORAGE_KEY)!,
|
||||
);
|
||||
assert.deepEqual(restored, a);
|
||||
assert.equal(JSON.stringify(restored.attempt), JSON.stringify(a.attempt));
|
||||
assert.ok(Object.isFrozen(restored.attempt));
|
||||
assert.notEqual(a.attempt.newSessionToken, a.kit.handoverSecret);
|
||||
saveHandoverClaim(saved, restored);
|
||||
assert.throws(() => saveHandoverClaim(saved, createHandoverClaim(a.kit)));
|
||||
assert.deepEqual(
|
||||
parseHandoverClaim(saved.getItem(HANDOVER_CLAIM_STORAGE_KEY)!),
|
||||
a,
|
||||
);
|
||||
clearHandoverClaim(saved);
|
||||
assert.equal(saved.getItem(HANDOVER_CLAIM_STORAGE_KEY), null);
|
||||
});
|
||||
void test('saved claim validation rejects altered bindings, reused secrets and unexpected authority fields', () => {
|
||||
const a = createHandoverClaim(kit());
|
||||
for (const value of [
|
||||
{ ...a, extra: true },
|
||||
{ ...a, attempt: { ...a.attempt, playerId: crypto.randomUUID() } },
|
||||
{ ...a, attempt: { ...a.attempt, offerId: crypto.randomUUID() } },
|
||||
{ ...a, attempt: { ...a.attempt, newSessionToken: a.kit.handoverSecret } },
|
||||
{ ...a, attempt: { ...a.attempt, operationId: 'again' } },
|
||||
{ ...a, attempt: { ...a.attempt, host: true } },
|
||||
])
|
||||
assert.throws(() => parseHandoverClaim(JSON.stringify(value)));
|
||||
});
|
||||
void test('unavailable or silently dropping browser storage cannot confirm saved or removed retry proof', () => {
|
||||
const a = createHandoverClaim(kit());
|
||||
assert.throws(() =>
|
||||
saveHandoverClaim({ getItem: () => null, setItem() {} }, a),
|
||||
);
|
||||
assert.throws(() =>
|
||||
saveHandoverClaim(
|
||||
{
|
||||
getItem: () => null,
|
||||
setItem() {
|
||||
throw new Error('blocked');
|
||||
},
|
||||
},
|
||||
a,
|
||||
),
|
||||
);
|
||||
assert.throws(() =>
|
||||
clearHandoverClaim({ getItem: () => JSON.stringify(a), removeItem() {} }),
|
||||
);
|
||||
});
|
||||
void test('a claimed seat is confirmed only after receipt and cookie-authorized read both identify the intended seat', () => {
|
||||
const a = createHandoverClaim(kit());
|
||||
const view = {
|
||||
code: a.kit.roomCode,
|
||||
me: player,
|
||||
version: 4,
|
||||
players: [{ id: player }],
|
||||
};
|
||||
const response = { view, transferred: true, replayed: false };
|
||||
assert.ok(handoverClaimConfirmed(a, response, view));
|
||||
assert.ok(
|
||||
handoverClaimConfirmed(
|
||||
a,
|
||||
{ ...response, replayed: true },
|
||||
{ ...view, version: 9 },
|
||||
),
|
||||
);
|
||||
assert.equal(handoverClaimConfirmed(a, { view }, view), false);
|
||||
assert.equal(
|
||||
handoverClaimConfirmed(a, response, { ...view, me: crypto.randomUUID() }),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
handoverClaimConfirmed(
|
||||
a,
|
||||
{ ...response, view: { ...view, code: 'BCDE2345' } },
|
||||
view,
|
||||
),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
handoverClaimConfirmed(a, response, { ...view, players: [] }),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
handoverClaimConfirmed(a, response, { ...view, version: 3 }),
|
||||
false,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,200 @@
|
||||
// @dune-suite integration
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import type { GameView } from '../game/engine';
|
||||
import { createHandoverKit, createHandoverClaim } from '../lib/seat-handover';
|
||||
const base = process.env.DUNE_TEST_URL ?? 'http://localhost:3000';
|
||||
const secret = () =>
|
||||
crypto.randomUUID().replaceAll('-', '') +
|
||||
crypto.randomUUID().replaceAll('-', '');
|
||||
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 parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
parsed = { error: 'Non-JSON response' };
|
||||
}
|
||||
const data = parsed as GameView & {
|
||||
view: GameView;
|
||||
error?: string;
|
||||
code: string;
|
||||
replayed?: boolean;
|
||||
transferred?: boolean;
|
||||
expiresAt?: number;
|
||||
revoked?: boolean;
|
||||
};
|
||||
return {
|
||||
status: response.status,
|
||||
data,
|
||||
cookie: response.headers.get('set-cookie')?.split(';')[0],
|
||||
cookieHeader: response.headers.get('set-cookie'),
|
||||
cache: response.headers.get('cache-control'),
|
||||
};
|
||||
}
|
||||
async function create() {
|
||||
const result = await request('/api/rooms', {
|
||||
name: 'Handover HTTP QA',
|
||||
faction: 'atreides',
|
||||
expansions: [],
|
||||
advanced: false,
|
||||
});
|
||||
assert.equal(result.status, 201);
|
||||
assert.ok(result.cookie);
|
||||
return { ...result, path: `/api/rooms/${result.data.code}` };
|
||||
}
|
||||
const sameGame = (a: GameView, b: GameView) =>
|
||||
assert.deepEqual({ ...a, version: 0 }, { ...b, version: 0 });
|
||||
void test('HTTP owner handover preserves seat, invalidates old browser/recovery and confirms exact claim retry', 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 kit = createHandoverKit(a.data.code, a.data.me);
|
||||
const body = {
|
||||
type: 'createSeatHandover',
|
||||
version: saved.data.view.version,
|
||||
offerId: kit.offerId,
|
||||
handoverSecret: kit.handoverSecret,
|
||||
};
|
||||
const offered = await request(a.path + '/control', body, a.cookie);
|
||||
assert.equal(offered.status, 200);
|
||||
assert.ok(offered.data.expiresAt! > Date.now());
|
||||
sameGame(offered.data.view, saved.data.view);
|
||||
const retriedOffer = await request(a.path + '/control', body, a.cookie);
|
||||
assert.equal(retriedOffer.status, 200);
|
||||
assert.equal(retriedOffer.data.replayed, true);
|
||||
assert.equal(retriedOffer.data.view.version, offered.data.view.version);
|
||||
const attempt = createHandoverClaim(kit).attempt;
|
||||
const claimed = await request(a.path + '/control', attempt);
|
||||
assert.equal(claimed.status, 200);
|
||||
assert.equal(claimed.data.transferred, true);
|
||||
assert.ok(claimed.cookieHeader?.includes('HttpOnly'));
|
||||
assert.ok(claimed.cookieHeader?.includes('SameSite=Strict'));
|
||||
assert.ok(claimed.cookieHeader?.includes(`Path=${a.path}`));
|
||||
assert.equal(claimed.cache, 'no-store');
|
||||
sameGame(claimed.data.view, offered.data.view);
|
||||
assert.equal((await request(a.path, undefined, a.cookie)).status, 409);
|
||||
assert.equal(
|
||||
(
|
||||
await request(a.path + '/control', {
|
||||
type: 'recoverSeat',
|
||||
playerId: a.data.me,
|
||||
recoverySecret,
|
||||
operationId: crypto.randomUUID(),
|
||||
newSessionToken: secret(),
|
||||
})
|
||||
).status,
|
||||
409,
|
||||
);
|
||||
const replay = await request(a.path + '/control', attempt);
|
||||
assert.equal(replay.status, 200);
|
||||
assert.equal(replay.cookie, claimed.cookie);
|
||||
assert.equal(replay.data.replayed, true);
|
||||
assert.equal(replay.data.view.version, claimed.data.view.version);
|
||||
const read = await request(a.path, undefined, replay.cookie);
|
||||
assert.equal(read.status, 200);
|
||||
sameGame(read.data, claimed.data.view);
|
||||
assert.equal(JSON.stringify(read.data).includes(kit.handoverSecret), false);
|
||||
assert.equal(JSON.stringify(read.data).includes(kit.offerId), false);
|
||||
assert.equal(
|
||||
(await request(a.path + '/control', createHandoverClaim(kit).attempt))
|
||||
.status,
|
||||
409,
|
||||
);
|
||||
});
|
||||
void test('HTTP handover rejects another active seat cookie, wrong secrets and cross-origin writes without changing ownership', async () => {
|
||||
const a = await create();
|
||||
const other = await request(a.path, {
|
||||
type: 'join',
|
||||
name: 'Other handover seat',
|
||||
faction: 'harkonnen',
|
||||
});
|
||||
assert.equal(other.status, 200);
|
||||
const kit = createHandoverKit(a.data.code, a.data.me);
|
||||
const offer = {
|
||||
type: 'createSeatHandover',
|
||||
version: other.data.version,
|
||||
offerId: kit.offerId,
|
||||
handoverSecret: kit.handoverSecret,
|
||||
};
|
||||
assert.equal((await request(a.path + '/control', offer)).status, 409);
|
||||
assert.equal(
|
||||
(await request(a.path + '/control', offer, a.cookie, 'https://other.test'))
|
||||
.status,
|
||||
403,
|
||||
);
|
||||
const made = await request(a.path + '/control', offer, a.cookie);
|
||||
assert.equal(made.status, 200);
|
||||
const attempt = createHandoverClaim(kit).attempt;
|
||||
const wrongSeat = await request(a.path + '/control', attempt, other.cookie);
|
||||
assert.equal(wrongSeat.status, 409);
|
||||
assert.equal(wrongSeat.data.code, 'ALREADY_SEATED');
|
||||
const bad = await request(a.path + '/control', {
|
||||
...attempt,
|
||||
handoverSecret: secret(),
|
||||
});
|
||||
assert.equal(bad.status, 409);
|
||||
assert.equal(bad.data.view, undefined);
|
||||
assert.equal((await request(a.path, undefined, a.cookie)).status, 200);
|
||||
const cancelled = await request(
|
||||
a.path + '/control',
|
||||
{
|
||||
type: 'revokeSeatHandover',
|
||||
version: made.data.view.version,
|
||||
offerId: kit.offerId,
|
||||
},
|
||||
a.cookie,
|
||||
);
|
||||
assert.equal(cancelled.status, 200);
|
||||
assert.equal(cancelled.data.revoked, true);
|
||||
assert.equal((await request(a.path + '/control', attempt)).status, 409);
|
||||
});
|
||||
void test('HTTP simultaneous identical handover retries establish one credential and one ownership change', async () => {
|
||||
const a = await create(),
|
||||
kit = createHandoverKit(a.data.code, a.data.me);
|
||||
const offered = await request(
|
||||
a.path + '/control',
|
||||
{
|
||||
type: 'createSeatHandover',
|
||||
version: a.data.version,
|
||||
offerId: kit.offerId,
|
||||
handoverSecret: kit.handoverSecret,
|
||||
},
|
||||
a.cookie,
|
||||
);
|
||||
assert.equal(offered.status, 200);
|
||||
const attempt = createHandoverClaim(kit).attempt;
|
||||
const results = await Promise.all([
|
||||
request(a.path + '/control', attempt),
|
||||
request(a.path + '/control', attempt),
|
||||
]);
|
||||
assert.ok(results.some((result) => result.status === 200));
|
||||
const confirmed = await request(a.path + '/control', attempt);
|
||||
assert.equal(confirmed.status, 200);
|
||||
assert.equal(confirmed.data.view.version, offered.data.view.version + 1);
|
||||
for (const result of results.filter((value) => value.status === 200))
|
||||
assert.equal(result.cookie, confirmed.cookie);
|
||||
assert.equal(
|
||||
(await request(a.path, undefined, confirmed.cookie)).status,
|
||||
200,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,768 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import type { DatabaseSync } from 'node:sqlite';
|
||||
import { baseDeck } from '../game/cards';
|
||||
import { unitStore } from './fixture-nexus-room-store';
|
||||
|
||||
const UUID = {
|
||||
entry: '10000000-0000-4000-8000-000000000001',
|
||||
recovery: '10000000-0000-4000-8000-000000000002',
|
||||
offer: '10000000-0000-4000-8000-000000000003',
|
||||
replacement: '10000000-0000-4000-8000-000000000004',
|
||||
claim: '10000000-0000-4000-8000-000000000005',
|
||||
rival: '10000000-0000-4000-8000-000000000006',
|
||||
};
|
||||
const secret = (pair: string) => pair.repeat(32);
|
||||
const digest = (value: string) =>
|
||||
createHash('sha256').update(value).digest('hex');
|
||||
const clock = (now: number) => ({ now: () => now });
|
||||
|
||||
async function fixture(
|
||||
withRecoveryRotation = false,
|
||||
migrationThrough?: string,
|
||||
) {
|
||||
const store = unitStore(undefined, migrationThrough);
|
||||
const entry = {
|
||||
operationId: UUID.entry,
|
||||
sessionToken: secret('10'),
|
||||
};
|
||||
const created = await store.rooms.createRoom(
|
||||
'Original owner',
|
||||
'atreides',
|
||||
false,
|
||||
[],
|
||||
entry,
|
||||
);
|
||||
const code = created.view.code;
|
||||
let auth = await store.rooms.authenticate(code, created.token);
|
||||
const game = await store.rooms.readRoom(code);
|
||||
game.players[0].hand = [baseDeck()[0]];
|
||||
game.players[0].traitors = ['emperor-0'];
|
||||
store.sqlite
|
||||
.prepare('UPDATE rooms SET state = ? WHERE code = ?')
|
||||
.run(JSON.stringify(game), code);
|
||||
let view = await store.rooms.readSeatView(code, auth);
|
||||
const recoverySecret = secret('20');
|
||||
let recoveryInput:
|
||||
| {
|
||||
playerId: string;
|
||||
recoverySecret: string;
|
||||
operationId: string;
|
||||
newSessionToken: string;
|
||||
}
|
||||
| undefined;
|
||||
if (withRecoveryRotation) {
|
||||
view = (
|
||||
await store.rooms.setRecoveryKey(
|
||||
code,
|
||||
auth,
|
||||
view.version,
|
||||
recoverySecret,
|
||||
)
|
||||
).view;
|
||||
recoveryInput = {
|
||||
playerId: created.view.me,
|
||||
recoverySecret,
|
||||
operationId: UUID.recovery,
|
||||
newSessionToken: secret('30'),
|
||||
};
|
||||
const recovered = await store.rooms.recoverSeat(code, recoveryInput);
|
||||
view = recovered.view;
|
||||
auth = await store.rooms.authenticate(code, recoveryInput.newSessionToken);
|
||||
}
|
||||
return {
|
||||
...store,
|
||||
code,
|
||||
created,
|
||||
entry,
|
||||
auth,
|
||||
view,
|
||||
recoverySecret,
|
||||
recoveryInput,
|
||||
};
|
||||
}
|
||||
|
||||
const offer = (id = UUID.offer, handoverSecret = secret('40')) => ({
|
||||
offerId: id,
|
||||
handoverSecret,
|
||||
});
|
||||
const claim = (
|
||||
playerId: string,
|
||||
operationId = UUID.claim,
|
||||
newSessionToken = secret('50'),
|
||||
handover = offer(),
|
||||
) => ({
|
||||
playerId,
|
||||
...handover,
|
||||
operationId,
|
||||
newSessionToken,
|
||||
});
|
||||
|
||||
function custody(sqlite: DatabaseSync) {
|
||||
return JSON.stringify(
|
||||
[
|
||||
'rooms',
|
||||
'seats',
|
||||
'seat_recovery_keys',
|
||||
'seat_recovery_receipts',
|
||||
'room_entry_receipts',
|
||||
'seat_handover_offers',
|
||||
'seat_handover_claim_receipts',
|
||||
].map((table) =>
|
||||
sqlite.prepare(`SELECT * FROM ${table} ORDER BY rowid`).all(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function batchBarrier(hooks: { beforeBatch?: () => Promise<void> }) {
|
||||
let entered!: () => void;
|
||||
let release!: () => void;
|
||||
const waiting = new Promise<void>((resolve) => {
|
||||
entered = resolve;
|
||||
});
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
hooks.beforeBatch = async () => {
|
||||
delete hooks.beforeBatch;
|
||||
entered();
|
||||
await gate;
|
||||
};
|
||||
return { waiting, release };
|
||||
}
|
||||
|
||||
void test('voluntary claim preserves exact game state and private seat while revoking every old authority', async () => {
|
||||
const f = await fixture(true);
|
||||
try {
|
||||
const handover = offer();
|
||||
const created = await f.rooms.createSeatHandover(
|
||||
f.code,
|
||||
f.auth,
|
||||
f.view.version,
|
||||
handover,
|
||||
clock(1000),
|
||||
);
|
||||
assert.equal(created.expiresAt, 1000 + f.rooms.SEAT_HANDOVER_TTL_MS);
|
||||
assert.equal(created.replayed, false);
|
||||
const state = f.sqlite
|
||||
.prepare('SELECT state FROM rooms WHERE code = ?')
|
||||
.get(f.code)!.state;
|
||||
const body = claim(f.created.view.me);
|
||||
const transferred = await f.rooms.claimSeatHandover(
|
||||
f.code,
|
||||
body,
|
||||
clock(2000),
|
||||
);
|
||||
assert.equal(transferred.transferred, true);
|
||||
assert.equal(transferred.replayed, false);
|
||||
assert.equal(transferred.view.me, f.created.view.me);
|
||||
assert.equal(transferred.view.players[0].faction, 'atreides');
|
||||
assert.equal(transferred.view.players[0].hand![0].id, baseDeck()[0].id);
|
||||
assert.equal(
|
||||
f.sqlite.prepare('SELECT state FROM rooms WHERE code = ?').get(f.code)!
|
||||
.state,
|
||||
state,
|
||||
);
|
||||
await assert.rejects(f.rooms.readSeatView(f.code, f.auth), /verified/);
|
||||
await assert.rejects(
|
||||
f.rooms.recoverSeat(f.code, f.recoveryInput!),
|
||||
/could not be verified|no longer active/,
|
||||
);
|
||||
await assert.rejects(
|
||||
f.rooms.createRoom(
|
||||
'Original owner',
|
||||
'atreides',
|
||||
false,
|
||||
[],
|
||||
f.entry,
|
||||
),
|
||||
/no longer active/,
|
||||
);
|
||||
assert.equal(
|
||||
f.sqlite.prepare('SELECT count(*) AS n FROM seat_recovery_keys').get()!
|
||||
.n,
|
||||
0,
|
||||
);
|
||||
assert.equal(
|
||||
f.sqlite
|
||||
.prepare('SELECT count(*) AS n FROM seat_recovery_receipts')
|
||||
.get()!.n,
|
||||
0,
|
||||
);
|
||||
const recipientAuth = await f.rooms.authenticate(
|
||||
f.code,
|
||||
body.newSessionToken,
|
||||
);
|
||||
const acted = await f.rooms.act(
|
||||
f.code,
|
||||
recipientAuth,
|
||||
transferred.view.version,
|
||||
{ type: 'ready' },
|
||||
);
|
||||
assert.equal(acted.players[0].ready, true);
|
||||
const restarted = f.restart();
|
||||
const replay = await restarted.claimSeatHandover(
|
||||
f.code,
|
||||
body,
|
||||
clock(1000 + f.rooms.SEAT_HANDOVER_TTL_MS + 1),
|
||||
);
|
||||
assert.equal(replay.replayed, true);
|
||||
assert.equal(replay.token, body.newSessionToken);
|
||||
assert.equal(replay.view.version, acted.version);
|
||||
} finally {
|
||||
f.sqlite.close();
|
||||
}
|
||||
});
|
||||
|
||||
void test('recovering the issuer invalidates its still-unclaimed handover offer', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
const recoverySecret = secret('21');
|
||||
const saved = await f.rooms.setRecoveryKey(
|
||||
f.code,
|
||||
f.auth,
|
||||
f.view.version,
|
||||
recoverySecret,
|
||||
);
|
||||
const handover = offer();
|
||||
const offered = await f.rooms.createSeatHandover(
|
||||
f.code,
|
||||
f.auth,
|
||||
saved.view.version,
|
||||
handover,
|
||||
clock(1000),
|
||||
);
|
||||
const recovery = {
|
||||
playerId: f.created.view.me,
|
||||
recoverySecret,
|
||||
operationId: UUID.recovery,
|
||||
newSessionToken: secret('31'),
|
||||
};
|
||||
const recovered = await f.rooms.recoverSeat(f.code, recovery);
|
||||
await assert.rejects(
|
||||
f.rooms.claimSeatHandover(
|
||||
f.code,
|
||||
claim(f.created.view.me),
|
||||
clock(2000),
|
||||
),
|
||||
/could not be verified/,
|
||||
);
|
||||
const newAuth = await f.rooms.authenticate(
|
||||
f.code,
|
||||
recovery.newSessionToken,
|
||||
);
|
||||
assert.deepEqual(
|
||||
await f.rooms.readSeatView(f.code, newAuth),
|
||||
recovered.view,
|
||||
);
|
||||
assert.equal(
|
||||
(await f.rooms.readRoom(f.code)).version,
|
||||
offered.view.version + 1,
|
||||
);
|
||||
} finally {
|
||||
f.sqlite.close();
|
||||
}
|
||||
});
|
||||
|
||||
void test('claim invalidates the issuer entry receipt that still controlled the seat', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
const offered = await f.rooms.createSeatHandover(
|
||||
f.code,
|
||||
f.auth,
|
||||
f.view.version,
|
||||
offer(),
|
||||
clock(1000),
|
||||
);
|
||||
await f.rooms.claimSeatHandover(
|
||||
f.code,
|
||||
claim(f.created.view.me),
|
||||
clock(2000),
|
||||
);
|
||||
await assert.rejects(
|
||||
f.rooms.createRoom(
|
||||
'Original owner',
|
||||
'atreides',
|
||||
false,
|
||||
[],
|
||||
f.entry,
|
||||
),
|
||||
/no longer active/,
|
||||
);
|
||||
assert.equal(
|
||||
(await f.rooms.readRoom(f.code)).version,
|
||||
offered.view.version + 1,
|
||||
);
|
||||
} finally {
|
||||
f.sqlite.close();
|
||||
}
|
||||
});
|
||||
|
||||
void test('0005 backfills an active 0004 claim for exact replay after upgrade', async () => {
|
||||
const f = await fixture(false, '0004_flippant_archangel.sql');
|
||||
try {
|
||||
const body = claim(f.created.view.me);
|
||||
const offered = await f.rooms.createSeatHandover(
|
||||
f.code,
|
||||
f.auth,
|
||||
f.view.version,
|
||||
offer(),
|
||||
clock(1000),
|
||||
);
|
||||
const operationHash = digest(body.operationId);
|
||||
const sessionHash = digest(body.newSessionToken);
|
||||
f.sqlite.exec('BEGIN');
|
||||
try {
|
||||
f.sqlite
|
||||
.prepare(
|
||||
'UPDATE rooms SET version = version + 1, updated_at = ? WHERE code = ? AND version = ?',
|
||||
)
|
||||
.run(2000, f.code, offered.view.version);
|
||||
f.sqlite
|
||||
.prepare(
|
||||
'UPDATE seat_handover_offers SET claim_operation_hash = ?, session_hash = ?, claimed_at = ? WHERE room_code = ? AND player_id = ?',
|
||||
)
|
||||
.run(operationHash, sessionHash, 2000, f.code, f.created.view.me);
|
||||
f.sqlite
|
||||
.prepare(
|
||||
'UPDATE seats SET revoked = 1 WHERE room_code = ? AND player_id = ? AND revoked = 0',
|
||||
)
|
||||
.run(f.code, f.created.view.me);
|
||||
f.sqlite
|
||||
.prepare(
|
||||
'INSERT INTO seats(token_hash,room_code,player_id) VALUES (?,?,?)',
|
||||
)
|
||||
.run(sessionHash, f.code, f.created.view.me);
|
||||
f.sqlite.exec('COMMIT');
|
||||
} catch (error) {
|
||||
f.sqlite.exec('ROLLBACK');
|
||||
throw error;
|
||||
}
|
||||
const stateBeforeUpgrade = f.sqlite
|
||||
.prepare('SELECT state FROM rooms WHERE code = ?')
|
||||
.get(f.code)!.state;
|
||||
f.sqlite.exec(
|
||||
readFileSync(
|
||||
new URL('../drizzle/0005_salty_alice.sql', import.meta.url),
|
||||
'utf8',
|
||||
),
|
||||
);
|
||||
const receipt = f.sqlite
|
||||
.prepare(
|
||||
'SELECT operation_hash,session_hash,claim_fence FROM seat_handover_claim_receipts WHERE room_code = ? AND player_id = ?',
|
||||
)
|
||||
.get(f.code, f.created.view.me)!;
|
||||
assert.equal(receipt.operation_hash, operationHash);
|
||||
assert.equal(receipt.session_hash, sessionHash);
|
||||
assert.match(receipt.claim_fence as string, /^legacy:/);
|
||||
assert.equal(
|
||||
(receipt.claim_fence as string).includes(body.newSessionToken),
|
||||
false,
|
||||
);
|
||||
const replay = await f.restart().claimSeatHandover(
|
||||
f.code,
|
||||
body,
|
||||
clock(3000),
|
||||
);
|
||||
assert.equal(replay.replayed, true);
|
||||
assert.equal(replay.token, body.newSessionToken);
|
||||
assert.equal(replay.view.me, f.created.view.me);
|
||||
assert.equal(replay.view.players[0].hand![0].id, baseDeck()[0].id);
|
||||
assert.equal(
|
||||
f.sqlite.prepare('SELECT state FROM rooms WHERE code = ?').get(f.code)!
|
||||
.state,
|
||||
stateBeforeUpgrade,
|
||||
);
|
||||
} finally {
|
||||
f.sqlite.close();
|
||||
}
|
||||
});
|
||||
|
||||
void test('exact claim replay survives a later offer created by the recipient', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
const body = claim(f.created.view.me);
|
||||
const offered = await f.rooms.createSeatHandover(
|
||||
f.code,
|
||||
f.auth,
|
||||
f.view.version,
|
||||
offer(),
|
||||
clock(1000),
|
||||
);
|
||||
const transferred = await f.rooms.claimSeatHandover(
|
||||
f.code,
|
||||
body,
|
||||
clock(2000),
|
||||
);
|
||||
const recipientAuth = await f.rooms.authenticate(
|
||||
f.code,
|
||||
body.newSessionToken,
|
||||
);
|
||||
const later = await f.rooms.createSeatHandover(
|
||||
f.code,
|
||||
recipientAuth,
|
||||
transferred.view.version,
|
||||
offer(UUID.replacement, secret('41')),
|
||||
clock(3000),
|
||||
);
|
||||
const replay = await f.rooms.claimSeatHandover(
|
||||
f.code,
|
||||
body,
|
||||
clock(4000),
|
||||
);
|
||||
assert.equal(replay.replayed, true);
|
||||
assert.equal(replay.token, body.newSessionToken);
|
||||
assert.equal(replay.view.version, later.view.version);
|
||||
assert.equal(offered.view.version + 2, later.view.version);
|
||||
} finally {
|
||||
f.sqlite.close();
|
||||
}
|
||||
});
|
||||
|
||||
void test('losing exact claim cannot erase recovery configured after the winner', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
const body = claim(f.created.view.me);
|
||||
await f.rooms.createSeatHandover(
|
||||
f.code,
|
||||
f.auth,
|
||||
f.view.version,
|
||||
offer(),
|
||||
clock(1000),
|
||||
);
|
||||
const barrier = batchBarrier(f.hooks);
|
||||
const loser = f.rooms.claimSeatHandover(f.code, body, clock(2000));
|
||||
await barrier.waiting;
|
||||
const winner = await f.rooms.claimSeatHandover(
|
||||
f.code,
|
||||
body,
|
||||
clock(2000),
|
||||
);
|
||||
const recipientAuth = await f.rooms.authenticate(
|
||||
f.code,
|
||||
body.newSessionToken,
|
||||
);
|
||||
const recoverySecret = secret('22');
|
||||
const configured = await f.rooms.setRecoveryKey(
|
||||
f.code,
|
||||
recipientAuth,
|
||||
winner.view.version,
|
||||
recoverySecret,
|
||||
);
|
||||
barrier.release();
|
||||
const replay = await loser;
|
||||
assert.equal(replay.replayed, true);
|
||||
assert.equal(replay.view.version, configured.view.version);
|
||||
assert.equal(
|
||||
f.sqlite.prepare('SELECT count(*) AS n FROM seat_recovery_keys').get()!
|
||||
.n,
|
||||
1,
|
||||
);
|
||||
const recovered = await f.rooms.recoverSeat(f.code, {
|
||||
playerId: f.created.view.me,
|
||||
recoverySecret,
|
||||
operationId: UUID.recovery,
|
||||
newSessionToken: secret('32'),
|
||||
});
|
||||
assert.equal(recovered.recovered, true);
|
||||
} finally {
|
||||
f.sqlite.close();
|
||||
}
|
||||
});
|
||||
|
||||
void test('a concurrent game action wins cleanly over a paused claim', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
const offered = await f.rooms.createSeatHandover(
|
||||
f.code,
|
||||
f.auth,
|
||||
f.view.version,
|
||||
offer(),
|
||||
clock(1000),
|
||||
);
|
||||
const barrier = batchBarrier(f.hooks);
|
||||
const pending = f.rooms.claimSeatHandover(
|
||||
f.code,
|
||||
claim(f.created.view.me),
|
||||
clock(2000),
|
||||
);
|
||||
await barrier.waiting;
|
||||
const acted = await f.rooms.act(f.code, f.auth, offered.view.version, {
|
||||
type: 'ready',
|
||||
});
|
||||
barrier.release();
|
||||
await assert.rejects(pending, /changed/);
|
||||
assert.equal(acted.players[0].ready, true);
|
||||
await f.rooms.readSeatView(f.code, f.auth);
|
||||
assert.equal(
|
||||
f.sqlite
|
||||
.prepare('SELECT count(*) AS n FROM seat_handover_claim_receipts')
|
||||
.get()!.n,
|
||||
0,
|
||||
);
|
||||
} finally {
|
||||
f.sqlite.close();
|
||||
}
|
||||
});
|
||||
|
||||
void test('issuer recovery wins cleanly over a paused claim', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
const recoverySecret = secret('23');
|
||||
const saved = await f.rooms.setRecoveryKey(
|
||||
f.code,
|
||||
f.auth,
|
||||
f.view.version,
|
||||
recoverySecret,
|
||||
);
|
||||
await f.rooms.createSeatHandover(
|
||||
f.code,
|
||||
f.auth,
|
||||
saved.view.version,
|
||||
offer(),
|
||||
clock(1000),
|
||||
);
|
||||
const barrier = batchBarrier(f.hooks);
|
||||
const pending = f.rooms.claimSeatHandover(
|
||||
f.code,
|
||||
claim(f.created.view.me),
|
||||
clock(2000),
|
||||
);
|
||||
await barrier.waiting;
|
||||
const recovery = {
|
||||
playerId: f.created.view.me,
|
||||
recoverySecret,
|
||||
operationId: UUID.recovery,
|
||||
newSessionToken: secret('33'),
|
||||
};
|
||||
const recovered = await f.rooms.recoverSeat(f.code, recovery);
|
||||
barrier.release();
|
||||
await assert.rejects(pending, /changed/);
|
||||
const recoveredAuth = await f.rooms.authenticate(
|
||||
f.code,
|
||||
recovery.newSessionToken,
|
||||
);
|
||||
assert.deepEqual(
|
||||
await f.rooms.readSeatView(f.code, recoveredAuth),
|
||||
recovered.view,
|
||||
);
|
||||
assert.equal(
|
||||
f.sqlite
|
||||
.prepare('SELECT count(*) AS n FROM seat_handover_claim_receipts')
|
||||
.get()!.n,
|
||||
0,
|
||||
);
|
||||
} finally {
|
||||
f.sqlite.close();
|
||||
}
|
||||
});
|
||||
|
||||
void test('owner revocation wins cleanly over a paused claim', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
const offered = await f.rooms.createSeatHandover(
|
||||
f.code,
|
||||
f.auth,
|
||||
f.view.version,
|
||||
offer(),
|
||||
clock(1000),
|
||||
);
|
||||
const barrier = batchBarrier(f.hooks);
|
||||
const pending = f.rooms.claimSeatHandover(
|
||||
f.code,
|
||||
claim(f.created.view.me),
|
||||
clock(2000),
|
||||
);
|
||||
await barrier.waiting;
|
||||
const revoked = await f.rooms.revokeSeatHandover(
|
||||
f.code,
|
||||
f.auth,
|
||||
offered.view.version,
|
||||
UUID.offer,
|
||||
clock(2000),
|
||||
);
|
||||
barrier.release();
|
||||
await assert.rejects(pending, /changed/);
|
||||
assert.equal(revoked.revoked, true);
|
||||
await f.rooms.readSeatView(f.code, f.auth);
|
||||
assert.equal(
|
||||
f.sqlite
|
||||
.prepare('SELECT count(*) AS n FROM seat_handover_claim_receipts')
|
||||
.get()!.n,
|
||||
0,
|
||||
);
|
||||
} finally {
|
||||
f.sqlite.close();
|
||||
}
|
||||
});
|
||||
|
||||
void test('exact concurrent claims replay one rotation while different claims have one winner', async () => {
|
||||
for (const same of [true, false]) {
|
||||
const f = await fixture();
|
||||
try {
|
||||
const offered = await f.rooms.createSeatHandover(
|
||||
f.code,
|
||||
f.auth,
|
||||
f.view.version,
|
||||
offer(),
|
||||
clock(1000),
|
||||
);
|
||||
const first = claim(f.created.view.me);
|
||||
const second = same
|
||||
? first
|
||||
: claim(f.created.view.me, UUID.rival, secret('60'));
|
||||
const results = await Promise.allSettled([
|
||||
f.rooms.claimSeatHandover(f.code, first, clock(2000)),
|
||||
f.rooms.claimSeatHandover(f.code, second, clock(2000)),
|
||||
]);
|
||||
assert.equal(
|
||||
results.filter((result) => result.status === 'fulfilled').length,
|
||||
same ? 2 : 1,
|
||||
);
|
||||
assert.equal(
|
||||
(await f.rooms.readRoom(f.code)).version,
|
||||
offered.view.version + 1,
|
||||
);
|
||||
assert.equal(
|
||||
f.sqlite
|
||||
.prepare('SELECT count(*) AS n FROM seats WHERE revoked = 0')
|
||||
.get()!.n,
|
||||
1,
|
||||
);
|
||||
if (same)
|
||||
assert.ok(
|
||||
results.every(
|
||||
(result) =>
|
||||
result.status === 'fulfilled' &&
|
||||
result.value.token === first.newSessionToken,
|
||||
),
|
||||
);
|
||||
else
|
||||
await assert.rejects(
|
||||
f.rooms.claimSeatHandover(f.code, second, clock(2000)),
|
||||
/already been claimed/,
|
||||
);
|
||||
} finally {
|
||||
f.sqlite.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
void test('offer replacement, revocation and expiry are version-fenced and retry safe', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
const first = await f.rooms.createSeatHandover(
|
||||
f.code,
|
||||
f.auth,
|
||||
f.view.version,
|
||||
offer(),
|
||||
clock(1000),
|
||||
);
|
||||
const replay = await f.rooms.createSeatHandover(
|
||||
f.code,
|
||||
f.auth,
|
||||
f.view.version,
|
||||
offer(),
|
||||
clock(1001),
|
||||
);
|
||||
assert.equal(replay.replayed, true);
|
||||
assert.equal(replay.view.version, first.view.version);
|
||||
const replacement = offer(UUID.replacement, secret('41'));
|
||||
const replaced = await f.rooms.createSeatHandover(
|
||||
f.code,
|
||||
f.auth,
|
||||
first.view.version,
|
||||
replacement,
|
||||
clock(2000),
|
||||
);
|
||||
await assert.rejects(
|
||||
f.rooms.claimSeatHandover(
|
||||
f.code,
|
||||
claim(f.created.view.me),
|
||||
clock(3000),
|
||||
),
|
||||
/could not be verified/,
|
||||
);
|
||||
await assert.rejects(
|
||||
f.rooms.revokeSeatHandover(
|
||||
f.code,
|
||||
f.auth,
|
||||
replaced.view.version,
|
||||
UUID.offer,
|
||||
),
|
||||
/different handover offer/,
|
||||
);
|
||||
const revoked = await f.rooms.revokeSeatHandover(
|
||||
f.code,
|
||||
f.auth,
|
||||
replaced.view.version,
|
||||
UUID.replacement,
|
||||
clock(3000),
|
||||
);
|
||||
assert.equal(revoked.replayed, false);
|
||||
const retried = await f.rooms.revokeSeatHandover(
|
||||
f.code,
|
||||
f.auth,
|
||||
replaced.view.version,
|
||||
UUID.replacement,
|
||||
clock(3001),
|
||||
);
|
||||
assert.equal(retried.replayed, true);
|
||||
|
||||
const expiring = await f.rooms.createSeatHandover(
|
||||
f.code,
|
||||
f.auth,
|
||||
retried.view.version,
|
||||
offer(),
|
||||
clock(4000),
|
||||
);
|
||||
await assert.rejects(
|
||||
f.rooms.claimSeatHandover(
|
||||
f.code,
|
||||
claim(f.created.view.me),
|
||||
clock(expiring.expiresAt),
|
||||
),
|
||||
/expired/,
|
||||
);
|
||||
} finally {
|
||||
f.sqlite.close();
|
||||
}
|
||||
});
|
||||
|
||||
void test('failed recipient credential insertion rolls back claim custody atomically', async () => {
|
||||
const f = await fixture(true);
|
||||
try {
|
||||
await f.rooms.createSeatHandover(
|
||||
f.code,
|
||||
f.auth,
|
||||
f.view.version,
|
||||
offer(),
|
||||
clock(1000),
|
||||
);
|
||||
const before = custody(f.sqlite);
|
||||
f.sqlite.exec(
|
||||
"CREATE TRIGGER reject_handover_session BEFORE INSERT ON seats BEGIN SELECT RAISE(ABORT, 'injected handover insertion failure'); END;",
|
||||
);
|
||||
await assert.rejects(
|
||||
f.rooms.claimSeatHandover(
|
||||
f.code,
|
||||
claim(f.created.view.me),
|
||||
clock(2000),
|
||||
),
|
||||
/injected handover insertion failure/,
|
||||
);
|
||||
assert.equal(custody(f.sqlite), before);
|
||||
await f.rooms.readSeatView(f.code, f.auth);
|
||||
f.sqlite.exec('DROP TRIGGER reject_handover_session');
|
||||
const done = await f.rooms.claimSeatHandover(
|
||||
f.code,
|
||||
claim(f.created.view.me),
|
||||
clock(2000),
|
||||
);
|
||||
assert.equal(done.transferred, true);
|
||||
} finally {
|
||||
f.sqlite.close();
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user