Files
Ilia Denisov 2d17760a5e ui/phase-26: history mode (turn navigator + read-only banner)
Split GameStateStore into currentTurn (server's latest) and viewedTurn
(displayed snapshot) so history excursions don't corrupt the resume
bookmark or the live-turn bound. Add viewTurn / returnToCurrent /
historyMode rune, plus a game-history cache namespace that stores
past-turn reports for fast re-entry. OrderDraftStore.bindClient takes
a getHistoryMode getter and short-circuits add / remove / move while
the user is viewing a past turn; RenderedReportSource skips the order
overlay in the same case. Header replaces the static "turn N" with a
clickable triplet (TurnNavigator), the layout mounts HistoryBanner
under the header, and visibility-refresh is a no-op while history is
active.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 00:13:19 +02:00

9.1 KiB

Storage Layer (Web)

The Galaxy UI client persists exactly two things on the web target:

  1. the device session keypair (one Ed25519 pair per browser profile), and
  2. a generic local cache for game state and small bookkeeping.

Both live in the same IndexedDB database galaxy-ui. The keypair private key is generated as a non-extractable WebCrypto handle and never appears as plain bytes in JavaScript memory or in IDB. The cache stores arbitrary structured-clone values and is partitioned by namespace.

This topic doc covers the web implementation only. The platform- agnostic KeyStore and Cache interfaces in src/platform/store/index.ts are what the rest of the client codes against; later phases bring WailsStore and CapacitorStore adapters that satisfy the same contracts.

Source-of-truth for the cross-service contract is ../../docs/ARCHITECTURE.md §15 "Transport security model"; this doc only covers the web-side primitives.

Browser baseline

The web keystore depends on WebCrypto Ed25519 ({ name: 'Ed25519' } in subtle.generateKey / subtle.sign / subtle.verify / subtle.importKey('raw')). Native support shipped relatively recently:

Engine Minimum version Released
Chrome 137 May 2025
Edge 137 May 2025
Firefox 130 Sept 2024
Safari 17.4 (iOS 17.4 / macOS) March 2024

Browsers older than the baseline above will fail at the first generateKey({ name: 'Ed25519' }, ...) call with a NotSupportedError. Phase 6 deliberately does not ship a JavaScript fallback (e.g. @noble/ed25519) — keeping the keystore on WebCrypto is what gives us non-extractable storage on every supported engine. The Phase 7 root layout runs a one-time probe on boot and switches to a "browser not supported" page (described in auth-flow.md) when the probe rejects, instead of attempting the keystore generate.

WebKit non-determinism note

Pure Ed25519 (RFC 8032) signatures are deterministic: same private key + same message ⇒ same 64-byte signature. Most engines (Chrome, Firefox, Node 22's WebCrypto) follow this strictly. WebKit's crypto.subtle.sign({ name: 'Ed25519' }, ...) returns non-deterministic signatures — calls with identical inputs produce different signature bytes each time. The signatures still verify correctly under the matching public key (the protocol-level guarantee Galaxy depends on); only the byte-for-byte determinism is missing.

Tests that assert "the same key signs the same message identically" must either pin to the Vitest path (Node WebCrypto) or be replaced with verify-after-sign assertions. The Phase 6 Playwright spec uses the verify path, which works on every engine in the baseline.

IndexedDB schema

database: galaxy-ui  (version 1)
├── object store: keypair
│     key:   string (the constant "device")
│     value: { privateKey: CryptoKey, publicKey: CryptoKey,
│              publicKeyRaw: ArrayBuffer }
└── object store: cache
      keyPath: ["namespace", "key"]
      value:   { namespace: string, key: string, value: unknown }

keypair store

Holds at most one row, keyed by the literal string "device". The row carries:

  • privateKey — non-extractable Ed25519 CryptoKey. Persisted via IndexedDB's structured-clone path; the cloned handle on load signs with the same private bytes as the original (asserted by the verify-after-reload Playwright test and by signature-byte equality under Node).
  • publicKey — extractable Ed25519 CryptoKey. The WebCrypto spec forces extractable=true on the public half of generated asymmetric pairs regardless of the extractable flag passed to generateKey.
  • publicKeyRaw — cached 32-byte raw export of publicKey. Lets every KeyStore.load() skip an exportKey round-trip; the Uint8Array view returned to callers is allocated from this buffer.

cache store

Compound-key ([namespace, key]) object store. Rows are { namespace, key, value }. The value field is whatever the caller stored, round-tripped through structured-clone semantics — plain objects, arrays, Uint8Array, Date, etc. all survive.

Cache.clear(namespace) walks a bounded cursor over [ns, ""]..[ns, "￿"]. The lexicographic upper bound "￿" is the largest BMP code point and acts as a sentinel above any realistic application-level key string.

Cache.clear() (no argument) calls IDBObjectStore.clear() and wipes every namespace.

Namespaces in current use:

Namespace Key Value type Owner
session device-session-id string Phase 7+
game-prefs {gameId}/wrap-mode WrapMode Phase 11+ (game-state.md)
game-prefs {gameId}/last-viewed-turn number Phase 11+ (game-state.md)
order-drafts {gameId}/draft OrderCommand[] Phase 12+ (order-composer.md)
game-history {gameId}/turn/{N} GameReport Phase 26+ (game-state.md)

Later phases will add more per-feature namespaces (fixtures, lobby snapshot, etc.). The contract is namespace-strings stay scoped to one feature; cross-feature reads through the cache are by convention disallowed.

Keystore lifecycle

KeyStore.generate()  — generates a non-extractable Ed25519 keypair
                       via subtle.generateKey, exports the public
                       half to raw bytes, writes the row, and
                       returns a DeviceKeypair view.

KeyStore.load()      — reads the row; returns null when missing,
                       otherwise wraps the persisted privateKey
                       in a DeviceKeypair whose .sign(canonical)
                       calls subtle.sign({name: 'Ed25519'}, ...).

KeyStore.clear()     — deletes the row. The next load() returns
                       null; the next generate() creates a fresh
                       keypair.

DeviceKeypair.sign matches the Signer type from api/galaxy-client.ts so a session can wire the signer with signer: keypair.sign.bind(keypair).

What counts as "site data cleared"

Browser-driven clearing — the user wipes site data, runs in private/incognito mode that ends, or storage is evicted under pressure — removes the keypair row. The next loadDeviceSession call generates a fresh keypair; the stored device_session_id (if any) becomes orphaned and the user is forced through the email-code login flow again. This is documented as acceptable in docs/ARCHITECTURE.md §15 "Key storage": loss of browser storage is recoverable through re-login, not through key recovery.

api/session.ts

Thin orchestration layer over KeyStore + Cache:

  • loadDeviceSession(keyStore, cache) returns { keypair, deviceSessionId }. The keypair field is always populated (loaded if present, freshly generated if not). The deviceSessionId field is null until Phase 7's confirm-email-code handler stores the gateway-issued id via setDeviceSessionId.
  • setDeviceSessionId(cache, id) writes the id to the session namespace.
  • clearDeviceSession(keyStore, cache) wipes both the keypair and the stored id. Used by the user-driven logout path and by the push-event-driven revocation path.

A null deviceSessionId is the signal that the session is unauthenticated — Phase 7 routes such users to /login.

Test layout

  • tests/store-idb-cache.test.ts — Vitest unit tests over the cache, using fake-indexeddb/auto and unique per-test database names so cases never share state.
  • tests/store-webcrypto-keystore.test.ts — Vitest unit tests over the keystore, using Node 22's deterministic WebCrypto plus fake-indexeddb/auto. Asserts byte-for-byte signature determinism (which is fine under Node) and verify-after-reload.
  • tests/e2e/storage-keypair-persistence.spec.ts — Playwright e2e test that drives the storage surface through a debug route at /__debug/store. Asserts persistence across reload via signature verification (works on every engine in the baseline, including non-deterministic WebKit) and that clearDeviceSession forces a fresh keypair on next load.

The debug route is gated by import.meta.env.DEV and +page.ts sets prerender = false; ssr = false;, so the production static-adapter build still emits an empty shell at that path but the debug entry point never attaches window.__galaxyDebug.

Future: native targets

Phase 31 (Wails) and Phase 32 (Capacitor) bring native keystores — Keychain on macOS / iOS, DPAPI/Credential Locker on Windows, libsecret on Linux, Android Keystore on Android — behind the same KeyStore interface, plus SQLite-backed Cache adapters. The web implementation here is the reference shape those adapters satisfy.