diff --git a/app.md b/app.md index 0d9b73f..ff86143 100644 --- a/app.md +++ b/app.md @@ -2,7 +2,7 @@ name: uniweb lang: ts domain: infra -version: 0.4.0 +version: 0.5.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://:8470 VITE_BUS_WS=wss://: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://:8470 VITE_BUS_WS=wss://: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,31 @@ 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.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 +143,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. diff --git a/web/src/ChatPanel.tsx b/web/src/ChatPanel.tsx index 228a065..a75a447 100644 --- a/web/src/ChatPanel.tsx +++ b/web/src/ChatPanel.tsx @@ -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 ( - {initials(msg.sender)} + {initials(name)} - - {msg.sender} + + {name} {timeShort(msg.ts)} diff --git a/web/src/ChatShell.tsx b/web/src/ChatShell.tsx index a5e2cfe..a1db7b9 100644 --- a/web/src/ChatShell.tsx +++ b/web/src/ChatShell.tsx @@ -21,24 +21,32 @@ export function ChatShell({ const [error, setError] = useState(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} /> diff --git a/web/src/Sidebar.tsx b/web/src/Sidebar.tsx index 47351d7..121bc82 100644 --- a/web/src/Sidebar.tsx +++ b/web/src/Sidebar.tsx @@ -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(), diff --git a/web/src/bus/client.ts b/web/src/bus/client.ts index fd4823a..e7df65a 100644 --- a/web/src/bus/client.ts +++ b/web/src/bus/client.ts @@ -176,6 +176,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; @@ -285,6 +308,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 { + const resp = await this.request("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> { diff --git a/web/src/busService.ts b/web/src/busService.ts index ed1d4fd..94640d4 100644 --- a/web/src/busService.ts +++ b/web/src/busService.ts @@ -72,11 +72,158 @@ 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(); + +// 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 { + try { + const entries = await s.control.fetchDirectory(); + const next = new Map(); + 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. There is no message history on the wire +// (NATS delivers live only), so the only way to know a room's latest message is to +// stay subscribed to every room while the app is open. This store owns that: it holds +// the room list, subscribes to each room for metadata, 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 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); +} + +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(); +} + +// subscribeRoomInternal is the shared core behind bus.subscribeRoom and the store's +// per-room metadata subscription: 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({ + 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(); + }; +} + // 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. @@ -87,6 +234,9 @@ async function connectSession(wallet: WalletIdentity, handle: string): Promise { 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 { + // 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 { 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, @@ -151,18 +325,43 @@ export const bus = { unread: 0, messages: [], })); + for (const r of roomList) trackRoomMeta(r.id); + notifyRooms(); + }, + + // 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), 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. + // 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 { 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 + await loadDirectory(s); // a new room may bring new members into the directory touchSession(); - return { id: roomID, name: subject, encrypted: true, lastMessage: "", lastTs: 0, unread: 0, messages: [] }; + 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 @@ -174,30 +373,10 @@ export const bus = { }, // subscribeRoom delivers decrypted, verified messages for a room (replaces the old - // SSE streamRoom). Returns an unsubscribe function. + // SSE streamRoom). Returns an unsubscribe function. ChatPanel uses this for the open + // conversation; the sidebar metadata uses the same core (subscribeRoomInternal). 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 subscribeRoomInternal(roomID, onMessage); }, }; diff --git a/web/src/types.ts b/web/src/types.ts index 3a5a802..218d299 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -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; diff --git a/web/vite.config.ts b/web/vite.config.ts index 96ac07d..1963f94 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -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://:8470 VITE_BUS_WS=wss://: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, }, });