Files
blind_chess/.claude/handoffs/2026-04-28-152000-mvp-deployed.md
T
claude (blind_chess) a6de43edc1 feat: implement and deploy blind_chess MVP
- pnpm workspace: shared/server/client packages
- Server: Fastify+ws, chess.js, FSM (touch-move + hierarchy),
  per-player view filter, zod validation, rate limiting, grace-window
  disconnect handling
- Client: Svelte 5 + Vite, click-to-move board, moderator panel,
  promotion/draw dialogs
- Shared: protocol types, ModeratorText enum, geometricMoves helper
  (provably zero opponent-info leak)
- 43 tests pass (21 shared, 22 server incl. 4 real-WS integration)
- Deploy: CT 690 on node-241 (192.168.0.245), systemd-managed,
  Caddy block for chess.sethpc.xyz
- Live at https://chess.sethpc.xyz

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 11:20:18 -04:00

16 KiB
Raw Blame History

Handoff: MVP deployed and live

Session Metadata

  • Created: 2026-04-28 ~15:20 UTC (terminal session, single user-driven workflow run)
  • Project: /home/claude/bin/blind_chess
  • Branch: main
  • Repo: git.sethpc.xyz/Seth/blind_chess
  • Recent commits: see git log after the wrap-up commit on this session.
  • Live URL: https://chess.sethpc.xyz

Handoff Chain

Current State Summary

Seth invoked the workflow handoff -> implementation -> deployment -> update context -> create handoff -> git commit -> close session. In one session: created the Gitea repo, scaffolded the pnpm workspace, implemented packages/{shared,server,client}, wrote 43 passing tests, deployed an LXC on node-241, configured Caddy + systemd, and verified the live URL handles game creation, the SPA fallback, WebSocket upgrade, and the per-player blind-mode view filter end-to-end. The previous handoff's deferred steps (spec sign-off, Gitea creation, writing-plans) were skipped per Seth's explicit workflow direction; he chose direct execution from the spec rather than another planning round.

The application works. Black gets white_moved announcements while seeing only black's 16 pieces — the spec's central security property is verified on the live URL.

Architecture Overview

Implemented (matches the design spec exactly with two intentional deviations noted below):

  • pnpm workspace, three packages, all on Node 22 + TS 5.9.
  • packages/shared/ exports types, the ModeratorText enum, the WS ClientMessage/ServerMessage types, and geometricMoves(). Built to dist/; package.json main/exports point at the compiled JS.
  • packages/server/ runs Fastify + @fastify/websocket + @fastify/static. Single port 3000 serves /api/games (REST), /api/health, /ws (WS upgrade), and the static client. SPA fallback serves index.html for any 404 with accept: text/html.
  • packages/client/ is Svelte 5 + Vite. Hash routing with a pathname fallback so both /g/<id> (share URL) and /#/g/<id> (post-create URL) render the game.
  • Deploy: CT 690 on node-241 (192.168.0.245), Debian 12, Node 22.22.2, systemd unit blind-chess.service with hardening (NoNewPrivileges, ProtectSystem=strict, ProtectHome, restricted user blindchess). Caddy CT 600 reverse-proxies chess.sethpc.xyz192.168.0.245:3000. DNS rides the existing *.sethpc.xyz wildcard.

