# 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.
