chore: auto-commit (2 archivos)

- dev/
- registry.db

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-14 11:27:04 +02:00
parent 2960b0984a
commit bf0884527e
2 changed files with 171 additions and 0 deletions
+171
View File
@@ -0,0 +1,171 @@
---
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.