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:
2026-06-14 13:58:06 +02:00
parent 1dc8b6257a
commit 103a7f2f05
10 changed files with 229 additions and 33 deletions
+1 -1
View File
@@ -2,7 +2,7 @@
name: uniweb
lang: ts
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."
tags: [messaging, web, frontend, e2e]
uses_functions: []
+13 -5
View File
@@ -31,11 +31,12 @@ export function App() {
const [token, setToken] = useState("");
const [storedHandle, setStoredHandle] = useState("");
// Decide the entry screen on mount: an invite link goes straight to join; a device
// with a stored identity shows the password unlock; an empty device shows the
// welcome chooser. There is no "resume session" step: the bus session lives in
// memory (the SDK runs in the browser), so a reload always re-unlocks locally
// rather than resuming a server-side cookie session.
// Decide the entry screen on mount: an invite link goes straight to join; otherwise
// try to RESTORE a persisted session (survives reloads, and — with "keep me signed
// in" — closing the browser, up to its TTL/idle limits) so a reload does not force a
// re-unlock; if there is none, a device with a stored identity shows the password
// 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(() => {
const t = readJoinToken();
if (t) {
@@ -45,6 +46,13 @@ export function App() {
}
let cancelled = false;
(async () => {
const restored = await bus.restoreSession();
if (cancelled) return;
if (restored) {
setUser(restored);
setRoute("chat");
return;
}
const stored = await localIdentity();
if (cancelled) return;
if (stored) {
+2 -1
View File
@@ -130,7 +130,8 @@ export function Join({
// session; if the identity is not yet authorized, openSession fails and we
// tell the user to have an admin authorize their public key.
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);
} catch (e) {
const base =
+2 -1
View File
@@ -108,7 +108,8 @@ export function Recover({
try {
// No register here: the identity is already in the allowlist. Just re-encrypt
// 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);
} catch (e) {
setError(
+9 -2
View File
@@ -1,5 +1,5 @@
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 { AuthCard, AuthHeader } from "./AuthShell";
import { SessionError } from "./busService";
@@ -20,6 +20,7 @@ export function WalletLogin({
onRecover: () => void;
}) {
const [password, setPassword] = useState("");
const [remember, setRemember] = useState(true);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -28,7 +29,7 @@ export function WalletLogin({
setBusy(true);
setError(null);
try {
const user = await unlockAndOpen(password);
const user = await unlockAndOpen(password, remember);
onLoggedIn(user);
} catch (e) {
if (e instanceof WrongPasswordError) {
@@ -60,6 +61,12 @@ export function WalletLogin({
onKeyDown={(e) => e.key === "Enter" && void unlock()}
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 && (
<Text c="red" size="sm" ta="center">
{error}
+13
View File
@@ -138,6 +138,10 @@ export type MessageHandler = (subject: string, data: Uint8Array) => void;
export interface NatsTransport {
publish(subject: string, data: Uint8Array): void | Promise<void>;
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>;
}
@@ -367,4 +371,13 @@ export class BusClient {
private async loadSigners(roomID: string): Promise<void> {
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();
}
}
+28 -6
View File
@@ -14,17 +14,39 @@ import type { Identity, NatsTransport, MessageHandler, Subscription } from "./cl
import { natsAuthenticator } from "./busauth.js";
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,
// authenticating with the user's nkey identity.
static async connect(servers: string[], id: Identity): Promise<WsNatsTransport> {
private static newConn(servers: string[], id: Identity): Promise<NatsConnection> {
const sign = natsAuthenticator(id.signPub, id.signPriv);
// nats.ws's Authenticator returns the nkey + the base64url signature of the
// server nonce; our natsAuthenticator produces exactly that shape.
const authenticator: Authenticator = (nonce?: string) => sign(nonce ?? "");
const nc = await connect({ servers, authenticator });
return new WsNatsTransport(nc);
return connect({ servers, authenticator });
}
// 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 {
+52 -14
View File
@@ -23,6 +23,7 @@ import {
} from "./bus/index";
import type { WalletIdentity } from "./wallet/derive";
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):
// 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;
}
// 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 = {
// openSession connects to the bus AS this wallet user: it builds the signed
// control-plane client and the nats.ws data-plane connection in the browser. The
// private key never leaves — this is the fix for the old gateway model where the
// browser POSTed its private key to /api/session.
async openSession(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) };
// openSession connects to the bus AS this wallet user and persists the session so a
// reload does not force a password re-unlock. remember=true keeps it across closing
// the browser (localStorage, up to 30 days / 12 h idle); false keeps it only for the
// tab (sessionStorage, survives F5). The private key never leaves the browser — this
// is the fix for the old gateway model where the browser POSTed its private key.
async openSession(wallet: WalletIdentity, handle: string, remember = false): Promise<User> {
const user = await connectSession(wallet, handle);
saveSession(wallet, handle, remember);
return user;
},
// 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).
@@ -97,8 +128,10 @@ export const bus = {
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> {
clearSession();
if (session) {
await session.transport.close().catch(() => {});
session = null;
@@ -121,10 +154,14 @@ export const bus = {
},
// 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> {
const s = require_();
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: [] };
},
@@ -133,6 +170,7 @@ export const bus = {
async send(roomID: string, body: string): Promise<void> {
const s = require_();
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
+105
View File
@@ -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);
}
+4 -3
View File
@@ -18,6 +18,7 @@ export async function saveAndOpen(
identity: WalletIdentity,
handle: string,
password: string,
remember = false,
): Promise<User> {
const enc = await encryptJSON(identity, password);
await putIdentity({
@@ -27,17 +28,17 @@ export async function saveAndOpen(
enc,
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
// `password`, and opens a bus session locally. Throws WrongPasswordError on a bad
// 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();
if (!stored) throw new NoLocalIdentityError();
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