Files
message_bus/reports/0015-2026-06-07-unibus-web-wired.md
T
egutierrez d43ffae3ae chore: auto-commit (17 archivos)
- 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>
2026-06-08 01:57:00 +02:00

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-repo dataforge/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 desde pass/fichero (gemelo de unibus_admin/internal/admin/identity.go).
  • cmd/webgw/gateway.go — wrapper del pkg/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/client deriva el nombre del consumer durable por (room, endpoint); dos Subscribe de 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 consistente g.mu → h.mu, broadcast no bloqueante que descarta a un cliente atascado en vez de frenar la room).
  • RefreshSession gateado 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 --ca está puesto.
  • Auth por cookie, no header. EventSource no permite cabeceras personalizadas pero sí envía la cookie same-origin; por eso la sesión es una cookie HttpOnly. La passphrase se compara con subtle.ConstantTimeCompare.
  • Sin embed estático en build. El gateway sirve la SPA opcionalmente desde --web-dir (no go:embed), para que go build ./... sea siempre verde sin exigir pnpm build previo. 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. Wrappers fetch (cookie same-origin) para login/logout/me y rooms list/create/join/send; streamRoom() abre un EventSource y 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/me para 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/rooms con estados loading / empty / error; mismo layout Flex.
  • 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 curl independiente 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 id crudo del remitente (vI8HXcin...). Resolver endpoint→handle vía /users o 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 DeliverAll por 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/client no expone borrar room ni self-leave (solo Kick a otros), así que las rooms test.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.work local. El worktree vive en /tmp, donde el replace fn-registry => ../../../../ del go.mod resuelve a /. Se añadió un go.work local (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)