diff --git a/template/server/src/index.ts b/template/server/src/index.ts index 5cd0acd..4bde5f5 100644 --- a/template/server/src/index.ts +++ b/template/server/src/index.ts @@ -19,6 +19,11 @@ // POST /api/rooms/:id/phrase {token, seat} the host mints one for another person's seat, which re-seats it when claimed // POST /api/rooms/:id/standin {token, seat} the host gives a person's seat to a bot for the rest of the game // POST /api/transfer {phrase} claim one +// POST /api/seats {seats: [{roomId, token}], forget?: [roomId]} bank this browser's seats in a cookie the server +// sets, and get back every seat the cookie holds; a browser whose storage was +// cleared gets its seats from the cookie, which clearing storage leaves alone +// POST /api/seats/find {name, roomId?} the seats a name holds at tables not yet over: those last used from this +// address are re-seated and returned; the rest named, to ask the host for a phrase // POST /api/rooms/:id/report {token?, happened, expected} a report to the keeper, pinned to the round // POST /api/reports/:id/image a screenshot for a report just filed // POST /api/reports/:id/answer {roomId, token, text} your word under the keeper's reply @@ -37,9 +42,9 @@ import * as Sentry from '@sentry/node'; import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'; -import { timingSafeEqual } from 'node:crypto'; -import { readFileSync } from 'node:fs'; -import { dirname } from 'node:path'; +import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto'; +import { readFileSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; import { WebSocketServer, WebSocket } from 'ws'; import { game } from '../../src/lib/game'; import { RateLimit } from './ratelimit'; @@ -58,6 +63,12 @@ const KEEPER_TZ = process.env.KEEPER_TZ ?? 'America/New_York'; /** The keeper's key, readable by root and this game's user alone, so another service on the host cannot use the keeper's door. Absent, the door stays shut. */ const KEEPER_KEY_FILE = process.env.KEEPER_KEY_FILE ?? '/etc/__SLUG__/keeper.key'; const BODY_LIMIT = 16 * 1024; +/** The seats a browser holds, banked in a cookie: the browser keeps it for the server alone, and sends it to the API only. */ +const SEATS_COOKIE = 'seats'; +/** As long as a browser will keep a cookie. */ +const SEATS_COOKIE_MAX_AGE = 400 * 24 * 60 * 60; +/** The newest seats a cookie carries; the rest fall off the end. */ +const SEATS_COOKIE_KEEP = 60; // Errors go to Sentry when a DSN is set; the SDK drops them otherwise. if (process.env.SENTRY_DSN) { @@ -107,6 +118,70 @@ function clientOf(req: IncomingMessage): string { return first || req.socket.remoteAddress || '?'; } +/** Addresses go on the ledgers as salted hashes. The salt is made once, beside the ledgers, and never leaves the host. */ +const SALT_FILE = join(dirname(DATA_DIR), 'addr.salt'); +function addrSalt(): string { + try { + const salt = readFileSync(SALT_FILE, 'utf8').trim(); + if (salt) return salt; + } catch { + // No salt yet: this is the first start. + } + const salt = randomBytes(32).toString('base64url'); + writeFileSync(SALT_FILE, salt + '\n', { mode: 0o600 }); + return salt; +} +const salt = addrSalt(); +/** The visitor's address as the ledgers note it: a hash a reader of the ledger cannot turn back into the address. */ +function addrOf(req: IncomingMessage): string { + return createHmac('sha256', salt).update(clientOf(req)).digest('hex').slice(0, 16); +} + +/** The seats the browser's cookie banks, as room and token; a cookie this server never wrote reads as none. */ +function bankedSeats(req: IncomingMessage): { roomId: string; token: string }[] { + const header = req.headers.cookie ?? ''; + const pair = header.split(';').map((c) => c.trim()).find((c) => c.startsWith(`${SEATS_COOKIE}=`)); + if (!pair) return []; + return pair + .slice(SEATS_COOKIE.length + 1) + .split(':') + .map((entry) => entry.split('.')) + .filter((e): e is [string, string] => e.length === 2 && /^[A-Z0-9]{4}$/.test(e[0]) && /^[A-Za-z0-9_-]{1,64}$/.test(e[1])) + .map(([roomId, token]) => ({ roomId, token })); +} + +/** The cookie written for the seats given, newest last; sent only to the API, only over https where there is any, never to a script. */ +function bankSeats(req: IncomingMessage, res: ServerResponse, seats: { roomId: string; token: string }[]): void { + const value = seats.slice(-SEATS_COOKIE_KEEP).map((s) => `${s.roomId}.${s.token}`).join(':'); + const secure = req.headers['x-forwarded-proto'] === 'https' ? '; Secure' : ''; + res.setHeader('set-cookie', `${SEATS_COOKIE}=${value}; Path=/api; Max-Age=${SEATS_COOKIE_MAX_AGE}; HttpOnly; SameSite=Lax${secure}`); +} + +/** The seats a browser claims, from its cookie and its own storage, kept to the ones their tokens still open, each room once. */ +function provenBank(req: IncomingMessage, claimed: unknown, forget: unknown): { roomId: string; seat: string; token: string }[] { + const dropped = new Set(Array.isArray(forget) ? forget.slice(0, 50).map((r) => String(r).toUpperCase()) : []); + const own = Array.isArray(claimed) ? claimed.slice(0, SEATS_COOKIE_KEEP) : []; + const byRoom = new Map(); + // The browser's own copy is the fresher where the two differ, so it is taken last. + for (const c of [...bankedSeats(req), ...own]) { + if (typeof c !== 'object' || c === null) continue; + const { roomId, token } = c as Record; + if (typeof roomId !== 'string' || typeof token !== 'string' || !token) continue; + const id = roomId.toUpperCase(); + if (!dropped.has(id)) byRoom.set(id, token); + } + const proven: { roomId: string; seat: string; token: string }[] = []; + for (const [roomId, token] of byRoom) { + try { + const room = rooms.get(roomId); + proven.push({ roomId: room.id, seat: rooms.seatOf(room, token).id, token }); + } catch { + // A seat whose token no longer opens is not banked again. + } + } + return proven; +} + function send(res: ServerResponse, status: number, body: unknown): void { res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }); res.end(status === 204 ? undefined : JSON.stringify(body)); @@ -245,6 +320,22 @@ async function handle(req: IncomingMessage, res: ServerResponse): Promise const body = await readBody(req); return send(res, 200, rooms.claim(body.phrase)); } + if (parts[0] === 'api' && parts[1] === 'seats' && req.method === 'POST') { + const body = await readBody(req); + if (parts.length === 2) { + const seats = provenBank(req, body.seats, body.forget); + bankSeats(req, res, seats); + return send(res, 200, { seats }); + } + if (parts[2] === 'find' && parts.length === 3) { + if (!claims.allow(clientOf(req))) throw new RoomError('Too many searches from here just now; try again later.', 429); + const found = rooms.findSeats(String(body.name ?? ''), addrOf(req), typeof body.roomId === 'string' ? body.roomId : undefined); + // The seats given back are banked at once, so the cookie and the browser agree. + bankSeats(req, res, provenBank(req, found.seated, [])); + return send(res, 200, found); + } + throw new RoomError('Not here.', 404); + } if (parts[0] !== 'api' || parts[1] !== 'rooms') throw new RoomError('Not here.', 404); if (parts.length === 2 && req.method === 'POST') { @@ -259,11 +350,13 @@ async function handle(req: IncomingMessage, res: ServerResponse): Promise if (!action && req.method === 'GET') { const token = url.searchParams.get('token'); if (!token) return send(res, 200, rooms.view(room, SPECTATOR)); + rooms.seen(room, token, addrOf(req)); return send(res, 200, rooms.view(room, rooms.seatOf(room, token).id)); } if (req.method !== 'POST') throw new RoomError('Not here.', 404); const body = await readBody(req); const token = typeof body.token === 'string' && body.token ? body.token : undefined; + if (token) rooms.seen(room, token, addrOf(req)); 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, token: minted } = rooms.join(room, String(body.name ?? '')); diff --git a/template/server/src/rooms.test.ts b/template/server/src/rooms.test.ts index 8453467..46a038d 100644 --- a/template/server/src/rooms.test.ts +++ b/template/server/src/rooms.test.ts @@ -327,3 +327,51 @@ describe('the host and the other seats', () => { expect(next.seats.map((s) => s.name)).toEqual(['Ann']); }); }); + +describe('a seat a browser lost', () => { + it('notes the address a seat is used from, once per change, without waking the table, and a restart keeps it', () => { + const { rooms, room, bo } = table(); + const seq = room.seq; + rooms.seen(room, bo, 'home'); + rooms.seen(room, bo, 'home'); + expect(room.seats.find((s) => s.id === 'B')?.addr).toBe('home'); + expect(room.seq).toBe(seq); + rooms.seen(room, 'no-such-token', 'elsewhere'); + expect(new Store(dir).read(room.id).filter((l) => l.t === 'seen')).toHaveLength(1); + expect(new Rooms(game, new Store(dir)).get(room.id).seats.find((s) => s.id === 'B')?.addr).toBe('home'); + }); + + it('gives the seat back, under its name, to the address it was last used from, with a new token', () => { + const { rooms, room, bo } = table(); + rooms.seen(room, bo, 'home'); + const found = rooms.findSeats('bo', 'home'); + expect(found.elsewhere).toEqual([]); + expect(found.seated).toHaveLength(1); + const { roomId, seat, token } = found.seated[0]; + expect([roomId, seat]).toEqual([room.id, 'B']); + expect(rooms.seatOf(room, token).name).toBe('Bo'); + expect(() => rooms.seatOf(room, bo)).toThrow(/opens no seat/); + }); + + it('only names the table when the address differs, or another person at it shares the address, or the name is nobody\'s', () => { + const { rooms, room, ann, bo } = table(); + rooms.seen(room, bo, 'home'); + expect(rooms.findSeats('Bo', 'away')).toEqual({ seated: [], elsewhere: [{ roomId: room.id, seat: 'B', players: ['Ann'] }] }); + // Ann and Bo play from one address: a name alone cannot tell them apart, so neither is re-seated by it. + rooms.seen(room, ann, 'home'); + expect(rooms.findSeats('Bo', 'home').seated).toEqual([]); + expect(rooms.findSeats('Dee', 'home')).toEqual({ seated: [], elsewhere: [] }); + expect(rooms.seatOf(room, bo).id).toBe('B'); + }); + + it('looks only at the table named when one is, and passes over a table that is over', () => { + const { rooms, room, bo } = table(); + rooms.seen(room, bo, 'home'); + const other = rooms.create('Bo', 3); + rooms.seen(other.room, other.token, 'home'); + expect(rooms.findSeats('Bo', 'home', other.room.id).seated.map((s) => s.roomId)).toEqual([other.room.id]); + expect(rooms.findSeats('Bo', 'home').seated.map((s) => s.roomId).sort()).toEqual([other.room.id, room.id].sort()); + room.over = true; + expect(rooms.findSeats('Bo', 'home').seated.map((s) => s.roomId)).toEqual([other.room.id]); + }); +}); diff --git a/template/server/src/rooms.ts b/template/server/src/rooms.ts index 8f7a76d..fb3bc38 100644 --- a/template/server/src/rooms.ts +++ b/template/server/src/rooms.ts @@ -30,8 +30,13 @@ export interface Seat { bot: boolean; /** The host gave this person's seat to a bot; a phrase from the host, or the keeper, gives it back. */ standIn?: true; + /** The address this person's seat was last used from, as a salted hash; a seat their browser lost goes back to the same address. */ + addr?: string; } +/** A table whose ledger has not been written in this long is not looked through for a lost seat. */ +const LOST_SEAT_LOOKBACK_MS = 90 * 24 * 60 * 60 * 1000; + function hashToken(token: string): string { return createHash('sha256').update(token).digest('hex'); } @@ -318,6 +323,51 @@ export class Rooms { return { phrase, expiresAt }; } + /** The address a seat is used from, noted when it changes. Not a change the table need hear of: nothing wakes for it. */ + seen(room: Room, token: string | undefined, addr: string): void { + let seat: Seat; + try { + seat = this.seatOf(room, token); + } catch { + return; + } + if (seat.addr === addr) return; + const line: LedgerLine = { t: 'seen', at: Date.now(), id: seat.id, addr }; + this.store.append(room.id, line); + this.apply(room, line); + } + + /** + * The seats a name holds at tables not yet over, for a browser that lost them. A seat last used from the + * asker's own address, at a table where no other person shares that address, is theirs again with a new + * token, the old one dead; the rest are named, so the asker can get a phrase from the host there. + */ + findSeats(rawName: string, addr: string, only?: string): { seated: { roomId: string; seat: SeatId; token: string }[]; elsewhere: { roomId: string; seat: SeatId; players: string[] }[] } { + const name = cleanName(rawName).toLowerCase(); + const seated: { roomId: string; seat: SeatId; token: string }[] = []; + const elsewhere: { roomId: string; seat: SeatId; players: string[] }[] = []; + const since = Date.now() - LOST_SEAT_LOOKBACK_MS; + const ids = only ? [normalizeCode(String(only))] : this.store.roomIds().filter((id) => this.store.updatedAt(id) > since); + for (const id of ids) { + let room: Room; + try { + room = this.get(id); + } catch { + continue; + } + if (room.over) continue; + const person = (s: Seat) => !s.bot || s.standIn; + const mine = room.seats.find((s) => person(s) && s.name.toLowerCase() === name); + if (!mine) continue; + const shared = room.seats.some((s) => s !== mine && person(s) && s.addr === addr); + if (mine.addr === addr && !shared) { + const { token } = this.reseat(room, mine.id); + seated.push({ roomId: room.id, seat: mine.id, token }); + } else elsewhere.push({ roomId: room.id, seat: mine.id, players: room.seats.filter((s) => s !== mine).map((s) => s.name) }); + } + return { seated, elsewhere }; + } + /** Claim a phrase: the seat and its token, once. Too many misses void every pending phrase. */ claim(raw: unknown): { roomId: string; seat: SeatId; token: string } { const now = Date.now(); @@ -714,6 +764,11 @@ export class Rooms { case 'seat': room.seats.push({ id: line.id, name: line.name, tokenHash: line.tokenHash, bot: line.bot }); break; + case 'seen': { + const seat = room.seats.find((s) => s.id === line.id); + if (seat) seat.addr = line.addr; + break; + } case 'reseat': { const seat = room.seats.find((s) => s.id === line.id); if (!seat) break; diff --git a/template/server/src/store.ts b/template/server/src/store.ts index c035278..3410353 100644 --- a/template/server/src/store.ts +++ b/template/server/src/store.ts @@ -2,7 +2,7 @@ // top: the engine is deterministic given the seed and the recorded inputs, so // nothing else needs saving. -import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync } from 'node:fs'; +import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync } from 'node:fs'; import { join } from 'node:path'; import type { SeatId, TableOptions } from '../../src/lib/game/spec'; @@ -43,7 +43,9 @@ export type LedgerLine = /** A seat is held for the keeper of the site, called to the table. */ | { t: 'challenge'; at: number; by: SeatId; keeper: string } /** A finished table called for a rematch: where it went, and who called. */ - | { t: 'rematch'; at: number; to: string; by: SeatId }; + | { t: 'rematch'; at: number; to: string; by: SeatId } + /** The address a person's seat was last used from, as a salted hash, so a seat a browser lost can go back to the same address. */ + | { t: 'seen'; at: number; id: SeatId; addr: string }; export class Store { constructor(private dir: string) { @@ -72,6 +74,15 @@ export class Store { return existsSync(this.path(roomId)); } + /** When the ledger was last written, for passing over tables long finished. */ + updatedAt(roomId: string): number { + try { + return statSync(this.path(roomId)).mtimeMs; + } catch { + return 0; + } + } + roomIds(): string[] { return readdirSync(this.dir) .filter((f) => f.endsWith('.jsonl')) diff --git a/template/src/lib/components/Hall.svelte b/template/src/lib/components/Hall.svelte index 415ad5f..ecacf7f 100644 --- a/template/src/lib/components/Hall.svelte +++ b/template/src/lib/components/Hall.svelte @@ -8,7 +8,7 @@ import TallyBoard from './Tally.svelte'; import Preferences from './Preferences.svelte'; import { prefs, setPref } from '$lib/prefs.svelte'; - import { allSeats, doubtSeat, forgetSeat, rememberSeat, ServerError, trustSeat } from '$lib/net/client'; + import { allSeats, doubtSeat, forgetSeat, rememberSeat, restoreSeats, ServerError, trustSeat } from '$lib/net/client'; import { api, NAME_MAX, playerName, rememberName, Room } from '$lib/net/room.svelte'; import { unreadTalk } from '$lib/net/talk'; import type { Report, RoomView, Tally } from '$lib/net/view'; @@ -26,6 +26,14 @@ let busy = $state(false); let error = $state(''); let seats = $state(allSeats()); + // The bank is asked once on arrival: a browser whose storage was cleared has its seats again before it misses them. + $effect(() => { + void restoreSeats() + .then((found) => { + if (found) seats = allSeats(); + }) + .catch(() => {}); + }); let games = $state | null>>({}); let aboutOpen = $state(false); /** The ledgers' tally, fetched when the about panel opens. */ @@ -112,6 +120,30 @@ } } + let finding = $state(false); + /** What a search for lost seats turned up, in words. */ + let found = $state(''); + /** Seats under this name at tables still in play: those last used from here are taken back; the rest are named, to ask the host. */ + async function findSeats() { + finding = true; + error = ''; + found = ''; + try { + rememberName(name); + const r = await api.findSeats(name.trim()); + for (const s of r.seated) rememberSeat(s.roomId, { seat: s.seat, token: s.token }); + if (r.seated.length) seats = allSeats(); + const back = r.seated.length ? `Seated again at ${r.seated.map((s) => s.roomId).join(', ')}.` : ''; + const others = r.elsewhere.map((s) => `${s.roomId} with ${s.players.join(', ') || 'nobody yet'}`); + const ask = others.length ? ` ${r.seated.length ? 'Also at' : 'At'} ${others.join('; ')}: that seat was last used from another address, so ask the host there for a transfer phrase, or write to the keeper.` : ''; + found = back + ask || 'No table still in play has a seat under that name.'; + } catch (e) { + error = e instanceof Error ? e.message : 'The search did not go through.'; + } finally { + finding = false; + } + } + // The desk is asked for every seat this browser holds, again whenever that set changes. $effect(() => { const held = seats; @@ -244,6 +276,8 @@ +

