13 Commits

Author SHA1 Message Date
egutierrez 59705b5a4f Merge branch 'issue/room-history'
feat(uniweb): load each room's history on open (GET /api/rooms/{id}/history),
deduped vs live, so reloading the page no longer loses the messages. Sidebar
preview also seeded from history. Bump v0.6.0.
2026-06-14 19:40:20 +02:00
egutierrez 63ebc1eed9 docs(uniweb): bump to v0.6.0 + growth log (room history)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 19:40:12 +02:00
egutierrez 893df42d29 feat(uniweb): seed each room from history on open, deduped vs live
When a room is opened, load its stored history and keep it live so reloading no
longer loses the conversation.

- bus.subscribeRoom (used by ChatPanel) now runs subscribeRoomWithHistory: it
  subscribes live immediately but buffers live messages until the history batch
  (oldest -> newest) is delivered, guaranteeing history-first order regardless of
  timing; both halves are deduplicated by frame id via a per-room Set. If the
  history endpoint is absent (404/500), it falls back to live-only as before.
- toMessage maps an opened frame to the UI Message using ulidTime(msgID) for ts
  (not arrival time), so history and live share one clock and sort correctly;
  ChatPanel keeps its list ordered by ts.
- Sidebar previews: loadRooms seeds each room's last message/time from
  history(id, 1) in the background, without blocking the render and without
  overwriting a newer live message; empty rooms keep the "—" placeholder.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 19:40:08 +02:00
egutierrez c142b3a025 feat(uniweb): room history client (fetchHistory + BusClient.history)
NATS delivers live only, so reloading the page lost a room's history. Add the
client half of the new history endpoint:

- ControlPlane.fetchHistory(roomID, limit): signed GET /rooms/{id}/history?limit=N,
  decoding each base64-std frame to the raw bytes the live subscription delivers.
- BusClient.history(roomID, limit): opens each replayed frame (verify + decrypt)
  exactly like subscribe, dropping any that fail, oldest -> newest.
- Extract BusClient.openFrame as the shared envelope-opening core for subscribe
  and history (no duplication; subscribe behavior unchanged).
- ulidTime(id): decode the ms-epoch a ULID encodes in its first 10 Crockford
  chars (inverse of newULID), so a frame's timestamp comes from its id (the wire
  carries none). Covered by ulid.test.ts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 19:39:57 +02:00
egutierrez 45d12e03aa Merge branch 'issue/names-cleanup'
Readable handle in messages (GET /api/directory), sidebar shows real
last message + time per room, pnpm dev usable after same-origin switch,
dedup growth log (v0.5.0).
2026-06-14 15:40:26 +02:00
egutierrez 3049265230 docs(uniweb): dedup growth log + bump to v0.5.0
A merge left the v0.2.0 and v0.1.0 growth-log entries duplicated. Keep one
entry per version in descending order and add the v0.5.0 line covering this
release: readable handles in messages, sidebar with real last message/time,
and the documented `pnpm dev` setup. Frontmatter version 0.4.0 -> 0.5.0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 15:34:02 +02:00
egutierrez 6c4baf1397 chore(uniweb): make pnpm dev usable after the same-origin switch
Same-origin (Caddy) means the SPA reaches /api and /nats through its own
origin in production, but those relative paths do not exist on the bare Vite
dev server, so `pnpm dev` no longer connects. busService already reads
VITE_BUS_HTTP / VITE_BUS_WS as overrides of the same-origin defaults — this
documents that path (Option A, no proxy code) and moves the dev server off the
port reserved by an unrelated local app.

- vite.config: dev server port 5173 -> 5174 (5173 is in use by another local
  app). strictPort left off so Vite falls back to the next free port. Comment
  explains the same-origin/dev split and the env-var override.
- app.md: Ejemplo and the CORS gotcha document the exact dev command
  (VITE_BUS_HTTP/WS pointing at a cluster node) on :5174 and the same-origin
  production model.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 15:32:12 +02:00
egutierrez 5fbf319172 feat(uniweb): sidebar shows the real last message and time per room
The sidebar showed room.lastTs/lastMessage that busService created as 0/""
on every load, so the time rendered as the epoch-0 "01:00" and there was no
preview. busService now owns a room store that keeps these fields live.

- busService gains a room store: the room list plus a per-room metadata
  subscription. Since the wire has no message history (NATS delivers live
  only), staying subscribed to every room is the only way to know each room's
  latest message and to count unread for rooms the user is not viewing. Each
  delivered message updates the room's lastTs, lastMessage (a "name: body"
  preview, truncated, reusing the directory resolver) and bumps unread when
  the room is not active.
- New surface: watchRooms(listener) to mirror the store into React,
  loadRooms() to (re)populate and subscribe, setActiveRoom(id) to clear a
  room's unread. createRoom adds the new room to the store and, because its
  data-plane refresh() drops all subscriptions, re-subscribes every room.
  subscribeRoom now shares the same decode core (subscribeRoomInternal) used
  by the metadata subscription. The store is reset on login/logout.
- ChatShell mirrors the store via watchRooms instead of a one-shot listRooms,
  selects a room through setActiveRoom (clearing its unread), and auto-selects
  the first room once the list loads.
- Sidebar: timeShort renders an em dash for a room with no message yet instead
  of the epoch-0 "01:00".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 15:31:08 +02:00
egutierrez 5e9bf4e777 feat(uniweb): readable handle instead of endpoint id in messages
Resolve a message sender's endpoint id to a human handle using a new
control-plane directory endpoint.

- ControlPlane.fetchDirectory(): signed GET /api/directory, mapped to
  DirectoryEntry { signPub, endpoint, handle, role }. The server's endpoint
  matches endpointID(signPub) byte for byte.
