5b07bb4e14
Wires the gateway's signed SubscribeEvents stream end-to-end:
- backend: emit game.turn.ready from lobby.OnRuntimeSnapshot on every
current_turn advance, addressed to every active membership, push-only
channel, idempotency key turn-ready:<game_id>:<turn>;
- ui: single EventStream singleton replaces revocation-watcher.ts and
carries both per-event dispatch and revocation detection; toast
primitive (store + host) lives in lib/; GameStateStore gains
pendingTurn/markPendingTurn/advanceToPending and a persisted
lastViewedTurn so a return after multiple turns surfaces the same
"view now" affordance as a live push event;
- mandatory event-signature verification through ui/core
(verifyPayloadHash + verifyEvent), full-jitter exponential backoff
1s -> 30s on transient failure, signOut("revoked") on
Unauthenticated or clean end-of-stream;
- catalog and migration accept the new kind; tests cover producer
(testcontainers + capturing publisher), consumer (Vitest event
stream, toast, game-state extensions), and a Playwright e2e
delivering a signed frame to the live UI.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
119 lines
4.8 KiB
Markdown
119 lines
4.8 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
|
|
|
|
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.
|