Hashed seat tokens, ledgered inputs, refusable moves, disk-checked codes, host-only bots, and a drift script
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0141G6xqLeNRYEtviLWSB5Up
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
ddb18cb08a
commit
c934bbe434
@@ -3,7 +3,7 @@
|
||||
//
|
||||
// POST /api/rooms {name, size?} create a room and take seat A
|
||||
// POST /api/rooms/:id/join {name} take the next seat
|
||||
// POST /api/rooms/:id/bot {token} seat a bot (any seated player may, before the game begins)
|
||||
// POST /api/rooms/:id/bot {token} the host seats a bot, before the game begins
|
||||
// POST /api/rooms/:id/begin {token} the host begins with the players seated so far
|
||||
// POST /api/rooms/:id/unseat {token, seat} the host sends a bot away before the game begins
|
||||
// GET /api/rooms/:id?token= the view for that seat; without a token, the gallery's view
|
||||
@@ -171,8 +171,8 @@ async function handle(req: IncomingMessage, res: ServerResponse): Promise<void>
|
||||
if (parts.length === 2 && req.method === 'POST') {
|
||||
if (!doors.allow(clientOf(req))) throw new RoomError('Too many rooms opened from here just now; try again later.', 429);
|
||||
const body = await readBody(req);
|
||||
const { room, seat } = rooms.create(String(body.name ?? ''), Number(body.size ?? game.minSeats));
|
||||
return send(res, 201, { seat: seat.id, token: seat.token, view: rooms.view(room, seat.id) });
|
||||
const { room, seat, token } = rooms.create(String(body.name ?? ''), Number(body.size ?? game.minSeats));
|
||||
return send(res, 201, { seat: seat.id, token, view: rooms.view(room, seat.id) });
|
||||
}
|
||||
|
||||
const room = rooms.get(parts[2] ?? '');
|
||||
@@ -187,8 +187,8 @@ async function handle(req: IncomingMessage, res: ServerResponse): Promise<void>
|
||||
const token = typeof body.token === 'string' && body.token ? body.token : undefined;
|
||||
if (action === 'join') {
|
||||
if (!doors.allow(clientOf(req))) throw new RoomError('Too many seats taken from here just now; try again later.', 429);
|
||||
const seat = rooms.join(room, String(body.name ?? ''));
|
||||
return send(res, 200, { seat: seat.id, token: seat.token, view: rooms.view(room, seat.id) });
|
||||
const { seat, token: minted } = rooms.join(room, String(body.name ?? ''));
|
||||
return send(res, 200, { seat: seat.id, token: minted, view: rooms.view(room, seat.id) });
|
||||
}
|
||||
if (action === 'bot') {
|
||||
rooms.addBot(room, token);
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
// then the round resolves at once, bots included. Nothing here knows the
|
||||
// game beyond the GameSpec it is given.
|
||||
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
|
||||
import { SPECTATOR, type GameSpec, type SeatId } from '../../src/lib/game/spec';
|
||||
import type { ChatLine, RoomView } from '../../src/lib/net/view';
|
||||
import type { LedgerLine, Store } from './store';
|
||||
@@ -17,10 +17,15 @@ export { SPECTATOR };
|
||||
export interface Seat {
|
||||
id: SeatId;
|
||||
name: string;
|
||||
token: string;
|
||||
/** SHA-256 of the seat's token, hex; empty for a bot. */
|
||||
tokenHash: string;
|
||||
bot: boolean;
|
||||
}
|
||||
|
||||
export function hashToken(token: string): string {
|
||||
return createHash('sha256').update(token).digest('hex');
|
||||
}
|
||||
|
||||
export interface Room<State> {
|
||||
id: string;
|
||||
size: number;
|
||||
@@ -122,28 +127,29 @@ export class Rooms<State, Input> {
|
||||
return this.rooms.size;
|
||||
}
|
||||
|
||||
/** The seat a token unlocks. */
|
||||
/** The seat a token unlocks, compared by hash in constant time. */
|
||||
seatOf(room: Room<State>, token: string | undefined): Seat {
|
||||
const seat = room.seats.find((s) => !s.bot && s.token === token);
|
||||
if (!token) throw new RoomError('That token opens no seat at this game.', 403);
|
||||
const given = Buffer.from(hashToken(token), 'hex');
|
||||
const seat = room.seats.find((s) => !s.bot && s.tokenHash.length === given.length * 2 && timingSafeEqual(Buffer.from(s.tokenHash, 'hex'), given));
|
||||
if (!seat) throw new RoomError('That token opens no seat at this game.', 403);
|
||||
return seat;
|
||||
}
|
||||
|
||||
create(name: string, size: number): { room: Room<State>; seat: Seat } {
|
||||
create(name: string, size: number): { room: Room<State>; seat: Seat; token: string } {
|
||||
const { minSeats } = this.game;
|
||||
if (!Number.isInteger(size) || size < minSeats || size > this.maxSeats) {
|
||||
throw new RoomError(`A table seats ${minSeats} to ${this.maxSeats} players.`);
|
||||
}
|
||||
let id = newCode();
|
||||
while (this.rooms.has(id)) id = newCode();
|
||||
while (this.rooms.has(id) || this.store.exists(id)) id = newCode();
|
||||
const room: Room<State> = { id, size, createdAt: Date.now(), seats: [], state: null, pending: {}, chat: [], seq: 0, updatedAt: Date.now() };
|
||||
this.rooms.set(id, room);
|
||||
this.commit(room, { t: 'room', id, seats: size, createdAt: room.createdAt });
|
||||
const seat = this.sit(room, name, false);
|
||||
return { room, seat };
|
||||
return { room, ...this.sit(room, name, false) };
|
||||
}
|
||||
|
||||
join(room: Room<State>, name: string): Seat {
|
||||
join(room: Room<State>, name: string): { seat: Seat; token: string } {
|
||||
if (room.state) throw new RoomError('The game has begun; no more seats are taken.', 409);
|
||||
return this.sit(room, name, false);
|
||||
}
|
||||
@@ -157,23 +163,27 @@ export class Rooms<State, Input> {
|
||||
this.commit(room, { t: 'start', seed: newSeed(), rules: this.game.currentRules });
|
||||
}
|
||||
|
||||
/** The host seats a bot in an empty chair. */
|
||||
addBot(room: Room<State>, token: string | undefined): Seat {
|
||||
this.seatOf(room, token);
|
||||
const host = this.seatOf(room, token);
|
||||
if (host.id !== room.seats[0]?.id) throw new RoomError('Only the player who opened the table may seat a bot.', 403);
|
||||
if (room.state) throw new RoomError('The game has begun; no more seats are taken.', 409);
|
||||
const taken = new Set(room.seats.map((s) => s.name.toLowerCase()));
|
||||
const pool = this.game.botNames.filter((n) => !taken.has(n.toLowerCase()));
|
||||
const name = pool[Math.floor(Math.random() * pool.length)] ?? 'The Construct';
|
||||
return this.sit(room, name, true);
|
||||
return this.sit(room, name, true).seat;
|
||||
}
|
||||
|
||||
private sit(room: Room<State>, rawName: string, bot: boolean): Seat {
|
||||
/** Take the next free seat. The token is returned once and never stored; the ledger keeps its hash. */
|
||||
private sit(room: Room<State>, rawName: string, bot: boolean): { seat: Seat; token: string } {
|
||||
if (room.seats.length >= room.size) throw new RoomError('Every seat at this table is taken.', 409);
|
||||
const name = cleanName(rawName);
|
||||
if (room.seats.some((s) => s.name.toLowerCase() === name.toLowerCase())) throw new RoomError('Another player here already has that name.', 409);
|
||||
const id = this.game.seatIds.find((s) => !room.seats.some((taken) => taken.id === s))!;
|
||||
const seat: Seat = { id, name, token: bot ? '' : newId(18), bot };
|
||||
this.commit(room, { t: 'seat', id: seat.id, name, token: seat.token, bot });
|
||||
return seat;
|
||||
const token = bot ? '' : newId(18);
|
||||
const seat: Seat = { id, name, tokenHash: bot ? '' : hashToken(token), bot };
|
||||
this.commit(room, { t: 'seat', id: seat.id, name, tokenHash: seat.tokenHash, bot });
|
||||
return { seat, token };
|
||||
}
|
||||
|
||||
/** The host sends a bot away before the game begins. People leave by not coming back. */
|
||||
@@ -186,17 +196,17 @@ export class Rooms<State, Input> {
|
||||
this.commit(room, { t: 'unseat', id: seat.id });
|
||||
}
|
||||
|
||||
/** Record a seat's move; resolve the round once nobody else is awaited. */
|
||||
/** Record a seat's move on the ledger, so a restart keeps it; resolve the round once nobody else is awaited. */
|
||||
submit(room: Room<State>, token: string | undefined, raw: unknown): void {
|
||||
const seat = this.seatOf(room, token);
|
||||
if (!room.state) throw new RoomError('The game has not begun: seats are still empty.', 409);
|
||||
if (this.game.over(room.state)) throw new RoomError('The game is over.', 409);
|
||||
if (!this.waitingOn(room).includes(seat.id)) throw new RoomError('It is not your move.', 409);
|
||||
room.pending[seat.id] = this.game.cleanInput(raw);
|
||||
room.updatedAt = Date.now();
|
||||
room.seq += 1;
|
||||
const input = this.game.cleanInput(raw);
|
||||
const why = this.game.validate?.(room.state, seat.id, input);
|
||||
if (why) throw new RoomError(why);
|
||||
this.commit(room, { t: 'input', at: Date.now(), id: seat.id, input });
|
||||
if (this.waitingOn(room).length === 0) this.resolve(room);
|
||||
this.notify(room);
|
||||
}
|
||||
|
||||
private resolve(room: Room<State>): void {
|
||||
@@ -295,7 +305,10 @@ export class Rooms<State, Input> {
|
||||
case 'over':
|
||||
break;
|
||||
case 'seat':
|
||||
room.seats.push({ id: line.id, name: line.name, token: line.token, bot: line.bot });
|
||||
room.seats.push({ id: line.id, name: line.name, tokenHash: line.tokenHash ?? (line.token ? hashToken(line.token) : ''), bot: line.bot });
|
||||
break;
|
||||
case 'input':
|
||||
room.pending[line.id] = this.game.cleanInput(line.input);
|
||||
break;
|
||||
case 'unseat':
|
||||
room.seats = room.seats.filter((s) => s.id !== line.id);
|
||||
@@ -321,7 +334,7 @@ export class Rooms<State, Input> {
|
||||
const room: Room<State> = { id, size: first.seats, createdAt: first.createdAt, seats: [], state: null, pending: {}, chat: [], seq: lines.length, updatedAt: first.createdAt };
|
||||
for (const line of lines) {
|
||||
this.apply(room, line);
|
||||
if (line.t === 'turn') room.updatedAt = line.at;
|
||||
if (line.t === 'turn' || line.t === 'input') room.updatedAt = line.at;
|
||||
}
|
||||
return room;
|
||||
}
|
||||
|
||||
@@ -8,7 +8,10 @@ import type { SeatId } from '../../src/lib/game/spec';
|
||||
|
||||
export type LedgerLine =
|
||||
| { t: 'room'; id: string; seats: number; createdAt: number }
|
||||
| { t: 'seat'; id: SeatId; name: string; token: string; bot: boolean }
|
||||
/** A seat holds only the hash of its token; the token itself goes once to the browser that earned it. Ledgers written before hashing carry `token` and are read once more. */
|
||||
| { t: 'seat'; id: SeatId; name: string; tokenHash?: string; token?: string; bot: boolean }
|
||||
/** One seat's move for the round in progress, so a restart keeps it. The turn line that follows carries every input again. */
|
||||
| { t: 'input'; at: number; id: SeatId; input: unknown }
|
||||
/** A bot sent away by the host before the game began; its seat id is free again. */
|
||||
| { t: 'unseat'; id: SeatId }
|
||||
/** Ledgers from before revisions were recorded resolve under rules 1. */
|
||||
@@ -41,6 +44,11 @@ export class Store {
|
||||
.map((l) => JSON.parse(l) as LedgerLine);
|
||||
}
|
||||
|
||||
/** Whether a ledger exists on disk, loaded or not; a code must not be reissued over one. */
|
||||
exists(roomId: string): boolean {
|
||||
return existsSync(this.path(roomId));
|
||||
}
|
||||
|
||||
roomIds(): string[] {
|
||||
return readdirSync(this.dir)
|
||||
.filter((f) => f.endsWith('.jsonl'))
|
||||
|
||||
Reference in New Issue
Block a user