Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 34 additions & 13 deletions .claude/hooks/session-start.sh
Original file line number Diff line number Diff line change
Expand Up @@ -90,47 +90,68 @@ else
add "⚠️ Docker Hub login: skipped — no creds (pulls may hit the anon rate limit)"
fi

# ── 3. Proxy-CA build secret (local-only compose override) ────────────────────
# The sandbox MITMs TLS; only the host trusts the egress CA, so build containers
# can't verify pypi/npm. Hand the full host CA bundle to the build as a secret.
# ── 3. Proxy-CA build secret + runtime trust (local-only compose override) ────
# The sandbox MITMs TLS; only the host trusts the egress CA. Build containers
# can't verify pypi/npm (handled via the BuildKit `proxy_ca` secret), and — just
# as important — the *running* web container egresses through the same proxy, so
# the app's own httpx clients (drand, discord, places) can't verify api.drand.sh
# et al. either. Mount the full host CA bundle into the container at runtime and
# point the standard CA env vars at it so trust_env-aware clients pick it up.
CA_BUNDLE="${SSL_CERT_FILE:-/etc/ssl/certs/ca-certificates.crt}"
OVERRIDE="$CLAUDE_PROJECT_DIR/docker-compose.override.yml"
CA_IN_CONTAINER="/etc/ssl/ccr-ca-bundle.crt"
if [ -r "$CA_BUNDLE" ]; then
cat > "$OVERRIDE" <<EOF
# AUTO-GENERATED by .claude/hooks/session-start.sh — DO NOT COMMIT (git-ignored).
# Feeds the sandbox egress-proxy CA into the image build so in-build npm/pip can
# verify TLS through the intercepting proxy. Regenerated each remote session.
# Feeds the sandbox egress-proxy CA into (a) the image build as a BuildKit secret
# so in-build npm/pip verify TLS, and (b) the running web container so the app's
# httpx clients (drand/discord/places) trust the intercepting proxy at runtime.
# Regenerated each remote session.
services:
web:
build:
context: .
secrets:
- proxy_ca
volumes:
- $CA_BUNDLE:$CA_IN_CONTAINER:ro
environment:
SSL_CERT_FILE: $CA_IN_CONTAINER
REQUESTS_CA_BUNDLE: $CA_IN_CONTAINER
secrets:
proxy_ca:
file: $CA_BUNDLE
EOF
log "wrote $OVERRIDE (proxy_ca <- $CA_BUNDLE)"
add "✅ Proxy-CA override: wrote docker-compose.override.yml (proxy_ca <- $CA_BUNDLE)"
log "wrote $OVERRIDE (proxy_ca + runtime SSL_CERT_FILE <- $CA_BUNDLE)"
add "✅ Proxy-CA override: wrote docker-compose.override.yml (build secret + runtime CA <- $CA_BUNDLE)"
else
log "no readable CA bundle at $CA_BUNDLE; skipping build-secret override"
add "⚠️ Proxy-CA override: skipped — no readable CA bundle at $CA_BUNDLE"
fi