- busService keeps an endpoint -> handle Map, loaded once after a session
  opens and refreshed after createRoom (where the ACL is already refreshed).
  Exposes a pure displayName(endpoint) resolver: handle when known, the
  session user's own handle for their messages, short id fallback otherwise.
- Resilience: loadDirectory never throws. A missing endpoint (404 on older
  clusters) or a transient error leaves the map empty and the UI falls back to
  the short id, so the chat keeps working exactly as before.
- ChatPanel renders displayName(msg.sender) in the message header and derives
  the avatar initials from the handle; the raw endpoint stays in a title
  tooltip for debugging.
- types: Message.sender comment updated (this is the "phase 2" readable name).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 15:27:15 +02:00
egutierrez 103a7f2f05 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.
2026-06-14 13:58:06 +02:00
egutierrez 1dc8b6257a Merge branch 'issue/caddy-same-origin' 2026-06-14 13:49:23 +02:00
egutierrez f8b2bf8e9e Merge branch 'issue/ui-rooms' 2026-06-14 13:49:23 +02:00
egutierrez e12894099f feat(uniweb): point the bus at the same-origin proxy, drop the IP fallback
The SPA is now served behind a same-origin reverse proxy (Caddy) that
fronts both bus planes, so the data layer reaches them through the page's
own origin instead of a hardcoded cluster node IP. This removes CORS
entirely and hides the cluster IPs behind the proxy.

- BUS_HTTP falls back to the relative path /api (the signed HTTPS control
  plane), resolved against the page origin by ControlPlane's fetch.
- BUS_WS falls back to a wss URL derived from window.location (same host,
  scheme mirroring https/http, path /nats), since a browser WebSocket needs
  an absolute ws(s) URL.
