/** * Thin JSON-typed Web-Storage adapter (localStorage or sessionStorage). * * Centralizes the SSR-guard + try/catch + silent quota-failure pattern * that every per-feature storage util would otherwise re-implement. * * Optional `namespace` prefix supports platform / user partitioning — * the resolver runs lazily at call time so the namespace can vary across * the lifetime of the page (e.g. proxy-auth switches user, which switches * the key suffix). * * Backend selection (`backend: 'local' | 'session'`): * - `'local'` (default): persists across browser sessions. Use for * UI state, chat history metadata, feature-flag opt-ins. * - `'session'`: cleared when the tab closes. Use for ANY auth-adjacent * value (bearer tokens, act-as identity, proxy credentials). Reduces * the XSS-exfiltration attack window from "indefinite" to "until tab * close" without losing per-session ergonomics. */ export type WebStorageBackend = 'local' | 'session' export interface LocalStorageAdapter { load(): T | null save(value: T): void clear(): void /** Resolved storage key for the current call. Useful for tests. */ resolveKey(): string } export interface LocalStorageAdapterOptions { /** Base storage key. Combined with `namespace()` when provided. */ key: string /** Optional dynamic namespace prefix appended via `.` separator. * Called on EVERY read/write so the key can vary across the page * lifetime (e.g. when the platform or user identity changes). */ namespace?: () => string | null | undefined /** Runtime shape check. Falsey return → `load()` yields null. */ validate?: (parsed: unknown) => parsed is T /** Diagnostic prefix written to `console.warn` on parse / write * failures. Defaults to `'[local-storage]'`. */ logTag?: string /** Which Web-Storage backend to use. Defaults to `'local'`. Pass * `'session'` for anything auth-adjacent so the value evaporates * when the tab closes. */ backend?: WebStorageBackend } function getStorage(backend: WebStorageBackend): Storage | null { if (typeof window === 'undefined') return null try { return backend === 'session' ? window.sessionStorage : window.localStorage } catch { // Some sandboxed contexts (Safari private mode older versions, // strict CSP) throw on storage access — treat as unavailable. return null } } export function createLocalStorageAdapter( options: LocalStorageAdapterOptions, ): LocalStorageAdapter { const tag = options.logTag ?? '[local-storage]' const backend: WebStorageBackend = options.backend ?? 'local' const resolveKey = (): string => { const ns = options.namespace?.() return ns ? `${ns}.${options.key}` : options.key } return { resolveKey, load() { const storage = getStorage(backend) if (!storage) return null try { const raw = storage.getItem(resolveKey()) if (!raw) return null const parsed = JSON.parse(raw) as unknown if (options.validate && !options.validate(parsed)) return null return parsed as T } catch (err) { console.warn(`${tag} parse failed for key ${resolveKey()}:`, err) return null } }, save(value: T) { const storage = getStorage(backend) if (!storage) return try { storage.setItem(resolveKey(), JSON.stringify(value)) } catch (err) { console.warn(`${tag} write failed for key ${resolveKey()}:`, err) } }, clear() { const storage = getStorage(backend) if (!storage) return try { storage.removeItem(resolveKey()) } catch (err) { console.warn(`${tag} clear failed for key ${resolveKey()}:`, err) } }, } }