a679d9cdcb
PR-feedback round on #60: - Time-zone field is now a continent-grouped <select> populated from `Intl.supportedValuesOf("timeZone")`, with the browser-detected zone pre-selected when no value is stored. A stored zone the runtime no longer advertises is preserved as an "Other" entry. - Saving the profile no longer kicks the user back to the lobby: the form stays put and shows a transient `saved` notice, cleared on the next edit. Only `cancel` returns to the lobby. - New `lib/account-store.svelte.ts` caches `user.account.get` for the session; lobby + profile share it through `account.ensure()`, so navigating Overview ⇄ Profile no longer flashes the "loading account…" placeholder or fires a second gateway call. Profile save writes through to the store so the shell identity strip picks up the new display name without refetching. Cleared on logout to prevent identity bleed between accounts. - e2e: existing 4 cases adjusted for save-stay; added two new ones for the timezone dropdown and identity-strip stability across navigation. - Docs: `ui/docs/lobby.md` updated to describe the shared cache, the new timezone picker shape, and the save-stay behaviour.
179 lines
5.8 KiB
TypeScript
179 lines
5.8 KiB
TypeScript
// `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<void> | null = null;
|
|
private keyStore: KeyStore | null = null;
|
|
private cache: Cache | null = null;
|
|
private supportProbe: () => Promise<boolean> = 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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<boolean>): 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<void> {
|
|
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<boolean> {
|
|
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();
|