diff options
Diffstat (limited to '')
| -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 |
3 files changed, 206 insertions, 1 deletions
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. | ||
