Automatically return the full combined hand when it is the only legal special-Karama return. Preserve real choices and restore the suspended response or auction payment through the same settlement. Record the actual blind draw as owner-only historical card faces, with a bound integrity receipt; legacy forced saves describe held-card history truthfully. Add readable private history and enlarged inspectors. Both manual and forced returns publish only a faction/action completion tag. Wake idle legacy exchanges through production recovery without writes for real choices. Keep provisional auction funding rules and release gates. Validation: types, lint, 5150 offline tests, build and 49 HTTP tests passed. Independent review resolved history substitution and root review completed idle recovery wiring. New tests cover 1-4 forced draws, real choices, overlays, exact saved history, immutable rejection, all four legal AI paths, production competing actions/recovery, private views and custody. One genuine Advanced four-player sample completed 158 actions with 4 JSON restores and no rejected action; no difficulty tuning. Browser QA QUTHVPTT used genuine Advanced setup and conserved Bidding positions. The actual power returned four cards without a prompt; private history/enlargement and exact refresh worked. Phone panel fit without horizontal overflow with 44px inspector controls. Screenshot capture timed out; complete visual acceptance and transient notice capture remain open. Preserved every prior row across all ten tables, including 1586 rooms and 3354 seats. New QA/integration records bring totals to 1626 rooms and 3442 seats. No reset, restart or automation.
1671 lines
55 KiB
TypeScript
1671 lines
55 KiB
TypeScript
import { env } from 'cloudflare:workers';
|
|
import {
|
|
applyAction,
|
|
normalizeAutomaticGame,
|
|
createGame,
|
|
joinGame,
|
|
newPlayer,
|
|
viewGame,
|
|
RuleError,
|
|
type Action,
|
|
type Game,
|
|
type GameView,
|
|
} from '@/game/engine';
|
|
import type { FactionId } from '@/game/catalog';
|
|
import type { Difficulty } from '@/game/bot-profiles';
|
|
import { runBots } from '@/game/bots';
|
|
import {
|
|
validSeatAiGrantId,
|
|
validSetSeatAiDelegateInput,
|
|
validUseSeatAiDelegateInput,
|
|
type SeatAiDelegationView,
|
|
type SetSeatAiDelegateInput,
|
|
type UseSeatAiDelegateInput,
|
|
} from '@/lib/seat-ai-delegation';
|
|
export const ONLINE_BOT_INTERVAL_MS = 1500;
|
|
/** Internal trusted dependency; never accepted from HTTP inputs. */
|
|
export type RoomsClock = {
|
|
now(): number;
|
|
sleep(ms: number): Promise<void>;
|
|
};
|
|
const roomsClock: RoomsClock = {
|
|
now: () => Date.now(),
|
|
sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
};
|
|
function queueRoomBots(next: Game, current: Game, now: number) {
|
|
next.botsPending =
|
|
(next.status === 'setup' || next.status === 'playing') &&
|
|
next.players.some((p) => p.bot ?? p.autopilot);
|
|
if (next.botsPending)
|
|
next.botNextActionAt =
|
|
current.botsPending && Number.isFinite(current.botNextActionAt)
|
|
? current.botNextActionAt
|
|
: now + ONLINE_BOT_INTERVAL_MS;
|
|
else delete next.botNextActionAt;
|
|
}
|
|
function db() {
|
|
if (!env.DB) throw new Error('Game storage is unavailable.');
|
|
return env.DB;
|
|
}
|
|
async function hash(value: string) {
|
|
const bytes = await crypto.subtle.digest(
|
|
'SHA-256',
|
|
new TextEncoder().encode(value),
|
|
);
|
|
return Array.from(new Uint8Array(bytes), (n) =>
|
|
n.toString(16).padStart(2, '0'),
|
|
).join('');
|
|
}
|
|
export class RoomEntryError extends RuleError {
|
|
constructor(
|
|
message: string,
|
|
readonly code: string,
|
|
readonly status = 409,
|
|
) {
|
|
super(message);
|
|
}
|
|
}
|
|
type PreparedEntry = {
|
|
operationId: string;
|
|
operationHash: string;
|
|
sessionToken: string;
|
|
sessionHash: string;
|
|
requestHash: string;
|
|
};
|
|
type EntryResult = {
|
|
token?: string;
|
|
view: ReturnType<typeof viewGame>;
|
|
entryReceipt?: { operationId: string; replayed: boolean };
|
|
alreadySeated?: true;
|
|
};
|
|
async function prepareEntry(
|
|
input: unknown,
|
|
identity: unknown,
|
|
): Promise<PreparedEntry | null> {
|
|
if (input === undefined) return null;
|
|
if (!input || typeof input !== 'object' || Array.isArray(input))
|
|
throw new RoomEntryError(
|
|
'Provide a valid room-entry receipt.',
|
|
'INVALID_ENTRY_REQUEST',
|
|
400,
|
|
);
|
|
const entry = input as Record<string, unknown>;
|
|
if (
|
|
Object.keys(entry).some(
|
|
(key) => !['operationId', 'sessionToken'].includes(key),
|
|
) ||
|
|
typeof entry.operationId !== 'string' ||
|
|
!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(
|
|
entry.operationId,
|
|
) ||
|
|
typeof entry.sessionToken !== 'string' ||
|
|
!/^[0-9a-f]{64}$/.test(entry.sessionToken)
|
|
)
|
|
throw new RoomEntryError(
|
|
'Generate a room-entry operation and private 256-bit session token.',
|
|
'INVALID_ENTRY_REQUEST',
|
|
400,
|
|
);
|
|
return {
|
|
operationId: entry.operationId,
|
|
operationHash: await hash(entry.operationId),
|
|
sessionToken: entry.sessionToken,
|
|
sessionHash: await hash(entry.sessionToken),
|
|
requestHash: await hash(JSON.stringify(identity)),
|
|
};
|
|
}
|
|
async function replayEntry(
|
|
entry: PreparedEntry | null,
|
|
): Promise<(EntryResult & { token: string }) | null> {
|
|
if (!entry) return null;
|
|
const row = await db()
|
|
.prepare(
|
|
'SELECT request_hash,session_hash,room_code,player_id FROM room_entry_receipts WHERE operation_hash = ?',
|
|
)
|
|
.bind(entry.operationHash)
|
|
.first<{
|
|
request_hash: string;
|
|
session_hash: string;
|
|
room_code: string;
|
|
player_id: string;
|
|
}>();
|
|
if (!row) return null;
|
|
if (
|
|
row.request_hash !== entry.requestHash ||
|
|
row.session_hash !== entry.sessionHash
|
|
)
|
|
throw new RoomEntryError(
|
|
'This room-entry receipt does not match the original request.',
|
|
'ENTRY_RECEIPT_INVALID',
|
|
);
|
|
try {
|
|
const view = await readSeatView(row.room_code, {
|
|
playerId: row.player_id,
|
|
tokenHash: row.session_hash,
|
|
});
|
|
return {
|
|
view,
|
|
token: entry.sessionToken,
|
|
entryReceipt: { operationId: entry.operationId, replayed: true },
|
|
};
|
|
} catch (error) {
|
|
if (!(error instanceof RuleError)) throw error;
|
|
throw new RoomEntryError(
|
|
'This room-entry session is no longer active. Use the saved seat recovery kit.',
|
|
'ENTRY_RECEIPT_INVALID',
|
|
);
|
|
}
|
|
}
|
|
async function requireUnusedEntryToken(entry: PreparedEntry | null) {
|
|
if (
|
|
entry &&
|
|
(await db()
|
|
.prepare('SELECT 1 AS present FROM seats WHERE token_hash = ?')
|
|
.bind(entry.sessionHash)
|
|
.first())
|
|
)
|
|
throw new RoomEntryError(
|
|
'This room-entry session token has already been used.',
|
|
'ENTRY_TOKEN_USED',
|
|
);
|
|
}
|
|
export async function createRoom(
|
|
name: string,
|
|
f: FactionId,
|
|
advanced: boolean,
|
|
expansions: string[],
|
|
entryInput?: unknown,
|
|
): Promise<EntryResult & { token: string }> {
|
|
const code = Array.from(
|
|
crypto.getRandomValues(new Uint8Array(8)),
|
|
(n) => 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'[n % 31],
|
|
).join('');
|
|
const id = crypto.randomUUID();
|
|
const g = createGame(code, newPlayer(id, name, f), advanced, expansions);
|
|
const entry = await prepareEntry(entryInput, {
|
|
kind: 'create',
|
|
name: g.players[0].name,
|
|
faction: f,
|
|
advanced,
|
|
expansions: [...g.expansions].sort(),
|
|
});
|
|
try {
|
|
const replay = await replayEntry(entry);
|
|
if (replay) return replay;
|
|
await requireUnusedEntryToken(entry);
|
|
const token =
|
|
entry?.sessionToken ?? crypto.randomUUID() + crypto.randomUUID();
|
|
const tokenHash = entry?.sessionHash ?? (await hash(token));
|
|
await db().batch([
|
|
db()
|
|
.prepare(
|
|
'INSERT INTO rooms(code,state,version,updated_at) VALUES(?,?,0,?)',
|
|
)
|
|
.bind(code, JSON.stringify(g), Date.now()),
|
|
db()
|
|
.prepare(
|
|
'INSERT INTO seats(token_hash,room_code,player_id) VALUES(?,?,?)',
|
|
)
|
|
.bind(tokenHash, code, id),
|
|
...(entry
|
|
? [
|
|
db()
|
|
.prepare(
|
|
'INSERT INTO room_entry_receipts(operation_hash,request_hash,session_hash,room_code,player_id) VALUES(?,?,?,?,?)',
|
|
)
|
|
.bind(
|
|
entry.operationHash,
|
|
entry.requestHash,
|
|
entry.sessionHash,
|
|
code,
|
|
id,
|
|
),
|
|
]
|
|
: []),
|
|
]);
|
|
return {
|
|
token,
|
|
view: await readSeatView(code, { playerId: id, tokenHash }),
|
|
...(entry
|
|
? { entryReceipt: { operationId: entry.operationId, replayed: false } }
|
|
: {}),
|
|
};
|
|
} catch (error) {
|
|
// The competing identical transaction may have won a unique token/operation constraint.
|
|
const replay = await replayEntry(entry);
|
|
if (replay) return replay;
|
|
await requireUnusedEntryToken(entry);
|
|
throw error;
|
|
}
|
|
}
|
|
export async function readRoom(code: string) {
|
|
const row = await db()
|
|
.prepare('SELECT state,version FROM rooms WHERE code = ?')
|
|
.bind(code)
|
|
.first<{ state: string; version: number }>();
|
|
if (!row) throw new RuleError('Room not found. Check your invite code.');
|
|
const g: Game = JSON.parse(row.state);
|
|
g.version = row.version;
|
|
return g;
|
|
}
|
|
export type SeatAuth = { playerId: string; tokenHash: string };
|
|
export async function authenticate(
|
|
code: string,
|
|
token: string,
|
|
): Promise<SeatAuth> {
|
|
if (!token) throw new RuleError('Join this room first.');
|
|
const tokenHash = await hash(token);
|
|
const seat = await db()
|
|
.prepare(
|
|
'SELECT player_id FROM seats WHERE token_hash = ? AND room_code = ? AND revoked = 0',
|
|
)
|
|
.bind(tokenHash, code)
|
|
.first<{ player_id: string }>();
|
|
if (!seat) throw new RuleError('Your seat could not be verified.');
|
|
return { playerId: seat.player_id, tokenHash };
|
|
}
|
|
export async function joinRoom(
|
|
code: string,
|
|
name: string,
|
|
f: FactionId,
|
|
entryInput?: unknown,
|
|
existingAuth?: SeatAuth | null,
|
|
): Promise<EntryResult> {
|
|
const id = crypto.randomUUID();
|
|
const player = newPlayer(id, name, f);
|
|
const entry = await prepareEntry(entryInput, {
|
|
kind: 'join',
|
|
room: code,
|
|
name: player.name,
|
|
faction: f,
|
|
});
|
|
try {
|
|
// Receipt possession must identify the original seat before considering a different browser cookie.
|
|
const replay = await replayEntry(entry);
|
|
if (replay) return replay;
|
|
if (existingAuth) {
|
|
try {
|
|
return {
|
|
view: await readSeatView(code, existingAuth),
|
|
alreadySeated: true,
|
|
};
|
|
} catch (error) {
|
|
if (!(error instanceof RuleError)) throw error;
|
|
}
|
|
}
|
|
await requireUnusedEntryToken(entry);
|
|
const g = await readRoom(code);
|
|
joinGame(g, player);
|
|
const old = g.version;
|
|
g.version++;
|
|
const token =
|
|
entry?.sessionToken ?? crypto.randomUUID() + crypto.randomUUID();
|
|
const tokenHash = entry?.sessionHash ?? (await hash(token));
|
|
const results = await db().batch([
|
|
db()
|
|
.prepare(
|
|
'UPDATE rooms SET state = ?, version = version + 1, updated_at = ? WHERE code = ? AND version = ?',
|
|
)
|
|
.bind(JSON.stringify(g), Date.now(), code, old),
|
|
db()
|
|
.prepare(
|
|
'INSERT INTO seats(token_hash,room_code,player_id) SELECT ?,?,? WHERE changes() = 1',
|
|
)
|
|
.bind(tokenHash, code, id),
|
|
...(entry
|
|
? [
|
|
db()
|
|
.prepare(
|
|
'INSERT INTO room_entry_receipts(operation_hash,request_hash,session_hash,room_code,player_id) SELECT ?,?,?,?,? WHERE changes() = 1',
|
|
)
|
|
.bind(
|
|
entry.operationHash,
|
|
entry.requestHash,
|
|
entry.sessionHash,
|
|
code,
|
|
id,
|
|
),
|
|
]
|
|
: []),
|
|
]);
|
|
if (results[0].meta.changes !== 1)
|
|
throw new RoomEntryError(
|
|
'The table changed. Retry the same room-entry request.',
|
|
'ENTRY_CONFLICT',
|
|
);
|
|
return {
|
|
token,
|
|
view: await readSeatView(code, { playerId: id, tokenHash }),
|
|
...(entry
|
|
? { entryReceipt: { operationId: entry.operationId, replayed: false } }
|
|
: {}),
|
|
};
|
|
} catch (error) {
|
|
const replay = await replayEntry(entry);
|
|
if (replay) return replay;
|
|
await requireUnusedEntryToken(entry);
|
|
throw error;
|
|
}
|
|
}
|
|
export async function act(
|
|
code: string,
|
|
auth: SeatAuth,
|
|
version: number,
|
|
action: Action,
|
|
clock: RoomsClock = roomsClock,
|
|
) {
|
|
const current = await readRoom(code);
|
|
if (current.version !== version)
|
|
throw new RuleError(
|
|
'The table changed. Your view has refreshed; try again.',
|
|
);
|
|
const id = auth.playerId;
|
|
if (
|
|
current.players.find((p) => p.id === id)?.autopilot &&
|
|
action.type !== 'setAutopilot' &&
|
|
action.type !== 'advanceBots'
|
|
)
|
|
throw new RuleError('Take back control before making a game decision.');
|
|
// This is a wake-up request, not another game action. The route schedules the
|
|
// persisted worker; repeated or early wake-ups cannot bypass its deadline.
|
|
if (action.type === 'advanceBots') return readSeatView(code, auth);
|
|
const next = applyAction(current, id, action);
|
|
queueRoomBots(next, current, clock.now());
|
|
next.version++;
|
|
const now = clock.now();
|
|
const update = db()
|
|
.prepare(
|
|
'UPDATE rooms SET state = ?, version = version + 1, updated_at = ? WHERE code = ? AND version = ? AND EXISTS (SELECT 1 FROM seats WHERE room_code = ? AND player_id = ? AND token_hash = ? AND revoked = 0)',
|
|
)
|
|
.bind(
|
|
JSON.stringify(next),
|
|
now,
|
|
code,
|
|
version,
|
|
code,
|
|
auth.playerId,
|
|
auth.tokenHash,
|
|
);
|
|
const results =
|
|
action.type === 'setAutopilot'
|
|
? await db().batch([
|
|
update,
|
|
db()
|
|
.prepare(
|
|
'UPDATE seat_ai_delegations SET revoked_at = ? WHERE room_code = ? AND owner_id = ? AND owner_session_hash = ? AND used_at IS NULL AND revoked_at IS NULL AND changes() = 1',
|
|
)
|
|
.bind(now, code, auth.playerId, auth.tokenHash),
|
|
])
|
|
: [await update.run()];
|
|
if (results[0].meta.changes !== 1)
|
|
throw new RuleError(
|
|
'Another action arrived first. Your view has refreshed; try again.',
|
|
);
|
|
return readSeatView(code, auth);
|
|
}
|
|
|
|
/** Check the credential and load its private state in one database statement. */
|
|
export async function readSeatView(code: string, auth: SeatAuth) {
|
|
const row = await db()
|
|
.prepare(
|
|
`SELECT rooms.state, rooms.version,
|
|
CASE WHEN COUNT(delegations.grant_id) = 0 THEN NULL ELSE
|
|
json_group_array(json_object(
|
|
'grantId', delegations.grant_id,
|
|
'ownerId', delegations.owner_id,
|
|
'delegateId', delegations.delegate_id,
|
|
'difficulty', delegations.difficulty,
|
|
'expiresAt', delegations.expires_at,
|
|
'usedAt', delegations.used_at
|
|
)) END AS seat_ai_delegations
|
|
FROM rooms
|
|
JOIN seats AS viewer ON viewer.room_code = rooms.code
|
|
LEFT JOIN seat_ai_delegations AS delegations
|
|
ON delegations.room_code = rooms.code
|
|
AND (delegations.owner_id = viewer.player_id OR delegations.delegate_id = viewer.player_id)
|
|
AND delegations.revoked_at IS NULL
|
|
AND EXISTS (
|
|
SELECT 1 FROM seats AS owner_session
|
|
WHERE owner_session.room_code = delegations.room_code
|
|
AND owner_session.player_id = delegations.owner_id
|
|
AND owner_session.token_hash = delegations.owner_session_hash
|
|
AND owner_session.revoked = 0
|
|
)
|
|
AND EXISTS (
|
|
SELECT 1 FROM seats AS delegate_session
|
|
WHERE delegate_session.room_code = delegations.room_code
|
|
AND delegate_session.player_id = delegations.delegate_id
|
|
AND delegate_session.token_hash = delegations.delegate_session_hash
|
|
AND delegate_session.revoked = 0
|
|
)
|
|
WHERE rooms.code = ? AND viewer.player_id = ?
|
|
AND viewer.token_hash = ? AND viewer.revoked = 0
|
|
GROUP BY rooms.code, rooms.state, rooms.version`,
|
|
)
|
|
.bind(code, auth.playerId, auth.tokenHash)
|
|
.first<{
|
|
state: string;
|
|
version: number;
|
|
seat_ai_delegations: string | null;
|
|
}>();
|
|
if (!row) throw new RuleError('Your seat could not be verified.');
|
|
const g: Game = JSON.parse(row.state);
|
|
g.version = row.version;
|
|
const view = viewGame(g, auth.playerId) as GameView;
|
|
if (row.seat_ai_delegations) {
|
|
const delegations = JSON.parse(
|
|
row.seat_ai_delegations,
|
|
) as SeatAiDelegationView[];
|
|
if (delegations.length) view.seatAiDelegations = delegations;
|
|
}
|
|
return view;
|
|
}
|
|
|
|
/** Scheduling hint only; the engine remains the authority on whether a choice exists. */
|
|
export function needsAutomaticRoomRecovery(
|
|
state: Pick<Game, 'response' | 'decision' | 'pendingTreacheryDiscard'> & {
|
|
richeseAuction?: unknown;
|
|
battle?: { revealed: boolean; territory: string } | null;
|
|
automaticContinuationPending?: boolean;
|
|
grummanCollection?: { event: string; stage?: string } | null;
|
|
},
|
|
) {
|
|
return (
|
|
state.automaticContinuationPending === true ||
|
|
state.grummanCollection?.stage === 'waiting' ||
|
|
!!state.pendingTreacheryDiscard ||
|
|
!!state.response ||
|
|
!!state.richeseAuction ||
|
|
(!state.decision &&
|
|
!state.response &&
|
|
state.battle?.revealed === true &&
|
|
state.battle.territory.startsWith('homeworld:')) ||
|
|
state.decision?.kind === 'choamMarket' ||
|
|
state.decision?.kind === 'choamAudit' ||
|
|
state.decision?.kind === 'choamAuditPayment' ||
|
|
state.decision?.kind === 'ecazAmbassador' ||
|
|
state.decision?.kind === 'homeworldShipmentGuild' ||
|
|
state.decision?.kind === 'fullPlanRead' ||
|
|
state.decision?.kind === 'handExchange' ||
|
|
state.decision?.kind === 'auctionPayment'
|
|
);
|
|
}
|
|
|
|
/** Persist an old automatic continuation through the same version fence as current actions. */
|
|
export async function continueRoomAutomatic(
|
|
code: string,
|
|
clock: RoomsClock = roomsClock,
|
|
) {
|
|
for (let attempt = 0; attempt < 4; attempt++) {
|
|
const current = await readRoom(code);
|
|
if (
|
|
!needsAutomaticRoomRecovery(current) ||
|
|
(!current.pendingTreacheryDiscard &&
|
|
(current.truthtrance ||
|
|
current.phaseOpening ||
|
|
current.status === 'finished'))
|
|
)
|
|
return;
|
|
const next = normalizeAutomaticGame(current);
|
|
if (JSON.stringify(next) === JSON.stringify(current)) return;
|
|
queueRoomBots(next, current, clock.now());
|
|
next.version = current.version + 1;
|
|
const result = await db()
|
|
.prepare(
|
|
'UPDATE rooms SET state = ?, version = version + 1, updated_at = ? WHERE code = ? AND version = ?',
|
|
)
|
|
.bind(JSON.stringify(next), clock.now(), code, current.version)
|
|
.run();
|
|
if (result.meta.changes === 1) return;
|
|
}
|
|
}
|
|
|
|
/** Resume only persisted AI control; every step is fenced against human control
|
|
* changes. This bounded worker is best-effort, not a durable alarm: if its host
|
|
* stops it, the persisted deadline remains for the next authenticated request.
|
|
*/
|
|
export async function continueRoomBots(
|
|
code: string,
|
|
batches = 16,
|
|
clock: RoomsClock = roomsClock,
|
|
) {
|
|
for (let batch = 0; batch < batches; batch++) {
|
|
let current = await readRoom(code);
|
|
if (!current.botsPending || current.status === 'finished') return;
|
|
const delay = (current.botNextActionAt ?? clock.now()) - clock.now();
|
|
if (delay > 0) {
|
|
await clock.sleep(Math.min(delay, ONLINE_BOT_INTERVAL_MS));
|
|
// A human may have taken control or another worker committed while asleep.
|
|
current = await readRoom(code);
|
|
if (!current.botsPending || current.status === 'finished') return;
|
|
if ((current.botNextActionAt ?? 0) > clock.now()) continue;
|
|
}
|
|
// Offline simulations retain runBots' fast default. Online persistence makes
|
|
// at most one AI decision visible per successful compare-and-swap.
|
|
const next = runBots(current, 1);
|
|
if (next.botsPending)
|
|
next.botNextActionAt = clock.now() + ONLINE_BOT_INTERVAL_MS;
|
|
else delete next.botNextActionAt;
|
|
next.version = current.version + 1;
|
|
const result = await db()
|
|
.prepare(
|
|
'UPDATE rooms SET state = ?, version = version + 1, updated_at = ? WHERE code = ? AND version = ?',
|
|
)
|
|
.bind(JSON.stringify(next), clock.now(), code, current.version)
|
|
.run();
|
|
// A different worker, seat recovery, or Take back control won. Re-read its authority.
|
|
if (result.meta.changes !== 1) continue;
|
|
if (!next.botsPending) return;
|
|
}
|
|
}
|
|
|
|
export class SeatControlError extends RuleError {
|
|
constructor(
|
|
message: string,
|
|
readonly code: string,
|
|
readonly status = 409,
|
|
) {
|
|
super(message);
|
|
}
|
|
}
|
|
function secretHash(value: unknown) {
|
|
if (typeof value !== 'string' || !/^[0-9a-f]{64}$/.test(value))
|
|
throw new SeatControlError(
|
|
'Use a private 256-bit key generated by the recovery tool.',
|
|
'INVALID_CONTROL_REQUEST',
|
|
400,
|
|
);
|
|
return hash(value);
|
|
}
|
|
function controlVersion(value: unknown): asserts value is number {
|
|
if (!Number.isSafeInteger(value) || (value as number) < 0)
|
|
throw new SeatControlError(
|
|
'Provide the current room version.',
|
|
'INVALID_CONTROL_REQUEST',
|
|
400,
|
|
);
|
|
}
|
|
|
|
export const SEAT_AI_DELEGATION_TTL_MS = 24 * 60 * 60 * 1000;
|
|
type ControlClock = Pick<RoomsClock, 'now'>;
|
|
type SeatAiDelegationRow = Readonly<{
|
|
room_code: string;
|
|
owner_id: string;
|
|
grant_id: string;
|
|
delegate_id: string;
|
|
difficulty: string;
|
|
owner_session_hash: string;
|
|
delegate_session_hash: string;
|
|
expires_at: number;
|
|
used_at: number | null;
|
|
revoked_at: number | null;
|
|
}>;
|
|
const invalidDelegation = (message: string, code = 'INVALID_AI_DELEGATION') =>
|
|
new SeatControlError(
|
|
message,
|
|
code,
|
|
code === 'AI_DELEGATION_EXPIRED' ? 410 : 409,
|
|
);
|
|
async function seatAiDelegation(grantId: string) {
|
|
return db()
|
|
.prepare(
|
|
'SELECT room_code,owner_id,grant_id,delegate_id,difficulty,owner_session_hash,delegate_session_hash,expires_at,used_at,revoked_at FROM seat_ai_delegations WHERE grant_id = ?',
|
|
)
|
|
.bind(grantId)
|
|
.first<SeatAiDelegationRow>();
|
|
}
|
|
async function delegationSessionsActive(row: SeatAiDelegationRow) {
|
|
return !!(await db()
|
|
.prepare(
|
|
`SELECT 1 AS present
|
|
FROM seats AS owner_session
|
|
JOIN seats AS delegate_session ON delegate_session.room_code = owner_session.room_code
|
|
WHERE owner_session.room_code = ?
|
|
AND owner_session.player_id = ? AND owner_session.token_hash = ? AND owner_session.revoked = 0
|
|
AND delegate_session.player_id = ? AND delegate_session.token_hash = ? AND delegate_session.revoked = 0`,
|
|
)
|
|
.bind(
|
|
row.room_code,
|
|
row.owner_id,
|
|
row.owner_session_hash,
|
|
row.delegate_id,
|
|
row.delegate_session_hash,
|
|
)
|
|
.first());
|
|
}
|
|
|
|
export async function setSeatAiDelegate(
|
|
code: string,
|
|
auth: SeatAuth,
|
|
version: unknown,
|
|
input: SetSeatAiDelegateInput,
|
|
clock: ControlClock = roomsClock,
|
|
) {
|
|
controlVersion(version);
|
|
if (!validSetSeatAiDelegateInput(input))
|
|
throw invalidDelegation(
|
|
'Choose one human seat, an AI difficulty and a fresh delegation identifier.',
|
|
);
|
|
const existing = await seatAiDelegation(input.grantId);
|
|
if (existing) {
|
|
const same =
|
|
existing.room_code === code &&
|
|
existing.owner_id === auth.playerId &&
|
|
existing.delegate_id === input.delegateId &&
|
|
existing.difficulty === input.difficulty &&
|
|
existing.owner_session_hash === auth.tokenHash;
|
|
if (!same)
|
|
throw invalidDelegation(
|
|
'This delegation identifier is already bound to different consent.',
|
|
'AI_DELEGATION_CHANGED',
|
|
);
|
|
if (!(await delegationSessionsActive(existing)))
|
|
throw invalidDelegation(
|
|
'A seat credential changed after this delegation was created.',
|
|
'AI_DELEGATION_INVALIDATED',
|
|
);
|
|
return { view: await readSeatView(code, auth), replayed: true };
|
|
}
|
|
const current = await readRoom(code);
|
|
if (current.version !== version)
|
|
throw invalidDelegation(
|
|
'The table changed. Reconnect before saving this consent.',
|
|
'STALE_VERSION',
|
|
);
|
|
const owner = current.players.find((player) => player.id === auth.playerId);
|
|
const delegate = current.players.find(
|
|
(player) => player.id === input.delegateId,
|
|
);
|
|
if (
|
|
(current.status !== 'setup' && current.status !== 'playing') ||
|
|
!owner ||
|
|
owner.bot ||
|
|
owner.autopilot ||
|
|
!delegate ||
|
|
delegate.id === owner.id ||
|
|
delegate.bot
|
|
)
|
|
throw invalidDelegation(
|
|
'A started human seat without active AI may authorize one different human seat.',
|
|
'AI_DELEGATION_UNAVAILABLE',
|
|
);
|
|
const delegateSession = await db()
|
|
.prepare(
|
|
'SELECT MIN(token_hash) AS token_hash, COUNT(*) AS count FROM seats WHERE room_code = ? AND player_id = ? AND revoked = 0',
|
|
)
|
|
.bind(code, delegate.id)
|
|
.first<{ token_hash: string | null; count: number }>();
|
|
if (!delegateSession?.token_hash || delegateSession.count !== 1)
|
|
throw invalidDelegation(
|
|
'The delegate must have one current authenticated session.',
|
|
'AI_DELEGATION_UNAVAILABLE',
|
|
);
|
|
const now = clock.now();
|
|
const expiresAt = now + SEAT_AI_DELEGATION_TTL_MS;
|
|
const results = await db().batch([
|
|
db()
|
|
.prepare(
|
|
`UPDATE rooms SET version = version + 1, updated_at = ?
|
|
WHERE code = ? AND version = ?
|
|
AND EXISTS (SELECT 1 FROM seats WHERE room_code = ? AND player_id = ? AND token_hash = ? AND revoked = 0)
|
|
AND EXISTS (SELECT 1 FROM seats WHERE room_code = ? AND player_id = ? AND token_hash = ? AND revoked = 0)
|
|
AND NOT EXISTS (SELECT 1 FROM seat_ai_delegations WHERE grant_id = ?)`,
|
|
)
|
|
.bind(
|
|
now,
|
|
code,
|
|
version,
|
|
code,
|
|
owner.id,
|
|
auth.tokenHash,
|
|
code,
|
|
delegate.id,
|
|
delegateSession.token_hash,
|
|
input.grantId,
|
|
),
|
|
db()
|
|
.prepare(
|
|
`INSERT INTO seat_ai_delegations(
|
|
room_code,owner_id,grant_id,delegate_id,difficulty,
|
|
owner_session_hash,delegate_session_hash,expires_at,used_at,revoked_at
|
|
)
|
|
SELECT ?,?,?,?,?,?,?,?,NULL,NULL WHERE changes() = 1`,
|
|
)
|
|
.bind(
|
|
code,
|
|
owner.id,
|
|
input.grantId,
|
|
delegate.id,
|
|
input.difficulty,
|
|
auth.tokenHash,
|
|
delegateSession.token_hash,
|
|
expiresAt,
|
|
),
|
|
db()
|
|
.prepare(
|
|
`UPDATE seat_ai_delegations SET revoked_at = ?
|
|
WHERE room_code = ? AND owner_id = ? AND grant_id != ?
|
|
AND revoked_at IS NULL
|
|
AND changes() = 1
|
|
AND EXISTS (
|
|
SELECT 1 FROM seat_ai_delegations AS current_grant
|
|
WHERE current_grant.room_code = ? AND current_grant.owner_id = ?
|
|
AND current_grant.grant_id = ?
|
|
)`,
|
|
)
|
|
.bind(now, code, owner.id, input.grantId, code, owner.id, input.grantId),
|
|
]);
|
|
if (results[0].meta.changes !== 1 || results[1].meta.changes !== 1) {
|
|
const won = await seatAiDelegation(input.grantId);
|
|
if (
|
|
won &&
|
|
won.room_code === code &&
|
|
won.owner_id === owner.id &&
|
|
won.delegate_id === delegate.id &&
|
|
won.difficulty === input.difficulty &&
|
|
won.owner_session_hash === auth.tokenHash &&
|
|
won.delegate_session_hash === delegateSession.token_hash &&
|
|
(await delegationSessionsActive(won))
|
|
)
|
|
return { view: await readSeatView(code, auth), replayed: true };
|
|
throw invalidDelegation(
|
|
'The table, owner or delegate changed. Reconnect before saving this consent.',
|
|
'STALE_VERSION',
|
|
);
|
|
}
|
|
return { view: await readSeatView(code, auth), replayed: false };
|
|
}
|
|
|
|
export async function revokeSeatAiDelegate(
|
|
code: string,
|
|
auth: SeatAuth,
|
|
version: unknown,
|
|
grantId: unknown,
|
|
clock: ControlClock = roomsClock,
|
|
) {
|
|
controlVersion(version);
|
|
if (!validSeatAiGrantId(grantId))
|
|
throw invalidDelegation('Choose the current AI delegation identifier.');
|
|
const existing = await seatAiDelegation(grantId);
|
|
if (
|
|
existing?.room_code === code &&
|
|
existing.owner_id === auth.playerId &&
|
|
existing.owner_session_hash === auth.tokenHash &&
|
|
(existing.revoked_at !== null || existing.used_at !== null)
|
|
)
|
|
return { view: await readSeatView(code, auth), replayed: true };
|
|
if (
|
|
!existing ||
|
|
existing.room_code !== code ||
|
|
existing.owner_id !== auth.playerId
|
|
)
|
|
throw invalidDelegation(
|
|
'This AI delegation is no longer current for your seat.',
|
|
'AI_DELEGATION_CHANGED',
|
|
);
|
|
const current = await readRoom(code);
|
|
if (current.version !== version)
|
|
throw invalidDelegation(
|
|
'The table changed. Reconnect before revoking this consent.',
|
|
'STALE_VERSION',
|
|
);
|
|
const now = clock.now();
|
|
const results = await db().batch([
|
|
db()
|
|
.prepare(
|
|
`UPDATE rooms SET version = version + 1, updated_at = ?
|
|
WHERE code = ? AND version = ?
|
|
AND EXISTS (SELECT 1 FROM seats WHERE room_code = ? AND player_id = ? AND token_hash = ? AND revoked = 0)
|
|
AND EXISTS (
|
|
SELECT 1 FROM seat_ai_delegations AS consent
|
|
JOIN seats AS delegate_session
|
|
ON delegate_session.room_code = consent.room_code
|
|
AND delegate_session.player_id = consent.delegate_id
|
|
AND delegate_session.token_hash = consent.delegate_session_hash
|
|
AND delegate_session.revoked = 0
|
|
WHERE consent.room_code = ? AND consent.owner_id = ? AND consent.grant_id = ?
|
|
AND consent.owner_session_hash = ?
|
|
AND consent.used_at IS NULL AND consent.revoked_at IS NULL
|
|
)`,
|
|
)
|
|
.bind(
|
|
now,
|
|
code,
|
|
version,
|
|
code,
|
|
auth.playerId,
|
|
auth.tokenHash,
|
|
code,
|
|
auth.playerId,
|
|
grantId,
|
|
auth.tokenHash,
|
|
),
|
|
db()
|
|
.prepare(
|
|
`UPDATE seat_ai_delegations SET revoked_at = ?
|
|
WHERE room_code = ? AND owner_id = ? AND grant_id = ?
|
|
AND owner_session_hash = ? AND used_at IS NULL AND revoked_at IS NULL
|
|
AND changes() = 1`,
|
|
)
|
|
.bind(now, code, auth.playerId, grantId, auth.tokenHash),
|
|
]);
|
|
if (results[0].meta.changes !== 1 || results[1].meta.changes !== 1)
|
|
throw invalidDelegation(
|
|
'The table, owner, delegate or consent changed. Reconnect before revoking it.',
|
|
'STALE_VERSION',
|
|
);
|
|
return { view: await readSeatView(code, auth), replayed: false };
|
|
}
|
|
|
|
export async function useSeatAiDelegate(
|
|
code: string,
|
|
auth: SeatAuth,
|
|
version: unknown,
|
|
input: UseSeatAiDelegateInput,
|
|
clock: RoomsClock = roomsClock,
|
|
): Promise<{ view: GameView; replayed?: boolean }> {
|
|
controlVersion(version);
|
|
if (!validUseSeatAiDelegateInput(input))
|
|
throw invalidDelegation('Choose the current owner and AI delegation.');
|
|
const consent = await seatAiDelegation(input.grantId);
|
|
if (
|
|
!consent ||
|
|
consent.room_code !== code ||
|
|
consent.owner_id !== input.ownerId ||
|
|
consent.delegate_id !== auth.playerId ||
|
|
consent.delegate_session_hash !== auth.tokenHash
|
|
)
|
|
throw invalidDelegation(
|
|
'This AI delegation is not available to this seat.',
|
|
'AI_DELEGATION_CHANGED',
|
|
);
|
|
if (!(await delegationSessionsActive(consent)))
|
|
throw invalidDelegation(
|
|
'A seat credential changed after this delegation was created.',
|
|
'AI_DELEGATION_INVALIDATED',
|
|
);
|
|
if (consent.used_at !== null)
|
|
return { view: await readSeatView(code, auth), replayed: true };
|
|
if (consent.revoked_at !== null)
|
|
throw invalidDelegation(
|
|
'This AI delegation was revoked or replaced.',
|
|
'AI_DELEGATION_CHANGED',
|
|
);
|
|
const now = clock.now();
|
|
if (consent.expires_at <= now)
|
|
throw invalidDelegation(
|
|
'This AI delegation has expired.',
|
|
'AI_DELEGATION_EXPIRED',
|
|
);
|
|
const current = await readRoom(code);
|
|
if (current.version !== version)
|
|
throw invalidDelegation(
|
|
'The table changed. Reconnect before activating this consent.',
|
|
'STALE_VERSION',
|
|
);
|
|
const owner = current.players.find(
|
|
(player) => player.id === consent.owner_id,
|
|
);
|
|
const delegate = current.players.find(
|
|
(player) => player.id === consent.delegate_id,
|
|
);
|
|
if (
|
|
(current.status !== 'setup' && current.status !== 'playing') ||
|
|
!owner ||
|
|
owner.bot ||
|
|
owner.autopilot ||
|
|
!delegate ||
|
|
delegate.bot
|
|
)
|
|
throw invalidDelegation(
|
|
'The owner is no longer available for this delegated AI activation.',
|
|
'AI_DELEGATION_UNAVAILABLE',
|
|
);
|
|
const next = applyAction(current, owner.id, {
|
|
type: 'setAutopilot',
|
|
difficulty: consent.difficulty as Difficulty,
|
|
});
|
|
const entry = next.log.at(-1);
|
|
if (entry)
|
|
entry.text = `${delegate.name} activated ${consent.difficulty} AI for ${owner.name} using their one-use delegation.`;
|
|
queueRoomBots(next, current, now);
|
|
next.version = current.version + 1;
|
|
const results = await db().batch([
|
|
db()
|
|
.prepare(
|
|
`UPDATE rooms SET state = ?, version = version + 1, updated_at = ?
|
|
WHERE code = ? AND version = ?
|
|
AND EXISTS (
|
|
SELECT 1 FROM seat_ai_delegations AS consent
|
|
JOIN seats AS owner_session
|
|
ON owner_session.room_code = consent.room_code
|
|
AND owner_session.player_id = consent.owner_id
|
|
AND owner_session.token_hash = consent.owner_session_hash
|
|
AND owner_session.revoked = 0
|
|
JOIN seats AS delegate_session
|
|
ON delegate_session.room_code = consent.room_code
|
|
AND delegate_session.player_id = consent.delegate_id
|
|
AND delegate_session.token_hash = consent.delegate_session_hash
|
|
AND delegate_session.revoked = 0
|
|
WHERE consent.room_code = ? AND consent.owner_id = ?
|
|
AND consent.delegate_id = ? AND consent.grant_id = ?
|
|
AND consent.owner_session_hash = ? AND consent.delegate_session_hash = ?
|
|
AND consent.expires_at > ? AND consent.used_at IS NULL
|
|
AND consent.revoked_at IS NULL
|
|
)`,
|
|
)
|
|
.bind(
|
|
JSON.stringify(next),
|
|
now,
|
|
code,
|
|
version,
|
|
code,
|
|
consent.owner_id,
|
|
consent.delegate_id,
|
|
consent.grant_id,
|
|
consent.owner_session_hash,
|
|
consent.delegate_session_hash,
|
|
now,
|
|
),
|
|
db()
|
|
.prepare(
|
|
`UPDATE seat_ai_delegations SET used_at = ?
|
|
WHERE room_code = ? AND owner_id = ? AND delegate_id = ? AND grant_id = ?
|
|
AND owner_session_hash = ? AND delegate_session_hash = ?
|
|
AND expires_at > ? AND used_at IS NULL AND revoked_at IS NULL
|
|
AND changes() = 1`,
|
|
)
|
|
.bind(
|
|
now,
|
|
code,
|
|
consent.owner_id,
|
|
consent.delegate_id,
|
|
consent.grant_id,
|
|
consent.owner_session_hash,
|
|
consent.delegate_session_hash,
|
|
now,
|
|
),
|
|
]);
|
|
if (results[0].meta.changes !== 1 || results[1].meta.changes !== 1) {
|
|
const won = await seatAiDelegation(input.grantId);
|
|
if (
|
|
won?.used_at !== null &&
|
|
won?.room_code === code &&
|
|
won?.owner_id === input.ownerId &&
|
|
won?.delegate_id === auth.playerId &&
|
|
won?.delegate_session_hash === auth.tokenHash &&
|
|
(await delegationSessionsActive(won))
|
|
)
|
|
return { view: await readSeatView(code, auth), replayed: true };
|
|
throw invalidDelegation(
|
|
'The table, owner, delegate or consent changed. Retry the exact activation after reconnecting.',
|
|
'STALE_VERSION',
|
|
);
|
|
}
|
|
return { view: await readSeatView(code, auth), replayed: false };
|
|
}
|
|
|
|
export async function setRecoveryKey(
|
|
code: string,
|
|
auth: SeatAuth,
|
|
version: unknown,
|
|
secret: unknown,
|
|
) {
|
|
controlVersion(version);
|
|
const recoveryHash = await secretHash(secret);
|
|
if (recoveryHash === auth.tokenHash)
|
|
throw new SeatControlError(
|
|
'The recovery key must differ from the session credential.',
|
|
'INVALID_CONTROL_REQUEST',
|
|
400,
|
|
);
|
|
const results = await db().batch([
|
|
db()
|
|
.prepare(
|
|
'UPDATE rooms SET version = version + 1, updated_at = ? WHERE code = ? AND version = ? AND EXISTS (SELECT 1 FROM seats WHERE room_code = ? AND player_id = ? AND token_hash = ? AND revoked = 0)',
|
|
)
|
|
.bind(Date.now(), code, version, code, auth.playerId, auth.tokenHash),
|
|
db()
|
|
.prepare(
|
|
'INSERT INTO seat_recovery_keys(room_code,player_id,recovery_hash) SELECT ?,?,? WHERE changes() = 1 ON CONFLICT(room_code,player_id) DO UPDATE SET recovery_hash = excluded.recovery_hash, current_operation_hash = NULL',
|
|
)
|
|
.bind(code, auth.playerId, recoveryHash),
|
|
]);
|
|
if (results[0].meta.changes !== 1)
|
|
throw new SeatControlError(
|
|
'The table or seat changed. Reconnect before saving the recovery key.',
|
|
'STALE_VERSION',
|
|
);
|
|
return {
|
|
view: await readSeatView(code, auth),
|
|
recoveryConfigured: true as const,
|
|
};
|
|
}
|
|
|
|
export async function recoverSeat(
|
|
code: string,
|
|
input: {
|
|
playerId: unknown;
|
|
recoverySecret: unknown;
|
|
operationId: unknown;
|
|
newSessionToken: unknown;
|
|
},
|
|
) {
|
|
if (
|
|
typeof input.playerId !== 'string' ||
|
|
!input.playerId ||
|
|
input.playerId.length > 100 ||
|
|
typeof input.operationId !== 'string' ||
|
|
!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(
|
|
input.operationId,
|
|
)
|
|
)
|
|
throw new SeatControlError(
|
|
'Provide the player ID and a recovery operation ID.',
|
|
'INVALID_CONTROL_REQUEST',
|
|
400,
|
|
);
|
|
const playerId = input.playerId;
|
|
const recoveryHash = await secretHash(input.recoverySecret);
|
|
const sessionHash = await secretHash(input.newSessionToken);
|
|
if (sessionHash === recoveryHash)
|
|
throw new SeatControlError(
|
|
'The new session credential must differ from the recovery key.',
|
|
'INVALID_SESSION_TOKEN',
|
|
);
|
|
const operationHash = await hash(input.operationId);
|
|
const key = await db()
|
|
.prepare(
|
|
'SELECT recovery_hash FROM seat_recovery_keys WHERE room_code = ? AND player_id = ?',
|
|
)
|
|
.bind(code, playerId)
|
|
.first<{ recovery_hash: string }>();
|
|
if (!key || key.recovery_hash !== recoveryHash)
|
|
throw new SeatControlError(
|
|
'The recovery proof could not be verified.',
|
|
'INVALID_RECOVERY_PROOF',
|
|
);
|
|
const receipt = await db()
|
|
.prepare(
|
|
'SELECT recovery_hash, session_hash FROM seat_recovery_receipts WHERE room_code = ? AND player_id = ? AND operation_hash = ?',
|
|
)
|
|
.bind(code, playerId, operationHash)
|
|
.first<{ recovery_hash: string; session_hash: string }>();
|
|
if (receipt) {
|
|
if (
|
|
receipt.recovery_hash !== recoveryHash ||
|
|
receipt.session_hash !== sessionHash
|
|
)
|
|
throw new SeatControlError(
|
|
'This recovery operation cannot be reused with a different proof or credential.',
|
|
'RECOVERY_RECEIPT_INVALID',
|
|
);
|
|
// A replaced key or later rotation invalidates the receipt, including races after the first lookup.
|
|
const row = await db()
|
|
.prepare(
|
|
'SELECT rooms.state, rooms.version FROM rooms JOIN seats ON seats.room_code = rooms.code JOIN seat_recovery_keys AS keys ON keys.room_code = seats.room_code AND keys.player_id = seats.player_id WHERE rooms.code = ? AND seats.player_id = ? AND seats.token_hash = ? AND seats.revoked = 0 AND keys.recovery_hash = ? AND keys.current_operation_hash = ?',
|
|
)
|
|
.bind(code, playerId, sessionHash, recoveryHash, operationHash)
|
|
.first<{ state: string; version: number }>();
|
|
if (!row)
|
|
throw new SeatControlError(
|
|
'This recovery receipt is no longer active.',
|
|
'RECOVERY_RECEIPT_INVALID',
|
|
);
|
|
const g: Game = JSON.parse(row.state);
|
|
g.version = row.version;
|
|
return {
|
|
view: viewGame(g, playerId),
|
|
token: input.newSessionToken as string,
|
|
recovered: true as const,
|
|
replayed: true,
|
|
};
|
|
}
|
|
if (
|
|
await db()
|
|
.prepare('SELECT 1 AS present FROM seats WHERE token_hash = ?')
|
|
.bind(sessionHash)
|
|
.first()
|
|
)
|
|
throw new SeatControlError(
|
|
'Generate a new session credential; this token has already been used.',
|
|
'INVALID_SESSION_TOKEN',
|
|
);
|
|
const current = await readRoom(code);
|
|
const results = await db().batch([
|
|
db()
|
|
.prepare(
|
|
'UPDATE rooms SET version = version + 1, updated_at = ? WHERE code = ? AND version = ? AND EXISTS (SELECT 1 FROM seat_recovery_keys WHERE room_code = ? AND player_id = ? AND recovery_hash = ?) AND EXISTS (SELECT 1 FROM seats WHERE room_code = ? AND player_id = ? AND revoked = 0) AND NOT EXISTS (SELECT 1 FROM seat_recovery_receipts WHERE room_code = ? AND player_id = ? AND operation_hash = ?) AND NOT EXISTS (SELECT 1 FROM seats WHERE token_hash = ?)',
|
|
)
|
|
.bind(
|
|
Date.now(),
|
|
code,
|
|
current.version,
|
|
code,
|
|
playerId,
|
|
recoveryHash,
|
|
code,
|
|
playerId,
|
|
code,
|
|
playerId,
|
|
operationHash,
|
|
sessionHash,
|
|
),
|
|
db()
|
|
.prepare(
|
|
'INSERT INTO seat_recovery_receipts(room_code,player_id,operation_hash,recovery_hash,session_hash) SELECT ?,?,?,?,? WHERE changes() = 1',
|
|
)
|
|
.bind(code, playerId, operationHash, recoveryHash, sessionHash),
|
|
db()
|
|
.prepare(
|
|
'UPDATE seats SET revoked = 1 WHERE room_code = ? AND player_id = ? AND revoked = 0 AND changes() = 1',
|
|
)
|
|
.bind(code, playerId),
|
|
db()
|
|
.prepare(
|
|
'INSERT INTO seats(token_hash,room_code,player_id) SELECT ?,?,? WHERE changes() > 0',
|
|
)
|
|
.bind(sessionHash, code, playerId),
|
|
db()
|
|
.prepare(
|
|
'UPDATE seat_recovery_keys SET current_operation_hash = ? WHERE room_code = ? AND player_id = ? AND changes() = 1',
|
|
)
|
|
.bind(operationHash, code, playerId),
|
|
]);
|
|
if (results[0].meta.changes !== 1) {
|
|
// A concurrent exact request may have completed: retrying the same proof is safe.
|
|
throw new SeatControlError(
|
|
'The table or recovery operation changed. Retry the exact recovery request.',
|
|
'STALE_VERSION',
|
|
);
|
|
}
|
|
return {
|
|
view: await readSeatView(code, { playerId, tokenHash: sessionHash }),
|
|
token: input.newSessionToken as string,
|
|
recovered: true as const,
|
|
replayed: false,
|
|
};
|
|
}
|
|
|
|
export const SEAT_HANDOVER_TTL_MS = 24 * 60 * 60 * 1000;
|
|
const controlUuid = (value: unknown, message: string) => {
|
|
if (
|
|
typeof value !== 'string' ||
|
|
!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(
|
|
value,
|
|
)
|
|
)
|
|
throw new SeatControlError(message, 'INVALID_HANDOVER_REQUEST', 400);
|
|
return value;
|
|
};
|
|
type HandoverClock = Pick<RoomsClock, 'now'>;
|
|
type HandoverOfferRow = Readonly<{
|
|
offer_hash: string;
|
|
secret_hash: string;
|
|
issuer_session_hash: string;
|
|
expires_at: number;
|
|
claim_operation_hash: string | null;
|
|
session_hash: string | null;
|
|
claimed_at: number | null;
|
|
}>;
|
|
async function handoverOffer(code: string, playerId: string) {
|
|
return db()
|
|
.prepare(
|
|
'SELECT offer_hash,secret_hash,issuer_session_hash,expires_at,claim_operation_hash,session_hash,claimed_at FROM seat_handover_offers WHERE room_code = ? AND player_id = ?',
|
|
)
|
|
.bind(code, playerId)
|
|
.first<HandoverOfferRow>();
|
|
}
|
|
async function replayCreatedHandover(
|
|
code: string,
|
|
auth: SeatAuth,
|
|
offerHash: string,
|
|
secretHashValue: string,
|
|
now: number,
|
|
) {
|
|
const row = await db()
|
|
.prepare(
|
|
'SELECT offers.expires_at FROM seat_handover_offers AS offers JOIN seats ON seats.room_code = offers.room_code AND seats.player_id = offers.player_id AND seats.token_hash = offers.issuer_session_hash WHERE offers.room_code = ? AND offers.player_id = ? AND offers.offer_hash = ? AND offers.secret_hash = ? AND offers.issuer_session_hash = ? AND offers.claim_operation_hash IS NULL AND seats.revoked = 0',
|
|
)
|
|
.bind(code, auth.playerId, offerHash, secretHashValue, auth.tokenHash)
|
|
.first<{ expires_at: number }>();
|
|
if (!row) return null;
|
|
if (row.expires_at <= now)
|
|
throw new SeatControlError(
|
|
'This handover offer has expired. Create a new private offer.',
|
|
'HANDOVER_EXPIRED',
|
|
410,
|
|
);
|
|
return {
|
|
view: await readSeatView(code, auth),
|
|
expiresAt: row.expires_at,
|
|
replayed: true,
|
|
};
|
|
}
|
|
|
|
export async function createSeatHandover(
|
|
code: string,
|
|
auth: SeatAuth,
|
|
version: unknown,
|
|
input: { offerId: unknown; handoverSecret: unknown },
|
|
clock: HandoverClock = roomsClock,
|
|
) {
|
|
controlVersion(version);
|
|
const offerId = controlUuid(
|
|
input.offerId,
|
|
'Generate a new handover offer identifier.',
|
|
);
|
|
const [offerHash, handoverHash] = await Promise.all([
|
|
hash(offerId),
|
|
secretHash(input.handoverSecret),
|
|
]);
|
|
if (handoverHash === auth.tokenHash)
|
|
throw new SeatControlError(
|
|
'The handover secret must differ from the current session credential.',
|
|
'INVALID_HANDOVER_REQUEST',
|
|
400,
|
|
);
|
|
const now = clock.now();
|
|
const replay = await replayCreatedHandover(
|
|
code,
|
|
auth,
|
|
offerHash,
|
|
handoverHash,
|
|
now,
|
|
);
|
|
if (replay) return replay;
|
|
const expiresAt = now + SEAT_HANDOVER_TTL_MS;
|
|
try {
|
|
const results = await db().batch([
|
|
db()
|
|
.prepare(
|
|
'UPDATE rooms SET version = version + 1, updated_at = ? WHERE code = ? AND version = ? AND EXISTS (SELECT 1 FROM seats WHERE room_code = ? AND player_id = ? AND token_hash = ? AND revoked = 0) AND NOT EXISTS (SELECT 1 FROM seat_handover_offers WHERE offer_hash = ? AND (room_code != ? OR player_id != ?))',
|
|
)
|
|
.bind(
|
|
now,
|
|
code,
|
|
version,
|
|
code,
|
|
auth.playerId,
|
|
auth.tokenHash,
|
|
offerHash,
|
|
code,
|
|
auth.playerId,
|
|
),
|
|
db()
|
|
.prepare(
|
|
'INSERT INTO seat_handover_offers(room_code,player_id,offer_hash,secret_hash,issuer_session_hash,expires_at,claim_operation_hash,session_hash,claimed_at) SELECT ?,?,?,?,?,?,NULL,NULL,NULL WHERE changes() = 1 ON CONFLICT(room_code,player_id) DO UPDATE SET offer_hash = excluded.offer_hash, secret_hash = excluded.secret_hash, issuer_session_hash = excluded.issuer_session_hash, expires_at = excluded.expires_at, claim_operation_hash = NULL, session_hash = NULL, claimed_at = NULL',
|
|
)
|
|
.bind(
|
|
code,
|
|
auth.playerId,
|
|
offerHash,
|
|
handoverHash,
|
|
auth.tokenHash,
|
|
expiresAt,
|
|
),
|
|
]);
|
|
if (results[0].meta.changes !== 1) {
|
|
const won = await replayCreatedHandover(
|
|
code,
|
|
auth,
|
|
offerHash,
|
|
handoverHash,
|
|
now,
|
|
);
|
|
if (won) return won;
|
|
throw new SeatControlError(
|
|
'The table, seat, or handover offer changed. Reconnect before creating the offer.',
|
|
'STALE_VERSION',
|
|
);
|
|
}
|
|
} catch (error) {
|
|
const won = await replayCreatedHandover(
|
|
code,
|
|
auth,
|
|
offerHash,
|
|
handoverHash,
|
|
now,
|
|
);
|
|
if (won) return won;
|
|
throw error;
|
|
}
|
|
return {
|
|
view: await readSeatView(code, auth),
|
|
expiresAt,
|
|
replayed: false,
|
|
};
|
|
}
|
|
|
|
export async function revokeSeatHandover(
|
|
code: string,
|
|
auth: SeatAuth,
|
|
version: unknown,
|
|
offerIdValue: unknown,
|
|
clock: HandoverClock = roomsClock,
|
|
) {
|
|
controlVersion(version);
|
|
const offerHash = await hash(
|
|
controlUuid(offerIdValue, 'Choose the handover offer to revoke.'),
|
|
);
|
|
const existing = await handoverOffer(code, auth.playerId);
|
|
if (!existing || existing.claim_operation_hash !== null) {
|
|
return {
|
|
view: await readSeatView(code, auth),
|
|
revoked: true as const,
|
|
replayed: true,
|
|
};
|
|
}
|
|
if (existing.offer_hash !== offerHash)
|
|
throw new SeatControlError(
|
|
'A different handover offer is now current for this seat.',
|
|
'HANDOVER_CHANGED',
|
|
);
|
|
const results = await db().batch([
|
|
db()
|
|
.prepare(
|
|
'UPDATE rooms SET version = version + 1, updated_at = ? WHERE code = ? AND version = ? AND EXISTS (SELECT 1 FROM seats WHERE room_code = ? AND player_id = ? AND token_hash = ? AND revoked = 0) AND EXISTS (SELECT 1 FROM seat_handover_offers WHERE room_code = ? AND player_id = ? AND offer_hash = ? AND claim_operation_hash IS NULL)',
|
|
)
|
|
.bind(
|
|
clock.now(),
|
|
code,
|
|
version,
|
|
code,
|
|
auth.playerId,
|
|
auth.tokenHash,
|
|
code,
|
|
auth.playerId,
|
|
offerHash,
|
|
),
|
|
db()
|
|
.prepare(
|
|
'DELETE FROM seat_handover_offers WHERE room_code = ? AND player_id = ? AND offer_hash = ? AND claim_operation_hash IS NULL AND changes() = 1',
|
|
)
|
|
.bind(code, auth.playerId, offerHash),
|
|
]);
|
|
if (results[0].meta.changes !== 1) {
|
|
const current = await handoverOffer(code, auth.playerId);
|
|
if (!current)
|
|
return {
|
|
view: await readSeatView(code, auth),
|
|
revoked: true as const,
|
|
replayed: true,
|
|
};
|
|
throw new SeatControlError(
|
|
'The table, seat, or handover offer changed. Reconnect before revoking it.',
|
|
current.offer_hash === offerHash ? 'STALE_VERSION' : 'HANDOVER_CHANGED',
|
|
);
|
|
}
|
|
return {
|
|
view: await readSeatView(code, auth),
|
|
revoked: true as const,
|
|
replayed: false,
|
|
};
|
|
}
|
|
|
|
type HandoverClaimReceiptRow = Readonly<{
|
|
offer_hash: string;
|
|
secret_hash: string;
|
|
session_hash: string;
|
|
}>;
|
|
async function handoverClaimReceipt(
|
|
code: string,
|
|
playerId: string,
|
|
operationHash: string,
|
|
) {
|
|
return db()
|
|
.prepare(
|
|
'SELECT offer_hash,secret_hash,session_hash FROM seat_handover_claim_receipts WHERE room_code = ? AND player_id = ? AND operation_hash = ?',
|
|
)
|
|
.bind(code, playerId, operationHash)
|
|
.first<HandoverClaimReceiptRow>();
|
|
}
|
|
async function replayClaimedHandover(
|
|
code: string,
|
|
playerId: string,
|
|
offerHash: string,
|
|
secretHashValue: string,
|
|
operationHash: string,
|
|
sessionHash: string,
|
|
newSessionToken: unknown,
|
|
) {
|
|
const row = await db()
|
|
.prepare(
|
|
'SELECT rooms.state,rooms.version FROM rooms JOIN seat_handover_claim_receipts AS receipts ON receipts.room_code = rooms.code JOIN seats ON seats.room_code = receipts.room_code AND seats.player_id = receipts.player_id WHERE rooms.code = ? AND receipts.player_id = ? AND receipts.offer_hash = ? AND receipts.secret_hash = ? AND receipts.operation_hash = ? AND receipts.session_hash = ? AND seats.token_hash = ? AND seats.revoked = 0',
|
|
)
|
|
.bind(
|
|
code,
|
|
playerId,
|
|
offerHash,
|
|
secretHashValue,
|
|
operationHash,
|
|
sessionHash,
|
|
sessionHash,
|
|
)
|
|
.first<{ state: string; version: number }>();
|
|
if (!row) return null;
|
|
const game: Game = JSON.parse(row.state);
|
|
game.version = row.version;
|
|
return {
|
|
view: viewGame(game, playerId),
|
|
token: newSessionToken as string,
|
|
transferred: true as const,
|
|
replayed: true,
|
|
};
|
|
}
|
|
|
|
export async function claimSeatHandover(
|
|
code: string,
|
|
input: {
|
|
playerId: unknown;
|
|
offerId: unknown;
|
|
handoverSecret: unknown;
|
|
operationId: unknown;
|
|
newSessionToken: unknown;
|
|
},
|
|
clock: HandoverClock = roomsClock,
|
|
) {
|
|
if (
|
|
typeof input.playerId !== 'string' ||
|
|
!input.playerId ||
|
|
input.playerId.length > 100
|
|
)
|
|
throw new SeatControlError(
|
|
'Provide a valid private handover kit.',
|
|
'INVALID_HANDOVER_REQUEST',
|
|
400,
|
|
);
|
|
const playerId = input.playerId;
|
|
const offerId = controlUuid(
|
|
input.offerId,
|
|
'Provide a valid private handover kit.',
|
|
);
|
|
const operationId = controlUuid(
|
|
input.operationId,
|
|
'Generate a handover claim operation identifier.',
|
|
);
|
|
const [offerHash, handoverHash, operationHash, sessionHash] =
|
|
await Promise.all([
|
|
hash(offerId),
|
|
secretHash(input.handoverSecret),
|
|
hash(operationId),
|
|
secretHash(input.newSessionToken),
|
|
]);
|
|
if (sessionHash === handoverHash)
|
|
throw new SeatControlError(
|
|
'The recipient session credential must differ from the handover secret.',
|
|
'INVALID_SESSION_TOKEN',
|
|
400,
|
|
);
|
|
const replay = await replayClaimedHandover(
|
|
code,
|
|
playerId,
|
|
offerHash,
|
|
handoverHash,
|
|
operationHash,
|
|
sessionHash,
|
|
input.newSessionToken,
|
|
);
|
|
if (replay) return replay;
|
|
if (await handoverClaimReceipt(code, playerId, operationHash))
|
|
throw new SeatControlError(
|
|
'This handover claim operation is no longer active or was reused with different details.',
|
|
'HANDOVER_RECEIPT_INVALID',
|
|
);
|
|
const offer = await handoverOffer(code, playerId);
|
|
if (
|
|
!offer ||
|
|
offer.offer_hash !== offerHash ||
|
|
offer.secret_hash !== handoverHash
|
|
) {
|
|
const claimed = await db()
|
|
.prepare(
|
|
'SELECT 1 AS present FROM seat_handover_claim_receipts WHERE room_code = ? AND player_id = ? AND offer_hash = ? AND secret_hash = ?',
|
|
)
|
|
.bind(code, playerId, offerHash, handoverHash)
|
|
.first();
|
|
throw new SeatControlError(
|
|
claimed
|
|
? 'This handover offer has already been claimed.'
|
|
: 'The handover proof could not be verified.',
|
|
claimed ? 'HANDOVER_ALREADY_CLAIMED' : 'INVALID_HANDOVER_PROOF',
|
|
);
|
|
}
|
|
if (offer.claim_operation_hash !== null)
|
|
throw new SeatControlError(
|
|
'This handover offer has already been claimed.',
|
|
'HANDOVER_ALREADY_CLAIMED',
|
|
);
|
|
const now = clock.now();
|
|
if (offer.expires_at <= now)
|
|
throw new SeatControlError(
|
|
'This handover offer has expired.',
|
|
'HANDOVER_EXPIRED',
|
|
410,
|
|
);
|
|
const issuer = await db()
|
|
.prepare(
|
|
'SELECT 1 AS present FROM seats WHERE room_code = ? AND player_id = ? AND token_hash = ? AND revoked = 0',
|
|
)
|
|
.bind(code, playerId, offer.issuer_session_hash)
|
|
.first();
|
|
if (!issuer)
|
|
throw new SeatControlError(
|
|
'The handover proof could not be verified.',
|
|
'INVALID_HANDOVER_PROOF',
|
|
);
|
|
if (
|
|
await db()
|
|
.prepare('SELECT 1 AS present FROM seats WHERE token_hash = ?')
|
|
.bind(sessionHash)
|
|
.first()
|
|
)
|
|
throw new SeatControlError(
|
|
'Generate a new recipient session credential; this token has already been used.',
|
|
'INVALID_SESSION_TOKEN',
|
|
);
|
|
const current = await readRoom(code);
|
|
const claimFence = await hash(crypto.randomUUID());
|
|
const results = await db().batch([
|
|
db()
|
|
.prepare(
|
|
'UPDATE rooms SET version = version + 1, updated_at = ? WHERE code = ? AND version = ? AND EXISTS (SELECT 1 FROM seat_handover_offers WHERE room_code = ? AND player_id = ? AND offer_hash = ? AND secret_hash = ? AND issuer_session_hash = ? AND expires_at > ? AND claim_operation_hash IS NULL) AND EXISTS (SELECT 1 FROM seats WHERE room_code = ? AND player_id = ? AND token_hash = ? AND revoked = 0) AND NOT EXISTS (SELECT 1 FROM seats WHERE token_hash = ?) AND NOT EXISTS (SELECT 1 FROM seat_handover_claim_receipts WHERE room_code = ? AND player_id = ? AND operation_hash = ?)',
|
|
)
|
|
.bind(
|
|
now,
|
|
code,
|
|
current.version,
|
|
code,
|
|
playerId,
|
|
offerHash,
|
|
handoverHash,
|
|
offer.issuer_session_hash,
|
|
now,
|
|
code,
|
|
playerId,
|
|
offer.issuer_session_hash,
|
|
sessionHash,
|
|
code,
|
|
playerId,
|
|
operationHash,
|
|
),
|
|
db()
|
|
.prepare(
|
|
'INSERT INTO seat_handover_claim_receipts(room_code,player_id,operation_hash,offer_hash,secret_hash,session_hash,claim_fence,claimed_at) SELECT ?,?,?,?,?,?,?,? WHERE changes() = 1',
|
|
)
|
|
.bind(
|
|
code,
|
|
playerId,
|
|
operationHash,
|
|
offerHash,
|
|
handoverHash,
|
|
sessionHash,
|
|
claimFence,
|
|
now,
|
|
),
|
|
db()
|
|
.prepare(
|
|
'DELETE FROM seat_handover_offers WHERE room_code = ? AND player_id = ? AND offer_hash = ? AND secret_hash = ? AND EXISTS (SELECT 1 FROM seat_handover_claim_receipts WHERE room_code = ? AND player_id = ? AND claim_fence = ?)',
|
|
)
|
|
.bind(
|
|
code,
|
|
playerId,
|
|
offerHash,
|
|
handoverHash,
|
|
code,
|
|
playerId,
|
|
claimFence,
|
|
),
|
|
db()
|
|
.prepare(
|
|
'UPDATE seats SET revoked = 1 WHERE room_code = ? AND player_id = ? AND revoked = 0 AND EXISTS (SELECT 1 FROM seat_handover_claim_receipts WHERE room_code = ? AND player_id = ? AND claim_fence = ?)',
|
|
)
|
|
.bind(code, playerId, code, playerId, claimFence),
|
|
db()
|
|
.prepare(
|
|
'INSERT INTO seats(token_hash,room_code,player_id) SELECT ?,?,? WHERE EXISTS (SELECT 1 FROM seat_handover_claim_receipts WHERE room_code = ? AND player_id = ? AND claim_fence = ?)',
|
|
)
|
|
.bind(sessionHash, code, playerId, code, playerId, claimFence),
|
|
db()
|
|
.prepare(
|
|
'DELETE FROM seat_recovery_receipts WHERE room_code = ? AND player_id = ? AND EXISTS (SELECT 1 FROM seat_handover_claim_receipts WHERE room_code = ? AND player_id = ? AND claim_fence = ?)',
|
|
)
|
|
.bind(code, playerId, code, playerId, claimFence),
|
|
db()
|
|
.prepare(
|
|
'DELETE FROM seat_recovery_keys WHERE room_code = ? AND player_id = ? AND EXISTS (SELECT 1 FROM seat_handover_claim_receipts WHERE room_code = ? AND player_id = ? AND claim_fence = ?)',
|
|
)
|
|
.bind(code, playerId, code, playerId, claimFence),
|
|
]);
|
|
if (results[0].meta.changes !== 1) {
|
|
const won = await replayClaimedHandover(
|
|
code,
|
|
playerId,
|
|
offerHash,
|
|
handoverHash,
|
|
operationHash,
|
|
sessionHash,
|
|
input.newSessionToken,
|
|
);
|
|
if (won) return won;
|
|
const claimed = await db()
|
|
.prepare(
|
|
'SELECT 1 AS present FROM seat_handover_claim_receipts WHERE room_code = ? AND player_id = ? AND offer_hash = ? AND secret_hash = ?',
|
|
)
|
|
.bind(code, playerId, offerHash, handoverHash)
|
|
.first();
|
|
throw new SeatControlError(
|
|
claimed
|
|
? 'This handover offer has already been claimed.'
|
|
: 'The table or handover offer changed. Retry the exact claim request.',
|
|
claimed ? 'HANDOVER_ALREADY_CLAIMED' : 'STALE_VERSION',
|
|
);
|
|
}
|
|
return {
|
|
view: await readSeatView(code, { playerId, tokenHash: sessionHash }),
|
|
token: input.newSessionToken as string,
|
|
transferred: true as const,
|
|
replayed: false,
|
|
};
|
|
}
|