1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
|
# 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.
|