Production: the tafl droplet at 209.97.148.244, tafl.kestrelsnest.social, and the Sentry project

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
This commit is contained in:
Eric Wagoner
2026-09-23 13:17:09 -04:00
co-authored by Claude Fable 5.1
parent 0d2f54fe02
commit fc62034290
13 changed files with 29 additions and 27 deletions
+1 -1
View File
@@ -7,7 +7,7 @@ description: The weekly Hnefatafl operations pulse — game server health, error
One command on the droplet prints everything; read it as a short report. One command on the droplet prints everything; read it as a short report.
ssh root@__IP__ hnefatafl-pulse.sh ssh root@209.97.148.244 hnefatafl-pulse.sh
- **game server**: whether the service and Caddy are up, restarts and - **game server**: whether the service and Caddy are up, restarts and
journal errors in the last seven days with the last three error lines. journal errors in the last seven days with the last three error lines.
+5 -5
View File
@@ -7,7 +7,7 @@ description: Work the Hnefatafl reports desk — fetch players' bug reports, sho
Players file reports from the Report button in a room's masthead (seated Players file reports from the Report button in a room's masthead (seated
or from the gallery). Each lands in `/var/lib/hnefatafl/feedback.jsonl` or from the gallery). Each lands in `/var/lib/hnefatafl/feedback.jsonl`
on the droplet (__IP__) pinned with `roomId`, `turn` (the turn on the droplet (209.97.148.244) pinned with `roomId`, `turn` (the turn
being written when it was filed) and `seq` (the ledger's length then), being written when it was filed) and `seq` (the ledger's length then),
enough to replay the game to the moment. Replies live in the same file enough to replay the game to the moment. Replies live in the same file
and appear under the report in the player's hall. This desk fetches, and appear under the report in the player's hall. This desk fetches,
@@ -34,7 +34,7 @@ Every report and every player's answer rings a Sentry issue (project
hnefatafl, fingerprinted per report or per line); the desk's own hnefatafl, fingerprinted per report or per line); the desk's own
replies ring nothing. replies ring nothing.
Fetch: `ssh root@__IP__ 'cat /var/lib/hnefatafl/feedback.jsonl'` Fetch: `ssh root@209.97.148.244 'cat /var/lib/hnefatafl/feedback.jsonl'`
## a) Fetch and b) display ## a) Fetch and b) display
@@ -47,7 +47,7 @@ to hear his players' voices; never compress their words into a table.
## c) Process an unanswered report ## c) Process an unanswered report
1. **Replay to the pin.** `scp root@__IP__:/var/lib/hnefatafl/rooms/<roomId>.jsonl <scratchpad>/` 1. **Replay to the pin.** `scp root@209.97.148.244:/var/lib/hnefatafl/rooms/<roomId>.jsonl <scratchpad>/`
and replay it with the engine as `deploy/replay-ledgers.ts` does: and replay it with the engine as `deploy/replay-ledgers.ts` does:
`game.create(names, start.seed, start.rules ?? 1)` then `game.create(names, start.seed, start.rules ?? 1)` then
`game.resolve(state, line.inputs)` per turn line, printing the state `game.resolve(state, line.inputs)` per turn line, printing the state
@@ -70,8 +70,8 @@ to hear his players' voices; never compress their words into a table.
passing `rules` to `createGame`). Old ledgers then replay as their passing `rules` to `createGame`). Old ledgers then replay as their
players saw them; new games get the fix. players saw them; new games get the fix.
Either way add a test in `src/lib/game/*.test.ts` pinning the Either way add a test in `src/lib/game/*.test.ts` pinning the
corrected behaviour. Deploy with `deploy/deploy.sh __IP__`. corrected behaviour. Deploy with `deploy/deploy.sh 209.97.148.244`.
5. **Reply.** `bash deploy/report-reply.sh __IP__ <reportId> <status> "text"`. 5. **Reply.** `bash deploy/report-reply.sh 209.97.148.244 <reportId> <status> "text"`.
Pass the id as ONE clean argument. Write to the PLAYER: name what you Pass the id as ONE clean argument. Write to the PLAYER: name what you
replayed, cite the rule, and say plainly what was wrong or why nothing replayed, cite the rule, and say plainly what was wrong or why nothing
was. The desk's voice is warm and specific. was. The desk's voice is warm and specific.
+1 -1
View File
@@ -10,7 +10,7 @@ host glancing over the hall.
## Gather ## Gather
ssh root@__IP__ hnefatafl-visitors.sh [YYYY-MM-DD] ssh root@209.97.148.244 hnefatafl-visitors.sh [YYYY-MM-DD]
Default is today, UTC; Eric is in US Eastern, so an evening at his desk Default is today, UTC; Eric is in US Eastern, so an evening at his desk
spills into the next UTC day. Run yesterday too when the hour is early. spills into the next UTC day. Run yesterday too when the hour is early.
+6 -6
View File
@@ -21,13 +21,13 @@ uptime and upgrades independent of anything else.
## Current production ## Current production
- Droplet: `hnefatafl` (nyc3, s-1vcpu-512mb-10gb, tag `hnefatafl`), IP __IP__ - Droplet: `hnefatafl` (nyc3, s-1vcpu-512mb-10gb, tag `hnefatafl`), IP 209.97.148.244
- URLs: https://hnefatafl.kestrelsnest.social (A record at Hover, where kestrelsnest.social's - URLs: https://tafl.kestrelsnest.social (A record at Hover, where kestrelsnest.social's
DNS lives) and https://hnefatafl.__IP__.sslip.io (always works, zero DNS). DNS lives) and https://hnefatafl.209.97.148.244.sslip.io (always works, zero DNS).
- Everyday deploy: `deploy/deploy.sh __IP__` - Everyday deploy: `deploy/deploy.sh 209.97.148.244`
- Players' reports: `deploy/pull-reports.sh` mirrors /var/lib/hnefatafl/feedback.jsonl - Players' reports: `deploy/pull-reports.sh` mirrors /var/lib/hnefatafl/feedback.jsonl
and the screenshots to ~/Desktop/hnefatafl-reports with a digest; and the screenshots to ~/Desktop/hnefatafl-reports with a digest;
`deploy/report-reply.sh __IP__ <id> <status> "text"` answers one. `deploy/report-reply.sh 209.97.148.244 <id> <status> "text"` answers one.
## New droplet from scratch ## New droplet from scratch
@@ -35,7 +35,7 @@ uptime and upgrades independent of anything else.
--size s-1vcpu-512mb-10gb --image ubuntu-24-04-x64 \ --size s-1vcpu-512mb-10gb --image ubuntu-24-04-x64 \
--ssh-keys <your-key-ids> --tag-name hnefatafl --wait` --ssh-keys <your-key-ids> --tag-name hnefatafl --wait`
2. `scp deploy/setup-droplet.sh deploy/Caddyfile.tmpl root@<ip>:/root/ && ssh root@<ip> \ 2. `scp deploy/setup-droplet.sh deploy/Caddyfile.tmpl root@<ip>:/root/ && ssh root@<ip> \
"bash /root/setup-droplet.sh 'hnefatafl.kestrelsnest.social, hnefatafl.<ip>.sslip.io'"` "bash /root/setup-droplet.sh 'tafl.kestrelsnest.social, hnefatafl.<ip>.sslip.io'"`
(point the A record at the new IP first, or leave the real name out until it is). (point the A record at the new IP first, or leave the real name out until it is).
3. `scp deploy/setup-server.sh deploy/Caddyfile.tmpl root@<ip>:/root/ && ssh root@<ip> "bash /root/setup-server.sh"` 3. `scp deploy/setup-server.sh deploy/Caddyfile.tmpl root@<ip>:/root/ && ssh root@<ip> "bash /root/setup-server.sh"`
4. `deploy/deploy.sh <ip>` 4. `deploy/deploy.sh <ip>`
+1 -1
View File
@@ -57,7 +57,7 @@ for f in sorted(glob.glob("/var/lib/caddy/access*.log*")):
elif path == "/guide": pages["guide"] += 1 elif path == "/guide": pages["guide"] += 1
elif path.startswith("/join/"): joins[path.split("/")[2].upper()] += 1 elif path.startswith("/join/"): joins[path.split("/")[2].upper()] += 1
ref = " ".join(h.get("Referer", [""])) ref = " ".join(h.get("Referer", [""]))
if ref and "hnefatafl.kestrelsnest.social" not in ref: if ref and "tafl.kestrelsnest.social" not in ref:
refs[re.sub(r"^https?://", "", ref).split("/")[0]] += 1 refs[re.sub(r"^https?://", "", ref).split("/")[0]] += 1
m = re.search(r"[?&](ref|utm_source|fbclid)=([^&]*)", uri) m = re.search(r"[?&](ref|utm_source|fbclid)=([^&]*)", uri)
if m and (ip, m.group(1)) not in seen_campaign: if m and (ip, m.group(1)) not in seen_campaign:
+1 -1
View File
@@ -13,7 +13,7 @@ Environment=KEEPER_TZ=America/New_York
Environment=HOST=127.0.0.1 Environment=HOST=127.0.0.1
Environment=DATA_DIR=/var/lib/hnefatafl/rooms Environment=DATA_DIR=/var/lib/hnefatafl/rooms
# Create the Sentry project (the Sentry MCP can) and paste its DSN here; empty means no error reporting. # Create the Sentry project (the Sentry MCP can) and paste its DSN here; empty means no error reporting.
Environment=SENTRY_DSN=__SENTRY_DSN__ Environment=SENTRY_DSN=https://2e3589c41ea45a58049740d3af64face@o4509525984149504.ingest.us.sentry.io/4512136791130112
ExecStart=/opt/hnefatafl/app/server/node_modules/.bin/tsx src/index.ts ExecStart=/opt/hnefatafl/app/server/node_modules/.bin/tsx src/index.ts
Restart=always Restart=always
RestartSec=3 RestartSec=3
+1 -1
View File
@@ -3,7 +3,7 @@
# to a local folder, and write a digest beside them for reading. # to a local folder, and write a digest beside them for reading.
# deploy/pull-reports.sh [host] [folder] (default ~/Desktop/hnefatafl-reports) # deploy/pull-reports.sh [host] [folder] (default ~/Desktop/hnefatafl-reports)
set -euo pipefail set -euo pipefail
HOST="${1:-__IP__}" HOST="${1:-209.97.148.244}"
OUT="${2:-$HOME/Desktop/hnefatafl-reports}" OUT="${2:-$HOME/Desktop/hnefatafl-reports}"
mkdir -p "$OUT/images" mkdir -p "$OUT/images"
scp -q "root@$HOST:/var/lib/hnefatafl/feedback.jsonl" "$OUT/feedback.jsonl" 2>/dev/null || : > "$OUT/feedback.jsonl" scp -q "root@$HOST:/var/lib/hnefatafl/feedback.jsonl" "$OUT/feedback.jsonl" 2>/dev/null || : > "$OUT/feedback.jsonl"
+2 -2
View File
@@ -19,9 +19,9 @@ AUTH="Authorization: Bearer $SENTRY_TOKEN"
# The Slack action takes the channel's ID as well as its name; the # The Slack action takes the channel's ID as well as its name; the
# project's error detector is looked up by project id; a workflow is bound # project's error detector is looked up by project id; a workflow is bound
# to its detectors by PUT after creation. # to its detectors by PUT after creation.
SLACK_ID="${SENTRY_SLACK_INTEGRATION:-__SENTRY_SLACK_INTEGRATION__}" SLACK_ID="${SENTRY_SLACK_INTEGRATION:-345334}"
CHANNEL_ID="${SLACK_CHANNEL_ID:-__SLACK_CHANNEL_ID__}" CHANNEL_ID="${SLACK_CHANNEL_ID:-__SLACK_CHANNEL_ID__}"
PROJECT_ID="${SENTRY_PROJECT_ID:-__SENTRY_PROJECT_ID__}" PROJECT_ID="${SENTRY_PROJECT_ID:-4512136791130112}"
echo "Slack integration $SLACK_ID; project $PROJECT_ID; channel $CHANNEL ($CHANNEL_ID)" echo "Slack integration $SLACK_ID; project $PROJECT_ID; channel $CHANNEL ($CHANNEL_ID)"
DETECTOR_ID=$(curl -s "$API/detectors/?project=$PROJECT_ID" -H "$AUTH" | python3 -c ' DETECTOR_ID=$(curl -s "$API/detectors/?project=$PROJECT_ID" -H "$AUTH" | python3 -c '
+4 -2
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# One-time droplet setup. Copy this script and Caddyfile.tmpl to the droplet # One-time droplet setup. Copy this script and Caddyfile.tmpl to the droplet
# and run ON the droplet as root: # and run ON the droplet as root:
# bash setup-droplet.sh 'hnefatafl.kestrelsnest.social, hnefatafl.<droplet-ip>.sslip.io' # bash setup-droplet.sh 'tafl.kestrelsnest.social, hnefatafl.<droplet-ip>.sslip.io'
# The argument is the Caddy site address line: one name, or several # The argument is the Caddy site address line: one name, or several
# separated by commas. Every name must already resolve to this droplet. # separated by commas. Every name must already resolve to this droplet.
# Hnefatafl is a static site: Caddy serves the built files and terminates # Hnefatafl is a static site: Caddy serves the built files and terminates
@@ -28,7 +28,9 @@ chmod -R g+rX /opt/hnefatafl
# Caddy: the site and the game server's paths, from the template beside this script. # Caddy: the site and the game server's paths, from the template beside this script.
sed "s|__HOST__|$HOST|" "$(dirname "$0")/Caddyfile.tmpl" > /etc/caddy/Caddyfile sed "s|__HOST__|$HOST|" "$(dirname "$0")/Caddyfile.tmpl" > /etc/caddy/Caddyfile
caddy validate --config /etc/caddy/Caddyfile caddy validate --config /etc/caddy/Caddyfile
systemctl reload caddy # Validation as root touches the access log; Caddy runs as caddy and must own it.
chown -R caddy:caddy /var/lib/caddy
systemctl restart caddy
# Firewall: ssh + web only. # Firewall: ssh + web only.
ufw allow OpenSSH ufw allow OpenSSH
+1 -1
View File
@@ -6,7 +6,7 @@
# deploy/verify-ledgers.sh <droplet-ip-or-host> [https://site] # deploy/verify-ledgers.sh <droplet-ip-or-host> [https://site]
set -euo pipefail set -euo pipefail
HOST="${1:?usage: verify-ledgers.sh <droplet-ip-or-host> [https://site]}" HOST="${1:?usage: verify-ledgers.sh <droplet-ip-or-host> [https://site]}"
SITE="${2:-https://hnefatafl.kestrelsnest.social}" SITE="${2:-https://tafl.kestrelsnest.social}"
DIR="$(mktemp -d)" DIR="$(mktemp -d)"
trap 'rm -rf "$DIR"' EXIT trap 'rm -rf "$DIR"' EXIT
rsync -az "root@$HOST:/var/lib/hnefatafl/rooms/" "$DIR/" rsync -az "root@$HOST:/var/lib/hnefatafl/rooms/" "$DIR/"
+1 -1
View File
@@ -57,7 +57,7 @@ for path in sorted(glob.glob("/var/lib/caddy/access.log*")):
elif path_only.startswith("/join/"): elif path_only.startswith("/join/"):
joins[path_only.split("/")[2].upper()] += 1 joins[path_only.split("/")[2].upper()] += 1
ref = " ".join(req.get("headers", {}).get("Referer", [""])) ref = " ".join(req.get("headers", {}).get("Referer", [""]))
if ref and "hnefatafl.kestrelsnest.social" not in ref: if ref and "tafl.kestrelsnest.social" not in ref:
referrers[re.sub(r"^https?://", "", ref).split("/")[0]] += 1 referrers[re.sub(r"^https?://", "", ref).split("/")[0]] += 1
m = re.search(r"[?&](ref|utm_source|fbclid)=([^&]*)", uri) m = re.search(r"[?&](ref|utm_source|fbclid)=([^&]*)", uri)
if m: if m:
+1 -1
View File
@@ -31,7 +31,7 @@ import { Store } from './store';
const PORT = Number(process.env.PORT ?? '8789'); const PORT = Number(process.env.PORT ?? '8789');
const HOST = process.env.HOST ?? '127.0.0.1'; const HOST = process.env.HOST ?? '127.0.0.1';
const DATA_DIR = process.env.DATA_DIR ?? '../data/rooms'; const DATA_DIR = process.env.DATA_DIR ?? '../data/rooms';
const PUBLIC_URL = (process.env.PUBLIC_URL ?? 'https://hnefatafl.kestrelsnest.social').replace(/\/$/, ''); const PUBLIC_URL = (process.env.PUBLIC_URL ?? 'https://tafl.kestrelsnest.social').replace(/\/$/, '');
/** The keeper of the site, whom a table may call to a seat, and where they sleep. */ /** The keeper of the site, whom a table may call to a seat, and where they sleep. */
const KEEPER = process.env.KEEPER ?? 'the keeper'; const KEEPER = process.env.KEEPER ?? 'the keeper';
const KEEPER_TZ = process.env.KEEPER_TZ ?? 'America/New_York'; const KEEPER_TZ = process.env.KEEPER_TZ ?? 'America/New_York';
+4 -4
View File
@@ -7,20 +7,20 @@
<meta name="theme-color" content="#121a20" /> <meta name="theme-color" content="#121a20" />
<title>Hnefatafl</title> <title>Hnefatafl</title>
<meta name="description" content="Hnefatafl, the Viking board game, free in the browser: play the bot or a friend, by Copenhagen rules or Linnaeus's Tablut." /> <meta name="description" content="Hnefatafl, the Viking board game, free in the browser: play the bot or a friend, by Copenhagen rules or Linnaeus's Tablut." />
<link rel="canonical" href="https://hnefatafl.kestrelsnest.social/" /> <link rel="canonical" href="https://tafl.kestrelsnest.social/" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta property="og:type" content="website" /> <meta property="og:type" content="website" />
<meta property="og:site_name" content="Hnefatafl" /> <meta property="og:site_name" content="Hnefatafl" />
<meta property="og:title" content="Hnefatafl" /> <meta property="og:title" content="Hnefatafl" />
<meta property="og:description" content="Hnefatafl, the Viking board game, free in the browser: play the bot or a friend, by Copenhagen rules or Linnaeus's Tablut." /> <meta property="og:description" content="Hnefatafl, the Viking board game, free in the browser: play the bot or a friend, by Copenhagen rules or Linnaeus's Tablut." />
<meta property="og:url" content="https://hnefatafl.kestrelsnest.social/" /> <meta property="og:url" content="https://tafl.kestrelsnest.social/" />
<meta property="og:image" content="https://hnefatafl.kestrelsnest.social/og.png" /> <meta property="og:image" content="https://tafl.kestrelsnest.social/og.png" />
<meta property="og:image:width" content="1200" /> <meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" /> <meta property="og:image:height" content="630" />
<meta name="twitter:card" content="summary_large_image" /> <meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="Hnefatafl" /> <meta name="twitter:title" content="Hnefatafl" />
<meta name="twitter:description" content="Hnefatafl, the Viking board game, free in the browser: play the bot or a friend, by Copenhagen rules or Linnaeus's Tablut." /> <meta name="twitter:description" content="Hnefatafl, the Viking board game, free in the browser: play the bot or a friend, by Copenhagen rules or Linnaeus's Tablut." />
<meta name="twitter:image" content="https://hnefatafl.kestrelsnest.social/og.png" /> <meta name="twitter:image" content="https://tafl.kestrelsnest.social/og.png" />
%sveltekit.head% %sveltekit.head%
</head> </head>
<body data-sveltekit-preload-data="hover"> <body data-sveltekit-preload-data="hover">