Files
galaxy-game/ui/docs/storage.md
T
Ilia Denisov a89048f6c5 docs(ui): finalize MVP plan structure and de-archaeologize topic docs
MVP web client (Phases 1-30) is complete; reorganize planning + living docs around that.

- PLAN.md kept as the staged MVP record (1-30) with a status block + pointers; removed the 31-36 stages, regression scenarios, and deferred-TODO section (moved out); fixed a stale cross-machine plan path.

- ui/PLAN-finalize.md (new): active web-finalization plan in 8 stages (visual system, a11y, i18n, error UX, PWA, build hygiene, docs, owner manual-QA loop); absorbs former Phases 33 and 35.

- ui/ROADMAP.md (new): post-MVP (Wails, Capacitor, realistic projection, acceptance + regression scenarios) and triaged deferred follow-ups.

- ui/docs/README.md (new): grouped topic-doc index.

- De-archaeologized all 20 ui/docs topic docs + ui/README.md + ui/core/README.md: stripped Phase-N build history, rewritten as current-state; deferred work now points at ROADMAP.md / PLAN-finalize.md. Docs-only; no code change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 23:17:51 +02:00

9.9 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; WailsStore and CapacitorStore adapters will satisfy the same contracts on their respective platforms (see ../ROADMAP.md).

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. No JavaScript fallback (e.g. @noble/ed25519) is shipped — keeping the keystore on WebCrypto is what gives non-extractable storage on every supported engine. The 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 Playwright spec for the keystore 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 auth-flow.md
game-prefs {gameId}/wrap-mode WrapMode game-state.md
game-prefs {gameId}/last-viewed-turn number game-state.md
order-drafts {gameId}/draft OrderCommand[] order-composer.md
game-history {gameId}/turn/{N} GameReport game-state.md
game-map-toggles {gameId} {toggles: MapToggles; lastResetTurn: number} game-state.md

The game-map-toggles blob stores the gear popover's per-game visibility state plus a lastResetTurn companion field. Reading a missing or malformed entry falls back to DEFAULT_MAP_TOGGLES field-by-field, so a stale older client losing a field added later does not nuke the rest of the user's overrides. The GameStateStore.setGame path resets the blob to defaults whenever lastResetTurn < currentTurn, so a fresh server turn always greets the player with every map category visible (see game-state.md for the new-turn-reset contract).

Additional per-feature namespaces will be added as needed (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 the 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 — the root layout 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

Native desktop and mobile targets (planned in ../ROADMAP.md) will 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.