The keeper can bring back a seat a browser lost: a new token on the ledger, the old one refused, and a link that carries it in the fragment, minted by hnefatafl-reseat.sh through a /keeper door Caddy does not proxy and that turns away anything Caddy forwarded; the join page takes a linked seat only once the server accepts it and never over a seat this browser holds that still opens, a spectator at a game in play is told both ways back, the hall asks before forgetting a game in play, and the server's tests now run with the rest

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MbtUPJ24P6vc6tE1x6RS3m
This commit is contained in:
Eric Wagoner
2026-10-13 18:24:31 -04:00
co-authored by Claude Opus 5.5
parent 4b6cf01949
commit 8bbc41cc87
12 changed files with 194 additions and 9 deletions
+2
View File
@@ -53,6 +53,8 @@ A fresh host, when one is needed: `doctl compute droplet create hall --region ny
deploy, so the live copies are the repo copies:
- `hnefatafl-visitors.sh [day]`: who is here now and who came that day.
- `hnefatafl-reseat.sh ROOM [SEAT]`: who sits at a table; with a seat, a new
token for a player locked out of it, as a link to send them.
- `hnefatafl-pulse.sh`: the weekly health check (service, errors, rollup
trend, backup, box).
- `hnefatafl-rollup.sh [day]`: one JSON line per day of counts, run
+1
View File
@@ -42,6 +42,7 @@ ssh "root@$HOST" '
install -m 755 /opt/hnefatafl/app/deploy/hnefatafl-pulse.sh /usr/local/bin/hnefatafl-pulse.sh
install -m 755 /opt/hnefatafl/app/deploy/hnefatafl-rollup.sh /usr/local/bin/hnefatafl-rollup.sh
install -m 755 /opt/hnefatafl/app/deploy/hnefatafl-backup.sh /usr/local/bin/hnefatafl-backup.sh
install -m 755 /opt/hnefatafl/app/deploy/hnefatafl-reseat.sh /usr/local/bin/hnefatafl-reseat.sh
install -m 644 -D /opt/hnefatafl/app/deploy/traffic.py /usr/local/lib/hnefatafl/traffic.py
# On a host the hall shares, its hall-nightly.sh runs the rollup and backup
# of every game and checks in with Sentry once for all; a game on a box of
+29
View File
@@ -0,0 +1,29 @@
#!/usr/bin/env bash
# A way back for a player whose browser lost their seat: the seat gets a new
# token, the old one stops working, and the link printed carries the new one.
# Whether the one asking holds that seat is the keeper's call; send the link
# only to them. Runs ON the droplet, through the game server's keeper door.
# hnefatafl-reseat.sh ROOM who sits at the table, and when each person last moved or spoke
# hnefatafl-reseat.sh ROOM SEAT re-seat SEAT and print the link
set -euo pipefail
ROOM="${1:?usage: hnefatafl-reseat.sh ROOM [SEAT]}"
DOOR="http://127.0.0.1:8789/keeper/rooms/${ROOM^^}"
if [ $# -lt 2 ]; then
curl -s "$DOOR" | python3 -c '
import datetime, json, sys
r = json.load(sys.stdin)
if "error" in r: sys.exit(r["error"])
print("room %s, %s" % (r["roomId"], "in play" if r["started"] else "not begun"))
for s in r["seats"]:
when = "bot" if s["bot"] else "never moved or spoke" if s["lastActed"] is None else "last acted " + datetime.datetime.fromtimestamp(s["lastActed"] / 1000, datetime.timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
print(" %-3s %-24s %s" % (s["id"], s["name"], when))
'
else
curl -s -X POST -H 'content-type: application/json' -d "{\"seat\":\"$2\"}" "$DOOR/reseat" | python3 -c '
import json, sys
r = json.load(sys.stdin)
if "error" in r: sys.exit(r["error"])
print("%s has a new token; their old one opens nothing now. Send them this link:" % r["name"])
print(r["link"])
'
fi
+11
View File
@@ -37,6 +37,15 @@ README.
- **A seat travels by phrase**: *Transfer seat* in the masthead mints four
words good for ten minutes and one use; the hall's *bring a seat from
another device* claims them. The browser that claims holds the seat too.
- **The keeper can bring a seat back**: a player whose browser lost the
seat (Safari's seven days, an app's own browser, cleared data) writes to
the keeper, and `<slug>-reseat.sh ROOM SEAT` on the host gives the seat
a new token and prints a link carrying it in the fragment. The old token
opens nothing after. A link is kept only once the server takes it, and
never over a seat the browser holds that still opens. The script answers
through the game server's `/keeper` door, which Caddy does not proxy. A
spectator at a game in play is told both ways back, and the hall asks
before forgetting a game in play.
- **Held seats are doubted before they are dropped**: the hall forgets a
seat only on the second refusal in a row from the server. A restart, a
stale answer or the wrong backend answer with ignorance, and ignorance
@@ -123,6 +132,8 @@ Every game gets the same tools, installed by `deploy.sh` on every deploy:
- `<slug>-visitors.sh` (skill `<slug>-visitors`): who is here now, who came
today, every room opened and how far it got. Names seated for the first
time are marked NEW.
- `<slug>-reseat.sh ROOM [SEAT]`: who sits at a table and when each last
acted; with a seat, a new token for a player locked out, as a link.
- `<slug>-pulse.sh` (skill `<slug>-pulse`): service health, journal errors,
the rollup's last week, the backup's last word, the box's vitals.
- `<slug>-rollup.sh` nightly at 00:12 UTC: one JSON line a day of counts,
+20 -1
View File
@@ -23,7 +23,11 @@
// WS /ws?room=:id&token= {type:"update", seq} whenever the room changes; without a token,
// a seat in the Peanut Gallery, counted for the table
//
// Caddy serves the static site and proxies /api and /ws here.
// Caddy serves the static site and proxies /api and /ws here. The keeper's
// door answers only on the host itself (deploy/hnefatafl-reseat.sh):
//
// GET /keeper/rooms/:id who sits at the table, and when each person last moved or spoke
// POST /keeper/rooms/:id/reseat {seat} a new token for a player locked out, as a link to send them
import * as Sentry from '@sentry/node';
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
@@ -184,9 +188,24 @@ async function handleReports(req: IncomingMessage, res: ServerResponse, parts: s
throw new RoomError('Not here.', 404);
}
/** The keeper's door. Caddy proxies only /api and /ws, so it is reached from the host alone; a request Caddy forwarded is turned away besides. */
async function handleKeeper(req: IncomingMessage, res: ServerResponse, parts: string[]): Promise<void> {
if (req.headers['x-forwarded-for'] || parts[1] !== 'rooms') throw new RoomError('Not here.', 404);
const room = rooms.get(parts[2] ?? '');
if (parts.length === 3 && req.method === 'GET') return send(res, 200, { roomId: room.id, started: !!room.state, seats: rooms.seatsForKeeper(room) });
if (parts[3] === 'reseat' && parts.length === 4 && req.method === 'POST') {
const body = await readBody(req);
const { seat, token } = rooms.reseat(room, body.seat);
// The token rides the fragment, which the browser keeps to itself: it reaches neither Caddy's log nor this server.
return send(res, 200, { name: seat.name, link: `${PUBLIC_URL}/join/${room.id}#seat=${seat.id}.${token}` });
}
throw new RoomError('Not here.', 404);
}
async function handle(req: IncomingMessage, res: ServerResponse): Promise<void> {
const url = new URL(req.url ?? '/', 'http://localhost');
const parts = url.pathname.split('/').filter(Boolean);
if (parts[0] === 'keeper') return handleKeeper(req, res, parts);
if (parts[0] === 'api' && parts[1] === 'reports') return handleReports(req, res, parts);
if (parts[0] === 'api' && parts[1] === 'tally' && parts.length === 2 && req.method === 'GET') return send(res, 200, tallies.get());
if (parts[0] === 'api' && parts[1] === 'transfer' && parts.length === 2 && req.method === 'POST') {
+51
View File
@@ -0,0 +1,51 @@
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { game } from '../../src/lib/game';
import { Rooms } from './rooms';
import { Store } from './store';
let dir: string;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'rooms-'));
});
afterEach(() => {
rmSync(dir, { recursive: true, force: true });
});
/** A table of two: Ann, who opened it, and Bo, the game begun. */
function table() {
const rooms = new Rooms(game, new Store(dir));
const host = rooms.create('Ann', 2);
const guest = rooms.join(host.room, 'Bo');
rooms.begin(host.room, host.token);
return { rooms, room: host.room, ann: host.token, bo: guest.token };
}
describe('rooms', () => {
it("gives a seat a new token at the keeper's word: the old one opens nothing, and a restart keeps the change", () => {
const { rooms, room, bo } = table();
const phrase = rooms.transfer(room, bo).phrase;
const { seat, token } = rooms.reseat(room, 'B');
expect(seat.name).toBe('Bo');
expect(rooms.seatOf(room, token).id).toBe('B');
expect(() => rooms.seatOf(room, bo)).toThrow(/opens no seat/);
// A phrase minted with the old token carries nothing now.
expect(() => rooms.claim(phrase)).toThrow(/unknown or has expired/);
const reopened = new Rooms(game, new Store(dir)).get(room.id);
expect(rooms.seatOf(reopened, token).id).toBe('B');
expect(() => rooms.seatOf(reopened, bo)).toThrow(/opens no seat/);
});
it('re-seats only a person, and says when each person last acted', () => {
const { rooms, room, ann, bo } = table();
expect(() => rooms.reseat(room, 'Z')).toThrow(/No person sits/);
const mover = rooms.waitingOn(room)[0];
rooms.submit(room, mover === 'A' ? ann : bo, game.botInput(room.state!, mover), 0);
const seats = rooms.seatsForKeeper(room);
expect(seats.map((s) => [s.id, s.name])).toEqual([['A', 'Ann'], ['B', 'Bo']]);
expect(seats.find((s) => s.id === mover)!.lastActed).toBeGreaterThan(0);
expect(seats.find((s) => s.id !== mover)!.lastActed).toBeNull();
});
});
+29
View File
@@ -350,6 +350,30 @@ export class Rooms<State, Input> {
return { seat, token };
}
/**
* The keeper gives a person's seat a new token, for a player whose browser lost the old one. Whether the one
* asking holds the seat is the keeper's judgment; this carries it out. The old token opens nothing after.
*/
reseat(room: Room<State>, seatId: unknown): { seat: Seat; token: string } {
const seat = room.seats.find((s) => s.id === seatId && !s.bot);
if (!seat) throw new RoomError('No person sits in that seat.', 404);
const token = newId(18);
this.commit(room, { t: 'reseat', at: Date.now(), id: seat.id, tokenHash: hashToken(token) });
// A phrase minted with the old token would carry a seat that no longer answers to it.
for (const [phrase, t] of this.transfers) if (t.roomId === room.id && t.seatId === seat.id) this.transfers.delete(phrase);
return { seat, token };
}
/** The table as the keeper sees it before re-seating: who sits where, and when each person last moved or spoke. */
seatsForKeeper(room: Room<State>): { id: SeatId; name: string; bot: boolean; lastActed: number | null }[] {
const last = new Map<SeatId, number>();
for (const line of this.store.read(room.id)) {
if (line.t === 'input' || line.t === 'chat') last.set(line.id, line.at);
else if (line.t === 'turn') for (const id of Object.keys(line.inputs)) last.set(id, line.at);
}
return room.seats.map((s) => ({ id: s.id, name: s.name, bot: s.bot, lastActed: s.bot ? null : (last.get(s.id) ?? null) }));
}
/** The host sends a bot away before the game begins. People leave by not coming back. */
unseat(room: Room<State>, token: string | undefined, seatId: unknown): void {
const host = this.seatOf(room, token);
@@ -486,6 +510,11 @@ export class Rooms<State, Input> {
case 'seat':
room.seats.push({ id: line.id, name: line.name, tokenHash: line.tokenHash, bot: line.bot });
break;
case 'reseat': {
const seat = room.seats.find((s) => s.id === line.id);
if (seat) seat.tokenHash = line.tokenHash;
break;
}
case 'galleryTalk':
room.galleryTalk = line.on;
break;
+2
View File
@@ -11,6 +11,8 @@ export type LedgerLine =
| { t: 'room'; id: string; seats: number; createdAt: number; rematchOf?: string; expected?: string[]; options: TableOptions }
/** A seat holds only the hash of its token; the token itself goes once to the browser that earned it. A bot's hash is empty. */
| { t: 'seat'; id: SeatId; name: string; tokenHash: string; bot: boolean }
/** The keeper gave a person's seat a new token, for a player whose browser lost the old one; the old one opens nothing after. */
| { t: 'reseat'; at: number; id: SeatId; tokenHash: string }
/** 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. */
+17 -1
View File
@@ -146,7 +146,14 @@
if (c) void goto(`/join/${c}`);
}
/** A game in play asks before it is forgotten: this browser may be the only thing that holds the seat. */
let forgetting = $state<string | null>(null);
function forget(roomId: string) {
if (!games[roomId]?.over && forgetting !== roomId) {
forgetting = roomId;
return;
}
forgetting = null;
forgetSeat(roomId);
seats = allSeats();
}
@@ -253,7 +260,11 @@
<span class="ledger-code">{held.roomId}</span>
<span class="ledger-info">{status(held)}{#if unread(held) > 0} · <span class="talk-new">{unread(held)} new {unread(held) === 1 ? 'line' : 'lines'} of talk</span>{/if}</span>
</a>
<button type="button" class="dismiss" title="forget this game" onclick={() => forget(held.roomId)}>×</button>
{#if forgetting === held.roomId}
<span class="forget-ask">This browser may be all that holds your seat. <button type="button" class="quiet" onclick={() => forget(held.roomId)}>Forget it</button> <button type="button" class="quiet" onclick={() => (forgetting = null)}>Keep it</button></span>
{:else}
<button type="button" class="dismiss" title="forget this game" onclick={() => forget(held.roomId)}>×</button>
{/if}
</div>
{/each}
{#if reports.length}
@@ -478,6 +489,11 @@
background: rgba(240, 165, 58, 0.08);
}
.forget-ask {
font-size: 0.85em;
flex: none;
}
.ledger-row.your-move .ledger-info {
color: var(--ember);
}
+6
View File
@@ -19,6 +19,12 @@ function heldSeats(): Record<string, HeldSeat> {
}
}
/** A seat the keeper sent in a link, `#seat=<seat>.<token>`: a fragment stays in the browser, out of every log. */
export function seatInLink(hash: string): HeldSeat | null {
const m = /^#seat=([^.]+)\.([A-Za-z0-9_-]+)$/.exec(hash);
return m ? { seat: m[1], token: m[2] } : null;
}
export function seatFor(roomId: string): HeldSeat | null {
return heldSeats()[roomId] ?? null;
}
+25 -6
View File
@@ -1,11 +1,11 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { goto, replaceState } from '$app/navigation';
import { page } from '$app/state';
import Board from '$lib/components/Board.svelte';
import Lobby from '$lib/components/Lobby.svelte';
import ReportSlip from '$lib/components/ReportSlip.svelte';
import Preferences from '$lib/components/Preferences.svelte';
import { seatFor } from '$lib/net/client';
import { rememberSeat, seatFor, seatInLink, trustSeat, type HeldSeat } from '$lib/net/client';
import { NAME_MAX, playerName, rememberName, Room } from '$lib/net/room.svelte';
const roomId = $derived((page.params.code ?? '').toUpperCase());
@@ -25,16 +25,31 @@
const link = $derived(`${location.origin}/join/${roomId}`);
const watching = $derived(room?.spectating ?? false);
// Return to a held seat, or take a place in the gallery and offer one.
// Return to a held seat, or take a place in the gallery and offer one. A link from the keeper carries a seat:
// it leaves the address bar at once, is kept only once the server takes it, and never replaces a seat that
// still opens, so a link someone else sends cannot take a working seat away.
$effect(() => {
const id = roomId;
room = null;
error = '';
const sent = seatInLink(location.hash);
if (sent) replaceState(location.pathname + location.search, {});
const held = seatFor(id);
const arrive = held ? Room.resume(id, held) : Room.watch(id);
joining = !held;
const take = (s: HeldSeat) =>
Room.resume(id, s).then((r) => {
rememberSeat(id, s);
trustSeat(id);
return r;
});
const arrive = held ? Room.resume(id, held).catch((e: Error) => (sent ? take(sent) : Promise.reject(e))) : sent ? take(sent).catch(() => Room.watch(id)) : Room.watch(id);
joining = !held && !sent;
if (!held) name = playerName();
arrive.then((r) => (room = r)).catch((e: Error) => (error = e.message));
arrive
.then((r) => {
room = r;
if (r.spectating) joining = true;
})
.catch((e: Error) => (error = e.message));
});
// Keep the view fresh for as long as the room is on screen.
@@ -171,6 +186,10 @@
</section>
{/if}
{#if watching && room?.started && !room.view.over}
<p class="notice muted">Had a seat at this table on another device? Transfer it from there and claim the phrase in the hall, or <a href="mailto:eric@ericwagoner.com?subject={encodeURIComponent(`My seat in room ${roomId}`)}">write to the keeper</a> for a link that brings it back.</p>
{/if}
{#if room && !room.started}
<Lobby {room} {link} {copied} {copyLink} />
{:else if room}
+1 -1
View File
@@ -21,6 +21,6 @@ export default defineConfig({
}
},
test: {
include: ['src/**/*.test.ts']
include: ['src/**/*.test.ts', 'server/src/**/*.test.ts']
}
});