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
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
|
# Browser frontend plan
Status: v1 implemented in 5.0.0. This is the working plan and the deployment
contract; see `CONTEXT.md` and ADRs 0003/0004 for the decisions.
## Shape
```
browser ──wss──> nginx ──ws──> webd ──TLS──> game server
/play/ /ws 127.0.0.1:8890 127.0.0.1:11525
static files gateway one thread per authoritative rules,
browser session balances, crash clock
```
- `web/play/` is a static TypeScript app. It sends `Command` JSON and renders
`View` JSON. It has no rules and no wire parser.
- `webd/` is a workspace member. It owns one `ClientCore`, one upstream TLS
connection and a 60-second grace window per browser session.
- `src/protocol.rs` is v5: bounded frames, `REQ`/`RES`/`EVT`, request ids,
server time and crash commit–reveal.
## Phases
1. **Protocol v5** — frames, envelope, correlation, replay cache, clock sync,
ticks, commit–reveal, TUI/GUI updates. Done in the 5.0.0 change.
2. **Gateway** — WS accept, cookie session registry, takeover, grace, pinned
upstream, `Command`/`View` JSON. Done in the 5.0.0 change.
3. **Web shell** — login/register, lobby, history, notices, route at `/play/`.
Done in the 5.0.0 change.
4. **Games** — slots, roulette (felt + chips), blackjack, crash (curve +
verification). Done in the 5.0.0 change.
5. **Deployment** — nginx routes, static build, systemd unit, verification in
`scripts/deploy.sh`. Done in the 5.0.0 change.
## Browser contract
Browser → gateway:
```json
{"type":"command","command":{"Login":{"user":"alice","credential":"<sha256 hex>"}}}
{"type":"command","command":{"Play":{"game":"crash","bet":10}}}
{"type":"command","command":"Start"}
{"type":"ping"}
```
`Command` uses serde's external tagging because several variants carry a
primitive or a string. Unit variants are bare strings (`"Spin"`, `"Start"`).
Gateway → browser:
```json
{"type":"hello","version":"5.0.0","grace_seconds":60,"user":"alice","time":1700000000000}
{"type":"view","view":{"screen":"lobby", "...": "..."}}
{"type":"notice","text":"Logged in as alice"}
{"type":"error","text":"Upstream connection failed: ..."}
{"type":"replaced"}
{"type":"pong"}
```
`View` is internally tagged by `screen` (`lobby`, `history`, `slots`,
`roulette`, `blackjack`, `crash`). The browser renders each screen; it never
computes a result.
The cookie is `__Host-zagan_session` on HTTPS (plain `zagan_session` for local
HTTP testing), HttpOnly, SameSite=Strict, Secure on HTTPS. It is set on the
WebSocket handshake. It covers refresh and the grace window only;
there is no remember-me.
## Build and run
```sh
# static app
cd web/play
npm ci
npm run build # writes app.js; commit it
# gateway
cargo build -p zagan-bet-webd --release
ZAGAN_UPSTREAM=127.0.0.1:11525 \
ZAGAN_UPSTREAM_FINGERPRINT=<64 hex> \
ZAGAN_WEB_ORIGIN=https://zagan.2137697.xyz \
ZAGAN_WEB_BIND=127.0.0.1:8890 \
target/release/zagan-bet-webd
```
The fingerprint is the same SPKI SHA-256 the desktop client shows:
```sh
openssl x509 -in cert.pem -pubkey -noout \
| openssl pkey -pubin -outform DER \
| sha256sum
```
Test against a staging site with
`ZAGAN_WEB_ORIGIN=http://127.0.0.1:8899 ZAGAN_WEB_COOKIE_SECURE=0`.
## Security boundaries
- The gateway binds loopback. nginx terminates TLS and is the only public
entry. Do not expose `ZAGAN_WEB_BIND` to the internet.
- The upstream key is pinned; there is no TOFU and no unsafe mode. A mismatch
logs both fingerprints and refuses credentials.
- The browser has no certificate UI and no credential storage. XSS cannot read
the HttpOnly cookie; the backend still must escape every notice.
- `web/robots.txt` disallows every crawler and the site sends `noindex`
headers. The game port is not HTTP: v5 drops a request whose first four bytes
are not a valid frame before `HELLO`, without replying or logging.
- The browser speaks JSON, not the game wire. A malicious page cannot use the
gateway as a protocol proxy: `Origin` is checked and the session cookie is
SameSite=Strict.
- The web app is play-money only. No deposits, withdrawals, KYC or real-money
claims belong in v1 copy. Do not advertise it in the player-facing copy.
## Failure behaviour
| Event | Result |
| --- | --- |
| Refresh / new tab, within grace | Same `ClientCore`, current `View` restored |
| New tab, same cookie | Old tab gets `replaced` and closes; new tab continues |
| Socket closed, grace expires | Upstream closes; server forfeits live hand |
| Gateway restart | Sessions lost; players log in again |
| Upstream disconnect | Browser gets an error, core resets, reconnect attempts |
| Browser retry of a request | Gateway does not retry; request ids are per connection |
## Verification
- `cargo test --workspace` covers frames, envelope parsing, idempotent replay,
commit verification, ticks, core routing and session JSON.
- `web/play`: `npm run typecheck` and `npm run build`.
- Manual WS smoke test: connect, login, play slots/crash, refresh in a live
crash round, verify the revealed seed.
- `scripts/deploy.sh` checks `/play/`, `app.js`, `style.css` and the existing
download feed.
|