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).
This commit is contained in:
2026-06-14 15:40:26 +02:00
8 changed files with 326 additions and 78 deletions
+30 -27
View File
@@ -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://<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,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.
+11 -3
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)}
+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}
/>
+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(),
+38
View File
@@ -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<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>> {
+207 -28
View File
@@ -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<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. 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<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);
}
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<U
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) };
}
@@ -128,21 +278,45 @@ export const bus = {
return { endpoint: s.endpoint, sign_pub: "", handle: s.handle };
},
// 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,
@@ -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<Room> {
const s = require_();
const { roomID } = await s.control.createRoom(subject, ModeMatrix);
await s.client.refresh(); // re-evaluate the per-subject ACL with the new room
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);
},
};
+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;
+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,
},
});