feat: persistent session (no re-unlock on reload) + reconnect ACL after createRoom
Session persistence (web/src/session.ts): the unlocked wallet identity is kept across reloads so an F5 no longer forces a password re-unlock. By default it lives in sessionStorage (survives F5, cleared with the tab); with 'keep me signed in' it lives in localStorage (survives closing the browser) bounded by a 30-day absolute TTL and a 12-hour inactivity auto-lock. logout clears it; activity (send/createRoom) refreshes the idle timer. No cookie is ever used — the private key never travels to any server. WalletLogin gains the 'keep me signed in' checkbox; Recover/Join keep the session by default (recovering/creating on a device implies it is yours). App.tsx restores the session on mount before falling back to the unlock screen. ACL reconnect: a room created while connected was not in the NATS per-subject ACL grant (subjects are frozen at connect time), so its first messages silently did not deliver until a re-login. WsNatsTransport gains reconnect(); BusClient.refresh() calls it; busService.createRoom reconnects after creating so the new room is usable immediately. Bumps uniweb to 0.4.0.
This commit is contained in:
@@ -0,0 +1,105 @@
|
||||
// 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);
|
||||
}
|
||||
Reference in New Issue
Block a user