OrderDraftStore persists per-game command drafts in Cache; the sidebar Order tab renders the list with a per-row delete control. The layout passes a `historyMode` prop through Sidebar / BottomTabs as a constant `false`, so Phase 26 only flips the source. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
8.8 KiB
Storage Layer (Web)
The Galaxy UI client persists exactly two things on the web target:
- the device session keypair (one Ed25519 pair per browser profile), and
- 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 Ed25519CryptoKey. 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 Ed25519CryptoKey. The WebCrypto spec forcesextractable=trueon the public half of generated asymmetric pairs regardless of theextractableflag passed togenerateKey.publicKeyRaw— cached 32-byte raw export ofpublicKey. Lets everyKeyStore.load()skip anexportKeyround-trip; theUint8Arrayview 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) |
order-drafts |
{gameId}/draft |
OrderCommand[] |
Phase 12+ (order-composer.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 }. Thekeypairfield is always populated (loaded if present, freshly generated if not). ThedeviceSessionIdfield isnulluntil Phase 7'sconfirm-email-codehandler stores the gateway-issued id viasetDeviceSessionId.setDeviceSessionId(cache, id)writes the id to thesessionnamespace.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, usingfake-indexeddb/autoand 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 plusfake-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 thatclearDeviceSessionforces 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.