# 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 Before Phase 24, the watcher in `lib/revocation-watcher.ts` opened a parallel stream just to observe session revocation. Phase 24 folds that watcher into `EventStream` 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 later) 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 planned in `PLAN.md` cross-cutting shell reads this rune; it is not part of Phase 24 but the rune is wired now so a later phase can add the 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, per-route), register from a layout that owns that scope and pass the gameId via a closure. The handler should filter events whose payload does not match its scope (see `routes/games/[id]/+layout.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.