# ── 4. Playwright MCP browser ─────────────────────────────────────────────────
# The Playwright MCP servers (.mcp.json) launch with `--browser chromium`, which
# resolves to a chrome-for-testing build that is NOT baked into the image — so a
# fresh container can't drive a browser until it's fetched. Ensure it's present.
# resolves to Playwright's OWN pinned Chromium build (revision 1232 for the
# current @playwright/mcp), NOT Google's chrome-for-testing. Installing
# chrome-for-testing (the old value here) left `--browser chromium` with no
# executable, so every MCP server failed to launch a browser and none of the
# mcp__playwright__* tools registered. Install the correct build — both the full
# browser and the headless-shell the servers use.
# Idempotent: install-browser is a fast no-op when the build is already there.
# NB: route ALL its output to the log — this hook's STDOUT is reserved for the
# additionalContext JSON below, and download progress bars would corrupt it.
if command -v npx >/dev/null 2>&1; then
log "ensuring Playwright MCP browser (chrome-for-testing) is installed…"
if npx --yes @playwright/mcp@latest install-browser chrome-for-testing >>"$LOGFILE" 2>&1; then
log "ensuring Playwright MCP browser (chromium) is installed…"
# A stale/corrupt @playwright/mcp npx cache (seen as a broken 1.62.0-alpha with
# a missing playwright-core) also breaks server launch — clear it first so
# @latest re-resolves cleanly.
rm -rf /root/.npm/_npx 2>/dev/null || true
if PLAYWRIGHT_BROWSERS_PATH="${PLAYWRIGHT_BROWSERS_PATH:-/opt/pw-browsers}" \
npx --yes @playwright/mcp@latest install-browser chromium >>"$LOGFILE" 2>&1; then
log "Playwright MCP browser ready"
add "✅ Playwright browser: chrome-for-testing ready"
add "✅ Playwright browser: chromium ready"
else
log "WARNING: failed to install chrome-for-testing for Playwright MCP"
log "WARNING: failed to install chromium for Playwright MCP"
add "‼️ Playwright browser: install FAILED (browser-driving may not work)"
fi
else
Expand Down
10 changes: 5 additions & 5 deletions .claude/skills/test-game/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -200,10 +200,10 @@ In **instance #2** (`mcp__playwright-guest__*`), navigate to:
http://localhost:8888/<GAME_CODE>
```

Verify:
- The join screen (`#join`) is active
- `#code-input` is pre-filled with `GAME_CODE` (deep-link works — `/<CODE>` is the primary format; the legacy `?join=<CODE>` still works as a fallback)
- The URL has been cleaned to `/` (no code remains in the address bar)
Verify (the deep-link now opens the **join sheet on the landing screen** — `openJoinOnLanding` in `static/js/router.js` — not a separate `#join` screen):
- `#landing` is the active screen and the `#join-sheet` `<dialog>` is **open** (`document.getElementById('join-sheet').open === true`)
- `#code-input` (inside the sheet) is pre-filled with `GAME_CODE` (deep-link works — `/<CODE>` is the primary format; the legacy `?join=<CODE>` still works as a fallback)
- The URL is canonicalised to `/<CODE>` (the legacy `?join=` form is rewritten to the path form)

Type `Beta` into `#join-name-input`, then submit the join form (`#join-form button[type="submit"]`).

