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/adr/0001-gui-frontend-architecture.md | 3 +- docs/adr/0003-web-gateway.md | 85 +++++++++++++++++++++ docs/adr/0004-protocol-v5.md | 119 +++++++++++++++++++++++++++++ 3 files changed, 206 insertions(+), 1 deletion(-) create mode 100644 docs/adr/0003-web-gateway.md create mode 100644 docs/adr/0004-protocol-v5.md (limited to 'docs/adr') 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