Initial commit
This commit is contained in:
commit
8c709fb52f
771 files changed
+222983
No files matched your search
Vendored
+5
@@ -0,0 +1,5 @@
|
||||
declare namespace Cloudflare {
|
||||
interface Env {
|
||||
DB: D1Database;
|
||||
}
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
import { env } from 'cloudflare:workers';
|
||||
import { drizzle } from 'drizzle-orm/d1';
|
||||
import * as schema from './schema';
|
||||
|
||||
export function getDb() {
|
||||
if (!env.DB) {
|
||||
throw new Error(
|
||||
'Cloudflare D1 binding `DB` is unavailable. Set the `d1` field in .openai/hosting.json to `DB` or let your control plane inject the real binding values before using the database.',
|
||||
);
|
||||
}
|
||||
|
||||
return drizzle(env.DB, { schema });
|
||||
}
|
||||
+696
@@ -0,0 +1,696 @@
|
||||
import { env } from 'cloudflare:workers';
|
||||
import {
|
||||
applyAction,
|
||||
normalizeAutomaticGame,
|
||||
createGame,
|
||||
joinGame,
|
||||
newPlayer,
|
||||
viewGame,
|
||||
RuleError,
|
||||
type Action,
|
||||
type Game,
|
||||
} from '@/game/engine';
|
||||
import type { FactionId } from '@/game/catalog';
|
||||
import { runBots } from '@/game/bots';
|
||||
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 result = await 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),
|
||||
clock.now(),
|
||||
code,
|
||||
version,
|
||||
code,
|
||||
auth.playerId,
|
||||
auth.tokenHash,
|
||||
)
|
||||
.run();
|
||||
if (result.meta.changes !== 1)
|
||||
throw new RuleError(
|
||||
'Another action arrived first. Your view has refreshed; try again.',
|
||||
);
|
||||
return viewGame(next, id);
|
||||
}
|
||||
|
||||
/** 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 FROM rooms JOIN seats ON seats.room_code = rooms.code WHERE rooms.code = ? AND seats.player_id = ? AND seats.token_hash = ? AND seats.revoked = 0',
|
||||
)
|
||||
.bind(code, auth.playerId, auth.tokenHash)
|
||||
.first<{ state: string; version: number }>();
|
||||
if (!row) throw new RuleError('Your seat could not be verified.');
|
||||
const g: Game = JSON.parse(row.state);
|
||||
g.version = row.version;
|
||||
return viewGame(g, auth.playerId);
|
||||
}
|
||||
|
||||
/** Scheduling hint only; the engine remains the authority on whether a choice exists. */
|
||||
export function needsAutomaticRoomRecovery(
|
||||
state: Pick<Game, 'response' | 'decision' | 'pendingTreacheryDiscard'> & {
|
||||
richeseAuction?: unknown;
|
||||
automaticContinuationPending?: boolean;
|
||||
},
|
||||
) {
|
||||
return (
|
||||
state.automaticContinuationPending === true ||
|
||||
!!state.pendingTreacheryDiscard ||
|
||||
!!state.response ||
|
||||
!!state.richeseAuction ||
|
||||
state.decision?.kind === 'choamMarket' ||
|
||||
state.decision?.kind === 'choamAudit' ||
|
||||
state.decision?.kind === 'choamAuditPayment' ||
|
||||
state.decision?.kind === 'ecazAmbassador' ||
|
||||
state.decision?.kind === 'fullPlanRead' ||
|
||||
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 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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import {
|
||||
sqliteTable,
|
||||
text,
|
||||
integer,
|
||||
primaryKey,
|
||||
} from 'drizzle-orm/sqlite-core';
|
||||
export const rooms = sqliteTable('rooms', {
|
||||
code: text('code').primaryKey(),
|
||||
state: text('state').notNull(),
|
||||
version: integer('version').notNull().default(0),
|
||||
updatedAt: integer('updated_at').notNull(),
|
||||
});
|
||||
export const seats = sqliteTable('seats', {
|
||||
tokenHash: text('token_hash').primaryKey(),
|
||||
revoked: integer('revoked').notNull().default(0),
|
||||
roomCode: text('room_code')
|
||||
.notNull()
|
||||
.references(() => rooms.code, { onDelete: 'cascade' }),
|
||||
playerId: text('player_id').notNull(),
|
||||
});
|
||||
|
||||
export const seatRecoveryKeys = sqliteTable(
|
||||
'seat_recovery_keys',
|
||||
{
|
||||
roomCode: text('room_code')
|
||||
.notNull()
|
||||
.references(() => rooms.code, { onDelete: 'cascade' }),
|
||||
playerId: text('player_id').notNull(),
|
||||
recoveryHash: text('recovery_hash').notNull(),
|
||||
currentOperationHash: text('current_operation_hash'),
|
||||
},
|
||||
(table) => [primaryKey({ columns: [table.roomCode, table.playerId] })],
|
||||
);
|
||||
export const seatRecoveryReceipts = sqliteTable(
|
||||
'seat_recovery_receipts',
|
||||
{
|
||||
roomCode: text('room_code')
|
||||
.notNull()
|
||||
.references(() => rooms.code, { onDelete: 'cascade' }),
|
||||
playerId: text('player_id').notNull(),
|
||||
operationHash: text('operation_hash').notNull(),
|
||||
recoveryHash: text('recovery_hash').notNull(),
|
||||
sessionHash: text('session_hash').notNull(),
|
||||
},
|
||||
(table) => [
|
||||
primaryKey({
|
||||
columns: [table.roomCode, table.playerId, table.operationHash],
|
||||
}),
|
||||
],
|
||||
);
|
||||
|
||||
export const roomEntryReceipts = sqliteTable('room_entry_receipts', {
|
||||
operationHash: text('operation_hash').primaryKey(),
|
||||
requestHash: text('request_hash').notNull(),
|
||||
sessionHash: text('session_hash').notNull(),
|
||||
roomCode: text('room_code')
|
||||
.notNull()
|
||||
.references(() => rooms.code, { onDelete: 'cascade' }),
|
||||
playerId: text('player_id').notNull(),
|
||||
});
|
||||
Reference in new issue
Block a user