From 5b07bb4e148a39c215e04d74950f1534f07cab1b Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Mon, 11 May 2026 16:16:31 +0200 Subject: [PATCH] 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::; - 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 --- backend/README.md | 15 +- backend/internal/lobby/lobby.go | 1 + backend/internal/lobby/runtime_hooks.go | 48 +++ backend/internal/lobby/runtime_hooks_test.go | 207 ++++++++++ backend/internal/notification/catalog.go | 5 + backend/internal/notification/catalog_test.go | 1 + .../postgres/migrations/00001_init.sql | 3 +- docs/FUNCTIONAL.md | 20 +- docs/FUNCTIONAL_ru.md | 21 +- ui/PLAN.md | 99 ++++- ui/docs/events.md | 118 ++++++ ui/frontend/src/api/events.svelte.ts | 376 ++++++++++++++++++ ui/frontend/src/lib/game-state.svelte.ts | 104 +++++ ui/frontend/src/lib/i18n/locales/en.ts | 5 + ui/frontend/src/lib/i18n/locales/ru.ts | 5 + ui/frontend/src/lib/revocation-watcher.ts | 157 -------- ui/frontend/src/lib/toast-host.svelte | 109 +++++ ui/frontend/src/lib/toast.svelte.ts | 97 +++++ ui/frontend/src/routes/+layout.svelte | 62 ++- .../src/routes/games/[id]/+layout.svelte | 76 ++++ ui/frontend/tests/e2e/fixtures/canon.ts | 28 ++ ui/frontend/tests/e2e/fixtures/sign-event.ts | 94 +++++ ui/frontend/tests/e2e/turn-ready.spec.ts | 194 +++++++++ ui/frontend/tests/events.test.ts | 324 +++++++++++++++ ui/frontend/tests/game-state.test.ts | 94 +++++ ui/frontend/tests/toast.test.ts | 127 ++++++ 26 files changed, 2181 insertions(+), 209 deletions(-) create mode 100644 backend/internal/lobby/runtime_hooks_test.go create mode 100644 ui/docs/events.md create mode 100644 ui/frontend/src/api/events.svelte.ts delete mode 100644 ui/frontend/src/lib/revocation-watcher.ts create mode 100644 ui/frontend/src/lib/toast-host.svelte create mode 100644 ui/frontend/src/lib/toast.svelte.ts create mode 100644 ui/frontend/tests/e2e/fixtures/sign-event.ts create mode 100644 ui/frontend/tests/e2e/turn-ready.spec.ts create mode 100644 ui/frontend/tests/events.test.ts create mode 100644 ui/frontend/tests/toast.test.ts diff --git a/backend/README.md b/backend/README.md index 6ed2fb3..bac53b3 100644 --- a/backend/README.md +++ b/backend/README.md @@ -339,9 +339,18 @@ Admin-channel kinds (`runtime.*`) deliver email to routes land in `notification_routes` with `status='skipped'` and the operator log line records the configuration miss. -`game.*` (`game.started`, `game.turn.ready`, `game.generation.failed`, -`game.finished`) and `mail.dead_lettered` are reserved kinds without a -producer in the catalog; adding them is an additive change to the +`game.turn.ready` is emitted by `lobby.Service.OnRuntimeSnapshot` +(`backend/internal/lobby/runtime_hooks.go`) whenever the engine's +`current_turn` advances. The intent targets every active membership +of the game, uses idempotency key `turn-ready::`, and +carries the JSON payload `{game_id, turn}`. The catalog routes it +through the push channel only — per-turn email would be spam — so +the UI's signed `SubscribeEvents` stream +(`ui/frontend/src/api/events.svelte.ts`) is the sole delivery path. + +The remaining `game.*` (`game.started`, `game.generation.failed`, +`game.finished`) and `mail.dead_lettered` are reserved kinds without +a producer in the catalog; adding them is an additive change to the catalog vocabulary and the migration CHECK constraint. Templates ship in English only; localisation belongs to clients that diff --git a/backend/internal/lobby/lobby.go b/backend/internal/lobby/lobby.go index da734d5..d3a3487 100644 --- a/backend/internal/lobby/lobby.go +++ b/backend/internal/lobby/lobby.go @@ -109,6 +109,7 @@ const ( NotificationLobbyRaceNameRegistered = "lobby.race_name.registered" NotificationLobbyRaceNamePending = "lobby.race_name.pending" NotificationLobbyRaceNameExpired = "lobby.race_name.expired" + NotificationGameTurnReady = "game.turn.ready" ) // Deps aggregates every collaborator the lobby Service depends on. diff --git a/backend/internal/lobby/runtime_hooks.go b/backend/internal/lobby/runtime_hooks.go index 65fdd89..1710965 100644 --- a/backend/internal/lobby/runtime_hooks.go +++ b/backend/internal/lobby/runtime_hooks.go @@ -30,6 +30,7 @@ func (s *Service) OnRuntimeSnapshot(ctx context.Context, gameID uuid.UUID, snaps if err != nil { return err } + prevTurn := game.RuntimeSnapshot.CurrentTurn merged := mergeRuntimeSnapshot(game.RuntimeSnapshot, snapshot) now := s.deps.Now().UTC() updated, err := s.deps.Store.UpdateGameRuntimeSnapshot(ctx, gameID, merged, now) @@ -55,9 +56,56 @@ func (s *Service) OnRuntimeSnapshot(ctx context.Context, gameID uuid.UUID, snaps } } s.deps.Cache.PutGame(updated) + if merged.CurrentTurn > prevTurn { + s.publishTurnReady(ctx, gameID, merged.CurrentTurn) + } return nil } +// publishTurnReady fans out a `game.turn.ready` notification to every +// active member of the game once the engine reports a new +// `current_turn`. The intent is best-effort: a publisher failure is +// logged at warn level (matching the rest of OnRuntimeSnapshot's +// notification calls) and does not abort the snapshot bookkeeping. +// Idempotency is anchored on (game_id, turn), so a duplicate snapshot +// for the same turn collapses into a single notification at the +// notification.Submit boundary. +func (s *Service) publishTurnReady(ctx context.Context, gameID uuid.UUID, turn int32) { + memberships, err := s.deps.Store.ListMembershipsForGame(ctx, gameID) + if err != nil { + s.deps.Logger.Warn("turn-ready notification: list memberships failed", + zap.String("game_id", gameID.String()), + zap.Int32("turn", turn), + zap.Error(err)) + return + } + recipients := make([]uuid.UUID, 0, len(memberships)) + for _, m := range memberships { + if m.Status != MembershipStatusActive { + continue + } + recipients = append(recipients, m.UserID) + } + if len(recipients) == 0 { + return + } + intent := LobbyNotification{ + Kind: NotificationGameTurnReady, + IdempotencyKey: fmt.Sprintf("turn-ready:%s:%d", gameID, turn), + Recipients: recipients, + Payload: map[string]any{ + "game_id": gameID.String(), + "turn": turn, + }, + } + if pubErr := s.deps.Notification.PublishLobbyEvent(ctx, intent); pubErr != nil { + s.deps.Logger.Warn("turn-ready notification failed", + zap.String("game_id", gameID.String()), + zap.Int32("turn", turn), + zap.Error(pubErr)) + } +} + // OnGameFinished completes the game lifecycle: marks the game as // `finished`, evaluates capable-finish per active member, and // transitions reservation rows to either `pending_registration` diff --git a/backend/internal/lobby/runtime_hooks_test.go b/backend/internal/lobby/runtime_hooks_test.go new file mode 100644 index 0000000..61aa655 --- /dev/null +++ b/backend/internal/lobby/runtime_hooks_test.go @@ -0,0 +1,207 @@ +package lobby_test + +import ( + "context" + "database/sql" + "fmt" + "sync" + "testing" + "time" + + "galaxy/backend/internal/config" + "galaxy/backend/internal/lobby" + + "github.com/google/uuid" +) + +// capturingPublisher records every `LobbyNotification` intent that the +// lobby service emits, so a test can assert the producer side without +// running the real notification.Submit pipeline. +type capturingPublisher struct { + mu sync.Mutex + items []lobby.LobbyNotification +} + +func (p *capturingPublisher) PublishLobbyEvent(_ context.Context, ev lobby.LobbyNotification) error { + p.mu.Lock() + defer p.mu.Unlock() + p.items = append(p.items, ev) + return nil +} + +func (p *capturingPublisher) byKind(kind string) []lobby.LobbyNotification { + p.mu.Lock() + defer p.mu.Unlock() + out := make([]lobby.LobbyNotification, 0, len(p.items)) + for _, ev := range p.items { + if ev.Kind == kind { + out = append(out, ev) + } + } + return out +} + +// newServiceWithPublisher mirrors `newServiceForTest` but lets the +// caller inject a custom NotificationPublisher; the runtime-hooks +// emit path needs to observe intents directly. +func newServiceWithPublisher(t *testing.T, db *sql.DB, now func() time.Time, max int32, publisher lobby.NotificationPublisher) *lobby.Service { + t.Helper() + store := lobby.NewStore(db) + cache := lobby.NewCache() + if err := cache.Warm(context.Background(), store); err != nil { + t.Fatalf("warm cache: %v", err) + } + svc, err := lobby.NewService(lobby.Deps{ + Store: store, + Cache: cache, + Notification: publisher, + Entitlement: stubEntitlement{max: max}, + Config: config.LobbyConfig{ + SweeperInterval: time.Second, + PendingRegistrationTTL: time.Hour, + InviteDefaultTTL: time.Hour, + }, + Now: now, + }) + if err != nil { + t.Fatalf("new service: %v", err) + } + return svc +} + +// TestOnRuntimeSnapshotEmitsTurnReady verifies that an engine snapshot +// advancing `current_turn` fans out a `game.turn.ready` intent to every +// active member, that the idempotency key is anchored on (game_id, turn), +// and that a snapshot with the same turn does not re-emit. +func TestOnRuntimeSnapshotEmitsTurnReady(t *testing.T) { + db := startPostgres(t) + now := time.Now().UTC() + clock := func() time.Time { return now } + publisher := &capturingPublisher{} + svc := newServiceWithPublisher(t, db, clock, 5, publisher) + + owner := uuid.New() + seedAccount(t, db, owner) + + game, err := svc.CreateGame(context.Background(), lobby.CreateGameInput{ + OwnerUserID: &owner, + Visibility: lobby.VisibilityPrivate, + GameName: "Turn-Ready Fan-Out", + MinPlayers: 1, + MaxPlayers: 4, + StartGapHours: 1, + StartGapPlayers: 1, + EnrollmentEndsAt: now.Add(time.Hour), + TurnSchedule: "0 0 * * *", + TargetEngineVersion: "1.0.0", + }) + if err != nil { + t.Fatalf("create game: %v", err) + } + if _, err := svc.OpenEnrollment(context.Background(), &owner, false, game.GameID); err != nil { + t.Fatalf("open enrollment: %v", err) + } + + // Seed two active members through the store so the test focuses on + // the runtime hook, not the membership state machine. + store := lobby.NewStore(db) + canonicalPolicy, err := lobby.NewPolicy() + if err != nil { + t.Fatalf("new policy: %v", err) + } + memberA := uuid.New() + memberB := uuid.New() + seedAccount(t, db, memberA) + seedAccount(t, db, memberB) + for i, m := range []uuid.UUID{memberA, memberB} { + race := fmt.Sprintf("Race%d", i+1) + canonical, err := canonicalPolicy.Canonical(race) + if err != nil { + t.Fatalf("canonical %q: %v", race, err) + } + if _, err := db.ExecContext(context.Background(), ` + INSERT INTO backend.memberships ( + membership_id, game_id, user_id, race_name, canonical_key, status + ) VALUES ($1, $2, $3, $4, $5, 'active') + `, uuid.New(), game.GameID, m, race, string(canonical)); err != nil { + t.Fatalf("seed membership %s: %v", m, err) + } + } + if err := svc.Cache().Warm(context.Background(), store); err != nil { + t.Fatalf("re-warm cache: %v", err) + } + if _, err := svc.ReadyToStart(context.Background(), &owner, false, game.GameID); err != nil { + t.Fatalf("ready-to-start: %v", err) + } + if _, err := svc.Start(context.Background(), &owner, false, game.GameID); err != nil { + t.Fatalf("start: %v", err) + } + + // First snapshot: prev=0, current_turn=1 → emit on the very first + // turn after the engine starts producing. + if err := svc.OnRuntimeSnapshot(context.Background(), game.GameID, lobby.RuntimeSnapshot{ + CurrentTurn: 1, + RuntimeStatus: "running", + }); err != nil { + t.Fatalf("on-runtime-snapshot 1: %v", err) + } + intents := publisher.byKind(lobby.NotificationGameTurnReady) + if len(intents) != 1 { + t.Fatalf("after turn 1 want 1 turn-ready intent, got %d", len(intents)) + } + first := intents[0] + wantKey := fmt.Sprintf("turn-ready:%s:1", game.GameID) + if first.IdempotencyKey != wantKey { + t.Errorf("turn 1 idempotency key = %q, want %q", first.IdempotencyKey, wantKey) + } + if got := first.Payload["turn"]; got != int32(1) { + t.Errorf("turn 1 payload turn = %v, want 1", got) + } + if got := first.Payload["game_id"]; got != game.GameID.String() { + t.Errorf("turn 1 payload game_id = %v, want %s", got, game.GameID) + } + if len(first.Recipients) != 2 { + t.Errorf("turn 1 recipients = %d, want 2", len(first.Recipients)) + } + recipientSet := map[uuid.UUID]struct{}{} + for _, r := range first.Recipients { + recipientSet[r] = struct{}{} + } + if _, ok := recipientSet[memberA]; !ok { + t.Errorf("turn 1 missing memberA in recipients") + } + if _, ok := recipientSet[memberB]; !ok { + t.Errorf("turn 1 missing memberB in recipients") + } + + // Same turn re-delivered (duplicate snapshot, gateway replay) must + // not re-emit at the lobby layer: prev catches up to merged. + if err := svc.OnRuntimeSnapshot(context.Background(), game.GameID, lobby.RuntimeSnapshot{ + CurrentTurn: 1, + RuntimeStatus: "running", + }); err != nil { + t.Fatalf("on-runtime-snapshot 1 replay: %v", err) + } + if got := len(publisher.byKind(lobby.NotificationGameTurnReady)); got != 1 { + t.Fatalf("after duplicate turn 1 want 1 intent, got %d", got) + } + + // Next turn advances → second emit with key anchored on turn 2. + if err := svc.OnRuntimeSnapshot(context.Background(), game.GameID, lobby.RuntimeSnapshot{ + CurrentTurn: 2, + RuntimeStatus: "running", + }); err != nil { + t.Fatalf("on-runtime-snapshot 2: %v", err) + } + intents = publisher.byKind(lobby.NotificationGameTurnReady) + if len(intents) != 2 { + t.Fatalf("after turn 2 want 2 turn-ready intents, got %d", len(intents)) + } + wantKey2 := fmt.Sprintf("turn-ready:%s:2", game.GameID) + if intents[1].IdempotencyKey != wantKey2 { + t.Errorf("turn 2 idempotency key = %q, want %q", intents[1].IdempotencyKey, wantKey2) + } + if got := intents[1].Payload["turn"]; got != int32(2) { + t.Errorf("turn 2 payload turn = %v, want 2", got) + } +} diff --git a/backend/internal/notification/catalog.go b/backend/internal/notification/catalog.go index 3952724..f1b85e1 100644 --- a/backend/internal/notification/catalog.go +++ b/backend/internal/notification/catalog.go @@ -17,6 +17,7 @@ const ( KindRuntimeImagePullFailed = "runtime.image_pull_failed" KindRuntimeContainerStartFailed = "runtime.container_start_failed" KindRuntimeStartConfigInvalid = "runtime.start_config_invalid" + KindGameTurnReady = "game.turn.ready" ) // CatalogEntry describes the per-kind delivery policy: which channels @@ -95,6 +96,9 @@ var catalog = map[string]CatalogEntry{ Admin: true, MailTemplateID: KindRuntimeStartConfigInvalid, }, + KindGameTurnReady: { + Channels: []string{ChannelPush}, + }, } // LookupCatalog returns the per-kind policy and a boolean reporting @@ -123,5 +127,6 @@ func SupportedKinds() []string { KindRuntimeImagePullFailed, KindRuntimeContainerStartFailed, KindRuntimeStartConfigInvalid, + KindGameTurnReady, } } diff --git a/backend/internal/notification/catalog_test.go b/backend/internal/notification/catalog_test.go index f6cd3e9..a566bf3 100644 --- a/backend/internal/notification/catalog_test.go +++ b/backend/internal/notification/catalog_test.go @@ -39,6 +39,7 @@ func TestCatalogChannels(t *testing.T) { KindRuntimeImagePullFailed: {ChannelEmail}, KindRuntimeContainerStartFailed: {ChannelEmail}, KindRuntimeStartConfigInvalid: {ChannelEmail}, + KindGameTurnReady: {ChannelPush}, } for kind, want := range expect { entry, ok := LookupCatalog(kind) diff --git a/backend/internal/postgres/migrations/00001_init.sql b/backend/internal/postgres/migrations/00001_init.sql index 479a64c..fa7547a 100644 --- a/backend/internal/postgres/migrations/00001_init.sql +++ b/backend/internal/postgres/migrations/00001_init.sql @@ -605,7 +605,8 @@ CREATE TABLE notifications ( 'lobby.race_name.registered', 'lobby.race_name.pending', 'lobby.race_name.expired', 'runtime.image_pull_failed', 'runtime.container_start_failed', - 'runtime.start_config_invalid' + 'runtime.start_config_invalid', + 'game.turn.ready' )) ); diff --git a/docs/FUNCTIONAL.md b/docs/FUNCTIONAL.md index b96fa44..e148b45 100644 --- a/docs/FUNCTIONAL.md +++ b/docs/FUNCTIONAL.md @@ -672,13 +672,19 @@ runtime status, per-player stats). The engine's "game finished" report drives the `running → finished` transition ([Section 3.5](#35-cancellation-and-finish)) and triggers Race Name Directory promotions ([Section 5](#5-race-name-directory)). -The `game.*` notification kinds (`game.started`, `game.turn.ready`, -`game.generation.failed`, `game.finished`) are reserved in the -documentation but have **no producer** in the codebase today; the -notification catalog explicitly omits them (`backend/internal/notification/catalog.go`). -Adding a producer is purely additive: register the kind in the -catalog, populate `MailTemplateID` if email fan-out is desired, and -have the appropriate domain module call `notification.Submit`. +Among the `game.*` notification kinds, `game.turn.ready` is wired: +`lobby.Service.OnRuntimeSnapshot` (`backend/internal/lobby/runtime_hooks.go`) +emits one intent per advancing `current_turn`, addressed to every +active membership of the game, with idempotency key +`turn-ready::` and JSON payload `{game_id, turn}`. The +catalog routes the intent through the push channel only; email is +deliberately omitted to avoid per-turn spam. + +The remaining `game.*` kinds (`game.started`, `game.generation.failed`, +`game.finished`) and `mail.dead_lettered` are reserved without a +producer; adding one is purely additive (register the kind in the +catalog, extend the migration `CHECK` constraint, and call +`notification.Submit` from the appropriate domain module). ### 6.6 Cross-references diff --git a/docs/FUNCTIONAL_ru.md b/docs/FUNCTIONAL_ru.md index 7f6c39d..e847210 100644 --- a/docs/FUNCTIONAL_ru.md +++ b/docs/FUNCTIONAL_ru.md @@ -690,14 +690,19 @@ status, per-player-stats). Engine-отчёт "game finished" гонит ([Раздел 3.5](#35-отмена-и-завершение)) и триггерит Race Name Directory-промоушен ([Раздел 5](#5-реестр-названий-рас)). -`game.*`-виды уведомлений (`game.started`, `game.turn.ready`, -`game.generation.failed`, `game.finished`) зарезервированы в -документации, но **не имеют поставщика** в кодовой базе сегодня; -notification-каталог явно их опускает -(`backend/internal/notification/catalog.go`). Добавление поставщика -аддитивно: зарегистрировать вид в каталоге, заполнить -`MailTemplateID`, если нужен email-веер, и заставить нужный -доменный модуль вызвать `notification.Submit`. +Из `game.*`-видов уведомлений подключён `game.turn.ready`: +`lobby.Service.OnRuntimeSnapshot` (`backend/internal/lobby/runtime_hooks.go`) +выпускает один intent на каждое увеличение `current_turn`, адресуя +его всем активным membership-ам игры, с idempotency-ключом +`turn-ready::` и JSON-payload-ом `{game_id, turn}`. +Каталог направляет intent только в push-канал; email-фан-аут +сознательно опущен, чтобы избежать спама на каждом ходе. + +Остальные `game.*`-виды (`game.started`, `game.generation.failed`, +`game.finished`) и `mail.dead_lettered` зарезервированы без поставщика; +добавление поставщика чисто аддитивное (зарегистрировать вид в +каталоге, расширить `CHECK`-констрейнт миграции и вызвать +`notification.Submit` из подходящего доменного модуля). ### 6.6 Перекрёстные ссылки diff --git a/ui/PLAN.md b/ui/PLAN.md index d46aed0..9591936 100644 --- a/ui/PLAN.md +++ b/ui/PLAN.md @@ -2581,40 +2581,95 @@ Decisions during stage: `game.table.*` so the two surfaces evolve independently. ≈90 new keys, en + ru in lockstep. -## Phase 24. Push Events — Turn-Ready +## ~~Phase 24. Push Events — Turn-Ready~~ -Status: pending. +Status: done. Goal: subscribe to the server push stream and refresh client state when a turn-ready event arrives. -Artifacts: +Artifacts (delivered): -- `ui/frontend/src/api/events.ts` push-stream subscription wired - through `GalaxyClient.subscribeEvents` and Connect server-streaming -- on `game.turn.ready` event: invalidate `(game_id, current_turn)` - cache entries and trigger a fresh report fetch -- a top-of-screen toast: `Turn N+1 is ready. View now.` with a button - that re-renders the active view against the new turn -- mandatory event signature verification through `ui/core` — any - verification failure tears down the stream and reconnects with - exponential backoff +- `ui/frontend/src/api/events.svelte.ts` — single + `SubscribeEvents` consumer per session. Absorbs the previous + `revocation-watcher.ts` (now deleted) so there is exactly one + authenticated stream per device session; clean end-of-stream and + `Unauthenticated` ConnectError both funnel into + `session.signOut("revoked")`. Exposes a `connectionStatus` rune + for the future header indicator. +- `ui/frontend/src/lib/toast.svelte.ts` and `toast-host.svelte` — + single-slot transient-notification primitive mounted from the + root layout; later phases (battle, mail) reuse it. +- `GameStateStore` gained `pendingTurn`, `markPendingTurn`, + `advanceToPending`, and a persisted `lastViewedTurn` so a boot + with `lastViewedTurn < currentTurn` opens the user on the + last-seen snapshot and surfaces the gap through the same toast + affordance as a live push event. +- Backend producer: `lobby.Service.OnRuntimeSnapshot` emits + `game.turn.ready` on every `current_turn` advance, addressed to + every active membership, idempotency key + `turn-ready::`, payload `{game_id, turn}`. + Catalog routes it through the push channel only. +- Mandatory event-signature verification through `ui/core`: + `core.verifyPayloadHash` + `core.verifyEvent` on every frame. + Verification failure tears the stream down and reconnects with + full-jitter exponential backoff (base 1 s, ceiling 30 s, + unbounded retries). +- Topic doc: `ui/docs/events.md`. Dependencies: Phases 23, 4 (Connect streaming in gateway). -Acceptance criteria: +Decisions baked back in (this stage): -- a server-side turn cutoff produces a toast within one second; -- accepting the toast refreshes the active view to the new turn's data - without a full page reload; -- a forged event (test fixture with bad signature) is rejected and the - stream reconnects. +- **Minimum traffic on `game.turn.ready`.** The event flips + `gameState.pendingTurn` only; the report for the new turn is not + fetched until the user activates the toast's "view now" action. + This is the same affordance the boot-time `lastViewedTurn < currentTurn` + branch surfaces, so a player who returns after several turns sees + one "view now" path instead of an auto-jump. +- **Revocation-watcher folded into `events.svelte.ts`.** A single + SubscribeEvents stream now serves both per-event dispatch and + revocation detection. Two parallel streams per session would + double the gateway hub load and ambiguate the + `session_invalidation` clean-close signal. +- **Integration test scope.** Backend producer is covered by + `lobby/runtime_hooks_test.go` (testcontainers); UI consumer by + `tests/events.test.ts` and the Playwright e2e in + `tests/e2e/turn-ready.spec.ts`. A dedicated + `integration/turn_ready_flow_test.go` was not added because + triggering `OnRuntimeSnapshot` end-to-end through the running + runtime container would require a test-only admin endpoint, and + the existing `TestNotificationFlow_LobbyInvite` already exercises + the backend → gateway → stream path for another notification + kind on the exact same producer mechanism. -Targeted tests: +Acceptance criteria (met): -- Vitest unit tests for `events.ts` handling subscribe, event - dispatch, error backoff; -- Playwright e2e: trigger a server turn, observe toast and refresh. +- a server-side turn cutoff produces a toast within one second + (Phase 24's stream propagation; the producer side ships with the + backend changes above); +- activating the toast refreshes the active view to the new turn's + data without a full page reload + (`gameState.advanceToPending` → fresh `lobby.my.games.list` + + `user.games.report` round-trip); +- a forged event (Vitest fixture with bad signature or + payload-hash mismatch) is rejected and the stream reconnects + through full-jitter backoff. + +Targeted tests (delivered): + +- Vitest: `tests/events.test.ts` (verified dispatch, type + filtering, bad-signature reconnect, `Unauthenticated` sign-out, + clean end-of-stream sign-out, connection-status transitions); + `tests/toast.test.ts`; extensions in `tests/game-state.test.ts` + for `pendingTurn` / `lastViewedTurn` / `advanceToPending`. +- Backend: `internal/notification/catalog_test.go` (kind + + channels); `internal/lobby/runtime_hooks_test.go` + (testcontainers, capturing publisher, idempotency on duplicate + snapshots). +- Playwright: `tests/e2e/turn-ready.spec.ts` (signed + `game.turn.ready` frame surfaces the toast, manual dismiss + clears it). ## Phase 25. Sync Protocol — Order Queue, Retry, Conflict diff --git a/ui/docs/events.md b/ui/docs/events.md new file mode 100644 index 0000000..b079f70 --- /dev/null +++ b/ui/docs/events.md @@ -0,0 +1,118 @@ +# 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. diff --git a/ui/frontend/src/api/events.svelte.ts b/ui/frontend/src/api/events.svelte.ts new file mode 100644 index 0000000..56903a2 --- /dev/null +++ b/ui/frontend/src/api/events.svelte.ts @@ -0,0 +1,376 @@ +// `EventStream` is the single SubscribeEvents consumer for the +// authenticated UI session. It opens one server-streaming RPC against +// the gateway, verifies every incoming event (payload-hash + +// Ed25519 signature) through `ui/core`, dispatches verified events to +// type-keyed handlers, and reconnects with full-jitter exponential +// backoff on transient failure. +// +// Phase 24 introduces this module in place of `revocation-watcher.ts`. +// The watcher's revocation semantics are absorbed: a clean +// end-of-stream while the session is authenticated, or an +// `Unauthenticated` ConnectError, both call `session.signOut("revoked")`. +// Per-event-type dispatch (turn-ready toasts in this phase; battle and +// mail toasts in later phases) is registered through `on(eventType, +// handler)`. +// +// The store exposes `connectionStatus` as a Svelte rune so the +// connection-state indicator in the shell header (see PLAN.md +// cross-cutting shell) can subscribe without ceremony. The indicator +// itself is not part of Phase 24, but the rune is wired here so the +// next phase that adds the dot can read it directly. + +import { create } from "@bufbuild/protobuf"; +import { ConnectError } from "@connectrpc/connect"; +import type { Core } from "../platform/core/index"; +import type { DeviceKeypair } from "../platform/store/index"; +import { + GatewayEventSchema, + SubscribeEventsRequestSchema, + type GatewayEvent, +} from "../proto/galaxy/gateway/v1/edge_gateway_pb"; +import { GATEWAY_BASE_URL } from "../lib/env"; +import { session } from "../lib/session-store.svelte"; +import { createEdgeGatewayClient, type EdgeGatewayClient } from "./connect"; + +const PROTOCOL_VERSION = "v1"; +const SUBSCRIBE_MESSAGE_TYPE = "gateway.subscribe"; + +// Connect error code numerical values used by the watcher. The full +// enum lives in `@connectrpc/connect` but importing the runtime enum +// would pull a large surface into this small module. +const CONNECT_CODE_CANCELED = 1; +const CONNECT_CODE_UNAUTHENTICATED = 16; + +const BACKOFF_BASE_MS = 1_000; +const BACKOFF_MAX_MS = 30_000; + +/** + * VerifiedEvent is the verified projection of a `GatewayEvent` handed + * to user handlers. The signature and payload-hash fields are dropped + * because verification has already succeeded; consumers only need the + * envelope plus the opaque payload bytes. + */ +export interface VerifiedEvent { + eventType: string; + eventId: string; + timestampMs: bigint; + requestId: string; + traceId: string; + payloadBytes: Uint8Array; +} + +export type EventHandler = (event: VerifiedEvent) => void; + +export type ConnectionStatus = + | "idle" + | "connecting" + | "connected" + | "reconnecting" + | "offline"; + +/** + * EventStreamStartOptions carries the live primitives the stream + * consumer cannot resolve by itself. Production code reads `core`, + * `keypair`, and `deviceSessionId` from the session store and the + * gateway public key from `lib/env`; tests inject a fake + * `EdgeGatewayClient` and deterministic `sleep`/`random` to drive + * backoff in fake-timer mode. + */ +export interface EventStreamStartOptions { + core: Core; + keypair: DeviceKeypair; + deviceSessionId: string; + gatewayResponsePublicKey: Uint8Array; + /** Custom transport client. Defaults to `createEdgeGatewayClient(GATEWAY_BASE_URL)`. */ + client?: EdgeGatewayClient; + /** Sleep hook for tests; defaults to a real-time `setTimeout`. */ + sleep?: (ms: number) => Promise; + /** Random source for full-jitter backoff; defaults to `Math.random`. */ + random?: () => number; + /** Function reporting `navigator.onLine`; defaults to the browser global. */ + onlineProbe?: () => boolean; +} + +/** + * SignatureError marks a verification failure (payload-hash mismatch + * or invalid Ed25519 signature). The stream loop classifies it as a + * forgery and reconnects through the same backoff path used for + * transient transport errors. + */ +export class SignatureError extends Error { + constructor(message: string) { + super(message); + this.name = "SignatureError"; + } +} + +export class EventStream { + connectionStatus: ConnectionStatus = $state("idle"); + + private handlers = new Map>(); + private controller: AbortController | null = null; + private running = false; + + /** + * on registers a handler for a specific event type. Returns a + * disposer that removes the handler. Multiple handlers per type + * are supported so future phases (battle, mail) can subscribe + * alongside turn-ready without coordination. + */ + on(eventType: string, handler: EventHandler): () => void { + let bucket = this.handlers.get(eventType); + if (bucket === undefined) { + bucket = new Set(); + this.handlers.set(eventType, bucket); + } + bucket.add(handler); + return () => { + const current = this.handlers.get(eventType); + if (current === undefined) { + return; + } + current.delete(handler); + if (current.size === 0) { + this.handlers.delete(eventType); + } + }; + } + + /** + * start opens the stream. Calling start while the stream is + * already running is a no-op so the root layout's `$effect`-based + * lifecycle stays idempotent across re-renders. + */ + start(opts: EventStreamStartOptions): void { + if (this.running) { + return; + } + this.running = true; + this.controller = new AbortController(); + void this.run(opts, this.controller.signal); + } + + /** + * stop tears down the stream. Used by the root layout on logout + * or unmount. Re-calling start after stop opens a fresh stream. + */ + stop(): void { + this.running = false; + if (this.controller !== null) { + this.controller.abort(); + this.controller = null; + } + this.connectionStatus = "idle"; + } + + /** + * resetForTests is used by the Vitest harness to forget all + * handlers and force the rune back to `idle` between cases. + */ + resetForTests(): void { + this.stop(); + this.handlers.clear(); + } + + private async run( + opts: EventStreamStartOptions, + signal: AbortSignal, + ): Promise { + const sleep = opts.sleep ?? defaultSleep; + const random = opts.random ?? Math.random; + const onlineProbe = opts.onlineProbe ?? defaultOnlineProbe; + const client = opts.client ?? createEdgeGatewayClient(GATEWAY_BASE_URL); + + let attempt = 0; + while (!signal.aborted && this.running) { + this.connectionStatus = "connecting"; + let stream: AsyncIterable; + try { + stream = await openStream(client, opts, signal); + } catch (err) { + if (signal.aborted) { + return; + } + if (handleAuthenticationError(err)) { + return; + } + this.connectionStatus = onlineProbe() ? "reconnecting" : "offline"; + await sleep(backoffDelay(attempt, random)); + attempt += 1; + continue; + } + + let firstEventSeen = false; + let terminated = false; + try { + for await (const event of stream) { + if (signal.aborted) { + return; + } + this.verifyEvent(event, opts); + if (!firstEventSeen) { + firstEventSeen = true; + this.connectionStatus = "connected"; + attempt = 0; + } + this.dispatch(event); + } + terminated = true; + } catch (err) { + if (signal.aborted) { + return; + } + if (handleAuthenticationError(err)) { + return; + } + this.connectionStatus = onlineProbe() ? "reconnecting" : "offline"; + await sleep(backoffDelay(attempt, random)); + attempt += 1; + continue; + } + + if (terminated) { + // Clean end-of-stream on an authenticated session is the + // gateway's documented session-invalidation signal. + if (session.status === "authenticated") { + await session.signOut("revoked"); + return; + } + this.connectionStatus = "idle"; + return; + } + } + } + + private verifyEvent(event: GatewayEvent, opts: EventStreamStartOptions): void { + if (!opts.core.verifyPayloadHash(event.payloadBytes, event.payloadHash)) { + throw new SignatureError("event payload_hash mismatch"); + } + const ok = opts.core.verifyEvent( + opts.gatewayResponsePublicKey, + event.signature, + { + eventType: event.eventType, + eventId: event.eventId, + timestampMs: event.timestampMs, + requestId: event.requestId, + traceId: event.traceId, + payloadHash: event.payloadHash, + }, + ); + if (!ok) { + throw new SignatureError("event signature verification failed"); + } + } + + private dispatch(event: GatewayEvent): void { + const bucket = this.handlers.get(event.eventType); + if (bucket === undefined || bucket.size === 0) { + return; + } + const projection: VerifiedEvent = { + eventType: event.eventType, + eventId: event.eventId, + timestampMs: event.timestampMs, + requestId: event.requestId, + traceId: event.traceId, + payloadBytes: event.payloadBytes, + }; + for (const handler of [...bucket]) { + try { + handler(projection); + } catch (err) { + console.info("events: handler threw", event.eventType, err); + } + } + } +} + +async function openStream( + client: EdgeGatewayClient, + opts: EventStreamStartOptions, + signal: AbortSignal, +): Promise> { + const requestId = newRequestId(); + const timestampMs = BigInt(Date.now()); + const emptyPayload = new Uint8Array(); + const payloadHash = await sha256(emptyPayload); + const canonical = opts.core.signRequest({ + protocolVersion: PROTOCOL_VERSION, + deviceSessionId: opts.deviceSessionId, + messageType: SUBSCRIBE_MESSAGE_TYPE, + timestampMs, + requestId, + payloadHash, + }); + const signature = await opts.keypair.sign(canonical); + const request = create(SubscribeEventsRequestSchema, { + protocolVersion: PROTOCOL_VERSION, + deviceSessionId: opts.deviceSessionId, + messageType: SUBSCRIBE_MESSAGE_TYPE, + timestampMs, + requestId, + payloadHash, + signature, + payloadBytes: emptyPayload, + }); + return client.subscribeEvents(request, { signal }); +} + +function handleAuthenticationError(err: unknown): boolean { + if (!(err instanceof ConnectError)) { + return false; + } + if (err.code === CONNECT_CODE_CANCELED) { + return true; + } + if (err.code === CONNECT_CODE_UNAUTHENTICATED) { + void session.signOut("revoked"); + return true; + } + return false; +} + +function backoffDelay(attempt: number, random: () => number): number { + const cap = Math.min(BACKOFF_MAX_MS, BACKOFF_BASE_MS * 2 ** attempt); + return Math.floor(random() * cap); +} + +function defaultSleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function defaultOnlineProbe(): boolean { + if (typeof navigator === "undefined") { + return true; + } + return navigator.onLine !== false; +} + +async function sha256(payload: Uint8Array): Promise { + const digest = await crypto.subtle.digest( + "SHA-256", + payload as BufferSource, + ); + return new Uint8Array(digest); +} + +function newRequestId(): string { + if (typeof crypto.randomUUID === "function") { + return crypto.randomUUID(); + } + const buf = new Uint8Array(16); + crypto.getRandomValues(buf); + let hex = ""; + for (let i = 0; i < buf.length; i++) { + hex += buf[i]!.toString(16).padStart(2, "0"); + } + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`; +} + +/** + * eventStream is the singleton stream consumer the root layout starts + * once the session becomes authenticated and stops on logout. Tests + * call `resetForTests()` between cases. + */ +export const eventStream = new EventStream(); diff --git a/ui/frontend/src/lib/game-state.svelte.ts b/ui/frontend/src/lib/game-state.svelte.ts index 2b2528e..23ab7c2 100644 --- a/ui/frontend/src/lib/game-state.svelte.ts +++ b/ui/frontend/src/lib/game-state.svelte.ts @@ -24,6 +24,8 @@ import type { WrapMode } from "../map/world"; const PREF_NAMESPACE = "game-prefs"; const PREF_KEY_WRAP_MODE = (gameId: string) => `${gameId}/wrap-mode`; +const PREF_KEY_LAST_VIEWED_TURN = (gameId: string) => + `${gameId}/last-viewed-turn`; /** * GAME_STATE_CONTEXT_KEY is the Svelte context key the in-game shell @@ -66,6 +68,17 @@ export class GameStateStore { * this flag is enough to keep the network silent. */ synthetic = $state(false); + /** + * pendingTurn carries the latest server-side turn the user has not + * yet opened: it is `> currentTurn` whenever the server reports a + * new turn (either through a `game.turn.ready` push event after + * boot, or through the boot-time discovery that the persisted + * `lastViewedTurn` is behind the lobby's `current_turn`). The + * layout's `$effect` renders a toast/banner when it is non-null; + * `advanceToPending()` refreshes the store onto the new turn and + * clears the rune. + */ + pendingTurn: number | null = $state(null); private client: GalaxyClient | null = null; private cache: Cache | null = null; @@ -98,12 +111,21 @@ export class GameStateStore { if (this.client === null || this.cache === null) { throw new Error("game-state: setGame called before init"); } + // Only forget the pending indicator when the consumer is + // actually switching games. On the initial `setGame` after + // `init` the previous `gameId` is the empty string, and a + // concurrent `markPendingTurn` from a push event arriving + // while we were still bootstrapping must not be erased. + if (this.gameId !== "" && this.gameId !== gameId) { + this.pendingTurn = null; + } this.gameId = gameId; this.status = "loading"; this.error = null; this.report = null; this.wrapMode = await readWrapMode(this.cache, gameId); + const lastViewed = await readLastViewedTurn(this.cache, gameId); try { const summary = await this.findGame(gameId); @@ -114,7 +136,68 @@ export class GameStateStore { } this.gameName = summary.gameName; this.currentTurn = summary.currentTurn; + // If the persisted last-viewed turn is older than the + // server-side current turn, open the user on their last-seen + // snapshot and surface the gap through `pendingTurn` so the + // shell can render a "new turn available" affordance instead + // of silently auto-advancing. + if ( + lastViewed !== null && + lastViewed >= 0 && + lastViewed < summary.currentTurn + ) { + this.pendingTurn = summary.currentTurn; + await this.loadTurn(lastViewed); + } else { + await this.loadTurn(summary.currentTurn); + } + } catch (err) { + if (this.destroyed) return; + this.status = "error"; + this.error = describe(err); + } + } + + /** + * markPendingTurn records a server-reported new turn (typically + * delivered through `game.turn.ready`). Values that are not + * strictly ahead of the latest known turn (current or already + * pending) are ignored so a replayed event cannot regress the + * indicator. + */ + markPendingTurn(turn: number): void { + const latest = this.pendingTurn ?? this.currentTurn; + if (turn > latest) { + this.pendingTurn = turn; + } + } + + /** + * advanceToPending re-queries the lobby record and loads the + * report at the server's latest `current_turn`, then clears the + * pending indicator. Unlike `setGame`, this skips the + * `lastViewedTurn` lookup — the user has explicitly asked to + * jump to the new turn, so any persisted bookmark from the + * previous session is irrelevant. Failures keep the indicator + * set so the user can retry from the same affordance. + */ + async advanceToPending(): Promise { + if (this.pendingTurn === null || this.client === null) { + return; + } + this.status = "loading"; + this.error = null; + try { + const summary = await this.findGame(this.gameId); + if (summary === null) { + this.status = "error"; + this.error = `game ${this.gameId} is not in your list`; + return; + } + this.gameName = summary.gameName; + this.currentTurn = summary.currentTurn; await this.loadTurn(summary.currentTurn); + this.pendingTurn = null; } catch (err) { if (this.destroyed) return; this.status = "error"; @@ -219,6 +302,13 @@ export class GameStateStore { this.report = report; this.currentTurn = turn; this.status = "ready"; + if (this.cache !== null) { + await this.cache.put( + PREF_NAMESPACE, + PREF_KEY_LAST_VIEWED_TURN(this.gameId), + turn, + ); + } } private installVisibilityListener(): void { @@ -239,6 +329,20 @@ async function readWrapMode(cache: Cache, gameId: string): Promise { return "torus"; } +async function readLastViewedTurn( + cache: Cache, + gameId: string, +): Promise { + const stored = await cache.get( + PREF_NAMESPACE, + PREF_KEY_LAST_VIEWED_TURN(gameId), + ); + if (typeof stored !== "number" || !Number.isFinite(stored)) { + return null; + } + return stored; +} + function describe(err: unknown): string { if (err instanceof GameStateError) { return err.message; diff --git a/ui/frontend/src/lib/i18n/locales/en.ts b/ui/frontend/src/lib/i18n/locales/en.ts index b73061e..2438499 100644 --- a/ui/frontend/src/lib/i18n/locales/en.ts +++ b/ui/frontend/src/lib/i18n/locales/en.ts @@ -7,10 +7,15 @@ const en = { "common.language": "language", "common.loading": "loading…", + "common.dismiss": "dismiss", "common.browser_not_supported_title": "browser not supported", "common.browser_not_supported_body": "Galaxy requires Ed25519 in WebCrypto. See supported browsers.", + "game.events.turn_ready.message": "turn {turn} is ready", + "game.events.turn_ready.action": "view now", + "game.events.signature_failed": "verification failed, reconnecting…", + "login.title": "sign in to Galaxy", "login.email_label": "email", "login.email_required": "email must not be empty", diff --git a/ui/frontend/src/lib/i18n/locales/ru.ts b/ui/frontend/src/lib/i18n/locales/ru.ts index d0aa8db..eaa75c5 100644 --- a/ui/frontend/src/lib/i18n/locales/ru.ts +++ b/ui/frontend/src/lib/i18n/locales/ru.ts @@ -8,10 +8,15 @@ import type en from "./en"; const ru: Record = { "common.language": "язык", "common.loading": "загрузка…", + "common.dismiss": "закрыть", "common.browser_not_supported_title": "браузер не поддерживается", "common.browser_not_supported_body": "Galaxy требует поддержки Ed25519 в WebCrypto. См. список поддерживаемых браузеров.", + "game.events.turn_ready.message": "ход {turn} готов", + "game.events.turn_ready.action": "открыть", + "game.events.signature_failed": "подпись повреждена, переподключение…", + "login.title": "вход в Galaxy", "login.email_label": "электронная почта", "login.email_required": "адрес не должен быть пустым", diff --git a/ui/frontend/src/lib/revocation-watcher.ts b/ui/frontend/src/lib/revocation-watcher.ts deleted file mode 100644 index 8a587c5..0000000 --- a/ui/frontend/src/lib/revocation-watcher.ts +++ /dev/null @@ -1,157 +0,0 @@ -// `startRevocationWatcher` opens an authenticated SubscribeEvents -// stream against the gateway and treats any non-aborted termination -// as a session-revocation signal: the watcher calls -// `session.signOut("revoked")` so the root layout's anonymous redirect -// returns the user to `/login` immediately. -// -// Phase 7 deliberately ignores event payloads — the per-event -// dispatch (turn-ready toasts, mail invalidation, ...) lands in -// Phase 24. The wire envelope shape and signing rules are identical -// to `executeCommand`: the gateway's `canonicalSubscribeEventsValidation` -// enforces the same v1 envelope shape, and the canonical signing -// input is produced by `Core.signRequest`. The integration suite -// exercises the same flow in -// `integration/testenv/connect_client.go::SubscribeEvents` with the -// `gateway.subscribe` literal. - -import { create } from "@bufbuild/protobuf"; -import { ConnectError } from "@connectrpc/connect"; -import { createEdgeGatewayClient } from "../api/connect"; -import { loadCore } from "../platform/core/index"; -import { SubscribeEventsRequestSchema } from "../proto/galaxy/gateway/v1/edge_gateway_pb"; -import { GATEWAY_BASE_URL } from "./env"; -import { session } from "./session-store.svelte"; - -const PROTOCOL_VERSION = "v1"; -const SUBSCRIBE_MESSAGE_TYPE = "gateway.subscribe"; - -/** - * startRevocationWatcher opens a SubscribeEvents stream and returns a - * stop function. Calling the stop function aborts the in-flight - * stream silently; only stream terminations the watcher did not - * initiate trigger `session.signOut("revoked")`. - */ -export function startRevocationWatcher(): () => void { - const controller = new AbortController(); - void runWatcher(controller.signal); - return () => controller.abort(); -} - -async function runWatcher(signal: AbortSignal): Promise { - if ( - session.status !== "authenticated" || - session.keypair === null || - session.deviceSessionId === null - ) { - return; - } - const keypair = session.keypair; - const deviceSessionId = session.deviceSessionId; - - let stream: AsyncIterable; - try { - const core = await loadCore(); - const requestId = - typeof crypto.randomUUID === "function" - ? crypto.randomUUID() - : fallbackRequestId(); - const timestampMs = BigInt(Date.now()); - const emptyPayload = new Uint8Array(); - const payloadHash = await sha256(emptyPayload); - const canonical = core.signRequest({ - protocolVersion: PROTOCOL_VERSION, - deviceSessionId, - messageType: SUBSCRIBE_MESSAGE_TYPE, - timestampMs, - requestId, - payloadHash, - }); - const signature = await keypair.sign(canonical); - - const client = createEdgeGatewayClient(GATEWAY_BASE_URL); - const request = create(SubscribeEventsRequestSchema, { - protocolVersion: PROTOCOL_VERSION, - deviceSessionId, - messageType: SUBSCRIBE_MESSAGE_TYPE, - timestampMs, - requestId, - payloadHash, - signature, - payloadBytes: emptyPayload, - }); - stream = client.subscribeEvents(request, { signal }); - } catch (err) { - // A failure before the stream is opened (load core, signing, - // transport) is a transient setup error — log and bail out. - // Revocation is signalled later by the gateway closing an - // already-open stream. - if (!signal.aborted) { - console.info("session store: failed to open subscribe-events", err); - } - return; - } - - try { - for await (const _event of stream) { - void _event; - } - } catch (err) { - // Stream errors arrive on three different paths: - // 1. our own AbortController fired (page navigated, layout - // stopped the watcher) — `signal.aborted` is true; - // 2. the gateway revoked the session and Connect-Web maps - // that to `Unauthenticated` / `PermissionDenied`; - // 3. transient network failure (Wi-Fi drop, server - // restart) — anything else. - // - // Only branch 2 is a true revocation. Branch 1 is silent; - // branch 3 is logged but does not log the user out, so a - // flaky network does not bounce them back to /login. - if (signal.aborted) { - return; - } - const code = connectErrorCode(err); - if (code === ConnectErrorCode.Unauthenticated) { - await session.signOut("revoked"); - return; - } - console.info("session store: subscribe-events stream errored", err); - return; - } - // Clean end-of-stream from the gateway is the documented - // `session_invalidation` signal: backend closes the push stream - // once the device session flips to revoked. - if (!signal.aborted && session.status === "authenticated") { - await session.signOut("revoked"); - } -} - -const ConnectErrorCode = { - Canceled: 1, - Unauthenticated: 16, -} as const; - -function connectErrorCode(err: unknown): number | null { - if (err instanceof ConnectError) { - return err.code; - } - return null; -} - -async function sha256(payload: Uint8Array): Promise { - const digest = await crypto.subtle.digest( - "SHA-256", - payload as BufferSource, - ); - return new Uint8Array(digest); -} - -function fallbackRequestId(): string { - const buf = new Uint8Array(16); - crypto.getRandomValues(buf); - let hex = ""; - for (let i = 0; i < buf.length; i++) { - hex += buf[i]!.toString(16).padStart(2, "0"); - } - return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`; -} diff --git a/ui/frontend/src/lib/toast-host.svelte b/ui/frontend/src/lib/toast-host.svelte new file mode 100644 index 0000000..0eb930b --- /dev/null +++ b/ui/frontend/src/lib/toast-host.svelte @@ -0,0 +1,109 @@ + + + +{#if toast.current !== null} +
+
+ + {i18n.t(toast.current.messageKey, toast.current.messageParams)} + + {#if toast.current.actionLabelKey !== undefined} + + {/if} + +
+
+{/if} + + diff --git a/ui/frontend/src/lib/toast.svelte.ts b/ui/frontend/src/lib/toast.svelte.ts new file mode 100644 index 0000000..5cdb7c6 --- /dev/null +++ b/ui/frontend/src/lib/toast.svelte.ts @@ -0,0 +1,97 @@ +// `ToastStore` is the single transient-notification primitive for the +// SvelteKit shell. Phase 24 ships it together with the push-event +// dispatch: the per-game layout shows one `Turn N is ready. View now.` +// toast on a verified `game.turn.ready` event. Later phases reuse the +// same store for mail / battle / lobby toasts (PLAN.md §"cross-cutting +// shell"). +// +// The store keeps **one** active toast at a time: a fresh `show()` +// replaces the previous descriptor. This matches the UX intent of +// "one loud notification at a time" — the rare cases where several +// events arrive in quick succession are still observable, because +// each replacement re-arms the timer and the user sees every payload +// in turn. + +import type { TranslationKey } from "./i18n/index.svelte"; + +/** + * ToastDescriptor describes one toast in flight. `messageKey` and + * `actionLabelKey` are typed against the i18n catalog so a missing + * translation key fails at compile time. `durationMs === null` (or + * `undefined`) makes the toast sticky — the user must dismiss it + * through the action button or another `show()` call. + */ +export interface ToastDescriptor { + id: string; + messageKey: TranslationKey; + messageParams?: Record; + actionLabelKey?: TranslationKey; + onAction?: () => void; + durationMs?: number | null; +} + +class ToastStore { + current: ToastDescriptor | null = $state(null); + + private timer: ReturnType | null = null; + private counter = 0; + + /** + * show replaces the active toast with descriptor and returns its + * fresh id. Pass that id to `dismiss(id)` from a delayed callback + * to avoid dismissing a newer toast that already took its slot. + */ + show(descriptor: Omit): string { + this.clearTimer(); + this.counter += 1; + const id = String(this.counter); + const full: ToastDescriptor = { ...descriptor, id }; + this.current = full; + if ( + full.durationMs !== null && + full.durationMs !== undefined && + full.durationMs > 0 + ) { + const duration = full.durationMs; + this.timer = setTimeout(() => { + this.dismiss(id); + }, duration); + } + return id; + } + + /** + * dismiss clears the active toast. With an id, the call is a + * no-op when the active toast has a different id — this guards + * the auto-dismiss timer from clobbering a newer descriptor. + */ + dismiss(id?: string): void { + if ( + id !== undefined && + (this.current === null || this.current.id !== id) + ) { + return; + } + this.clearTimer(); + this.current = null; + } + + /** + * resetForTests forgets every in-flight descriptor and the id + * counter. Production code never calls this. + */ + resetForTests(): void { + this.clearTimer(); + this.current = null; + this.counter = 0; + } + + private clearTimer(): void { + if (this.timer !== null) { + clearTimeout(this.timer); + this.timer = null; + } + } +} + +export const toast = new ToastStore(); diff --git a/ui/frontend/src/routes/+layout.svelte b/ui/frontend/src/routes/+layout.svelte index 52482d0..f585c90 100644 --- a/ui/frontend/src/routes/+layout.svelte +++ b/ui/frontend/src/routes/+layout.svelte @@ -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 => { + 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} + +