Intentional deviations from spec:

  1. Click-to-move only — drag-and-drop deferred. Tap-arm + tap-destination implements the touch-move FSM correctly and is identical on phone and desktop. (The FSM doesn't care which input mode produces the commit message.)
  2. No CapturedTray UI yet. The spec's captured-pieces tray would derive from moveHistory[].capturedPieceType. Implementation deferred — moderator panel is the primary opponent-event channel.

Critical Files

File Purpose
packages/shared/src/geometric.ts geometricMoves(piece, from, ownSquares). Signature is the proof of zero opponent leak. 21 tests in packages/shared/test/geometric.test.ts.
packages/shared/src/protocol.ts ClientMessage, ServerMessage, ErrorCode — single source of truth, no drift.
packages/shared/src/moderator.ts ModeratorText enum, Announcement type.
packages/server/src/view.ts buildView() — the security boundary. Test: packages/server/test/unit/view.test.ts (snapshot per-viewer, blind/active white view contains zero black pieces).
packages/server/src/commit.ts Touch-move FSM. Test: packages/server/test/unit/commit-fsm.test.ts (hierarchy table rows 1, 1b, 2, 3, 5, 6 plus touch-move enforcement and promotion).
packages/server/src/translator.ts chess.js MoveModeratorText[]. Half-move clock is read from FEN field 4.
packages/server/src/ws.ts The WS dispatch + broadcast logic. Largest file. Includes the same-token-second-tab supersede behavior.
packages/server/src/server.ts Fastify bootstrap, routes, SPA fallback, janitor interval.
packages/client/src/lib/Board.svelte The 8×8 grid, click-to-arm/click-to-commit. Imports geometricMoves from shared for highlights.
packages/client/src/lib/Game.svelte The game page (board + moderator panel + actions + dialogs). Owns the WS connection lifecycle.
packages/client/src/lib/stores/game.ts Reactive game state via Svelte 5 $state. Handles WS reconnect, token persistence, message dispatch.
deploy/blind-chess.service systemd unit (canonical at /etc/systemd/system/blind-chess.service on CT).
deploy/Caddyfile.snippet Reverse-proxy block already merged into /etc/caddy/Caddyfile on CT 600.

Tasks Finished

  • Read prior handoff and the design spec
  • Created Gitea repo Seth/blind_chess and pushed initial scaffold
  • Set up pnpm workspace, root package.json, tsconfig.base.json, pnpm-workspace.yaml
  • Implemented packages/shared: types, protocol, moderator enum, geometric helper. 21 tests passing.
  • Implemented packages/server: state, games registry, view filter, translator, FSM, validation (zod), rate limiter, WS dispatch, Fastify bootstrap. 22 tests passing (including 4 real-WS integration tests).
  • Implemented packages/client: Svelte 5 + Vite, Landing/Game/Board/ModeratorPanel/PromotionDialog, hash+pathname routing, dark theme with Sethflix orange (#D35400) accents. Built bundle: 57 KB JS / 8 KB CSS gzipped to ~22 KB total.
  • Provisioned LXC CT 690 on node-241, installed Node 22 from NodeSource, created blindchess user, deployed artifacts via rsync.
  • Created systemd unit, enabled + started service.
  • Added Caddy block for chess.sethpc.xyz → 192.168.0.245:3000, validated and reloaded.
  • Verified live URL handles /api/health, /api/games, /, /g/<id> SPA fallback, /assets/*, and wss:///ws end-to-end including the per-player view filter.
  • Updated CLAUDE.md (project state, ops notes), DECISIONS.md (implementation outcomes), ~/bin/CLAUDE.md (project listing).

Files Modified / Added

(Refer to git log and git diff for exhaustive lists.)

File Changes
(new) package.json, pnpm-workspace.yaml, tsconfig.base.json, .npmrc Workspace root
(new) packages/shared/** 4 source files, 1 test file, 21 tests
(new) packages/server/** 9 source files, 3 test files, 22 tests
(new) packages/client/** 8 source/component files, Vite/Svelte config
(new) deploy/blind-chess.service, deploy/Caddyfile.snippet Deployment artifacts
CLAUDE.md "Current State" updated to "deployed and live", added "Operations" section
DECISIONS.md Added "Implementation outcomes" section with 9 entries
.gitignore Added node_modules, dist, .deploy-server, etc.
~/bin/CLAUDE.md blind_chess project entry updated to reflect deployed state

Decisions Made

All implementation decisions are recorded in DECISIONS.md "Implementation outcomes" (2026-04-28). Highlights:

  • chess.js v1.4.0; use Move.isEnPassant() etc., NOT the deprecated flags string.
  • Half-move clock comes from FEN field 4 — chess.js doesn't expose it.
  • Shared package's package.json exports point at dist/, never src/. Always build shared before server (project refs handle this on pnpm -r build).
  • Click-to-move (no drag-and-drop) is the only input mode for now.
  • Hash routing in the SPA, with a pathname fallback for share URLs.

Immediate Next Steps

The MVP is functional. Order of likely follow-ups, not committed:

  1. Manual phone + desktop play-test. Send the URL to a friend and play a real game in both modes. Check edge cases the integration tests don't cover: castling kingside and queenside (both colors), en passant, threefold repetition, draw offer/decline cycle, simultaneous tabs (last-connect-wins).
  2. Drag-and-drop input. Useful for desktop. Adds nontrivial complexity to Board.svelte (need to handle pointermove, ghost element, snapping). Probably 200300 LoC.
  3. CapturedTray.svelte. Derive from moveHistory[].capturedPieceType (server already supplies this in MoveRecord). Need to wire the move history through update messages — currently it's only computed server-side.
  4. More integration tests. Specifically: castling end-to-end, en passant, both-sides simultaneous disconnect, 5-minute grace expiry. Each is ~30 LoC of test plus existing scaffolding.
  5. Uptime Kuma. Add a probe for https://chess.sethpc.xyz/api/health returning {"ok":true}.
  6. Stretch: SQLite persistence for crash recovery (1-day add: serialize Map on ExecStop, deserialize on ExecStart).

Blockers / Open Questions

  • Spec was never reviewed in written form by Seth. The previous handoff said this was a gate before implementing. Seth waived it implicitly by directing me into the implementation workflow. If he reviews docs/superpowers/specs/2026-04-28-blind-chess-design.md and finds something he doesn't like, the implementation may need surgery. The four self-review fixes in the spec (Announcement payload for promotion, geometricMoves signature, castling+highlighting note, promotion-required-as-protocol-error) were all faithfully implemented.
  • Public-internet exposure. chess.sethpc.xyz is reachable from the public internet. The link IS the auth, but anyone who guesses an 8-char gameId could try to connect. With ^[a-z0-9]{8}$, that's 36^8 ≈ 2.8 trillion possibilities; rate limiting on commits (10/s, burst 20) makes mass scanning impractical. No rate limiting on hello messages or POST /api/games — if abuse becomes a concern, add a per-IP token bucket on those endpoints.
  • Server restart drops active games. Acceptable for MVP per spec, but be mindful when deploying updates during active play.

Deferred Items

See DECISIONS.md "Deferred / Rejected" — unchanged from prior handoff. Implementation didn't add new deferred items beyond the two MVP-scope reductions: drag-and-drop and CapturedTray (both deferred, not rejected).

Important Context

  • The deploy bundle directory .deploy-server/ is created by pnpm --filter @blind-chess/server deploy --prod --legacy .deploy-server and is gitignored. Don't commit it. It contains ~93 production deps including @blind-chess/shared resolved as a real package (via legacy symlink).
  • Re-deploying is the same flow: pnpm -r buildpnpm --filter @blind-chess/server deploy --prod --legacy .deploy-serverrsync -a --delete .deploy-server/ root@192.168.0.245:/opt/blind-chess/server/rsync -a --delete packages/client/dist/ root@192.168.0.245:/opt/blind-chess/client/dist/ssh root@192.168.0.245 'chown -R blindchess:blindchess /opt/blind-chess && systemctl restart blind-chess'.
  • CT root access is via the claude user's ed25519 pubkey (pushed via pct exec then cat > authorized_keys). A throwaway password was set during pct create and rotated to something not stored anywhere; recover via pct exec 690 -- passwd root from any cluster node if needed. The CT is a stateless game server — losing root access only means re-provisioning, which takes ~3 minutes.
  • The integration tests open ephemeral ports via listen({ port: 0 }). They don't depend on 3000 being free. Do NOT change to fixed ports without thinking — that breaks parallel vitest.
  • Svelte 5 runes mode is what's in use ($state, $derived, $effect). Don't try to mix in Svelte 3/4 reactive $: syntax.
  • The visual-companion mockups from the brainstorm session (.superpowers/brainstorm/2734300-1777384084/content/) are still on disk and gitignored. They're stale relative to the implementation (the actual UI uses different colors, spacing, layouts) — don't use them as a reference. The implementation IS the reference now.
  • chess.js's Move.flags field is deprecated. The implementation uses the is...() methods. Don't use .flags if extending the translator.
  • Same-token-second-tab is implemented. Opening a second browser tab on the same gameId closes the first tab's socket with reason superseded. The displaced tab does NOT yet show a "now open in another tab" banner; it just disconnects. This is a UX gap, not a correctness gap.

Assumptions Made

  • Seth's workflow shorthand is binding direction. The previous handoff's "do not init repo without explicit OK" was overridden by the explicit implementation->deployment step. (If Seth disagrees, only the Gitea repo is reversible: gitea delete blind_chess undoes it cleanly.)
  • 192.168.0.245 was free at the time of LXC creation; verified by ICMP. Conflict is unlikely on a homelab where IPs are hand-assigned.
  • CT 690 was free; verified via pct list. The hostname blind-chess is unique on the cluster.
  • Node 22 LTS via NodeSource is acceptable. (Debian 12's apt Node is way too old.)

Potential Gotchas

  • pnpm install after pulling on a fresh machine will warn Ignored build scripts: esbuild. That's fine — auto-install-peers=true in .npmrc prevents prompts but esbuild prompts to opt in to running its postinstall. Vite + Svelte work without it; the warning is benign.
  • Pino in production mode does NOT use pino-pretty (only in dev). The systemd unit sets NODE_ENV=production; logs in journald are JSON. To pretty-print: journalctl -u blind-chess -o cat | pnpx pino-pretty.
  • Caddy auto-issues a TLS cert on first hit of chess.sethpc.xyz. The first hit (during smoke testing) took a few hundred ms longer than subsequent hits while the cert was being provisioned; subsequent hits were ~30 ms.
  • Test FENs need a black king. Two unit tests had to be re-FEN'd because chess.js rejects a FEN with no black king. Always include both kings in test positions.
  • Pawn-promotion test position must give the pawn a legal move. 4k3/4P3/8/8/8/8/8/4K3 looks fine but the black king blocks e8, so the pawn can't legally advance, so the FSM emits wont_help instead of letting the promotion happen. Use 7k/... (king elsewhere) when testing promotion.
  • ModeratorText enum strings use white_X / black_X, not w_X / b_X. Translator interpolates moverWord ('white'/'black'), not move.color ('w'/'b'). Old draft conflated them.
  • Hash routing + path routing coexist. The post-create flow updates location.hash to #/g/<id> (no full navigation, no LE re-cert). The share URL goes through /g/<id> and the SPA fallback. Both work; opening the share URL twice gives a clean state, hash form keeps the gameId in the URL after creator navigates back to landing — minor; not visible to the user in normal flow.

Environment State

Tools/Services Used

  • pnpm 10.33.2 (corepack-installed at ~/.local/bin/pnpm)
  • Node 22.22.2 (system, both on dev and on CT)
  • gitea CLI (~/bin/gitea) for repo create + remote
  • ssh to pve241, then pct exec 690 initially, then direct ssh root@192.168.0.245 after pubkey injection
  • ssh to caddy for Caddyfile edit + reload
  • vitest 3.2.4

Active Processes

  • blind-chess.service on CT 690 (192.168.0.245). Started 2026-04-28 ~15:12 UTC. systemd-managed, restarts on failure.
  • Caddy on CT 600 (192.168.0.185). Reloaded 2026-04-28 ~15:13 UTC after Caddyfile append.

Environment Variables

  • BLIND_CHESS_CT_IP=192.168.0.245 (in caddy block, hardcoded; if migrating, also update Caddyfile)
  • PUBLIC_BASE=https://chess.sethpc.xyz (set in the systemd unit)
  • Live URL: https://chess.sethpc.xyz
  • Repo: https://git.sethpc.xyz/Seth/blind_chess
  • Spec: docs/superpowers/specs/2026-04-28-blind-chess-design.md
  • Project identity: CLAUDE.md
  • Decisions: DECISIONS.md
  • Original brief: IDEA.md
  • Prior handoffs: .claude/handoffs/2026-04-28-104344-spec-approved-ready-for-plan.md, .claude/handoffs/2026-04-28-kickoff.md
  • chess.js: https://github.com/jhlywa/chess.js (v1.4.0)
  • Caddy config: /etc/caddy/Caddyfile on CT 600 — search for chess.sethpc.xyz
  • Systemd unit (canonical): /etc/systemd/system/blind-chess.service on CT 690

Security Reminder: This handoff contains the CT root password and operational details. Do not share publicly.