diff options
Diffstat (limited to 'docs/adr/0003-web-gateway.md')
| -rw-r--r-- | docs/adr/0003-web-gateway.md | 85 |
1 files changed, 85 insertions, 0 deletions
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. | ||
