// `SessionStore` is the single source of truth for the device // session state across every authenticated UI surface. It owns the // lifecycle of the WebCrypto keypair (loaded or generated through // the `KeyStore`), the persisted `device_session_id` (read/written // through the `Cache`), and the high-level status that the root // layout uses to gate routing. // // The store runs in two stages: `init()` loads the persisted // keypair and session id, sanity-checks WebCrypto Ed25519 support, // and settles `status` into one of `unsupported`, `anonymous`, or // `authenticated`. Callers (the login form, the lobby) drive the // rest through `signIn` and `signOut`. // // `signOut("revoked")` is a separate code path because gateway-side // session revocation closes the SubscribeEvents stream // asynchronously; the watcher in `lib/revocation-watcher.ts` calls // it without user interaction. The post-condition is the same as // `signOut("user")` — keypair regenerated, session id wiped, // status returned to `anonymous` — so the dispatcher's state-based // auth gate renders the login screen for both reasons uniformly. import type { Cache, DeviceKeypair, KeyStore, StoreLoader, } from "../platform/store/index"; import { loadStore } from "../platform/store/index"; import { clearDeviceSession, loadDeviceSession, setDeviceSessionId, } from "../api/session"; import { account } from "./account-store.svelte"; export type SessionStatus = | "loading" | "unsupported" | "anonymous" | "authenticated"; export class SessionStore { status: SessionStatus = $state("loading"); keypair: DeviceKeypair | null = $state(null); deviceSessionId: string | null = $state(null); private initPromise: Promise | null = null; private keyStore: KeyStore | null = null; private cache: Cache | null = null; private supportProbe: () => Promise = defaultSupportProbe; private storeLoader: StoreLoader = loadStore; /** * init loads the persisted keypair and device-session id, runs a * one-time WebCrypto Ed25519 sanity check, and settles `status`. * Calling it multiple times is safe — the first call drives the * actual work; subsequent calls await the same promise. */ init(): Promise { if (this.initPromise === null) { this.initPromise = this.doInit(); } return this.initPromise; } /** * signIn persists the device-session id returned by the * confirm-email-code response and flips the status to * `authenticated`. The keypair already lives in the store from * `init()`. */ async signIn(deviceSessionId: string): Promise { if (this.cache === null) { throw new Error("session store: signIn called before init"); } await setDeviceSessionId(this.cache, deviceSessionId); this.deviceSessionId = deviceSessionId; this.status = "authenticated"; } /** * signOut wipes the keypair and the persisted device-session id, * generates a fresh keypair so the next login does not reuse the * revoked public key, and returns the status to `anonymous`. The * `reason` is recorded in console output for telemetry but does * not change the post-state — both user-driven logout and * gateway-driven revocation return the user to the login screen. */ async signOut(reason: "user" | "revoked"): Promise { if (this.keyStore === null || this.cache === null) { throw new Error("session store: signOut called before init"); } await clearDeviceSession(this.keyStore, this.cache); const fresh = await loadDeviceSession(this.keyStore, this.cache); this.keypair = fresh.keypair; this.deviceSessionId = null; this.status = "anonymous"; // Drop the cached identity so a different user signing in on the // same browser does not briefly see the previous display name // through the post-login shell. account.clear(); if (reason === "revoked") { console.info("session store: device session revoked by gateway"); } } /** * setSupportProbeForTests overrides the WebCrypto Ed25519 probe. * Production code calls the real `crypto.subtle.generateKey`; tests * can swap in a deterministic stub. */ setSupportProbeForTests(probe: () => Promise): void { this.supportProbe = probe; } /** * setStoreLoaderForTests overrides the storage adapter resolver. * Production code calls `loadStore()` from `platform/store`; tests * inject a per-test `KeyStore` + `Cache` pair backed by a unique * IndexedDB name so cases stay independent. */ setStoreLoaderForTests(loader: StoreLoader): void { this.storeLoader = loader; } /** * resetForTests forgets all persisted state on the instance so a * subsequent `init()` runs from scratch. Production code never * calls this; it exists only for the Vitest harness. */ resetForTests(): void { this.status = "loading"; this.keypair = null; this.deviceSessionId = null; this.initPromise = null; this.keyStore = null; this.cache = null; this.supportProbe = defaultSupportProbe; this.storeLoader = loadStore; } private async doInit(): Promise { const supported = await this.supportProbe(); if (!supported) { this.status = "unsupported"; return; } const { keyStore, cache } = await this.storeLoader(); this.keyStore = keyStore; this.cache = cache; const loaded = await loadDeviceSession(keyStore, cache); this.keypair = loaded.keypair; this.deviceSessionId = loaded.deviceSessionId; this.status = loaded.deviceSessionId === null ? "anonymous" : "authenticated"; } } async function defaultSupportProbe(): Promise { if ( typeof globalThis.crypto !== "object" || typeof globalThis.crypto.subtle !== "object" ) { return false; } try { await globalThis.crypto.subtle.generateKey( { name: "Ed25519" } as KeyAlgorithm, false, ["sign", "verify"], ); return true; } catch { return false; } } export const session = new SessionStore();