// Session persistence for the SPA. The bus session (the unlocked wallet identity) // normally lives only in memory, so a page reload — even an F5 — drops it and forces // a password re-unlock. This module keeps the session usable across reloads without // ever sending anything to the network. // // Storage choice and its trade-off: // - By DEFAULT the session is kept in sessionStorage: it survives an F5 but is // cleared when the tab/window closes. This already fixes the "logs out on // refresh" annoyance at minimal risk. // - When the user ticks "keep me signed in" (remember=true) it is kept in // localStorage instead: it survives closing the tab and the browser, until it // EXPIRES or the user logs out. // // We never use a cookie: the wallet's private key must not travel to any server, and // a cookie rides every request. The persisted value (the decrypted hex identity) // stays on the device and is read only by this origin's own code. // // Two time bounds keep the persisted private key from living unbounded on disk: // - TTL: an absolute lifetime (30 days). After it, re-unlock with the password. // - IDLE: an inactivity auto-lock (12 h). Activity calls touchSession(); after 12 h // with no activity the session re-locks even if the TTL has not elapsed. import type { WalletIdentity } from "./wallet/derive"; const KEY = "unibus-session"; const TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days absolute lifetime const IDLE_MS = 12 * 60 * 60 * 1000; // 12 h inactivity auto-lock interface PersistedSession { // The decrypted wallet identity (hex), INCLUDING the private halves. This is the // sensitive part that lives on the device so the user need not re-enter the // password on every reload. Bounded by TTL_MS + IDLE_MS and cleared on logout. wallet: WalletIdentity; handle: string; remember: boolean; createdAt: number; lastActivity: number; } function stores(): Storage[] { // Guard for SSR/tests where window is absent. if (typeof window === "undefined") return []; return [window.localStorage, window.sessionStorage]; } // saveSession persists the unlocked identity. remember=true uses localStorage // (survives closing the browser); false uses sessionStorage (cleared with the tab). export function saveSession(wallet: WalletIdentity, handle: string, remember: boolean): void { clearSession(); // never keep it in both stores at once const target = remember ? window.localStorage : window.sessionStorage; const s: PersistedSession = { wallet, handle, remember, createdAt: Date.now(), lastActivity: Date.now(), }; try { target.setItem(KEY, JSON.stringify(s)); } catch { /* storage full/blocked: fall back to memory-only (no persistence) */ } } // loadSession returns the persisted identity if one exists and is still valid (not // past its TTL and not idle-expired), otherwise null. An expired entry is removed. export function loadSession(): { wallet: WalletIdentity; handle: string; remember: boolean } | null { for (const st of stores()) { const raw = st.getItem(KEY); if (!raw) continue; try { const s = JSON.parse(raw) as PersistedSession; const now = Date.now(); if (now - s.createdAt > TTL_MS || now - s.lastActivity > IDLE_MS) { st.removeItem(KEY); // expired by TTL or idle auto-lock continue; } return { wallet: s.wallet, handle: s.handle, remember: s.remember }; } catch { st.removeItem(KEY); // corrupt entry } } return null; } // touchSession refreshes the last-activity timestamp so the idle auto-lock window // restarts. Call it on meaningful user activity (sending, navigating rooms). export function touchSession(): void { for (const st of stores()) { const raw = st.getItem(KEY); if (!raw) continue; try { const s = JSON.parse(raw) as PersistedSession; s.lastActivity = Date.now(); st.setItem(KEY, JSON.stringify(s)); } catch { /* ignore */ } } } // clearSession removes the persisted session from both stores (logout / lock). export function clearSession(): void { for (const st of stores()) st.removeItem(KEY); }