The keeper can bring back a seat a browser lost: a reseat line on the ledger gives that wizard's seat a new token and the old one opens nothing after, wizwar-reseat.sh on the host asks for it through a keeper door that listens on the loopback alone and opens only to a key the deploy makes once, and the link it prints carries the seat in the fragment, which leaves the address bar at once and is kept only when no seat held in this browser still opens and the server takes it; a gallery watching a game in play is told both ways back, the front page asks before forgetting a game not yet finished, 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:33:04 -04:00
co-authored by Claude Opus 5.5
parent 1b9acfd7c4
commit 5ea0d4fdec
16 changed files with 423 additions and 16 deletions
+11
View File
@@ -52,4 +52,15 @@ longer replays becomes unreachable after restart, so this is not optional.
`setup-droplet.sh` does NOT install it — on a fresh droplet, copy the
script, configure rclone (the script refuses to run while the config still
holds its CHANGE_ME placeholder), and add the cron entry by hand.
- A player whose browser lost their seat (Safari's seven days, an app's own
browser, cleared data) writes to the keeper. On the droplet,
`wizwar-reseat.sh ROOM` lists who sits at the table and when each last
moved or spoke; `wizwar-reseat.sh ROOM NAME` gives that wizard's seat a new
token and prints a link carrying it in the fragment. The old token opens
nothing after. The script answers through the game server's keeper door,
a listener of its own on 127.0.0.1:9787 that Caddy never reaches, which
opens only to the key in /etc/wizwar/keeper.key (root and the wizwar user
alone can read it, so the other services on the host cannot use the door).
The first deploy makes the key, and every deploy installs the script at
/usr/local/bin.
- One-off backup: `scp -r root@<ip>:/var/lib/wizwar/rooms ./rooms-backup`
+8
View File
@@ -25,6 +25,14 @@ ssh "root@$HOST" '
install -m 755 /opt/wizwar/deploy/wizwar-backup.sh /usr/local/bin/wizwar-backup.sh
install -m 755 /opt/wizwar/deploy/wizwar-rollup.sh /usr/local/bin/wizwar-rollup.sh
install -m 755 /opt/wizwar/deploy/wizwar-visitors.sh /usr/local/bin/wizwar-visitors.sh
install -m 755 /opt/wizwar/deploy/wizwar-reseat.sh /usr/local/bin/wizwar-reseat.sh
# The key to the keeper door: made once, readable by root (the reseat
# script) and the wizwar user (the server) alone.
if [ ! -s /etc/wizwar/keeper.key ]; then
install -d -m 750 -o root -g wizwar /etc/wizwar
(umask 037; openssl rand -hex 32 > /etc/wizwar/keeper.key)
chown root:wizwar /etc/wizwar/keeper.key
fi
# 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
# its own schedules its own.
+36
View File
@@ -0,0 +1,36 @@
#!/bin/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,
# which listens on the loopback alone.
# wizwar-reseat.sh ROOM who sits at the table, and when each person last moved or spoke
# wizwar-reseat.sh ROOM NAME re-seat the wizard NAME and print the link
set -euo pipefail
ROOM="${1:?usage: wizwar-reseat.sh ROOM [NAME]}"
DOOR="http://127.0.0.1:${KEEPER_PORT:-9787}/keeper/rooms/${ROOM^^}"
KEY_FILE="${KEEPER_KEY_FILE:-/etc/wizwar/keeper.key}"
[ -s "$KEY_FILE" ] || { echo "No keeper's key at $KEY_FILE; a deploy makes it." >&2; exit 1; }
# The key goes as a header read from a file descriptor, never on a command line another user could see.
KEY="@/dev/fd/3"
exec 3< <(printf 'x-keeper-key: %s\n' "$(cat "$KEY_FILE")")
if [ $# -lt 2 ]; then
curl -s -H "$KEY" "$DOOR" | python3 -c '
import json, sys
r = json.load(sys.stdin)
if "error" in r: sys.exit(r["error"])
print("room %s, %s" % (r["roomId"], "finished" if r["finished"] else "in play" if r["started"] else "not begun"))
for s in r["seats"]:
when = "automaton" if s["bot"] else "never moved or spoke" if s["lastActed"] is None else "last acted " + s["lastActed"][:16].replace("T", " ") + " UTC"
print(" %-24s %-5s %s" % (s["name"], "host" if s["host"] else "", when))
'
else
python3 -c 'import json, sys; print(json.dumps({"seat": sys.argv[1]}))' "$2" \
| curl -s -X POST -H "$KEY" -H 'content-type: application/json' --data-binary @- "$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
+3
View File
@@ -9,6 +9,9 @@ WorkingDirectory=/opt/wizwar/packages/server
Environment=PORT=8787
# Caddy terminates TLS; the plaintext port must not face the internet.
Environment=HOST=127.0.0.1
# The keeper's door (deploy/wizwar-reseat.sh) binds the loopback whatever HOST says.
Environment=KEEPER_PORT=9787
Environment=KEEPER_KEY_FILE=/etc/wizwar/keeper.key
Environment=WIZWAR_DATA_DIR=/var/lib/wizwar/rooms
Environment=WIZWAR_STATIC_DIR=/opt/wizwar/packages/web/dist
Environment=WIZWAR_KEEPER_TZ=America/New_York