--- issue: 0001 title: uniweb como cliente browser-nativo del bus (eliminar el gateway Go, desacoplar de unibus) status: spec created: 2026-06-13 domain: architecture scope: uniweb (web/src/bus, web/src, eliminar cmd/webgw + go.mod) + unibus (pkg/embeddednats WebSocket, cmd/membershipd CORS, export de vectores de test cripto) --- # Objetivo Convertir `uniweb` en un cliente del bus **browser-nativo y autónomo**, al mismo nivel que `unibus_android`: la SPA habla directamente con el data plane (NATS sobre WebSocket) y el control plane (HTTP), e implementa el protocolo del bus y su cifrado E2E **en el propio navegador** (TypeScript + `@noble`). Como consecuencia se **elimina el gateway Go** (`cmd/webgw`) y `uniweb` deja de depender del módulo Go de `unibus`: queda como una app de solo frontend (`web/`), sin `go.mod`, sin `replace => ../unibus`. # Por qué (estado actual auditado el 13/06/2026) `uniweb` v0.1.0 nació partiendo `unibus` v0.13.0 en dos: la SPA (`web/`) y un gateway Go (`cmd/webgw`). El gateway importa `pkg/{busauth,client,frame,room}` de unibus y `functions/cybersecurity` del registry, así que `uniweb` solo compila con `unibus` presente en disco (`replace github.com/enmanuel/unibus => ../unibus`). Esa es la deuda que este issue salda. Hay además un **defecto de seguridad** que este rediseño corrige de paso. Hoy el flujo wallet funciona así: - La SPA deriva la identidad del usuario (Ed25519 + X25519) de una mnemónica BIP39 y la cifra at-rest en el dispositivo. - Pero al abrir sesión, `POST /api/session` **envía al gateway la identidad COMPLETA, incluida la clave privada** (`sign_priv` 64 bytes + `kex_priv` 32 bytes, en hex). El gateway construye un `cs.Identity` con la privada y abre un cliente del bus que **actúa como el usuario server-side** (ver `cmd/webgw/session.go`, `cmd/webgw/identity.go`). Es decir: la clave privada del usuario **viaja al servidor y vive en la RAM del gateway**. El cifrado de contenido sigue siendo E2E respecto al broker NATS, pero NO respecto al gateway — el gateway puede leer y firmar todo en nombre del usuario. El report 0019 prometía que "la clave privada nunca viaja al servidor"; eso solo será cierto cuando la cripto del bus ocurra en el navegador. Este issue lo hace cierto. `unibus_android` ya demuestra que el patrón es viable: es un cliente Kotlin que habla directo con NATS (:4250) y el control plane (:8470) y hace toda la cripto E2E en el dispositivo, sin gateway y sin compartir su clave privada. # Diseño objetivo ``` ┌─────────────────────────────────────────────┐ │ unibus (el bus) │ │ membershipd :8470 control-plane HTTP │ │ + CORS (nuevo) /register /session... │ │ nats-server (embebido) │ │ :4250 TCP (peers Go, android) │ │ :4250+ WS (navegador, nats.ws) (nuevo)│ └─────────────────────────────────────────────┘ ▲ TCP ▲ WebSocket │ │ ┌────────┴───────┐ ┌─────────┴──────────────┐ │ peers Go / │ │ uniweb (SPA, browser) │ │ android (Kt) │ │ web/src/bus/ (TS SDK)│ │ cripto nativa │ │ cripto en @noble │ └────────────────┘ │ priv NUNCA sale │ └────────────────────────┘ ``` `uniweb` final = solo `web/`. Sin `cmd/webgw`, sin `go.mod`. Se sirve como estáticos (cualquier static server; en dev, vite). La identidad del usuario se desbloquea y se usa **solo** en el navegador. # Fases ## Fase 0 — Preparar unibus (habilitar clientes browser) Cambios en `unibus` (aditivos, no rompen peers Go/android existentes): 1. **WebSocket en el nats-server embebido** (`pkg/embeddednats`): configurar `opts.Websocket` (puerto dedicado, `NoTLS` en loopback para dev; TLS en prod). Permite que `nats.ws` conecte desde el navegador. Flag/env para el puerto WS. 2. **CORS en el control plane** (`cmd/membershipd` / `pkg/membership`): añadir cabeceras `Access-Control-Allow-Origin/Methods/Headers` y manejar preflight `OPTIONS` en los endpoints que el browser llamará directo (`/register`, `/session` o equivalente, listado de rooms, invite, etc.). Configurable (allowlist de orígenes), no `*` en prod. 3. **Exportar vectores de test cripto**: un comando/test que vuelca vectores deterministas (derivación de identidad desde seed, envelope de frame seal/open, reparto sealed-box de la room key, firma por-mensaje) a JSON, para que los tests TS validen **paridad byte a byte** con la implementación Go de referencia. ## Fase 1 — SDK del bus en TypeScript (`web/src/bus/`) Port de la lógica que hoy vive en el gateway (`pkg/client` y dependencias). La cripto base ya está disponible (`@noble/curves`, `@noble/hashes`, `@noble/ciphers`); lo que se porta es el **protocolo**, no las primitivas. - `frame.ts` — port de `pkg/frame` (~100 LOC): estructura del frame, ULID ids, timestamp, threading (`ThreadID`, `ReplyTo`), tipos `MSG`/`REACT`. - `room.ts` — port de `pkg/room` (~42 LOC): `Policy`, tipos de clave de room, epochs. - `busauth.ts` — port de `pkg/busauth` (~331 LOC): autenticación nkey contra el control plane (nonce + firma Ed25519). Verificar cómo el browser obtiene/firma el nonce con la identidad del wallet (decisión: ¿la identidad del bus deriva del mismo seed BIP39?). - `client.ts` — port de `pkg/client` (~1368 LOC, el grueso): conexión `nats.ws`, `Join`, `Publish`/`PublishReply`/`React`, `Subscribe`, listado de rooms/users, `CreateRoom`, `Invite`, `Kick`; envelope E2E (ChaCha20-Poly1305 seal/open con la room key); reparto de la room key por sealed-box (X25519) a los invitados; rotación de epoch en LEAVE/KICK. ## Fase 2 — Cablear la SPA al SDK y eliminar el gateway - Reemplazar `web/src/api.ts` (hoy llama `/api/*` del gateway) por llamadas al SDK del bus + al control plane directo. - La identidad del wallet (`web/src/wallet/`) se usa **localmente** para firmar/abrir; la privada nunca se serializa hacia la red. - Borrar `cmd/webgw/`, `go.mod`, `go.sum`. `uniweb` queda como app de solo frontend. - Servir la SPA: static server trivial (sin lógica de bus). Documentar en `app.md`. - Actualizar `app.md`: `lang` deja de ser `go` puro (o se marca `framework: react`, sin `entry_point` Go), quitar `uses_functions` Go, ajustar `service`/`e2e_checks`. ## Fase 3 — Validación (DoD) - Tests TS de paridad cripto contra los vectores de la Fase 0. - E2E visual: join (invitación) / login (passphrase) / recover (mnemónica) + enviar y recibir mensajes cifrados **browser ↔ browser** y **browser ↔ peer Go**. - Auditoría de red (DevTools / proxy): confirmar que **ninguna petición transporta la clave privada**. # Riesgos / decisiones abiertas - **nkey auth del navegador**: el bus autentica peers con nkey (Ed25519). Decidir si la identidad nkey del bus deriva del mismo seed BIP39 del wallet o es separada. Afecta a `busauth.ts` y a cómo el control plane reconoce al usuario. - **WebSocket + TLS en prod**: el navegador exigirá `wss://` salvo loopback. Encaja con el issue de TLS del bus (unibus 0001-bus-auth-and-tls). - **CORS vs same-origin**: alternativa a CORS = servir la SPA detrás de un reverse proxy que comparta origen con el control plane. Decidir en Fase 0/2. - **Tamaño del bundle**: la cripto + nats.ws añaden peso; medir y, si hace falta, code-split. - **Paridad de protocolo**: cualquier divergencia en el envelope o el reparto de claves rompe la interoperabilidad con peers Go/android. Los vectores de la Fase 0 son el contrato. # Definition of Done (3 capas) ## Capa 1 — Mecánica - `uniweb` compila y se sirve **sin** `unibus` en disco y **sin** `go.mod` (es solo `web/`). - `pnpm build` verde; `unibus` build/vet/test verdes tras los cambios aditivos de Fase 0. ## Capa 2 — Cobertura de comportamiento | Escenario | Tipo | Evidencia | Esperado | |---|---|---|---| | Golden: browser ↔ peer Go intercambian mensaje cifrado | e2e | dos clientes en una room, mensaje round-trip | plaintext idéntico en ambos extremos | | Edge: recover en dispositivo nuevo re-deriva la misma identidad | e2e | pegar mnemónica → `sign_pub` reconstruido | igual al original (vector Go) | | Edge: rotación de epoch en KICK | e2e | KICK a un miembro → publicar | el kicked ya no descifra; los demás sí | | Error: passphrase incorrecta | unit | unlock con clave mala | `WrongPasswordError`, sin tocar la red | | Paridad: envelope seal/open TS vs Go | unit | vectores de Fase 0 | bytes idénticos | ## Capa 3 — Vida útil | Métrica | Umbral | Dónde | Ventana | |---|---|---|---| | Mensajes browser↔Go sin pérdida | 100% | uso real | 7 días | | Peticiones que filtran la priv | 0 | audit de red | continuo | | Errores de descifrado | 0 | consola/log SPA | 7 días | ## Anti-criterios (invalidan la DoD) - La clave privada aparece en CUALQUIER petición de red. - `uniweb` necesita `unibus` en disco o como módulo Go para compilar/servir. - Mensajes browser↔Go ilegibles por divergencia de protocolo. - Queda algún `cmd/webgw`/`go.mod` en `uniweb`. # Notas Onboarding: tras este issue, `uniweb` se desarrolla y despliega como cualquier SPA estática. No necesita el binario del bus para nada salvo apuntar `nats.ws` al puerto WebSocket del bus y el control plane HTTP a `membershipd`. El bus (`unibus`) y los demás clientes (`unibus_android`) no cambian de contrato: este issue solo añade el transporte WebSocket y CORS, ambos aditivos.