ui/phase-24: push events, turn-ready toast, single SubscribeEvents consumer

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>
This commit is contained in:
Ilia Denisov
2026-05-11 16:16:31 +02:00
parent 5a2a977dc6
commit 5b07bb4e14
26 changed files with 2181 additions and 209 deletions
+51 -11
View File
@@ -4,28 +4,66 @@
import { page } from "$app/state";
import { i18n } from "$lib/i18n/index.svelte";
import { session } from "$lib/session-store.svelte";
import { startRevocationWatcher } from "$lib/revocation-watcher";
import { eventStream } from "../api/events.svelte";
import { loadCore } from "../platform/core/index";
import { GATEWAY_RESPONSE_PUBLIC_KEY } from "$lib/env";
import ToastHost from "$lib/toast-host.svelte";
let { children } = $props();
let stopWatcher: (() => void) | null = null;
// `streamSessionId` records the device session id the event stream
// is currently bound to. The `$effect` below uses it to detect a
// re-login (different session id while still authenticated) and
// restart the stream against the fresh credentials.
let streamSessionId: string | null = null;
onMount(() => {
void session.init();
return () => {
if (stopWatcher !== null) {
stopWatcher();
stopWatcher = null;
}
eventStream.stop();
streamSessionId = null;
};
});
$effect(() => {
if (session.status === "authenticated" && stopWatcher === null) {
stopWatcher = startRevocationWatcher();
} else if (session.status !== "authenticated" && stopWatcher !== null) {
stopWatcher();
stopWatcher = null;
if (
session.status === "authenticated" &&
session.keypair !== null &&
session.deviceSessionId !== null &&
GATEWAY_RESPONSE_PUBLIC_KEY.length > 0
) {
const keypair = session.keypair;
const deviceSessionId = session.deviceSessionId;
if (streamSessionId !== deviceSessionId) {
if (streamSessionId !== null) {
eventStream.stop();
}
streamSessionId = deviceSessionId;
void (async (): Promise<void> => {
try {
const core = await loadCore();
// Bail out if the session flipped away from this id
// while we were loading core (logout, re-login).
if (
session.deviceSessionId !== deviceSessionId ||
session.status !== "authenticated"
) {
return;
}
eventStream.start({
core,
keypair,
deviceSessionId,
gatewayResponsePublicKey: GATEWAY_RESPONSE_PUBLIC_KEY,
});
} catch (err) {
console.info("layout: failed to start event stream", err);
}
})();
}
} else if (streamSessionId !== null) {
eventStream.stop();
streamSessionId = null;
}
const pathname = page.url.pathname;
@@ -57,6 +95,8 @@
{@render children()}
{/if}
<ToastHost />
<style>
.status {
padding: 2rem;