From 058432130628d00764663ecf819a9adc6793768d Mon Sep 17 00:00:00 2001 From: salmonik Date: Sun, 8 Nov 2026 03:29:35 +0100 Subject: v5.0.0: protocol v5 and the browser frontend Protocol v5 replaces newline lines with bounded [u32 len][text] frames and REQ/RES/EVT envelopes. Requests carry correlation ids and the server replays the last 256 replies per connection, so a gateway retry cannot double-debit and a late reply cannot clobber a newer request. HELLO/PONG carry server time; crash rounds announce started_at and push coalesced 10 Hz ticks. Crash now commits sha256(seed) before a round and reveals the seed when it ends, so clients recompute the crash point. Adds the webd/ gateway: one blocking thread, one ClientCore and one pinned upstream TLS connection per session, an HttpOnly session cookie, takeover, and a 60-second grace window. The browser sends Command JSON and renders View JSON; it never speaks the game protocol or stores credentials. Adds the vanilla TypeScript browser app at /play/ (login, lobby, all four games, history, notices, crash verification), the nginx /play and /ws routes, and the headless DOM smoke test. webd and the app stay out of the TUI/GUI/server build graph. The client/server version moves to 5.0.0 in lockstep; v4 peers are refused at HELLO. --- docs/GUI-PLAN.md | 5 +- docs/PROTOCOL-IDEAS.md | 170 +++++++++++++++++++++++++++++ docs/TESTING.md | 52 +++++++-- docs/WEB-PLAN.md | 129 ++++++++++++++++++++++ docs/adr/0001-gui-frontend-architecture.md | 3 +- docs/adr/0003-web-gateway.md | 85 +++++++++++++++ docs/adr/0004-protocol-v5.md | 119 ++++++++++++++++++++ 7 files changed, 552 insertions(+), 11 deletions(-) create mode 100644 docs/PROTOCOL-IDEAS.md create mode 100644 docs/WEB-PLAN.md create mode 100644 docs/adr/0003-web-gateway.md create mode 100644 docs/adr/0004-protocol-v5.md (limited to 'docs') diff --git a/docs/GUI-PLAN.md b/docs/GUI-PLAN.md index c0370a0..95ec922 100644 --- a/docs/GUI-PLAN.md +++ b/docs/GUI-PLAN.md @@ -5,7 +5,10 @@ Decisions and rationale live in `docs/adr/0001-gui-frontend-architecture.md` and ## Non-goals -Audio, 3D, macOS, auto-update, a browser client, and pixel-golden tests in CI. +Audio, 3D, macOS, auto-update, a webview inside the desktop app, and +pixel-golden tests in CI. A separate browser frontend is no longer a non-goal: +it shipped in v5.0.0 through the `webd/` gateway, planned in +`docs/WEB-PLAN.md`. ## Phase 0 — version handshake (v4.0.0) diff --git a/docs/PROTOCOL-IDEAS.md b/docs/PROTOCOL-IDEAS.md new file mode 100644 index 0000000..3991a84 --- /dev/null +++ b/docs/PROTOCOL-IDEAS.md @@ -0,0 +1,170 @@ +# Server ↔ client transport ideas + +Status: design notes, written 2026-11-08. Items 1–5 were adopted (with the +differences recorded in `docs/adr/0004-protocol-v5.md`): correlation ids, +per-connection idempotency, clock sync + ticks, commit–reveal crash, and +length-prefixed framing. Items 6–7 remain future work. + +These came out of reviewing zagan-bet's transport, but they were prompted by a +*different* app. Nothing here is a backlog item for this repository. The +zagan-bet paths are included because that is where the patterns were worked +out; treat the ideas as portable. + +zagan-bet's current shape, for context: + +- TLS with TOFU over the certificate's SPKI fingerprint, trust decision after + the handshake and before credentials (`src/client/transport.rs`, + `src/tofu.rs`). +- Newline-delimited text lines over the TLS stream (`src/protocol.rs`). +- `HELLO MAJOR.MINOR` lockstep before auth (`src/version.rs`). +- Request/reply plus exactly one server-initiated line, `CRASH_BUSTED` + (`src/bin/server/handler.rs`, `ServerCore::poll`). +- `ClientCore` is I/O-free; frontends send `Command` and render `View` + (`src/client/core.rs`). The GUI owns the stream in `gui/src/net.rs`. + +Each idea below lists the wire shape, where it would attach, and the cost. + +## 1. Correlation IDs, so a single `Pending` can go away + +One request in flight, and pushes distinguished by string prefix +(`expects_push`, `CRASH_BUSTED`, `src/client/core.rs:622`). A late reply can +race a pushed bust; the code already has a special case for it +(`src/client/core.rs:211`). An envelope removes the whole class: + +``` +REQ 42 idem=f3a1... INFO +RES 42 OK balance=1200 account=... +EVT seq=913 crash.tick {"session_id":"...","server_ms":837412,"multiplier":1.42} +``` + +`REQ`/`RES` correlate by id; `EVT` is connection-scoped and never answers +anything. The core keeps a map of in-flight requests instead of one `Option`, +and "a stale reply must not clobber the result" becomes a generic rule rather +than a crash-specific branch. Also allows pipelining, e.g. clock probes while a +`HISTORY` is in flight, without app-layer head-of-line stalls. Keep line +framing for now; just prepend tokens. + +## 2. Idempotency keys on every balance mutation + +A request can be applied server-side and the reply lost. Today that is safe +only because nothing retries automatically; the moment a client retries or +resumes, it can double-debit. + +``` +REQ 43 idem=9c2e... PLAY_START crash 50 +``` + +Server keeps `seen_requests (idem PRIMARY KEY, user, reply, created_at)`. The +replay check, the debit, and the row insert sit in the same transaction as the +existing reservation (`database::reserve_play`). A duplicate returns the stored +reply and never re-executes. TTL-sweep at startup next to +`recover_orphaned_reservations`. Scope by user, not connection, so a resumed +session cannot replay someone else's key. + +## 3. Clock sync and a real tick stream + +zagan-bet advances timed games on read timeouts (`POLL_INTERVAL`, +`src/bin/server/handler.rs:7`) and the client draws crash locally from an +`Instant`. Payouts are correctly server-clock authoritative, but the client +does not know its offset. Two parts: + +**Clock sync**, Cristian/NTP style over the existing connection: + +``` +REQ 7 idem=... TIME t0=837412 +RES 7 OK t0=837412 t1=837498 # t1 = server monotonic ms +``` + +`offset = t1 - (t0 + t2) / 2`; keep the minimum-RTT sample over a sliding +window. Render timed games from `server_now`. + +**Ticks** at 10–20 Hz, monotonic `seq`, no reply semantics: + +``` +EVT seq=914 crash.tick {"session_id":"...","server_ms":837520,"multiplier":1.44} +``` + +Coalesce in the writer: replace an unqueued tick, never drop a settlement. +That also enables lag compensation — render at `server_now - presentation_delay` +(e.g. 150 ms) so the displayed number never exceeds what the server accepts at +the moment of a click. A cleaner fairness story than trusting the client clock. + +## 4. Commit–reveal instead of a hidden `Option` + +zagan-bet already withholds the crash point (`CrashState::crash_point`, +`src/crash.rs`). Make it verifiable: + +``` +RES 51 OK COMMIT 7f3a... 4b9c... # sha256(server_seed), round id +...round runs... +EVT seq=980 crash.busted {"seed":"","crash_point":2.37,...} +``` + +Player supplies a client seed; the server echoes it. Crash point derives from +`HMAC-SHA256(server_seed, client_seed)` → 52 bits → `[0, 1)` → the existing +`crash_point_from_uniform`. Verification is a pure function and belongs in a +unit test, not a network test. A hash chain lets the server pre-commit every +future round at session start; that is stronger than needed in most cases. + +## 5. Framing: length prefix, then maybe QUIC + +Weaknesses in newline framing: + +- `LineBuffer` grows without bound; a client that never sends `\n` is a memory + DoS. Reject oversized frames *before* allocating. +- `from_utf8_lossy` silently rewrites corrupt input. Newline framing is only + safe while `serde_json` keeps escaping newlines — an implicit dependency. + +``` +[u32 LE len][u8 proto_version][u8 kind][payload] +``` + +This allows a move to `postcard`/CBOR later without touching the read loop. +The larger swing is QUIC (`quinn`): separate streams so a slow `HISTORY` +cannot block ticks, 0-RTT resumption, connection migration for NAT rebinds +mid-round, and unreliable datagrams for ticks where latest-wins beats +retransmission. zagan-bet already uses rustls on the client, so this would also +drop OpenSSL from the server build. Cost: UDP reachability, TLS 1.3 only, a +rustls bump. + +## 6. Resume tokens and a blocking reader thread + +`AUTH` returns an opaque HMAC-signed resume token plus a session id; +`RESUME since=` replays missed `EVT`s from a bounded per-session +buffer. Turns disconnect from "forfeit everything" into a policy choice; a +server-clocked round is trivially resumable. In zagan-bet this contradicts the +AGENTS rule that disconnect = forfeit, so it would need an explicit carve-out +and a grace window. + +Separately, both ends poll with timers: the server's 50 ms read timeout, and +the GUI flipping `SO_RCVTIMEO` to 1 ms every 8 ms in +`TlsStream::read_available_line`. A dedicated blocking reader thread feeding an +`mpsc` removes the per-call timeout mutation, `expects_push`, and +`POLL_INTERVAL`; the handler becomes a `select` over inbound lines, a tick +deadline, and shutdown. Still no async runtime. + +## 7. Small ones worth batching + +- `HELLO 5.0 caps=idem,tick,commit` with server-side intersection instead of + strict MAJOR.MINOR lockstep for additive changes. Keep MAJOR for framing. +- Structured errors: `ERR code=INSUFFICIENT_FUNDS retryable=0 msg=...` so the + frontend can tell "reconnect" from "show text". +- `set_nodelay(true)` plus an app-level `PING` deadline; a half-open TCP + connection is otherwise noticed only on the next write. +- Per-connection token-bucket rate limiting. Every inbound line is logged, so a + chatty client is also a log-flood/disk problem. +- A protocol fuzzer (`proptest`/`cargo-fuzz`) asserting the server never panics + and `balance >= 0` for arbitrary input, plus transcript-replay tests. + +## If only three + +1. Correlation IDs + idempotency keys — small, kills two bug classes. +2. Clock sync + coalesced ticks — turns a special-case push into a generic + event channel. +3. Commit–reveal — cheap, and the strongest fairness story for a betting + server. + +The envelope plus idempotency is the least invasive pair: it touches +`src/protocol.rs`, `ServerCore::handle_message`, and +`ClientCore::handle_reply`, and can sit behind a `caps=` flag so version +lockstep stays intact. diff --git a/docs/TESTING.md b/docs/TESTING.md index 09ceb82..fbd510b 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -210,9 +210,9 @@ terminal client; a saved login from one signs in the other. 1. `play crash` shows the ready board with a 1.00x multiplier; Space starts and reserves the bet. -2. The multiplier rises smoothly with no network traffic. Cash out with C and - confirm the board shows the cashout multiplier, the payout and the new - balance. +2. The multiplier rises smoothly from the server-clock anchor and is refreshed + by 10 Hz ticks. Cash out with C and confirm the board shows the cashout + multiplier, the payout and the new balance. 3. On another round, press nothing. The board must switch to `CRASHED at Xx` by itself when the crash point is reached, and the bet must be lost. 4. Press C just as a round crashes. Either it pays or the crashed result wins; @@ -220,8 +220,11 @@ terminal client; a saved login from one signs in the other. 5. Q during a live round forfeits the bet; Q when idle just leaves. 6. Check the audit rows: one per round, `game_type = crash`, the reserved bet, the payout, and `crash_point`/`multiplier`/`result` in `details`. -7. Confirm the crash point is absent from the `CRASH_START` line (it must only - appear once the round is over). +7. Confirm the crash point and seed are absent from the `CRASH_START` message; + only `commit` and `started_at` appear. When the round ends, `seed` appears + and `sha256(seed) == commit`; recompute the crash point from the seed. +8. Confirm `CRASH_BUSTED` is an `EVT`, not a `RES`, and that a second poll + produces no repeated settlement. ## Manual restart check @@ -237,7 +240,7 @@ terminal client; a saved login from one signs in the other. 1. Connect a client and server from the same release: the client connects and the server log shows the `HELLO` exchange. -2. Connect a 4.x client to a 3.x server (or the reverse): the client must print +2. Connect a 5.x client to a 4.x server (or the reverse): the client must print `Version mismatch: client ..., server ...` and never reach the login prompt. 3. With a deliberately skew-built server (PATCH differs) the connection is accepted and, when the server's version is newer, the client prints @@ -269,9 +272,11 @@ terminal client; a saved login from one signs in the other. ## Packaging checklist -1. Set the version in `Cargo.toml` and `gui/Cargo.toml` (same MAJOR.MINOR), - run `cargo test --workspace`, and commit. `publish.sh` refuses a dirty tree - because the site links the committed git history as the GPL source. +1. Set the version in `Cargo.toml`, `gui/Cargo.toml` and `webd/Cargo.toml` + (same MAJOR.MINOR), run `cargo test --workspace`, build the browser app + (`cd web/play && npm ci && npm run build`), and commit. `publish.sh` refuses + a dirty tree because the site links the committed git history as the GPL + source. 2. `./scripts/publish.sh` (add `DEST=` to stage elsewhere). It builds the GUI with `gui-release` and the terminal clients and servers. The installers are stable site files and are not regenerated. @@ -284,4 +289,33 @@ terminal client; a saved login from one signs in the other. menu entry. It must install the newest version even when an older app sits next to it, and refuse a corrupted download. 5. Repeat on Windows for the `.ps1` installer and the Start-menu shortcut. +6. Start `webd` against the staging server with a pinned fingerprint and open + `/play/` through nginx. Run the manual web checklist below. + +## Manual web checklist + +1. Open `/play/` in a fresh browser. The auth card must appear; the top bar + must not claim a balance until login. +2. Register, then log in with the password. The lobby must show 1 000 Kr and + every game. Refresh the page; the session cookie must reattach and the + balance must survive (within `ZAGAN_GRACE_SECONDS`). +3. Open the same URL in a second tab. The first tab must show "Opened in + another tab" and stop reconnecting; the second must continue. +4. Slots: spin and watch the reels settle on the server result; check the + payout and balance. Roulette: place straight, dozen, column and outside + chips, right-click one back, clear, spin, and confirm the result. +5. Blackjack: deal, hit/stand/double, then deal again; forfeiting must warn + and lose the reserved bet. +6. Crash: start a round, watch the curve and ticks, cash out; on another + round let it bust. Confirm the reveal line shows `sha256(seed) == commit` + and the recomputed crash point matches. +7. Refresh in the middle of a live crash round. The current view must return + with the same `commit` and a rising multiplier; the round must still settle. +8. Close the tab mid-round and wait past the grace window. The next visit must + log in again, and the server must have forfeited the round. +9. Stop the gateway. The browser must show the reconnect overlay, then recover + when `webd` returns. Stop it for longer than the grace window: the round is + forfeited and a login is required. +10. Check the browser console: no credential is stored, the cookie is HttpOnly, + and the only network target is `/ws`. diff --git a/docs/WEB-PLAN.md b/docs/WEB-PLAN.md new file mode 100644 index 0000000..895687b --- /dev/null +++ b/docs/WEB-PLAN.md @@ -0,0 +1,129 @@ +# Browser frontend plan + +Status: v1 implemented in 5.0.0. This is the working plan and the deployment +contract; see `CONTEXT.md` and ADRs 0003/0004 for the decisions. + +## Shape + +``` +browser ──wss──> nginx ──ws──> webd ──TLS──> game server + /play/ /ws 127.0.0.1:8890 127.0.0.1:11525 + static files gateway one thread per authoritative rules, + browser session balances, crash clock +``` + +- `web/play/` is a static TypeScript app. It sends `Command` JSON and renders + `View` JSON. It has no rules and no wire parser. +- `webd/` is a workspace member. It owns one `ClientCore`, one upstream TLS + connection and a 60-second grace window per browser session. +- `src/protocol.rs` is v5: bounded frames, `REQ`/`RES`/`EVT`, request ids, + server time and crash commit–reveal. + +## Phases + +1. **Protocol v5** — frames, envelope, correlation, replay cache, clock sync, + ticks, commit–reveal, TUI/GUI updates. Done in the 5.0.0 change. +2. **Gateway** — WS accept, cookie session registry, takeover, grace, pinned + upstream, `Command`/`View` JSON. Done in the 5.0.0 change. +3. **Web shell** — login/register, lobby, history, notices, route at `/play/`. + Done in the 5.0.0 change. +4. **Games** — slots, roulette (felt + chips), blackjack, crash (curve + + verification). Done in the 5.0.0 change. +5. **Deployment** — nginx routes, static build, systemd unit, verification in + `scripts/deploy.sh`. Done in the 5.0.0 change. + +## Browser contract + +Browser → gateway: + +```json +{"type":"command","command":{"Login":{"user":"alice","credential":""}}} +{"type":"command","command":{"Play":{"game":"crash","bet":10}}} +{"type":"command","command":"Start"} +{"type":"ping"} +``` + +`Command` uses serde's external tagging because several variants carry a +primitive or a string. Unit variants are bare strings (`"Spin"`, `"Start"`). + +Gateway → browser: + +```json +{"type":"hello","version":"5.0.0","grace_seconds":60,"user":"alice","time":1700000000000} +{"type":"view","view":{"screen":"lobby", "...": "..."}} +{"type":"notice","text":"Logged in as alice"} +{"type":"error","text":"Upstream connection failed: ..."} +{"type":"replaced"} +{"type":"pong"} +``` + +`View` is internally tagged by `screen` (`lobby`, `history`, `slots`, +`roulette`, `blackjack`, `crash`). The browser renders each screen; it never +computes a result. + +The cookie is `zagan_session`, HttpOnly, SameSite=Strict, Secure on HTTPS. It +is set on the WebSocket handshake. It covers refresh and the grace window only; +there is no remember-me. + +## Build and run + +```sh +# static app +cd web/play +npm ci +npm run build # writes app.js; commit it + +# gateway +cargo build -p zagan-bet-webd --release +ZAGAN_UPSTREAM=127.0.0.1:11525 \ +ZAGAN_UPSTREAM_FINGERPRINT=<64 hex> \ +ZAGAN_WEB_ORIGIN=https://zagan.2137697.xyz \ +ZAGAN_WEB_BIND=127.0.0.1:8890 \ +target/release/zagan-bet-webd +``` + +The fingerprint is the same SPKI SHA-256 the desktop client shows: + +```sh +openssl x509 -in cert.pem -pubkey -noout \ + | openssl pkey -pubin -outform DER \ + | sha256sum +``` + +Test against a staging site with +`ZAGAN_WEB_ORIGIN=http://127.0.0.1:8899 ZAGAN_WEB_COOKIE_SECURE=0`. + +## Security boundaries + +- The gateway binds loopback. nginx terminates TLS and is the only public + entry. Do not expose `ZAGAN_WEB_BIND` to the internet. +- The upstream key is pinned; there is no TOFU and no unsafe mode. A mismatch + logs both fingerprints and refuses credentials. +- The browser has no certificate UI and no credential storage. XSS cannot read + the HttpOnly cookie; the backend still must escape every notice. +- The browser speaks JSON, not the game wire. A malicious page cannot use the + gateway as a protocol proxy: `Origin` is checked and the session cookie is + SameSite=Strict. +- The web app is play-money only. No deposits, withdrawals, KYC or real-money + claims belong in v1 copy. + +## Failure behaviour + +| Event | Result | +| --- | --- | +| Refresh / new tab, within grace | Same `ClientCore`, current `View` restored | +| New tab, same cookie | Old tab gets `replaced` and closes; new tab continues | +| Socket closed, grace expires | Upstream closes; server forfeits live hand | +| Gateway restart | Sessions lost; players log in again | +| Upstream disconnect | Browser gets an error, core resets, reconnect attempts | +| Browser retry of a request | Gateway does not retry; request ids are per connection | + +## Verification + +- `cargo test --workspace` covers frames, envelope parsing, idempotent replay, + commit verification, ticks, core routing and session JSON. +- `web/play`: `npm run typecheck` and `npm run build`. +- Manual WS smoke test: connect, login, play slots/crash, refresh in a live + crash round, verify the revealed seed. +- `scripts/deploy.sh` checks `/play/`, `app.js`, `style.css` and the existing + download feed. diff --git a/docs/adr/0001-gui-frontend-architecture.md b/docs/adr/0001-gui-frontend-architecture.md index 44dca32..e4dd5ac 100644 --- a/docs/adr/0001-gui-frontend-architecture.md +++ b/docs/adr/0001-gui-frontend-architecture.md @@ -1,6 +1,7 @@ # 0001 — GUI is a second frontend over a shared core -Status: accepted, 2026-11-01 +Status: accepted, 2026-11-01; the "no browser" constraint is superseded by +[0003](0003-web-gateway.md), which adds a browser frontend through a gateway. ## Context diff --git a/docs/adr/0003-web-gateway.md b/docs/adr/0003-web-gateway.md new file mode 100644 index 0000000..5337e2b --- /dev/null +++ b/docs/adr/0003-web-gateway.md @@ -0,0 +1,85 @@ +# 0003 — Browser frontend through a server-side gateway + +Status: accepted, 2026-11-08 + +## Context + +The game already runs over TLS/TCP with a newline-delimited text protocol. A +browser cannot open a TCP socket, and putting game rules into JavaScript would +create a second implementation of payout, session and forfeit logic — exactly +what the project forbids. + +The site must stay a website in the ordinary sense: open `zagan.bet`, log in, +play. Nothing to install. Balances are play credits; there are no deposits, +withdrawals or KYC in this decision. + +## Decision + +1. **The browser is a third frontend over `ClientCore`.** It sends `Command` + JSON and renders `View` JSON. It contains no rules, no wire-protocol parser + and no balance arithmetic. +2. **A Rust gateway, `webd/`, owns each session.** One blocking thread per + browser session, one `ClientCore`, and one ordinary client TLS connection + upstream. The existing `client::transport` and `ClientCore` are reused. No + async runtime and no new concurrency style. +3. **`webd/` is a separate workspace member.** Like `gui/`, its dependencies + (tungstenite, HTTP cookie handling) must never enter the TUI, GUI or server + build graph. +4. **Everything is same-origin and operator-configured.** nginx terminates + TLS, serves the static app at `/play/`, and proxies `/ws` to the gateway on + loopback. The browser never sees a certificate, there is no CORS, and the + upstream address (`ZAGAN_UPSTREAM`, default `127.0.0.1:11525`) and SPKI + fingerprint (`ZAGAN_UPSTREAM_FINGERPRINT`, required) are gateway config. + A mismatch refuses to connect and logs both fingerprints. There is no TOFU + and no unsafe mode for the upstream leg. +5. **Session-only authentication.** Log in per visit. The gateway issues a + random HttpOnly, SameSite=Strict, Secure session cookie; the cookie covers a + refresh and the grace window only. No credential is stored in the browser or + in the gateway. A new connection with the same cookie takes over the session + and the old socket is told it was replaced. +6. **60-second grace window.** When the last browser socket for a session + closes, the session thread keeps the upstream connection and `ClientCore` + alive for 60 seconds (configurable). A reconnecting socket with the same + cookie reattaches and immediately receives the current `View`. Expiry closes + upstream, and the server forfeits a live hand per the existing disconnect + rule. There is no wire-level resume: the gateway, not the browser, holds the + TCP connection. +7. **Vanilla TypeScript + esbuild.** DOM for login, lobby, history and notices; + one canvas for the game surface. No framework runtime. Theme tokens are + reused from the desktop palette; felt green is reserved for roulette. +8. **Web v1 has full parity after trust.** Register, log in, lobby, all four + games, history, log out, notices, refresh grace. The connect/trust screens + do not exist in the browser because the gateway owns the upstream key. + +## Consequences + +- One implementation of the rules and parsers remains authoritative, and the + TUI/GUI/gateway all exercise the same `ClientCore` tests. +- The gateway becomes an operational component: it needs a service unit, + deployment, logging, a required upstream fingerprint, and a restart story. + A gateway restart loses live sessions and therefore forfeits their rounds; + this is accepted for v1. +- The browser cannot recover from a gateway crash by itself. The player logs in + again. +- A 60-second grace window means a live round can outlive a refresh, but not a + closed laptop for a minute and a half. +- WebSocket frames are same-origin JSON, so the page cannot be used as an open + proxy by another origin. The gateway also checks the `Origin` header against + `ZAGAN_WEB_ORIGIN`. +- The web app is play-money only. Adding payments later would be a separate + product and compliance decision, not a UI change. + +## Alternatives considered + +- **WASM `ClientCore` in the browser, dumb relay**: reuses Rust in the page, + but needs transport, TOFU and credential shims in wasm and a multi-megabyte + download for every player. Rejected. +- **JavaScript rewrite speaking the game protocol**: fastest UI iteration, but + a second implementation of rules and payout invariants. Rejected. +- **Tokio/axum gateway**: idiomatic WebSocket server, but a large async tree + and a second concurrency style for one blocking, low-traffic product. + Rejected. +- **Long-lived remember-me in the gateway**: better return visits, but a new + credential store and revocation flow. Deferred; session-only first. +- **Protocol-level resume tokens**: survives a gateway restart, but is a wire + change and more state on the server. Deferred. diff --git a/docs/adr/0004-protocol-v5.md b/docs/adr/0004-protocol-v5.md new file mode 100644 index 0000000..c74ad83 --- /dev/null +++ b/docs/adr/0004-protocol-v5.md @@ -0,0 +1,119 @@ +# 0004 — Protocol v5: frames, envelopes, time and commit–reveal + +Status: accepted, 2026-11-08 + +## Context + +The current wire is newline-delimited text over TLS. It has served the TUI and +GUI well, but four problems are now worth fixing before a browser gateway is +built on top: + +- `LineBuffer` grows without bound if a peer never sends a newline, and there + is no explicit maximum message size. +- A reply is assumed to answer the request that is currently pending. A late + reply, a retry or a second in-flight request can clobber state. +- There is no idempotency: a gateway retry after a timeout can charge a bet + twice. +- The crash multiplier is anchored to each frontend's local clock, and the + crash point is only "trust the server". + +The web gateway makes the first three worse (it retries and multiplexes) and +the browser makes the fourth visible (a page and the desktop can disagree). + +## Decision + +This is one wire change and one `MAJOR` release (`5.0.0`); there is no +compatibility mode. + +### Framing + +- Every packet is `[u32 length][payload]`, length big-endian. +- `payload` is UTF-8 text, at most 64 KiB. A larger declared length is a + protocol error and the connection closes; buffers never grow past one frame. +- The payload has no trailing newline. + +### Envelope + +- `REQ [idem=] ` — client request. `` is a `u64` + chosen by the client, monotonically increasing on the connection. +- `RES ` — the reply to that request. +- `EVT ` — a server-initiated event. `` is a `u64`, + incremented once per event on the connection. + +The existing text grammar is the `` and ``; `HELLO 5.0.0`, +`PLAY_START crash 50`, `GAME_RESULT ...` keep their meaning. + +`HELLO` is the first framed request. The server ignores every other command +before a successful handshake. The reply is +`HELLO latest= url= time=`. + +### Correlation and idempotency + +- The client keeps a request id until its `RES` arrives. Replies are routed by + id, never by "the next line". +- The server keeps a bounded 256-entry FIFO of `(id, payload, reply)` on each + connection. A repeated id with the same payload returns the stored reply + without executing the command again. A repeated id with a different payload + returns `ERROR Request id reused with a different command`. +- The cache is per connection and in memory. A reconnect is a new game session + by design; durable idempotency is deferred. +- `idem=` is optional metadata for logs and for a future cross- + connection cache. The request id is the cache key. + +### Clock and ticks + +- `HELLO` and `PONG` carry the server's Unix time in milliseconds. `PING` + replies `PONG `. +- Clients keep an offset between server time and their own clock. The offset is + for display and interpolation only; the server clock settles a cash-out. +- A crash start carries `started_at=` and `commit=`. +- While a crash round runs, the server pushes at most one + `EVT crash.tick session= t= m=` per 100 ms. + The client interpolates between ticks and may keep rendering locally. +- `CRASH_BUSTED` remains an event, now with the reveal attached. + +### Commit–reveal crash + +- At round start the server draws 32 random bytes as the round seed and sends + `commit = sha256(seed)` in hex. It also sends the crash point's start time. +- The crash point is a pure function of the seed: + `u = u64_be(seed[0..8]) / 2^64`, then + `crash_point_from_uniform(u)`. The seed never reaches the client before the + round ends. +- When the round ends (`CRASH_STATE` after cash-out, or `CRASH_BUSTED`), the + server includes `seed=`. A client verifies `sha256(seed) == commit` and + recomputes the crash point. The UI can show "verified". +- This proves the crash point was fixed before the player acted. It does not + prove the seed was not ground to a favourable value; a hash chain with client + entropy is the documented next step if that matters. + +## Consequences + +- The TUI, GUI, server and gateway all move to v5 together; a v4 peer is + refused at `HELLO`. +- `LineBuffer` is replaced by a bounded `FrameBuffer`. Existing newline-based + tests and helpers are rewritten around frames. +- `ClientCore` allocates request ids and routes replies by id. Its `Effect` + carries the id, so an adapter writes the envelope and hands the `RES` back + with the same id. +- Crash rendering can be smoother and consistent across frontends, and the + crash result is independently checkable from the revealed seed. +- The per-connection cache is deliberately small and forgets on reconnect; a + retry after a reconnect is a new action, not a replay. + +## Alternatives considered + +- **JSON or binary frames**: cleaner types or smaller payloads, but a rewrite + of every parser for no scale need. Text inside a bounded frame keeps logs + readable and the change small. +- **Newline-delimited frames with a maximum line length**: still needs a + separate envelope and does not bound the buffer as cleanly; a declared length + is simpler. +- **Durable idempotency keyed by `(user, idem)`**: survives reconnects, but the + gateway owns retries on one connection and a reconnect is a fresh session. + Deferred. +- **Server seed plus client seed without a commit round-trip**: the server + sees the client seed before committing and can bias the pair; a half-measure + is worse than an honest per-round commitment. +- **Hash-chain epoch**: stronger anti-grinding, but requires persistent chain + state and a restart story. Deferred. -- cgit v1.2.3