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
|
||||
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
@@ -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
@@ -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
@@ -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(
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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,
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user