diff options
| author | salmonik <salmonik@tuta.io> | 2026-11-08 03:29:35 +0100 |
|---|---|---|
| committer | salmonik <salmonik@tuta.io> | 2026-11-08 03:29:35 +0100 |
| commit | 058432130628d00764663ecf819a9adc6793768d (patch) | |
| tree | d3f49f111fbfaa48647cca2600adcf9017af2d30 /docs | |
| parent | web: serve latest.php through PHP and refuse stray PHP files (diff) | |
| download | zagan-bet-058432130628d00764663ecf819a9adc6793768d.tar.gz zagan-bet-058432130628d00764663ecf819a9adc6793768d.zip | |
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.
Diffstat (limited to 'docs')
| -rw-r--r-- | docs/GUI-PLAN.md | 5 | ||||
| -rw-r--r-- | docs/PROTOCOL-IDEAS.md | 170 | ||||
| -rw-r--r-- | docs/TESTING.md | 52 | ||||
| -rw-r--r-- | docs/WEB-PLAN.md | 129 | ||||
| -rw-r--r-- | docs/adr/0001-gui-frontend-architecture.md | 3 | ||||
| -rw-r--r-- | docs/adr/0003-web-gateway.md | 85 | ||||
| -rw-r--r-- | docs/adr/0004-protocol-v5.md | 119 |
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 | ||
| 8 | Audio, 3D, macOS, auto-update, a browser client, and pixel-golden tests in CI. | 8 | Audio, 3D, macOS, auto-update, a webview inside the desktop app, and |
| 9 | pixel-golden tests in CI. A separate browser frontend is no longer a non-goal: | ||
| 10 | it 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 | |||
| 3 | Status: design notes, written 2026-11-08. Items 1–5 were adopted (with the | ||
| 4 | differences recorded in `docs/adr/0004-protocol-v5.md`): correlation ids, | ||
| 5 | per-connection idempotency, clock sync + ticks, commit–reveal crash, and | ||
| 6 | length-prefixed framing. Items 6–7 remain future work. | ||
| 7 | |||
| 8 | These 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 | ||
| 10 | zagan-bet paths are included because that is where the patterns were worked | ||
| 11 | out; treat the ideas as portable. | ||
| 12 | |||
| 13 | zagan-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 | |||
| 25 | Each 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 | |||
| 29 | One request in flight, and pushes distinguished by string prefix | ||
| 30 | (`expects_push`, `CRASH_BUSTED`, `src/client/core.rs:622`). A late reply can | ||
| 31 | race 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 | ``` | ||
| 35 | REQ 42 idem=f3a1... INFO | ||
| 36 | RES 42 OK balance=1200 account=... | ||
| 37 | EVT 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 | ||
| 41 | anything. The core keeps a map of in-flight requests instead of one `Option`, | ||
| 42 | and "a stale reply must not clobber the result" becomes a generic rule rather | ||
| 43 | than 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 | ||
| 45 | framing for now; just prepend tokens. | ||
| 46 | |||
| 47 | ## 2. Idempotency keys on every balance mutation | ||
| 48 | |||
| 49 | A request can be applied server-side and the reply lost. Today that is safe | ||
| 50 | only because nothing retries automatically; the moment a client retries or | ||
| 51 | resumes, it can double-debit. | ||
| 52 | |||
| 53 | ``` | ||
| 54 | REQ 43 idem=9c2e... PLAY_START crash 50 | ||
| 55 | ``` | ||
| 56 | |||
| 57 | Server keeps `seen_requests (idem PRIMARY KEY, user, reply, created_at)`. The | ||
| 58 | replay check, the debit, and the row insert sit in the same transaction as the | ||
| 59 | existing reservation (`database::reserve_play`). A duplicate returns the stored | ||
| 60 | reply and never re-executes. TTL-sweep at startup next to | ||
| 61 | `recover_orphaned_reservations`. Scope by user, not connection, so a resumed | ||
| 62 | session cannot replay someone else's key. | ||
| 63 | |||
| 64 | ## 3. Clock sync and a real tick stream | ||
| 65 | |||
| 66 | zagan-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 | ||
| 69 | does not know its offset. Two parts: | ||
| 70 | |||
| 71 | **Clock sync**, Cristian/NTP style over the existing connection: | ||
| 72 | |||
| 73 | ``` | ||
| 74 | REQ 7 idem=... TIME t0=837412 | ||
| 75 | RES 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 | ||
| 79 | window. Render timed games from `server_now`. | ||
| 80 | |||
| 81 | **Ticks** at 10–20 Hz, monotonic `seq`, no reply semantics: | ||
| 82 | |||
| 83 | ``` | ||
| 84 | EVT seq=914 crash.tick {"session_id":"...","server_ms":837520,"multiplier":1.44} | ||
| 85 | ``` | ||
| 86 | |||
| 87 | Coalesce in the writer: replace an unqueued tick, never drop a settlement. | ||
| 88 | That 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 | ||
| 90 | the 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 | |||
| 94 | zagan-bet already withholds the crash point (`CrashState::crash_point`, | ||
| 95 | `src/crash.rs`). Make it verifiable: | ||
| 96 | |||
| 97 | ``` | ||
| 98 | RES 51 OK COMMIT 7f3a... 4b9c... # sha256(server_seed), round id | ||
| 99 | ...round runs... | ||
| 100 | EVT seq=980 crash.busted {"seed":"<server_seed>","crash_point":2.37,...} | ||
| 101 | ``` | ||
| 102 | |||
| 103 | Player 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 | ||
| 106 | unit test, not a network test. A hash chain lets the server pre-commit every | ||
| 107 | future round at session start; that is stronger than needed in most cases. | ||
| 108 | |||
| 109 | ## 5. Framing: length prefix, then maybe QUIC | ||
| 110 | |||
| 111 | Weaknesses 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 | |||
| 122 | This allows a move to `postcard`/CBOR later without touching the read loop. | ||
| 123 | The larger swing is QUIC (`quinn`): separate streams so a slow `HISTORY` | ||
| 124 | cannot block ticks, 0-RTT resumption, connection migration for NAT rebinds | ||
| 125 | mid-round, and unreliable datagrams for ticks where latest-wins beats | ||
| 126 | retransmission. zagan-bet already uses rustls on the client, so this would also | ||
| 127 | drop OpenSSL from the server build. Cost: UDP reachability, TLS 1.3 only, a | ||
| 128 | rustls 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 | ||
| 134 | buffer. Turns disconnect from "forfeit everything" into a policy choice; a | ||
| 135 | server-clocked round is trivially resumable. In zagan-bet this contradicts the | ||
| 136 | AGENTS rule that disconnect = forfeit, so it would need an explicit carve-out | ||
| 137 | and a grace window. | ||
| 138 | |||
| 139 | Separately, both ends poll with timers: the server's 50 ms read timeout, and | ||
| 140 | the 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 | ||
| 144 | deadline, 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 | |||
| 161 | 1. Correlation IDs + idempotency keys — small, kills two bug classes. | ||
| 162 | 2. Clock sync + coalesced ticks — turns a special-case push into a generic | ||
| 163 | event channel. | ||
| 164 | 3. Commit–reveal — cheap, and the strongest fairness story for a betting | ||
| 165 | server. | ||
| 166 | |||
| 167 | The 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 | ||
| 170 | 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. | |||
| 210 | 210 | ||
| 211 | 1. `play crash` shows the ready board with a 1.00x multiplier; Space starts and | 211 | 1. `play crash` shows the ready board with a 1.00x multiplier; Space starts and |
| 212 | reserves the bet. | 212 | reserves the bet. |
| 213 | 2. The multiplier rises smoothly with no network traffic. Cash out with C and | 213 | 2. 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. |
| 216 | 3. On another round, press nothing. The board must switch to `CRASHED at Xx` | 216 | 3. 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. |
| 218 | 4. Press C just as a round crashes. Either it pays or the crashed result wins; | 218 | 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. | |||
| 220 | 5. Q during a live round forfeits the bet; Q when idle just leaves. | 220 | 5. Q during a live round forfeits the bet; Q when idle just leaves. |
| 221 | 6. Check the audit rows: one per round, `game_type = crash`, the reserved bet, | 221 | 6. 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`. |
| 223 | 7. Confirm the crash point is absent from the `CRASH_START` line (it must only | 223 | 7. 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. | ||
| 226 | 8. 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 | ||
| 238 | 1. Connect a client and server from the same release: the client connects and | 241 | 1. 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. |
| 240 | 2. Connect a 4.x client to a 3.x server (or the reverse): the client must print | 243 | 2. 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. |
| 242 | 3. With a deliberately skew-built server (PATCH differs) the connection is | 245 | 3. 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 | ||
| 272 | 1. Set the version in `Cargo.toml` and `gui/Cargo.toml` (same MAJOR.MINOR), | 275 | 1. 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. | ||
| 275 | 2. `./scripts/publish.sh` (add `DEST=` to stage elsewhere). It builds the GUI | 280 | 2. `./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. |
| 286 | 5. Repeat on Windows for the `.ps1` installer and the Start-menu shortcut. | 291 | 5. Repeat on Windows for the `.ps1` installer and the Start-menu shortcut. |
| 292 | 6. 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 | |||
| 297 | 1. Open `/play/` in a fresh browser. The auth card must appear; the top bar | ||
| 298 | must not claim a balance until login. | ||
| 299 | 2. 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`). | ||
| 302 | 3. 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. | ||
| 304 | 4. 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. | ||
| 307 | 5. Blackjack: deal, hit/stand/double, then deal again; forfeiting must warn | ||
| 308 | and lose the reserved bet. | ||
| 309 | 6. 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. | ||
| 312 | 7. 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. | ||
| 314 | 8. 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. | ||
| 316 | 9. 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. | ||
| 319 | 10. 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 | |||
| 3 | Status: v1 implemented in 5.0.0. This is the working plan and the deployment | ||
| 4 | contract; see `CONTEXT.md` and ADRs 0003/0004 for the decisions. | ||
| 5 | |||
| 6 | ## Shape | ||
| 7 | |||
| 8 | ``` | ||
| 9 | browser ──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 | |||
| 24 | 1. **Protocol v5** — frames, envelope, correlation, replay cache, clock sync, | ||
| 25 | ticks, commit–reveal, TUI/GUI updates. Done in the 5.0.0 change. | ||
| 26 | 2. **Gateway** — WS accept, cookie session registry, takeover, grace, pinned | ||
| 27 | upstream, `Command`/`View` JSON. Done in the 5.0.0 change. | ||
| 28 | 3. **Web shell** — login/register, lobby, history, notices, route at `/play/`. | ||
| 29 | Done in the 5.0.0 change. | ||
| 30 | 4. **Games** — slots, roulette (felt + chips), blackjack, crash (curve + | ||
| 31 | verification). Done in the 5.0.0 change. | ||
| 32 | 5. **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 | |||
| 37 | Browser → 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 | ||
| 47 | primitive or a string. Unit variants are bare strings (`"Spin"`, `"Start"`). | ||
| 48 | |||
| 49 | Gateway → 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 | ||
| 62 | computes a result. | ||
| 63 | |||
| 64 | The cookie is `zagan_session`, HttpOnly, SameSite=Strict, Secure on HTTPS. It | ||
| 65 | is set on the WebSocket handshake. It covers refresh and the grace window only; | ||
| 66 | there is no remember-me. | ||
| 67 | |||
| 68 | ## Build and run | ||
| 69 | |||
| 70 | ```sh | ||
| 71 | # static app | ||
| 72 | cd web/play | ||
| 73 | npm ci | ||
| 74 | npm run build # writes app.js; commit it | ||
| 75 | |||
| 76 | # gateway | ||
| 77 | cargo build -p zagan-bet-webd --release | ||
| 78 | ZAGAN_UPSTREAM=127.0.0.1:11525 \ | ||
| 79 | ZAGAN_UPSTREAM_FINGERPRINT=<64 hex> \ | ||
| 80 | ZAGAN_WEB_ORIGIN=https://zagan.2137697.xyz \ | ||
| 81 | ZAGAN_WEB_BIND=127.0.0.1:8890 \ | ||
| 82 | target/release/zagan-bet-webd | ||
| 83 | ``` | ||
| 84 | |||
| 85 | The fingerprint is the same SPKI SHA-256 the desktop client shows: | ||
| 86 | |||
| 87 | ```sh | ||
| 88 | openssl x509 -in cert.pem -pubkey -noout \ | ||
| 89 | | openssl pkey -pubin -outform DER \ | ||
| 90 | | sha256sum | ||
| 91 | ``` | ||
| 92 | |||
| 93 | Test 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 | ||
| 3 | Status: accepted, 2026-11-01 | 3 | Status: 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 | |||
| 3 | Status: accepted, 2026-11-08 | ||
| 4 | |||
| 5 | ## Context | ||
| 6 | |||
| 7 | The game already runs over TLS/TCP with a newline-delimited text protocol. A | ||
| 8 | browser cannot open a TCP socket, and putting game rules into JavaScript would | ||
| 9 | create a second implementation of payout, session and forfeit logic — exactly | ||
| 10 | what the project forbids. | ||
| 11 | |||
| 12 | The site must stay a website in the ordinary sense: open `zagan.bet`, log in, | ||
| 13 | play. Nothing to install. Balances are play credits; there are no deposits, | ||
| 14 | withdrawals or KYC in this decision. | ||
| 15 | |||
| 16 | ## Decision | ||
| 17 | |||
| 18 | 1. **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. | ||
| 21 | 2. **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. | ||
| 25 | 3. **`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. | ||
| 28 | 4. **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. | ||
| 35 | 5. **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. | ||
| 40 | 6. **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. | ||
| 47 | 7. **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. | ||
| 50 | 8. **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 | |||
| 3 | Status: accepted, 2026-11-08 | ||
| 4 | |||
| 5 | ## Context | ||
| 6 | |||
| 7 | The current wire is newline-delimited text over TLS. It has served the TUI and | ||
| 8 | GUI well, but four problems are now worth fixing before a browser gateway is | ||
| 9 | built 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 | |||
| 20 | The web gateway makes the first three worse (it retries and multiplexes) and | ||
| 21 | the browser makes the fourth visible (a page and the desktop can disagree). | ||
| 22 | |||
| 23 | ## Decision | ||
| 24 | |||
| 25 | This is one wire change and one `MAJOR` release (`5.0.0`); there is no | ||
| 26 | compatibility 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 | |||
| 43 | The 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 | ||
| 47 | before 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. | ||
