- reports/0001-2026-06-07-unibus-grafana-monitoring.md - reports/0008-2026-06-07-unibus-admin-users-wired.md - reports/0008-2026-06-07-unibus-decentralization-audit.md - reports/0009-2026-06-07-unibus-cluster-hardening.md - reports/0010-2026-06-07-unibus-android-native.md - reports/0011-2026-06-07-unibus-cluster-deploy.md - reports/0012-2026-06-07-unibus-deploy-gaps-closed.md - reports/0013-2026-06-07-unibus-admin-panel.md - reports/0014-2026-06-07-unibus-users-http-admin-api.md - reports/0015-2026-06-07-unibus-web-wired.md - ... Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
13 KiB
Report 0015 — unibus: SPA web cableada al bus (gateway REST/SSE)
- Fecha: 07/06/2026
- Autor: agente (Claude Opus 4.8) + operador
- Ámbito:
projects/message_bus/apps/unibus— gateway web (cmd/webgw) + capa de datos de la SPA (web/src) - Rama:
quick/web-wire(sub-repodataforge/unibus), 2 commits, pusheada. NO mergeada (la integra el orquestador coordinando con el agente de estilo que trabaja en master). - Estado: done (MVP funcional end-to-end verificado contra el cluster vivo)
Resumen
La SPA de chat de unibus estaba 100% en datos mock (src/mock.ts). Ahora funciona de verdad contra el bus: un gateway Go (cmd/webgw) actúa como peer autenticado del bus (sesión pkg/client con la identidad del operador) y expone REST + SSE al navegador; la SPA tiene una capa de datos (src/api.ts) que reemplaza el mock — la sidebar lista rooms reales, el panel hace stream de mensajes reales descifrados por SSE y el composer publica de verdad. Verificado end-to-end por curl y en el navegador (login → ver rooms → enviar → recibir descifrado en vivo, incluido fan-out a varios clientes simultáneos). No se reestilizó ningún componente: solo cambió de dónde vienen los datos.
El gateway (cmd/webgw)
Binario Go único. Mantiene la identidad del operador (desde pass unibus/operator-identity o un fichero 0600, solo en memoria) y una sesión pkg/client conectada al bus. Mismo posture seam que unibus_admin y cmd/clientcheck: --ca vacío = plaintext dev; --ca <path> = TLS + nkey en ambos planos.
Archivos:
cmd/webgw/main.go— flags, carga de identidad, wiring, arranque + shutdown.cmd/webgw/identity.go— carga de identidad/passphrase desdepass/fichero (gemelo deunibus_admin/internal/admin/identity.go).cmd/webgw/gateway.go— wrapper delpkg/client: tipos wire + operaciones de room.cmd/webgw/hub.go— fan-out: una suscripción al bus por room, multiplexada a N clientes SSE.cmd/webgw/server.go— superficie HTTP: auth por cookie, REST, SSE, static opcional.
Endpoints
| Método y ruta | Acción de pkg/client |
Notas |
|---|---|---|
POST /api/login |
— | Desbloquea sesión con la passphrase del operador (compare en tiempo constante). Emite cookie unibus_session HttpOnly. Única ruta /api sin sesión. |
POST /api/logout |
— | Invalida la sesión. |
GET /api/me |
Endpoint() |
Identidad del operador que el gateway encarna. La SPA la consulta al montar para reanudar sesión. |
GET /api/rooms |
ListMyRooms() |
Lista de rooms del operador. |
POST /api/rooms |
CreateRoom(subject, policy) |
{subject, encrypted} basta; policy por defecto = encrypted+persisted+signed (Matrix-like). Acepta override encrypt/persist/sign_msgs. |
POST /api/rooms/{id}/join |
Join(roomID) |
Idempotente; obtiene la clave de room para rooms cifradas. |
POST /api/rooms/{id}/send |
Publish(roomID, body) |
El peer sella (AEAD) y firma antes de salir del proceso. |
GET /api/rooms/{id}/stream |
Subscribe(roomID, handler) |
SSE: cada frame descifrado como evento data:. Historia primero (rooms persistidas, DeliverAll en el primer bind) y luego en vivo. Heartbeat : ping cada 25 s. |
GET /healthz |
— | Liveness sin auth. |
Decisiones de diseño
- Hub fan-out por room. El
pkg/clientderiva el nombre del consumer durable por(room, endpoint); dosSubscribede la misma room desde el mismo operador competirían por el durable. El gateway mantiene una suscripción por room y reparte cada frame descifrado a todos los clientes SSE — así varias pestañas/clientes ven todos los mensajes y se abre como mucho una suscripción al bus por room. Conteo de referencias: la suscripción se cierra cuando se va el último cliente (con orden de locks consistenteg.mu → h.mu, broadcast no bloqueante que descarta a un cliente atascado en vez de frenar la room). RefreshSessiongateado por posture. Tras crear/unirse a una room, NATS congela los permisos por-subject; bajo ACL hay que refrescar la sesión (reconectar). En plaintext dev NO hace falta y además tiraría los streams SSE vivos, así que solo se llama cuando--caestá puesto.- Auth por cookie, no header.
EventSourceno permite cabeceras personalizadas pero sí envía la cookie same-origin; por eso la sesión es una cookie HttpOnly. La passphrase se compara consubtle.ConstantTimeCompare. - Sin embed estático en build. El gateway sirve la SPA opcionalmente desde
--web-dir(nogo:embed), para quego build ./...sea siempre verde sin exigirpnpm buildprevio. En dev se usa el proxy de vite; para servir el dist se pasa--web-dir web/dist.
Modelo de confianza E2E
El contenido sigue cifrado end-to-end en el bus. El gateway puede leer el plaintext porque actúa como el cliente del operador: es un miembro legítimo de cada room y posee la clave de room K como cualquier peer; sella/abre en el lado servidor del peer. Es la misma confianza que tendría un cliente nativo de escritorio del operador. El cleartext solo cruza un canal SSE autenticado (cookie de sesión) sobre loopback (o TLS fronted en deploy). En la fase 2 (wallet por-navegador con WebCrypto, identidad Ed25519 por usuario) el descifrado puede moverse al navegador y el gateway dejaría de ver plaintext.
La capa de datos de la SPA (web/src)
Los componentes conservan su aspecto y props; solo cambió la fuente de datos (permitido: no se reestiliza).
src/api.ts— capa única de repositorio. Wrappersfetch(cookie same-origin) para login/logout/me y rooms list/create/join/send;streamRoom()abre unEventSourcey entrega cada mensaje descifrado. Mappers wire→UI (roomFromWire,messageFromWire).src/types.ts— añade las formas wire del gateway (MeInfo,RoomWire,MsgWire) junto a los tipos UI existentes.src/App.tsx— al montar consulta/api/mepara reanudar sesión; si no hay (401), muestra Login. Logout llama al gateway.src/Login.tsx— el campo de contraseña desbloquea la sesión del gateway (passphrase del operador); estados básicos de carga y error. El handle es solo nombre a mostrar en esta iteración.src/ChatShell.tsx— carga rooms de/api/roomscon estados loading / empty / error; mismo layoutFlex.src/ChatPanel.tsx— stream de mensajes por SSE para la room activa (dedup por id); el composer publica por el gateway. Sin inserción optimista: el eco del propio mensaje vuelve por SSE con el id de frame real, evitando duplicados.src/vite.config.ts— proxy de dev/api(REST + SSE) → gateway en:8481.
mock.ts se deja intacto (ya no se importa) para no chocar con el trabajo de estilo paralelo en master.
Verificación (evidencia ejecutable)
Entorno: cluster vivo local membershipd --bind 0.0.0.0 (control plane HTTP :8470, NATS :4250), plaintext dev (https en :8470 no responde; http /healthz → {"status":"ok"}). Identidad operador desde pass unibus/operator-identity.
Build
$ CGO_ENABLED=1 go build ./... # módulo entero → EXIT 0
$ CGO_ENABLED=1 go vet ./cmd/webgw/ # EXIT 0
$ gofmt -l cmd/webgw/ # (vacío)
$ cd web && pnpm build # tsc -b && vite build
✓ 6948 modules transformed.
dist/assets/index-*.js 411.85 kB
✓ built in 2.09s
Gateway arranca y conecta al bus
[webgw] operator endpoint: vI8HXcintzK-oTAz_AgSErXBUeIygQzGMQlCRTnzcWo
[webgw] control plane: http://127.0.0.1:8470 (+0 failover)
[webgw] bus TLS+nkey: OFF (plaintext dev)
[webgw] web gateway: http://127.0.0.1:8481
REST + auth (curl)
$ curl -s :8481/healthz -> {"status":"ok"}
$ curl -s -o /dev/null -w '%{http_code}' :8481/api/rooms -> 401 (sin sesión)
$ curl -c jar -d '{"passphrase":"..."}' :8481/api/login
-> {"endpoint":"vI8HXcin...","sign_pub":"48bc0dc8...729571a1332b4b2deb0bd78326a06bcf149e7d560728d8dc0b98173fa"}
$ curl -b jar :8481/api/me -> {"endpoint":"vI8HXcin...", ...}
$ curl -b jar :8481/api/rooms -> [] (operador sin rooms al inicio)
E2E cifrado: crear room → SSE → enviar → recibir DESCIFRADO
Room efímera cifrada+firmada (misma policy que clientcheck):
$ curl -b jar -d '{"subject":"test.webwire.22a152c9","encrypt":true,"persist":false,"sign_msgs":true}' :8481/api/rooms
-> {"id":"01KTHQKFPTR8VYZGVKP1CDZBY8","subject":"test.webwire.22a152c9","encrypt":true,...,"role":"owner"}
# SSE abierto en background, luego 2 sends:
$ curl -b jar -d '{"body":"hola E2E desde webgw"}' .../send -> {"status":"sent"}
$ curl -b jar -d '{"body":"segundo mensaje cifrado"}' .../send -> {"status":"sent"}
# El SSE entregó AMBOS, descifrados:
: connected
data: {"id":"01KTHQKGPX...","sender":"vI8HXcin...","body":"hola E2E desde webgw","ts":1780859126493,"mine":true}
data: {"id":"01KTHQKGQ3...","sender":"vI8HXcin...","body":"segundo mensaje cifrado","ts":1780859126499,"mine":true}
Historia / scrollback (room persistida)
Mensajes enviados ANTES de suscribir se entregan al abrir el SSE (DeliverAll):
$ curl -b jar -d '{"subject":"test.webwire.persist.a8d5dfc8","encrypted":true}' :8481/api/rooms
-> {"id":"01KTHQMAY7MV479A9QWD48928F","encrypt":true,"persist":true,"sign_msgs":true,...}
$ curl -b jar -d '{"body":"historico-1"}' .../send ; curl -b jar -d '{"body":"historico-2"}' .../send
# SSE abierto DESPUÉS de enviar:
data: {"...","body":"historico-1",...}
data: {"...","body":"historico-2",...}
Navegador (browser MCP, gateway sirviendo el dist en :8481)
- Login con handle
leo+ passphrase → entra a ChatShell. - Sidebar muestra las 2 rooms reales del bus (
webwire.22a152c9,webwire.persist.a8d5dfc8) con candado E2E. - Escribir en el composer + enviar → el mensaje aparece descifrado en el panel (sender + hora), composer se limpia.
- Fan-out / segundo cliente: con el navegador suscrito a la room, un
curlindependiente sobre la MISMA room recibió el mensaje descifrado, y un mensaje enviado por curl (PING-LIVE-NAVEGADOR-check) apareció en vivo en el panel del navegador (21:12) — confirma una suscripción al bus multiplexada a varios clientes.
Gaps / pendientes
- Login wallet multiusuario (FASE 2). Hoy el gateway usa UNA identidad (la del operador) y la passphrase solo desbloquea la sesión del gateway; el handle es cosmético. La fase 2 es identidad Ed25519 por-navegador (WebCrypto), descifrado en el cliente y por-usuario; el modelo de confianza E2E se endurece (el gateway dejaría de ver plaintext). Está documentado en el header de
main.go/gateway.go, no bloquea el MVP. - Handle legible del remitente. La UI muestra el
endpoint idcrudo del remitente (vI8HXcin...). Resolver endpoint→handle vía/userso la lista de miembros de la room es trabajo de pulido (fase 2). - Historia en re-suscripción de rooms persistidas. El consumer durable es por-operador (compartido entre pestañas/sesiones del gateway): al reabrir el SSE, resume desde su último ack y NO re-entrega la historia ya consumida. En la prueba en navegador, un re-montaje del panel dejó el panel sin la historia previa (los mensajes nuevos sí llegan). Para "todas las pestañas ven toda la historia" hace falta un buffer de scrollback en el gateway o consumers efímeros con
DeliverAllpor stream SSE. Documentado; no bloquea el chat en vivo. - Crear room desde la UI. El MVP lista/streamea/envía; no hay botón "nueva room" en la SPA (las rooms se crean por gateway/admin). Es un añadido de UI menor.
- Limpieza de rooms de prueba. El
pkg/clientno expone borrar room ni self-leave (soloKicka otros), así que las roomstest.webwire.*creadas en la verificación quedan en el control plane. No es posible limpiarlas con la API actual; queda anotado. - Sidebar:
lastMessage/unread/hora. Se rellenan neutros (no se inventan datos); alimentarlos del último frame por room es pulido futuro. go.worklocal. El worktree vive en/tmp, donde elreplace fn-registry => ../../../../delgo.modresuelve a/. Se añadió ungo.worklocal (override del replace al repo real) excluido de git (info/exclude). En el repo en su ubicación normal no hace falta.
Cómo ejecutarlo
# 1. gateway (operador desde pass, bus local plaintext)
go run ./cmd/webgw --identity-pass unibus/operator-identity \
--unlock-pass-entry unibus/admin-panel-password \
--ctrl-url http://127.0.0.1:8470 --nats-url nats://127.0.0.1:4250 --port 8481
# 2a. dev: vite con proxy a :8481
cd web && pnpm install && pnpm dev # http://localhost:5181
# 2b. o servir el dist desde el propio gateway
cd web && pnpm build
go run ./cmd/webgw ... --web-dir web/dist # http://127.0.0.1:8481
# secured cluster: añadir --ca ca.crt y URLs https:// / seeds adicionales (como clientcheck)