Files
2026-06-14 19:40:12 +02:00

159 lines
9.3 KiB
Markdown

---
name: uniweb
lang: ts
domain: infra
version: 0.6.0
description: "Cliente web browser-nativo del bus unibus: SPA de chat (React+Mantine) con wallet por usuario (BIP39) que habla DIRECTO al bus (nats.ws + control-plane HTTPS firmado), sin gateway. La clave privada nunca sale del navegador."
tags: [messaging, web, frontend, e2e]
uses_functions: []
uses_types: []
framework: "react"
entry_point: "web/src/main.tsx"
dir_path: "projects/message_bus/apps/uniweb"
repo_url: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/dataforge/uniweb"
icon:
phosphor: "chats-circle"
accent: "#6366f1"
e2e_checks:
- id: typecheck
cmd: "cd web && pnpm install --frozen-lockfile && pnpm exec tsc --noEmit -p tsconfig.app.json"
timeout_s: 180
- id: unit
cmd: "cd web && pnpm test"
timeout_s: 120
- id: web_build
cmd: "cd web && pnpm build"
timeout_s: 180
---
## Qué es
`uniweb` es el **cliente web browser-nativo** del bus [unibus](../unibus/app.md): la interfaz
que un humano usa desde el navegador para hablar por el bus. Es **solo frontend** (`web/`) —
una SPA, sin backend Go, sin gateway. Habla **directamente** con el bus, igual que
`unibus_android` lo hace en Kotlin:
- **Control plane** — HTTPS firmado al `membershipd` (rooms, claves, miembros). Cada request
lleva la firma Ed25519 del usuario (cabeceras `X-Unibus-*`).
- **Data plane** — NATS sobre WebSocket (`nats.ws`), autenticado con el nkey derivado de la
identidad del usuario.
Stack: React 18 + Vite + Mantine v9. La identidad criptográfica de cada usuario se deriva de
forma determinista de una frase BIP39 de 12 palabras y se cifra at-rest en el dispositivo
(AES-256-GCM). **La clave privada nunca sale del navegador**: firma, sella y descifra en el
cliente. No hay servidor al que enviarla.
## El SDK del bus (`web/src/bus/`)
El protocolo y el cifrado E2E del bus están **portados a TypeScript**, validados byte a byte
contra la implementación Go de referencia (vectores de `unibus cmd/busvectors`):
- `crypto.ts` — Ed25519, ChaCha20-Poly1305, sealed box (nonce BLAKE2b, igual que Go).
- `frame.ts` — wire format = `encoding/json` de Go byte a byte.
- `room.ts` — Policy (ModeNATS / ModeMatrix).
- `busauth.ts` — nkey NATS (base32 + crc16) + firma de requests del control-plane.
- `client.ts` — envelope de room + `BusClient` + `ControlPlane` HTTP firmado.
- `wstransport.ts` — transporte `nats.ws`.
`busService.ts` es la capa de datos de la SPA sobre el SDK (reemplazó al viejo módulo `api`
que hablaba con el gateway). Ya **no depende de `unibus` como módulo Go**: el desacople es
total.
## Ejemplo
```bash
# Producción: SPA same-origin detrás de Caddy, que sirve la SPA + /api + /nats.
# Build estático y despliegue de web/dist:
cd web && pnpm install
pnpm build # genera web/dist (se despliega a magnus:/opt/uniweb/dist)
# Dev (`pnpm dev`): el dev server NO tiene el proxy de Caddy, así que /api y /nats no
# existen en localhost. Apunta la SPA a un nodo real del cluster con las env vars
# (overrides del default same-origin). El dev server corre en el puerto 5174:
VITE_BUS_HTTP=https://<nodo>:8470 VITE_BUS_WS=wss://<nodo>:8480 pnpm dev
# Navegador: http://localhost:5174
# (Añade http://localhost:5174 a la --cors-origins del nodo, o el control-plane
# rechazará la petición por CORS.)
```
## Cuándo usarla
Cuando quieras que un humano hable por el bus desde un navegador, o cuando trabajes en la UI
de chat / el onboarding wallet. Para la lógica del bus en sí (membresía, claves, peers
programáticos) ve a `unibus`; `uniweb` es el cliente web encima.
## Gotchas
- **`wss://` con CA self-signed**: el cluster sirve el WebSocket con el cert del bus (CA
propia). Un navegador rechaza `wss://` self-signed salvo que se importe la CA o se ponga un
reverse proxy con cert válido (Let's Encrypt). En dev se puede aceptar el cert a mano.
- **Onboarding admin-side**: el bus no tiene endpoint de auto-registro (el viejo gateway lo
*mockeaba*). En `enforce`, una identidad nueva debe ser autorizada por un admin
(`membershipd user add`) antes de poder abrir sesión; el flujo de Join muestra la clave
pública del usuario para que un admin la autorice.
- **CORS / same-origin**: en producción la SPA es same-origin detrás de Caddy (`/api` y
`/nats` proxyados), así que no hay CORS. En dev (`pnpm dev`, puerto 5174) esos paths
relativos no existen: hay que apuntar a un nodo con `VITE_BUS_HTTP`/`VITE_BUS_WS` y
añadir `http://localhost:5174` a la `--cors-origins` del nodo. El puerto 5173 está
reservado a otra app local; si 5174 está ocupado, Vite usa el siguiente libre.
- La passphrase del wallet nunca se guarda ni se envía; perderla en un dispositivo sin la
mnemónica BIP39 = identidad irrecuperable en ese dispositivo (recuperable en otro con las 12
palabras).
## Capability growth log
- v0.6.0 (2026-06-14) — carga el histórico de cada room (`GET /api/rooms/{id}/history`) al
abrirla, con dedup vs live; recargar ya no pierde los mensajes. `ControlPlane.fetchHistory`
pega al control-plane (firmado, mismas cabeceras `X-Unibus-*`) y decodifica cada frame de
base64-std; `BusClient.history` lo descifra/verifica con el MISMO camino de envelope que
`subscribe` (refactor: helper privado `openFrame` compartido por ambos). En `busService`,
`bus.subscribeRoom` (que usa `ChatPanel`) ahora siembra la room con su historia y sigue en
vivo: dedup por `frame.id` con un `Set` por room y los mensajes live se bufferean hasta que
la historia (oldest->newest) se entrega, garantizando el orden; si el endpoint falta
(404/500) cae a live-only como antes. El ts de cada mensaje se deriva del ULID `msgID`
(`ulidTime`, inverso de `newULID`) para que historia y live compartan reloj y ordenen bien;
`ChatPanel` ordena por ts. El sidebar siembra su preview con `history(id, 1)` (sin traer
todo), manteniendo el fallback "—" para rooms vacías. `tsc` + 23 unit (incluye `ulid.test.ts`)
+ `pnpm build` verdes.
- v0.5.0 (2026-06-14) — nombres legibles en mensajes + sidebar con último mensaje/hora
reales + `pnpm dev` documentado. (1) Los mensajes muestran el **handle** del remitente en
vez del endpoint id: `ControlPlane.fetchDirectory()` pega al control-plane
`GET /api/directory` (firmado) y `busService` mantiene un mapa `endpoint -> handle`
(cargado al abrir sesión, refrescado tras `createRoom`); el resolver
`bus.displayName(endpoint)` devuelve el handle o un id corto de fallback (nunca el
endpoint largo), usado en la cabecera y el avatar de `ChatPanel` (el endpoint queda en un
`title` para depurar). Resiliente: si el endpoint aún no existe en el cluster (404) el
mapa queda vacío y el chat funciona igual que antes. (2) El sidebar muestra el **último
mensaje y la hora reales**: `busService` posee un store de rooms con una suscripción de
metadatos por room (último mensaje/hora + unread de rooms no activas); `Sidebar` ya no
pinta el "01:00" de epoch-0. (3) `pnpm dev` queda usable tras el cambio a same-origin:
apunta a un nodo con `VITE_BUS_HTTP`/`VITE_BUS_WS` y el dev server corre en el puerto 5174
(documentado en `app.md` + `vite.config.ts`). `tsc` + 19/19 unit + `pnpm build` verdes.
- v0.3.0 (2026-06-14) — `uniweb` se vuelve **cliente browser-nativo puro** (issue 0001, Fase
2): la SPA se cablea al SDK del bus (`busService.ts` reemplaza el módulo `api`) y se
**elimina el gateway Go** (`cmd/webgw`, `go.mod`, `go.sum`). `uniweb` queda como solo `web/`,
sin nada de Go, sin dependencia de `unibus` como módulo. La clave privada se usa solo en el
navegador (`saveAndOpen`/`unlockAndOpen` abren la sesión localmente; ya NO se hace
`POST /api/session` con la privada — se cierra el agujero E2E del modelo gateway). Validado
end-to-end contra el cluster descentralizado real (Fase 3): identidad registrada conecta por
`nats.ws` y hace round-trip de un mensaje cifrado (crear room → publicar → recibir
descifrado + firma verificada). El onboarding por token queda admin-side (el bus no tiene
auto-registro). `tsc` + `pnpm build` + 19/19 unit verdes.
- v0.2.0 (2026-06-13) — SDK del bus en TypeScript (`web/src/bus/`), issue 0001 Fase 1:
el protocolo y el cifrado E2E del bus portados al navegador para que `uniweb` deje
de depender del gateway Go. Módulos: `crypto.ts` (Ed25519, ChaCha20-Poly1305,
sealed box con nonce BLAKE2b igual que Go), `frame.ts` (wire format = `encoding/json`
de Go byte a byte), `room.ts` (Policy), `busauth.ts` (nkey NATS + firma de requests
del control-plane), `client.ts` (envelope de room puro + `BusClient` sobre una
interfaz de transporte + cliente HTTP firmado) y `wstransport.ts` (adaptador
`nats.ws`). Paridad cross-language verificada contra vectores Go (`cmd/busvectors`):
**19/19 tests verdes** — endpoint id, firma Ed25519, AEAD, sealed box, frame
marshal/sign, nkey y canonical request. La clave privada del usuario nunca se
serializa hacia la red. La conexión `nats.ws` + control-plane reales se validan en
la Fase 3 (E2E) por requerir un unibus vivo con WebSocket.
- v0.1.0 (2026-06-13) — scaffold inicial: extracción de la SPA (`web/`) y el gateway
(`cmd/webgw`) desde `unibus` v0.13.0 a su propia app/sub-repo. Sin cambios de capacidad
respecto a lo que ya vivía en unibus 0.12.0 (wallet BIP39 + sesiones por usuario); solo
cambia la ubicación y el módulo Go. go build/vet/test + pnpm build verdes en la nueva
ubicación con los `replace` cross-repo.