The credibility pass on Hnefatafl, ported to the template: the ops scripts count traffic through one parser, the reports digest is one program, the plaintext-token fallback and its migration are gone, and the stylesheet holds each shared rule once
From the hnefatafl repo's pass of the same day, everything that touched a kit-shared file. The visitors digest and the nightly rollup share deploy/traffic.py, installed to /usr/local/lib/<slug>; the rollup writes the finished-games count it computed behind "and False". pull-reports.sh and the reports skill both use deploy/report-digest.ts. The seat line requires its token hash and the start line its rules revision; the migration for ledgers written before hashing goes with them. The route table in server/src/index.ts lists every route; Report, ReportLine and Tally are declared once in view.ts for both sides; exports nobody imported are exports no more. In the client: .small, the × that dismisses, and the frame of the reading pages are in app.css once; the preferences panel shares the report slip's modal shape; the room store gains seatEmpty and seatUnheld, and the lobby and the join page read those instead of three spellings of their own. The room's moved and awaiting fields stay: the demo board reads them, and simultaneous rounds are the contract. The deploy README no longer describes a browser-only game; the visitors skill no longer names a /play route; the reports skill's replay call carries the room's options. The Slack channel id is passed in the environment rather than filled in as a placeholder. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GwFKMuQnPAEHJ5yA1q4orh
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
6c42e55f59
commit
33ac0ec808
@@ -31,7 +31,7 @@ handle @game {
|
||||
# <route>.html, so /rules is tried as /rules.html before falling back to the
|
||||
# app shell, which serves the rooms. Hashed assets under _app/immutable are
|
||||
# cached for a year; every other response is revalidated so a deploy shows
|
||||
# up on the next load. The two header matchers are disjoint.
|
||||
# up on the next load.
|
||||
handle {
|
||||
@immutable path /_app/immutable/*
|
||||
header @immutable Cache-Control "public, max-age=31536000, immutable"
|
||||
|
||||
+15
-24
@@ -1,12 +1,12 @@
|
||||
# Deploying __NAME__
|
||||
|
||||
The single-player game runs entirely in the browser. Duels between people
|
||||
go through a small Node process that keeps each room as an append-only
|
||||
ledger of moves in /var/lib/__SLUG__/rooms. Production is one
|
||||
DigitalOcean droplet: Caddy terminates TLS with automatic certificates,
|
||||
serves the static build from /opt/__SLUG__/build, and proxies /api and
|
||||
/ws to the game server on port __PORT__, which runs as the `__SLUG__` user
|
||||
under systemd from /opt/__SLUG__/app.
|
||||
Every game, against a housecarl or a friend, is a room on a small Node
|
||||
process that keeps it as an append-only ledger of moves in
|
||||
/var/lib/__SLUG__/rooms. Production is one DigitalOcean droplet: Caddy
|
||||
terminates TLS with automatic certificates, serves the static build from
|
||||
/opt/__SLUG__/build, and proxies /api and /ws to the game server on port
|
||||
__PORT__, which runs as the `__SLUG__` user under systemd from
|
||||
/opt/__SLUG__/app.
|
||||
|
||||
## Sizing
|
||||
|
||||
@@ -14,11 +14,6 @@ The smallest droplet, `s-1vcpu-512mb-10gb` ($4 a month), carries both Caddy
|
||||
and the game server comfortably: the server is one small Node process capped
|
||||
at 300 MB by its unit file, and a room is a few kilobytes of ledger.
|
||||
|
||||
The zero-cost alternative is a second site block in an existing Caddy
|
||||
server's configuration pointing at a second directory; the deploy script
|
||||
works unchanged against that host. A droplet of its own keeps this site's
|
||||
uptime and upgrades independent of anything else.
|
||||
|
||||
## Current production
|
||||
|
||||
- Droplet: `__SLUG__` (nyc3, s-1vcpu-512mb-10gb, tag `__SLUG__`), IP __IP__
|
||||
@@ -47,8 +42,9 @@ fetches the certificate on first request.
|
||||
|
||||
## Operations scripts on the droplet
|
||||
|
||||
`deploy.sh` installs these to /usr/local/bin and the cron file to
|
||||
/etc/cron.d/__SLUG__ on every deploy, so the live copies are the repo copies:
|
||||
`deploy.sh` installs these to /usr/local/bin, the `traffic.py` they share to
|
||||
/usr/local/lib/__SLUG__, and the cron file to /etc/cron.d/__SLUG__ on every
|
||||
deploy, so the live copies are the repo copies:
|
||||
|
||||
- `__SLUG__-visitors.sh [day]`: who is here now and who came that day.
|
||||
- `__SLUG__-pulse.sh`: the weekly health check (service, errors, rollup
|
||||
@@ -70,7 +66,8 @@ URL with the project's cron path and public key), so a missed night is noticed.
|
||||
To have every new issue, regression and reappearance posted to Slack the way
|
||||
wizwar's are, run `deploy/sentry-slack-alert.sh [#channel]` once from your own
|
||||
shell with `SENTRY_TOKEN` (an org auth token with alerts:write) and
|
||||
`SLACK_CHANNEL_ID` set; the token never leaves the shell.
|
||||
`SLACK_CHANNEL_ID` (the channel's Slack ID) set; the token never leaves the
|
||||
shell.
|
||||
|
||||
Before every deploy, `deploy/verify-ledgers.sh <ip>` fetches every production
|
||||
ledger and replays it with the local engine, comparing each room with what the
|
||||
@@ -83,8 +80,7 @@ deploy: the server would rewrite that game on restart.
|
||||
|
||||
Runs the type-checks, the tests and the build locally, rsyncs `build/` to
|
||||
the droplet keeping the previous week's hashed assets, rsyncs the server and
|
||||
engine sources, installs dependencies, and restarts the game server. A
|
||||
single-player game is in the player's own browser and loses nothing. A room
|
||||
engine sources, installs dependencies, and restarts the game server. A room
|
||||
is replayed from its ledger when the server comes back, which takes a few
|
||||
seconds; Caddy holds requests that land in the gap.
|
||||
|
||||
@@ -93,10 +89,5 @@ seconds; Caddy holds requests that land in the gap.
|
||||
- Logs: `ssh root@<ip> journalctl -u __SLUG__ -f` for the game server,
|
||||
`journalctl -u caddy -f` and /var/lib/caddy/access.log for the web side.
|
||||
- Restart: `ssh root@<ip> systemctl restart __SLUG__`
|
||||
- Who has been playing: `ssh root@<ip> __SLUG__-visitors.sh [YYYY-MM-DD]`
|
||||
prints today's visitors from Caddy's log (people and bots apart, by page),
|
||||
every room opened today with how far it got, and the names seated, marking
|
||||
those seen for the first time.
|
||||
- Rooms: `/var/lib/__SLUG__/rooms/<CODE>.jsonl`, one ledger per game.
|
||||
Copy that directory to back them up; single-player games are in players'
|
||||
browsers.
|
||||
- Rooms: `/var/lib/__SLUG__/rooms/<CODE>.jsonl`, one ledger per game; the
|
||||
nightly backup keeps them in the Space.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
#!/bin/bash
|
||||
#!/usr/bin/env bash
|
||||
# Nightly ledger backup to DigitalOcean Spaces, one prefix per game in the shared bucket.
|
||||
# current/ - exact mirror of /var/lib/__SLUG__
|
||||
# snapshots/ - one dated copy per day, pruned after 90 days
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
#!/bin/bash
|
||||
#!/usr/bin/env bash
|
||||
# The operations pulse, printed ON the droplet: the game server's health,
|
||||
# errors in its journal, the last week of the rollup, the backup's last word,
|
||||
# and the box's vitals. Read by the __SLUG__-pulse skill.
|
||||
@@ -10,7 +10,7 @@ systemctl is-active caddy >/dev/null && echo "caddy: active" || echo "caddy: NOT
|
||||
echo "restarts in 7 days: $(journalctl -u __SLUG__ --since '7 days ago' -o cat | grep -c 'Started __SLUG__')"
|
||||
ERR=$(journalctl -u __SLUG__ --since '7 days ago' -p err -o cat | grep -vc '^$')
|
||||
echo "journal errors in 7 days: $ERR"
|
||||
journalctl -u __SLUG__ --since '7 days ago' -o cat | grep -iE "error|unhandled|exception" | grep -v "Rate" | tail -3 | sed 's/^/ /'
|
||||
journalctl -u __SLUG__ --since '7 days ago' -o cat | grep -iE "error|unhandled|exception" | tail -3 | sed 's/^/ /'
|
||||
echo "--- rollup, last 7 days (people / requests / rooms opened / turns)"
|
||||
if [ -s /var/lib/__SLUG__/rollup.jsonl ]; then
|
||||
tail -7 /var/lib/__SLUG__/rollup.jsonl | python3 -c '
|
||||
@@ -1,4 +1,4 @@
|
||||
#!/bin/bash
|
||||
#!/usr/bin/env bash
|
||||
# Nightly rollup: one JSON line per day in /var/lib/__SLUG__/rollup.jsonl:
|
||||
# the day's traffic from Caddy's access log (people and bots apart, by page,
|
||||
# with referrers and the campaign tags links carry), the rooms opened,
|
||||
@@ -24,45 +24,13 @@ checkin() {
|
||||
mkdir -p "$(dirname "$LOG")"
|
||||
checkin in_progress
|
||||
if python3 - "$DAY" "$OUT" <<'PY' >> "$LOG" 2>&1
|
||||
import collections, datetime, glob, gzip, json, os, re, shutil, sys
|
||||
import datetime, glob, json, os, shutil, sys
|
||||
sys.path.insert(0, "/usr/local/lib/__SLUG__")
|
||||
import traffic
|
||||
day, out = sys.argv[1], sys.argv[2]
|
||||
d0 = datetime.datetime.fromisoformat(day).replace(tzinfo=datetime.timezone.utc)
|
||||
t0, t1 = d0.timestamp(), (d0 + datetime.timedelta(days=1)).timestamp()
|
||||
BOT = re.compile(r"bot|crawl|spider|slurp|facebookexternalhit|slack|discord|twitter|preview|fetch|curl|python|go-http|wget|headless|scan|monitor|uptime", re.I)
|
||||
|
||||
req = human = bot = 0
|
||||
hips, bips = set(), set()
|
||||
pages, joins, refs, campaigns = collections.Counter(), collections.Counter(), collections.Counter(), collections.Counter()
|
||||
seen_campaign = set()
|
||||
for f in sorted(glob.glob("/var/lib/caddy/access*.log*")):
|
||||
if os.path.getmtime(f) < t0: continue
|
||||
opener = gzip.open if f.endswith(".gz") else open
|
||||
with opener(f, "rt", errors="ignore") as fh:
|
||||
for line in fh:
|
||||
try: r = json.loads(line)
|
||||
except ValueError: continue
|
||||
ts = r.get("ts", 0)
|
||||
if not (t0 <= ts < t1): continue
|
||||
q = r.get("request", {}); h = q.get("headers", {})
|
||||
ua = " ".join(h.get("User-Agent", [""]))
|
||||
ip = q.get("client_ip") or q.get("remote_ip") or "?"
|
||||
uri = q.get("uri", ""); path = uri.split("?")[0]
|
||||
req += 1
|
||||
if BOT.search(ua) or not ua:
|
||||
bot += 1; bips.add(ip); continue
|
||||
if path.startswith(("/_app/", "/api/")) or path == "/ws" or path.endswith((".png", ".ico", ".txt")): continue
|
||||
human += 1; hips.add(ip)
|
||||
if path == "/": pages["hall"] += 1
|
||||
elif path == "/rules": pages["rules"] += 1
|
||||
elif path == "/guide": pages["guide"] += 1
|
||||
elif path.startswith("/join/"): joins[path.split("/")[2].upper()] += 1
|
||||
ref = " ".join(h.get("Referer", [""]))
|
||||
if ref and "__DOMAIN__" not in ref:
|
||||
refs[re.sub(r"^https?://", "", ref).split("/")[0]] += 1
|
||||
m = re.search(r"[?&](ref|utm_source|fbclid)=([^&]*)", uri)
|
||||
if m and (ip, m.group(1)) not in seen_campaign:
|
||||
seen_campaign.add((ip, m.group(1)))
|
||||
campaigns[m.group(1) + ("=" + m.group(2)[:24] if m.group(1) != "fbclid" else "")] += 1
|
||||
t = traffic.scan(day)
|
||||
|
||||
created = started = finished = turned = turns = 0
|
||||
humans, bots = set(), 0
|
||||
@@ -80,20 +48,18 @@ for f in glob.glob("/var/lib/__SLUG__/rooms/*.jsonl"):
|
||||
if x["bot"]: bots += 1
|
||||
else: humans.add(x["name"])
|
||||
elif x["t"] == "unseat": bots -= 1
|
||||
day_turns = [x for x in lines if x["t"] == "turn" and t0 <= x["at"] / 1000 < t1]
|
||||
turns += len(day_turns)
|
||||
# A game finished today: its last turn is today's and the engine would say so; approximate by replay-free means:
|
||||
# the ledger's final turn falls today and no turn follows it within the day's end.
|
||||
if day_turns and day_turns[-1] is lines[-1] and False: finished += 1
|
||||
turns += sum(1 for x in lines if x["t"] == "turn" and t0 <= x["at"] / 1000 < t1)
|
||||
# The over line is written with the turn that ends the game, so a finish counts on its own day.
|
||||
if any(x["t"] == "over" and t0 <= x["at"] / 1000 < t1 for x in lines): finished += 1
|
||||
|
||||
disk = shutil.disk_usage("/")
|
||||
mem = {}
|
||||
for line in open("/proc/meminfo"):
|
||||
k, v = line.split(":", 1); mem[k] = int(v.split()[0])
|
||||
load = os.getloadavg()[1]
|
||||
row = dict(day=day, requests=req, human=human, bot=bot, humanAddresses=len(hips), botAddresses=len(bips),
|
||||
paths=dict(pages), roomLinks=sum(joins.values()), referrers=dict(refs), campaigns=dict(campaigns),
|
||||
rooms=dict(created=created, started=started, withTurns=turned, humanNames=len(humans), botSeats=bots),
|
||||
row = dict(day=day, requests=t["requests"], human=t["human"], bot=t["bot"], humanAddresses=len(t["addresses"]), botAddresses=len(t["botAddresses"]),
|
||||
paths=dict(t["pages"]), roomLinks=sum(t["joins"].values()), referrers=dict(t["referrers"]), campaigns=dict(t["campaigns"]),
|
||||
rooms=dict(created=created, started=started, withTurns=turned, finished=finished, humanNames=len(humans), botSeats=bots),
|
||||
turns=turns, ledgers=len(glob.glob("/var/lib/__SLUG__/rooms/*.jsonl")),
|
||||
vitals=dict(diskUsedPct=round(disk.used * 100 / disk.total), memAvailableMb=mem.get("MemAvailable", 0) // 1024, load5=round(load, 2)),
|
||||
at=datetime.datetime.now(datetime.timezone.utc).isoformat(timespec="seconds"))
|
||||
@@ -105,6 +71,6 @@ rows.sort(key=lambda r: r["day"])
|
||||
with open(out + ".tmp", "w") as fh:
|
||||
for r in rows: fh.write(json.dumps(r) + "\n")
|
||||
os.replace(out + ".tmp", out)
|
||||
print("%s rolled: %d people, %d requests, %d rooms" % (day, len(hips), human, created))
|
||||
print("%s rolled: %d people, %d requests, %d rooms" % (day, len(t["addresses"]), t["human"], created))
|
||||
PY
|
||||
then checkin ok; else checkin error; fi
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
#!/bin/bash
|
||||
#!/usr/bin/env bash
|
||||
# The visitors digest: who is at the table right now and who came today,
|
||||
# the rooms opened and how far each got, with today's traffic so far.
|
||||
# Runs ON the droplet: visitors.sh [YYYY-MM-DD] (default: today, UTC)
|
||||
# Runs ON the droplet: __SLUG__-visitors.sh [YYYY-MM-DD] (default: today, UTC)
|
||||
# Every game, bot games included, is a room with a ledger, so all show in full.
|
||||
set -u
|
||||
DAY="${1:-$(date -u +%F)}"
|
||||
python3 - "$DAY" <<'PY'
|
||||
import datetime, glob, json, re, subprocess, sys
|
||||
from collections import Counter
|
||||
import datetime, glob, json, subprocess, sys
|
||||
sys.path.insert(0, "/usr/local/lib/__SLUG__")
|
||||
import traffic
|
||||
|
||||
day = sys.argv[1]
|
||||
now = datetime.datetime.now(datetime.timezone.utc)
|
||||
@@ -24,50 +25,10 @@ print("live sockets: %s | rooms touched in the last hour: %s" % (
|
||||
sh("find /var/lib/__SLUG__/rooms -name '*.jsonl' -mmin -60 2>/dev/null | wc -l")))
|
||||
|
||||
# --- traffic: Caddy's access log, people and bots apart ---
|
||||
BOT = re.compile(r"bot|crawl|spider|slurp|facebookexternalhit|preview|fetch|curl|python|go-http|headless|scan|monitor|uptime", re.I)
|
||||
PAGES = {"/": "hall", "/rules": "rules", "/guide": "guide"}
|
||||
addresses, bots = set(), set()
|
||||
pages, joins, referrers, campaigns, agents = Counter(), Counter(), Counter(), Counter(), Counter()
|
||||
requests = 0
|
||||
for path in sorted(glob.glob("/var/lib/caddy/access.log*")):
|
||||
try:
|
||||
with open(path) as f:
|
||||
for line in f:
|
||||
try:
|
||||
x = json.loads(line)
|
||||
except Exception:
|
||||
continue
|
||||
ts = datetime.datetime.fromtimestamp(x["ts"], datetime.timezone.utc)
|
||||
if ts.strftime("%F") != day:
|
||||
continue
|
||||
req = x["request"]
|
||||
ua = " ".join(req.get("headers", {}).get("User-Agent", [""]))
|
||||
ip = req.get("remote_ip") or req.get("client_ip", "?")
|
||||
uri = req.get("uri", "")
|
||||
path_only = uri.split("?")[0]
|
||||
if BOT.search(ua) or not ua:
|
||||
bots.add(ip)
|
||||
continue
|
||||
if path_only.startswith("/_app/") or path_only.startswith("/api/") or path_only == "/ws" or path_only.endswith((".png", ".ico", ".txt")):
|
||||
continue
|
||||
requests += 1
|
||||
addresses.add(ip)
|
||||
if path_only in PAGES:
|
||||
pages[PAGES[path_only]] += 1
|
||||
elif path_only.startswith("/join/"):
|
||||
joins[path_only.split("/")[2].upper()] += 1
|
||||
ref = " ".join(req.get("headers", {}).get("Referer", [""]))
|
||||
if ref and "__DOMAIN__" not in ref:
|
||||
referrers[re.sub(r"^https?://", "", ref).split("/")[0]] += 1
|
||||
m = re.search(r"[?&](ref|utm_source|fbclid)=([^&]*)", uri)
|
||||
if m:
|
||||
campaigns[m.group(1) + "=" + m.group(2)[:24]] += 1
|
||||
agents[re.sub(r"\(.*?\)", "", ua)[:40].strip()] += 1
|
||||
except OSError:
|
||||
continue
|
||||
t = traffic.scan(day)
|
||||
print("traffic: %d addresses of people, %d page requests (%d bot addresses); hall %d, rules %d, guide %d, room links %s; referrers %s; campaigns %s" % (
|
||||
len(addresses), requests, len(bots), pages["hall"], pages["rules"], pages["guide"],
|
||||
dict(joins) or "none", dict(referrers) or "none", dict(campaigns) or "none"))
|
||||
len(t["addresses"]), t["human"], len(t["botAddresses"]), t["pages"]["hall"], t["pages"]["rules"], t["pages"]["guide"],
|
||||
dict(t["joins"]) or "none", dict(t["referrers"]) or "none", dict(t["campaigns"]) or "none"))
|
||||
|
||||
# --- rooms: every ledger, today's in detail ---
|
||||
first_seen = {}
|
||||
@@ -87,7 +48,7 @@ for f in glob.glob("/var/lib/__SLUG__/rooms/*.jsonl"):
|
||||
(bots_seated if x["bot"] else humans).append(x["name"])
|
||||
names[x["id"]] = x["name"]
|
||||
elif x["t"] == "unseat":
|
||||
bots_seated.remove(names.pop(x["id"], None)) if x["id"] in names else None
|
||||
if x["id"] in names: bots_seated.remove(names.pop(x["id"]))
|
||||
elif x["t"] == "start":
|
||||
started = True
|
||||
elif x["t"] == "turn":
|
||||
@@ -12,7 +12,7 @@ Environment=KEEPER_TZ=America/New_York
|
||||
# Caddy terminates TLS; the plaintext port must not face the internet.
|
||||
Environment=HOST=127.0.0.1
|
||||
Environment=DATA_DIR=/var/lib/__SLUG__/rooms
|
||||
# Create the Sentry project (the Sentry MCP can) and paste its DSN here; empty means no error reporting.
|
||||
# Empty means no error reporting.
|
||||
Environment=SENTRY_DSN=__SENTRY_DSN__
|
||||
ExecStart=/opt/__SLUG__/app/server/node_modules/.bin/tsx src/index.ts
|
||||
Restart=always
|
||||
|
||||
@@ -25,7 +25,7 @@ rsync -az --delete --filter='P _app/immutable/*' \
|
||||
# it needs only its code and the engine. Ledgers live outside this tree.
|
||||
rsync -az --delete --exclude='/server/node_modules' \
|
||||
--include='/server/***' --include='/src/' --include='/src/lib/' --include='/src/lib/game/***' --include='/src/lib/net/' --include='/src/lib/net/view.ts' \
|
||||
--include='/deploy/' --include='/deploy/__SLUG__.service' --include='/deploy/__SLUG__.cron' --include='/deploy/*.sh' --include='/deploy/hash-tokens.ts' \
|
||||
--include='/deploy/' --include='/deploy/__SLUG__.service' --include='/deploy/__SLUG__.cron' --include='/deploy/*.sh' --include='/deploy/traffic.py' \
|
||||
--exclude='*' \
|
||||
./ "root@$HOST:/opt/__SLUG__/app/"
|
||||
|
||||
@@ -36,12 +36,13 @@ ssh "root@$HOST" '
|
||||
cd /opt/__SLUG__/app/server && npm install --no-audit --no-fund
|
||||
chown -R __SLUG__:__SLUG__ /opt/__SLUG__/app
|
||||
cp /opt/__SLUG__/app/deploy/__SLUG__.service /etc/systemd/system/__SLUG__.service
|
||||
# Cron runs the rollup and the backup from /usr/local/bin: install them on
|
||||
# every deploy so the live copies are always the repo copies.
|
||||
install -m 755 /opt/__SLUG__/app/deploy/visitors.sh /usr/local/bin/__SLUG__-visitors.sh
|
||||
install -m 755 /opt/__SLUG__/app/deploy/pulse.sh /usr/local/bin/__SLUG__-pulse.sh
|
||||
# The ops scripts run from /usr/local/bin and share traffic.py: install them
|
||||
# on every deploy so the live copies are always the repo copies.
|
||||
install -m 755 /opt/__SLUG__/app/deploy/__SLUG__-visitors.sh /usr/local/bin/__SLUG__-visitors.sh
|
||||
install -m 755 /opt/__SLUG__/app/deploy/__SLUG__-pulse.sh /usr/local/bin/__SLUG__-pulse.sh
|
||||
install -m 755 /opt/__SLUG__/app/deploy/__SLUG__-rollup.sh /usr/local/bin/__SLUG__-rollup.sh
|
||||
install -m 755 /opt/__SLUG__/app/deploy/__SLUG__-backup.sh /usr/local/bin/__SLUG__-backup.sh
|
||||
install -m 644 -D /opt/__SLUG__/app/deploy/traffic.py /usr/local/lib/__SLUG__/traffic.py
|
||||
install -m 644 /opt/__SLUG__/app/deploy/__SLUG__.cron /etc/cron.d/__SLUG__
|
||||
mkdir -p /var/log/__SLUG__
|
||||
systemctl daemon-reload
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
// One-time migration: replace each seat line's raw token with its SHA-256,
|
||||
// so no ledger on disk or in a backup holds a live seat key. Idempotent; a
|
||||
// line already hashed is left alone. Run ON the droplet from the server
|
||||
// directory (tsx is installed there):
|
||||
// cd /opt/__SLUG__/app/server && npx tsx ../deploy/hash-tokens.ts /var/lib/__SLUG__/rooms
|
||||
// Browsers keep their raw tokens; the server hashes what they send and
|
||||
// compares, so nobody loses a seat.
|
||||
|
||||
import { createHash } from 'node:crypto';
|
||||
import { readdirSync, readFileSync, renameSync, statSync, writeFileSync, chownSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
const dir = process.argv[2];
|
||||
if (!dir) {
|
||||
console.error('usage: hash-tokens.ts <ledger-dir>');
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
let files = 0;
|
||||
let lines = 0;
|
||||
for (const file of readdirSync(dir).filter((f) => f.endsWith('.jsonl'))) {
|
||||
const path = join(dir, file);
|
||||
const raw = readFileSync(path, 'utf8');
|
||||
let changed = 0;
|
||||
const out = raw
|
||||
.split('\n')
|
||||
.map((line) => {
|
||||
if (!line.trim()) return line;
|
||||
const entry = JSON.parse(line) as Record<string, unknown>;
|
||||
if (entry.t !== 'seat' || typeof entry.token !== 'string') return line;
|
||||
const { token, ...rest } = entry;
|
||||
changed += 1;
|
||||
return JSON.stringify({ ...rest, tokenHash: token ? createHash('sha256').update(token).digest('hex') : '' });
|
||||
})
|
||||
.join('\n');
|
||||
if (!changed) continue;
|
||||
const { uid, gid } = statSync(path);
|
||||
writeFileSync(path + '.tmp', out);
|
||||
chownSync(path + '.tmp', uid, gid);
|
||||
renameSync(path + '.tmp', path);
|
||||
files += 1;
|
||||
lines += changed;
|
||||
}
|
||||
console.log(`${files} ledger${files === 1 ? '' : 's'} rewritten, ${lines} seat line${lines === 1 ? '' : 's'} hashed`);
|
||||
@@ -1,6 +1,7 @@
|
||||
#!/bin/bash
|
||||
#!/usr/bin/env bash
|
||||
# Mirror the player reports, the keeper's replies and the players' screenshots
|
||||
# to a local folder, and write a digest beside them for reading.
|
||||
# to a local folder, and write a digest beside them for reading. Run from the
|
||||
# repository root:
|
||||
# deploy/pull-reports.sh [host] [folder] (default ~/Desktop/__SLUG__-reports)
|
||||
set -euo pipefail
|
||||
HOST="${1:-__IP__}"
|
||||
@@ -8,34 +9,4 @@ OUT="${2:-$HOME/Desktop/__SLUG__-reports}"
|
||||
mkdir -p "$OUT/images"
|
||||
scp -q "root@$HOST:/var/lib/__SLUG__/feedback.jsonl" "$OUT/feedback.jsonl" 2>/dev/null || : > "$OUT/feedback.jsonl"
|
||||
rsync -aq "root@$HOST:/var/lib/__SLUG__/feedback-images/" "$OUT/images/" 2>/dev/null || true
|
||||
python3 - "$OUT" <<'PY'
|
||||
import json, sys, os
|
||||
out = sys.argv[1]
|
||||
reports, order = {}, []
|
||||
for raw in open(os.path.join(out, "feedback.jsonl"), encoding="utf8"):
|
||||
raw = raw.strip()
|
||||
if not raw: continue
|
||||
line = json.loads(raw)
|
||||
if "reportId" in line:
|
||||
r = reports.get(line["reportId"])
|
||||
if not r: continue
|
||||
if "image" in line: r["image"] = line["image"]
|
||||
else: r["replies"].append(line) # the keeper's replies and the player's answers alike, in order
|
||||
continue
|
||||
reports[line["id"]] = dict(line, replies=[]); order.append(line["id"])
|
||||
lines = ["# __NAME__ reports", "", f"{len(order)} reports; newest first. Pictures are in `images/`.", ""]
|
||||
for rid in reversed(order):
|
||||
r = reports[rid]
|
||||
lines.append(f"## {r.get('at','')[:16].replace('T',' ')} — {r.get('player','?')} in {r.get('roomId','?')} (turn {r.get('turn','?')}, seq {r.get('seq','?')}) — id {rid}")
|
||||
lines.append("")
|
||||
lines.append(f"**What happened:** {r.get('happened','').strip()}")
|
||||
if r.get("expected","").strip(): lines.append(f"**What they expected:** {r['expected'].strip()}")
|
||||
if r.get("image"): lines.append(f"**Picture:** ![{r['image']}](images/{r['image']})")
|
||||
for rep in r["replies"]:
|
||||
who = f"{r.get('player','the player')} answers" if rep.get("from") == "player" else f"**{rep.get('status','')}**"
|
||||
lines.append(f"> {who} ({rep.get('at','')[:16].replace('T',' ')}): {rep.get('text','').strip()}")
|
||||
if not r["replies"] or r["replies"][-1].get("from") == "player": lines.append("> _awaiting the keeper_")
|
||||
lines.append("")
|
||||
open(os.path.join(out, "reports.md"), "w", encoding="utf8").write("\n".join(lines))
|
||||
print(f"{len(order)} reports, {sum(1 for r in reports.values() if r.get('image'))} with pictures -> {out}/reports.md")
|
||||
PY
|
||||
npx tsx deploy/report-digest.ts "$OUT"
|
||||
|
||||
@@ -40,7 +40,7 @@ async function main(): Promise<void> {
|
||||
for (const line of lines) {
|
||||
if (line.t === 'seat') seats.push(line);
|
||||
else if (line.t === 'unseat') seats.splice(seats.findIndex((s) => s.id === line.id), 1);
|
||||
else if (line.t === 'start') state = game.create(Object.fromEntries(seats.map((s) => [s.id, s.name])), line.seed, line.rules ?? 1, options);
|
||||
else if (line.t === 'start') state = game.create(Object.fromEntries(seats.map((s) => [s.id, s.name])), line.seed, line.rules, options);
|
||||
else if (line.t === 'turn' && state) {
|
||||
state = game.resolve(state, line.inputs);
|
||||
turns += 1;
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
// The reports digest: every report with its exchange, newest first, written
|
||||
// as reports.md beside a mirrored feedback.jsonl. Used by deploy/pull-reports.sh.
|
||||
// tsx deploy/report-digest.ts <folder>
|
||||
|
||||
import { writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { Reports } from '../server/src/reports';
|
||||
|
||||
const dir = process.argv[2];
|
||||
if (!dir) {
|
||||
console.error('usage: report-digest.ts <folder>');
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const when = (iso: string) => iso.slice(0, 16).replace('T', ' ');
|
||||
const reports = new Reports(dir).read().reverse();
|
||||
const lines = ['# __NAME__ reports', '', `${reports.length} reports; newest first. Pictures are in \`images/\`.`, ''];
|
||||
for (const r of reports) {
|
||||
lines.push(`## ${when(r.at)} — ${r.player} in ${r.roomId} (turn ${r.turn ?? '?'}, seq ${r.seq}) — id ${r.id}`, '');
|
||||
lines.push(`**What happened:** ${r.happened.trim()}`);
|
||||
if (r.expected.trim()) lines.push(`**What they expected:** ${r.expected.trim()}`);
|
||||
if (r.image) lines.push(`**Picture:** `);
|
||||
for (const line of r.thread) {
|
||||
const who = line.from === 'player' ? `${r.player} answers` : `**${line.status}**`;
|
||||
lines.push(`> ${who} (${when(line.at)}): ${line.text.trim()}`);
|
||||
}
|
||||
const last = r.thread[r.thread.length - 1];
|
||||
if (!last || last.from === 'player') lines.push('> _awaiting the keeper_');
|
||||
lines.push('');
|
||||
}
|
||||
writeFileSync(join(dir, 'reports.md'), lines.join('\n'));
|
||||
console.log(`${reports.length} reports, ${reports.filter((r) => r.image).length} with pictures -> ${join(dir, 'reports.md')}`);
|
||||
@@ -1,4 +1,4 @@
|
||||
#!/bin/bash
|
||||
#!/usr/bin/env bash
|
||||
# Answer a player's report; the reply appears under it in their hall.
|
||||
# deploy/report-reply.sh <host> <reportId> <resolved|by-design|open> <text...>
|
||||
set -euo pipefail
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
#!/bin/bash
|
||||
#!/usr/bin/env bash
|
||||
# Route every __NAME__ issue to a Slack channel: new issues (any level or
|
||||
# priority), regressions, and reappearances. Creates one Sentry workflow
|
||||
# via the alerts API. The token never leaves your shell:
|
||||
# SENTRY_TOKEN=sntryu_... deploy/sentry-slack-alert.sh [#channel]
|
||||
# The channel defaults to #__SLUG__-notifications (__SLACK_CHANNEL_ID__); pass
|
||||
# SLACK_CHANNEL_ID with the channel name to route elsewhere.
|
||||
# SENTRY_TOKEN=sntryu_... SLACK_CHANNEL_ID=C... deploy/sentry-slack-alert.sh [#channel]
|
||||
# SLACK_CHANNEL_ID is the channel's Slack ID; the name defaults to
|
||||
# #__SLUG__-notifications and is shown in Sentry beside the ID.
|
||||
# The token needs the alerts:write scope (an org auth token from
|
||||
# https://locallygrownnet.sentry.io/settings/auth-tokens/), and Slack must
|
||||
# already be connected to the org under Settings → Integrations.
|
||||
@@ -20,7 +20,8 @@ AUTH="Authorization: Bearer $SENTRY_TOKEN"
|
||||
# project's error detector is looked up by project id; a workflow is bound
|
||||
# to its detectors by PUT after creation.
|
||||
SLACK_ID="${SENTRY_SLACK_INTEGRATION:-__SENTRY_SLACK_INTEGRATION__}"
|
||||
CHANNEL_ID="${SLACK_CHANNEL_ID:-__SLACK_CHANNEL_ID__}"
|
||||
: "${SLACK_CHANNEL_ID:?SLACK_CHANNEL_ID=C... is required (the Slack ID of the channel)}"
|
||||
CHANNEL_ID="$SLACK_CHANNEL_ID"
|
||||
PROJECT_ID="${SENTRY_PROJECT_ID:-__SENTRY_PROJECT_ID__}"
|
||||
echo "Slack integration $SLACK_ID; project $PROJECT_ID; channel $CHANNEL ($CHANNEL_ID)"
|
||||
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
# bash setup-droplet.sh '__DOMAIN__, __SLUG__.<droplet-ip>.sslip.io'
|
||||
# The argument is the Caddy site address line: one name, or several
|
||||
# separated by commas. Every name must already resolve to this droplet.
|
||||
# __NAME__ is a static site: Caddy serves the built files and terminates
|
||||
# TLS. There is no application process to install or supervise.
|
||||
# Caddy serves the built files and terminates TLS; setup-server.sh adds the
|
||||
# game server behind it.
|
||||
set -euo pipefail
|
||||
HOST="${1:?usage: setup-droplet.sh <hostname>}"
|
||||
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
"""Caddy's access log for one day, people and bots apart. The visitors
|
||||
digest and the nightly rollup both count through here, so they agree."""
|
||||
|
||||
import datetime
|
||||
import glob
|
||||
import gzip
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from collections import Counter
|
||||
|
||||
BOT = re.compile(r"bot|crawl|spider|slurp|facebookexternalhit|slack|discord|twitter|preview|fetch|curl|python|go-http|wget|headless|scan|monitor|uptime", re.I)
|
||||
PAGES = {"/": "hall", "/rules": "rules", "/guide": "guide"}
|
||||
SITE = "__DOMAIN__"
|
||||
LOGS = "/var/lib/caddy/access*.log*"
|
||||
|
||||
|
||||
def scan(day, logs=LOGS):
|
||||
"""Counts for one UTC day (YYYY-MM-DD): every request, page requests by
|
||||
people, the addresses of people and of bots, requests by page, room links
|
||||
opened, referrers, and campaign tags (each counted once per address)."""
|
||||
d0 = datetime.datetime.fromisoformat(day).replace(tzinfo=datetime.timezone.utc)
|
||||
t0, t1 = d0.timestamp(), (d0 + datetime.timedelta(days=1)).timestamp()
|
||||
t = dict(requests=0, human=0, bot=0, addresses=set(), botAddresses=set(), pages=Counter(), joins=Counter(), referrers=Counter(), campaigns=Counter())
|
||||
seen_campaign = set()
|
||||
for f in sorted(glob.glob(logs)):
|
||||
if os.path.getmtime(f) < t0:
|
||||
continue
|
||||
opener = gzip.open if f.endswith(".gz") else open
|
||||
try:
|
||||
with opener(f, "rt", errors="ignore") as fh:
|
||||
for line in fh:
|
||||
try:
|
||||
r = json.loads(line)
|
||||
except ValueError:
|
||||
continue
|
||||
if not (t0 <= r.get("ts", 0) < t1):
|
||||
continue
|
||||
q = r.get("request", {})
|
||||
h = q.get("headers", {})
|
||||
ua = " ".join(h.get("User-Agent", [""]))
|
||||
ip = q.get("client_ip") or q.get("remote_ip") or "?"
|
||||
uri = q.get("uri", "")
|
||||
path = uri.split("?")[0]
|
||||
t["requests"] += 1
|
||||
if BOT.search(ua) or not ua:
|
||||
t["bot"] += 1
|
||||
t["botAddresses"].add(ip)
|
||||
continue
|
||||
if path.startswith(("/_app/", "/api/")) or path == "/ws" or path.endswith((".png", ".ico", ".txt")):
|
||||
continue
|
||||
t["human"] += 1
|
||||
t["addresses"].add(ip)
|
||||
if path in PAGES:
|
||||
t["pages"][PAGES[path]] += 1
|
||||
elif path.startswith("/join/"):
|
||||
t["joins"][path.split("/")[2].upper()] += 1
|
||||
ref = " ".join(h.get("Referer", [""]))
|
||||
if ref and SITE not in ref:
|
||||
t["referrers"][re.sub(r"^https?://", "", ref).split("/")[0]] += 1
|
||||
m = re.search(r"[?&](ref|utm_source|fbclid)=([^&]*)", uri)
|
||||
if m and (ip, m.group(1)) not in seen_campaign:
|
||||
seen_campaign.add((ip, m.group(1)))
|
||||
t["campaigns"][m.group(1) + ("=" + m.group(2)[:24] if m.group(1) != "fbclid" else "")] += 1
|
||||
except OSError:
|
||||
continue
|
||||
return t
|
||||
@@ -6,7 +6,7 @@
|
||||
# deploy/verify-ledgers.sh <droplet-ip-or-host> [https://site]
|
||||
set -euo pipefail
|
||||
HOST="${1:?usage: verify-ledgers.sh <droplet-ip-or-host> [https://site]}"
|
||||
# SITE in the environment overrides the name, for a machine whose DNS has not caught up with a new record.
|
||||
# SITE overrides the public name: the sslip.io address, or a name not yet in DNS.
|
||||
SITE="${2:-${SITE:-https://__DOMAIN__}}"
|
||||
DIR="$(mktemp -d)"
|
||||
trap 'rm -rf "$DIR"' EXIT
|
||||
|
||||
Reference in New Issue
Block a user