Lost your games? If this browser forgot them, .

+ {#if found}

{found}

{/if} {#if error}

{error}

{/if} diff --git a/template/src/lib/net/client.ts b/template/src/lib/net/client.ts index 11d836a..d04aebd 100644 --- a/template/src/lib/net/client.ts +++ b/template/src/lib/net/client.ts @@ -42,6 +42,29 @@ export function forgetSeat(roomId: string): void { } catch { // Nothing to forget without storage. } + void bankSeats([roomId]).catch(() => {}); +} + +/** + * The seats this browser holds are banked with the server as well, in a cookie it sets and the browser keeps for + * it alone. A browser whose storage is cleared, or that Safari clears for it after a week away, still has the + * cookie, and gets its seats back from it. The answer is every seat the bank holds that still opens. + */ +export async function bankSeats(forget: string[] = []): Promise<{ roomId: string; seat: SeatId; token: string }[]> { + const seats = allSeats().map(({ roomId, token }) => ({ roomId, token })); + const { seats: banked } = await call<{ seats: { roomId: string; seat: SeatId; token: string }[] }>('POST', '/api/seats', { seats, forget }); + return banked; +} + +/** The seats the bank holds that this browser does not, taken into storage; true when there were any. */ +export async function restoreSeats(): Promise { + let found = false; + for (const s of await bankSeats()) { + if (seatFor(s.roomId)) continue; + store(s.roomId, { seat: s.seat, token: s.token }); + found = true; + } + return found; } /** @@ -92,7 +115,7 @@ export function trustSeat(roomId: string): void { } } -export function rememberSeat(roomId: string, held: HeldSeat): void { +function store(roomId: string, held: HeldSeat): void { try { localStorage.setItem(SEATS_KEY, JSON.stringify({ ...heldSeats(), [roomId]: held })); } catch { @@ -100,6 +123,12 @@ export function rememberSeat(roomId: string, held: HeldSeat): void { } } +/** A seat taken is kept here and banked with the server, each time, so the two agree. */ +export function rememberSeat(roomId: string, held: HeldSeat): void { + store(roomId, held); + void bankSeats().catch(() => {}); +} + export class ServerError extends Error { constructor( message: string, @@ -152,6 +181,9 @@ export function makeApi() { hostPhrase: (roomId: string, token: string, seat: SeatId) => call<{ phrase: string; expiresAt: number }>('POST', `/api/rooms/${roomId}/phrase`, { token, seat }), standIn: (roomId: string, token: string, seat: SeatId) => call('POST', `/api/rooms/${roomId}/standin`, { token, seat }), claim: (phrase: string) => call<{ roomId: string; seat: SeatId; token: string }>('POST', '/api/transfer', { phrase }), + /** The seats a name holds at tables not over: those last used from this address come back seated; the rest are named. */ + findSeats: (name: string, roomId?: string) => + call<{ seated: { roomId: string; seat: SeatId; token: string }[]; elsewhere: { roomId: string; seat: SeatId; players: string[] }[] }>('POST', '/api/seats/find', { name, ...(roomId ? { roomId } : {}) }), report: (roomId: string, token: string, happened: string, expected: string) => call<{ id: string }>('POST', `/api/rooms/${roomId}/report`, { token, happened, expected }), reportImage: (id: string, file: File) => diff --git a/template/src/routes/join/[code]/+page.svelte b/template/src/routes/join/[code]/+page.svelte index a9a4ae0..c7b647f 100644 --- a/template/src/routes/join/[code]/+page.svelte +++ b/template/src/routes/join/[code]/+page.svelte @@ -117,12 +117,45 @@ room = await Room.join(roomId, name.trim()); joining = false; } catch (err) { + // A seat already under this name may be this person's own, lost with the browser's storage: it is looked for first. + if (err instanceof ServerError && err.status === 409 && (await sitBackDown())) return; error = err instanceof Error ? err.message : 'The seat could not be taken.'; } finally { busy = false; } } + /** The seat under this name at this table, back again if it was last used from this address; true when it was. */ + async function sitBackDown(): Promise { + const r = await api.findSeats(name.trim(), roomId).catch(() => null); + const mine = r?.seated.find((s) => s.roomId === roomId); + if (!mine) { + if (r?.elsewhere.length) error = `A seat here is under that name, last used from another address. Ask the host for a transfer phrase, or write to the keeper.`; + return false; + } + rememberSeat(roomId, { seat: mine.seat, token: mine.token }); + trustSeat(roomId); + seatLost = ''; + room = await Room.resume(roomId, { seat: mine.seat, token: mine.token }); + joining = false; + return true; + } + + /** From the gallery of a game begun: the seat under this name, if it was last used from here. */ + async function findMySeat(e: SubmitEvent) { + e.preventDefault(); + busy = true; + error = ''; + try { + rememberName(name); + if (!(await sitBackDown()) && !error) error = 'No seat here is under that name.'; + } catch (err) { + error = err instanceof Error ? err.message : 'The seat could not be found.'; + } finally { + busy = false; + } + } + async function copyLink() { try { await navigator.clipboard.writeText(link); @@ -247,7 +280,11 @@
{#if seatLost}

{seatLost} Claim it here, or write to the keeper.

{:else}{#if room.view.expected.length > 0}

{room.nameOf(room.view.host)} began the game before {room.view.expected.join(' and ')} arrived, and the seat held for {room.view.expected.length === 1 ? 'them' : 'each'} went with it.

{/if} -

Had a seat at this table? Ask the host for a transfer phrase, or make one on the device that still holds the seat, and claim it here. Or write to the keeper for a link that brings it back.

{/if} +

Had a seat at this table? Give your name, and if the seat was last used from here it is yours again. Otherwise ask the host for a transfer phrase, or make one on the device that still holds the seat, and claim it here. Or write to the keeper for a link that brings it back.

{/if} +
+ + +