Files
galaxy-game/ui/docs/events.md
T
Ilia Denisov e31fb2c17a
Tests · UI / test (push) Failing after 9m28s
docs(ui): sync docs to the app-shell; fix stale nav comments
Rewrite ui/docs (navigation, order-composer, auth-flow, pwa-strategy,
game-state + secondary topic docs) and ui/README for the single-URL
app-shell (in-memory screens/views, Back→lobby via shallow routing,
sessionStorage restore + validation, return-to-lobby). ui/PLAN.md gets a
Phase-10 supersede note (implemented; standalone-compatible). Fix stale
code comments (session-store auth gate, report-sections spec contract).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 21:04:11 +02:00

117 lines
4.6 KiB
Markdown

# UI events stream (`api/events.svelte.ts`)
This document describes how the SvelteKit frontend consumes the
gateway's `SubscribeEvents` server-streaming RPC. The single
authenticated session keeps **one** stream open through the
`EventStream` singleton declared in `src/api/events.svelte.ts`; the
root layout starts it once the session reaches `authenticated` and
stops it on sign-out.
## Why a single consumer
The `EventStream` singleton consolidates what was previously a
separate revocation watcher in `lib/revocation-watcher.ts` so that:
- there is only **one** SubscribeEvents connection per session
(avoids doubling the gateway hub load);
- both clean end-of-stream on an authenticated session and an
`Unauthenticated` ConnectError funnel through one
`session.signOut("revoked")` call site;
- per-event-type dispatch (turn-ready toasts, lobby/mail/battle
notifications) shares the same verification path.
## Lifecycle
```
SessionStore.status = "authenticated"
↓ (root layout $effect)
EventStream.start({ core, keypair, deviceSessionId, gatewayResponsePublicKey })
loop: open SubscribeEvents → verify each frame → dispatch to handlers
EventStream.stop() (on logout, unmount, or session id change)
```
`start` is idempotent for the same session: re-calling while the
stream is running is a no-op. The root layout detects a session id
flip (re-login on the same tab) by storing the bound id and calling
`stop()` + `start()` against the fresh credentials.
## Frame handling
Every `GatewayEvent` is verified before dispatch:
1. `core.verifyPayloadHash(payloadBytes, payloadHash)` — the SHA-256
digest of `payloadBytes` must equal `payloadHash`. A mismatch is
treated as a forgery.
2. `core.verifyEvent(gatewayResponsePublicKey, signature, fields)`
Ed25519 verification using the canonical signing input defined in
`ui/core/canon/event.go` (mirrored by `gateway/authn/event.go`).
3. On success the verified projection (`VerifiedEvent`) is passed to
every handler registered via `eventStream.on(eventType, handler)`.
Any verification failure throws `SignatureError`, which falls into
the same retry path as a transport error: the loop classifies it as
transient, tears the stream down, and reconnects with full-jitter
exponential backoff (base 1 s, ceiling 30 s, unbounded retries).
## Connection status
`EventStream.connectionStatus` is a Svelte rune with five values:
- `idle` — stream not running.
- `connecting``subscribeEvents()` issued, no frame received yet.
- `connected` — first frame verified and dispatched, attempt counter
reset to zero.
- `reconnecting` — transient failure, backoff in flight.
- `offline``navigator.onLine === false` at the moment of failure.
The header connection-state indicator reads this rune; the rune is
wired so a future change can add the indicator dot without touching
this module.
## Revocation semantics
Two paths lead to `session.signOut("revoked")`:
- a `ConnectError` with code `Unauthenticated`: the gateway rejected
the stream credentials (revoked device session);
- a clean end-of-stream while `session.status === "authenticated"`:
the gateway's documented `session_invalidation` signal closes the
stream once the device session flips to revoked.
Any other error (network drop, gateway 5xx, transient close,
signature failure) keeps the session alive and triggers backoff +
reconnect.
## Adding a new event type
1. Register a handler from the consumer module:
```ts
const off = eventStream.on("mail.received", (event) => {
// parse event.payloadBytes
});
onDestroy(off);
```
2. If the handler reads scoped data (per-game), register from a
component that owns that scope and pass the gameId via a closure.
The handler should filter events whose payload does not match its
scope (see `lib/game/game-shell.svelte` for the `game.turn.ready`
filter).
3. The payload encoding is owned by the producer side: the
`game.turn.ready` event uses JSON `{game_id, turn}`. Document
the schema next to the producer (e.g. `backend/README.md` §10).
## Tests
- Unit (Vitest): `tests/events.test.ts` mocks the transport via
`createRouterTransport` and covers verified dispatch, type
filtering, bad-signature reconnect, `Unauthenticated` sign-out,
clean end-of-stream sign-out, and connection-status transitions.
- E2E (Playwright): `tests/e2e/turn-ready.spec.ts` serves a signed
`game.turn.ready` frame through `page.route`, asserts the toast
surfaces, and verifies manual dismiss without advance. The
advance roundtrip (toast → click "view now" → fresh report) is
covered by Vitest at the store level because it is sensitive to
Playwright-side network ordering.