import type {SessionData} from '@pds-moover/moover'; /** * Generic localStorage-backed cache for login sessions, so a page refresh can offer to resume a * multi-step flow instead of forcing the user to re-enter credentials and re-trigger 2FA. All * persistence policy lives here; the `@pds-moover/moover` package only exposes generic hooks * (onSessionUpdate / restoreSessions). * * A flow names its own set of `Side` values (e.g. 'old'|'new' for migration, 'current'|'old' for * missing blobs) and which of those are required before a resume is offered. * * Security note: this stores access + refresh JWTs in localStorage. Acceptable for this SPA, * mitigated by clearing on completion / start-over and by PasswordSession.resume() invalidating * dead sessions. */ export type SessionCache = Partial> & { savedAt: number; }; export interface SessionCacheApi { /** * Persist (data) or drop (null) one side of the flow. When every side ends up gone the whole * key is removed so we never leave an orphaned entry behind. */ saveSide(side: Side, data: SessionData | null): void; /** * Read the cached sessions. Returns null (after clearing the key) when any required side is * missing — a half-populated set of JWTs is never worth resuming. */ load(): SessionCache | null; /** Remove the whole cache entry (completion / start-over / expired sessions). */ clear(): void; } function hasStorage(): boolean { return typeof localStorage !== 'undefined'; } /** * Builds a session cache bound to a localStorage key and the set of sides required before a * resume is offered. */ export function createSessionCache( storageKey: string, requiredSides: readonly Side[], ): SessionCacheApi { function readRaw(): SessionCache | null { if (!hasStorage()) return null; const raw = localStorage.getItem(storageKey); if (!raw) return null; try { return JSON.parse(raw) as SessionCache; } catch { // Corrupt entry — drop it so we don't keep tripping over it. localStorage.removeItem(storageKey); return null; } } function writeRaw(cache: SessionCache): void { if (!hasStorage()) return; localStorage.setItem(storageKey, JSON.stringify(cache)); } function hasAnySide(cache: SessionCache): boolean { return Object.entries(cache).some(([key, value]) => key !== 'savedAt' && value); } function clear(): void { if (!hasStorage()) return; localStorage.removeItem(storageKey); } function saveSide(side: Side, data: SessionData | null): void { if (!hasStorage()) return; const cache = readRaw() ?? ({savedAt: Date.now()} as SessionCache); // Narrow to the record view so indexing by the open `Side` type param stays well-typed. const sides = cache as Partial>; if (data) { sides[side] = data; } else { delete sides[side]; } cache.savedAt = Date.now(); if (!hasAnySide(cache)) { localStorage.removeItem(storageKey); return; } writeRaw(cache); } function load(): SessionCache | null { const cache = readRaw(); if (!cache) return null; const sides = cache as Partial>; if (requiredSides.some((side) => !sides[side])) { // Orphan hygiene: don't leave a half-populated set of JWTs lying around. clear(); return null; } return cache; } return {saveSide, load, clear}; }