# 0003 — Browser frontend through a server-side gateway Status: accepted, 2026-11-08 ## Context The game already runs over TLS/TCP with a newline-delimited text protocol. A browser cannot open a TCP socket, and putting game rules into JavaScript would create a second implementation of payout, session and forfeit logic — exactly what the project forbids. The site must stay a website in the ordinary sense: open `zagan.bet`, log in, play. Nothing to install. Balances are play credits; there are no deposits, withdrawals or KYC in this decision. ## Decision 1. **The browser is a third frontend over `ClientCore`.** It sends `Command` JSON and renders `View` JSON. It contains no rules, no wire-protocol parser and no balance arithmetic. 2. **A Rust gateway, `webd/`, owns each session.** One blocking thread per browser session, one `ClientCore`, and one ordinary client TLS connection upstream. The existing `client::transport` and `ClientCore` are reused. No async runtime and no new concurrency style. 3. **`webd/` is a separate workspace member.** Like `gui/`, its dependencies (tungstenite, HTTP cookie handling) must never enter the TUI, GUI or server build graph. 4. **Everything is same-origin and operator-configured.** nginx terminates TLS, serves the static app at `/play/`, and proxies `/ws` to the gateway on loopback. The browser never sees a certificate, there is no CORS, and the upstream address (`ZAGAN_UPSTREAM`, default `127.0.0.1:11525`) and SPKI fingerprint (`ZAGAN_UPSTREAM_FINGERPRINT`, required) are gateway config. A mismatch refuses to connect and logs both fingerprints. There is no TOFU and no unsafe mode for the upstream leg. 5. **Session-only authentication.** Log in per visit. The gateway issues a random HttpOnly, SameSite=Strict, Secure session cookie; the cookie covers a refresh and the grace window only. No credential is stored in the browser or in the gateway. A new connection with the same cookie takes over the session and the old socket is told it was replaced. 6. **60-second grace window.** When the last browser socket for a session closes, the session thread keeps the upstream connection and `ClientCore` alive for 60 seconds (configurable). A reconnecting socket with the same cookie reattaches and immediately receives the current `View`. Expiry closes upstream, and the server forfeits a live hand per the existing disconnect rule. There is no wire-level resume: the gateway, not the browser, holds the TCP connection. 7. **Vanilla TypeScript + esbuild.** DOM for login, lobby, history and notices; one canvas for the game surface. No framework runtime. Theme tokens are reused from the desktop palette; felt green is reserved for roulette. 8. **Web v1 has full parity after trust.** Register, log in, lobby, all four games, history, log out, notices, refresh grace. The connect/trust screens do not exist in the browser because the gateway owns the upstream key. ## Consequences - One implementation of the rules and parsers remains authoritative, and the TUI/GUI/gateway all exercise the same `ClientCore` tests. - The gateway becomes an operational component: it needs a service unit, deployment, logging, a required upstream fingerprint, and a restart story. A gateway restart loses live sessions and therefore forfeits their rounds; this is accepted for v1. - The browser cannot recover from a gateway crash by itself. The player logs in again. - A 60-second grace window means a live round can outlive a refresh, but not a closed laptop for a minute and a half. - WebSocket frames are same-origin JSON, so the page cannot be used as an open proxy by another origin. The gateway also checks the `Origin` header against `ZAGAN_WEB_ORIGIN`. - The web app is play-money only. Adding payments later would be a separate product and compliance decision, not a UI change. ## Alternatives considered - **WASM `ClientCore` in the browser, dumb relay**: reuses Rust in the page, but needs transport, TOFU and credential shims in wasm and a multi-megabyte download for every player. Rejected. - **JavaScript rewrite speaking the game protocol**: fastest UI iteration, but a second implementation of rules and payout invariants. Rejected. - **Tokio/axum gateway**: idiomatic WebSocket server, but a large async tree and a second concurrency style for one blocking, low-traffic product. Rejected. - **Long-lived remember-me in the gateway**: better return visits, but a new credential store and revocation flow. Deferred; session-only first. - **Protocol-level resume tokens**: survives a gateway restart, but is a wire change and more state on the server. Deferred.