From 5e9bf4e777fbb561ccf47db9bf2994bd0521caf9 Mon Sep 17 00:00:00 2001 From: Egutierrez Date: Sun, 14 Jun 2026 15:27:15 +0200 Subject: [PATCH 1/4] 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) --- web/src/ChatPanel.tsx | 14 +++++++++++--- web/src/bus/client.ts | 38 +++++++++++++++++++++++++++++++++++++ web/src/busService.ts | 44 +++++++++++++++++++++++++++++++++++++++++++ web/src/types.ts | 2 +- 4 files changed, 94 insertions(+), 4 deletions(-) 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/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..8f4e28c 100644 --- a/web/src/busService.ts +++ b/web/src/busService.ts @@ -72,6 +72,36 @@ 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). + } +} + function require_(): Session { if (!session) throw new SessionError("no active bus session"); return session; @@ -87,6 +117,8 @@ async function connectSession(wallet: WalletIdentity, handle: string): Promise { @@ -161,6 +204,7 @@ export const bus = { 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: [] }; }, 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; From 5fbf319172090cbf4defec472d04bc64d19271ba Mon Sep 17 00:00:00 2001 From: Egutierrez Date: Sun, 14 Jun 2026 15:31:08 +0200 Subject: [PATCH 2/4] 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) --- web/src/ChatShell.tsx | 41 ++++++--- web/src/Sidebar.tsx | 3 + web/src/busService.ts | 199 +++++++++++++++++++++++++++++++++++------- 3 files changed, 197 insertions(+), 46 deletions(-) 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/busService.ts b/web/src/busService.ts index 8f4e28c..94640d4 100644 --- a/web/src/busService.ts +++ b/web/src/busService.ts @@ -102,11 +102,128 @@ async function loadDirectory(s: Session): Promise { } } +// 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. @@ -118,6 +235,7 @@ 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, @@ -194,19 +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 @@ -218,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); }, }; From 6c4baf13975cdfd02cbcc7e922d39ede9d50da49 Mon Sep 17 00:00:00 2001 From: Egutierrez Date: Sun, 14 Jun 2026 15:32:12 +0200 Subject: [PATCH 3/4] chore(uniweb): make `pnpm dev` usable after the same-origin switch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- app.md | 23 +++++++++++++++-------- web/vite.config.ts | 14 +++++++++----- 2 files changed, 24 insertions(+), 13 deletions(-) diff --git a/app.md b/app.md index 0d9b73f..647b68a 100644 --- a/app.md +++ b/app.md @@ -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,8 +91,11 @@ 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). 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, }, }); From 3049265230e40c5d29f3fa3f592319a7d1ff8cd7 Mon Sep 17 00:00:00 2001 From: Egutierrez Date: Sun, 14 Jun 2026 15:34:02 +0200 Subject: [PATCH 4/4] 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) --- app.md | 34 +++++++++++++++------------------- 1 file changed, 15 insertions(+), 19 deletions(-) diff --git a/app.md b/app.md index 647b68a..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: [] @@ -102,6 +102,20 @@ programáticos) ve a `unibus`; `uniweb` es el cliente web encima. ## 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/`, @@ -129,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.