a89048f6c5
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>
220 lines
9.9 KiB
Markdown
220 lines
9.9 KiB
Markdown
# 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](../ROADMAP.md)).
|
|
|
|
Source-of-truth for the cross-service contract is
|
|
[`../../docs/ARCHITECTURE.md` §15 "Transport security model"](../../docs/ARCHITECTURE.md);
|
|
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`](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
|
|
|
|
```text
|
|
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
|
|
|
|
```text
|
|
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](../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.
|