- The raw self-signed-IP fallback (https://51.91.100.142:8470, wss://...:8480)
  is gone. The VITE_BUS_HTTP / VITE_BUS_WS build-time overrides remain for a
  dev setup that points straight at a cluster node.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 12:32:47 +02:00
16 changed files with 786 additions and 124 deletions
+43 -27
View File
@@ -2,7 +2,7 @@
name: uniweb
lang: ts
domain: infra
version: 0.3.0
version: 0.6.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: []
@@ -62,14 +62,18 @@ total.
## Ejemplo
```bash
# El bus ya corre (cluster unibus con WebSocket habilitado, --ws-port). Apunta la SPA a un
# nodo y arráncala en dev (puerto 5173, que coincide con la CORS allowlist del cluster):
# Producción: SPA same-origin detrás de Caddy, que sirve la SPA + /api + /nats.
# Build estático y despliegue de web/dist:
cd web && pnpm install
VITE_BUS_HTTP=https://<nodo>:8470 VITE_BUS_WS=wss://<nodo>:8480 pnpm dev
# Navegador: http://localhost:5173
pnpm build # genera web/dist (se despliega a magnus:/opt/uniweb/dist)
# Producción: build estático y sirve web/dist con cualquier static server.
cd web && pnpm build # genera web/dist
# Dev (`pnpm dev`): el dev server NO tiene el proxy de Caddy, así que /api y /nats no
# existen en localhost. Apunta la SPA a un nodo real del cluster con las env vars
# (overrides del default same-origin). El dev server corre en el puerto 5174:
VITE_BUS_HTTP=https://<nodo>:8470 VITE_BUS_WS=wss://<nodo>:8480 pnpm dev
# Navegador: http://localhost:5174
# (Añade http://localhost:5174 a la --cors-origins del nodo, o el control-plane
# rechazará la petición por CORS.)
```
## Cuándo usarla
@@ -87,14 +91,44 @@ programáticos) ve a `unibus`; `uniweb` es el cliente web encima.
*mockeaba*). En `enforce`, una identidad nueva debe ser autorizada por un admin
(`membershipd user add`) antes de poder abrir sesión; el flujo de Join muestra la clave
pública del usuario para que un admin la autorice.
- **CORS**: el dev server corre en `http://localhost:5173` para coincidir con la
`--cors-origins` del cluster. Otro origen necesita añadirse a esa allowlist.
- **CORS / same-origin**: en producción la SPA es same-origin detrás de Caddy (`/api` y
`/nats` proxyados), así que no hay CORS. En dev (`pnpm dev`, puerto 5174) esos paths
relativos no existen: hay que apuntar a un nodo con `VITE_BUS_HTTP`/`VITE_BUS_WS` y
añadir `http://localhost:5174` a la `--cors-origins` del nodo. El puerto 5173 está
reservado a otra app local; si 5174 está ocupado, Vite usa el siguiente libre.
- La passphrase del wallet nunca se guarda ni se envía; perderla en un dispositivo sin la
mnemónica BIP39 = identidad irrecuperable en ese dispositivo (recuperable en otro con las 12
palabras).
## Capability growth log
- v0.6.0 (2026-06-14) — carga el histórico de cada room (`GET /api/rooms/{id}/history`) al
abrirla, con dedup vs live; recargar ya no pierde los mensajes. `ControlPlane.fetchHistory`
pega al control-plane (firmado, mismas cabeceras `X-Unibus-*`) y decodifica cada frame de
base64-std; `BusClient.history` lo descifra/verifica con el MISMO camino de envelope que
`subscribe` (refactor: helper privado `openFrame` compartido por ambos). En `busService`,
`bus.subscribeRoom` (que usa `ChatPanel`) ahora siembra la room con su historia y sigue en
vivo: dedup por `frame.id` con un `Set` por room y los mensajes live se bufferean hasta que
la historia (oldest->newest) se entrega, garantizando el orden; si el endpoint falta
(404/500) cae a live-only como antes. El ts de cada mensaje se deriva del ULID `msgID`
(`ulidTime`, inverso de `newULID`) para que historia y live compartan reloj y ordenen bien;
`ChatPanel` ordena por ts. El sidebar siembra su preview con `history(id, 1)` (sin traer
todo), manteniendo el fallback "—" para rooms vacías. `tsc` + 23 unit (incluye `ulid.test.ts`)
+ `pnpm build` verdes.
- v0.5.0 (2026-06-14) — nombres legibles en mensajes + sidebar con último mensaje/hora
reales + `pnpm dev` documentado. (1) Los mensajes muestran el **handle** del remitente en
vez del endpoint id: `ControlPlane.fetchDirectory()` pega al control-plane
`GET /api/directory` (firmado) y `busService` mantiene un mapa `endpoint -> handle`
(cargado al abrir sesión, refrescado tras `createRoom`); el resolver
`bus.displayName(endpoint)` devuelve el handle o un id corto de fallback (nunca el
endpoint largo), usado en la cabecera y el avatar de `ChatPanel` (el endpoint queda en un
`title` para depurar). Resiliente: si el endpoint aún no existe en el cluster (404) el
mapa queda vacío y el chat funciona igual que antes. (2) El sidebar muestra el **último
mensaje y la hora reales**: `busService` posee un store de rooms con una suscripción de
metadatos por room (último mensaje/hora + unread de rooms no activas); `Sidebar` ya no
pinta el "01:00" de epoch-0. (3) `pnpm dev` queda usable tras el cambio a same-origin:
apunta a un nodo con `VITE_BUS_HTTP`/`VITE_BUS_WS` y el dev server corre en el puerto 5174
(documentado en `app.md` + `vite.config.ts`). `tsc` + 19/19 unit + `pnpm build` verdes.
- v0.3.0 (2026-06-14) — `uniweb` se vuelve **cliente browser-nativo puro** (issue 0001, Fase
2): la SPA se cablea al SDK del bus (`busService.ts` reemplaza el módulo `api`) y se
**elimina el gateway Go** (`cmd/webgw`, `go.mod`, `go.sum`). `uniweb` queda como solo `web/`,
@@ -122,21 +156,3 @@ programáticos) ve a `unibus`; `uniweb` es el cliente web encima.
respecto a lo que ya vivía en unibus 0.12.0 (wallet BIP39 + sesiones por usuario); solo
cambia la ubicación y el módulo Go. go build/vet/test + pnpm build verdes en la nueva
ubicación con los `replace` cross-repo.
- v0.2.0 (2026-06-13) — SDK del bus en TypeScript (`web/src/bus/`), issue 0001 Fase 1:
el protocolo y el cifrado E2E del bus portados al navegador para que `uniweb` deje
de depender del gateway Go. Módulos: `crypto.ts` (Ed25519, ChaCha20-Poly1305,
sealed box con nonce BLAKE2b igual que Go), `frame.ts` (wire format = `encoding/json`
de Go byte a byte), `room.ts` (Policy), `busauth.ts` (nkey NATS + firma de requests
del control-plane), `client.ts` (envelope de room puro + `BusClient` sobre una
interfaz de transporte + cliente HTTP firmado) y `wstransport.ts` (adaptador
`nats.ws`). Paridad cross-language verificada contra vectores Go (`cmd/busvectors`):
**19/19 tests verdes** — endpoint id, firma Ed25519, AEAD, sealed box, frame
marshal/sign, nkey y canonical request. La clave privada del usuario nunca se
serializa hacia la red. La conexión `nats.ws` + control-plane reales se validan en
la Fase 3 (E2E) por requerir un unibus vivo con WebSocket.
- v0.1.0 (2026-06-13) — scaffold inicial: extracción de la SPA (`web/`) y el gateway
(`cmd/webgw`) desde `unibus` v0.13.0 a su propia app/sub-repo. Sin cambios de capacidad
respecto a lo que ya vivía en unibus 0.12.0 (wallet BIP39 + sesiones por usuario); solo
cambia la ubicación y el módulo Go. go build/vet/test + pnpm build verdes en la nueva
ubicación con los `replace` cross-repo.
+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) {
+19 -7
View File
@@ -33,15 +33,23 @@ function timeShort(ts: number) {
}
function MessageRow({ msg }: { msg: Message }) {
// Show the readable handle (resolved from the bus directory); the raw endpoint id
// stays in the title attribute as a debugging tooltip.
const name = bus.displayName(msg.sender);
return (
<Group align="flex-start" gap="sm" wrap="nowrap">
<Avatar radius="xl" size={36} color={msg.mine ? "brand" : "gray"}>
{initials(msg.sender)}
{initials(name)}
</Avatar>
<Box style={{ minWidth: 0 }}>
<Group gap={8} align="baseline">
<Text size="sm" fw={600} c={msg.mine ? "brand.4" : undefined}>
{msg.sender}
<Text
size="sm"
fw={600}
c={msg.mine ? "brand.4" : undefined}
title={msg.sender}
>
{name}
</Text>
<Text size="xs" c="dimmed">
{timeShort(msg.ts)}
@@ -61,16 +69,20 @@ export function ChatPanel({ room }: { room: Room | undefined }) {
const [sendError, setSendError] = useState<string | null>(null);
const viewport = useRef<HTMLDivElement>(null);
// Abre el stream SSE de la room activa. El gateway entrega historia (rooms
// persistidas) y luego mensajes en vivo, ya descifrados. Dedup por id porque
// un re-render no debe duplicar y el eco del propio envío llega por aquí.
// Carga el histórico de la room activa y luego sigue en vivo: bus.subscribeRoom
// entrega primero la historia (oldest->newest) y después los mensajes en vivo, ya
// descifrados y deduplicados por id. Aquí se mantiene la lista ordenada por ts y se
// deduplica de nuevo por id, porque un re-render no debe duplicar y el eco del propio
// envío también llega por esta vía.
useEffect(() => {
setMessages([]);
setSendError(null);
if (!room) return;
const close = bus.subscribeRoom(room.id, (m) => {
setMessages((prev) =>
prev.some((p) => p.id === m.id) ? prev : [...prev, m],
prev.some((p) => p.id === m.id)
? prev
: [...prev, m].sort((a, b) => a.ts - b.ts),
);
});
return close;
+27 -14
View File
@@ -21,24 +21,32 @@ export function ChatShell({
const [error, setError] = useState<string | null>(null);
const [modalOpen, modal] = useDisclosure(false);
// Inserta la room recién creada al principio de la lista y la activa, sin
// recargar todo. Evita duplicar si el id ya estaba presente.
const handleRoomCreated = useCallback((room: Room) => {
setRooms((prev) =>
prev.some((r) => r.id === room.id) ? prev : [room, ...prev],
);
setActiveId(room.id);
// The room list lives in busService (it owns a per-room metadata subscription so the
// sidebar shows the latest message/time and unread for rooms not being viewed). The
// shell just mirrors the store into React state.
useEffect(() => bus.watchRooms(setRooms), []);
// selectRoom activates a room in the UI and tells the store, which clears that room's
// unread badge.
const selectRoom = useCallback((id: string) => {
setActiveId(id);
bus.setActiveRoom(id);
}, []);
// La room recién creada ya está en el store (bus.createRoom la insertó); aquí solo
// se activa.
const handleRoomCreated = useCallback(
(room: Room) => {
selectRoom(room.id);
},
[selectRoom],
);
const load = useCallback(() => {
setLoading(true);
bus
.listRooms()
.then((rs) => {
setRooms(rs);
setActiveId((cur) => cur || rs[0]?.id || "");
setError(null);
})
.loadRooms()
.then(() => setError(null))
.catch((e) => setError(e?.message ?? "No se pudieron cargar las rooms"))
.finally(() => setLoading(false));
}, []);
@@ -47,6 +55,11 @@ export function ChatShell({
load();
}, [load]);
// Activa la primera room en cuanto la lista se puebla y aún no hay ninguna activa.
useEffect(() => {
if (!activeId && rooms.length > 0) selectRoom(rooms[0].id);
}, [rooms, activeId, selectRoom]);
const active = rooms.find((r) => r.id === activeId);
// El panel derecho muestra el estado de carga/error/empty sin tocar el layout.
@@ -106,7 +119,7 @@ export function ChatShell({
user={user}
rooms={rooms}
activeId={activeId}
onSelect={setActiveId}
onSelect={selectRoom}
onLogout={onLogout}
onNewRoom={modal.open}
/>
+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(
+3
View File
@@ -28,7 +28,10 @@ function initials(s: string) {
return s.replace(/[^a-z0-9]/gi, "").slice(0, 2).toUpperCase() || "?";
}
// timeShort renders HH:MM, or an em dash when there is no message yet (ts 0/falsy) so
// an empty room does not show the epoch-0 "01:00".
function timeShort(ts: number) {
if (!ts) return "—";
const d = new Date(ts);
return `${String(d.getHours()).padStart(2, "0")}:${String(
d.getMinutes(),
+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}
+126 -5
View File
@@ -60,6 +60,22 @@ export function newULID(nowMs: number = Date.now()): string {
return ts + r;
}
// ulidTime decodes the millisecond epoch timestamp a ULID encodes in its first 10
// Crockford base32 characters (the inverse of newULID's time prefix). A frame carries
// no explicit timestamp on the wire — its ULID id IS the timestamp — so the UI derives
// a message's time from it, which keeps live and replayed-history messages on the same
// clock (the sender's send time, not the receiver's arrival time). Returns 0 for an id
// whose prefix is not valid Crockford base32, so a malformed id never blows up the UI.
export function ulidTime(id: string): number {
let t = 0;
for (let i = 0; i < 10 && i < id.length; i++) {
const v = CROCKFORD.indexOf(id[i].toUpperCase());
if (v < 0) return 0;
t = t * 32 + v;
}
return t;
}
// --- room envelope (pure, the security-critical core) ------------------------
export interface SealOptions {
@@ -138,6 +154,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>;
}
@@ -152,6 +172,14 @@ interface RoomKeyResponse {
epoch: number;
}
// HistoryResp is GET /rooms/{id}/history?limit=N: a room's replayed frames, oldest ->
// newest, each base64-standard encoded. Every entry is one marshaled wire frame — the
// exact bytes the live subscription delivers — so the caller opens them with the same
// envelope path as a live message. A room with no stored history yields an empty list.
interface HistoryResp {
messages: string[];
}
// PolicyWire is the control-plane JSON shape of a policy (snake_case sign_msgs).
interface PolicyWire {
encrypt: boolean;
@@ -172,6 +200,29 @@ interface MemberJSON {
sign_pub: string; // base64
}
// DirectoryMemberWire is one row of GET /directory: a cluster-wide member with its
// human handle and role. sign_pub here is 64-hex (the raw Ed25519 public key), and
// endpoint matches endpointID(signPub) byte for byte.
interface DirectoryMemberWire {
sign_pub: string; // 64-hex
endpoint: string; // base64url-nopad, == endpointID(signPub)
handle: string;
role: string;
}
interface DirectoryResp {
members: DirectoryMemberWire[];
}
// DirectoryEntry is the SDK shape of one directory member: the readable handle keyed
// by the stable endpoint id, so the UI can show a name instead of the raw id.
export interface DirectoryEntry {
signPub: string; // 64-hex
endpoint: string;
handle: string;
role: string;
}
// MemberRoomWire is one row of GET /members/{endpoint}/rooms.
interface MemberRoomWire {
room_id: string;
@@ -281,6 +332,21 @@ export class ControlPlane {
}));
}
// fetchDirectory returns the cluster-wide member directory (GET /api/directory), so
// the UI can resolve a message sender's endpoint id to a readable handle. The
// request is signed like every other control-plane call. The caller is expected to
// tolerate this endpoint being absent on older clusters (404) and fall back to the
// short id; this method only maps the wire shape and lets transport errors surface.
async fetchDirectory(): Promise<DirectoryEntry[]> {
const resp = await this.request<DirectoryResp>("GET", "/directory");
return (resp.members ?? []).map((m) => ({
signPub: m.sign_pub,
endpoint: m.endpoint,
handle: m.handle,
role: m.role,
}));
}
// listMembers returns the room's members keyed by endpoint, so a receiver can find
// a sender's signing public key to verify message signatures.
async signerKeys(roomID: string): Promise<Map<string, Uint8Array>> {
@@ -289,6 +355,17 @@ export class ControlPlane {
for (const member of members) m.set(member.endpoint, base64ToBytesLocal(member.sign_pub));
return m;
}
// fetchHistory replays a room's stored frames (GET /rooms/{id}/history?limit=N),
// returning up to N marshaled wire frames oldest -> newest. The server base64-standard
// encodes each frame; this decodes them back to the raw bytes the live subscription
// delivers, so BusClient.history can open each with the same envelope path as
// subscribe. The caller tolerates this endpoint being absent on older clusters
// (404/500): the error surfaces and BusClient.history's caller falls back to live-only.
async fetchHistory(roomID: string, limit = 200): Promise<Uint8Array[]> {
const resp = await this.request<HistoryResp>("GET", `/rooms/${roomID}/history?limit=${limit}`);
return (resp.messages ?? []).map((b64) => base64ToBytesLocal(b64));
}
}
// base64ToBytesLocal decodes standard base64 (kept local to avoid widening crypto's
@@ -350,21 +427,65 @@ export class BusClient {
await this.transport.publish(room.subject, marshal(f));
}
// openFrame is the shared envelope-opening core behind subscribe (live) and history
// (replay): it unmarshals one wire frame, resolves the sender's signing key (from the
// sign cache, populated by loadSigners for signed rooms) and the room key for the
// frame's epoch, then verifies + decrypts via openRoomMessage. Returns null when the
// frame fails verification or decryption, so both callers drop it the same way.
private async openFrame(
roomID: string,
policy: Policy,
bytes: Uint8Array,
): Promise<{ frame: Frame; plaintext: Uint8Array } | null> {
const frame = unmarshal(bytes);
const signerPub = policy.signMsgs ? this.signCache.get(roomID)?.get(frame.sender) : undefined;
const roomKey = policy.encrypt ? await this.roomKey(roomID, frame.epoch) : undefined;
const plaintext = openRoomMessage(frame, policy, signerPub, roomKey);
return plaintext ? { frame, plaintext } : null;
}
// subscribe delivers decoded, verified, decrypted messages for a room. Messages
// that fail signature verification or decryption are dropped silently.
async subscribe(roomID: string, handler: (f: Frame, plaintext: Uint8Array) => void): Promise<Subscription> {
const room = await this.control.fetchRoom(roomID);
if (room.policy.signMsgs) await this.loadSigners(roomID);
return this.transport.subscribe(room.subject, async (_subject, data) => {
const f = unmarshal(data);
const signerPub = room.policy.signMsgs ? this.signCache.get(roomID)?.get(f.sender) : undefined;
const roomKey = room.policy.encrypt ? await this.roomKey(roomID, f.epoch) : undefined;
const plaintext = openRoomMessage(f, room.policy, signerPub, roomKey);
if (plaintext) handler(f, plaintext);
const opened = await this.openFrame(roomID, room.policy, data);
if (opened) handler(opened.frame, opened.plaintext);
});
}
// history replays a room's stored messages, decrypted and verified exactly like
// subscribe (NATS delivers live only, so without this a reload shows nothing until
// new traffic arrives). It resolves the room policy, loads the signer keys for a
// signed room, fetches the marshaled frames from the control plane, and opens each
// with the same openFrame path. Frames that fail verification/decryption are dropped.
// Returns the opened messages in the server's order (oldest -> newest).
async history(
roomID: string,
limit = 200,
): Promise<Array<{ frame: Frame; plaintext: Uint8Array }>> {
const room = await this.control.fetchRoom(roomID);
if (room.policy.signMsgs) await this.loadSigners(roomID);
const frames = await this.control.fetchHistory(roomID, limit);
const out: Array<{ frame: Frame; plaintext: Uint8Array }> = [];
for (const bytes of frames) {
const opened = await this.openFrame(roomID, room.policy, bytes);
if (opened) out.push(opened);
}
return out;
}
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();
}
}
+34
View File
@@ -0,0 +1,34 @@
// Tests for ulidTime, the decoder of the millisecond timestamp a ULID encodes in its
// first 10 Crockford base32 characters. A wire frame carries no explicit timestamp —
// its ULID id IS the timestamp — so the UI derives a message's time (and thus its sort
// order, live and replayed-history alike) from this function. These tests pin that it
// is the exact inverse of newULID's time prefix and that it is time-ordered.
import { describe, it, expect } from "vitest";
import { newULID, ulidTime } from "./client.js";
describe("ulidTime", () => {
it("round-trips the millisecond timestamp newULID encodes", () => {
for (const ms of [0, 1, 1_000, 1_700_000_000_000, 2_000_000_000_000, Date.now()]) {
expect(ulidTime(newULID(ms))).toBe(ms);
}
});
it("is monotonic: a later message decodes to a larger time", () => {
const earlier = newULID(1_700_000_000_000);
const later = newULID(1_700_000_001_000);
expect(ulidTime(earlier)).toBeLessThan(ulidTime(later));
});
it("ignores the 16-char random suffix (only the 10-char time prefix matters)", () => {
const ms = 1_736_000_000_000;
// Two ULIDs minted at the same ms differ only in their random tail, yet decode equal.
expect(ulidTime(newULID(ms))).toBe(ms);
expect(ulidTime(newULID(ms))).toBe(ms);
});
it("returns 0 for an id whose prefix is not valid Crockford base32", () => {
expect(ulidTime("!!!!!!!!!!xxxxxxxxxxxxxxxx")).toBe(0);
expect(ulidTime("")).toBe(0);
});
});
+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 {
+361 -47
View File
@@ -17,18 +17,35 @@ import {
WsNatsTransport,
hexToBytes,
endpointID,
ulidTime,
type Identity,
type Frame,
ModeMatrix,
} 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. A browser cannot open a raw TCP NATS socket, so the data plane is
// reached over WebSocket; the control plane is the signed HTTPS API. Both default to
// a cluster node and can be overridden at build time (VITE_BUS_HTTP / VITE_BUS_WS).
const BUS_HTTP = import.meta.env.VITE_BUS_HTTP ?? "https://51.91.100.142:8470";
const BUS_WS = import.meta.env.VITE_BUS_WS ?? "wss://51.91.100.142:8480";
// 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
// the cluster node IPs stay hidden behind the proxy. The control plane is the
// signed HTTPS API under the relative path /api; the data plane is NATS over
// WebSocket under /nats (a browser cannot open a raw TCP NATS socket). Both can
// still be overridden at build time (VITE_BUS_HTTP / VITE_BUS_WS) for a dev setup
// that points straight at a cluster node.
const BUS_HTTP = import.meta.env.VITE_BUS_HTTP ?? "/api";
const BUS_WS = import.meta.env.VITE_BUS_WS ?? defaultBusWS();
// defaultBusWS derives the data-plane WebSocket URL from the page origin: the same
// host and port as the SPA, the wss/ws scheme mirroring https/http, path /nats. A
// browser WebSocket needs an absolute ws(s) URL, so this is computed from location
// rather than left relative. Returns "" where window is absent (SSR/tests), where
// the build-time override is expected instead.
function defaultBusWS(): string {
if (typeof window === "undefined") return "";
const proto = window.location.protocol === "https:" ? "wss:" : "ws:";
return `${proto}//${window.location.host}/nats`;
}
export class SessionError extends Error {}
@@ -56,24 +73,282 @@ interface Session {
let session: Session | null = null;
// directory maps a peer's stable endpoint id to its human handle, so the UI can show
// a readable name instead of the long base64url id. Populated from the control-plane
// GET /api/directory once a session opens, and refreshed when membership changes. It
// is best-effort: a cluster without the directory endpoint leaves it empty and the UI
// falls back to a short id (see displayName), so the chat keeps working regardless.
let directory = new Map<string, string>();
// shortId is the display fallback for an endpoint with no known handle: the first 8
// characters of the id, never the full long string.
function shortId(endpoint: string): string {
return endpoint.slice(0, 8);
}
// loadDirectory (re)loads the cluster member directory into the endpoint -> handle
// map. It NEVER throws: if the endpoint is missing (older cluster, 404) or the request
// fails, the existing map is kept (empty on first load) and callers fall back to the
// short id. The new map is built locally and only swapped in on success, so a failed
// refresh never wipes a directory that loaded earlier.
async function loadDirectory(s: Session): Promise<void> {
try {
const entries = await s.control.fetchDirectory();
const next = new Map<string, string>();
for (const e of entries) if (e.handle) next.set(e.endpoint, e.handle);
directory = next;
} catch {
// No directory endpoint yet, or a transient failure: keep what we have (the chat
// must work exactly as before without readable names).
}
}
// displayNameOf is the resolver behind bus.displayName, kept module-level so the
// room store can reuse it for last-message previews.
function displayNameOf(endpoint: string): string {
if (session && endpoint === session.endpoint) {
return session.handle || directory.get(endpoint) || shortId(endpoint);
}
return directory.get(endpoint) || shortId(endpoint);
}
function require_(): Session {
if (!session) throw new SessionError("no active bus session");
return session;
}
// ---- room store (sidebar metadata) -----------------------------------------
//
// The sidebar needs each room's last message and time, plus an unread count for
// rooms the user is NOT currently viewing. NATS delivers live only, so a live metadata
// subscription per room keeps the sidebar current while the app is open; on first load
// (or after a reload) the control plane's history endpoint seeds each room's last
// message so a room with no live traffic yet still shows its real latest line instead
// of "—". This store owns that: it holds the room list, subscribes to each room for
// metadata, seeds the preview from history, and notifies React watchers on every
// change. ChatPanel keeps its own subscription for the open conversation; this store's
// per-room subscription is independent and only updates sidebar metadata.
let roomList: Room[] = [];
let activeRoomID = "";
const roomListeners = new Set<(rooms: Room[]) => void>();
const metaSubs = new Map<string, () => void>(); // roomID -> unsubscribe
const PREVIEW_MAX = 48; // characters of a last-message preview in the sidebar
// snapshotRooms returns a shallow copy so React sees a new array/objects and re-renders.
function snapshotRooms(): Room[] {
return roomList.map((r) => ({ ...r }));
}
function notifyRooms(): void {
const snap = snapshotRooms();
for (const l of roomListeners) l(snap);
}
// previewText builds the sidebar's last-message line: "name: body" with the body
// truncated, reusing the directory resolver so the sender shows as a readable handle.
function previewText(m: Message): string {
const body =
m.body.length > PREVIEW_MAX ? m.body.slice(0, PREVIEW_MAX - 1) + "…" : m.body;
return `${displayNameOf(m.sender)}: ${body}`;
}
// trackRoomMeta opens a metadata subscription for one room: each delivered message
// updates the room's last message/time and bumps unread when the room is not active.
function trackRoomMeta(roomID: string): void {
if (metaSubs.has(roomID)) return;
const unsub = subscribeRoomInternal(roomID, (m) => {
const r = roomList.find((x) => x.id === roomID);
if (!r) return;
r.lastTs = m.ts;
r.lastMessage = previewText(m);
if (roomID !== activeRoomID && !m.mine) r.unread += 1;
notifyRooms();
});
metaSubs.set(roomID, unsub);
}
// seedRoomPreviews fills each room's sidebar preview (last message + time) from the
// control plane's history, best-effort and in the background: the room list renders
// immediately, then each preview updates as its single most-recent stored message
// arrives. It never overwrites a live message that is already newer, and a room with
// genuinely no history keeps the "—" placeholder (lastTs 0). Errors (missing endpoint,
// transient) are swallowed per room so one failure never blocks the others.
function seedRoomPreviews(s: Session): void {
for (const r of roomList) {
s.client
.history(r.id, 1)
.then((items) => {
if (!items.length) return;
const last = items[items.length - 1];
const m = toMessage(s, last.frame, last.plaintext);
const room = roomList.find((x) => x.id === r.id);
if (!room || m.ts < room.lastTs) return; // a newer live message already won
room.lastTs = m.ts;
room.lastMessage = previewText(m);
notifyRooms();
})
.catch(() => {});
}
}
function untrackAllRooms(): void {
for (const unsub of metaSubs.values()) {
try {
unsub();
} catch {
/* a closing transport may already be gone */
}
}
metaSubs.clear();
}
// retrackRooms re-establishes a metadata subscription for every room. Used after a
// data-plane reconnect (createRoom's refresh), which drops all existing subscriptions.
function retrackRooms(): void {
untrackAllRooms();
for (const r of roomList) trackRoomMeta(r.id);
}
// resetRoomStore clears the store and tears down subscriptions (on logout / new
// session), then pushes the empty snapshot so any live watcher renders an empty list.
function resetRoomStore(): void {
untrackAllRooms();
roomList = [];
activeRoomID = "";
notifyRooms();
}
// toMessage maps an opened bus frame to the UI's Message. The timestamp comes from the
// frame's ULID id (ulidTime), NOT the arrival time: a frame carries no explicit ts on
// the wire, and deriving it from the id puts live and replayed-history messages on the
// same clock so they sort into one correct order.
function toMessage(s: Session, f: Frame, plaintext: Uint8Array): Message {
return {
id: f.msgID,
sender: f.sender,
body: new TextDecoder().decode(plaintext),
ts: ulidTime(f.msgID),
mine: f.sender === s.endpoint,
};
}
// subscribeRoomInternal is the live-only core behind the store's per-room metadata
// subscription (and the live half of subscribeRoomWithHistory): it decodes each frame
// into a UI Message and hands it to onMessage. Returns a function that cancels the
// subscription.
function subscribeRoomInternal(
roomID: string,
onMessage: (m: Message) => void,
): () => void {
const s = require_();
let unsub: (() => void) | null = null;
let closed = false;
s.client
.subscribe(roomID, (f: Frame, plaintext: Uint8Array) => {
onMessage(toMessage(s, f, plaintext));
})
.then((sub) => {
if (closed) void sub.unsubscribe();
else unsub = () => void sub.unsubscribe();
})
.catch(() => {});
return () => {
closed = true;
if (unsub) unsub();
};
}
// subscribeRoomWithHistory is what ChatPanel opens for the conversation it is viewing:
// it seeds the room with its stored history (so a reload no longer loses the messages)
// and then keeps it live. History and live are deduplicated by frame id through a
// per-room `seen` set — a message can arrive both ways when it lands between the fetch
// and the subscription. To guarantee history shows first (oldest -> newest) regardless
// of timing, live messages are buffered until the history batch has been delivered,
// then flushed. If history fails or the endpoint is absent (404/500 on an older
// cluster), it is treated as empty and the room runs live-only, exactly as before.
function subscribeRoomWithHistory(
roomID: string,
onMessage: (m: Message) => void,
): () => void {
const s = require_();
const seen = new Set<string>();
let historyDone = false;
let pending: Message[] = [];
const deliver = (m: Message): void => {
if (seen.has(m.id)) return;
seen.add(m.id);
onMessage(m);
};
// Live is subscribed immediately so nothing published during the history fetch is
// missed; messages are buffered until the history batch lands, then delivered.
const liveUnsub = subscribeRoomInternal(roomID, (m) => {
if (historyDone) deliver(m);
else pending.push(m);
});
s.client
.history(roomID)
.then((items) => {
for (const { frame, plaintext } of items) deliver(toMessage(s, frame, plaintext));
})
.catch(() => {
// No history endpoint yet, or a transient failure: fall back to live-only.
})
.finally(() => {
historyDone = true;
for (const m of pending) deliver(m);
pending = [];
});
return liveUnsub;
}
// 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 };
directory = new Map(); // fresh identity: drop any prior session's handle map
resetRoomStore(); // drop any prior session's room store + metadata subscriptions
await loadDirectory(session); // best-effort; never blocks login on a directory error
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).
@@ -82,19 +357,45 @@ export const bus = {
return { endpoint: s.endpoint, sign_pub: "", handle: s.handle };
},
// logout closes the data-plane connection and drops the session.
// displayName resolves a sender endpoint id to a readable name for the UI: the
// member's handle when the directory knows it, the session user's own handle for
// their own messages, and a short id fallback otherwise — NEVER the full long
// endpoint. Pure lookup over the in-memory directory; safe to call from render.
displayName(endpoint: string): string {
return displayNameOf(endpoint);
},
// 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();
resetRoomStore();
directory = new Map();
if (session) {
await session.transport.close().catch(() => {});
session = null;
}
},
// listRooms lists the rooms this peer belongs to.
async listRooms(): Promise<Room[]> {
// watchRooms subscribes a listener to the sidebar room list and returns a function
// to detach it. The current snapshot is pushed immediately, so a component mounting
// mid-session renders the rooms it already has. Call loadRooms() to (re)populate.
watchRooms(listener: (rooms: Room[]) => void): () => void {
roomListeners.add(listener);
listener(snapshotRooms());
return () => {
roomListeners.delete(listener);
};
},
// loadRooms fetches the rooms this peer belongs to, replaces the store, opens a
// metadata subscription per room (so the sidebar shows the latest message/time and
// unread for rooms the user is not viewing), and notifies watchers.
async loadRooms(): Promise<void> {
const s = require_();
const wire = await s.control.listMemberRooms(s.endpoint);
return wire.map((r) => ({
untrackAllRooms();
roomList = wire.map((r) => ({
id: r.id,
name: r.subject,
encrypted: r.policy.encrypt,
@@ -103,14 +404,44 @@ export const bus = {
unread: 0,
messages: [],
}));
for (const r of roomList) trackRoomMeta(r.id);
notifyRooms();
seedRoomPreviews(s); // fill each preview from history without blocking the render
},
// setActiveRoom marks the room the user is viewing: its unread count is cleared and
// future messages to it do not bump unread (see trackRoomMeta).
setActiveRoom(roomID: string): void {
activeRoomID = roomID;
const r = roomList.find((x) => x.id === roomID);
if (r) r.unread = 0;
notifyRooms();
},
// 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. The reconnect drops every
// metadata subscription, so they are re-established here. Returns the UI Room.
async createRoom(subject: string): Promise<Room> {
const s = require_();
const { roomID } = await s.control.createRoom(subject, ModeMatrix);
return { id: roomID, name: subject, encrypted: true, lastMessage: "", lastTs: 0, unread: 0, messages: [] };
await s.client.refresh(); // re-evaluate the per-subject ACL with the new room
await loadDirectory(s); // a new room may bring new members into the directory
touchSession();
const room: Room = {
id: roomID,
name: subject,
encrypted: true,
lastMessage: "",
lastTs: 0,
unread: 0,
messages: [],
};
if (!roomList.some((r) => r.id === roomID)) roomList = [room, ...roomList];
retrackRooms(); // refresh() dropped all data-plane subs; re-subscribe every room
notifyRooms();
return room;
},
// send publishes a plaintext message to a room; the SDK seals + signs it per the
@@ -118,33 +449,16 @@ 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
// SSE streamRoom). Returns an unsubscribe function.
// subscribeRoom delivers a room's stored history followed by its live messages, both
// decrypted, verified and deduplicated by id (replaces the old SSE streamRoom).
// Returns an unsubscribe function. ChatPanel uses this for the open conversation, so
// reloading the page no longer loses the conversation; the sidebar metadata uses the
// live-only core (subscribeRoomInternal) and seeds its preview from history separately.
subscribeRoom(roomID: string, onMessage: (m: Message) => void): () => void {
const s = require_();
let unsub: (() => void) | null = null;
let closed = false;
s.client
.subscribe(roomID, (f: Frame, plaintext: Uint8Array) => {
onMessage({
id: f.msgID,
sender: f.sender,
body: new TextDecoder().decode(plaintext),
ts: Date.now(),
mine: f.sender === s.endpoint,
});
})
.then((sub) => {
if (closed) void sub.unsubscribe();
else unsub = () => void sub.unsubscribe();
})
.catch(() => {});
return () => {
closed = true;
if (unsub) unsub();
};
return subscribeRoomWithHistory(roomID, onMessage);
},
};
+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);
}
+1 -1
View File
@@ -8,7 +8,7 @@ export interface User {
export interface Message {
id: string;
sender: string; // endpoint id del remitente (handle legible es fase 2)
sender: string; // endpoint id del remitente; el nombre legible se resuelve con bus.displayName()
body: string;
ts: number; // epoch ms
mine?: boolean;
+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
+9 -5
View File
@@ -3,12 +3,16 @@ import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
// The SPA talks DIRECTLY to the bus (signed HTTPS control plane + nats.ws data
// plane), so there is no gateway and no /api proxy. The dev server runs on 5173 to
// match the bus CORS allowlist (--cors-origins http://localhost:5173). Point the
// SPA at a cluster node with VITE_BUS_HTTP / VITE_BUS_WS (see busService.ts).
// In production the SPA is served same-origin behind Caddy, which proxies /api and
// /nats to the cluster; those relative paths do not exist on the bare dev server, so
// `pnpm dev` must be pointed at a real cluster node with VITE_BUS_HTTP / VITE_BUS_WS
// (busService.ts uses them as overrides of the same-origin defaults). Example:
// VITE_BUS_HTTP=https://<node>:8470 VITE_BUS_WS=wss://<node>:8480 pnpm dev
// The dev server runs on 5174 (5173 is reserved for an unrelated local app). Add the
// dev origin (http://localhost:5174) to the node's --cors-origins allowlist. strictPort
// is left off, so Vite falls back to the next free port if 5174 is busy.
server: {
host: true,
port: 5173,
port: 5174,
},
});