Expand Down Expand Up @@ -492,7 +492,7 @@ After the winner overlay appears, wait (up to 4 seconds, less if Step 13 already
Verify on both tabs:
- Winner overlay is closed — `document.getElementById('winner-overlay').open === false`
- `round_num` has incremented by 1
- `target` has changed and matches the cycle **1→2→3→4→5→6→1** (one step up, wrapping 6→1). This is the live check of `next_target` (`t % 6 + 1` in `server/game.py`); confirm the new `target` is exactly the successor in that cycle.
- `target` has changed and follows the **triangle wave** over the 1-based round number: `1→2→3→4→5→6→5→4→3→2→1→2→…` (climbs to 6, then descends to 1, then climbs again). This is the live check of `target_for_round(round_num)` in `server/game.py` (`m=(round_num-1)%10; m+1 if m<=5 else 11-m`) — the old `next_target` (`t % 6 + 1`, straight 6→1 wrap) was replaced by the ping-pong in commit 78a03df. Confirm the new `target` equals `target_for_round(new round_num)`.
- All dice are unlocked (new fresh dice dealt)
- `has_rolled` is `false` for all players
- Roll button is enabled on Tab 1
Expand Down
6 changes: 3 additions & 3 deletions .claude/skills/test-game/scripts/ws_integration_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,8 +66,8 @@ async def main():
check("create: private reconnect_token frame sent", bool(host_token),
f"len={len(host_token) if host_token else 0}")
check("create: token_hash NOT leaked in state", "token_hash" not in json.dumps(state))
check("create: not started, target=6, round 1",
state["started"] is False and state["target"] == 6 and state["round_num"] == 1)
check("create: not started, target=1, round 1",
state["started"] is False and state["target"] == 1 and state["round_num"] == 1)
check("create: host has HOST role", state["host"] == host_pid)

# ── invalid code rejection ───────────────────────────────────────
Expand Down Expand Up @@ -144,7 +144,7 @@ async def main():

adv = await recv_until(host, "state", timeout=6.0)
check("round advance: round_num -> 2", adv["round_num"] == 2, f"round={adv['round_num']}")
check("round advance: target cycled 6->5", adv["target"] == 5, f"target={adv['target']}")
check("round advance: triangle wave 1->2 (round 2)", adv["target"] == 2, f"target={adv['target']}")
check("round advance: dice re-dealt, has_rolled reset",
adv["players"][host_pid]["has_rolled"] is False)

Expand Down
4 changes: 4 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ __pycache__/
venv/
.playwright-mcp/
.DS_Store
# Deploy-time secrets (e.g. ops/secrets/metrics_token) are mounted into the
# prometheus/web containers at runtime — never bake them into an image layer,
# where `docker history`/layer extraction would recover them.
ops/secrets/
# Frontend build: node_modules is reinstalled (npm ci) and dist is rebuilt
# inside the `assets` stage; never ship the host copies into the build context.
node_modules/
Expand Down
8 changes: 4 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ sessions, ack_events, drop_tasks, pause_tasks # live asyncio objects, owned by t

Games are destroyed when the last player disconnects. Distinct players write distinct hash fields (atomic `HSET`/`HINCRBY`, no contention), so simultaneous rolling stays parallel; the one contended write — crowning the round winner — is an atomic Lua compare-and-set (`try_finish_round`). A periodic **reaper** (`server/reaper.py`) is the cross-instance backstop for grace-drops / pause-caps whose owning instance died, and publishes the global active-games gauge (aggregate it with `max()` across instances, not `sum()`).

**Accounts / auth.** Passkey (WebAuthn) sign-up/sign-in lives in `server/auth.py` — a `/auth/*` router (register/login `options`+`verify`, `/auth/me`) backed by a Postgres `users`/`webauthn_credentials` schema via `server/db.py`. Sessions are JWTs (HS256); the client authenticates its WebSocket with the `auth` action, which rebinds `session.pid` to the account UUID. `main.py` calls `db.init()` before serving because auth needs Postgres even when telemetry is off — gameplay itself still degrades gracefully when the DB is absent (`db.available()`). Public read APIs hang off `server/routes.py`: `/api/profile/{username}`, `/api/game/{code}`, `/api/game/{code}/verify`, `/api/verify/{code}/{pid}/{roll_count}`, plus SPA shells for `/@{username}`, `/games/{code}`, `/signin`, and `/welcome`.
**Accounts / auth.** Passkey (WebAuthn) sign-up/sign-in lives in `server/auth.py` — a `/auth/*` router (register/login `options`+`verify`, `/auth/me`) backed by a Postgres `users`/`webauthn_credentials` schema via `server/db.py`. Sessions are JWTs (HS256); the client authenticates its WebSocket with the `auth` action, which rebinds `session.pid` to the account UUID. **Anonymous players keep one durable pid across games:** the client preserves `tensies_pid`/`tensies_token` when a game ends (only `tensies_code` is cleared) and presents them on the next `create`/`join`, where `ws._adopt_identity` re-adopts the pid if the private token verifies (`gamestore.verify_claim` — the pid alone leaks via `state_msg`, so the token is the credential). Because every game then shares one pid, sign-up transfers the player's **whole history** onto the account: `register_verify` re-attributes `player_stats`, `round_player`, `sessions`, `events`, and `rounds.winner_user_id` for that pid (the recent-games list reads `round_player.user_id`, so those rows must move, not just the aggregate) — the same table set as `scripts/reattribute_user.sql`, still gated behind the private-token claim. `main.py` calls `db.init()` before serving because auth needs Postgres even when telemetry is off — gameplay itself still degrades gracefully when the DB is absent (`db.available()`). Public read APIs hang off `server/routes.py`: `/api/profile/{username}`, `/api/game/{code}`, `/api/game/{code}/verify`, `/api/verify/{code}/{pid}/{roll_count}`, plus SPA shells for `/@{username}`, `/games/{code}`, `/signin`, and `/welcome`.

Key env vars (`server/config.py`): `REDIS_URL`, `TELEMETRY_ENABLED`, `ALLOWED_ORIGINS` (WS origin allowlist), `METRICS_TOKEN`/`STATS_TOKEN` (bearer-gate `/metrics`+`/stats`), `WIDGET_TOKEN` (`?key=` gate for the `/api/widget` home-screen card; unset → 503), `MAX_GAMES`, `MAX_PLAYERS_PER_GAME`, `MAX_CONNECTIONS_PER_IP`, `CREATE_RATE_*`/`JOIN_RATE_*`, `MAX_WS_MESSAGE_BYTES`. Accounts add `JWT_SECRET`, `JWT_EXPIRY_DAYS`, `WEBAUTHN_RP_ID`, `WEBAUTHN_RP_NAME`, `WEBAUTHN_ORIGIN`; provably-fair rolling adds `ENABLE_DRAND_ROLLING`, `DRAND_BASE_URL`, `DRAND_CHAIN_HASH`, `DRAND_POLL_INTERVAL` (`server/drand.py`); the optional Discord notifier adds `DISCORD_ENABLED`, `DISCORD_BOT_TOKEN`, `DISCORD_CHANNEL_ID`, `DISCORD_PUBLIC_KEY`, `DISCORD_APPLICATION_ID`, `DISCORD_GUILD_ID` (`server/discord.py`); the nearby-games radar + place check-in add `DISCOVERY_ENABLED` (+ `DISCOVERY_RADIUS_M`/`DISCOVERY_DISTANCE_BUCKET_M`/`DISCOVERY_MAX_RESULTS`, `NEARBY_RATE_*`/`CHECKIN_RATE_*`) and, for the Google-backed picker, `PLACES_ENABLED`, `GOOGLE_MAPS_API_KEY`, `GOOGLE_APPLICATION_CREDENTIALS`, `GOOGLE_CLOUD_PROJECT` (+ `PLACES_RADIUS_M`/`PLACES_*_CACHE_TTL`, `PLACES_RATE_*`) — a game is discoverable only while checked in to a place (`server/places.py`, `server/routes.py`; see `.env.prod.example`); `APP_URL` sets the absolute og:image origin. Behind a trusted proxy, set `TRUST_PROXY_HEADERS`/`TRUSTED_PROXY_HOPS` so the per-IP caps read the real client from `X-Forwarded-For`. Security response headers are governed by `SECURITY_HEADERS` (CSP, on by default), `CSP_OVERRIDE`/`CSP_EXTRA_SCRIPT_SRC`/`CSP_EXTRA_CONNECT_SRC`/`CSP_EXTRA_IMG_SRC`, and the `HSTS_*` group (off in dev, on for HTTPS deploys) — see `server/security.py`. Asset serving is split on `FRONTEND_DIST` (see Cache-busting).

Expand Down Expand Up @@ -265,8 +265,8 @@ maxDiffPixels:0; behaviour is unchanged except documented fixes.)
| action | description |
|--------|-------------|
| `auth` | authenticate the session with a passkey JWT; payload: `token` (rebinds `session.pid` to the account UUID) |
| `create` | create new game; payload: `name` |
| `join` | join existing game; payload: `name`, `code` |
| `create` | create new game; payload: `name` (+ optional `player_id`, `token` — the client's durable anonymous identity, re-adopted via `verify_claim` so all its games share one pid) |
| `join` | join existing game; payload: `name`, `code` (+ optional `player_id`, `token`, same anon-identity continuity as `create`) |
| `reconnect` | rejoin a held slot after a drop; payload: `player_id`, `game_code`, `token` (the private reconnect token) |
| `start` | host starts the game (host only) |
| `pause` | host-only toggle that freezes/unfreezes rolling for everyone |
Expand All @@ -281,7 +281,7 @@ maxDiffPixels:0; behaviour is unchanged except documented fixes.)
|------|-------------|
| `welcome` | connection established; contains `player_id` |
| `auth_ok` | auth succeeded; carries `username`, `user_id`, `player_id` |
| `reconnect_token` | private token (sent after create/join) the client stores to rejoin a held slot |
| `reconnect_token` | private token (sent after create/join) the client stores to rejoin a held slot; also carries the authoritative `player_id` the server bound the game to (it may have re-adopted the client's durable anon pid), which the client syncs to |
| `state` | full game state snapshot |
| `round_won` | state snapshot with `winner_name`; triggers overlay |
| `game_ended` | host ended the game; carries `ended_by`, `round_num`, and a `players` map of `name`/`wins` |
Expand Down
Loading