# 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

```bash
# 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)
```
