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