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:
@@ -2,7 +2,7 @@
|
|||||||
name: uniweb
|
name: uniweb
|
||||||
lang: ts
|
lang: ts
|
||||||
domain: infra
|
domain: infra
|
||||||
version: 0.3.0
|
version: 0.4.0
|
||||||
description: "Cliente web browser-nativo del bus unibus: SPA de chat (React+Mantine) con wallet por usuario (BIP39) que habla DIRECTO al bus (nats.ws + control-plane HTTPS firmado), sin gateway. La clave privada nunca sale del navegador."
|
description: "Cliente web browser-nativo del bus unibus: SPA de chat (React+Mantine) con wallet por usuario (BIP39) que habla DIRECTO al bus (nats.ws + control-plane HTTPS firmado), sin gateway. La clave privada nunca sale del navegador."
|
||||||
tags: [messaging, web, frontend, e2e]
|
tags: [messaging, web, frontend, e2e]
|
||||||
uses_functions: []
|
uses_functions: []
|
||||||
|
|||||||
+13
-5
@@ -31,11 +31,12 @@ export function App() {
|
|||||||
const [token, setToken] = useState("");
|
const [token, setToken] = useState("");
|
||||||
const [storedHandle, setStoredHandle] = useState("");
|
const [storedHandle, setStoredHandle] = useState("");
|
||||||
|
|
||||||
// Decide the entry screen on mount: an invite link goes straight to join; a device
|
// Decide the entry screen on mount: an invite link goes straight to join; otherwise
|
||||||
// with a stored identity shows the password unlock; an empty device shows the
|
// try to RESTORE a persisted session (survives reloads, and — with "keep me signed
|
||||||
// welcome chooser. There is no "resume session" step: the bus session lives in
|
// in" — closing the browser, up to its TTL/idle limits) so a reload does not force a
|
||||||
// memory (the SDK runs in the browser), so a reload always re-unlocks locally
|
// re-unlock; if there is none, a device with a stored identity shows the password
|
||||||
// rather than resuming a server-side cookie session.
|
// unlock and an empty device shows the welcome chooser. The private key stays in the
|
||||||
|
// browser throughout; nothing is resumed from a server-side cookie.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const t = readJoinToken();
|
const t = readJoinToken();
|
||||||
if (t) {
|
if (t) {
|
||||||
@@ -45,6 +46,13 @@ export function App() {
|
|||||||
}
|
}
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
(async () => {
|
(async () => {
|
||||||
|
const restored = await bus.restoreSession();
|
||||||
|
if (cancelled) return;
|
||||||
|
if (restored) {
|
||||||
|
setUser(restored);
|
||||||
|
setRoute("chat");
|
||||||
|
return;
|
||||||
|
}
|
||||||
const stored = await localIdentity();
|
const stored = await localIdentity();
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
if (stored) {
|
if (stored) {
|
||||||
|
|||||||
+2
-1
@@ -130,7 +130,8 @@ export function Join({
|
|||||||
// session; if the identity is not yet authorized, openSession fails and we
|
// session; if the identity is not yet authorized, openSession fails and we
|
||||||
// tell the user to have an admin authorize their public key.
|
// tell the user to have an admin authorize their public key.
|
||||||
const handle = identity.signPub.slice(0, 8);
|
const handle = identity.signPub.slice(0, 8);
|
||||||
const user = await saveAndOpen(identity, handle, password);
|
// Creating the account on this device implies it is yours: keep the session.
|
||||||
|
const user = await saveAndOpen(identity, handle, password, true);
|
||||||
onJoined(user);
|
onJoined(user);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const base =
|
const base =
|
||||||
|
|||||||
+2
-1
@@ -108,7 +108,8 @@ export function Recover({
|
|||||||
try {
|
try {
|
||||||
// No register here: the identity is already in the allowlist. Just re-encrypt
|
// No register here: the identity is already in the allowlist. Just re-encrypt
|
||||||
// locally and open the session as the recovered user.
|
// locally and open the session as the recovered user.
|
||||||
const user = await saveAndOpen(identity, handle.trim(), pw);
|
// Recovering on this device implies it is yours: keep the session by default.
|
||||||
|
const user = await saveAndOpen(identity, handle.trim(), pw, true);
|
||||||
onRecovered(user);
|
onRecovered(user);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(
|
setError(
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Anchor, Button, Group, PasswordInput, Text } from "@mantine/core";
|
import { Anchor, Button, Checkbox, Group, PasswordInput, Text } from "@mantine/core";
|
||||||
import { IconKey, IconWallet } from "@tabler/icons-react";
|
import { IconKey, IconWallet } from "@tabler/icons-react";
|
||||||
import { AuthCard, AuthHeader } from "./AuthShell";
|
import { AuthCard, AuthHeader } from "./AuthShell";
|
||||||
import { SessionError } from "./busService";
|
import { SessionError } from "./busService";
|
||||||
@@ -20,6 +20,7 @@ export function WalletLogin({
|
|||||||
onRecover: () => void;
|
onRecover: () => void;
|
||||||
}) {
|
}) {
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
|
const [remember, setRemember] = useState(true);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -28,7 +29,7 @@ export function WalletLogin({
|
|||||||
setBusy(true);
|
setBusy(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const user = await unlockAndOpen(password);
|
const user = await unlockAndOpen(password, remember);
|
||||||
onLoggedIn(user);
|
onLoggedIn(user);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof WrongPasswordError) {
|
if (e instanceof WrongPasswordError) {
|
||||||
@@ -60,6 +61,12 @@ export function WalletLogin({
|
|||||||
onKeyDown={(e) => e.key === "Enter" && void unlock()}
|
onKeyDown={(e) => e.key === "Enter" && void unlock()}
|
||||||
data-autofocus
|
data-autofocus
|
||||||
/>
|
/>
|
||||||
|
<Checkbox
|
||||||
|
label="Mantener la sesión en este dispositivo"
|
||||||
|
description="Hasta 30 días; se bloquea sola tras 12 h sin usarla. Desmárcala en un dispositivo compartido."
|
||||||
|
checked={remember}
|
||||||
|
onChange={(e) => setRemember(e.currentTarget.checked)}
|
||||||
|
/>
|
||||||
{error && (
|
{error && (
|
||||||
<Text c="red" size="sm" ta="center">
|
<Text c="red" size="sm" ta="center">
|
||||||
{error}
|
{error}
|
||||||
|
|||||||
@@ -138,6 +138,10 @@ export type MessageHandler = (subject: string, data: Uint8Array) => void;
|
|||||||
export interface NatsTransport {
|
export interface NatsTransport {
|
||||||
publish(subject: string, data: Uint8Array): void | Promise<void>;
|
publish(subject: string, data: Uint8Array): void | Promise<void>;
|
||||||
subscribe(subject: string, handler: MessageHandler): Promise<Subscription>;
|
subscribe(subject: string, handler: MessageHandler): Promise<Subscription>;
|
||||||
|
// reconnect rebuilds the connection so the server's per-subject ACL re-evaluates
|
||||||
|
// this peer's room membership (a room created after connecting is otherwise not in
|
||||||
|
// the grant). Active subscriptions are dropped; re-subscribe after calling it.
|
||||||
|
reconnect(): Promise<void>;
|
||||||
close(): Promise<void>;
|
close(): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -367,4 +371,13 @@ export class BusClient {
|
|||||||
private async loadSigners(roomID: string): Promise<void> {
|
private async loadSigners(roomID: string): Promise<void> {
|
||||||
this.signCache.set(roomID, await this.control.signerKeys(roomID));
|
this.signCache.set(roomID, await this.control.signerKeys(roomID));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// refresh reconnects the data plane so the server's per-subject ACL re-evaluates
|
||||||
|
// this peer's room membership. Call it after creating or joining a room while
|
||||||
|
// connected: NATS freezes a connection's publishable/subscribable subjects at
|
||||||
|
// connect time, so the new room's subject only becomes usable on a fresh
|
||||||
|
// connection. Active subscriptions are dropped — re-subscribe afterwards.
|
||||||
|
async refresh(): Promise<void> {
|
||||||
|
await this.transport.reconnect();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,17 +14,39 @@ import type { Identity, NatsTransport, MessageHandler, Subscription } from "./cl
|
|||||||
import { natsAuthenticator } from "./busauth.js";
|
import { natsAuthenticator } from "./busauth.js";
|
||||||
|
|
||||||
export class WsNatsTransport implements NatsTransport {
|
export class WsNatsTransport implements NatsTransport {
|
||||||
private constructor(private nc: NatsConnection) {}
|
// servers + id are retained so reconnect() can rebuild the connection with the same
|
||||||
|
// identity — needed because the per-subject ACL freezes a peer's publishable/
|
||||||
|
// subscribable subjects at connect time, so a room created after connecting only
|
||||||
|
// becomes usable after a fresh connection re-evaluates membership.
|
||||||
|
private constructor(
|
||||||
|
private nc: NatsConnection,
|
||||||
|
private servers: string[],
|
||||||
|
private id: Identity,
|
||||||
|
) {}
|
||||||
|
|
||||||
// connect opens a WebSocket connection to one of the given ws(s):// servers,
|
private static newConn(servers: string[], id: Identity): Promise<NatsConnection> {
|
||||||
// authenticating with the user's nkey identity.
|
|
||||||
static async connect(servers: string[], id: Identity): Promise<WsNatsTransport> {
|
|
||||||
const sign = natsAuthenticator(id.signPub, id.signPriv);
|
const sign = natsAuthenticator(id.signPub, id.signPriv);
|
||||||
// nats.ws's Authenticator returns the nkey + the base64url signature of the
|
// nats.ws's Authenticator returns the nkey + the base64url signature of the
|
||||||
// server nonce; our natsAuthenticator produces exactly that shape.
|
// server nonce; our natsAuthenticator produces exactly that shape.
|
||||||
const authenticator: Authenticator = (nonce?: string) => sign(nonce ?? "");
|
const authenticator: Authenticator = (nonce?: string) => sign(nonce ?? "");
|
||||||
const nc = await connect({ servers, authenticator });
|
return connect({ servers, authenticator });
|
||||||
return new WsNatsTransport(nc);
|
}
|
||||||
|
|
||||||
|
// connect opens a WebSocket connection to one of the given ws(s):// servers,
|
||||||
|
// authenticating with the user's nkey identity.
|
||||||
|
static async connect(servers: string[], id: Identity): Promise<WsNatsTransport> {
|
||||||
|
const nc = await WsNatsTransport.newConn(servers, id);
|
||||||
|
return new WsNatsTransport(nc, servers, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// reconnect drops the current connection and opens a fresh one with the same
|
||||||
|
// identity, so the server's subject-ACL re-evaluates this peer's room membership.
|
||||||
|
// Active subscriptions from the previous connection are lost; the caller must
|
||||||
|
// re-subscribe (BusClient.subscribe) to the rooms it cares about afterwards.
|
||||||
|
async reconnect(): Promise<void> {
|
||||||
|
const old = this.nc;
|
||||||
|
this.nc = await WsNatsTransport.newConn(this.servers, this.id);
|
||||||
|
await old.close().catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
publish(subject: string, data: Uint8Array): void {
|
publish(subject: string, data: Uint8Array): void {
|
||||||
|
|||||||
+52
-14
@@ -23,6 +23,7 @@ import {
|
|||||||
} from "./bus/index";
|
} from "./bus/index";
|
||||||
import type { WalletIdentity } from "./wallet/derive";
|
import type { WalletIdentity } from "./wallet/derive";
|
||||||
import type { MeInfo, Message, Room, User } from "./types";
|
import type { MeInfo, Message, Room, User } from "./types";
|
||||||
|
import { saveSession, loadSession, touchSession, clearSession } from "./session";
|
||||||
|
|
||||||
// Bus endpoints. The SPA is served same-origin behind a reverse proxy (Caddy):
|
// Bus endpoints. The SPA is served same-origin behind a reverse proxy (Caddy):
|
||||||
// both planes are reached through this page's OWN origin, so there is no CORS and
|
// both planes are reached through this page's OWN origin, so there is no CORS and
|
||||||
@@ -76,19 +77,49 @@ function require_(): Session {
|
|||||||
return session;
|
return session;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// connectSession opens the live bus connection (control plane + nats.ws data plane)
|
||||||
|
// for a wallet identity, WITHOUT touching persistence. The private key is used here
|
||||||
|
// in the browser and never leaves it.
|
||||||
|
async function connectSession(wallet: WalletIdentity, handle: string): Promise<User> {
|
||||||
|
const identity = toIdentity(wallet);
|
||||||
|
const endpoint = endpointID(identity.signPub);
|
||||||
|
const control = new ControlPlane(BUS_HTTP, identity);
|
||||||
|
const transport = await WsNatsTransport.connect([BUS_WS], identity);
|
||||||
|
const client = new BusClient(identity, transport, control);
|
||||||
|
session = { identity, handle, endpoint, control, transport, client };
|
||||||
|
return { id: endpoint, handle: handle || endpoint.slice(0, 8) };
|
||||||
|
}
|
||||||
|
|
||||||
export const bus = {
|
export const bus = {
|
||||||
// openSession connects to the bus AS this wallet user: it builds the signed
|
// openSession connects to the bus AS this wallet user and persists the session so a
|
||||||
// control-plane client and the nats.ws data-plane connection in the browser. The
|
// reload does not force a password re-unlock. remember=true keeps it across closing
|
||||||
// private key never leaves — this is the fix for the old gateway model where the
|
// the browser (localStorage, up to 30 days / 12 h idle); false keeps it only for the
|
||||||
// browser POSTed its private key to /api/session.
|
// tab (sessionStorage, survives F5). The private key never leaves the browser — this
|
||||||
async openSession(wallet: WalletIdentity, handle: string): Promise<User> {
|
// is the fix for the old gateway model where the browser POSTed its private key.
|
||||||
const identity = toIdentity(wallet);
|
async openSession(wallet: WalletIdentity, handle: string, remember = false): Promise<User> {
|
||||||
const endpoint = endpointID(identity.signPub);
|
const user = await connectSession(wallet, handle);
|
||||||
const control = new ControlPlane(BUS_HTTP, identity);
|
saveSession(wallet, handle, remember);
|
||||||
const transport = await WsNatsTransport.connect([BUS_WS], identity);
|
return user;
|
||||||
const client = new BusClient(identity, transport, control);
|
},
|
||||||
session = { identity, handle, endpoint, control, transport, client };
|
|
||||||
return { id: endpoint, handle: handle || endpoint.slice(0, 8) };
|
// restoreSession re-opens a previously persisted session on page load, if one exists
|
||||||
|
// and has not expired (TTL/idle checked in loadSession). It does NOT re-save (so the
|
||||||
|
// absolute 30-day TTL is not renewed on every reload) — it only refreshes the idle
|
||||||
|
// timer. Returns the User on success, or null when there is nothing to restore.
|
||||||
|
async restoreSession(): Promise<User | null> {
|
||||||
|
const persisted = loadSession();
|
||||||
|
if (!persisted) return null;
|
||||||
|
try {
|
||||||
|
const user = await connectSession(persisted.wallet, persisted.handle);
|
||||||
|
touchSession(); // restart the idle window; keep createdAt (TTL) intact
|
||||||
|
return user;
|
||||||
|
} catch {
|
||||||
|
// Connection failed (offline, identity revoked, ...): drop the stale session so
|
||||||
|
// the router falls back to the password unlock rather than looping.
|
||||||
|
clearSession();
|
||||||
|
session = null;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// me returns the identity of the active session (was GET /api/me).
|
// me returns the identity of the active session (was GET /api/me).
|
||||||
@@ -97,8 +128,10 @@ export const bus = {
|
|||||||
return { endpoint: s.endpoint, sign_pub: "", handle: s.handle };
|
return { endpoint: s.endpoint, sign_pub: "", handle: s.handle };
|
||||||
},
|
},
|
||||||
|
|
||||||
// logout closes the data-plane connection and drops the session.
|
// logout closes the data-plane connection, drops the in-memory session, and clears
|
||||||
|
// the persisted session from both stores so it cannot be restored.
|
||||||
async logout(): Promise<void> {
|
async logout(): Promise<void> {
|
||||||
|
clearSession();
|
||||||
if (session) {
|
if (session) {
|
||||||
await session.transport.close().catch(() => {});
|
await session.transport.close().catch(() => {});
|
||||||
session = null;
|
session = null;
|
||||||
@@ -121,10 +154,14 @@ export const bus = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
// createRoom creates an encrypted, signed room owned by this peer (the Matrix-like
|
// createRoom creates an encrypted, signed room owned by this peer (the Matrix-like
|
||||||
// default). Returns the UI Room.
|
// default), then reconnects the data plane so the new room's subject enters this
|
||||||
|
// connection's ACL grant — otherwise publish/subscribe on a just-created room would
|
||||||
|
// silently not deliver until a reconnect/re-login. Returns the UI Room.
|
||||||
async createRoom(subject: string): Promise<Room> {
|
async createRoom(subject: string): Promise<Room> {
|
||||||
const s = require_();
|
const s = require_();
|
||||||
const { roomID } = await s.control.createRoom(subject, ModeMatrix);
|
const { roomID } = await s.control.createRoom(subject, ModeMatrix);
|
||||||
|
await s.client.refresh(); // re-evaluate the per-subject ACL with the new room
|
||||||
|
touchSession();
|
||||||
return { id: roomID, name: subject, encrypted: true, lastMessage: "", lastTs: 0, unread: 0, messages: [] };
|
return { id: roomID, name: subject, encrypted: true, lastMessage: "", lastTs: 0, unread: 0, messages: [] };
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -133,6 +170,7 @@ export const bus = {
|
|||||||
async send(roomID: string, body: string): Promise<void> {
|
async send(roomID: string, body: string): Promise<void> {
|
||||||
const s = require_();
|
const s = require_();
|
||||||
await s.client.publish(roomID, new TextEncoder().encode(body));
|
await s.client.publish(roomID, new TextEncoder().encode(body));
|
||||||
|
touchSession(); // user activity: restart the idle auto-lock window
|
||||||
},
|
},
|
||||||
|
|
||||||
// subscribeRoom delivers decrypted, verified messages for a room (replaces the old
|
// subscribeRoom delivers decrypted, verified messages for a room (replaces the old
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ export async function saveAndOpen(
|
|||||||
identity: WalletIdentity,
|
identity: WalletIdentity,
|
||||||
handle: string,
|
handle: string,
|
||||||
password: string,
|
password: string,
|
||||||
|
remember = false,
|
||||||
): Promise<User> {
|
): Promise<User> {
|
||||||
const enc = await encryptJSON(identity, password);
|
const enc = await encryptJSON(identity, password);
|
||||||
await putIdentity({
|
await putIdentity({
|
||||||
@@ -27,17 +28,17 @@ export async function saveAndOpen(
|
|||||||
enc,
|
enc,
|
||||||
createdAt: Date.now(),
|
createdAt: Date.now(),
|
||||||
});
|
});
|
||||||
return bus.openSession(identity, handle);
|
return bus.openSession(identity, handle, remember);
|
||||||
}
|
}
|
||||||
|
|
||||||
// unlockAndOpen reads this device's stored identity, decrypts the private key with
|
// unlockAndOpen reads this device's stored identity, decrypts the private key with
|
||||||
// `password`, and opens a bus session locally. Throws WrongPasswordError on a bad
|
// `password`, and opens a bus session locally. Throws WrongPasswordError on a bad
|
||||||
// password (GCM auth failure) and NoLocalIdentityError if the device has none.
|
// password (GCM auth failure) and NoLocalIdentityError if the device has none.
|
||||||
export async function unlockAndOpen(password: string): Promise<User> {
|
export async function unlockAndOpen(password: string, remember = false): Promise<User> {
|
||||||
const stored = await getIdentity();
|
const stored = await getIdentity();
|
||||||
if (!stored) throw new NoLocalIdentityError();
|
if (!stored) throw new NoLocalIdentityError();
|
||||||
const identity = await decryptJSON<WalletIdentity>(stored.enc, password);
|
const identity = await decryptJSON<WalletIdentity>(stored.enc, password);
|
||||||
return bus.openSession(identity, stored.handle);
|
return bus.openSession(identity, stored.handle, remember);
|
||||||
}
|
}
|
||||||
|
|
||||||
// localIdentity returns the device's stored identity record (or null), for the
|
// localIdentity returns the device's stored identity record (or null), for the
|
||||||
|
|||||||
Reference in New Issue
Block a user