Compare commits
22 Commits
e8e37d77fe
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 59705b5a4f | |||
| 63ebc1eed9 | |||
| 893df42d29 | |||
| c142b3a025 | |||
| 45d12e03aa | |||
| 3049265230 | |||
| 6c4baf1397 | |||
| 5fbf319172 | |||
| 5e9bf4e777 | |||
| 103a7f2f05 | |||
| 1dc8b6257a | |||
| f8b2bf8e9e | |||
| e8850d8965 | |||
| e12894099f | |||
| 3f52167b04 | |||
| bf0884527e | |||
| 2960b0984a | |||
| b44aa02326 | |||
| 024af306fe | |||
| b72976e06c | |||
| 3d9b4ce392 | |||
| cb6b51156a |
@@ -1,123 +1,156 @@
|
|||||||
---
|
---
|
||||||
name: uniweb
|
name: uniweb
|
||||||
lang: go
|
lang: ts
|
||||||
domain: infra
|
domain: infra
|
||||||
version: 0.1.0
|
version: 0.6.0
|
||||||
description: "Frontend web del bus unibus: SPA de chat (React+Mantine) con wallet por usuario (BIP39) + gateway Go (REST+SSE) que actúa de peer del bus para el navegador."
|
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: [service, messaging, web, frontend, e2e]
|
tags: [messaging, web, frontend, e2e]
|
||||||
uses_functions:
|
uses_functions: []
|
||||||
- generate_identity_go_cybersecurity
|
|
||||||
- seal_aead_go_cybersecurity
|
|
||||||
- open_aead_go_cybersecurity
|
|
||||||
- seal_key_box_go_cybersecurity
|
|
||||||
- open_key_box_go_cybersecurity
|
|
||||||
- sign_ed25519_go_cybersecurity
|
|
||||||
- verify_ed25519_go_cybersecurity
|
|
||||||
uses_types: []
|
uses_types: []
|
||||||
framework: "react"
|
framework: "react"
|
||||||
entry_point: "cmd/webgw"
|
entry_point: "web/src/main.tsx"
|
||||||
dir_path: "projects/message_bus/apps/uniweb"
|
dir_path: "projects/message_bus/apps/uniweb"
|
||||||
repo_url: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/dataforge/uniweb"
|
repo_url: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/dataforge/uniweb"
|
||||||
icon:
|
icon:
|
||||||
phosphor: "chats-circle"
|
phosphor: "chats-circle"
|
||||||
accent: "#6366f1"
|
accent: "#6366f1"
|
||||||
service:
|
|
||||||
port: 8481
|
|
||||||
health_endpoint: null
|
|
||||||
health_timeout_s: 3
|
|
||||||
systemd_unit: null
|
|
||||||
systemd_scope: null
|
|
||||||
restart_policy: always
|
|
||||||
runtime: manual
|
|
||||||
pc_targets:
|
|
||||||
- lucas-linux
|
|
||||||
is_local_only: false
|
|
||||||
e2e_checks:
|
e2e_checks:
|
||||||
- id: build
|
- id: typecheck
|
||||||
cmd: "CGO_ENABLED=0 go build ./..."
|
cmd: "cd web && pnpm install --frozen-lockfile && pnpm exec tsc --noEmit -p tsconfig.app.json"
|
||||||
timeout_s: 180
|
timeout_s: 180
|
||||||
- id: vet
|
|
||||||
cmd: "CGO_ENABLED=0 go vet ./..."
|
|
||||||
timeout_s: 120
|
|
||||||
- id: unit
|
- id: unit
|
||||||
cmd: "CGO_ENABLED=0 go test ./..."
|
cmd: "cd web && pnpm test"
|
||||||
timeout_s: 120
|
timeout_s: 120
|
||||||
- id: web_build
|
- id: web_build
|
||||||
cmd: "cd web && pnpm install --frozen-lockfile && pnpm build"
|
cmd: "cd web && pnpm build"
|
||||||
timeout_s: 180
|
timeout_s: 180
|
||||||
---
|
---
|
||||||
|
|
||||||
## Qué es
|
## Qué es
|
||||||
|
|
||||||
`uniweb` es el frontend web del bus [unibus](../unibus/app.md): la interfaz que un humano
|
`uniweb` es el **cliente web browser-nativo** del bus [unibus](../unibus/app.md): la interfaz
|
||||||
usa desde el navegador para hablar por el bus. Se separó de `unibus` (v0.13.0) para que el
|
que un humano usa desde el navegador para hablar por el bus. Es **solo frontend** (`web/`) —
|
||||||
plano del bus (membresía, claves, librería cliente) quede limpio y el frontend tenga su
|
una SPA, sin backend Go, sin gateway. Habla **directamente** con el bus, igual que
|
||||||
propia carpeta de servicio y su propio ciclo de release.
|
`unibus_android` lo hace en Kotlin:
|
||||||
|
|
||||||
Tiene dos mitades que viven juntas:
|
- **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.
|
||||||
|
|
||||||
- **SPA (`web/`)** — React 18 + Vite + Mantine v9. Pantallas de chat y onboarding wallet
|
Stack: React 18 + Vite + Mantine v9. La identidad criptográfica de cada usuario se deriva de
|
||||||
(join por invitación, login por passphrase local, recover por mnemónica). La identidad
|
forma determinista de una frase BIP39 de 12 palabras y se cifra at-rest en el dispositivo
|
||||||
criptográfica de cada usuario se deriva de forma determinista de una frase BIP39 de 12
|
(AES-256-GCM). **La clave privada nunca sale del navegador**: firma, sella y descifra en el
|
||||||
palabras y se cifra at-rest en el dispositivo (AES-256-GCM); la clave privada nunca viaja
|
cliente. No hay servidor al que enviarla.
|
||||||
al servidor en claro.
|
|
||||||
- **Gateway (`cmd/webgw`)** — binario Go (`package main`, REST + SSE) que actúa como peer
|
|
||||||
del bus en nombre del navegador. Mantiene una sesión wallet por usuario, registra claves
|
|
||||||
públicas por token de invitación, y traduce HTTP/SSE ↔ el protocolo del bus usando la
|
|
||||||
librería cliente de unibus.
|
|
||||||
|
|
||||||
## Cómo se acopla a unibus
|
## El SDK del bus (`web/src/bus/`)
|
||||||
|
|
||||||
`uniweb` consume `unibus` como **módulo Go**, no reimplementa nada del 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).
|
||||||
replace github.com/enmanuel/unibus => ../unibus # pkg/{busauth,client,frame,room}
|
- `frame.ts` — wire format = `encoding/json` de Go byte a byte.
|
||||||
replace fn-registry => ../../../../ # functions/cybersecurity
|
- `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`.
|
||||||
|
|
||||||
Los `replace` no son transitivos en Go, así que `uniweb` (módulo principal) declara los dos:
|
`busService.ts` es la capa de datos de la SPA sobre el SDK (reemplazó al viejo módulo `api`
|
||||||
el de `unibus` (de donde importa la librería cliente) y el de `fn-registry` (de donde
|
que hablaba con el gateway). Ya **no depende de `unibus` como módulo Go**: el desacople es
|
||||||
`pkg/client` toma las primitivas de cifrado). Compila con `CGO_ENABLED=0` igual que unibus.
|
total.
|
||||||
|
|
||||||
## Ejemplo
|
## Ejemplo
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. Backend: el control-plane del bus (en la carpeta de unibus)
|
# Producción: SPA same-origin detrás de Caddy, que sirve la SPA + /api + /nats.
|
||||||
cd ../unibus && CGO_ENABLED=0 go run ./cmd/membershipd # :8470
|
# Build estático y despliegue de web/dist:
|
||||||
|
cd web && pnpm install
|
||||||
|
pnpm build # genera web/dist (se despliega a magnus:/opt/uniweb/dist)
|
||||||
|
|
||||||
# 2. Build de la SPA
|
# Dev (`pnpm dev`): el dev server NO tiene el proxy de Caddy, así que /api y /nats no
|
||||||
cd web && pnpm install && pnpm build # genera web/dist
|
# 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:
|
||||||
# 3. Gateway sirviendo la SPA + API contra el control-plane
|
VITE_BUS_HTTP=https://<nodo>:8470 VITE_BUS_WS=wss://<nodo>:8480 pnpm dev
|
||||||
cd .. && CGO_ENABLED=0 go run ./cmd/webgw \
|
# Navegador: http://localhost:5174
|
||||||
--port 8481 --ctrl-url http://127.0.0.1:8470 --web-dir web/dist
|
# (Añade http://localhost:5174 a la --cors-origins del nodo, o el control-plane
|
||||||
# Navegador: http://127.0.0.1:8481
|
# rechazará la petición por CORS.)
|
||||||
|
|
||||||
# Desarrollo de la SPA con hot-reload (gateway en modo API-only, sin --web-dir):
|
|
||||||
cd web && pnpm dev # vite proxya /api + /stream al gateway
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Cuándo usarla
|
## Cuándo usarla
|
||||||
|
|
||||||
Cuando quieras que un humano hable por el bus desde un navegador, o cuando trabajes en la UI
|
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
|
de chat / el onboarding wallet. Para la lógica del bus en sí (membresía, claves, peers
|
||||||
programáticos) ve a `unibus`; `uniweb` solo es la capa web encima.
|
programáticos) ve a `unibus`; `uniweb` es el cliente web encima.
|
||||||
|
|
||||||
## Gotchas
|
## Gotchas
|
||||||
|
|
||||||
- El gateway necesita el control-plane de unibus vivo (`--ctrl-url`, por defecto
|
- **`wss://` con CA self-signed**: el cluster sirve el WebSocket con el cert del bus (CA
|
||||||
`http://127.0.0.1:8470`); si no, las sesiones fallan al abrir el peer.
|
propia). Un navegador rechaza `wss://` self-signed salvo que se importe la CA o se ponga un
|
||||||
- `--web-dir` es **opcional**: vacío = API-only (úsalo con el dev server de vite); apuntando a
|
reverse proxy con cert válido (Let's Encrypt). En dev se puede aceptar el cert a mano.
|
||||||
`web/dist` = sirve la SPA buildeada. Un path inválido degrada a API-only con un WARN, no
|
- **Onboarding admin-side**: el bus no tiene endpoint de auto-registro (el viejo gateway lo
|
||||||
peta.
|
*mockeaba*). En `enforce`, una identidad nueva debe ser autorizada por un admin
|
||||||
- Build cross-repo: `uniweb` no compila si `../unibus` no está presente en disco (el `replace`
|
(`membershipd user add`) antes de poder abrir sesión; el flujo de Join muestra la clave
|
||||||
es local). Para deploy hay que llevar ambos repos, o vendorizar unibus.
|
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
|
- 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
|
mnemónica BIP39 = identidad irrecuperable en ese dispositivo (recuperable en otro con las 12
|
||||||
palabras).
|
palabras).
|
||||||
|
|
||||||
## Capability growth log
|
## 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
|
- 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
|
(`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
|
respecto a lo que ya vivía en unibus 0.12.0 (wallet BIP39 + sesiones por usuario); solo
|
||||||
|
|||||||
@@ -1,246 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/hex"
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
|
|
||||||
cs "fn-registry/functions/cybersecurity"
|
|
||||||
|
|
||||||
"github.com/enmanuel/unibus/pkg/busauth"
|
|
||||||
"github.com/enmanuel/unibus/pkg/client"
|
|
||||||
"github.com/enmanuel/unibus/pkg/frame"
|
|
||||||
"github.com/enmanuel/unibus/pkg/room"
|
|
||||||
)
|
|
||||||
|
|
||||||
// gateway is the live web gateway: it owns the operator's identity and a single
|
|
||||||
// connected unibus client, and turns the bus's crypto-bearing API into the plain
|
|
||||||
// REST/SSE surface the browser consumes. The browser never signs, never speaks
|
|
||||||
// NATS, and never sees a private key — the gateway is the legitimate room member
|
|
||||||
// that seals/opens payloads on the browser's behalf.
|
|
||||||
//
|
|
||||||
// TRUST MODEL: content stays end-to-end encrypted on the wire. The gateway can
|
|
||||||
// read plaintext because it acts AS the operator's client — a real member of
|
|
||||||
// each room, holding the room key K like any peer. It is the same trust a native
|
|
||||||
// desktop client has. In the wallet phase (per-browser WebCrypto identity) the
|
|
||||||
// decryption can move into the browser; today, for the single-operator MVP, the
|
|
||||||
// gateway decrypts server-side and pushes cleartext over a loopback/authenticated
|
|
||||||
// SSE channel.
|
|
||||||
type gateway struct {
|
|
||||||
id cs.Identity
|
|
||||||
endpoint string
|
|
||||||
cli *client.Client
|
|
||||||
refreshACL bool // call RefreshSession after a membership change (needed under a per-subject ACL bus)
|
|
||||||
|
|
||||||
mu sync.Mutex
|
|
||||||
hubs map[string]*roomHub // roomID -> live fan-out of decrypted frames to SSE clients
|
|
||||||
}
|
|
||||||
|
|
||||||
// gatewayConfig wires a live gateway.
|
|
||||||
type gatewayConfig struct {
|
|
||||||
Identity cs.Identity
|
|
||||||
NatsURL string
|
|
||||||
CtrlURL string
|
|
||||||
CtrlURLs []string
|
|
||||||
NatsURLs []string
|
|
||||||
CAPath string // bus CA; empty => plaintext dev connection (matches a loopback membershipd)
|
|
||||||
}
|
|
||||||
|
|
||||||
// newGateway connects the unibus client with the operator identity following the
|
|
||||||
// same posture seam every peer uses: a non-empty CA path means TLS + nkey, empty
|
|
||||||
// means plaintext dev. When a CA is configured the bus is assumed to enforce a
|
|
||||||
// per-subject ACL, so membership changes trigger a session refresh.
|
|
||||||
func newGateway(cfg gatewayConfig) (*gateway, error) {
|
|
||||||
opts := client.Options{
|
|
||||||
CtrlURLs: cfg.CtrlURLs,
|
|
||||||
NatsServers: cfg.NatsURLs,
|
|
||||||
}
|
|
||||||
if cfg.CAPath != "" {
|
|
||||||
tlsCfg, err := busauth.LoadCATLSConfig(cfg.CAPath)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("webgw: load bus CA %q: %w", cfg.CAPath, err)
|
|
||||||
}
|
|
||||||
opts.UseNkey = true
|
|
||||||
opts.TLS = tlsCfg
|
|
||||||
opts.CtrlTLS = tlsCfg
|
|
||||||
}
|
|
||||||
cli, err := client.NewWithOptions(cfg.NatsURL, cfg.CtrlURL, cfg.Identity, opts)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("webgw: connect bus client: %w", err)
|
|
||||||
}
|
|
||||||
return &gateway{
|
|
||||||
id: cfg.Identity,
|
|
||||||
endpoint: frame.EndpointID(cfg.Identity.SignPub),
|
|
||||||
cli: cli,
|
|
||||||
refreshACL: cfg.CAPath != "",
|
|
||||||
hubs: map[string]*roomHub{},
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close stops every hub and releases the bus client connection.
|
|
||||||
func (g *gateway) Close() error {
|
|
||||||
g.mu.Lock()
|
|
||||||
for _, h := range g.hubs {
|
|
||||||
h.stop()
|
|
||||||
}
|
|
||||||
g.hubs = map[string]*roomHub{}
|
|
||||||
g.mu.Unlock()
|
|
||||||
if g.cli != nil {
|
|
||||||
return g.cli.Close()
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- wire types (browser-facing JSON) ------------------------------------
|
|
||||||
|
|
||||||
// meInfo is what GET /api/me returns: the operator identity the gateway acts as.
|
|
||||||
type meInfo struct {
|
|
||||||
Endpoint string `json:"endpoint"`
|
|
||||||
SignPub string `json:"sign_pub"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// roomWire is the browser view of a room. It deliberately omits messages: those
|
|
||||||
// stream over SSE (GET /api/rooms/{id}/stream), not in the room list.
|
|
||||||
type roomWire struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
Subject string `json:"subject"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
Epoch int `json:"epoch"`
|
|
||||||
Encrypt bool `json:"encrypt"`
|
|
||||||
Persist bool `json:"persist"`
|
|
||||||
SignMsgs bool `json:"sign_msgs"`
|
|
||||||
Role string `json:"role"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// createRoomReq is the POST /api/rooms body. Encrypt/Persist/SignMsgs are
|
|
||||||
// pointers so an omitted field falls back to the chat default rather than to the
|
|
||||||
// Go zero value (false). The common case — the browser sending only {subject,
|
|
||||||
// encrypted} — maps encrypted onto all three (the Matrix-like chat policy).
|
|
||||||
type createRoomReq struct {
|
|
||||||
Subject string `json:"subject"`
|
|
||||||
Encrypted *bool `json:"encrypted,omitempty"`
|
|
||||||
Encrypt *bool `json:"encrypt,omitempty"`
|
|
||||||
Persist *bool `json:"persist,omitempty"`
|
|
||||||
SignMsgs *bool `json:"sign_msgs,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// policy resolves the requested policy. A bare {subject} defaults to the
|
|
||||||
// Matrix-like chat room (encrypted + persisted + signed) so a created room keeps
|
|
||||||
// durable, end-to-end-encrypted, authored history. Callers can override any leg.
|
|
||||||
func (r createRoomReq) policy() room.Policy {
|
|
||||||
enc, per, sig := true, true, true
|
|
||||||
if r.Encrypted != nil {
|
|
||||||
enc, per, sig = *r.Encrypted, *r.Encrypted, *r.Encrypted
|
|
||||||
}
|
|
||||||
if r.Encrypt != nil {
|
|
||||||
enc = *r.Encrypt
|
|
||||||
}
|
|
||||||
if r.Persist != nil {
|
|
||||||
per = *r.Persist
|
|
||||||
}
|
|
||||||
if r.SignMsgs != nil {
|
|
||||||
sig = *r.SignMsgs
|
|
||||||
}
|
|
||||||
return room.Policy{Encrypt: enc, Persist: per, SignMsgs: sig}
|
|
||||||
}
|
|
||||||
|
|
||||||
// sendReq is the POST /api/rooms/{id}/send body.
|
|
||||||
type sendReq struct {
|
|
||||||
Body string `json:"body"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// msgWire is one decrypted message pushed over SSE.
|
|
||||||
type msgWire struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
Sender string `json:"sender"`
|
|
||||||
Body string `json:"body"`
|
|
||||||
TS int64 `json:"ts"` // epoch ms (decoded from the frame's ULID id)
|
|
||||||
Mine bool `json:"mine"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- operations -----------------------------------------------------------
|
|
||||||
|
|
||||||
func (g *gateway) me() meInfo {
|
|
||||||
return meInfo{Endpoint: g.endpoint, SignPub: hex.EncodeToString(g.id.SignPub)}
|
|
||||||
}
|
|
||||||
|
|
||||||
// subjectName derives a short, human-friendly room name from its bus subject by
|
|
||||||
// dropping the leading namespace segment (room., test., proc., agent.). It is a
|
|
||||||
// display nicety only; the canonical identity stays the subject/room id.
|
|
||||||
func subjectName(subject string) string {
|
|
||||||
for _, p := range []string{"room.", "test.", "proc.", "agent.", "rpc."} {
|
|
||||||
if strings.HasPrefix(subject, p) {
|
|
||||||
return strings.TrimPrefix(subject, p)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return subject
|
|
||||||
}
|
|
||||||
|
|
||||||
func (g *gateway) listRooms() ([]roomWire, error) {
|
|
||||||
rooms, err := g.cli.ListMyRooms()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
out := make([]roomWire, 0, len(rooms))
|
|
||||||
for _, rm := range rooms {
|
|
||||||
out = append(out, roomWire{
|
|
||||||
ID: rm.RoomID,
|
|
||||||
Subject: rm.Subject,
|
|
||||||
Name: subjectName(rm.Subject),
|
|
||||||
Epoch: rm.Epoch,
|
|
||||||
Encrypt: rm.Policy.Encrypt,
|
|
||||||
Persist: rm.Policy.Persist,
|
|
||||||
SignMsgs: rm.Policy.SignMsgs,
|
|
||||||
Role: rm.Role,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (g *gateway) createRoom(req createRoomReq) (roomWire, error) {
|
|
||||||
subject := strings.TrimSpace(req.Subject)
|
|
||||||
if subject == "" {
|
|
||||||
return roomWire{}, fmt.Errorf("webgw: subject required")
|
|
||||||
}
|
|
||||||
p := req.policy()
|
|
||||||
roomID, err := g.cli.CreateRoom(subject, p)
|
|
||||||
if err != nil {
|
|
||||||
return roomWire{}, err
|
|
||||||
}
|
|
||||||
// Under a per-subject ACL the operator's frozen NATS permissions do not yet
|
|
||||||
// cover the new room's subject; refresh so subsequent data-plane use works. On
|
|
||||||
// a plaintext/non-ACL dev bus this is unnecessary and would needlessly drop any
|
|
||||||
// live SSE subscriptions, so it is gated on the secured posture.
|
|
||||||
if g.refreshACL {
|
|
||||||
_ = g.cli.RefreshSession()
|
|
||||||
}
|
|
||||||
return roomWire{
|
|
||||||
ID: roomID,
|
|
||||||
Subject: subject,
|
|
||||||
Name: subjectName(subject),
|
|
||||||
Epoch: 1,
|
|
||||||
Encrypt: p.Encrypt,
|
|
||||||
Persist: p.Persist,
|
|
||||||
SignMsgs: p.SignMsgs,
|
|
||||||
Role: "owner",
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// join resolves room metadata and (for encrypted rooms) fetches the room key so
|
|
||||||
// the gateway can later open payloads. Idempotent.
|
|
||||||
func (g *gateway) join(roomID string) error {
|
|
||||||
if err := g.cli.Join(roomID); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if g.refreshACL {
|
|
||||||
_ = g.cli.RefreshSession()
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// send publishes plaintext to a room. The unibus client seals it with the room
|
|
||||||
// key (encrypted rooms) and signs it (signed rooms) before it leaves the process.
|
|
||||||
func (g *gateway) send(roomID, body string) error {
|
|
||||||
return g.cli.Publish(roomID, []byte(body))
|
|
||||||
}
|
|
||||||
@@ -1,140 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"sync"
|
|
||||||
|
|
||||||
"github.com/enmanuel/unibus/pkg/client"
|
|
||||||
"github.com/enmanuel/unibus/pkg/frame"
|
|
||||||
"github.com/oklog/ulid/v2"
|
|
||||||
)
|
|
||||||
|
|
||||||
// roomHub multiplexes ONE unibus room subscription to MANY SSE clients. The
|
|
||||||
// unibus client derives a per-(room, endpoint) durable consumer name, so a
|
|
||||||
// second Subscribe for the same room from the same operator would contend for
|
|
||||||
// the same durable (load-balanced delivery) rather than each browser receiving
|
|
||||||
// every message. The hub holds a single subscription per room and fans each
|
|
||||||
// decrypted frame out to every connected browser, which also means the gateway
|
|
||||||
// opens at most one bus subscription per room regardless of how many tabs watch
|
|
||||||
// it.
|
|
||||||
type roomHub struct {
|
|
||||||
roomID string
|
|
||||||
myEndpoint string
|
|
||||||
sub *client.Sub
|
|
||||||
|
|
||||||
mu sync.Mutex
|
|
||||||
clients map[chan msgWire]struct{}
|
|
||||||
}
|
|
||||||
|
|
||||||
// frameTS decodes the millisecond timestamp embedded in a frame's ULID id. A
|
|
||||||
// malformed id (should not happen for bus-produced frames) yields 0, which the
|
|
||||||
// browser renders without crashing.
|
|
||||||
func frameTS(msgID string) int64 {
|
|
||||||
id, err := ulid.Parse(msgID)
|
|
||||||
if err != nil {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
return int64(id.Time())
|
|
||||||
}
|
|
||||||
|
|
||||||
// newRoomHub opens the single bus subscription for roomID and starts fanning
|
|
||||||
// decrypted frames out to registered clients. The room must already be joined
|
|
||||||
// (so the gateway holds the room key) before this is called.
|
|
||||||
func newRoomHub(cli *client.Client, roomID, myEndpoint string) (*roomHub, error) {
|
|
||||||
h := &roomHub{
|
|
||||||
roomID: roomID,
|
|
||||||
myEndpoint: myEndpoint,
|
|
||||||
clients: map[chan msgWire]struct{}{},
|
|
||||||
}
|
|
||||||
sub, err := cli.Subscribe(roomID, func(f frame.Frame, plaintext []byte) {
|
|
||||||
m := msgWire{
|
|
||||||
ID: f.MsgID,
|
|
||||||
Sender: f.Sender,
|
|
||||||
Body: string(plaintext),
|
|
||||||
TS: frameTS(f.MsgID),
|
|
||||||
Mine: f.Sender == myEndpoint,
|
|
||||||
}
|
|
||||||
h.broadcast(m)
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
h.sub = sub
|
|
||||||
return h, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// broadcast delivers a message to every registered client without blocking the
|
|
||||||
// NATS delivery goroutine: a client whose buffer is full (a stalled browser)
|
|
||||||
// drops this frame rather than stalling the whole room.
|
|
||||||
func (h *roomHub) broadcast(m msgWire) {
|
|
||||||
h.mu.Lock()
|
|
||||||
defer h.mu.Unlock()
|
|
||||||
for ch := range h.clients {
|
|
||||||
select {
|
|
||||||
case ch <- m:
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// add registers a new SSE client channel.
|
|
||||||
func (h *roomHub) add(ch chan msgWire) {
|
|
||||||
h.mu.Lock()
|
|
||||||
defer h.mu.Unlock()
|
|
||||||
h.clients[ch] = struct{}{}
|
|
||||||
}
|
|
||||||
|
|
||||||
// stop unsubscribes from the bus. Local delivery ends; for a persisted room the
|
|
||||||
// durable consumer's ack position stays on the server, so a later subscription
|
|
||||||
// with the same operator resumes from where it left off.
|
|
||||||
func (h *roomHub) stop() {
|
|
||||||
if h.sub != nil {
|
|
||||||
_ = h.sub.Unsubscribe()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// openStream joins the room (idempotent; fetches the room key for encrypted
|
|
||||||
// rooms), attaches an SSE client to the room's hub (creating it on first watcher),
|
|
||||||
// and returns the client's message channel plus a cleanup func. The cleanup
|
|
||||||
// detaches the client and, when it was the last watcher, tears down the room's
|
|
||||||
// single bus subscription.
|
|
||||||
func (g *gateway) openStream(roomID string) (chan msgWire, func(), error) {
|
|
||||||
if err := g.join(roomID); err != nil {
|
|
||||||
return nil, nil, err
|
|
||||||
}
|
|
||||||
g.mu.Lock()
|
|
||||||
h := g.hubs[roomID]
|
|
||||||
if h == nil {
|
|
||||||
var err error
|
|
||||||
h, err = newRoomHub(g.cli, roomID, g.endpoint)
|
|
||||||
if err != nil {
|
|
||||||
g.mu.Unlock()
|
|
||||||
return nil, nil, err
|
|
||||||
}
|
|
||||||
g.hubs[roomID] = h
|
|
||||||
}
|
|
||||||
g.mu.Unlock()
|
|
||||||
|
|
||||||
// Buffer so a brief render hitch in the browser does not drop live frames; a
|
|
||||||
// sustained stall still drops (broadcast is non-blocking) rather than wedging
|
|
||||||
// the room.
|
|
||||||
ch := make(chan msgWire, 64)
|
|
||||||
h.add(ch)
|
|
||||||
|
|
||||||
// cleanup takes g.mu before h.mu (the single, consistent lock order) so a
|
|
||||||
// concurrent openStream that re-creates the hub cannot race the teardown.
|
|
||||||
cleanup := func() {
|
|
||||||
g.mu.Lock()
|
|
||||||
defer g.mu.Unlock()
|
|
||||||
h.mu.Lock()
|
|
||||||
delete(h.clients, ch)
|
|
||||||
empty := len(h.clients) == 0
|
|
||||||
h.mu.Unlock()
|
|
||||||
if empty {
|
|
||||||
if cur := g.hubs[roomID]; cur == h {
|
|
||||||
delete(g.hubs, roomID)
|
|
||||||
h.stop()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ch, cleanup, nil
|
|
||||||
}
|
|
||||||
@@ -1,98 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/base64"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"os/exec"
|
|
||||||
|
|
||||||
cs "fn-registry/functions/cybersecurity"
|
|
||||||
)
|
|
||||||
|
|
||||||
// identityJSON mirrors the on-disk / pass-stored identity format shared across
|
|
||||||
// the unibus tooling: the four keypair halves, each std-base64. It is the SAME
|
|
||||||
// shape the bus client persists (pkg/client identity file) and the operator's
|
|
||||||
// `pass` entry unibus/operator-identity, so the web gateway loads the operator's
|
|
||||||
// identity without a divergent serialization. Kept in lockstep with
|
|
||||||
// unibus_admin/internal/admin/identity.go.
|
|
||||||
type identityJSON struct {
|
|
||||||
SignPub string `json:"sign_pub"`
|
|
||||||
SignPriv string `json:"sign_priv"`
|
|
||||||
KexPub string `json:"kex_pub"`
|
|
||||||
KexPriv string `json:"kex_priv"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// decodeIdentity turns the JSON identity bytes into a cs.Identity. The private
|
|
||||||
// halves stay only in memory; this never writes them anywhere.
|
|
||||||
func decodeIdentity(raw []byte) (cs.Identity, error) {
|
|
||||||
var f identityJSON
|
|
||||||
if err := json.Unmarshal(raw, &f); err != nil {
|
|
||||||
return cs.Identity{}, fmt.Errorf("webgw: parse identity json: %w", err)
|
|
||||||
}
|
|
||||||
dec := base64.StdEncoding.DecodeString
|
|
||||||
signPub, err := dec(f.SignPub)
|
|
||||||
if err != nil {
|
|
||||||
return cs.Identity{}, fmt.Errorf("webgw: decode sign_pub: %w", err)
|
|
||||||
}
|
|
||||||
signPriv, err := dec(f.SignPriv)
|
|
||||||
if err != nil {
|
|
||||||
return cs.Identity{}, fmt.Errorf("webgw: decode sign_priv: %w", err)
|
|
||||||
}
|
|
||||||
kexPub, err := dec(f.KexPub)
|
|
||||||
if err != nil {
|
|
||||||
return cs.Identity{}, fmt.Errorf("webgw: decode kex_pub: %w", err)
|
|
||||||
}
|
|
||||||
kexPriv, err := dec(f.KexPriv)
|
|
||||||
if err != nil {
|
|
||||||
return cs.Identity{}, fmt.Errorf("webgw: decode kex_priv: %w", err)
|
|
||||||
}
|
|
||||||
if len(signPub) != 32 || len(signPriv) != 64 || len(kexPub) != 32 || len(kexPriv) != 32 {
|
|
||||||
return cs.Identity{}, fmt.Errorf("webgw: identity has wrong key sizes (sign_pub=%d sign_priv=%d kex_pub=%d kex_priv=%d)",
|
|
||||||
len(signPub), len(signPriv), len(kexPub), len(kexPriv))
|
|
||||||
}
|
|
||||||
return cs.Identity{SignPub: signPub, SignPriv: signPriv, KexPub: kexPub, KexPriv: kexPriv}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// loadIdentityFromFile reads a 0600 identity JSON file (the same format the bus
|
|
||||||
// client writes) and decodes it. Used on a deploy host where `pass` is not
|
|
||||||
// available and the operator identity is delivered as a protected file.
|
|
||||||
func loadIdentityFromFile(path string) (cs.Identity, error) {
|
|
||||||
raw, err := os.ReadFile(path)
|
|
||||||
if err != nil {
|
|
||||||
return cs.Identity{}, fmt.Errorf("webgw: read identity file %q: %w", path, err)
|
|
||||||
}
|
|
||||||
return decodeIdentity(raw)
|
|
||||||
}
|
|
||||||
|
|
||||||
// loadIdentityFromPass shells out to `pass show <entry>` and decodes the JSON
|
|
||||||
// identity it returns. The secret is held only in memory; this process never
|
|
||||||
// writes it to disk or argv. Used in local operator workflows where the GNU
|
|
||||||
// password store holds unibus/operator-identity.
|
|
||||||
func loadIdentityFromPass(entry string) (cs.Identity, error) {
|
|
||||||
out, err := exec.Command("pass", "show", entry).Output()
|
|
||||||
if err != nil {
|
|
||||||
return cs.Identity{}, fmt.Errorf("webgw: pass show %q: %w", entry, err)
|
|
||||||
}
|
|
||||||
return decodeIdentity(out)
|
|
||||||
}
|
|
||||||
|
|
||||||
// loadPassValue returns the first line of a `pass show <entry>` for non-identity
|
|
||||||
// secrets (e.g. the unlock passphrase). Empty entry yields an empty string and
|
|
||||||
// no error, so callers can treat "no pass entry configured" as "not set".
|
|
||||||
func loadPassValue(entry string) (string, error) {
|
|
||||||
if entry == "" {
|
|
||||||
return "", nil
|
|
||||||
}
|
|
||||||
out, err := exec.Command("pass", "show", entry).Output()
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("webgw: pass show %q: %w", entry, err)
|
|
||||||
}
|
|
||||||
s := string(out)
|
|
||||||
for i := 0; i < len(s); i++ {
|
|
||||||
if s[i] == '\n' || s[i] == '\r' {
|
|
||||||
return s[:i], nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return s, nil
|
|
||||||
}
|
|
||||||
@@ -1,199 +0,0 @@
|
|||||||
// Command webgw is the web gateway for the unibus chat SPA. It is a single Go
|
|
||||||
// binary that holds the operator's bus identity, connects to the bus as a real
|
|
||||||
// authenticated peer (pkg/client), and exposes a small REST + SSE API the
|
|
||||||
// browser consumes. The browser never signs, never speaks NATS, and never sees a
|
|
||||||
// private key: it authenticates to the gateway with a passphrase and thereafter
|
|
||||||
// holds only an opaque session cookie.
|
|
||||||
//
|
|
||||||
// TRUST MODEL (MVP, single operator): room content stays end-to-end encrypted on
|
|
||||||
// the bus. The gateway can read plaintext because it acts AS the operator's
|
|
||||||
// client — a legitimate member of each room holding the room key. Decryption
|
|
||||||
// happens server-side in this process; cleartext then crosses an authenticated
|
|
||||||
// (loopback or TLS-fronted) SSE channel to the browser. The wallet phase (issue:
|
|
||||||
// per-browser WebCrypto identity) can move decryption into the browser; see the
|
|
||||||
// report for the FASE 2 plan.
|
|
||||||
//
|
|
||||||
// # local dev against a loopback membershipd (plaintext), operator from pass:
|
|
||||||
// webgw --identity-pass unibus/operator-identity \
|
|
||||||
// --ctrl-url http://127.0.0.1:8470 --nats-url nats://127.0.0.1:4250
|
|
||||||
//
|
|
||||||
// # secured cluster (TLS + nkey on both planes), identity from a 0600 file:
|
|
||||||
// webgw --ca ca.crt --identity-file operator.id \
|
|
||||||
// --ctrl-url https://node-a:8470 --nats-url nats://node-a:4250 \
|
|
||||||
// --ctrl-urls https://node-b:8470,https://node-c:8470 \
|
|
||||||
// --nats-urls nats://node-b:4250,nats://node-c:4250
|
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"flag"
|
|
||||||
"log"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
"os/signal"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"syscall"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
cs "fn-registry/functions/cybersecurity"
|
|
||||||
)
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
var (
|
|
||||||
bind = flag.String("bind", "127.0.0.1", "interface to bind the gateway HTTP server to (loopback by default)")
|
|
||||||
port = flag.String("port", "8481", "gateway HTTP port")
|
|
||||||
ctrlURL = flag.String("ctrl-url", "http://127.0.0.1:8470", "primary unibus control-plane base URL")
|
|
||||||
ctrlURLs = flag.String("ctrl-urls", "", "comma-separated ADDITIONAL control-plane base URLs (cluster failover)")
|
|
||||||
natsURL = flag.String("nats-url", "nats://127.0.0.1:4250", "primary NATS URL")
|
|
||||||
natsURLs = flag.String("nats-urls", "", "comma-separated ADDITIONAL NATS seed URLs (cluster failover)")
|
|
||||||
caPath = flag.String("ca", "", "bus CA cert path; set to talk TLS+nkey to a secured bus (empty = plaintext dev)")
|
|
||||||
identityFile = flag.String("identity-file", "", "path to the operator identity JSON file (0600). Mutually exclusive with --identity-pass")
|
|
||||||
identityPass = flag.String("identity-pass", "", "pass(1) entry holding the operator identity JSON, e.g. unibus/operator-identity")
|
|
||||||
unlockPass = flag.String("unlock-pass", "", "literal passphrase the browser must send to unlock a LEGACY operator session (dev). Prefer --unlock-pass-entry")
|
|
||||||
unlockEntry = flag.String("unlock-pass-entry", "unibus/admin-panel-password", "pass(1) entry holding the operator unlock passphrase (used when --unlock-pass is empty)")
|
|
||||||
registerURL = flag.String("register-url", "", "bus POST /register URL for wallet onboarding. Empty = derive from --ctrl-url (<ctrl-url>/register)")
|
|
||||||
mockTokens = flag.String("mock-tokens", "", "DEV ONLY: comma-separated one-shot invite tokens for local testing, 'token=handle:role'. Empty in production (real invites come from the bus). Example: demo=demo:member")
|
|
||||||
webDir = flag.String("web-dir", "", "OPTIONAL path to the built SPA (web/dist) to serve. Empty = API only (use vite dev server)")
|
|
||||||
)
|
|
||||||
flag.Parse()
|
|
||||||
|
|
||||||
log.SetFlags(log.LstdFlags | log.Lmsgprefix)
|
|
||||||
log.SetPrefix("[webgw] ")
|
|
||||||
|
|
||||||
id, err := loadIdentity(*identityFile, *identityPass)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("%v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
unlock := *unlockPass
|
|
||||||
if unlock == "" {
|
|
||||||
unlock, err = loadPassValue(*unlockEntry)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("resolve unlock passphrase: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if unlock == "" {
|
|
||||||
log.Fatalf("an unlock passphrase is required: set --unlock-pass or a non-empty --unlock-pass-entry (default unibus/admin-panel-password)")
|
|
||||||
}
|
|
||||||
|
|
||||||
resolvedWebDir := resolveWebDir(*webDir)
|
|
||||||
|
|
||||||
// busTemplate is the connection config every bus client uses. The operator
|
|
||||||
// gateway uses it as-is; each wallet session clones it and overrides Identity
|
|
||||||
// with the logged-in user's keypair.
|
|
||||||
busTemplate := gatewayConfig{
|
|
||||||
Identity: id,
|
|
||||||
NatsURL: *natsURL,
|
|
||||||
CtrlURL: *ctrlURL,
|
|
||||||
CtrlURLs: splitCSV(*ctrlURLs),
|
|
||||||
NatsURLs: splitCSV(*natsURLs),
|
|
||||||
CAPath: *caPath,
|
|
||||||
}
|
|
||||||
|
|
||||||
gw, err := newGateway(busTemplate)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("%v", err)
|
|
||||||
}
|
|
||||||
defer gw.Close()
|
|
||||||
|
|
||||||
// Wallet onboarding backend: POST /api/register targets the bus's /register
|
|
||||||
// (added by the user-accounts work). When --register-url is empty we derive it
|
|
||||||
// from --ctrl-url; --mock-tokens supplies one-shot invites for local testing
|
|
||||||
// before that endpoint is deployed.
|
|
||||||
regURL := *registerURL
|
|
||||||
if regURL == "" {
|
|
||||||
regURL = strings.TrimRight(*ctrlURL, "/") + "/register"
|
|
||||||
}
|
|
||||||
registrar := newRegistrar(regURL, *mockTokens)
|
|
||||||
|
|
||||||
log.Printf("operator endpoint: %s", gw.endpoint)
|
|
||||||
log.Printf("control plane: %s (+%d failover)", *ctrlURL, len(splitCSV(*ctrlURLs)))
|
|
||||||
tls := "OFF (plaintext dev)"
|
|
||||||
if *caPath != "" {
|
|
||||||
tls = "ON (CA " + *caPath + ")"
|
|
||||||
}
|
|
||||||
log.Printf("bus TLS+nkey: %s", tls)
|
|
||||||
if resolvedWebDir != "" {
|
|
||||||
log.Printf("serving SPA from: %s", resolvedWebDir)
|
|
||||||
} else {
|
|
||||||
log.Printf("API only (no --web-dir): use the vite dev server with a /api+stream proxy")
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Printf("wallet register: %s (mock tokens: %d)", regURL, mockTokenCount(*mockTokens))
|
|
||||||
|
|
||||||
srv := newServer(gw, busTemplate, registrar, unlock, resolvedWebDir)
|
|
||||||
addr := *bind + ":" + *port
|
|
||||||
httpSrv := &http.Server{
|
|
||||||
Addr: addr,
|
|
||||||
Handler: srv,
|
|
||||||
// No global write timeout: SSE streams are long-lived. Header timeout still
|
|
||||||
// bounds slowloris on the request line/headers.
|
|
||||||
ReadHeaderTimeout: 10 * time.Second,
|
|
||||||
}
|
|
||||||
|
|
||||||
go func() {
|
|
||||||
log.Printf("web gateway: http://%s", addr)
|
|
||||||
if err := httpSrv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
|
||||||
log.Fatalf("http server: %v", err)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
stop := make(chan os.Signal, 1)
|
|
||||||
signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM)
|
|
||||||
<-stop
|
|
||||||
log.Printf("shutting down...")
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
_ = httpSrv.Shutdown(ctx)
|
|
||||||
log.Printf("bye")
|
|
||||||
}
|
|
||||||
|
|
||||||
// loadIdentity resolves the operator identity from exactly one of --identity-file
|
|
||||||
// or --identity-pass.
|
|
||||||
func loadIdentity(file, passEntry string) (cs.Identity, error) {
|
|
||||||
switch {
|
|
||||||
case file != "" && passEntry != "":
|
|
||||||
return cs.Identity{}, errFlag("set only one of --identity-file or --identity-pass")
|
|
||||||
case file != "":
|
|
||||||
return loadIdentityFromFile(file)
|
|
||||||
case passEntry != "":
|
|
||||||
return loadIdentityFromPass(passEntry)
|
|
||||||
default:
|
|
||||||
return cs.Identity{}, errFlag("an identity is required: pass --identity-file <path> or --identity-pass <entry>")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// resolveWebDir validates the --web-dir flag. An empty flag means API-only. A
|
|
||||||
// non-empty dir is kept only if it actually holds an index.html, so a typo logs
|
|
||||||
// "API only" rather than serving 404s.
|
|
||||||
func resolveWebDir(dir string) string {
|
|
||||||
if dir == "" {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
abs, err := filepath.Abs(dir)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("WARN --web-dir %q: %v; serving API only", dir, err)
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
if !statFile(filepath.Join(abs, "index.html")) {
|
|
||||||
log.Printf("WARN --web-dir %q has no index.html; serving API only", abs)
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return abs
|
|
||||||
}
|
|
||||||
|
|
||||||
type flagErr string
|
|
||||||
|
|
||||||
func (e flagErr) Error() string { return string(e) }
|
|
||||||
func errFlag(s string) error { return flagErr("webgw: " + s) }
|
|
||||||
|
|
||||||
func splitCSV(s string) []string {
|
|
||||||
var out []string
|
|
||||||
for _, p := range strings.Split(s, ",") {
|
|
||||||
if p = strings.TrimSpace(p); p != "" {
|
|
||||||
out = append(out, p)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
@@ -1,193 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"encoding/hex"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// registerReq is the POST /api/register body. It mirrors the bus contract exactly
|
|
||||||
// (token + the two PUBLIC key halves, each 64 hex chars). The private key never
|
|
||||||
// appears here — registration only publishes the public identity. The handle and
|
|
||||||
// role are NOT accepted from the client; they are fixed by the invite the token
|
|
||||||
// belongs to (no privilege escalation).
|
|
||||||
type registerReq struct {
|
|
||||||
Token string `json:"token"`
|
|
||||||
SignPub string `json:"sign_pub"`
|
|
||||||
KexPub string `json:"kex_pub"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// registerResp is what we return to the browser on success. The bus's /register
|
|
||||||
// (issue: user-accounts) decides handle/role from the invite; in mock mode the
|
|
||||||
// gateway echoes the configured pair so the SPA can greet the new user.
|
|
||||||
type registerResp struct {
|
|
||||||
Handle string `json:"handle"`
|
|
||||||
Role string `json:"role"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// registrar fulfils POST /api/register. It targets the bus's POST /register
|
|
||||||
// endpoint (added by the user-accounts work, bus >= 0.12.0). Until that endpoint
|
|
||||||
// is rolled out, a built-in mock validates against a configured set of one-shot
|
|
||||||
// tokens so the whole wallet flow is testable locally. Mock tokens are checked
|
|
||||||
// first; anything else is proxied to the real bus when --register-url is set.
|
|
||||||
type registrar struct {
|
|
||||||
mu sync.Mutex
|
|
||||||
|
|
||||||
registerURL string // bus POST /register; empty => mock-only
|
|
||||||
httpc *http.Client // for proxying to the bus
|
|
||||||
mockTokens map[string]*mockToken // configured one-shot invites for local testing
|
|
||||||
}
|
|
||||||
|
|
||||||
// mockToken is a local stand-in for a bus invite: a token that maps to a fixed
|
|
||||||
// handle+role and can be consumed exactly once.
|
|
||||||
type mockToken struct {
|
|
||||||
handle string
|
|
||||||
role string
|
|
||||||
used bool
|
|
||||||
}
|
|
||||||
|
|
||||||
// newRegistrar parses the --mock-tokens spec ("tok=handle:role,tok2=h2:role2")
|
|
||||||
// and configures the optional proxy target.
|
|
||||||
func newRegistrar(registerURL, mockSpec string) *registrar {
|
|
||||||
r := ®istrar{
|
|
||||||
registerURL: strings.TrimSpace(registerURL),
|
|
||||||
httpc: &http.Client{Timeout: 10 * time.Second},
|
|
||||||
mockTokens: map[string]*mockToken{},
|
|
||||||
}
|
|
||||||
for _, part := range strings.Split(mockSpec, ",") {
|
|
||||||
part = strings.TrimSpace(part)
|
|
||||||
if part == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
// tok=handle:role (role optional, defaults to member)
|
|
||||||
eq := strings.IndexByte(part, '=')
|
|
||||||
if eq < 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
tok := strings.TrimSpace(part[:eq])
|
|
||||||
hr := strings.TrimSpace(part[eq+1:])
|
|
||||||
handle, role := hr, "member"
|
|
||||||
if c := strings.IndexByte(hr, ':'); c >= 0 {
|
|
||||||
handle, role = strings.TrimSpace(hr[:c]), strings.TrimSpace(hr[c+1:])
|
|
||||||
}
|
|
||||||
if tok != "" && handle != "" {
|
|
||||||
r.mockTokens[tok] = &mockToken{handle: handle, role: role}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return r
|
|
||||||
}
|
|
||||||
|
|
||||||
// mockTokenCount counts configured mock tokens in a --mock-tokens spec (for the
|
|
||||||
// startup log line).
|
|
||||||
func mockTokenCount(spec string) int {
|
|
||||||
n := 0
|
|
||||||
for _, part := range strings.Split(spec, ",") {
|
|
||||||
if p := strings.TrimSpace(part); p != "" && strings.ContainsRune(p, '=') {
|
|
||||||
n++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return n
|
|
||||||
}
|
|
||||||
|
|
||||||
// validHexKey reports whether s is exactly 64 lowercase/uppercase hex chars (a
|
|
||||||
// 32-byte key). Both sign_pub and kex_pub are 32-byte keys.
|
|
||||||
func validHexKey(s string) bool {
|
|
||||||
if len(s) != 64 {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
_, err := hex.DecodeString(s)
|
|
||||||
return err == nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleRegister validates the keys and consumes the token. Order of resolution:
|
|
||||||
// 1. strict validation of the public keys (defends both mock and proxy paths);
|
|
||||||
// 2. mock token (one-shot) if configured;
|
|
||||||
// 3. proxy to the bus /register if --register-url is set;
|
|
||||||
// 4. otherwise reject with a clear error.
|
|
||||||
func (s *server) handleRegister(w http.ResponseWriter, r *http.Request) {
|
|
||||||
var req registerReq
|
|
||||||
if !decode(w, r, &req) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
req.Token = strings.TrimSpace(req.Token)
|
|
||||||
if req.Token == "" {
|
|
||||||
writeErr(w, http.StatusBadRequest, "token required")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if !validHexKey(req.SignPub) {
|
|
||||||
writeErr(w, http.StatusBadRequest, "sign_pub must be 64 hex chars (32 bytes)")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if !validHexKey(req.KexPub) {
|
|
||||||
writeErr(w, http.StatusBadRequest, "kex_pub must be 64 hex chars (32 bytes)")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
reg := s.registrar
|
|
||||||
|
|
||||||
// 2) mock one-shot token.
|
|
||||||
reg.mu.Lock()
|
|
||||||
mt, isMock := reg.mockTokens[req.Token]
|
|
||||||
if isMock {
|
|
||||||
if mt.used {
|
|
||||||
reg.mu.Unlock()
|
|
||||||
writeErr(w, http.StatusConflict, "invite already used")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
mt.used = true
|
|
||||||
handle, role := mt.handle, mt.role
|
|
||||||
reg.mu.Unlock()
|
|
||||||
writeJSON(w, http.StatusCreated, registerResp{Handle: handle, Role: role})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
reg.mu.Unlock()
|
|
||||||
|
|
||||||
// 3) proxy to the real bus /register when configured.
|
|
||||||
if reg.registerURL != "" {
|
|
||||||
s.proxyRegister(w, req)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4) no mock match, no proxy target.
|
|
||||||
writeErr(w, http.StatusBadRequest, "invalid or unknown token (and no bus /register configured)")
|
|
||||||
}
|
|
||||||
|
|
||||||
// proxyRegister forwards the registration to the bus's POST /register. The bus
|
|
||||||
// validates the invite (existence, not-used, not-expired) and adds the public
|
|
||||||
// identity to the allowlist with the invite's handle+role. This is unsigned by
|
|
||||||
// design: the TOKEN authorizes the call, not an admin signature.
|
|
||||||
func (s *server) proxyRegister(w http.ResponseWriter, req registerReq) {
|
|
||||||
body, _ := json.Marshal(req)
|
|
||||||
resp, err := s.registrar.httpc.Post(
|
|
||||||
s.registrar.registerURL,
|
|
||||||
"application/json",
|
|
||||||
bytes.NewReader(body),
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
writeErr(w, http.StatusBadGateway, "bus register unreachable: "+err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
|
||||||
|
|
||||||
// On success, try to pass through the bus's handle/role if it returned them;
|
|
||||||
// otherwise a bare 201 is still success.
|
|
||||||
if resp.StatusCode == http.StatusCreated || resp.StatusCode == http.StatusOK {
|
|
||||||
var rr registerResp
|
|
||||||
_ = json.Unmarshal(raw, &rr)
|
|
||||||
writeJSON(w, http.StatusCreated, rr)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// Forward the bus's error verbatim where possible.
|
|
||||||
msg := strings.TrimSpace(string(raw))
|
|
||||||
if msg == "" {
|
|
||||||
msg = fmt.Sprintf("bus register failed (HTTP %d)", resp.StatusCode)
|
|
||||||
}
|
|
||||||
writeErr(w, resp.StatusCode, msg)
|
|
||||||
}
|
|
||||||
@@ -1,327 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"crypto/rand"
|
|
||||||
"crypto/subtle"
|
|
||||||
"encoding/hex"
|
|
||||||
"encoding/json"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// sessionCookie is the name of the gateway's session cookie. The browser sends
|
|
||||||
// it automatically on same-origin fetches AND on EventSource (SSE) connections —
|
|
||||||
// EventSource cannot set custom headers, so a cookie is the only way to
|
|
||||||
// authenticate the stream. It is HttpOnly so page JS can never read the token.
|
|
||||||
const sessionCookie = "unibus_session"
|
|
||||||
|
|
||||||
// server is the gateway's HTTP surface: a small REST/SSE API under /api plus an
|
|
||||||
// optional static file server for the built SPA.
|
|
||||||
//
|
|
||||||
// Two ways to get a session:
|
|
||||||
// - POST /api/session — the WALLET model. The browser hands its own bus
|
|
||||||
// identity (unlocked from its local encrypted key) and the gateway connects a
|
|
||||||
// dedicated bus client AS that user. Per-user, the primary path.
|
|
||||||
// - POST /api/login — the legacy operator passphrase. Binds the session to the
|
|
||||||
// single shared operator gateway. Kept for backward compatibility.
|
|
||||||
// - POST /api/register — the WALLET onboarding. Unauthenticated (the invite
|
|
||||||
// token authorizes), it consumes a token and publishes the new user's PUBLIC
|
|
||||||
// identity to the bus allowlist.
|
|
||||||
type server struct {
|
|
||||||
operatorGW *gateway // shared operator client (legacy passphrase login)
|
|
||||||
busTemplate gatewayConfig // bus connection config; Identity is overridden per user session
|
|
||||||
registrar *registrar // POST /api/register backend (mock + proxy)
|
|
||||||
unlock string // passphrase that unlocks an operator session (constant-time compare)
|
|
||||||
webDir string // optional path to the built SPA (web/dist); empty = API only
|
|
||||||
mux *http.ServeMux
|
|
||||||
sessions *sessionStore
|
|
||||||
}
|
|
||||||
|
|
||||||
func newServer(operatorGW *gateway, busTemplate gatewayConfig, registrar *registrar, unlock, webDir string) *server {
|
|
||||||
s := &server{
|
|
||||||
operatorGW: operatorGW,
|
|
||||||
busTemplate: busTemplate,
|
|
||||||
registrar: registrar,
|
|
||||||
unlock: unlock,
|
|
||||||
webDir: webDir,
|
|
||||||
mux: http.NewServeMux(),
|
|
||||||
sessions: newSessionStore(),
|
|
||||||
}
|
|
||||||
s.routes()
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *server) ServeHTTP(w http.ResponseWriter, r *http.Request) { s.mux.ServeHTTP(w, r) }
|
|
||||||
|
|
||||||
func (s *server) routes() {
|
|
||||||
// Liveness, unauthenticated (systemd / deploy smoke).
|
|
||||||
s.mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) {
|
|
||||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
|
||||||
})
|
|
||||||
|
|
||||||
// Unauthenticated onboarding / auth routes.
|
|
||||||
s.mux.HandleFunc("POST /api/register", s.handleRegister) // invite token authorizes
|
|
||||||
s.mux.HandleFunc("POST /api/session", s.handleSession) // wallet: per-user identity
|
|
||||||
s.mux.HandleFunc("POST /api/login", s.handleLogin) // legacy operator passphrase
|
|
||||||
|
|
||||||
// Session-gated routes.
|
|
||||||
s.mux.HandleFunc("POST /api/logout", s.auth(s.handleLogout))
|
|
||||||
s.mux.HandleFunc("GET /api/me", s.auth(s.handleMe))
|
|
||||||
s.mux.HandleFunc("GET /api/rooms", s.auth(s.handleListRooms))
|
|
||||||
s.mux.HandleFunc("POST /api/rooms", s.auth(s.handleCreateRoom))
|
|
||||||
s.mux.HandleFunc("POST /api/rooms/{id}/join", s.auth(s.handleJoin))
|
|
||||||
s.mux.HandleFunc("POST /api/rooms/{id}/send", s.auth(s.handleSend))
|
|
||||||
s.mux.HandleFunc("GET /api/rooms/{id}/stream", s.auth(s.handleStream))
|
|
||||||
|
|
||||||
// Everything else is the SPA (when --web-dir is set). Registered last.
|
|
||||||
if s.webDir != "" {
|
|
||||||
s.mux.Handle("/", s.spaHandler())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// meResp is the identity view returned by /api/session, /api/login and /api/me:
|
|
||||||
// the bus endpoint the session acts as, its signing public key, and the display
|
|
||||||
// handle.
|
|
||||||
type meResp struct {
|
|
||||||
Endpoint string `json:"endpoint"`
|
|
||||||
SignPub string `json:"sign_pub"`
|
|
||||||
Handle string `json:"handle"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- auth -----------------------------------------------------------------
|
|
||||||
|
|
||||||
// auth wraps a handler so it runs only with a valid session cookie, resolving the
|
|
||||||
// session (and thus the per-user gateway) it belongs to. A missing or unknown
|
|
||||||
// token yields 401, which the SPA treats as "show the login screen".
|
|
||||||
func (s *server) auth(next func(http.ResponseWriter, *http.Request, *session)) http.HandlerFunc {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
c, err := r.Cookie(sessionCookie)
|
|
||||||
if err != nil {
|
|
||||||
writeErr(w, http.StatusUnauthorized, "not authenticated")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
sess, ok := s.sessions.get(c.Value)
|
|
||||||
if !ok {
|
|
||||||
writeErr(w, http.StatusUnauthorized, "not authenticated")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
next(w, r, sess)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleLogin is the legacy operator passphrase login: it unlocks a session bound
|
|
||||||
// to the shared operator gateway. The wallet path (POST /api/session) is
|
|
||||||
// preferred; this remains for backward compatibility with the single-operator MVP.
|
|
||||||
func (s *server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
|
||||||
var req struct {
|
|
||||||
Passphrase string `json:"passphrase"`
|
|
||||||
}
|
|
||||||
if !decode(w, r, &req) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// Constant-time compare so a wrong passphrase cannot be timed character by
|
|
||||||
// character. An empty configured passphrase never matches.
|
|
||||||
if s.unlock == "" || subtle.ConstantTimeCompare([]byte(req.Passphrase), []byte(s.unlock)) != 1 {
|
|
||||||
writeErr(w, http.StatusUnauthorized, "wrong passphrase")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
tok := newToken()
|
|
||||||
handle := s.operatorGW.endpoint
|
|
||||||
if len(handle) > 8 {
|
|
||||||
handle = handle[:8]
|
|
||||||
}
|
|
||||||
s.sessions.put(tok, &session{gw: s.operatorGW, owned: false, handle: handle, issuedAt: time.Now()})
|
|
||||||
|
|
||||||
http.SetCookie(w, &http.Cookie{
|
|
||||||
Name: sessionCookie,
|
|
||||||
Value: tok,
|
|
||||||
Path: "/",
|
|
||||||
HttpOnly: true,
|
|
||||||
SameSite: http.SameSiteLaxMode,
|
|
||||||
})
|
|
||||||
writeJSON(w, http.StatusOK, meResp{Endpoint: s.operatorGW.endpoint, SignPub: hex.EncodeToString(s.operatorGW.id.SignPub), Handle: handle})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *server) handleLogout(w http.ResponseWriter, r *http.Request, _ *session) {
|
|
||||||
if c, err := r.Cookie(sessionCookie); err == nil {
|
|
||||||
if sess, ok := s.sessions.drop(c.Value); ok && sess.owned && sess.gw != nil {
|
|
||||||
// Per-user session: tear down its bus client so the private key and the
|
|
||||||
// NATS connection do not outlive the session.
|
|
||||||
_ = sess.gw.Close()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
http.SetCookie(w, &http.Cookie{Name: sessionCookie, Value: "", Path: "/", MaxAge: -1, HttpOnly: true})
|
|
||||||
writeJSON(w, http.StatusOK, map[string]string{"status": "logged_out"})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *server) handleMe(w http.ResponseWriter, _ *http.Request, sess *session) {
|
|
||||||
writeJSON(w, http.StatusOK, meResp{
|
|
||||||
Endpoint: sess.gw.endpoint,
|
|
||||||
SignPub: hex.EncodeToString(sess.gw.id.SignPub),
|
|
||||||
Handle: sess.handle,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- rooms ----------------------------------------------------------------
|
|
||||||
|
|
||||||
func (s *server) handleListRooms(w http.ResponseWriter, _ *http.Request, sess *session) {
|
|
||||||
rooms, err := sess.gw.listRooms()
|
|
||||||
if err != nil {
|
|
||||||
writeErr(w, http.StatusBadGateway, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeJSON(w, http.StatusOK, rooms)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *server) handleCreateRoom(w http.ResponseWriter, r *http.Request, sess *session) {
|
|
||||||
var req createRoomReq
|
|
||||||
if !decode(w, r, &req) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
rv, err := sess.gw.createRoom(req)
|
|
||||||
if err != nil {
|
|
||||||
writeErr(w, http.StatusBadGateway, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeJSON(w, http.StatusCreated, rv)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *server) handleJoin(w http.ResponseWriter, r *http.Request, sess *session) {
|
|
||||||
if err := sess.gw.join(r.PathValue("id")); err != nil {
|
|
||||||
writeErr(w, http.StatusBadGateway, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeJSON(w, http.StatusOK, map[string]string{"status": "joined"})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *server) handleSend(w http.ResponseWriter, r *http.Request, sess *session) {
|
|
||||||
var req sendReq
|
|
||||||
if !decode(w, r, &req) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(req.Body) == "" {
|
|
||||||
writeErr(w, http.StatusBadRequest, "body required")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := sess.gw.send(r.PathValue("id"), req.Body); err != nil {
|
|
||||||
writeErr(w, http.StatusBadGateway, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeJSON(w, http.StatusOK, map[string]string{"status": "sent"})
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleStream is the SSE endpoint: it joins the room, attaches to the session's
|
|
||||||
// fan-out hub, and streams each decrypted message as a `data:` event. For a
|
|
||||||
// persisted room the hub's underlying subscription delivers history first
|
|
||||||
// (scrollback) and then live messages; for an ephemeral room only live messages
|
|
||||||
// flow. The stream ends when the browser disconnects (ctx cancelled).
|
|
||||||
func (s *server) handleStream(w http.ResponseWriter, r *http.Request, sess *session) {
|
|
||||||
flusher, ok := w.(http.Flusher)
|
|
||||||
if !ok {
|
|
||||||
writeErr(w, http.StatusInternalServerError, "streaming unsupported")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
ch, cleanup, err := sess.gw.openStream(r.PathValue("id"))
|
|
||||||
if err != nil {
|
|
||||||
writeErr(w, http.StatusBadGateway, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer cleanup()
|
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "text/event-stream")
|
|
||||||
w.Header().Set("Cache-Control", "no-cache")
|
|
||||||
w.Header().Set("Connection", "keep-alive")
|
|
||||||
w.Header().Set("X-Accel-Buffering", "no") // disable proxy buffering (nginx/caddy)
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
// An initial comment opens the stream immediately so the browser's
|
|
||||||
// EventSource fires `onopen` without waiting for the first message.
|
|
||||||
_, _ = w.Write([]byte(": connected\n\n"))
|
|
||||||
flusher.Flush()
|
|
||||||
|
|
||||||
ctx := r.Context()
|
|
||||||
ping := time.NewTicker(25 * time.Second)
|
|
||||||
defer ping.Stop()
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
return
|
|
||||||
case <-ping.C:
|
|
||||||
// Comment line keeps idle proxies from closing the connection.
|
|
||||||
if _, err := w.Write([]byte(": ping\n\n")); err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
flusher.Flush()
|
|
||||||
case m := <-ch:
|
|
||||||
b, err := json.Marshal(m)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if _, err := w.Write([]byte("data: " + string(b) + "\n\n")); err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
flusher.Flush()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- SPA serving (optional) -----------------------------------------------
|
|
||||||
|
|
||||||
// spaHandler serves the built SPA from s.webDir. A request for an existing asset
|
|
||||||
// is served directly; any other path (a client-side route) falls back to
|
|
||||||
// index.html so the SPA router can take over. /api and /healthz are matched first.
|
|
||||||
func (s *server) spaHandler() http.Handler {
|
|
||||||
root := http.Dir(s.webDir)
|
|
||||||
fileServer := http.FileServer(root)
|
|
||||||
index := filepath.Join(s.webDir, "index.html")
|
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
p := strings.TrimPrefix(r.URL.Path, "/")
|
|
||||||
if p == "" {
|
|
||||||
http.ServeFile(w, r, index)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if f, err := root.Open(p); err == nil {
|
|
||||||
_ = f.Close()
|
|
||||||
fileServer.ServeHTTP(w, r)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
http.ServeFile(w, r, index) // unknown path -> SPA client-side routing
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- helpers --------------------------------------------------------------
|
|
||||||
|
|
||||||
func newToken() string {
|
|
||||||
b := make([]byte, 32)
|
|
||||||
_, _ = rand.Read(b)
|
|
||||||
return hex.EncodeToString(b)
|
|
||||||
}
|
|
||||||
|
|
||||||
func writeJSON(w http.ResponseWriter, code int, v any) {
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
w.WriteHeader(code)
|
|
||||||
_ = json.NewEncoder(w).Encode(v)
|
|
||||||
}
|
|
||||||
|
|
||||||
func writeErr(w http.ResponseWriter, code int, msg string) {
|
|
||||||
writeJSON(w, code, map[string]string{"error": msg})
|
|
||||||
}
|
|
||||||
|
|
||||||
// decode reads a JSON body into v, writing a 400 and returning false on failure.
|
|
||||||
func decode(w http.ResponseWriter, r *http.Request, v any) bool {
|
|
||||||
defer r.Body.Close()
|
|
||||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(v); err != nil {
|
|
||||||
writeErr(w, http.StatusBadRequest, "bad json: "+err.Error())
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// statFile reports whether path exists and is a regular file (used to validate
|
|
||||||
// --web-dir at startup so a typo surfaces as a clear log line, not 404s later).
|
|
||||||
func statFile(path string) bool {
|
|
||||||
fi, err := os.Stat(path)
|
|
||||||
return err == nil && !fi.IsDir()
|
|
||||||
}
|
|
||||||
@@ -1,146 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/hex"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
cs "fn-registry/functions/cybersecurity"
|
|
||||||
)
|
|
||||||
|
|
||||||
// session is one logged-in browser. In the wallet model each session carries the
|
|
||||||
// user's OWN bus identity: the browser unlocks its locally-encrypted private key
|
|
||||||
// and hands the full keypair to the gateway over TLS, and the gateway spins up a
|
|
||||||
// dedicated bus client (a *gateway) that acts AS that user. The private key lives
|
|
||||||
// only in this process's memory for the life of the session — it is never written
|
|
||||||
// to disk and is dropped when the session ends.
|
|
||||||
//
|
|
||||||
// A session may instead point at the shared operator gateway (the legacy
|
|
||||||
// passphrase login); `owned` distinguishes the two so logout only closes the bus
|
|
||||||
// client it created.
|
|
||||||
type session struct {
|
|
||||||
gw *gateway
|
|
||||||
owned bool // true => gw was built for this session and must be Closed on logout
|
|
||||||
handle string
|
|
||||||
issuedAt time.Time
|
|
||||||
}
|
|
||||||
|
|
||||||
// sessionStore is the gateway's set of live browser sessions, keyed by the opaque
|
|
||||||
// cookie token. It is independent of any single bus identity.
|
|
||||||
type sessionStore struct {
|
|
||||||
mu sync.Mutex
|
|
||||||
m map[string]*session
|
|
||||||
}
|
|
||||||
|
|
||||||
func newSessionStore() *sessionStore { return &sessionStore{m: map[string]*session{}} }
|
|
||||||
|
|
||||||
func (st *sessionStore) put(token string, s *session) {
|
|
||||||
st.mu.Lock()
|
|
||||||
st.m[token] = s
|
|
||||||
st.mu.Unlock()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (st *sessionStore) get(token string) (*session, bool) {
|
|
||||||
st.mu.Lock()
|
|
||||||
defer st.mu.Unlock()
|
|
||||||
s, ok := st.m[token]
|
|
||||||
return s, ok
|
|
||||||
}
|
|
||||||
|
|
||||||
// drop removes a session and returns it so the caller can close an owned gateway.
|
|
||||||
func (st *sessionStore) drop(token string) (*session, bool) {
|
|
||||||
st.mu.Lock()
|
|
||||||
defer st.mu.Unlock()
|
|
||||||
s, ok := st.m[token]
|
|
||||||
if ok {
|
|
||||||
delete(st.m, token)
|
|
||||||
}
|
|
||||||
return s, ok
|
|
||||||
}
|
|
||||||
|
|
||||||
// closeAll closes every owned per-user gateway (used at shutdown). The shared
|
|
||||||
// operator gateway is owned by main and closed separately.
|
|
||||||
func (st *sessionStore) closeAll() {
|
|
||||||
st.mu.Lock()
|
|
||||||
defer st.mu.Unlock()
|
|
||||||
for tok, s := range st.m {
|
|
||||||
if s.owned && s.gw != nil {
|
|
||||||
_ = s.gw.Close()
|
|
||||||
}
|
|
||||||
delete(st.m, tok)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// identityFromHex builds a cs.Identity from the four hex halves the browser sends
|
|
||||||
// on POST /api/session. It enforces the exact key sizes (sign_pub 32, sign_priv
|
|
||||||
// 64, kex_pub 32, kex_priv 32) so a malformed body cannot produce a half-built
|
|
||||||
// identity that fails opaquely deep in the bus client.
|
|
||||||
func identityFromHex(signPub, signPriv, kexPub, kexPriv string) (cs.Identity, error) {
|
|
||||||
sp, err := hex.DecodeString(signPub)
|
|
||||||
if err != nil {
|
|
||||||
return cs.Identity{}, fmt.Errorf("sign_pub: %w", err)
|
|
||||||
}
|
|
||||||
spriv, err := hex.DecodeString(signPriv)
|
|
||||||
if err != nil {
|
|
||||||
return cs.Identity{}, fmt.Errorf("sign_priv: %w", err)
|
|
||||||
}
|
|
||||||
kp, err := hex.DecodeString(kexPub)
|
|
||||||
if err != nil {
|
|
||||||
return cs.Identity{}, fmt.Errorf("kex_pub: %w", err)
|
|
||||||
}
|
|
||||||
kpriv, err := hex.DecodeString(kexPriv)
|
|
||||||
if err != nil {
|
|
||||||
return cs.Identity{}, fmt.Errorf("kex_priv: %w", err)
|
|
||||||
}
|
|
||||||
if len(sp) != 32 || len(spriv) != 64 || len(kp) != 32 || len(kpriv) != 32 {
|
|
||||||
return cs.Identity{}, fmt.Errorf("wrong key sizes (sign_pub=%d sign_priv=%d kex_pub=%d kex_priv=%d; want 32/64/32/32)",
|
|
||||||
len(sp), len(spriv), len(kp), len(kpriv))
|
|
||||||
}
|
|
||||||
return cs.Identity{SignPub: sp, SignPriv: spriv, KexPub: kp, KexPriv: kpriv}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// sessionReq is the POST /api/session body: the user's full wallet identity (hex)
|
|
||||||
// plus a display handle. The private halves arrive only over TLS and are held in
|
|
||||||
// memory for the session; they are never persisted server-side.
|
|
||||||
type sessionReq struct {
|
|
||||||
Handle string `json:"handle"`
|
|
||||||
SignPub string `json:"sign_pub"`
|
|
||||||
SignPriv string `json:"sign_priv"`
|
|
||||||
KexPub string `json:"kex_pub"`
|
|
||||||
KexPriv string `json:"kex_priv"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleSession opens a per-user session. It builds the user's bus identity from
|
|
||||||
// the posted keypair, connects a dedicated bus client as that user, and issues a
|
|
||||||
// session cookie bound to it. This is the wallet-model replacement for the
|
|
||||||
// operator passphrase login.
|
|
||||||
func (s *server) handleSession(w http.ResponseWriter, r *http.Request) {
|
|
||||||
var req sessionReq
|
|
||||||
if !decode(w, r, &req) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
id, err := identityFromHex(req.SignPub, req.SignPriv, req.KexPub, req.KexPriv)
|
|
||||||
if err != nil {
|
|
||||||
writeErr(w, http.StatusBadRequest, "bad identity: "+err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
cfg := s.busTemplate
|
|
||||||
cfg.Identity = id
|
|
||||||
gw, err := newGateway(cfg)
|
|
||||||
if err != nil {
|
|
||||||
writeErr(w, http.StatusBadGateway, "connect bus as user: "+err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
tok := newToken()
|
|
||||||
s.sessions.put(tok, &session{gw: gw, owned: true, handle: req.Handle, issuedAt: time.Now()})
|
|
||||||
http.SetCookie(w, &http.Cookie{
|
|
||||||
Name: sessionCookie,
|
|
||||||
Value: tok,
|
|
||||||
Path: "/",
|
|
||||||
HttpOnly: true,
|
|
||||||
SameSite: http.SameSiteLaxMode,
|
|
||||||
})
|
|
||||||
writeJSON(w, http.StatusOK, meResp{Endpoint: gw.endpoint, SignPub: req.SignPub, Handle: req.Handle})
|
|
||||||
}
|
|
||||||
@@ -1,114 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"net/http/httptest"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
// fixed wallet vector derived in the browser from the mnemonic
|
|
||||||
// "legal winner thank year wave sausage worth useful legal winner thank yellow"
|
|
||||||
// using the unibus-sign-v1 / unibus-kex-v1 HKDF scheme. Used to assert the Go
|
|
||||||
// side accepts the browser-derived key sizes.
|
|
||||||
const (
|
|
||||||
fixSignPub = "3d594317212e53a3685b305539f6789eb8c538579e350ca795278b180ebb53db"
|
|
||||||
fixSignPriv = "94485d66ac958e23546be2e3b7575a47e1264bdf082e09abb7ad02ab32fcd55e3d594317212e53a3685b305539f6789eb8c538579e350ca795278b180ebb53db"
|
|
||||||
fixKexPub = "f3561ca116e4444b8880b8c0a35f2c9e85804d8628006facd84b1a6146208257"
|
|
||||||
fixKexPriv = "f6ffdf15e5ee2af0494897ff43e61a06d632af425a0372cb53a7c3e0f84c2bb2"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestIdentityFromHex(t *testing.T) {
|
|
||||||
id, err := identityFromHex(fixSignPub, fixSignPriv, fixKexPub, fixKexPriv)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("identityFromHex valid vector: %v", err)
|
|
||||||
}
|
|
||||||
if len(id.SignPub) != 32 || len(id.SignPriv) != 64 || len(id.KexPub) != 32 || len(id.KexPriv) != 32 {
|
|
||||||
t.Fatalf("wrong sizes: %d/%d/%d/%d", len(id.SignPub), len(id.SignPriv), len(id.KexPub), len(id.KexPriv))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wrong sign_priv size (32 instead of 64) must be rejected.
|
|
||||||
if _, err := identityFromHex(fixSignPub, fixSignPub, fixKexPub, fixKexPriv); err == nil {
|
|
||||||
t.Fatalf("expected error for short sign_priv")
|
|
||||||
}
|
|
||||||
// Non-hex must be rejected.
|
|
||||||
if _, err := identityFromHex("zz", fixSignPriv, fixKexPub, fixKexPriv); err == nil {
|
|
||||||
t.Fatalf("expected error for non-hex sign_pub")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestValidHexKey(t *testing.T) {
|
|
||||||
if !validHexKey(fixSignPub) {
|
|
||||||
t.Fatalf("fixSignPub should be a valid 32-byte hex key")
|
|
||||||
}
|
|
||||||
if validHexKey("abcd") {
|
|
||||||
t.Fatalf("short key should be invalid")
|
|
||||||
}
|
|
||||||
if validHexKey(strings.Repeat("z", 64)) {
|
|
||||||
t.Fatalf("non-hex key should be invalid")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNewRegistrarParsesMockTokens(t *testing.T) {
|
|
||||||
r := newRegistrar("", "demo=demo:member, bob=bob, alice=alice:admin")
|
|
||||||
if len(r.mockTokens) != 3 {
|
|
||||||
t.Fatalf("want 3 mock tokens, got %d", len(r.mockTokens))
|
|
||||||
}
|
|
||||||
if r.mockTokens["demo"].role != "member" || r.mockTokens["demo"].handle != "demo" {
|
|
||||||
t.Fatalf("demo token parsed wrong: %+v", r.mockTokens["demo"])
|
|
||||||
}
|
|
||||||
if r.mockTokens["bob"].role != "member" {
|
|
||||||
t.Fatalf("bob should default to role member, got %q", r.mockTokens["bob"].role)
|
|
||||||
}
|
|
||||||
if r.mockTokens["alice"].role != "admin" {
|
|
||||||
t.Fatalf("alice should be admin, got %q", r.mockTokens["alice"].role)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// post builds a server with only a registrar (the register path does not touch a
|
|
||||||
// gateway) and runs one POST /api/register, returning status + decoded body.
|
|
||||||
func postRegister(t *testing.T, s *server, body string) (int, map[string]string) {
|
|
||||||
t.Helper()
|
|
||||||
req := httptest.NewRequest("POST", "/api/register", strings.NewReader(body))
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
s.handleRegister(w, req)
|
|
||||||
var m map[string]string
|
|
||||||
_ = json.Unmarshal(w.Body.Bytes(), &m)
|
|
||||||
return w.Code, m
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestHandleRegisterMockSingleUse(t *testing.T) {
|
|
||||||
s := &server{registrar: newRegistrar("", "demo=demo:member")}
|
|
||||||
|
|
||||||
// 1) valid token + valid keys => 201 with the invite's handle/role.
|
|
||||||
code, body := postRegister(t, s, `{"token":"demo","sign_pub":"`+fixSignPub+`","kex_pub":"`+fixKexPub+`"}`)
|
|
||||||
if code != 201 {
|
|
||||||
t.Fatalf("first register: want 201, got %d (%v)", code, body)
|
|
||||||
}
|
|
||||||
if body["handle"] != "demo" || body["role"] != "member" {
|
|
||||||
t.Fatalf("first register body: %v", body)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2) same token again => 409 (single-use consumed).
|
|
||||||
code, _ = postRegister(t, s, `{"token":"demo","sign_pub":"`+fixSignPub+`","kex_pub":"`+fixKexPub+`"}`)
|
|
||||||
if code != 409 {
|
|
||||||
t.Fatalf("reused token: want 409, got %d", code)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestHandleRegisterValidation(t *testing.T) {
|
|
||||||
s := &server{registrar: newRegistrar("", "demo=demo:member")}
|
|
||||||
|
|
||||||
// bad sign_pub (too short) => 400
|
|
||||||
if code, _ := postRegister(t, s, `{"token":"demo","sign_pub":"abcd","kex_pub":"`+fixKexPub+`"}`); code != 400 {
|
|
||||||
t.Fatalf("short sign_pub: want 400, got %d", code)
|
|
||||||
}
|
|
||||||
// missing token => 400
|
|
||||||
if code, _ := postRegister(t, s, `{"sign_pub":"`+fixSignPub+`","kex_pub":"`+fixKexPub+`"}`); code != 400 {
|
|
||||||
t.Fatalf("missing token: want 400, got %d", code)
|
|
||||||
}
|
|
||||||
// unknown token with no mock match and no register-url => 400
|
|
||||||
if code, _ := postRegister(t, s, `{"token":"nope","sign_pub":"`+fixSignPub+`","kex_pub":"`+fixKexPub+`"}`); code != 400 {
|
|
||||||
t.Fatalf("unknown token: want 400, got %d", code)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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.
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
module github.com/enmanuel/uniweb
|
|
||||||
|
|
||||||
go 1.26.4
|
|
||||||
|
|
||||||
replace fn-registry => ../../../../
|
|
||||||
|
|
||||||
replace github.com/enmanuel/unibus => ../unibus
|
|
||||||
|
|
||||||
require (
|
|
||||||
fn-registry v0.0.0-00010101000000-000000000000
|
|
||||||
github.com/enmanuel/unibus v0.0.0-00010101000000-000000000000
|
|
||||||
github.com/oklog/ulid/v2 v2.1.0
|
|
||||||
)
|
|
||||||
|
|
||||||
require (
|
|
||||||
github.com/antithesishq/antithesis-sdk-go v0.6.0-default-no-op // indirect
|
|
||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
|
||||||
github.com/google/go-tpm v0.9.8 // indirect
|
|
||||||
github.com/google/uuid v1.6.0 // indirect
|
|
||||||
github.com/klauspost/compress v1.18.4 // indirect
|
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
|
||||||
github.com/minio/highwayhash v1.0.4-0.20251030100505-070ab1a87a76 // indirect
|
|
||||||
github.com/nats-io/jwt/v2 v2.8.1 // indirect
|
|
||||||
github.com/nats-io/nats-server/v2 v2.11.15 // indirect
|
|
||||||
github.com/nats-io/nats.go v1.49.0 // indirect
|
|
||||||
github.com/nats-io/nkeys v0.4.15 // indirect
|
|
||||||
github.com/nats-io/nuid v1.0.1 // indirect
|
|
||||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
|
||||||
golang.org/x/crypto v0.51.0 // indirect
|
|
||||||
golang.org/x/sys v0.44.0 // indirect
|
|
||||||
golang.org/x/time v0.15.0 // indirect
|
|
||||||
modernc.org/libc v1.70.0 // indirect
|
|
||||||
modernc.org/mathutil v1.7.1 // indirect
|
|
||||||
modernc.org/memory v1.11.0 // indirect
|
|
||||||
modernc.org/sqlite v1.47.0 // indirect
|
|
||||||
)
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
github.com/antithesishq/antithesis-sdk-go v0.6.0-default-no-op h1:kpBdlEPbRvff0mDD1gk7o9BhI16b9p5yYAXRlidpqJE=
|
|
||||||
github.com/antithesishq/antithesis-sdk-go v0.6.0-default-no-op/go.mod h1:IUpT2DPAKh6i/YhSbt6Gl3v2yvUZjmKncl7U91fup7E=
|
|
||||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
|
||||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
|
||||||
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
|
|
||||||
github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
|
|
||||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
|
||||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
|
||||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
|
||||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
|
||||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
|
||||||
github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c=
|
|
||||||
github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
|
|
||||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
|
||||||
github.com/minio/highwayhash v1.0.4-0.20251030100505-070ab1a87a76 h1:KGuD/pM2JpL9FAYvBrnBBeENKZNh6eNtjqytV6TYjnk=
|
|
||||||
github.com/minio/highwayhash v1.0.4-0.20251030100505-070ab1a87a76/go.mod h1:GGYsuwP/fPD6Y9hMiXuapVvlIUEhFhMTh0rxU3ik1LQ=
|
|
||||||
github.com/nats-io/jwt/v2 v2.8.1 h1:V0xpGuD/N8Mi+fQNDynXohVvp7ZztevW5io8CUWlPmU=
|
|
||||||
github.com/nats-io/jwt/v2 v2.8.1/go.mod h1:nWnOEEiVMiKHQpnAy4eXlizVEtSfzacZ1Q43LIRavZg=
|
|
||||||
github.com/nats-io/nats-server/v2 v2.11.15 h1:StSf9TINInaZtr4oww2+kXmfwa9SkN//g/LwS19/UJ0=
|
|
||||||
github.com/nats-io/nats-server/v2 v2.11.15/go.mod h1:zwhv8Y0PE3KHyKgznJc/9Xoai638SaJd83zzJ5GJn74=
|
|
||||||
github.com/nats-io/nats.go v1.49.0 h1:yh/WvY59gXqYpgl33ZI+XoVPKyut/IcEaqtsiuTJpoE=
|
|
||||||
github.com/nats-io/nats.go v1.49.0/go.mod h1:fDCn3mN5cY8HooHwE2ukiLb4p4G4ImmzvXyJt+tGwdw=
|
|
||||||
github.com/nats-io/nkeys v0.4.15 h1:JACV5jRVO9V856KOapQ7x+EY8Jo3qw1vJt/9Jpwzkk4=
|
|
||||||
github.com/nats-io/nkeys v0.4.15/go.mod h1:CpMchTXC9fxA5zrMo4KpySxNjiDVvr8ANOSZdiNfUrs=
|
|
||||||
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
|
|
||||||
github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
|
|
||||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
|
||||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
|
||||||
github.com/oklog/ulid/v2 v2.1.0 h1:+9lhoxAP56we25tyYETBBY1YLA2SaoLvUFgrP2miPJU=
|
|
||||||
github.com/oklog/ulid/v2 v2.1.0/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ=
|
|
||||||
github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o=
|
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
|
||||||
golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
|
|
||||||
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
|
|
||||||
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
|
|
||||||
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
|
|
||||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
|
||||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|
||||||
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
|
||||||
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
|
|
||||||
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
|
||||||
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
|
||||||
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
|
||||||
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
|
|
||||||
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
|
|
||||||
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
|
|
||||||
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
|
||||||
modernc.org/ccgo/v4 v4.32.0 h1:hjG66bI/kqIPX1b2yT6fr/jt+QedtP2fqojG2VrFuVw=
|
|
||||||
modernc.org/ccgo/v4 v4.32.0/go.mod h1:6F08EBCx5uQc38kMGl+0Nm0oWczoo1c7cgpzEry7Uc0=
|
|
||||||
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
|
|
||||||
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
|
|
||||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
|
||||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
|
||||||
modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
|
|
||||||
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
|
||||||
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
|
||||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
|
||||||
modernc.org/libc v1.70.0 h1:U58NawXqXbgpZ/dcdS9kMshu08aiA6b7gusEusqzNkw=
|
|
||||||
modernc.org/libc v1.70.0/go.mod h1:OVmxFGP1CI/Z4L3E0Q3Mf1PDE0BucwMkcXjjLntvHJo=
|
|
||||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
|
||||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
|
||||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
|
||||||
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
|
||||||
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
|
||||||
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
|
||||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
|
||||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
|
||||||
modernc.org/sqlite v1.47.0 h1:R1XyaNpoW4Et9yly+I2EeX7pBza/w+pmYee/0HJDyKk=
|
|
||||||
modernc.org/sqlite v1.47.0/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig=
|
|
||||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
|
||||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
|
||||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
|
||||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
|
||||||
+8
-3
@@ -6,17 +6,21 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b && vite build",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview",
|
||||||
|
"test": "vitest run"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@mantine/core": "^9.3.0",
|
"@mantine/core": "^9.3.0",
|
||||||
"@mantine/hooks": "^9.3.0",
|
"@mantine/hooks": "^9.3.0",
|
||||||
|
"@noble/ciphers": "^2.2.0",
|
||||||
"@noble/curves": "^2.2.0",
|
"@noble/curves": "^2.2.0",
|
||||||
"@noble/hashes": "^2.2.0",
|
"@noble/hashes": "^2.2.0",
|
||||||
"@scure/bip39": "^2.2.0",
|
"@scure/bip39": "^2.2.0",
|
||||||
"@tabler/icons-react": "^3.36.0",
|
"@tabler/icons-react": "^3.36.0",
|
||||||
|
"nats.ws": "^1.30.3",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0"
|
"react-dom": "^19.2.0",
|
||||||
|
"tweetnacl": "^1.0.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/react": "^19.2.0",
|
"@types/react": "^19.2.0",
|
||||||
@@ -26,6 +30,7 @@
|
|||||||
"postcss-preset-mantine": "^1.17.0",
|
"postcss-preset-mantine": "^1.17.0",
|
||||||
"postcss-simple-vars": "^7.0.1",
|
"postcss-simple-vars": "^7.0.1",
|
||||||
"typescript": "~5.6.3",
|
"typescript": "~5.6.3",
|
||||||
"vite": "^6.0.3"
|
"vite": "^6.0.3",
|
||||||
|
"vitest": "^4.1.8"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+284
@@ -14,6 +14,9 @@ importers:
|
|||||||
'@mantine/hooks':
|
'@mantine/hooks':
|
||||||
specifier: ^9.3.0
|
specifier: ^9.3.0
|
||||||
version: 9.3.0(react@19.2.7)
|
version: 9.3.0(react@19.2.7)
|
||||||
|
'@noble/ciphers':
|
||||||
|
specifier: ^2.2.0
|
||||||
|
version: 2.2.0
|
||||||
'@noble/curves':
|
'@noble/curves':
|
||||||
specifier: ^2.2.0
|
specifier: ^2.2.0
|
||||||
version: 2.2.0
|
version: 2.2.0
|
||||||
@@ -26,12 +29,18 @@ importers:
|
|||||||
'@tabler/icons-react':
|
'@tabler/icons-react':
|
||||||
specifier: ^3.36.0
|
specifier: ^3.36.0
|
||||||
version: 3.44.0(react@19.2.7)
|
version: 3.44.0(react@19.2.7)
|
||||||
|
nats.ws:
|
||||||
|
specifier: ^1.30.3
|
||||||
|
version: 1.30.3
|
||||||
react:
|
react:
|
||||||
specifier: ^19.2.0
|
specifier: ^19.2.0
|
||||||
version: 19.2.7
|
version: 19.2.7
|
||||||
react-dom:
|
react-dom:
|
||||||
specifier: ^19.2.0
|
specifier: ^19.2.0
|
||||||
version: 19.2.7(react@19.2.7)
|
version: 19.2.7(react@19.2.7)
|
||||||
|
tweetnacl:
|
||||||
|
specifier: ^1.0.3
|
||||||
|
version: 1.0.3
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@types/react':
|
'@types/react':
|
||||||
specifier: ^19.2.0
|
specifier: ^19.2.0
|
||||||
@@ -57,6 +66,9 @@ importers:
|
|||||||
vite:
|
vite:
|
||||||
specifier: ^6.0.3
|
specifier: ^6.0.3
|
||||||
version: 6.4.3(sugarss@5.0.1(postcss@8.5.15))
|
version: 6.4.3(sugarss@5.0.1(postcss@8.5.15))
|
||||||
|
vitest:
|
||||||
|
specifier: ^4.1.8
|
||||||
|
version: 4.1.8(vite@6.4.3(sugarss@5.0.1(postcss@8.5.15)))
|
||||||
|
|
||||||
packages:
|
packages:
|
||||||
|
|
||||||
@@ -348,6 +360,10 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
react: ^19.2.0
|
react: ^19.2.0
|
||||||
|
|
||||||
|
'@noble/ciphers@2.2.0':
|
||||||
|
resolution: {integrity: sha512-Z6pjIZ/8IJcCGzb2S/0Px5J81yij85xASuk1teLNeg75bfT07MV3a/O2Mtn1I2se43k3lkVEcFaR10N4cgQcZA==}
|
||||||
|
engines: {node: '>= 20.19.0'}
|
||||||
|
|
||||||
'@noble/curves@2.2.0':
|
'@noble/curves@2.2.0':
|
||||||
resolution: {integrity: sha512-T/BoHgFXirb0ENSPBquzX0rcjXeM6Lo892a2jlYJkqk83LqZx0l1Of7DzlKJ6jkpvMrkHSnAcgb5JegL8SeIkQ==}
|
resolution: {integrity: sha512-T/BoHgFXirb0ENSPBquzX0rcjXeM6Lo892a2jlYJkqk83LqZx0l1Of7DzlKJ6jkpvMrkHSnAcgb5JegL8SeIkQ==}
|
||||||
engines: {node: '>= 20.19.0'}
|
engines: {node: '>= 20.19.0'}
|
||||||
@@ -503,6 +519,9 @@ packages:
|
|||||||
'@scure/bip39@2.2.0':
|
'@scure/bip39@2.2.0':
|
||||||
resolution: {integrity: sha512-T/Bj/YvYMNkIPq6EENO6/rcs2e7qTNuyoUXf0KBFDmp0ZDu0H2X4Lq6yC3i0c8PcWkov5EbW+yQZZbdMmk154A==}
|
resolution: {integrity: sha512-T/Bj/YvYMNkIPq6EENO6/rcs2e7qTNuyoUXf0KBFDmp0ZDu0H2X4Lq6yC3i0c8PcWkov5EbW+yQZZbdMmk154A==}
|
||||||
|
|
||||||
|
'@standard-schema/spec@1.1.0':
|
||||||
|
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
|
||||||
|
|
||||||
'@tabler/icons-react@3.44.0':
|
'@tabler/icons-react@3.44.0':
|
||||||
resolution: {integrity: sha512-8+rvzBbVm/1Z3sG3x7GUNAaxIKxwgz8xaMhRs23nrCnMTKRFAhEC+82zAIFeAA0seXdrAGX5HFCkaLpGK2rVHg==}
|
resolution: {integrity: sha512-8+rvzBbVm/1Z3sG3x7GUNAaxIKxwgz8xaMhRs23nrCnMTKRFAhEC+82zAIFeAA0seXdrAGX5HFCkaLpGK2rVHg==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -523,6 +542,12 @@ packages:
|
|||||||
'@types/babel__traverse@7.28.0':
|
'@types/babel__traverse@7.28.0':
|
||||||
resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==}
|
resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==}
|
||||||
|
|
||||||
|
'@types/chai@5.2.3':
|
||||||
|
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
|
||||||
|
|
||||||
|
'@types/deep-eql@4.0.2':
|
||||||
|
resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
|
||||||
|
|
||||||
'@types/estree@1.0.9':
|
'@types/estree@1.0.9':
|
||||||
resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
|
resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
|
||||||
|
|
||||||
@@ -540,6 +565,39 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0
|
vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0
|
||||||
|
|
||||||
|
'@vitest/expect@4.1.8':
|
||||||
|
resolution: {integrity: sha512-h3nDO677RDLEGlBxyQ5CW8RlMThSKSRLUePLOx09gNIWRL40edgA1GCZSZgf1W55MFAG6/Sw14KeaAnqv0NKdQ==}
|
||||||
|
|
||||||
|
'@vitest/mocker@4.1.8':
|
||||||
|
resolution: {integrity: sha512-LEiN/xe4OSIbKe9HQIp5OC24agGD9J5CnmMgsLohVVoOPWL9a2sBoR6VBx43jQZb7Kr1l4RCuyCJzcAa0+dojw==}
|
||||||
|
peerDependencies:
|
||||||
|
msw: ^2.4.9
|
||||||
|
vite: ^6.0.0 || ^7.0.0 || ^8.0.0
|
||||||
|
peerDependenciesMeta:
|
||||||
|
msw:
|
||||||
|
optional: true
|
||||||
|
vite:
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@vitest/pretty-format@4.1.8':
|
||||||
|
resolution: {integrity: sha512-9GasEBxpZ1VYIpqHf/0+YGg121uSNwCKOJqIrTwWP/TB7DmFCiaBpNl3aPZzoLWfWkuqhbH8vJIVobZkvdo2cA==}
|
||||||
|
|
||||||
|
'@vitest/runner@4.1.8':
|
||||||
|
resolution: {integrity: sha512-EmVxeBAfMJvycdjd6Hm+RbFBbA9fKvo0Kx37hNpBYoYeavH3RNsBXWDooR1mgD52dCrxIIuP7UotpfiwOikvcg==}
|
||||||
|
|
||||||
|
'@vitest/snapshot@4.1.8':
|
||||||
|
resolution: {integrity: sha512-acfZboRmAIf05DEKcBQy33VXojFJjtUdLyo7oOmV9kebb2xdU01UknNiPuPZoJZQyO7DF0gZdTGTpeAzET9QPQ==}
|
||||||
|
|
||||||
|
'@vitest/spy@4.1.8':
|
||||||
|
resolution: {integrity: sha512-6EevtBp6OZOPF7bmz36HrGMeP3txgVSrgebWxHOafDXGkhIzfXK14f8KF6MuFfgXXUeHxmpD3BQxkV00/3s5mA==}
|
||||||
|
|
||||||
|
'@vitest/utils@4.1.8':
|
||||||
|
resolution: {integrity: sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg==}
|
||||||
|
|
||||||
|
assertion-error@2.0.1:
|
||||||
|
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
baseline-browser-mapping@2.10.34:
|
baseline-browser-mapping@2.10.34:
|
||||||
resolution: {integrity: sha512-IMDedajPifLnHNY0X9n8hKxRTQ6/eTHwr5bDo04WnuqxyKw6LYtQywCuuqPZwhl3aBXMvQpJov42GLCwRRdQzw==}
|
resolution: {integrity: sha512-IMDedajPifLnHNY0X9n8hKxRTQ6/eTHwr5bDo04WnuqxyKw6LYtQywCuuqPZwhl3aBXMvQpJov42GLCwRRdQzw==}
|
||||||
engines: {node: '>=6.0.0'}
|
engines: {node: '>=6.0.0'}
|
||||||
@@ -557,6 +615,10 @@ packages:
|
|||||||
caniuse-lite@1.0.30001797:
|
caniuse-lite@1.0.30001797:
|
||||||
resolution: {integrity: sha512-l8xKG+gwAIExZGl9FrF7KUwuOmk6wbEPC9Xoy/RtnWv1XG0Q4LFlagaLpUv3Kiza3W/wm27zy0yWJEieYKAP6w==}
|
resolution: {integrity: sha512-l8xKG+gwAIExZGl9FrF7KUwuOmk6wbEPC9Xoy/RtnWv1XG0Q4LFlagaLpUv3Kiza3W/wm27zy0yWJEieYKAP6w==}
|
||||||
|
|
||||||
|
chai@6.2.2:
|
||||||
|
resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
clsx@2.1.1:
|
clsx@2.1.1:
|
||||||
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
|
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
@@ -587,6 +649,9 @@ packages:
|
|||||||
electron-to-chromium@1.5.368:
|
electron-to-chromium@1.5.368:
|
||||||
resolution: {integrity: sha512-7RckJJK4uESJF9PxvfMWd3TGqIiieUTG4HxnKaKuIpGbcr+r2ZEB3g2gAhCP3Fqm42vJSzLfgab9eva/C4/XVw==}
|
resolution: {integrity: sha512-7RckJJK4uESJF9PxvfMWd3TGqIiieUTG4HxnKaKuIpGbcr+r2ZEB3g2gAhCP3Fqm42vJSzLfgab9eva/C4/XVw==}
|
||||||
|
|
||||||
|
es-module-lexer@2.1.0:
|
||||||
|
resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==}
|
||||||
|
|
||||||
esbuild@0.25.12:
|
esbuild@0.25.12:
|
||||||
resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==}
|
resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@@ -596,6 +661,13 @@ packages:
|
|||||||
resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
|
resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
|
estree-walker@3.0.3:
|
||||||
|
resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==}
|
||||||
|
|
||||||
|
expect-type@1.3.0:
|
||||||
|
resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==}
|
||||||
|
engines: {node: '>=12.0.0'}
|
||||||
|
|
||||||
fdir@6.5.0:
|
fdir@6.5.0:
|
||||||
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
|
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
|
||||||
engines: {node: '>=12.0.0'}
|
engines: {node: '>=12.0.0'}
|
||||||
@@ -634,6 +706,9 @@ packages:
|
|||||||
lru-cache@5.1.1:
|
lru-cache@5.1.1:
|
||||||
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
|
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
|
||||||
|
|
||||||
|
magic-string@0.30.21:
|
||||||
|
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
|
||||||
|
|
||||||
ms@2.1.3:
|
ms@2.1.3:
|
||||||
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
|
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
|
||||||
|
|
||||||
@@ -642,10 +717,25 @@ packages:
|
|||||||
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
nats.ws@1.30.3:
|
||||||
|
resolution: {integrity: sha512-aM77V2SEc+B6lbxCMZK3qfRy4jg8pmHj+wZzQKDiDIQYhLPj6U2NSHHBex0syj72Ayzl4uR5Lp3aKXTaVLbRpw==}
|
||||||
|
deprecated: 'Package deprecated. Use @nats-io/nats-core or nats.js instead: https://github.com/nats-io/nats.js'
|
||||||
|
|
||||||
|
nkeys.js@1.1.0:
|
||||||
|
resolution: {integrity: sha512-tB/a0shZL5UZWSwsoeyqfTszONTt4k2YS0tuQioMOD180+MbombYVgzDUYHlx+gejYK6rgf08n/2Df99WY0Sxg==}
|
||||||
|
engines: {node: '>=10.0.0'}
|
||||||
|
|
||||||
node-releases@2.0.47:
|
node-releases@2.0.47:
|
||||||
resolution: {integrity: sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og==}
|
resolution: {integrity: sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
|
obug@2.1.3:
|
||||||
|
resolution: {integrity: sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==}
|
||||||
|
engines: {node: '>=12.20.0'}
|
||||||
|
|
||||||
|
pathe@2.0.3:
|
||||||
|
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
|
||||||
|
|
||||||
picocolors@1.1.1:
|
picocolors@1.1.1:
|
||||||
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
|
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
|
||||||
|
|
||||||
@@ -751,10 +841,19 @@ packages:
|
|||||||
resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
|
resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
siginfo@2.0.0:
|
||||||
|
resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
|
||||||
|
|
||||||
source-map-js@1.2.1:
|
source-map-js@1.2.1:
|
||||||
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
|
stackback@0.0.2:
|
||||||
|
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
|
||||||
|
|
||||||
|
std-env@4.1.0:
|
||||||
|
resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==}
|
||||||
|
|
||||||
sugarss@5.0.1:
|
sugarss@5.0.1:
|
||||||
resolution: {integrity: sha512-ctS5RYCBVvPoZAnzIaX5QSShK8ZiZxD5HUqSxlusvEMC+QZQIPCPOIJg6aceFX+K2rf4+SH89eu++h1Zmsr2nw==}
|
resolution: {integrity: sha512-ctS5RYCBVvPoZAnzIaX5QSShK8ZiZxD5HUqSxlusvEMC+QZQIPCPOIJg6aceFX+K2rf4+SH89eu++h1Zmsr2nw==}
|
||||||
engines: {node: '>=18.0'}
|
engines: {node: '>=18.0'}
|
||||||
@@ -768,13 +867,27 @@ packages:
|
|||||||
resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==}
|
resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==}
|
||||||
engines: {node: '>=20'}
|
engines: {node: '>=20'}
|
||||||
|
|
||||||
|
tinybench@2.9.0:
|
||||||
|
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
|
||||||
|
|
||||||
|
tinyexec@1.2.4:
|
||||||
|
resolution: {integrity: sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
tinyglobby@0.2.17:
|
tinyglobby@0.2.17:
|
||||||
resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==}
|
resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==}
|
||||||
engines: {node: '>=12.0.0'}
|
engines: {node: '>=12.0.0'}
|
||||||
|
|
||||||
|
tinyrainbow@3.1.0:
|
||||||
|
resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==}
|
||||||
|
engines: {node: '>=14.0.0'}
|
||||||
|
|
||||||
tslib@2.8.1:
|
tslib@2.8.1:
|
||||||
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
|
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
|
||||||
|
|
||||||
|
tweetnacl@1.0.3:
|
||||||
|
resolution: {integrity: sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==}
|
||||||
|
|
||||||
type-fest@5.7.0:
|
type-fest@5.7.0:
|
||||||
resolution: {integrity: sha512-1URUxUqfHFM1c+zfSPsa3gnkO7Aq21qyH75SIduNYz4SzY964rn1X2vCMQaHSHhktiw+0kPa2iyb6PUpXqB6Vg==}
|
resolution: {integrity: sha512-1URUxUqfHFM1c+zfSPsa3gnkO7Aq21qyH75SIduNYz4SzY964rn1X2vCMQaHSHhktiw+0kPa2iyb6PUpXqB6Vg==}
|
||||||
engines: {node: '>=20'}
|
engines: {node: '>=20'}
|
||||||
@@ -853,6 +966,52 @@ packages:
|
|||||||
yaml:
|
yaml:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
vitest@4.1.8:
|
||||||
|
resolution: {integrity: sha512-flY6ScbCIt9HThs+C5HS7jvGOB560DJtk/Z15IQROTA6zEy49Nh8T/dofWTQL+n3vswqn87sbJNiuqw1SDp5Ig==}
|
||||||
|
engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0}
|
||||||
|
hasBin: true
|
||||||
|
peerDependencies:
|
||||||
|
'@edge-runtime/vm': '*'
|
||||||
|
'@opentelemetry/api': ^1.9.0
|
||||||
|
'@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0
|
||||||
|
'@vitest/browser-playwright': 4.1.8
|
||||||
|
'@vitest/browser-preview': 4.1.8
|
||||||
|
'@vitest/browser-webdriverio': 4.1.8
|
||||||
|
'@vitest/coverage-istanbul': 4.1.8
|
||||||
|
'@vitest/coverage-v8': 4.1.8
|
||||||
|
'@vitest/ui': 4.1.8
|
||||||
|
happy-dom: '*'
|
||||||
|
jsdom: '*'
|
||||||
|
vite: ^6.0.0 || ^7.0.0 || ^8.0.0
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@edge-runtime/vm':
|
||||||
|
optional: true
|
||||||
|
'@opentelemetry/api':
|
||||||
|
optional: true
|
||||||
|
'@types/node':
|
||||||
|
optional: true
|
||||||
|
'@vitest/browser-playwright':
|
||||||
|
optional: true
|
||||||
|
'@vitest/browser-preview':
|
||||||
|
optional: true
|
||||||
|
'@vitest/browser-webdriverio':
|
||||||
|
optional: true
|
||||||
|
'@vitest/coverage-istanbul':
|
||||||
|
optional: true
|
||||||
|
'@vitest/coverage-v8':
|
||||||
|
optional: true
|
||||||
|
'@vitest/ui':
|
||||||
|
optional: true
|
||||||
|
happy-dom:
|
||||||
|
optional: true
|
||||||
|
jsdom:
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
why-is-node-running@2.3.0:
|
||||||
|
resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==}
|
||||||
|
engines: {node: '>=8'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
yallist@3.1.1:
|
yallist@3.1.1:
|
||||||
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
|
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
|
||||||
|
|
||||||
@@ -1109,6 +1268,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
react: 19.2.7
|
react: 19.2.7
|
||||||
|
|
||||||
|
'@noble/ciphers@2.2.0': {}
|
||||||
|
|
||||||
'@noble/curves@2.2.0':
|
'@noble/curves@2.2.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@noble/hashes': 2.2.0
|
'@noble/hashes': 2.2.0
|
||||||
@@ -1199,6 +1360,8 @@ snapshots:
|
|||||||
'@noble/hashes': 2.2.0
|
'@noble/hashes': 2.2.0
|
||||||
'@scure/base': 2.2.0
|
'@scure/base': 2.2.0
|
||||||
|
|
||||||
|
'@standard-schema/spec@1.1.0': {}
|
||||||
|
|
||||||
'@tabler/icons-react@3.44.0(react@19.2.7)':
|
'@tabler/icons-react@3.44.0(react@19.2.7)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@tabler/icons': 3.44.0
|
'@tabler/icons': 3.44.0
|
||||||
@@ -1227,6 +1390,13 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@babel/types': 7.29.7
|
'@babel/types': 7.29.7
|
||||||
|
|
||||||
|
'@types/chai@5.2.3':
|
||||||
|
dependencies:
|
||||||
|
'@types/deep-eql': 4.0.2
|
||||||
|
assertion-error: 2.0.1
|
||||||
|
|
||||||
|
'@types/deep-eql@4.0.2': {}
|
||||||
|
|
||||||
'@types/estree@1.0.9': {}
|
'@types/estree@1.0.9': {}
|
||||||
|
|
||||||
'@types/react-dom@19.2.3(@types/react@19.2.17)':
|
'@types/react-dom@19.2.3(@types/react@19.2.17)':
|
||||||
@@ -1249,6 +1419,49 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
|
'@vitest/expect@4.1.8':
|
||||||
|
dependencies:
|
||||||
|
'@standard-schema/spec': 1.1.0
|
||||||
|
'@types/chai': 5.2.3
|
||||||
|
'@vitest/spy': 4.1.8
|
||||||
|
'@vitest/utils': 4.1.8
|
||||||
|
chai: 6.2.2
|
||||||
|
tinyrainbow: 3.1.0
|
||||||
|
|
||||||
|
'@vitest/mocker@4.1.8(vite@6.4.3(sugarss@5.0.1(postcss@8.5.15)))':
|
||||||
|
dependencies:
|
||||||
|
'@vitest/spy': 4.1.8
|
||||||
|
estree-walker: 3.0.3
|
||||||
|
magic-string: 0.30.21
|
||||||
|
optionalDependencies:
|
||||||
|
vite: 6.4.3(sugarss@5.0.1(postcss@8.5.15))
|
||||||
|
|
||||||
|
'@vitest/pretty-format@4.1.8':
|
||||||
|
dependencies:
|
||||||
|
tinyrainbow: 3.1.0
|
||||||
|
|
||||||
|
'@vitest/runner@4.1.8':
|
||||||
|
dependencies:
|
||||||
|
'@vitest/utils': 4.1.8
|
||||||
|
pathe: 2.0.3
|
||||||
|
|
||||||
|
'@vitest/snapshot@4.1.8':
|
||||||
|
dependencies:
|
||||||
|
'@vitest/pretty-format': 4.1.8
|
||||||
|
'@vitest/utils': 4.1.8
|
||||||
|
magic-string: 0.30.21
|
||||||
|
pathe: 2.0.3
|
||||||
|
|
||||||
|
'@vitest/spy@4.1.8': {}
|
||||||
|
|
||||||
|
'@vitest/utils@4.1.8':
|
||||||
|
dependencies:
|
||||||
|
'@vitest/pretty-format': 4.1.8
|
||||||
|
convert-source-map: 2.0.0
|
||||||
|
tinyrainbow: 3.1.0
|
||||||
|
|
||||||
|
assertion-error@2.0.1: {}
|
||||||
|
|
||||||
baseline-browser-mapping@2.10.34: {}
|
baseline-browser-mapping@2.10.34: {}
|
||||||
|
|
||||||
browserslist@4.28.2:
|
browserslist@4.28.2:
|
||||||
@@ -1263,6 +1476,8 @@ snapshots:
|
|||||||
|
|
||||||
caniuse-lite@1.0.30001797: {}
|
caniuse-lite@1.0.30001797: {}
|
||||||
|
|
||||||
|
chai@6.2.2: {}
|
||||||
|
|
||||||
clsx@2.1.1: {}
|
clsx@2.1.1: {}
|
||||||
|
|
||||||
convert-source-map@2.0.0: {}
|
convert-source-map@2.0.0: {}
|
||||||
@@ -1279,6 +1494,8 @@ snapshots:
|
|||||||
|
|
||||||
electron-to-chromium@1.5.368: {}
|
electron-to-chromium@1.5.368: {}
|
||||||
|
|
||||||
|
es-module-lexer@2.1.0: {}
|
||||||
|
|
||||||
esbuild@0.25.12:
|
esbuild@0.25.12:
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@esbuild/aix-ppc64': 0.25.12
|
'@esbuild/aix-ppc64': 0.25.12
|
||||||
@@ -1310,6 +1527,12 @@ snapshots:
|
|||||||
|
|
||||||
escalade@3.2.0: {}
|
escalade@3.2.0: {}
|
||||||
|
|
||||||
|
estree-walker@3.0.3:
|
||||||
|
dependencies:
|
||||||
|
'@types/estree': 1.0.9
|
||||||
|
|
||||||
|
expect-type@1.3.0: {}
|
||||||
|
|
||||||
fdir@6.5.0(picomatch@4.0.4):
|
fdir@6.5.0(picomatch@4.0.4):
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
picomatch: 4.0.4
|
picomatch: 4.0.4
|
||||||
@@ -1331,12 +1554,29 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
yallist: 3.1.1
|
yallist: 3.1.1
|
||||||
|
|
||||||
|
magic-string@0.30.21:
|
||||||
|
dependencies:
|
||||||
|
'@jridgewell/sourcemap-codec': 1.5.5
|
||||||
|
|
||||||
ms@2.1.3: {}
|
ms@2.1.3: {}
|
||||||
|
|
||||||
nanoid@3.3.12: {}
|
nanoid@3.3.12: {}
|
||||||
|
|
||||||
|
nats.ws@1.30.3:
|
||||||
|
optionalDependencies:
|
||||||
|
nkeys.js: 1.1.0
|
||||||
|
|
||||||
|
nkeys.js@1.1.0:
|
||||||
|
dependencies:
|
||||||
|
tweetnacl: 1.0.3
|
||||||
|
optional: true
|
||||||
|
|
||||||
node-releases@2.0.47: {}
|
node-releases@2.0.47: {}
|
||||||
|
|
||||||
|
obug@2.1.3: {}
|
||||||
|
|
||||||
|
pathe@2.0.3: {}
|
||||||
|
|
||||||
picocolors@1.1.1: {}
|
picocolors@1.1.1: {}
|
||||||
|
|
||||||
picomatch@4.0.4: {}
|
picomatch@4.0.4: {}
|
||||||
@@ -1456,8 +1696,14 @@ snapshots:
|
|||||||
|
|
||||||
semver@6.3.1: {}
|
semver@6.3.1: {}
|
||||||
|
|
||||||
|
siginfo@2.0.0: {}
|
||||||
|
|
||||||
source-map-js@1.2.1: {}
|
source-map-js@1.2.1: {}
|
||||||
|
|
||||||
|
stackback@0.0.2: {}
|
||||||
|
|
||||||
|
std-env@4.1.0: {}
|
||||||
|
|
||||||
sugarss@5.0.1(postcss@8.5.15):
|
sugarss@5.0.1(postcss@8.5.15):
|
||||||
dependencies:
|
dependencies:
|
||||||
postcss: 8.5.15
|
postcss: 8.5.15
|
||||||
@@ -1466,13 +1712,21 @@ snapshots:
|
|||||||
|
|
||||||
tagged-tag@1.0.0: {}
|
tagged-tag@1.0.0: {}
|
||||||
|
|
||||||
|
tinybench@2.9.0: {}
|
||||||
|
|
||||||
|
tinyexec@1.2.4: {}
|
||||||
|
|
||||||
tinyglobby@0.2.17:
|
tinyglobby@0.2.17:
|
||||||
dependencies:
|
dependencies:
|
||||||
fdir: 6.5.0(picomatch@4.0.4)
|
fdir: 6.5.0(picomatch@4.0.4)
|
||||||
picomatch: 4.0.4
|
picomatch: 4.0.4
|
||||||
|
|
||||||
|
tinyrainbow@3.1.0: {}
|
||||||
|
|
||||||
tslib@2.8.1: {}
|
tslib@2.8.1: {}
|
||||||
|
|
||||||
|
tweetnacl@1.0.3: {}
|
||||||
|
|
||||||
type-fest@5.7.0:
|
type-fest@5.7.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
tagged-tag: 1.0.0
|
tagged-tag: 1.0.0
|
||||||
@@ -1514,4 +1768,34 @@ snapshots:
|
|||||||
fsevents: 2.3.3
|
fsevents: 2.3.3
|
||||||
sugarss: 5.0.1(postcss@8.5.15)
|
sugarss: 5.0.1(postcss@8.5.15)
|
||||||
|
|
||||||
|
vitest@4.1.8(vite@6.4.3(sugarss@5.0.1(postcss@8.5.15))):
|
||||||
|
dependencies:
|
||||||
|
'@vitest/expect': 4.1.8
|
||||||
|
'@vitest/mocker': 4.1.8(vite@6.4.3(sugarss@5.0.1(postcss@8.5.15)))
|
||||||
|
'@vitest/pretty-format': 4.1.8
|
||||||
|
'@vitest/runner': 4.1.8
|
||||||
|
'@vitest/snapshot': 4.1.8
|
||||||
|
'@vitest/spy': 4.1.8
|
||||||
|
'@vitest/utils': 4.1.8
|
||||||
|
es-module-lexer: 2.1.0
|
||||||
|
expect-type: 1.3.0
|
||||||
|
magic-string: 0.30.21
|
||||||
|
obug: 2.1.3
|
||||||
|
pathe: 2.0.3
|
||||||
|
picomatch: 4.0.4
|
||||||
|
std-env: 4.1.0
|
||||||
|
tinybench: 2.9.0
|
||||||
|
tinyexec: 1.2.4
|
||||||
|
tinyglobby: 0.2.17
|
||||||
|
tinyrainbow: 3.1.0
|
||||||
|
vite: 6.4.3(sugarss@5.0.1(postcss@8.5.15))
|
||||||
|
why-is-node-running: 2.3.0
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- msw
|
||||||
|
|
||||||
|
why-is-node-running@2.3.0:
|
||||||
|
dependencies:
|
||||||
|
siginfo: 2.0.0
|
||||||
|
stackback: 0.0.2
|
||||||
|
|
||||||
yallist@3.1.1: {}
|
yallist@3.1.1: {}
|
||||||
|
|||||||
+12
-11
@@ -5,7 +5,7 @@ import { Join } from "./Join";
|
|||||||
import { Recover } from "./Recover";
|
import { Recover } from "./Recover";
|
||||||
import { WalletLogin } from "./WalletLogin";
|
import { WalletLogin } from "./WalletLogin";
|
||||||
import { Welcome } from "./Welcome";
|
import { Welcome } from "./Welcome";
|
||||||
import { api } from "./api";
|
import { bus } from "./busService";
|
||||||
import { localIdentity } from "./wallet/account";
|
import { localIdentity } from "./wallet/account";
|
||||||
import type { User } from "./types";
|
import type { User } from "./types";
|
||||||
|
|
||||||
@@ -31,9 +31,12 @@ export function App() {
|
|||||||
const [token, setToken] = useState("");
|
const [token, setToken] = useState("");
|
||||||
const [storedHandle, setStoredHandle] = useState("");
|
const [storedHandle, setStoredHandle] = useState("");
|
||||||
|
|
||||||
// Decide the entry screen on mount: an invite link goes straight to join; a live
|
// Decide the entry screen on mount: an invite link goes straight to join; otherwise
|
||||||
// gateway session resumes the chat; a device with a stored identity shows the
|
// try to RESTORE a persisted session (survives reloads, and — with "keep me signed
|
||||||
// password unlock; an empty device shows the welcome chooser.
|
// in" — closing the browser, up to its TTL/idle limits) so a reload does not force a
|
||||||
|
// re-unlock; if there is none, a device with a stored identity shows the password
|
||||||
|
// unlock and an empty device shows the welcome chooser. The private key stays in the
|
||||||
|
// browser throughout; nothing is resumed from a server-side cookie.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const t = readJoinToken();
|
const t = readJoinToken();
|
||||||
if (t) {
|
if (t) {
|
||||||
@@ -43,14 +46,12 @@ export function App() {
|
|||||||
}
|
}
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
const restored = await bus.restoreSession();
|
||||||
const me = await api.me();
|
if (cancelled) return;
|
||||||
if (cancelled) return;
|
if (restored) {
|
||||||
setUser({ id: me.endpoint, handle: me.handle || me.endpoint.slice(0, 8) });
|
setUser(restored);
|
||||||
setRoute("chat");
|
setRoute("chat");
|
||||||
return;
|
return;
|
||||||
} catch {
|
|
||||||
// no live session — fall through
|
|
||||||
}
|
}
|
||||||
const stored = await localIdentity();
|
const stored = await localIdentity();
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
@@ -73,7 +74,7 @@ export function App() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const logout = () => {
|
const logout = () => {
|
||||||
void api.logout().catch(() => {});
|
void bus.logout().catch(() => {});
|
||||||
setUser(null);
|
setUser(null);
|
||||||
// Keep the encrypted identity on the device: logging out returns to the
|
// Keep the encrypted identity on the device: logging out returns to the
|
||||||
// password unlock, not a full reset.
|
// password unlock, not a full reset.
|
||||||
|
|||||||
+24
-12
@@ -19,7 +19,7 @@ import {
|
|||||||
IconDotsVertical,
|
IconDotsVertical,
|
||||||
IconPaperclip,
|
IconPaperclip,
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
import { api, streamRoom } from "./api";
|
import { bus } from "./busService";
|
||||||
import type { Message, Room } from "./types";
|
import type { Message, Room } from "./types";
|
||||||
|
|
||||||
function initials(s: string) {
|
function initials(s: string) {
|
||||||
@@ -33,15 +33,23 @@ function timeShort(ts: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function MessageRow({ msg }: { msg: Message }) {
|
function MessageRow({ msg }: { msg: Message }) {
|
||||||
|
// Show the readable handle (resolved from the bus directory); the raw endpoint id
|
||||||
|
// stays in the title attribute as a debugging tooltip.
|
||||||
|
const name = bus.displayName(msg.sender);
|
||||||
return (
|
return (
|
||||||
<Group align="flex-start" gap="sm" wrap="nowrap">
|
<Group align="flex-start" gap="sm" wrap="nowrap">
|
||||||
<Avatar radius="xl" size={36} color={msg.mine ? "brand" : "gray"}>
|
<Avatar radius="xl" size={36} color={msg.mine ? "brand" : "gray"}>
|
||||||
{initials(msg.sender)}
|
{initials(name)}
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<Box style={{ minWidth: 0 }}>
|
<Box style={{ minWidth: 0 }}>
|
||||||
<Group gap={8} align="baseline">
|
<Group gap={8} align="baseline">
|
||||||
<Text size="sm" fw={600} c={msg.mine ? "brand.4" : undefined}>
|
<Text
|
||||||
{msg.sender}
|
size="sm"
|
||||||
|
fw={600}
|
||||||
|
c={msg.mine ? "brand.4" : undefined}
|
||||||
|
title={msg.sender}
|
||||||
|
>
|
||||||
|
{name}
|
||||||
</Text>
|
</Text>
|
||||||
<Text size="xs" c="dimmed">
|
<Text size="xs" c="dimmed">
|
||||||
{timeShort(msg.ts)}
|
{timeShort(msg.ts)}
|
||||||
@@ -61,16 +69,20 @@ export function ChatPanel({ room }: { room: Room | undefined }) {
|
|||||||
const [sendError, setSendError] = useState<string | null>(null);
|
const [sendError, setSendError] = useState<string | null>(null);
|
||||||
const viewport = useRef<HTMLDivElement>(null);
|
const viewport = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
// Abre el stream SSE de la room activa. El gateway entrega historia (rooms
|
// Carga el histórico de la room activa y luego sigue en vivo: bus.subscribeRoom
|
||||||
// persistidas) y luego mensajes en vivo, ya descifrados. Dedup por id porque
|
// entrega primero la historia (oldest->newest) y después los mensajes en vivo, ya
|
||||||
// un re-render no debe duplicar y el eco del propio envío llega por aquí.
|
// descifrados y deduplicados por id. Aquí se mantiene la lista ordenada por ts y se
|
||||||
|
// deduplica de nuevo por id, porque un re-render no debe duplicar y el eco del propio
|
||||||
|
// envío también llega por esta vía.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setMessages([]);
|
setMessages([]);
|
||||||
setSendError(null);
|
setSendError(null);
|
||||||
if (!room) return;
|
if (!room) return;
|
||||||
const close = streamRoom(room.id, (m) => {
|
const close = bus.subscribeRoom(room.id, (m) => {
|
||||||
setMessages((prev) =>
|
setMessages((prev) =>
|
||||||
prev.some((p) => p.id === m.id) ? prev : [...prev, m],
|
prev.some((p) => p.id === m.id)
|
||||||
|
? prev
|
||||||
|
: [...prev, m].sort((a, b) => a.ts - b.ts),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
return close;
|
return close;
|
||||||
@@ -94,9 +106,9 @@ export function ChatPanel({ room }: { room: Room | undefined }) {
|
|||||||
setDraft("");
|
setDraft("");
|
||||||
setSendError(null);
|
setSendError(null);
|
||||||
try {
|
try {
|
||||||
// No optimista: el mensaje propio vuelve por SSE con su id real (mine:true),
|
// No optimista: el mensaje propio vuelve por la suscripción con su id real
|
||||||
// evitando duplicados.
|
// (mine:true), evitando duplicados.
|
||||||
await api.send(room.id, body);
|
await bus.send(room.id, body);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setDraft(body); // restaura el borrador si el envío falló
|
setDraft(body); // restaura el borrador si el envío falló
|
||||||
setSendError(e instanceof Error ? e.message : "No se pudo enviar");
|
setSendError(e instanceof Error ? e.message : "No se pudo enviar");
|
||||||
|
|||||||
+55
-10
@@ -1,8 +1,11 @@
|
|||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { Flex, Box, Center, Loader, Stack, Text, Button } from "@mantine/core";
|
import { Flex, Box, Center, Loader, Stack, Text, Button } from "@mantine/core";
|
||||||
|
import { useDisclosure } from "@mantine/hooks";
|
||||||
|
import { IconPlus } from "@tabler/icons-react";
|
||||||
import { Sidebar } from "./Sidebar";
|
import { Sidebar } from "./Sidebar";
|
||||||
import { ChatPanel } from "./ChatPanel";
|
import { ChatPanel } from "./ChatPanel";
|
||||||
import { api } from "./api";
|
import { NewRoomModal } from "./NewRoomModal";
|
||||||
|
import { bus } from "./busService";
|
||||||
import type { Room, User } from "./types";
|
import type { Room, User } from "./types";
|
||||||
|
|
||||||
export function ChatShell({
|
export function ChatShell({
|
||||||
@@ -16,16 +19,34 @@ export function ChatShell({
|
|||||||
const [activeId, setActiveId] = useState<string>("");
|
const [activeId, setActiveId] = useState<string>("");
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [modalOpen, modal] = useDisclosure(false);
|
||||||
|
|
||||||
|
// The room list lives in busService (it owns a per-room metadata subscription so the
|
||||||
|
// sidebar shows the latest message/time and unread for rooms not being viewed). The
|
||||||
|
// shell just mirrors the store into React state.
|
||||||
|
useEffect(() => bus.watchRooms(setRooms), []);
|
||||||
|
|
||||||
|
// selectRoom activates a room in the UI and tells the store, which clears that room's
|
||||||
|
// unread badge.
|
||||||
|
const selectRoom = useCallback((id: string) => {
|
||||||
|
setActiveId(id);
|
||||||
|
bus.setActiveRoom(id);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// La room recién creada ya está en el store (bus.createRoom la insertó); aquí solo
|
||||||
|
// se activa.
|
||||||
|
const handleRoomCreated = useCallback(
|
||||||
|
(room: Room) => {
|
||||||
|
selectRoom(room.id);
|
||||||
|
},
|
||||||
|
[selectRoom],
|
||||||
|
);
|
||||||
|
|
||||||
const load = useCallback(() => {
|
const load = useCallback(() => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
api
|
bus
|
||||||
.listRooms()
|
.loadRooms()
|
||||||
.then((rs) => {
|
.then(() => setError(null))
|
||||||
setRooms(rs);
|
|
||||||
setActiveId((cur) => cur || rs[0]?.id || "");
|
|
||||||
setError(null);
|
|
||||||
})
|
|
||||||
.catch((e) => setError(e?.message ?? "No se pudieron cargar las rooms"))
|
.catch((e) => setError(e?.message ?? "No se pudieron cargar las rooms"))
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, []);
|
}, []);
|
||||||
@@ -34,6 +55,11 @@ export function ChatShell({
|
|||||||
load();
|
load();
|
||||||
}, [load]);
|
}, [load]);
|
||||||
|
|
||||||
|
// Activa la primera room en cuanto la lista se puebla y aún no hay ninguna activa.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!activeId && rooms.length > 0) selectRoom(rooms[0].id);
|
||||||
|
}, [rooms, activeId, selectRoom]);
|
||||||
|
|
||||||
const active = rooms.find((r) => r.id === activeId);
|
const active = rooms.find((r) => r.id === activeId);
|
||||||
|
|
||||||
// El panel derecho muestra el estado de carga/error/empty sin tocar el layout.
|
// El panel derecho muestra el estado de carga/error/empty sin tocar el layout.
|
||||||
@@ -60,7 +86,20 @@ export function ChatShell({
|
|||||||
} else if (rooms.length === 0) {
|
} else if (rooms.length === 0) {
|
||||||
panel = (
|
panel = (
|
||||||
<Center h="100%">
|
<Center h="100%">
|
||||||
<Text c="dimmed">No perteneces a ninguna room todavía</Text>
|
<Stack align="center" gap="sm" maw={320} ta="center">
|
||||||
|
<Text fw={600}>Aún no hay conversaciones</Text>
|
||||||
|
<Text c="dimmed" size="sm">
|
||||||
|
Crea tu primera room cifrada para empezar a chatear.
|
||||||
|
</Text>
|
||||||
|
<Button
|
||||||
|
color="brand"
|
||||||
|
leftSection={<IconPlus size={16} />}
|
||||||
|
onClick={modal.open}
|
||||||
|
mt="xs"
|
||||||
|
>
|
||||||
|
Crear room
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
</Center>
|
</Center>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -80,13 +119,19 @@ export function ChatShell({
|
|||||||
user={user}
|
user={user}
|
||||||
rooms={rooms}
|
rooms={rooms}
|
||||||
activeId={activeId}
|
activeId={activeId}
|
||||||
onSelect={setActiveId}
|
onSelect={selectRoom}
|
||||||
onLogout={onLogout}
|
onLogout={onLogout}
|
||||||
|
onNewRoom={modal.open}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
<Box flex={1} h="100%" bg="dark.7" style={{ minWidth: 0 }}>
|
<Box flex={1} h="100%" bg="dark.7" style={{ minWidth: 0 }}>
|
||||||
{panel}
|
{panel}
|
||||||
</Box>
|
</Box>
|
||||||
|
<NewRoomModal
|
||||||
|
opened={modalOpen}
|
||||||
|
onClose={modal.close}
|
||||||
|
onCreated={handleRoomCreated}
|
||||||
|
/>
|
||||||
</Flex>
|
</Flex>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+14
-6
@@ -21,7 +21,7 @@ import {
|
|||||||
IconKey,
|
IconKey,
|
||||||
IconShieldLock,
|
IconShieldLock,
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
import { api, ApiError } from "./api";
|
import { SessionError } from "./busService";
|
||||||
import { AuthCard, AuthHeader } from "./AuthShell";
|
import { AuthCard, AuthHeader } from "./AuthShell";
|
||||||
import type { User } from "./types";
|
import type { User } from "./types";
|
||||||
import { newMnemonic, mnemonicWords } from "./wallet/bip39";
|
import { newMnemonic, mnemonicWords } from "./wallet/bip39";
|
||||||
@@ -124,14 +124,22 @@ export function Join({
|
|||||||
setStep("joining");
|
setStep("joining");
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
// Register the PUBLIC identity with the bus (token authorizes), then
|
// The bus has no token-register endpoint (that was a gateway mock): a
|
||||||
// encrypt the private key locally and open the per-user session.
|
// browser cannot self-register on an enforce cluster. The identity must be
|
||||||
const res = await api.register(token, identity.signPub, identity.kexPub);
|
// allow-listed by an admin first. We persist it locally and try to open the
|
||||||
const user = await saveAndOpen(identity, res.handle, password);
|
// session; if the identity is not yet authorized, openSession fails and we
|
||||||
|
// tell the user to have an admin authorize their public key.
|
||||||
|
const handle = identity.signPub.slice(0, 8);
|
||||||
|
// Creating the account on this device implies it is yours: keep the session.
|
||||||
|
const user = await saveAndOpen(identity, handle, password, true);
|
||||||
onJoined(user);
|
onJoined(user);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
const base =
|
||||||
|
e instanceof SessionError || e instanceof Error
|
||||||
|
? e.message
|
||||||
|
: "No se pudo completar el alta.";
|
||||||
setError(
|
setError(
|
||||||
e instanceof ApiError ? e.message : "No se pudo completar el alta.",
|
`${base}. Pide a un administrador que autorice tu clave pública: ${identity.signPub}`,
|
||||||
);
|
);
|
||||||
setStep("password");
|
setStep("password");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,89 +0,0 @@
|
|||||||
import { useState } from "react";
|
|
||||||
import {
|
|
||||||
Button,
|
|
||||||
Card,
|
|
||||||
Center,
|
|
||||||
PasswordInput,
|
|
||||||
Stack,
|
|
||||||
Text,
|
|
||||||
TextInput,
|
|
||||||
ThemeIcon,
|
|
||||||
Title,
|
|
||||||
} from "@mantine/core";
|
|
||||||
import { IconShieldLock, IconKey } from "@tabler/icons-react";
|
|
||||||
import { api, ApiError } from "./api";
|
|
||||||
import type { User } from "./types";
|
|
||||||
|
|
||||||
export function Login({ onLogin }: { onLogin: (u: User) => void }) {
|
|
||||||
const [handle, setHandle] = useState("");
|
|
||||||
const [password, setPassword] = useState("");
|
|
||||||
const [busy, setBusy] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const ready = handle.trim().length > 0 && password.length > 0;
|
|
||||||
const connect = async () => {
|
|
||||||
if (!ready || busy) return;
|
|
||||||
setBusy(true);
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
// La contraseña desbloquea la sesión del gateway (passphrase del operador).
|
|
||||||
// El handle es solo el nombre a mostrar en esta iteración (wallet = fase 2).
|
|
||||||
const me = await api.login(password);
|
|
||||||
const h = handle.trim() || me.endpoint.slice(0, 8);
|
|
||||||
onLogin({ id: me.endpoint, handle: h });
|
|
||||||
} catch (e) {
|
|
||||||
setError(e instanceof ApiError ? e.message : "No se pudo conectar al gateway");
|
|
||||||
setBusy(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Center h="100vh" bg="dark.9">
|
|
||||||
<Card w={380} p="xl" radius="lg" withBorder bg="dark.7">
|
|
||||||
<Stack align="center" gap="lg">
|
|
||||||
<ThemeIcon size={60} radius="xl" variant="light" color="brand">
|
|
||||||
<IconShieldLock size={32} />
|
|
||||||
</ThemeIcon>
|
|
||||||
<Stack gap={2} align="center">
|
|
||||||
<Title order={2}>unibus</Title>
|
|
||||||
<Text c="dimmed" size="sm">
|
|
||||||
Mensajería cifrada de extremo a extremo
|
|
||||||
</Text>
|
|
||||||
</Stack>
|
|
||||||
<TextInput
|
|
||||||
w="100%"
|
|
||||||
label="Identidad"
|
|
||||||
placeholder="tu-handle"
|
|
||||||
value={handle}
|
|
||||||
onChange={(e) => setHandle(e.currentTarget.value)}
|
|
||||||
onKeyDown={(e) => e.key === "Enter" && connect()}
|
|
||||||
data-autofocus
|
|
||||||
/>
|
|
||||||
<PasswordInput
|
|
||||||
w="100%"
|
|
||||||
label="Contraseña"
|
|
||||||
description="Desbloquea tu identidad cifrada en este dispositivo"
|
|
||||||
placeholder="••••••••"
|
|
||||||
leftSection={<IconKey size={16} />}
|
|
||||||
value={password}
|
|
||||||
onChange={(e) => setPassword(e.currentTarget.value)}
|
|
||||||
onKeyDown={(e) => e.key === "Enter" && void connect()}
|
|
||||||
/>
|
|
||||||
{error && (
|
|
||||||
<Text c="red" size="sm" ta="center">
|
|
||||||
{error}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
<Button
|
|
||||||
w="100%"
|
|
||||||
size="md"
|
|
||||||
onClick={() => void connect()}
|
|
||||||
disabled={!ready}
|
|
||||||
loading={busy}
|
|
||||||
>
|
|
||||||
Conectar
|
|
||||||
</Button>
|
|
||||||
</Stack>
|
|
||||||
</Card>
|
|
||||||
</Center>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Button,
|
||||||
|
Group,
|
||||||
|
Modal,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { IconAlertCircle, IconLock, IconPlus } from "@tabler/icons-react";
|
||||||
|
import { bus, SessionError } from "./busService";
|
||||||
|
import type { Room } from "./types";
|
||||||
|
|
||||||
|
// NewRoomModal pide el asunto de una room nueva y la crea contra el bus. La room
|
||||||
|
// que devuelve `bus.createRoom` (cifrada + firmada, propiedad del usuario) se
|
||||||
|
// entrega al padre vía `onCreated` para insertarla en la lista sin recargar.
|
||||||
|
export function NewRoomModal({
|
||||||
|
opened,
|
||||||
|
onClose,
|
||||||
|
onCreated,
|
||||||
|
}: {
|
||||||
|
opened: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onCreated: (room: Room) => void;
|
||||||
|
}) {
|
||||||
|
const [subject, setSubject] = useState("");
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Cada vez que se abre el modal partimos de un formulario limpio.
|
||||||
|
useEffect(() => {
|
||||||
|
if (opened) {
|
||||||
|
setSubject("");
|
||||||
|
setBusy(false);
|
||||||
|
setError(null);
|
||||||
|
}
|
||||||
|
}, [opened]);
|
||||||
|
|
||||||
|
const create = async () => {
|
||||||
|
const name = subject.trim();
|
||||||
|
if (!name || busy) return;
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const room = await bus.createRoom(name);
|
||||||
|
onCreated(room);
|
||||||
|
onClose();
|
||||||
|
} catch (e) {
|
||||||
|
const msg =
|
||||||
|
e instanceof SessionError
|
||||||
|
? "Tu sesión con el bus expiró. Vuelve a iniciar sesión."
|
||||||
|
: e instanceof Error
|
||||||
|
? e.message
|
||||||
|
: "No se pudo crear la room";
|
||||||
|
setError(msg);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
opened={opened}
|
||||||
|
onClose={onClose}
|
||||||
|
title="Nueva room"
|
||||||
|
centered
|
||||||
|
radius="md"
|
||||||
|
overlayProps={{ blur: 2 }}
|
||||||
|
>
|
||||||
|
<Stack gap="md">
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
Crea una conversación cifrada de extremo a extremo. Tú eres la dueña y
|
||||||
|
puedes invitar miembros después.
|
||||||
|
</Text>
|
||||||
|
<TextInput
|
||||||
|
label="Asunto"
|
||||||
|
placeholder="ej. equipo-infra, anuncios, 1-a-1 con ana…"
|
||||||
|
data-autofocus
|
||||||
|
value={subject}
|
||||||
|
onChange={(e) => setSubject(e.currentTarget.value)}
|
||||||
|
onKeyDown={(e) => e.key === "Enter" && void create()}
|
||||||
|
leftSection={<IconLock size={16} />}
|
||||||
|
disabled={busy}
|
||||||
|
/>
|
||||||
|
{error && (
|
||||||
|
<Alert
|
||||||
|
color="red"
|
||||||
|
variant="light"
|
||||||
|
icon={<IconAlertCircle size={16} />}
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
<Group justify="flex-end" gap="sm">
|
||||||
|
<Button variant="subtle" color="gray" onClick={onClose} disabled={busy}>
|
||||||
|
Cancelar
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
color="brand"
|
||||||
|
leftSection={<IconPlus size={16} />}
|
||||||
|
onClick={() => void create()}
|
||||||
|
loading={busy}
|
||||||
|
disabled={!subject.trim()}
|
||||||
|
>
|
||||||
|
Crear room
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
+4
-3
@@ -13,7 +13,7 @@ import {
|
|||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { IconKey, IconRotateClockwise } from "@tabler/icons-react";
|
import { IconKey, IconRotateClockwise } from "@tabler/icons-react";
|
||||||
import { AuthCard, AuthHeader } from "./AuthShell";
|
import { AuthCard, AuthHeader } from "./AuthShell";
|
||||||
import { ApiError } from "./api";
|
import { SessionError } from "./busService";
|
||||||
import type { User } from "./types";
|
import type { User } from "./types";
|
||||||
import { isValidMnemonic, mnemonicWords, normalizeMnemonic } from "./wallet/bip39";
|
import { isValidMnemonic, mnemonicWords, normalizeMnemonic } from "./wallet/bip39";
|
||||||
import { deriveIdentity } from "./wallet/derive";
|
import { deriveIdentity } from "./wallet/derive";
|
||||||
@@ -108,11 +108,12 @@ export function Recover({
|
|||||||
try {
|
try {
|
||||||
// No register here: the identity is already in the allowlist. Just re-encrypt
|
// No register here: the identity is already in the allowlist. Just re-encrypt
|
||||||
// locally and open the session as the recovered user.
|
// locally and open the session as the recovered user.
|
||||||
const user = await saveAndOpen(identity, handle.trim(), pw);
|
// Recovering on this device implies it is yours: keep the session by default.
|
||||||
|
const user = await saveAndOpen(identity, handle.trim(), pw, true);
|
||||||
onRecovered(user);
|
onRecovered(user);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(
|
setError(
|
||||||
e instanceof ApiError
|
e instanceof SessionError || e instanceof Error
|
||||||
? e.message
|
? e.message
|
||||||
: "No se pudo abrir la sesión con la identidad recuperada.",
|
: "No se pudo abrir la sesión con la identidad recuperada.",
|
||||||
);
|
);
|
||||||
|
|||||||
+58
-19
@@ -1,5 +1,6 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import {
|
import {
|
||||||
|
ActionIcon,
|
||||||
Avatar,
|
Avatar,
|
||||||
Badge,
|
Badge,
|
||||||
Box,
|
Box,
|
||||||
@@ -10,6 +11,7 @@ import {
|
|||||||
Stack,
|
Stack,
|
||||||
Text,
|
Text,
|
||||||
TextInput,
|
TextInput,
|
||||||
|
Tooltip,
|
||||||
UnstyledButton,
|
UnstyledButton,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import {
|
import {
|
||||||
@@ -18,6 +20,7 @@ import {
|
|||||||
IconDots,
|
IconDots,
|
||||||
IconLock,
|
IconLock,
|
||||||
IconHash,
|
IconHash,
|
||||||
|
IconPlus,
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
import type { Room, User } from "./types";
|
import type { Room, User } from "./types";
|
||||||
|
|
||||||
@@ -25,7 +28,10 @@ function initials(s: string) {
|
|||||||
return s.replace(/[^a-z0-9]/gi, "").slice(0, 2).toUpperCase() || "?";
|
return s.replace(/[^a-z0-9]/gi, "").slice(0, 2).toUpperCase() || "?";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// timeShort renders HH:MM, or an em dash when there is no message yet (ts 0/falsy) so
|
||||||
|
// an empty room does not show the epoch-0 "01:00".
|
||||||
function timeShort(ts: number) {
|
function timeShort(ts: number) {
|
||||||
|
if (!ts) return "—";
|
||||||
const d = new Date(ts);
|
const d = new Date(ts);
|
||||||
return `${String(d.getHours()).padStart(2, "0")}:${String(
|
return `${String(d.getHours()).padStart(2, "0")}:${String(
|
||||||
d.getMinutes(),
|
d.getMinutes(),
|
||||||
@@ -94,12 +100,14 @@ export function Sidebar({
|
|||||||
activeId,
|
activeId,
|
||||||
onSelect,
|
onSelect,
|
||||||
onLogout,
|
onLogout,
|
||||||
|
onNewRoom,
|
||||||
}: {
|
}: {
|
||||||
user: User;
|
user: User;
|
||||||
rooms: Room[];
|
rooms: Room[];
|
||||||
activeId: string;
|
activeId: string;
|
||||||
onSelect: (id: string) => void;
|
onSelect: (id: string) => void;
|
||||||
onLogout: () => void;
|
onLogout: () => void;
|
||||||
|
onNewRoom: () => void;
|
||||||
}) {
|
}) {
|
||||||
const [q, setQ] = useState("");
|
const [q, setQ] = useState("");
|
||||||
const query = q.trim().toLowerCase();
|
const query = q.trim().toLowerCase();
|
||||||
@@ -122,21 +130,35 @@ export function Sidebar({
|
|||||||
{user.handle}
|
{user.handle}
|
||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
<Menu position="bottom-end" withinPortal>
|
<Group gap={4} wrap="nowrap">
|
||||||
<Menu.Target>
|
<Tooltip label="Nueva room" position="bottom" withArrow>
|
||||||
<UnstyledButton c="dimmed">
|
<ActionIcon
|
||||||
<IconDots size={18} />
|
variant="subtle"
|
||||||
</UnstyledButton>
|
color="brand"
|
||||||
</Menu.Target>
|
size="lg"
|
||||||
<Menu.Dropdown>
|
radius="md"
|
||||||
<Menu.Item
|
onClick={onNewRoom}
|
||||||
leftSection={<IconLogout size={15} />}
|
aria-label="Crear nueva room"
|
||||||
onClick={onLogout}
|
|
||||||
>
|
>
|
||||||
Desconectar
|
<IconPlus size={20} />
|
||||||
</Menu.Item>
|
</ActionIcon>
|
||||||
</Menu.Dropdown>
|
</Tooltip>
|
||||||
</Menu>
|
<Menu position="bottom-end" withinPortal>
|
||||||
|
<Menu.Target>
|
||||||
|
<UnstyledButton c="dimmed">
|
||||||
|
<IconDots size={18} />
|
||||||
|
</UnstyledButton>
|
||||||
|
</Menu.Target>
|
||||||
|
<Menu.Dropdown>
|
||||||
|
<Menu.Item
|
||||||
|
leftSection={<IconLogout size={15} />}
|
||||||
|
onClick={onLogout}
|
||||||
|
>
|
||||||
|
Desconectar
|
||||||
|
</Menu.Item>
|
||||||
|
</Menu.Dropdown>
|
||||||
|
</Menu>
|
||||||
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<Box px="sm" pb="sm">
|
<Box px="sm" pb="sm">
|
||||||
@@ -161,11 +183,28 @@ export function Sidebar({
|
|||||||
onClick={() => onSelect(room.id)}
|
onClick={() => onSelect(room.id)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{filtered.length === 0 && (
|
{filtered.length === 0 &&
|
||||||
<Text c="dimmed" size="sm" ta="center" mt="md">
|
(query ? (
|
||||||
Sin resultados
|
<Text c="dimmed" size="sm" ta="center" mt="md">
|
||||||
</Text>
|
Sin resultados
|
||||||
)}
|
</Text>
|
||||||
|
) : (
|
||||||
|
<Stack align="center" gap="xs" mt="xl" px="md">
|
||||||
|
<Text c="dimmed" size="sm" ta="center">
|
||||||
|
Aún no tienes ninguna room.
|
||||||
|
</Text>
|
||||||
|
<UnstyledButton
|
||||||
|
onClick={onNewRoom}
|
||||||
|
c="brand.4"
|
||||||
|
style={{ fontSize: "var(--mantine-font-size-sm)", fontWeight: 600 }}
|
||||||
|
>
|
||||||
|
<Group gap={4} wrap="nowrap">
|
||||||
|
<IconPlus size={15} />
|
||||||
|
Crear tu primera room
|
||||||
|
</Group>
|
||||||
|
</UnstyledButton>
|
||||||
|
</Stack>
|
||||||
|
))}
|
||||||
</Stack>
|
</Stack>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
+13
-5
@@ -1,8 +1,8 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Anchor, Button, Group, PasswordInput, Text } from "@mantine/core";
|
import { Anchor, Button, Checkbox, Group, PasswordInput, Text } from "@mantine/core";
|
||||||
import { IconKey, IconWallet } from "@tabler/icons-react";
|
import { IconKey, IconWallet } from "@tabler/icons-react";
|
||||||
import { AuthCard, AuthHeader } from "./AuthShell";
|
import { AuthCard, AuthHeader } from "./AuthShell";
|
||||||
import { ApiError } from "./api";
|
import { SessionError } from "./busService";
|
||||||
import type { User } from "./types";
|
import type { User } from "./types";
|
||||||
import { unlockAndOpen } from "./wallet/account";
|
import { unlockAndOpen } from "./wallet/account";
|
||||||
import { WrongPasswordError } from "./wallet/crypto";
|
import { WrongPasswordError } from "./wallet/crypto";
|
||||||
@@ -20,6 +20,7 @@ export function WalletLogin({
|
|||||||
onRecover: () => void;
|
onRecover: () => void;
|
||||||
}) {
|
}) {
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
|
const [remember, setRemember] = useState(true);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -28,15 +29,16 @@ export function WalletLogin({
|
|||||||
setBusy(true);
|
setBusy(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const user = await unlockAndOpen(password);
|
const user = await unlockAndOpen(password, remember);
|
||||||
onLoggedIn(user);
|
onLoggedIn(user);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof WrongPasswordError) {
|
if (e instanceof WrongPasswordError) {
|
||||||
setError("Contraseña incorrecta.");
|
setError("Contraseña incorrecta.");
|
||||||
} else if (e instanceof ApiError) {
|
} else if (e instanceof SessionError) {
|
||||||
setError(e.message);
|
setError(e.message);
|
||||||
} else {
|
} else {
|
||||||
setError("No se pudo abrir tu identidad.");
|
// A connection/authorization failure (e.g. identity not yet allow-listed).
|
||||||
|
setError(e instanceof Error ? e.message : "No se pudo abrir tu identidad.");
|
||||||
}
|
}
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
}
|
}
|
||||||
@@ -59,6 +61,12 @@ export function WalletLogin({
|
|||||||
onKeyDown={(e) => e.key === "Enter" && void unlock()}
|
onKeyDown={(e) => e.key === "Enter" && void unlock()}
|
||||||
data-autofocus
|
data-autofocus
|
||||||
/>
|
/>
|
||||||
|
<Checkbox
|
||||||
|
label="Mantener la sesión en este dispositivo"
|
||||||
|
description="Hasta 30 días; se bloquea sola tras 12 h sin usarla. Desmárcala en un dispositivo compartido."
|
||||||
|
checked={remember}
|
||||||
|
onChange={(e) => setRemember(e.currentTarget.checked)}
|
||||||
|
/>
|
||||||
{error && (
|
{error && (
|
||||||
<Text c="red" size="sm" ta="center">
|
<Text c="red" size="sm" ta="center">
|
||||||
{error}
|
{error}
|
||||||
|
|||||||
-167
@@ -1,167 +0,0 @@
|
|||||||
// La única capa por la que la SPA habla con el bus. Cada llamada va al gateway Go
|
|
||||||
// bajo /api; el gateway mantiene la sesión `pkg/client` (peer autenticado del
|
|
||||||
// bus), cifra/descifra por room y traduce a REST/SSE. El navegador nunca firma,
|
|
||||||
// nunca habla NATS y nunca ve una clave privada: solo guarda una cookie de
|
|
||||||
// sesión opaca (HttpOnly) que el gateway emite tras el login.
|
|
||||||
import type {
|
|
||||||
MeInfo,
|
|
||||||
Message,
|
|
||||||
MsgWire,
|
|
||||||
RegisterResult,
|
|
||||||
Room,
|
|
||||||
RoomWire,
|
|
||||||
} from "./types";
|
|
||||||
import type { WalletIdentity } from "./wallet/derive";
|
|
||||||
|
|
||||||
export class ApiError extends Error {
|
|
||||||
status: number;
|
|
||||||
constructor(message: string, status: number) {
|
|
||||||
super(message);
|
|
||||||
this.status = status;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function req<T>(path: string, init?: RequestInit): Promise<T> {
|
|
||||||
const res = await fetch(path, {
|
|
||||||
// same-origin envía la cookie de sesión automáticamente (también detrás del
|
|
||||||
// proxy de vite en dev).
|
|
||||||
credentials: "same-origin",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
...init,
|
|
||||||
});
|
|
||||||
const text = await res.text();
|
|
||||||
let body: unknown = null;
|
|
||||||
if (text) {
|
|
||||||
try {
|
|
||||||
body = JSON.parse(text);
|
|
||||||
} catch {
|
|
||||||
body = text;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!res.ok) {
|
|
||||||
const msg =
|
|
||||||
body && typeof body === "object" && "error" in body
|
|
||||||
? String((body as { error: unknown }).error)
|
|
||||||
: `HTTP ${res.status}`;
|
|
||||||
throw new ApiError(msg, res.status);
|
|
||||||
}
|
|
||||||
return body as T;
|
|
||||||
}
|
|
||||||
|
|
||||||
// roomFromWire mapea la fila del gateway al tipo Room que consume la UI. Los
|
|
||||||
// mensajes NO viven aquí: llegan por stream(). lastMessage/lastTs/unread se
|
|
||||||
// rellenan de forma neutra para no inventar datos (la cabecera de la sidebar se
|
|
||||||
// alimentará del stream en una iteración futura).
|
|
||||||
export function roomFromWire(r: RoomWire): Room {
|
|
||||||
return {
|
|
||||||
id: r.id,
|
|
||||||
name: r.name || r.subject,
|
|
||||||
encrypted: r.encrypt,
|
|
||||||
lastMessage: "",
|
|
||||||
lastTs: 0,
|
|
||||||
unread: 0,
|
|
||||||
messages: [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// messageFromWire mapea un frame descifrado del SSE al tipo Message de la UI.
|
|
||||||
export function messageFromWire(m: MsgWire): Message {
|
|
||||||
return {
|
|
||||||
id: m.id,
|
|
||||||
sender: m.sender,
|
|
||||||
body: m.body,
|
|
||||||
ts: m.ts,
|
|
||||||
mine: m.mine,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export const api = {
|
|
||||||
// ---- onboarding wallet --------------------------------------------------
|
|
||||||
// register publica la identidad PÚBLICA del nuevo usuario en el allowlist del
|
|
||||||
// bus usando el token del enlace de invitación. NO requiere sesión: el token
|
|
||||||
// autoriza. El handle y el rol los fija el invite, no el cliente. La clave
|
|
||||||
// privada NUNCA se envía aquí.
|
|
||||||
register: (token: string, signPub: string, kexPub: string) =>
|
|
||||||
req<RegisterResult>("/api/register", {
|
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify({ token, sign_pub: signPub, kex_pub: kexPub }),
|
|
||||||
}),
|
|
||||||
|
|
||||||
// session abre una sesión POR USUARIO: el navegador entrega su identidad wallet
|
|
||||||
// completa (incluida la privada, solo por TLS) y el gateway conecta un cliente
|
|
||||||
// del bus que actúa COMO ese usuario. La privada vive en memoria del gateway
|
|
||||||
// mientras dure la sesión; no se persiste en el servidor.
|
|
||||||
session: (id: WalletIdentity, handle: string) =>
|
|
||||||
req<MeInfo>("/api/session", {
|
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify({
|
|
||||||
handle,
|
|
||||||
sign_pub: id.signPub,
|
|
||||||
sign_priv: id.signPriv,
|
|
||||||
kex_pub: id.kexPub,
|
|
||||||
kex_priv: id.kexPriv,
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
|
|
||||||
// ---- sesión (legacy operador) ------------------------------------------
|
|
||||||
// login desbloquea una sesión ligada al gateway del operador con su passphrase.
|
|
||||||
// El camino principal ahora es el wallet (session); login se mantiene por
|
|
||||||
// compatibilidad con el MVP de operador único.
|
|
||||||
login: (passphrase: string) =>
|
|
||||||
req<MeInfo>("/api/login", {
|
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify({ passphrase }),
|
|
||||||
}),
|
|
||||||
logout: () => req<{ status: string }>("/api/logout", { method: "POST" }),
|
|
||||||
me: () => req<MeInfo>("/api/me"),
|
|
||||||
|
|
||||||
// ---- rooms --------------------------------------------------------------
|
|
||||||
listRooms: async (): Promise<Room[]> => {
|
|
||||||
const wire = await req<RoomWire[]>("/api/rooms");
|
|
||||||
return wire.map(roomFromWire);
|
|
||||||
},
|
|
||||||
// createRoom: {subject, encrypted} basta — el gateway deriva la policy
|
|
||||||
// Matrix-like (cifrada + persistida + firmada) por defecto.
|
|
||||||
createRoom: async (subject: string, encrypted = true): Promise<Room> => {
|
|
||||||
const r = await req<RoomWire>("/api/rooms", {
|
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify({ subject, encrypted }),
|
|
||||||
});
|
|
||||||
return roomFromWire(r);
|
|
||||||
},
|
|
||||||
join: (roomID: string) =>
|
|
||||||
req<{ status: string }>(
|
|
||||||
`/api/rooms/${encodeURIComponent(roomID)}/join`,
|
|
||||||
{ method: "POST" },
|
|
||||||
),
|
|
||||||
send: (roomID: string, body: string) =>
|
|
||||||
req<{ status: string }>(
|
|
||||||
`/api/rooms/${encodeURIComponent(roomID)}/send`,
|
|
||||||
{ method: "POST", body: JSON.stringify({ body }) },
|
|
||||||
),
|
|
||||||
};
|
|
||||||
|
|
||||||
// streamRoom abre el SSE de una room y llama onMessage por cada frame descifrado
|
|
||||||
// (historia primero en rooms persistidas, luego en vivo). Devuelve una función
|
|
||||||
// de cierre. EventSource manda la cookie de sesión automáticamente y reconecta
|
|
||||||
// solo si la conexión cae; onError se invoca en cada corte para que la UI pueda
|
|
||||||
// reflejar el estado.
|
|
||||||
export function streamRoom(
|
|
||||||
roomID: string,
|
|
||||||
onMessage: (m: Message) => void,
|
|
||||||
onError?: (e: Event) => void,
|
|
||||||
): () => void {
|
|
||||||
const es = new EventSource(
|
|
||||||
`/api/rooms/${encodeURIComponent(roomID)}/stream`,
|
|
||||||
);
|
|
||||||
es.onmessage = (ev) => {
|
|
||||||
try {
|
|
||||||
const wire = JSON.parse(ev.data) as MsgWire;
|
|
||||||
onMessage(messageFromWire(wire));
|
|
||||||
} catch {
|
|
||||||
// frame malformado: se ignora, el stream sigue.
|
|
||||||
}
|
|
||||||
};
|
|
||||||
if (onError) es.onerror = onError;
|
|
||||||
return () => es.close();
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
// Parity tests for the auth bridge: the browser must produce the same NATS nkey and
|
||||||
|
// the same signed control-plane request bytes as the Go client, or it would not
|
||||||
|
// authenticate on either plane (issue 0001, Phase 1).
|
||||||
|
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import vectors from "./testdata/vectors.json";
|
||||||
|
import { hexToBytes, bytesToHex, base64ToBytes } from "./crypto.js";
|
||||||
|
import { nkeyPublic, canonicalRequest, signedHeaders } from "./busauth.js";
|
||||||
|
|
||||||
|
describe("NATS nkey encoding", () => {
|
||||||
|
it("derives the same user nkey ('U...') as Go from the Ed25519 pubkey", () => {
|
||||||
|
const v = vectors.nkey;
|
||||||
|
expect(nkeyPublic(hexToBytes(v.sign_pub_hex))).toBe(v.nkey_public);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("control-plane request signing", () => {
|
||||||
|
it("builds the same canonical request bytes as Go", () => {
|
||||||
|
const v = vectors.control_request;
|
||||||
|
const got = canonicalRequest(v.method, v.path, v.ts, v.nonce, hexToBytes(v.body_hex));
|
||||||
|
expect(bytesToHex(got)).toBe(v.canonical_hex);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("produces the same Ed25519 signature as Go (X-Unibus-Sig)", () => {
|
||||||
|
const v = vectors.control_request;
|
||||||
|
const headers = signedHeaders(
|
||||||
|
hexToBytes(vectors.sign.sign_pub_hex),
|
||||||
|
hexToBytes(v.sign_priv_hex),
|
||||||
|
v.method,
|
||||||
|
v.path,
|
||||||
|
v.ts,
|
||||||
|
v.nonce,
|
||||||
|
hexToBytes(v.body_hex),
|
||||||
|
);
|
||||||
|
// X-Unibus-Sig is base64-standard; decode and compare hex to the Go vector.
|
||||||
|
expect(bytesToHex(base64ToBytes(headers["X-Unibus-Sig"]))).toBe(v.sig_hex);
|
||||||
|
expect(headers["X-Unibus-Pub"]).toBe(vectors.sign.sign_pub_hex);
|
||||||
|
expect(headers["X-Unibus-Ts"]).toBe(v.ts);
|
||||||
|
expect(headers["X-Unibus-Nonce"]).toBe(v.nonce);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
// Bridges the user's Ed25519 identity to the two authentication surfaces of the
|
||||||
|
// bus, ported from Go pkg/busauth and the client's request signing:
|
||||||
|
//
|
||||||
|
// - DATA PLANE (NATS): a NATS user nkey IS an Ed25519 keypair. nkeyPublic encodes
|
||||||
|
// the Ed25519 public key into the "U..." nkey string the server expects, and
|
||||||
|
// natsAuthenticator signs the server-presented nonce with the same key — so the
|
||||||
|
// browser authenticates to NATS with the user's identity, no extra key material.
|
||||||
|
// - CONTROL PLANE (HTTP): every request to membershipd is signed. canonicalRequest
|
||||||
|
// reproduces Go's membership.CanonicalRequest, and signedHeaders attaches the
|
||||||
|
// X-Unibus-Pub/Ts/Nonce/Sig headers the server verifies.
|
||||||
|
//
|
||||||
|
// Parity with Go is pinned by the `nkey` and `control_request` vectors in
|
||||||
|
// testdata/vectors.json (busauth.test.ts).
|
||||||
|
|
||||||
|
import { sha256 } from "@noble/hashes/sha2.js";
|
||||||
|
import { signEd25519, bytesToHex, bytesToBase64 } from "./crypto.js";
|
||||||
|
|
||||||
|
// --- NATS nkey encoding (base32 + crc16, matching github.com/nats-io/nkeys) ---
|
||||||
|
|
||||||
|
// PrefixByteUser is nkeys' user prefix (20 << 3). Its top 5 bits encode to 'U', so
|
||||||
|
// every user nkey string starts with "U".
|
||||||
|
const PREFIX_USER = 20 << 3;
|
||||||
|
|
||||||
|
// crc16 table (CRC-16/XMODEM, poly 0x1021, MSB-first) — the exact CRC nkeys appends.
|
||||||
|
const CRC16TAB: Uint16Array = (() => {
|
||||||
|
const tab = new Uint16Array(256);
|
||||||
|
for (let i = 0; i < 256; i++) {
|
||||||
|
let crc = (i << 8) & 0xffff;
|
||||||
|
for (let j = 0; j < 8; j++) {
|
||||||
|
crc = crc & 0x8000 ? ((crc << 1) ^ 0x1021) & 0xffff : (crc << 1) & 0xffff;
|
||||||
|
}
|
||||||
|
tab[i] = crc;
|
||||||
|
}
|
||||||
|
return tab;
|
||||||
|
})();
|
||||||
|
|
||||||
|
function crc16(data: Uint8Array): number {
|
||||||
|
let crc = 0;
|
||||||
|
for (const b of data) crc = ((crc << 8) & 0xffff) ^ CRC16TAB[((crc >> 8) ^ b) & 0xff];
|
||||||
|
return crc & 0xffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BASE32_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
||||||
|
|
||||||
|
// base32Encode is RFC4648 standard base32 WITHOUT padding, as nkeys uses.
|
||||||
|
function base32Encode(data: Uint8Array): string {
|
||||||
|
let bits = 0;
|
||||||
|
let value = 0;
|
||||||
|
let out = "";
|
||||||
|
for (const b of data) {
|
||||||
|
value = (value << 8) | b;
|
||||||
|
bits += 8;
|
||||||
|
while (bits >= 5) {
|
||||||
|
out += BASE32_ALPHABET[(value >>> (bits - 5)) & 31];
|
||||||
|
bits -= 5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (bits > 0) out += BASE32_ALPHABET[(value << (5 - bits)) & 31];
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// nkeyPublic encodes a 32-byte Ed25519 public key as a NATS user nkey ("U...").
|
||||||
|
// Layout: prefixByte || pubkey(32) || crc16-little-endian(2), base32 (no padding).
|
||||||
|
export function nkeyPublic(signPub: Uint8Array): string {
|
||||||
|
if (signPub.length !== 32) throw new Error(`nkeyPublic: signPub must be 32 bytes, got ${signPub.length}`);
|
||||||
|
const raw = new Uint8Array(1 + 32);
|
||||||
|
raw[0] = PREFIX_USER;
|
||||||
|
raw.set(signPub, 1);
|
||||||
|
const crc = crc16(raw);
|
||||||
|
const full = new Uint8Array(raw.length + 2);
|
||||||
|
full.set(raw, 0);
|
||||||
|
full[raw.length] = crc & 0xff; // little-endian
|
||||||
|
full[raw.length + 1] = (crc >> 8) & 0xff;
|
||||||
|
return base32Encode(full);
|
||||||
|
}
|
||||||
|
|
||||||
|
// natsAuthenticator returns the callback a NATS WebSocket connection uses to
|
||||||
|
// authenticate: it presents the user nkey and signs the server's nonce with the
|
||||||
|
// Ed25519 key. The nonce arrives as a string; we sign its UTF-8 bytes and return the
|
||||||
|
// signature base64url-encoded, the form the NATS protocol expects.
|
||||||
|
export function natsAuthenticator(signPub: Uint8Array, signPriv: Uint8Array) {
|
||||||
|
const nkey = nkeyPublic(signPub);
|
||||||
|
return (nonce: string) => {
|
||||||
|
const sig = signEd25519(signPriv, new TextEncoder().encode(nonce));
|
||||||
|
const b64url = bytesToBase64(sig).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
||||||
|
return { nkey, sig: b64url };
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- control-plane request signing (HTTP) ------------------------------------
|
||||||
|
|
||||||
|
// canonicalRequest reproduces Go's membership.CanonicalRequest: the bytes signed for
|
||||||
|
// a control-plane HTTP request. body is the raw request body (empty for GET).
|
||||||
|
export function canonicalRequest(
|
||||||
|
method: string,
|
||||||
|
path: string,
|
||||||
|
ts: string,
|
||||||
|
nonce: string,
|
||||||
|
body: Uint8Array,
|
||||||
|
): Uint8Array {
|
||||||
|
const bodyHashHex = bytesToHex(sha256(body));
|
||||||
|
return new TextEncoder().encode([method, path, ts, nonce, bodyHashHex].join("\n"));
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ControlHeaders {
|
||||||
|
"X-Unibus-Pub": string;
|
||||||
|
"X-Unibus-Ts": string;
|
||||||
|
"X-Unibus-Nonce": string;
|
||||||
|
"X-Unibus-Sig": string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// signedHeaders builds the transport-auth headers for a control-plane request,
|
||||||
|
// signing canonicalRequest with the user's Ed25519 key. ts/nonce are injected so the
|
||||||
|
// function is deterministic and testable; in production use the current unix seconds
|
||||||
|
// and a fresh 16-byte random nonce (base64).
|
||||||
|
export function signedHeaders(
|
||||||
|
signPub: Uint8Array,
|
||||||
|
signPriv: Uint8Array,
|
||||||
|
method: string,
|
||||||
|
path: string,
|
||||||
|
ts: string,
|
||||||
|
nonce: string,
|
||||||
|
body: Uint8Array,
|
||||||
|
): ControlHeaders {
|
||||||
|
const sig = signEd25519(signPriv, canonicalRequest(method, path, ts, nonce, body));
|
||||||
|
return {
|
||||||
|
"X-Unibus-Pub": bytesToHex(signPub),
|
||||||
|
"X-Unibus-Ts": ts,
|
||||||
|
"X-Unibus-Nonce": nonce,
|
||||||
|
"X-Unibus-Sig": bytesToBase64(sig), // base64 standard, matching the Go client
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// freshNonce returns a base64 (standard) 16-byte random nonce for a live request.
|
||||||
|
export function freshNonce(): string {
|
||||||
|
return bytesToBase64(crypto.getRandomValues(new Uint8Array(16)));
|
||||||
|
}
|
||||||
@@ -0,0 +1,491 @@
|
|||||||
|
// The browser-native bus client, ported from Go pkg/client. It does what the Go
|
||||||
|
// gateway used to do server-side — only now it runs in the browser, so the user's
|
||||||
|
// private key never leaves the device (issue 0001).
|
||||||
|
//
|
||||||
|
// The module is split so the security-critical part is pure and unit-testable
|
||||||
|
// without a live server:
|
||||||
|
// - sealRoomMessage / openRoomMessage: the room ENVELOPE (build a frame, AEAD-seal
|
||||||
|
// the payload with the room key using the subject as AAD, sign it; and the
|
||||||
|
// inverse: verify the signature and open the payload). These are pure and pinned
|
||||||
|
// by tests.
|
||||||
|
// - NatsTransport: the data-plane transport interface. The concrete WebSocket
|
||||||
|
// implementation (nats.ws) is thin glue wired and E2E-tested in a later phase.
|
||||||
|
// - ControlPlane: the signed HTTP client for membershipd (rooms, keys, members).
|
||||||
|
// - BusClient: orchestrates transport + control plane + envelope.
|
||||||
|
|
||||||
|
import { Policy, Room } from "./room.js";
|
||||||
|
import {
|
||||||
|
Frame,
|
||||||
|
FrameType,
|
||||||
|
marshal,
|
||||||
|
unmarshal,
|
||||||
|
signingBytes,
|
||||||
|
} from "./frame.js";
|
||||||
|
import {
|
||||||
|
sealAEAD,
|
||||||
|
openAEAD,
|
||||||
|
randomNonce,
|
||||||
|
signEd25519,
|
||||||
|
verifyEd25519,
|
||||||
|
sealKeyBox,
|
||||||
|
openKeyBox,
|
||||||
|
endpointID,
|
||||||
|
bytesToBase64,
|
||||||
|
} from "./crypto.js";
|
||||||
|
import { signedHeaders, freshNonce } from "./busauth.js";
|
||||||
|
|
||||||
|
// Identity is the user's full cryptographic identity. The private halves stay in
|
||||||
|
// memory in the browser and are NEVER serialized to the network.
|
||||||
|
export interface Identity {
|
||||||
|
signPub: Uint8Array;
|
||||||
|
signPriv: Uint8Array; // 64-byte Ed25519 (seed||pub)
|
||||||
|
kexPub: Uint8Array;
|
||||||
|
kexPriv: Uint8Array;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- ULID (message ids), Crockford base32, time-ordered ----------------------
|
||||||
|
|
||||||
|
const CROCKFORD = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
|
||||||
|
|
||||||
|
export function newULID(nowMs: number = Date.now()): string {
|
||||||
|
let ts = "";
|
||||||
|
let t = nowMs;
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
ts = CROCKFORD[t % 32] + ts;
|
||||||
|
t = Math.floor(t / 32);
|
||||||
|
}
|
||||||
|
const rnd = crypto.getRandomValues(new Uint8Array(16));
|
||||||
|
let r = "";
|
||||||
|
for (let i = 0; i < 16; i++) r += CROCKFORD[rnd[i] & 31];
|
||||||
|
return ts + r;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ulidTime decodes the millisecond epoch timestamp a ULID encodes in its first 10
|
||||||
|
// Crockford base32 characters (the inverse of newULID's time prefix). A frame carries
|
||||||
|
// no explicit timestamp on the wire — its ULID id IS the timestamp — so the UI derives
|
||||||
|
// a message's time from it, which keeps live and replayed-history messages on the same
|
||||||
|
// clock (the sender's send time, not the receiver's arrival time). Returns 0 for an id
|
||||||
|
// whose prefix is not valid Crockford base32, so a malformed id never blows up the UI.
|
||||||
|
export function ulidTime(id: string): number {
|
||||||
|
let t = 0;
|
||||||
|
for (let i = 0; i < 10 && i < id.length; i++) {
|
||||||
|
const v = CROCKFORD.indexOf(id[i].toUpperCase());
|
||||||
|
if (v < 0) return 0;
|
||||||
|
t = t * 32 + v;
|
||||||
|
}
|
||||||
|
return t;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- room envelope (pure, the security-critical core) ------------------------
|
||||||
|
|
||||||
|
export interface SealOptions {
|
||||||
|
type: FrameType;
|
||||||
|
subject: string;
|
||||||
|
sender: string; // this peer's endpoint id
|
||||||
|
signPriv: Uint8Array;
|
||||||
|
policy: Policy;
|
||||||
|
epoch: number;
|
||||||
|
plaintext: Uint8Array;
|
||||||
|
roomKey?: Uint8Array; // required when policy.encrypt
|
||||||
|
threadID?: string;
|
||||||
|
replyTo?: string;
|
||||||
|
msgID?: string; // defaults to a fresh ULID
|
||||||
|
}
|
||||||
|
|
||||||
|
// sealRoomMessage builds a wire frame from plaintext exactly as Go's publishFrame:
|
||||||
|
// for encrypted rooms the payload is ChaCha20-Poly1305-sealed with the room key and
|
||||||
|
// the SUBJECT as additional authenticated data; for signed rooms an Ed25519
|
||||||
|
// signature over the canonical bytes is attached.
|
||||||
|
export function sealRoomMessage(o: SealOptions): Frame {
|
||||||
|
const f: Frame = {
|
||||||
|
type: o.type,
|
||||||
|
subject: o.subject,
|
||||||
|
sender: o.sender,
|
||||||
|
msgID: o.msgID ?? newULID(),
|
||||||
|
epoch: o.epoch,
|
||||||
|
threadID: o.threadID,
|
||||||
|
replyTo: o.replyTo,
|
||||||
|
};
|
||||||
|
if (o.policy.encrypt) {
|
||||||
|
if (!o.roomKey) throw new Error("sealRoomMessage: encrypted room requires roomKey");
|
||||||
|
const nonce = randomNonce();
|
||||||
|
f.nonce = nonce;
|
||||||
|
f.payload = sealAEAD(o.roomKey, nonce, o.plaintext, new TextEncoder().encode(o.subject));
|
||||||
|
} else {
|
||||||
|
f.payload = o.plaintext;
|
||||||
|
}
|
||||||
|
if (o.policy.signMsgs) {
|
||||||
|
f.sig = signEd25519(o.signPriv, signingBytes(f));
|
||||||
|
}
|
||||||
|
return f;
|
||||||
|
}
|
||||||
|
|
||||||
|
// openRoomMessage is the inverse: it verifies the signature (for signed rooms) and
|
||||||
|
// opens the AEAD payload (for encrypted rooms), returning the plaintext or null if
|
||||||
|
// verification/decryption fails (the caller drops the message).
|
||||||
|
export function openRoomMessage(
|
||||||
|
f: Frame,
|
||||||
|
policy: Policy,
|
||||||
|
signerPub: Uint8Array | undefined,
|
||||||
|
roomKey: Uint8Array | undefined,
|
||||||
|
): Uint8Array | null {
|
||||||
|
if (policy.signMsgs) {
|
||||||
|
if (!f.sig || !signerPub || !verifyEd25519(f.sig, signingBytes(f), signerPub)) return null;
|
||||||
|
}
|
||||||
|
if (policy.encrypt) {
|
||||||
|
if (!f.nonce || !f.payload || !roomKey) return null;
|
||||||
|
try {
|
||||||
|
return openAEAD(roomKey, f.nonce, f.payload, new TextEncoder().encode(f.subject));
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return f.payload ?? new Uint8Array(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- data-plane transport ----------------------------------------------------
|
||||||
|
|
||||||
|
export type MessageHandler = (subject: string, data: Uint8Array) => void;
|
||||||
|
|
||||||
|
// NatsTransport abstracts the NATS data plane so BusClient's logic is testable with
|
||||||
|
// a mock and the concrete WebSocket transport (nats.ws) stays swappable. The browser
|
||||||
|
// transport connects over ws(s):// using a NATS nkey authenticator built from the
|
||||||
|
// user's Ed25519 identity (see busauth.natsAuthenticator).
|
||||||
|
export interface NatsTransport {
|
||||||
|
publish(subject: string, data: Uint8Array): void | Promise<void>;
|
||||||
|
subscribe(subject: string, handler: MessageHandler): Promise<Subscription>;
|
||||||
|
// reconnect rebuilds the connection so the server's per-subject ACL re-evaluates
|
||||||
|
// this peer's room membership (a room created after connecting is otherwise not in
|
||||||
|
// the grant). Active subscriptions are dropped; re-subscribe after calling it.
|
||||||
|
reconnect(): Promise<void>;
|
||||||
|
close(): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Subscription {
|
||||||
|
unsubscribe(): void | Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- control plane (signed HTTP to membershipd) ------------------------------
|
||||||
|
|
||||||
|
interface RoomKeyResponse {
|
||||||
|
sealed_key: string; // base64 sealed box of the room key for this peer
|
||||||
|
epoch: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// HistoryResp is GET /rooms/{id}/history?limit=N: a room's replayed frames, oldest ->
|
||||||
|
// newest, each base64-standard encoded. Every entry is one marshaled wire frame — the
|
||||||
|
// exact bytes the live subscription delivers — so the caller opens them with the same
|
||||||
|
// envelope path as a live message. A room with no stored history yields an empty list.
|
||||||
|
interface HistoryResp {
|
||||||
|
messages: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// PolicyWire is the control-plane JSON shape of a policy (snake_case sign_msgs).
|
||||||
|
interface PolicyWire {
|
||||||
|
encrypt: boolean;
|
||||||
|
persist: boolean;
|
||||||
|
sign_msgs: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// RoomResp is GET /rooms/{id}: the room metadata WITHOUT the id (the caller knows it)
|
||||||
|
// and with the policy nested under snake_case keys.
|
||||||
|
interface RoomResp {
|
||||||
|
subject: string;
|
||||||
|
epoch: number;
|
||||||
|
policy: PolicyWire;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MemberJSON {
|
||||||
|
endpoint: string;
|
||||||
|
sign_pub: string; // base64
|
||||||
|
}
|
||||||
|
|
||||||
|
// DirectoryMemberWire is one row of GET /directory: a cluster-wide member with its
|
||||||
|
// human handle and role. sign_pub here is 64-hex (the raw Ed25519 public key), and
|
||||||
|
// endpoint matches endpointID(signPub) byte for byte.
|
||||||
|
interface DirectoryMemberWire {
|
||||||
|
sign_pub: string; // 64-hex
|
||||||
|
endpoint: string; // base64url-nopad, == endpointID(signPub)
|
||||||
|
handle: string;
|
||||||
|
role: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DirectoryResp {
|
||||||
|
members: DirectoryMemberWire[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// DirectoryEntry is the SDK shape of one directory member: the readable handle keyed
|
||||||
|
// by the stable endpoint id, so the UI can show a name instead of the raw id.
|
||||||
|
export interface DirectoryEntry {
|
||||||
|
signPub: string; // 64-hex
|
||||||
|
endpoint: string;
|
||||||
|
handle: string;
|
||||||
|
role: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// MemberRoomWire is one row of GET /members/{endpoint}/rooms.
|
||||||
|
interface MemberRoomWire {
|
||||||
|
room_id: string;
|
||||||
|
subject: string;
|
||||||
|
epoch: number;
|
||||||
|
policy: PolicyWire;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ControlPlane is the signed HTTP client for the membershipd control plane. Every
|
||||||
|
// request carries the X-Unibus-* auth headers (busauth.signedHeaders). It pins no
|
||||||
|
// host so it can target any cluster node.
|
||||||
|
export class ControlPlane {
|
||||||
|
constructor(
|
||||||
|
private baseURL: string,
|
||||||
|
private id: Identity,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
private async request<T>(method: string, path: string, body?: unknown): Promise<T> {
|
||||||
|
const bodyBytes = body === undefined ? new Uint8Array(0) : new TextEncoder().encode(JSON.stringify(body));
|
||||||
|
const headers = signedHeaders(
|
||||||
|
this.id.signPub,
|
||||||
|
this.id.signPriv,
|
||||||
|
method,
|
||||||
|
path,
|
||||||
|
String(Math.floor(Date.now() / 1000)),
|
||||||
|
freshNonce(),
|
||||||
|
bodyBytes,
|
||||||
|
);
|
||||||
|
const init: RequestInit = { method, headers: { ...headers } };
|
||||||
|
if (body !== undefined) {
|
||||||
|
(init.headers as Record<string, string>)["Content-Type"] = "application/json";
|
||||||
|
init.body = bodyBytes;
|
||||||
|
}
|
||||||
|
const resp = await fetch(this.baseURL + path, init);
|
||||||
|
if (!resp.ok) {
|
||||||
|
let msg = `${method} ${path} -> ${resp.status}`;
|
||||||
|
try {
|
||||||
|
const e = await resp.json();
|
||||||
|
if (e?.error) msg = `${e.error} (HTTP ${resp.status})`;
|
||||||
|
} catch {
|
||||||
|
/* keep the generic message */
|
||||||
|
}
|
||||||
|
throw new Error(`control plane: ${msg}`);
|
||||||
|
}
|
||||||
|
return (await resp.json()) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetchRoom resolves room metadata, mapping the control-plane wire shape
|
||||||
|
// (snake_case policy, no id) to the SDK's Room type.
|
||||||
|
async fetchRoom(roomID: string): Promise<Room> {
|
||||||
|
const r = await this.request<RoomResp>("GET", `/rooms/${roomID}`);
|
||||||
|
return {
|
||||||
|
id: roomID,
|
||||||
|
subject: r.subject,
|
||||||
|
epoch: r.epoch,
|
||||||
|
policy: { encrypt: r.policy.encrypt, persist: r.policy.persist, signMsgs: r.policy.sign_msgs },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// createRoom creates a room owned by this peer. For an encrypted room it mints a
|
||||||
|
// fresh 32-byte room key, seals it to the owner's own X25519 key (sealed box), and
|
||||||
|
// ships it as sealed_key_self so the server can store the owner's copy without ever
|
||||||
|
// seeing the key. Returns the new room id and (for encrypted rooms) the key.
|
||||||
|
async createRoom(subject: string, policy: Policy): Promise<{ roomID: string; key?: Uint8Array }> {
|
||||||
|
const body: Record<string, unknown> = {
|
||||||
|
subject,
|
||||||
|
policy: { encrypt: policy.encrypt, persist: policy.persist, sign_msgs: policy.signMsgs },
|
||||||
|
owner: {
|
||||||
|
endpoint: endpointID(this.id.signPub),
|
||||||
|
sign_pub: bytesToBase64(this.id.signPub),
|
||||||
|
kex_pub: bytesToBase64(this.id.kexPub),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let key: Uint8Array | undefined;
|
||||||
|
if (policy.encrypt) {
|
||||||
|
key = crypto.getRandomValues(new Uint8Array(32));
|
||||||
|
body.sealed_key_self = bytesToBase64(sealKeyBox(this.id.kexPub, key));
|
||||||
|
}
|
||||||
|
const resp = await this.request<{ room_id: string }>("POST", "/rooms", body);
|
||||||
|
return { roomID: resp.room_id, key };
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetchRoomKey fetches the sealed room key for this peer and opens it with the
|
||||||
|
// user's X25519 private key. The server only ever stores the key sealed for each
|
||||||
|
// member, so it cannot read it.
|
||||||
|
async fetchRoomKey(roomID: string, epoch: number): Promise<{ key: Uint8Array; epoch: number }> {
|
||||||
|
const q = epoch > 0 ? `&epoch=${epoch}` : "";
|
||||||
|
const resp = await this.request<RoomKeyResponse>(
|
||||||
|
"GET",
|
||||||
|
`/rooms/${roomID}/key?endpoint=${endpointID(this.id.signPub)}${q}`,
|
||||||
|
);
|
||||||
|
const sealed = base64ToBytesLocal(resp.sealed_key);
|
||||||
|
const key = openKeyBox(this.id.kexPub, this.id.kexPriv, sealed);
|
||||||
|
if (!key) throw new Error("control plane: failed to open room key");
|
||||||
|
return { key, epoch: resp.epoch };
|
||||||
|
}
|
||||||
|
|
||||||
|
// listMemberRooms returns the rooms a peer belongs to (GET /members/{endpoint}/rooms),
|
||||||
|
// mapping the wire shape (room_id, snake_case policy) to the SDK Room type.
|
||||||
|
async listMemberRooms(endpoint: string): Promise<Room[]> {
|
||||||
|
const wire = await this.request<MemberRoomWire[]>("GET", `/members/${endpoint}/rooms`);
|
||||||
|
return wire.map((r) => ({
|
||||||
|
id: r.room_id,
|
||||||
|
subject: r.subject,
|
||||||
|
epoch: r.epoch,
|
||||||
|
policy: { encrypt: r.policy.encrypt, persist: r.policy.persist, signMsgs: r.policy.sign_msgs },
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetchDirectory returns the cluster-wide member directory (GET /api/directory), so
|
||||||
|
// the UI can resolve a message sender's endpoint id to a readable handle. The
|
||||||
|
// request is signed like every other control-plane call. The caller is expected to
|
||||||
|
// tolerate this endpoint being absent on older clusters (404) and fall back to the
|
||||||
|
// short id; this method only maps the wire shape and lets transport errors surface.
|
||||||
|
async fetchDirectory(): Promise<DirectoryEntry[]> {
|
||||||
|
const resp = await this.request<DirectoryResp>("GET", "/directory");
|
||||||
|
return (resp.members ?? []).map((m) => ({
|
||||||
|
signPub: m.sign_pub,
|
||||||
|
endpoint: m.endpoint,
|
||||||
|
handle: m.handle,
|
||||||
|
role: m.role,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// listMembers returns the room's members keyed by endpoint, so a receiver can find
|
||||||
|
// a sender's signing public key to verify message signatures.
|
||||||
|
async signerKeys(roomID: string): Promise<Map<string, Uint8Array>> {
|
||||||
|
const members = await this.request<MemberJSON[]>("GET", `/rooms/${roomID}/members`);
|
||||||
|
const m = new Map<string, Uint8Array>();
|
||||||
|
for (const member of members) m.set(member.endpoint, base64ToBytesLocal(member.sign_pub));
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetchHistory replays a room's stored frames (GET /rooms/{id}/history?limit=N),
|
||||||
|
// returning up to N marshaled wire frames oldest -> newest. The server base64-standard
|
||||||
|
// encodes each frame; this decodes them back to the raw bytes the live subscription
|
||||||
|
// delivers, so BusClient.history can open each with the same envelope path as
|
||||||
|
// subscribe. The caller tolerates this endpoint being absent on older clusters
|
||||||
|
// (404/500): the error surfaces and BusClient.history's caller falls back to live-only.
|
||||||
|
async fetchHistory(roomID: string, limit = 200): Promise<Uint8Array[]> {
|
||||||
|
const resp = await this.request<HistoryResp>("GET", `/rooms/${roomID}/history?limit=${limit}`);
|
||||||
|
return (resp.messages ?? []).map((b64) => base64ToBytesLocal(b64));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// base64ToBytesLocal decodes standard base64 (kept local to avoid widening crypto's
|
||||||
|
// surface; identical behavior to crypto.base64ToBytes).
|
||||||
|
function base64ToBytesLocal(s: string): Uint8Array {
|
||||||
|
const bin = atob(s);
|
||||||
|
const out = new Uint8Array(bin.length);
|
||||||
|
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- BusClient ---------------------------------------------------------------
|
||||||
|
|
||||||
|
// BusClient ties the data plane (transport) and control plane together, applying the
|
||||||
|
// room envelope on publish and subscribe. It holds the user's identity in memory and
|
||||||
|
// never sends the private key anywhere.
|
||||||
|
export class BusClient {
|
||||||
|
private endpoint: string;
|
||||||
|
private keyCache = new Map<string, Map<number, Uint8Array>>(); // roomID -> epoch -> K
|
||||||
|
private signCache = new Map<string, Map<string, Uint8Array>>(); // roomID -> endpoint -> signPub
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private id: Identity,
|
||||||
|
private transport: NatsTransport,
|
||||||
|
private control: ControlPlane,
|
||||||
|
) {
|
||||||
|
this.endpoint = endpointID(id.signPub);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async roomKey(roomID: string, epoch: number): Promise<Uint8Array> {
|
||||||
|
const cached = this.keyCache.get(roomID)?.get(epoch);
|
||||||
|
if (cached) return cached;
|
||||||
|
const { key, epoch: ep } = await this.control.fetchRoomKey(roomID, epoch);
|
||||||
|
let byEpoch = this.keyCache.get(roomID);
|
||||||
|
if (!byEpoch) {
|
||||||
|
byEpoch = new Map();
|
||||||
|
this.keyCache.set(roomID, byEpoch);
|
||||||
|
}
|
||||||
|
byEpoch.set(ep, key);
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
|
||||||
|
// publish seals plaintext per the room policy and publishes it on the data plane.
|
||||||
|
async publish(roomID: string, plaintext: Uint8Array, opts: { threadID?: string; replyTo?: string; type?: FrameType } = {}): Promise<void> {
|
||||||
|
const room = await this.control.fetchRoom(roomID);
|
||||||
|
const roomKey = room.policy.encrypt ? await this.roomKey(roomID, room.epoch) : undefined;
|
||||||
|
const f = sealRoomMessage({
|
||||||
|
type: opts.type ?? FrameType.PUB,
|
||||||
|
subject: room.subject,
|
||||||
|
sender: this.endpoint,
|
||||||
|
signPriv: this.id.signPriv,
|
||||||
|
policy: room.policy,
|
||||||
|
epoch: room.epoch,
|
||||||
|
plaintext,
|
||||||
|
roomKey,
|
||||||
|
threadID: opts.threadID,
|
||||||
|
replyTo: opts.replyTo,
|
||||||
|
});
|
||||||
|
await this.transport.publish(room.subject, marshal(f));
|
||||||
|
}
|
||||||
|
|
||||||
|
// openFrame is the shared envelope-opening core behind subscribe (live) and history
|
||||||
|
// (replay): it unmarshals one wire frame, resolves the sender's signing key (from the
|
||||||
|
// sign cache, populated by loadSigners for signed rooms) and the room key for the
|
||||||
|
// frame's epoch, then verifies + decrypts via openRoomMessage. Returns null when the
|
||||||
|
// frame fails verification or decryption, so both callers drop it the same way.
|
||||||
|
private async openFrame(
|
||||||
|
roomID: string,
|
||||||
|
policy: Policy,
|
||||||
|
bytes: Uint8Array,
|
||||||
|
): Promise<{ frame: Frame; plaintext: Uint8Array } | null> {
|
||||||
|
const frame = unmarshal(bytes);
|
||||||
|
const signerPub = policy.signMsgs ? this.signCache.get(roomID)?.get(frame.sender) : undefined;
|
||||||
|
const roomKey = policy.encrypt ? await this.roomKey(roomID, frame.epoch) : undefined;
|
||||||
|
const plaintext = openRoomMessage(frame, policy, signerPub, roomKey);
|
||||||
|
return plaintext ? { frame, plaintext } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// subscribe delivers decoded, verified, decrypted messages for a room. Messages
|
||||||
|
// that fail signature verification or decryption are dropped silently.
|
||||||
|
async subscribe(roomID: string, handler: (f: Frame, plaintext: Uint8Array) => void): Promise<Subscription> {
|
||||||
|
const room = await this.control.fetchRoom(roomID);
|
||||||
|
if (room.policy.signMsgs) await this.loadSigners(roomID);
|
||||||
|
return this.transport.subscribe(room.subject, async (_subject, data) => {
|
||||||
|
const opened = await this.openFrame(roomID, room.policy, data);
|
||||||
|
if (opened) handler(opened.frame, opened.plaintext);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// history replays a room's stored messages, decrypted and verified exactly like
|
||||||
|
// subscribe (NATS delivers live only, so without this a reload shows nothing until
|
||||||
|
// new traffic arrives). It resolves the room policy, loads the signer keys for a
|
||||||
|
// signed room, fetches the marshaled frames from the control plane, and opens each
|
||||||
|
// with the same openFrame path. Frames that fail verification/decryption are dropped.
|
||||||
|
// Returns the opened messages in the server's order (oldest -> newest).
|
||||||
|
async history(
|
||||||
|
roomID: string,
|
||||||
|
limit = 200,
|
||||||
|
): Promise<Array<{ frame: Frame; plaintext: Uint8Array }>> {
|
||||||
|
const room = await this.control.fetchRoom(roomID);
|
||||||
|
if (room.policy.signMsgs) await this.loadSigners(roomID);
|
||||||
|
const frames = await this.control.fetchHistory(roomID, limit);
|
||||||
|
const out: Array<{ frame: Frame; plaintext: Uint8Array }> = [];
|
||||||
|
for (const bytes of frames) {
|
||||||
|
const opened = await this.openFrame(roomID, room.policy, bytes);
|
||||||
|
if (opened) out.push(opened);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadSigners(roomID: string): Promise<void> {
|
||||||
|
this.signCache.set(roomID, await this.control.signerKeys(roomID));
|
||||||
|
}
|
||||||
|
|
||||||
|
// refresh reconnects the data plane so the server's per-subject ACL re-evaluates
|
||||||
|
// this peer's room membership. Call it after creating or joining a room while
|
||||||
|
// connected: NATS freezes a connection's publishable/subscribable subjects at
|
||||||
|
// connect time, so the new room's subject only becomes usable on a fresh
|
||||||
|
// connection. Active subscriptions are dropped — re-subscribe afterwards.
|
||||||
|
async refresh(): Promise<void> {
|
||||||
|
await this.transport.reconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
// Bus crypto primitives, ported to the browser to match the Go reference
|
||||||
|
// implementation (functions/cybersecurity in fn-registry) byte-for-byte. The bus
|
||||||
|
// is end-to-end encrypted; doing the crypto here is what keeps the user's private
|
||||||
|
// key on the device and out of any server (issue 0001). Parity with Go is enforced
|
||||||
|
// by the vectors in testdata/vectors.json (see vectors.test.ts).
|
||||||
|
//
|
||||||
|
// Primitive map (Go -> here):
|
||||||
|
// EndpointID -> endpointID : base64url(sha256(signPub)), unpadded
|
||||||
|
// SignEd25519 -> signEd25519 : Ed25519 detached signature
|
||||||
|
// verify -> verifyEd25519
|
||||||
|
// SealAEAD/Open -> sealAEAD/openAEAD : ChaCha20-Poly1305 (IETF, 12-byte nonce)
|
||||||
|
// SealKeyBox/Open -> sealKeyBox/openKeyBox : NaCl anonymous sealed box (X25519),
|
||||||
|
// with the nonce derived as sha512(ephPub||recipientPub)[:24]
|
||||||
|
// EXACTLY as Go's nacl/box.SealAnonymous (Go uses SHA-512, not
|
||||||
|
// libsodium's blake2b — matching this is the whole point).
|
||||||
|
|
||||||
|
import { ed25519 } from "@noble/curves/ed25519.js";
|
||||||
|
import { chacha20poly1305 } from "@noble/ciphers/chacha.js";
|
||||||
|
import { sha256 } from "@noble/hashes/sha2.js";
|
||||||
|
import { blake2b } from "@noble/hashes/blake2.js";
|
||||||
|
import { concatBytes } from "@noble/hashes/utils.js";
|
||||||
|
import nacl from "tweetnacl";
|
||||||
|
|
||||||
|
// sealedBoxNonce derives the 24-byte nonce for an anonymous sealed box the same way
|
||||||
|
// Go's nacl/box.SealAnonymous (and libsodium's crypto_box_seal) do: BLAKE2b-192 over
|
||||||
|
// ephemeralPub || recipientPub. NOT SHA-512 — matching the exact hash is what makes
|
||||||
|
// a Go-sealed room key openable here.
|
||||||
|
function sealedBoxNonce(ephPub: Uint8Array, recipientPub: Uint8Array): Uint8Array {
|
||||||
|
return blake2b(concatBytes(ephPub, recipientPub), { dkLen: 24 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- byte / encoding helpers (browser-safe; no Buffer) -----------------------
|
||||||
|
|
||||||
|
export function bytesToHex(b: Uint8Array): string {
|
||||||
|
let s = "";
|
||||||
|
for (const x of b) s += x.toString(16).padStart(2, "0");
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hexToBytes(hex: string): Uint8Array {
|
||||||
|
if (hex.length % 2 !== 0) throw new Error("hex: odd length");
|
||||||
|
const out = new Uint8Array(hex.length / 2);
|
||||||
|
for (let i = 0; i < out.length; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// base64 standard (with padding) — matches Go's encoding/json for []byte fields.
|
||||||
|
export function bytesToBase64(b: Uint8Array): string {
|
||||||
|
let bin = "";
|
||||||
|
for (const x of b) bin += String.fromCharCode(x);
|
||||||
|
return btoa(bin);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function base64ToBytes(s: string): Uint8Array {
|
||||||
|
const bin = atob(s);
|
||||||
|
const out = new Uint8Array(bin.length);
|
||||||
|
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// base64url without padding — matches Go's base64.RawURLEncoding (EndpointID).
|
||||||
|
export function bytesToBase64URL(b: Uint8Array): string {
|
||||||
|
return bytesToBase64(b).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- identity / signing ------------------------------------------------------
|
||||||
|
|
||||||
|
// endpointID is the stable, transport-agnostic peer id: base64url(sha256(signPub)).
|
||||||
|
export function endpointID(signPub: Uint8Array): string {
|
||||||
|
return bytesToBase64URL(sha256(signPub));
|
||||||
|
}
|
||||||
|
|
||||||
|
// signEd25519 signs msg with an Ed25519 private key. It accepts the bus/Go 64-byte
|
||||||
|
// private key (seed || pub) OR a bare 32-byte seed; @noble signs from the 32-byte
|
||||||
|
// seed, so we slice the seed out of the 64-byte form.
|
||||||
|
export function signEd25519(priv: Uint8Array, msg: Uint8Array): Uint8Array {
|
||||||
|
const seed = priv.length === 64 ? priv.subarray(0, 32) : priv;
|
||||||
|
return ed25519.sign(msg, seed);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function verifyEd25519(sig: Uint8Array, msg: Uint8Array, pub: Uint8Array): boolean {
|
||||||
|
return ed25519.verify(sig, msg, pub);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- AEAD (room message content) ---------------------------------------------
|
||||||
|
|
||||||
|
// sealAEAD encrypts plaintext with ChaCha20-Poly1305 (IETF, 12-byte nonce). The
|
||||||
|
// caller supplies the nonce so the operation is testable; in the bus a fresh random
|
||||||
|
// nonce is generated per message and stored alongside the ciphertext.
|
||||||
|
export function sealAEAD(key: Uint8Array, nonce: Uint8Array, plaintext: Uint8Array, aad?: Uint8Array): Uint8Array {
|
||||||
|
return chacha20poly1305(key, nonce, aad).encrypt(plaintext);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function openAEAD(key: Uint8Array, nonce: Uint8Array, ciphertext: Uint8Array, aad?: Uint8Array): Uint8Array {
|
||||||
|
return chacha20poly1305(key, nonce, aad).decrypt(ciphertext);
|
||||||
|
}
|
||||||
|
|
||||||
|
// randomNonce returns a fresh 12-byte AEAD nonce (ChaCha20-Poly1305 IETF size).
|
||||||
|
export function randomNonce(): Uint8Array {
|
||||||
|
return crypto.getRandomValues(new Uint8Array(12));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- anonymous sealed box (room key distribution) ----------------------------
|
||||||
|
|
||||||
|
// sealKeyBox seals secret to a recipient's X25519 public key as an anonymous NaCl
|
||||||
|
// sealed box, matching Go's nacl/box.SealAnonymous: an ephemeral keypair is created,
|
||||||
|
// the nonce is sha512(ephPub || recipientPub)[:24], and the output is
|
||||||
|
// ephPub(32) || box(secret). The recipient opens it with openKeyBox; the sender is
|
||||||
|
// anonymous (no long-term sender key is revealed).
|
||||||
|
export function sealKeyBox(recipientKexPub: Uint8Array, secret: Uint8Array): Uint8Array {
|
||||||
|
const eph = nacl.box.keyPair();
|
||||||
|
const nonce = sealedBoxNonce(eph.publicKey, recipientKexPub);
|
||||||
|
const boxed = nacl.box(secret, nonce, recipientKexPub, eph.secretKey);
|
||||||
|
return concatBytes(eph.publicKey, boxed);
|
||||||
|
}
|
||||||
|
|
||||||
|
// openKeyBox opens an anonymous sealed box produced by sealKeyBox (or Go's
|
||||||
|
// SealKeyBox). It re-derives the same sha512-based nonce from the embedded ephemeral
|
||||||
|
// public key and the recipient's own public key, then opens the box with the
|
||||||
|
// recipient's private key. Returns null if authentication fails.
|
||||||
|
export function openKeyBox(
|
||||||
|
recipientKexPub: Uint8Array,
|
||||||
|
recipientKexPriv: Uint8Array,
|
||||||
|
sealed: Uint8Array,
|
||||||
|
): Uint8Array | null {
|
||||||
|
if (sealed.length < 32) return null;
|
||||||
|
const ephPub = sealed.subarray(0, 32);
|
||||||
|
const boxed = sealed.subarray(32);
|
||||||
|
const nonce = sealedBoxNonce(ephPub, recipientKexPub);
|
||||||
|
return nacl.box.open(boxed, nonce, ephPub, recipientKexPriv);
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
// Tests for the room envelope (the security-critical core of the client): sealing a
|
||||||
|
// message and opening it back, for the encrypted+signed room and the cleartext room,
|
||||||
|
// plus the failure paths (bad signature, wrong key) that MUST drop the message.
|
||||||
|
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { ModeMatrix, ModeNATS } from "./room.js";
|
||||||
|
import { FrameType } from "./frame.js";
|
||||||
|
import { sealRoomMessage, openRoomMessage } from "./client.js";
|
||||||
|
import { endpointID, hexToBytes } from "./crypto.js";
|
||||||
|
import vectors from "./testdata/vectors.json";
|
||||||
|
|
||||||
|
// A deterministic identity from the vectors, so tests do not depend on randomness
|
||||||
|
// for the keys (the AEAD nonce is still random, which is what we want).
|
||||||
|
const signPriv = hexToBytes(vectors.sign.sign_priv_hex);
|
||||||
|
const signPub = hexToBytes(vectors.sign.sign_pub_hex);
|
||||||
|
const sender = endpointID(signPub);
|
||||||
|
const roomKey = hexToBytes(vectors.aead.key_hex);
|
||||||
|
|
||||||
|
const utf8 = (s: string) => new TextEncoder().encode(s);
|
||||||
|
const str = (b: Uint8Array) => new TextDecoder().decode(b);
|
||||||
|
|
||||||
|
describe("room envelope — encrypted + signed (ModeMatrix)", () => {
|
||||||
|
function seal(plaintext: string) {
|
||||||
|
return sealRoomMessage({
|
||||||
|
type: FrameType.PUB,
|
||||||
|
subject: "room.parity",
|
||||||
|
sender,
|
||||||
|
signPriv,
|
||||||
|
policy: ModeMatrix,
|
||||||
|
epoch: 1,
|
||||||
|
plaintext: utf8(plaintext),
|
||||||
|
roomKey,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it("round-trips: seal then open recovers the plaintext", () => {
|
||||||
|
const f = seal("hello e2e");
|
||||||
|
expect(f.nonce && f.nonce.length).toBeTruthy();
|
||||||
|
expect(f.payload && f.payload.length).toBeTruthy();
|
||||||
|
expect(f.sig && f.sig.length).toBeTruthy();
|
||||||
|
const opened = openRoomMessage(f, ModeMatrix, signPub, roomKey);
|
||||||
|
expect(opened).not.toBeNull();
|
||||||
|
expect(str(opened!)).toBe("hello e2e");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops a message with a tampered signature", () => {
|
||||||
|
const f = seal("trust me");
|
||||||
|
f.sig![0] ^= 0xff; // corrupt the signature
|
||||||
|
expect(openRoomMessage(f, ModeMatrix, signPub, roomKey)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops a message opened with the wrong room key", () => {
|
||||||
|
const f = seal("secret");
|
||||||
|
const wrongKey = hexToBytes(vectors.keybox.secret_hex); // a different 32-byte key
|
||||||
|
expect(openRoomMessage(f, ModeMatrix, signPub, wrongKey)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ciphertext does not contain the plaintext", () => {
|
||||||
|
const f = seal("plaintext-marker");
|
||||||
|
const wire = new TextDecoder("latin1").decode(f.payload!);
|
||||||
|
expect(wire.includes("plaintext-marker")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("room envelope — cleartext (ModeNATS)", () => {
|
||||||
|
it("carries the payload as-is and opens without a key", () => {
|
||||||
|
const f = sealRoomMessage({
|
||||||
|
type: FrameType.PUB,
|
||||||
|
subject: "room.clear",
|
||||||
|
sender,
|
||||||
|
signPriv,
|
||||||
|
policy: ModeNATS,
|
||||||
|
epoch: 0,
|
||||||
|
plaintext: utf8("in the clear"),
|
||||||
|
});
|
||||||
|
expect(f.sig).toBeUndefined();
|
||||||
|
const opened = openRoomMessage(f, ModeNATS, undefined, undefined);
|
||||||
|
expect(str(opened!)).toBe("in the clear");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
// The wire format of the unibus message bus, ported from Go pkg/frame. A Frame is
|
||||||
|
// the unit transported over NATS: a cleartext envelope plus an optional AEAD
|
||||||
|
// ciphertext payload, signed end-to-end with Ed25519.
|
||||||
|
//
|
||||||
|
// The signature covers the canonical JSON of the frame with the signature field
|
||||||
|
// cleared, so the marshaler here must reproduce Go's encoding/json BYTE FOR BYTE or
|
||||||
|
// signatures verified by Go peers would fail. That means: struct field order, the
|
||||||
|
// `omitempty` rules, base64-standard encoding of []byte fields, and Go's default
|
||||||
|
// HTML escaping of <, >, & and the U+2028/U+2029 separators inside strings. Parity
|
||||||
|
// is pinned by testdata/vectors.json (vectors.test.ts).
|
||||||
|
|
||||||
|
import {
|
||||||
|
bytesToBase64,
|
||||||
|
base64ToBytes,
|
||||||
|
signEd25519,
|
||||||
|
verifyEd25519,
|
||||||
|
endpointID,
|
||||||
|
} from "./crypto.js";
|
||||||
|
|
||||||
|
export enum FrameType {
|
||||||
|
PUB = 0,
|
||||||
|
INVITE = 1,
|
||||||
|
JOIN = 2,
|
||||||
|
LEAVE = 3,
|
||||||
|
KICK = 4,
|
||||||
|
ACK = 5,
|
||||||
|
REACT = 6,
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BlobRef {
|
||||||
|
hash: string; // sha256 hex of the blob ciphertext
|
||||||
|
nonce: Uint8Array; // AEAD nonce used to encrypt the blob
|
||||||
|
size: number; // ciphertext size in bytes
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Frame {
|
||||||
|
type: FrameType;
|
||||||
|
subject: string;
|
||||||
|
sender: string; // endpoint id = endpointID(signPub)
|
||||||
|
msgID: string; // ULID
|
||||||
|
epoch: number; // epoch of the room key used to encrypt
|
||||||
|
threadID?: string; // root message id of the thread (optional)
|
||||||
|
replyTo?: string; // message id this frame replies to / reacts to (optional)
|
||||||
|
nonce?: Uint8Array; // AEAD nonce (encrypted rooms only)
|
||||||
|
payload?: Uint8Array; // AEAD ciphertext (or cleartext if the room is not encrypted)
|
||||||
|
blob?: BlobRef;
|
||||||
|
sig?: Uint8Array; // Ed25519 signature over signingBytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Go's encoding/json HTML-escapes these code points inside strings by default. We
|
||||||
|
// replay the exact same set so our canonical bytes match Go's. The two separators
|
||||||
|
// (U+2028 line separator, U+2029 paragraph separator) are built via fromCharCode so
|
||||||
|
// this source file holds no invisible characters while the RegExp still matches the
|
||||||
|
// real code points at runtime.
|
||||||
|
const GO_ESCAPES: ReadonlyArray<[RegExp, string]> = [
|
||||||
|
[/</g, "\\u003c"],
|
||||||
|
[/>/g, "\\u003e"],
|
||||||
|
[/&/g, "\\u0026"],
|
||||||
|
[new RegExp(String.fromCharCode(0x2028), "g"), "\\u2028"],
|
||||||
|
[new RegExp(String.fromCharCode(0x2029), "g"), "\\u2029"],
|
||||||
|
];
|
||||||
|
|
||||||
|
// goJSONStringify serializes obj the way Go's encoding/json does: compact (no
|
||||||
|
// spaces), insertion-ordered keys, and the default HTML escaping above. Apply only
|
||||||
|
// to objects built key-by-key in field order, so the output matches Go's struct
|
||||||
|
// marshaling exactly.
|
||||||
|
function goJSONStringify(obj: Record<string, unknown>): string {
|
||||||
|
let s = JSON.stringify(obj);
|
||||||
|
for (const [re, rep] of GO_ESCAPES) s = s.replace(re, rep);
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
// frameObject builds the plain object with keys inserted in Go struct-declaration
|
||||||
|
// order, applying each field's omitempty rule. includeSig controls whether the
|
||||||
|
// signature field is emitted: false yields the canonical signing-bytes object.
|
||||||
|
function frameObject(f: Frame, includeSig: boolean): Record<string, unknown> {
|
||||||
|
const o: Record<string, unknown> = {};
|
||||||
|
// Always-present fields (no omitempty in Go).
|
||||||
|
o.t = f.type;
|
||||||
|
o.s = f.subject;
|
||||||
|
o.from = f.sender;
|
||||||
|
o.id = f.msgID;
|
||||||
|
o.e = f.epoch;
|
||||||
|
// omitempty fields, in declaration order.
|
||||||
|
if (f.threadID) o.thr = f.threadID;
|
||||||
|
if (f.replyTo) o.re = f.replyTo;
|
||||||
|
if (f.nonce && f.nonce.length) o.n = bytesToBase64(f.nonce);
|
||||||
|
if (f.payload && f.payload.length) o.p = bytesToBase64(f.payload);
|
||||||
|
if (f.blob) o.b = { h: f.blob.hash, n: bytesToBase64(f.blob.nonce), sz: f.blob.size };
|
||||||
|
if (includeSig && f.sig && f.sig.length) o.sig = bytesToBase64(f.sig);
|
||||||
|
return o;
|
||||||
|
}
|
||||||
|
|
||||||
|
// marshal returns the wire bytes of the frame (UTF-8 of the canonical JSON).
|
||||||
|
export function marshal(f: Frame): Uint8Array {
|
||||||
|
return new TextEncoder().encode(goJSONStringify(frameObject(f, true)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// signingBytes returns the canonical bytes that are signed and verified: the frame
|
||||||
|
// JSON with the signature field cleared.
|
||||||
|
export function signingBytes(f: Frame): Uint8Array {
|
||||||
|
return new TextEncoder().encode(goJSONStringify(frameObject(f, false)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// unmarshal parses wire bytes back into a Frame, decoding the base64 []byte fields.
|
||||||
|
export function unmarshal(b: Uint8Array): Frame {
|
||||||
|
const o = JSON.parse(new TextDecoder().decode(b));
|
||||||
|
const f: Frame = {
|
||||||
|
type: o.t ?? 0,
|
||||||
|
subject: o.s ?? "",
|
||||||
|
sender: o.from ?? "",
|
||||||
|
msgID: o.id ?? "",
|
||||||
|
epoch: o.e ?? 0,
|
||||||
|
};
|
||||||
|
if (o.thr) f.threadID = o.thr;
|
||||||
|
if (o.re) f.replyTo = o.re;
|
||||||
|
if (o.n) f.nonce = base64ToBytes(o.n);
|
||||||
|
if (o.p) f.payload = base64ToBytes(o.p);
|
||||||
|
if (o.b) f.blob = { hash: o.b.h, nonce: base64ToBytes(o.b.n), size: o.b.sz };
|
||||||
|
if (o.sig) f.sig = base64ToBytes(o.sig);
|
||||||
|
return f;
|
||||||
|
}
|
||||||
|
|
||||||
|
// signFrame fills f.sig with an Ed25519 signature over signingBytes(f). signPriv is
|
||||||
|
// the 64-byte (seed||pub) or 32-byte seed private key.
|
||||||
|
export function signFrame(f: Frame, signPriv: Uint8Array): Frame {
|
||||||
|
f.sig = signEd25519(signPriv, signingBytes(f));
|
||||||
|
return f;
|
||||||
|
}
|
||||||
|
|
||||||
|
// verifyFrame checks f.sig against signPub over signingBytes(f).
|
||||||
|
export function verifyFrame(f: Frame, signPub: Uint8Array): boolean {
|
||||||
|
if (!f.sig) return false;
|
||||||
|
return verifyEd25519(f.sig, signingBytes(f), signPub);
|
||||||
|
}
|
||||||
|
|
||||||
|
// senderEndpoint derives the canonical sender endpoint id from a signing public key.
|
||||||
|
export function senderEndpoint(signPub: Uint8Array): string {
|
||||||
|
return endpointID(signPub);
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
// Public API of the browser-native bus SDK. The SPA imports from here; the internal
|
||||||
|
// module split (crypto / frame / room / busauth / client / wstransport) stays an
|
||||||
|
// implementation detail. See issue uniweb/0001.
|
||||||
|
|
||||||
|
export * from "./crypto.js";
|
||||||
|
export * from "./frame.js";
|
||||||
|
export * from "./room.js";
|
||||||
|
export * from "./busauth.js";
|
||||||
|
export * from "./client.js";
|
||||||
|
export { WsNatsTransport } from "./wstransport.js";
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
// Live integration smoke against the real unibus cluster. NOT part of the unit
|
||||||
|
// suite (needs network + a running cluster + TLS bypass), so it self-skips unless
|
||||||
|
// BUS_HTTP / BUS_WS are set. Run it explicitly:
|
||||||
|
//
|
||||||
|
// NODE_TLS_REJECT_UNAUTHORIZED=0 \
|
||||||
|
// BUS_HTTP=https://51.91.100.142:8470 BUS_WS=wss://51.91.100.142:8480 \
|
||||||
|
// pnpm exec vitest run src/bus/integration.test.ts
|
||||||
|
//
|
||||||
|
// What it proves WITHOUT a registered user: a fresh random identity is NOT in the
|
||||||
|
// bus allowlist, so both planes must reject it with an AUTHORIZATION error — not a
|
||||||
|
// signature/protocol error. That result confirms the SDK speaks both planes
|
||||||
|
// correctly end-to-end (busauth canonical+signature on HTTP, nkey handshake on the
|
||||||
|
// data plane); only the allowlist gate stops it. Issue uniweb/0001, Phase 3.
|
||||||
|
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { readFileSync, existsSync } from "node:fs";
|
||||||
|
import { ed25519, x25519 } from "@noble/curves/ed25519.js";
|
||||||
|
import { concatBytes } from "@noble/hashes/utils.js";
|
||||||
|
import { signedHeaders, freshNonce } from "./busauth.js";
|
||||||
|
import { hexToBytes } from "./crypto.js";
|
||||||
|
import { WsNatsTransport } from "./wstransport.js";
|
||||||
|
import { BusClient, ControlPlane, type Identity } from "./client.js";
|
||||||
|
import type { Frame } from "./frame.js";
|
||||||
|
|
||||||
|
const BUS_HTTP = process.env.BUS_HTTP;
|
||||||
|
const BUS_WS = process.env.BUS_WS;
|
||||||
|
const live = !!(BUS_HTTP && BUS_WS);
|
||||||
|
|
||||||
|
// An optional REGISTERED identity (its sign_pub added to the bus allowlist out of
|
||||||
|
// band). When present, the second describe block proves the same SDK that gets
|
||||||
|
// rejected with a fresh identity is ACCEPTED once the identity is allow-listed —
|
||||||
|
// closing the loop that the allowlist is the only gate.
|
||||||
|
const ID_FILE = process.env.BUS_IDENTITY || "/tmp/smoke_identity.json";
|
||||||
|
function registeredIdentity(): Identity | null {
|
||||||
|
if (!existsSync(ID_FILE)) return null;
|
||||||
|
const j = JSON.parse(readFileSync(ID_FILE, "utf8"));
|
||||||
|
return {
|
||||||
|
signPub: hexToBytes(j.signPub),
|
||||||
|
signPriv: hexToBytes(j.signPriv),
|
||||||
|
kexPub: hexToBytes(j.kexPub),
|
||||||
|
kexPriv: hexToBytes(j.kexPriv),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function freshIdentity(): Identity {
|
||||||
|
const seed = crypto.getRandomValues(new Uint8Array(32));
|
||||||
|
const signPub = ed25519.getPublicKey(seed);
|
||||||
|
const signPriv = concatBytes(seed, signPub); // 64-byte Go layout
|
||||||
|
const kexPriv = crypto.getRandomValues(new Uint8Array(32));
|
||||||
|
const kexPub = x25519.getPublicKey(kexPriv);
|
||||||
|
return { signPub, signPriv, kexPub, kexPriv };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe.skipIf(!live)("live cluster smoke", () => {
|
||||||
|
const id = freshIdentity();
|
||||||
|
|
||||||
|
it("control plane: a signed request is processed (rejected by allowlist, not by signature)", async () => {
|
||||||
|
const ts = String(Math.floor(Date.now() / 1000));
|
||||||
|
const path = "/rooms/smoke-probe/members";
|
||||||
|
const headers = signedHeaders(id.signPub, id.signPriv, "GET", path, ts, freshNonce(), new Uint8Array(0));
|
||||||
|
const resp = await fetch(BUS_HTTP + path, { method: "GET", headers });
|
||||||
|
const body = await resp.text();
|
||||||
|
// The server verified our X-Unibus-* signature (busauth canonical + Ed25519 are
|
||||||
|
// correct) and then rejected us for not being in the allowlist. A 401 whose body
|
||||||
|
// is an authorization message — NOT "signature"/"canonical" — is the pass.
|
||||||
|
expect(resp.status).toBe(401);
|
||||||
|
expect(body.toLowerCase()).toContain("unauthorized");
|
||||||
|
expect(body.toLowerCase()).not.toContain("signature");
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log(`[control-plane] status=${resp.status} body=${body.trim()}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("data plane: nats.ws handshake reaches the nkey authenticator (authorization violation)", async () => {
|
||||||
|
let connected = false;
|
||||||
|
let errMsg = "";
|
||||||
|
try {
|
||||||
|
const t = await WsNatsTransport.connect([BUS_WS!], id);
|
||||||
|
connected = true;
|
||||||
|
await t.close();
|
||||||
|
} catch (e) {
|
||||||
|
errMsg = String((e as Error).message || e).toLowerCase();
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log(`[data-plane] connected=${connected} err=${errMsg}`);
|
||||||
|
// A fresh identity is not allow-listed, so the nkey authenticator must refuse the
|
||||||
|
// connection. Reaching an "authorization"/"nkey" rejection proves the WS transport
|
||||||
|
// + nkey signing path work against the real server. (If the user WERE registered,
|
||||||
|
// connected would be true.)
|
||||||
|
expect(connected || /authorization|nkey|permission|violation/.test(errMsg)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const regId = live ? registeredIdentity() : null;
|
||||||
|
|
||||||
|
describe.skipIf(!live || !regId)("live cluster smoke — REGISTERED identity is accepted", () => {
|
||||||
|
const id = regId!;
|
||||||
|
|
||||||
|
it("control plane: a registered identity is authorized (not 401)", async () => {
|
||||||
|
const ts = String(Math.floor(Date.now() / 1000));
|
||||||
|
const path = "/rooms/smoke-probe/members";
|
||||||
|
const headers = signedHeaders(id.signPub, id.signPriv, "GET", path, ts, freshNonce(), new Uint8Array(0));
|
||||||
|
const resp = await fetch(BUS_HTTP + path, { method: "GET", headers });
|
||||||
|
const body = await resp.text();
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log(`[control-plane:registered] status=${resp.status} body=${body.trim()}`);
|
||||||
|
// The allowlist no longer rejects us: the status is anything but 401 (a missing
|
||||||
|
// room yields 404/403, an existing one 200). The point is the identity passed.
|
||||||
|
expect(resp.status).not.toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("data plane: a registered identity connects over nats.ws (authenticated)", async () => {
|
||||||
|
let connected = false;
|
||||||
|
let errMsg = "";
|
||||||
|
try {
|
||||||
|
const t = await WsNatsTransport.connect([BUS_WS!], id);
|
||||||
|
connected = true;
|
||||||
|
await t.close();
|
||||||
|
} catch (e) {
|
||||||
|
errMsg = String((e as Error).message || e).toLowerCase();
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log(`[data-plane:registered] connected=${connected} err=${errMsg}`);
|
||||||
|
// Now the nkey authenticator accepts us: the connection succeeds. This is the
|
||||||
|
// full proof that the SDK authenticates on the live data plane end-to-end.
|
||||||
|
expect(connected).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe.skipIf(!live || !regId)("live cluster — end-to-end encrypted round-trip", () => {
|
||||||
|
const id = regId!;
|
||||||
|
|
||||||
|
it("creates an encrypted room, publishes, and receives its own decrypted message", async () => {
|
||||||
|
const control = new ControlPlane(BUS_HTTP!, id);
|
||||||
|
// Encrypted + signed, but EPHEMERAL (no JetStream persistence) to keep the smoke
|
||||||
|
// to core NATS pub/sub. A unique subject avoids colliding with prior runs.
|
||||||
|
const subject = `room.smoke-${id.signPub[0]}-${Math.floor(Date.now() / 1000)}`;
|
||||||
|
const { roomID } = await control.createRoom(subject, { encrypt: true, persist: false, signMsgs: true });
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log(`[round-trip] created room ${roomID} subject=${subject}`);
|
||||||
|
|
||||||
|
// Connect the data plane AFTER creating the room: the per-subject ACL freezes a
|
||||||
|
// peer's publishable/subscribable subjects at connect time, so the room's subject
|
||||||
|
// is in our grant only once we connect post-creation.
|
||||||
|
const transport = await WsNatsTransport.connect([BUS_WS!], id);
|
||||||
|
const bus = new BusClient(id, transport, control);
|
||||||
|
|
||||||
|
const got = new Promise<string>((resolve) => {
|
||||||
|
bus.subscribe(roomID, (_f: Frame, plaintext: Uint8Array) => {
|
||||||
|
resolve(new TextDecoder().decode(plaintext));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Give the subscription a moment to register on the server before publishing.
|
||||||
|
await new Promise((r) => setTimeout(r, 600));
|
||||||
|
const message = "hello from the browser SDK, end to end";
|
||||||
|
await bus.publish(roomID, new TextEncoder().encode(message));
|
||||||
|
|
||||||
|
const received = await Promise.race([
|
||||||
|
got,
|
||||||
|
new Promise<string>((_r, reject) => setTimeout(() => reject(new Error("timeout waiting for message")), 8000)),
|
||||||
|
]);
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log(`[round-trip] received="${received}"`);
|
||||||
|
await transport.close();
|
||||||
|
|
||||||
|
expect(received).toBe(message);
|
||||||
|
}, 20000);
|
||||||
|
});
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
// Room policy and metadata, ported from Go pkg/room. The policy decides how a
|
||||||
|
// message is treated on the wire: encrypted (AEAD with the room key), persisted
|
||||||
|
// (durable JetStream history), and/or signed (Ed25519 per message).
|
||||||
|
|
||||||
|
export interface Policy {
|
||||||
|
encrypt: boolean; // payload is AEAD-encrypted with the room key K
|
||||||
|
persist: boolean; // messages are kept in durable history (JetStream)
|
||||||
|
signMsgs: boolean; // each message carries an Ed25519 signature over its canonical bytes
|
||||||
|
}
|
||||||
|
|
||||||
|
// ModeNATS is a cleartext, ephemeral, unsigned room (the raw NATS behavior).
|
||||||
|
export const ModeNATS: Policy = { encrypt: false, persist: false, signMsgs: false };
|
||||||
|
|
||||||
|
// ModeMatrix is the secure default: end-to-end encrypted, persisted, and signed —
|
||||||
|
// the Matrix-like room the bus uses for real conversations.
|
||||||
|
export const ModeMatrix: Policy = { encrypt: true, persist: true, signMsgs: true };
|
||||||
|
|
||||||
|
export interface Room {
|
||||||
|
id: string;
|
||||||
|
subject: string;
|
||||||
|
epoch: number;
|
||||||
|
policy: Policy;
|
||||||
|
}
|
||||||
Vendored
+52
@@ -0,0 +1,52 @@
|
|||||||
|
{
|
||||||
|
"note": "Deterministic cross-language vectors for the unibus protocol. Generated by cmd/busvectors in the unibus repo; regenerate with `go run ./cmd/busvectors`. sealed_hex varies per run (anonymous sealed box); assert via OpenKeyBox.",
|
||||||
|
"endpoint_id": {
|
||||||
|
"sign_pub_hex": "03a107bff3ce10be1d70dd18e74bc09967e4d6309ba50d5f1ddc8664125531b8",
|
||||||
|
"endpoint_id": "Vkdap1RjR0wChd9dvyvKtz2mUTWIOem3dIGy6rEHcIw"
|
||||||
|
},
|
||||||
|
"nkey": {
|
||||||
|
"sign_pub_hex": "03a107bff3ce10be1d70dd18e74bc09967e4d6309ba50d5f1ddc8664125531b8",
|
||||||
|
"nkey_public": "UAB2CB576PHBBPQ5ODORRZ2LYCMWPZGWGCN2KDK7DXOIMZASKUY3RLKK"
|
||||||
|
},
|
||||||
|
"sign": {
|
||||||
|
"sign_priv_hex": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f03a107bff3ce10be1d70dd18e74bc09967e4d6309ba50d5f1ddc8664125531b8",
|
||||||
|
"sign_pub_hex": "03a107bff3ce10be1d70dd18e74bc09967e4d6309ba50d5f1ddc8664125531b8",
|
||||||
|
"message_hex": "756e696275732070617269747920766563746f72206d657373616765",
|
||||||
|
"sig_hex": "4cb94c5e3d81ac795e62e089b069c678a3ad3abdf67aed6daf84c023e77378a9c37e2c5b7350d2b129b7985dae132bdfe8b3e2d273d52b522a311131c62ec005"
|
||||||
|
},
|
||||||
|
"aead": {
|
||||||
|
"key_hex": "606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f",
|
||||||
|
"nonce_hex": "808182838485868788898a8b",
|
||||||
|
"aad_hex": "756e696275732d726f6f6d2d3432",
|
||||||
|
"plaintext_hex": "68656c6c6f2066726f6d2074686520627573",
|
||||||
|
"ciphertext_hex": "31a15f343585bd1831a35a43fdc974e87d5d76957284f13a1ffabdba78fe762ab7e4"
|
||||||
|
},
|
||||||
|
"keybox": {
|
||||||
|
"recipient_kex_pub_hex": "79a631eede1bf9c98f12032cdeadd0e7a079398fc786b88cc846ec89af85a51a",
|
||||||
|
"recipient_kex_priv_hex": "404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f",
|
||||||
|
"secret_hex": "a0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebf",
|
||||||
|
"sealed_hex": "70dfe90c477bac85a758c0c420c36d44e84a8e06434e2344e9e5c730a56e71404a592d37d79aa7c7a997c002160bac6a91c96fb0e6898153348eb19a6d9dc53b5677d40b0c0fdfc47c0b00727a61f04f"
|
||||||
|
},
|
||||||
|
"frame": {
|
||||||
|
"type": 0,
|
||||||
|
"subject": "room.parity",
|
||||||
|
"sender": "Vkdap1RjR0wChd9dvyvKtz2mUTWIOem3dIGy6rEHcIw",
|
||||||
|
"msg_id": "01HZY0VECTORFIXEDULID0001",
|
||||||
|
"epoch": 1,
|
||||||
|
"nonce_hex": "808182838485868788898a8b",
|
||||||
|
"payload_hex": "31a15f343585bd1831a35a43fdc974e87d5d76957284f13a1ffabdba78fe762ab7e4",
|
||||||
|
"wire_b64": "eyJ0IjowLCJzIjoicm9vbS5wYXJpdHkiLCJmcm9tIjoiVmtkYXAxUmpSMHdDaGQ5ZHZ5dkt0ejJtVVRXSU9lbTNkSUd5NnJFSGNJdyIsImlkIjoiMDFIWlkwVkVDVE9SRklYRURVTElEMDAwMSIsImUiOjEsIm4iOiJnSUdDZzRTRmhvZUlpWXFMIiwicCI6Ik1hRmZORFdGdlJneG8xcEQvY2wwNkgxZGRwVnloUEU2SC9xOXVuaitkaXEzNUE9PSIsInNpZyI6IkZOTDFhak0yZFA2c3J5WENyMmoxOVNCVS9rT29MUEpUR2gzNGpuK3pTMVdrV1JPa1ZhTTlXU042WnFrSW1BUjluSGNHYXo4VnJJL3dSMzAyNWFLbkRRPT0ifQ==",
|
||||||
|
"signing_bytes_b64": "eyJ0IjowLCJzIjoicm9vbS5wYXJpdHkiLCJmcm9tIjoiVmtkYXAxUmpSMHdDaGQ5ZHZ5dkt0ejJtVVRXSU9lbTNkSUd5NnJFSGNJdyIsImlkIjoiMDFIWlkwVkVDVE9SRklYRURVTElEMDAwMSIsImUiOjEsIm4iOiJnSUdDZzRTRmhvZUlpWXFMIiwicCI6Ik1hRmZORFdGdlJneG8xcEQvY2wwNkgxZGRwVnloUEU2SC9xOXVuaitkaXEzNUE9PSJ9",
|
||||||
|
"sig_hex": "14d2f56a333674feacaf25c2af68f5f52054fe43a82cf2531a1df88e7fb34b55a45913a455a33d59237a66a90898047d9c77066b3f15ac8ff0477d36e5a2a70d"
|
||||||
|
},
|
||||||
|
"control_request": {
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/rooms",
|
||||||
|
"ts": "1700000000",
|
||||||
|
"nonce": "Zm9vYmFyMTIzNDU2Nzg5MA==",
|
||||||
|
"body_hex": "7b227375626a656374223a22726f6f6d2e706172697479227d",
|
||||||
|
"canonical_hex": "504f53540a2f726f6f6d730a313730303030303030300a5a6d3976596d46794d54497a4e4455324e7a67354d413d3d0a30393038653333663161366261633463363465313938656530613935623532323866383865393337333366323739663038653830336463353931623137643834",
|
||||||
|
"sig_hex": "1802bd9d6b05b027ed43f0eecdcc831f257065e6e7306e7f0cf8c5db5b07ac57802f6c1e37d4bbc7cc6452d812be644817b908982ba64a455c5e287c6a4c2c0d",
|
||||||
|
"sign_priv_hex": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f03a107bff3ce10be1d70dd18e74bc09967e4d6309ba50d5f1ddc8664125531b8"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
// Tests for ulidTime, the decoder of the millisecond timestamp a ULID encodes in its
|
||||||
|
// first 10 Crockford base32 characters. A wire frame carries no explicit timestamp —
|
||||||
|
// its ULID id IS the timestamp — so the UI derives a message's time (and thus its sort
|
||||||
|
// order, live and replayed-history alike) from this function. These tests pin that it
|
||||||
|
// is the exact inverse of newULID's time prefix and that it is time-ordered.
|
||||||
|
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { newULID, ulidTime } from "./client.js";
|
||||||
|
|
||||||
|
describe("ulidTime", () => {
|
||||||
|
it("round-trips the millisecond timestamp newULID encodes", () => {
|
||||||
|
for (const ms of [0, 1, 1_000, 1_700_000_000_000, 2_000_000_000_000, Date.now()]) {
|
||||||
|
expect(ulidTime(newULID(ms))).toBe(ms);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is monotonic: a later message decodes to a larger time", () => {
|
||||||
|
const earlier = newULID(1_700_000_000_000);
|
||||||
|
const later = newULID(1_700_000_001_000);
|
||||||
|
expect(ulidTime(earlier)).toBeLessThan(ulidTime(later));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores the 16-char random suffix (only the 10-char time prefix matters)", () => {
|
||||||
|
const ms = 1_736_000_000_000;
|
||||||
|
// Two ULIDs minted at the same ms differ only in their random tail, yet decode equal.
|
||||||
|
expect(ulidTime(newULID(ms))).toBe(ms);
|
||||||
|
expect(ulidTime(newULID(ms))).toBe(ms);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 0 for an id whose prefix is not valid Crockford base32", () => {
|
||||||
|
expect(ulidTime("!!!!!!!!!!xxxxxxxxxxxxxxxx")).toBe(0);
|
||||||
|
expect(ulidTime("")).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
// Cross-language parity tests: the TypeScript bus SDK must reproduce the Go
|
||||||
|
// reference implementation byte-for-byte. The golden vectors in testdata/vectors.json
|
||||||
|
// are generated by unibus `cmd/busvectors`. Any divergence here means a browser
|
||||||
|
// client and a Go/Kotlin peer would not interoperate (issue 0001, Phase 1).
|
||||||
|
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import vectors from "./testdata/vectors.json";
|
||||||
|
import {
|
||||||
|
hexToBytes,
|
||||||
|
bytesToHex,
|
||||||
|
base64ToBytes,
|
||||||
|
endpointID,
|
||||||
|
signEd25519,
|
||||||
|
verifyEd25519,
|
||||||
|
sealAEAD,
|
||||||
|
openAEAD,
|
||||||
|
openKeyBox,
|
||||||
|
sealKeyBox,
|
||||||
|
} from "./crypto.js";
|
||||||
|
import { Frame, FrameType, marshal, signingBytes, signFrame, verifyFrame } from "./frame.js";
|
||||||
|
|
||||||
|
describe("endpoint id", () => {
|
||||||
|
it("matches Go EndpointID = base64url(sha256(signPub))", () => {
|
||||||
|
const v = vectors.endpoint_id;
|
||||||
|
expect(endpointID(hexToBytes(v.sign_pub_hex))).toBe(v.endpoint_id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Ed25519 signing", () => {
|
||||||
|
it("produces the same deterministic signature as Go", () => {
|
||||||
|
const v = vectors.sign;
|
||||||
|
const sig = signEd25519(hexToBytes(v.sign_priv_hex), hexToBytes(v.message_hex));
|
||||||
|
expect(bytesToHex(sig)).toBe(v.sig_hex);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("verifies the Go-produced signature", () => {
|
||||||
|
const v = vectors.sign;
|
||||||
|
const ok = verifyEd25519(hexToBytes(v.sig_hex), hexToBytes(v.message_hex), hexToBytes(v.sign_pub_hex));
|
||||||
|
expect(ok).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ChaCha20-Poly1305 AEAD", () => {
|
||||||
|
it("opens the Go-sealed ciphertext", () => {
|
||||||
|
const v = vectors.aead;
|
||||||
|
const pt = openAEAD(
|
||||||
|
hexToBytes(v.key_hex),
|
||||||
|
hexToBytes(v.nonce_hex),
|
||||||
|
hexToBytes(v.ciphertext_hex),
|
||||||
|
hexToBytes(v.aad_hex),
|
||||||
|
);
|
||||||
|
expect(bytesToHex(pt)).toBe(v.plaintext_hex);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("seals to the same ciphertext as Go with a fixed nonce", () => {
|
||||||
|
const v = vectors.aead;
|
||||||
|
const ct = sealAEAD(
|
||||||
|
hexToBytes(v.key_hex),
|
||||||
|
hexToBytes(v.nonce_hex),
|
||||||
|
hexToBytes(v.plaintext_hex),
|
||||||
|
hexToBytes(v.aad_hex),
|
||||||
|
);
|
||||||
|
expect(bytesToHex(ct)).toBe(v.ciphertext_hex);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("anonymous sealed box (room key distribution)", () => {
|
||||||
|
it("opens the Go-sealed room key", () => {
|
||||||
|
const v = vectors.keybox;
|
||||||
|
const secret = openKeyBox(
|
||||||
|
hexToBytes(v.recipient_kex_pub_hex),
|
||||||
|
hexToBytes(v.recipient_kex_priv_hex),
|
||||||
|
hexToBytes(v.sealed_hex),
|
||||||
|
);
|
||||||
|
expect(secret).not.toBeNull();
|
||||||
|
expect(bytesToHex(secret!)).toBe(v.secret_hex);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("round-trips a TS-sealed box (seal then open)", () => {
|
||||||
|
const v = vectors.keybox;
|
||||||
|
const pub = hexToBytes(v.recipient_kex_pub_hex);
|
||||||
|
const priv = hexToBytes(v.recipient_kex_priv_hex);
|
||||||
|
const secret = hexToBytes(v.secret_hex);
|
||||||
|
const sealed = sealKeyBox(pub, secret);
|
||||||
|
const opened = openKeyBox(pub, priv, sealed);
|
||||||
|
expect(opened).not.toBeNull();
|
||||||
|
expect(bytesToHex(opened!)).toBe(v.secret_hex);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Frame wire format", () => {
|
||||||
|
function vectorFrame(): Frame {
|
||||||
|
const v = vectors.frame;
|
||||||
|
return {
|
||||||
|
type: v.type as FrameType,
|
||||||
|
subject: v.subject,
|
||||||
|
sender: v.sender,
|
||||||
|
msgID: v.msg_id,
|
||||||
|
epoch: v.epoch,
|
||||||
|
nonce: hexToBytes(v.nonce_hex),
|
||||||
|
payload: hexToBytes(v.payload_hex),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
it("produces the same canonical signing bytes as Go", () => {
|
||||||
|
const got = signingBytes(vectorFrame());
|
||||||
|
const want = base64ToBytes(vectors.frame.signing_bytes_b64);
|
||||||
|
expect(bytesToHex(got)).toBe(bytesToHex(want));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("signs the frame to the same Ed25519 signature as Go", () => {
|
||||||
|
const f = signFrame(vectorFrame(), hexToBytes(vectors.sign.sign_priv_hex));
|
||||||
|
expect(bytesToHex(f.sig!)).toBe(vectors.frame.sig_hex);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marshals the signed frame to the same wire bytes as Go", () => {
|
||||||
|
const f = signFrame(vectorFrame(), hexToBytes(vectors.sign.sign_priv_hex));
|
||||||
|
const got = marshal(f);
|
||||||
|
const want = base64ToBytes(vectors.frame.wire_b64);
|
||||||
|
expect(bytesToHex(got)).toBe(bytesToHex(want));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("verifies the marshaled frame signature against the signer pubkey", () => {
|
||||||
|
const f = signFrame(vectorFrame(), hexToBytes(vectors.sign.sign_priv_hex));
|
||||||
|
expect(verifyFrame(f, hexToBytes(vectors.sign.sign_pub_hex))).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
// Concrete NATS-over-WebSocket transport for the browser, built on nats.ws. This is
|
||||||
|
// the thin glue between the BusClient logic (which is transport-agnostic and unit-
|
||||||
|
// tested) and a live NATS server reached over ws(s)://. Because it needs a running
|
||||||
|
// unibus with the WebSocket listener enabled (issue uniweb/0001, Phase 0), it is
|
||||||
|
// exercised by the end-to-end tests in Phase 3, not by unit tests.
|
||||||
|
//
|
||||||
|
// Note: nats.ws 1.30.x is deprecated upstream in favor of @nats-io/nats-core with a
|
||||||
|
// WebSocket transport; migrating is tracked as Phase 3 follow-up. The connection
|
||||||
|
// authenticates with the user's NATS nkey (derived from their Ed25519 identity), so
|
||||||
|
// the private key signs the server nonce in the browser and never leaves it.
|
||||||
|
|
||||||
|
import { connect, type NatsConnection, type Authenticator } from "nats.ws";
|
||||||
|
import type { Identity, NatsTransport, MessageHandler, Subscription } from "./client.js";
|
||||||
|
import { natsAuthenticator } from "./busauth.js";
|
||||||
|
|
||||||
|
export class WsNatsTransport implements NatsTransport {
|
||||||
|
// servers + id are retained so reconnect() can rebuild the connection with the same
|
||||||
|
// identity — needed because the per-subject ACL freezes a peer's publishable/
|
||||||
|
// subscribable subjects at connect time, so a room created after connecting only
|
||||||
|
// becomes usable after a fresh connection re-evaluates membership.
|
||||||
|
private constructor(
|
||||||
|
private nc: NatsConnection,
|
||||||
|
private servers: string[],
|
||||||
|
private id: Identity,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
private static newConn(servers: string[], id: Identity): Promise<NatsConnection> {
|
||||||
|
const sign = natsAuthenticator(id.signPub, id.signPriv);
|
||||||
|
// nats.ws's Authenticator returns the nkey + the base64url signature of the
|
||||||
|
// server nonce; our natsAuthenticator produces exactly that shape.
|
||||||
|
const authenticator: Authenticator = (nonce?: string) => sign(nonce ?? "");
|
||||||
|
return connect({ servers, authenticator });
|
||||||
|
}
|
||||||
|
|
||||||
|
// connect opens a WebSocket connection to one of the given ws(s):// servers,
|
||||||
|
// authenticating with the user's nkey identity.
|
||||||
|
static async connect(servers: string[], id: Identity): Promise<WsNatsTransport> {
|
||||||
|
const nc = await WsNatsTransport.newConn(servers, id);
|
||||||
|
return new WsNatsTransport(nc, servers, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// reconnect drops the current connection and opens a fresh one with the same
|
||||||
|
// identity, so the server's subject-ACL re-evaluates this peer's room membership.
|
||||||
|
// Active subscriptions from the previous connection are lost; the caller must
|
||||||
|
// re-subscribe (BusClient.subscribe) to the rooms it cares about afterwards.
|
||||||
|
async reconnect(): Promise<void> {
|
||||||
|
const old = this.nc;
|
||||||
|
this.nc = await WsNatsTransport.newConn(this.servers, this.id);
|
||||||
|
await old.close().catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
publish(subject: string, data: Uint8Array): void {
|
||||||
|
this.nc.publish(subject, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
async subscribe(subject: string, handler: MessageHandler): Promise<Subscription> {
|
||||||
|
const sub = this.nc.subscribe(subject, {
|
||||||
|
callback: (err, msg) => {
|
||||||
|
if (!err) handler(subject, msg.data);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return { unsubscribe: () => sub.unsubscribe() };
|
||||||
|
}
|
||||||
|
|
||||||
|
async close(): Promise<void> {
|
||||||
|
await this.nc.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,468 @@
|
|||||||
|
// The single data layer of the SPA — the browser-native replacement for the old
|
||||||
|
// `api` module. Where `api` talked to a Go gateway under /api (cookie session, SSE,
|
||||||
|
// and the private key shipped to the server), this talks DIRECTLY to the bus:
|
||||||
|
//
|
||||||
|
// - control plane: signed HTTPS to membershipd (rooms, keys, members), and
|
||||||
|
// - data plane: nats.ws to NATS,
|
||||||
|
//
|
||||||
|
// using the user's wallet identity, which stays in the browser. The private key
|
||||||
|
// signs and decrypts here and is NEVER sent anywhere (issue uniweb/0001, Phase 2).
|
||||||
|
//
|
||||||
|
// The exported `bus` object mirrors the old `api` surface so the page components
|
||||||
|
// change only their import; streamRoom is replaced by bus.subscribeRoom.
|
||||||
|
|
||||||
|
import {
|
||||||
|
BusClient,
|
||||||
|
ControlPlane,
|
||||||
|
WsNatsTransport,
|
||||||
|
hexToBytes,
|
||||||
|
endpointID,
|
||||||
|
ulidTime,
|
||||||
|
type Identity,
|
||||||
|
type Frame,
|
||||||
|
ModeMatrix,
|
||||||
|
} from "./bus/index";
|
||||||
|
import type { WalletIdentity } from "./wallet/derive";
|
||||||
|
import type { MeInfo, Message, Room, User } from "./types";
|
||||||
|
import { saveSession, loadSession, touchSession, clearSession } from "./session";
|
||||||
|
|
||||||
|
// Bus endpoints. The SPA is served same-origin behind a reverse proxy (Caddy):
|
||||||
|
// both planes are reached through this page's OWN origin, so there is no CORS and
|
||||||
|
// the cluster node IPs stay hidden behind the proxy. The control plane is the
|
||||||
|
// signed HTTPS API under the relative path /api; the data plane is NATS over
|
||||||
|
// WebSocket under /nats (a browser cannot open a raw TCP NATS socket). Both can
|
||||||
|
// still be overridden at build time (VITE_BUS_HTTP / VITE_BUS_WS) for a dev setup
|
||||||
|
// that points straight at a cluster node.
|
||||||
|
const BUS_HTTP = import.meta.env.VITE_BUS_HTTP ?? "/api";
|
||||||
|
const BUS_WS = import.meta.env.VITE_BUS_WS ?? defaultBusWS();
|
||||||
|
|
||||||
|
// defaultBusWS derives the data-plane WebSocket URL from the page origin: the same
|
||||||
|
// host and port as the SPA, the wss/ws scheme mirroring https/http, path /nats. A
|
||||||
|
// browser WebSocket needs an absolute ws(s) URL, so this is computed from location
|
||||||
|
// rather than left relative. Returns "" where window is absent (SSR/tests), where
|
||||||
|
// the build-time override is expected instead.
|
||||||
|
function defaultBusWS(): string {
|
||||||
|
if (typeof window === "undefined") return "";
|
||||||
|
const proto = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||||
|
return `${proto}//${window.location.host}/nats`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SessionError extends Error {}
|
||||||
|
|
||||||
|
// toIdentity maps the wallet's hex identity to the SDK's byte identity. The private
|
||||||
|
// halves stay in memory only.
|
||||||
|
function toIdentity(w: WalletIdentity): Identity {
|
||||||
|
return {
|
||||||
|
signPub: hexToBytes(w.signPub),
|
||||||
|
signPriv: hexToBytes(w.signPriv),
|
||||||
|
kexPub: hexToBytes(w.kexPub),
|
||||||
|
kexPriv: hexToBytes(w.kexPriv),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// A live session: the connected BusClient plus the display identity. Held in a
|
||||||
|
// module singleton — one active wallet per tab (MVP), like the wallet store.
|
||||||
|
interface Session {
|
||||||
|
identity: Identity;
|
||||||
|
handle: string;
|
||||||
|
endpoint: string;
|
||||||
|
control: ControlPlane;
|
||||||
|
transport: WsNatsTransport;
|
||||||
|
client: BusClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
let session: Session | null = null;
|
||||||
|
|
||||||
|
// directory maps a peer's stable endpoint id to its human handle, so the UI can show
|
||||||
|
// a readable name instead of the long base64url id. Populated from the control-plane
|
||||||
|
// GET /api/directory once a session opens, and refreshed when membership changes. It
|
||||||
|
// is best-effort: a cluster without the directory endpoint leaves it empty and the UI
|
||||||
|
// falls back to a short id (see displayName), so the chat keeps working regardless.
|
||||||
|
let directory = new Map<string, string>();
|
||||||
|
|
||||||
|
// shortId is the display fallback for an endpoint with no known handle: the first 8
|
||||||
|
// characters of the id, never the full long string.
|
||||||
|
function shortId(endpoint: string): string {
|
||||||
|
return endpoint.slice(0, 8);
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadDirectory (re)loads the cluster member directory into the endpoint -> handle
|
||||||
|
// map. It NEVER throws: if the endpoint is missing (older cluster, 404) or the request
|
||||||
|
// fails, the existing map is kept (empty on first load) and callers fall back to the
|
||||||
|
// short id. The new map is built locally and only swapped in on success, so a failed
|
||||||
|
// refresh never wipes a directory that loaded earlier.
|
||||||
|
async function loadDirectory(s: Session): Promise<void> {
|
||||||
|
try {
|
||||||
|
const entries = await s.control.fetchDirectory();
|
||||||
|
const next = new Map<string, string>();
|
||||||
|
for (const e of entries) if (e.handle) next.set(e.endpoint, e.handle);
|
||||||
|
directory = next;
|
||||||
|
} catch {
|
||||||
|
// No directory endpoint yet, or a transient failure: keep what we have (the chat
|
||||||
|
// must work exactly as before without readable names).
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// displayNameOf is the resolver behind bus.displayName, kept module-level so the
|
||||||
|
// room store can reuse it for last-message previews.
|
||||||
|
function displayNameOf(endpoint: string): string {
|
||||||
|
if (session && endpoint === session.endpoint) {
|
||||||
|
return session.handle || directory.get(endpoint) || shortId(endpoint);
|
||||||
|
}
|
||||||
|
return directory.get(endpoint) || shortId(endpoint);
|
||||||
|
}
|
||||||
|
|
||||||
|
function require_(): Session {
|
||||||
|
if (!session) throw new SessionError("no active bus session");
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- room store (sidebar metadata) -----------------------------------------
|
||||||
|
//
|
||||||
|
// The sidebar needs each room's last message and time, plus an unread count for
|
||||||
|
// rooms the user is NOT currently viewing. NATS delivers live only, so a live metadata
|
||||||
|
// subscription per room keeps the sidebar current while the app is open; on first load
|
||||||
|
// (or after a reload) the control plane's history endpoint seeds each room's last
|
||||||
|
// message so a room with no live traffic yet still shows its real latest line instead
|
||||||
|
// of "—". This store owns that: it holds the room list, subscribes to each room for
|
||||||
|
// metadata, seeds the preview from history, and notifies React watchers on every
|
||||||
|
// change. ChatPanel keeps its own subscription for the open conversation; this store's
|
||||||
|
// per-room subscription is independent and only updates sidebar metadata.
|
||||||
|
|
||||||
|
let roomList: Room[] = [];
|
||||||
|
let activeRoomID = "";
|
||||||
|
const roomListeners = new Set<(rooms: Room[]) => void>();
|
||||||
|
const metaSubs = new Map<string, () => void>(); // roomID -> unsubscribe
|
||||||
|
|
||||||
|
const PREVIEW_MAX = 48; // characters of a last-message preview in the sidebar
|
||||||
|
|
||||||
|
// snapshotRooms returns a shallow copy so React sees a new array/objects and re-renders.
|
||||||
|
function snapshotRooms(): Room[] {
|
||||||
|
return roomList.map((r) => ({ ...r }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function notifyRooms(): void {
|
||||||
|
const snap = snapshotRooms();
|
||||||
|
for (const l of roomListeners) l(snap);
|
||||||
|
}
|
||||||
|
|
||||||
|
// previewText builds the sidebar's last-message line: "name: body" with the body
|
||||||
|
// truncated, reusing the directory resolver so the sender shows as a readable handle.
|
||||||
|
function previewText(m: Message): string {
|
||||||
|
const body =
|
||||||
|
m.body.length > PREVIEW_MAX ? m.body.slice(0, PREVIEW_MAX - 1) + "…" : m.body;
|
||||||
|
return `${displayNameOf(m.sender)}: ${body}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// trackRoomMeta opens a metadata subscription for one room: each delivered message
|
||||||
|
// updates the room's last message/time and bumps unread when the room is not active.
|
||||||
|
function trackRoomMeta(roomID: string): void {
|
||||||
|
if (metaSubs.has(roomID)) return;
|
||||||
|
const unsub = subscribeRoomInternal(roomID, (m) => {
|
||||||
|
const r = roomList.find((x) => x.id === roomID);
|
||||||
|
if (!r) return;
|
||||||
|
r.lastTs = m.ts;
|
||||||
|
r.lastMessage = previewText(m);
|
||||||
|
if (roomID !== activeRoomID && !m.mine) r.unread += 1;
|
||||||
|
notifyRooms();
|
||||||
|
});
|
||||||
|
metaSubs.set(roomID, unsub);
|
||||||
|
}
|
||||||
|
|
||||||
|
// seedRoomPreviews fills each room's sidebar preview (last message + time) from the
|
||||||
|
// control plane's history, best-effort and in the background: the room list renders
|
||||||
|
// immediately, then each preview updates as its single most-recent stored message
|
||||||
|
// arrives. It never overwrites a live message that is already newer, and a room with
|
||||||
|
// genuinely no history keeps the "—" placeholder (lastTs 0). Errors (missing endpoint,
|
||||||
|
// transient) are swallowed per room so one failure never blocks the others.
|
||||||
|
function seedRoomPreviews(s: Session): void {
|
||||||
|
for (const r of roomList) {
|
||||||
|
s.client
|
||||||
|
.history(r.id, 1)
|
||||||
|
.then((items) => {
|
||||||
|
if (!items.length) return;
|
||||||
|
const last = items[items.length - 1];
|
||||||
|
const m = toMessage(s, last.frame, last.plaintext);
|
||||||
|
const room = roomList.find((x) => x.id === r.id);
|
||||||
|
if (!room || m.ts < room.lastTs) return; // a newer live message already won
|
||||||
|
room.lastTs = m.ts;
|
||||||
|
room.lastMessage = previewText(m);
|
||||||
|
notifyRooms();
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function untrackAllRooms(): void {
|
||||||
|
for (const unsub of metaSubs.values()) {
|
||||||
|
try {
|
||||||
|
unsub();
|
||||||
|
} catch {
|
||||||
|
/* a closing transport may already be gone */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
metaSubs.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
// retrackRooms re-establishes a metadata subscription for every room. Used after a
|
||||||
|
// data-plane reconnect (createRoom's refresh), which drops all existing subscriptions.
|
||||||
|
function retrackRooms(): void {
|
||||||
|
untrackAllRooms();
|
||||||
|
for (const r of roomList) trackRoomMeta(r.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// resetRoomStore clears the store and tears down subscriptions (on logout / new
|
||||||
|
// session), then pushes the empty snapshot so any live watcher renders an empty list.
|
||||||
|
function resetRoomStore(): void {
|
||||||
|
untrackAllRooms();
|
||||||
|
roomList = [];
|
||||||
|
activeRoomID = "";
|
||||||
|
notifyRooms();
|
||||||
|
}
|
||||||
|
|
||||||
|
// toMessage maps an opened bus frame to the UI's Message. The timestamp comes from the
|
||||||
|
// frame's ULID id (ulidTime), NOT the arrival time: a frame carries no explicit ts on
|
||||||
|
// the wire, and deriving it from the id puts live and replayed-history messages on the
|
||||||
|
// same clock so they sort into one correct order.
|
||||||
|
function toMessage(s: Session, f: Frame, plaintext: Uint8Array): Message {
|
||||||
|
return {
|
||||||
|
id: f.msgID,
|
||||||
|
sender: f.sender,
|
||||||
|
body: new TextDecoder().decode(plaintext),
|
||||||
|
ts: ulidTime(f.msgID),
|
||||||
|
mine: f.sender === s.endpoint,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// subscribeRoomInternal is the live-only core behind the store's per-room metadata
|
||||||
|
// subscription (and the live half of subscribeRoomWithHistory): it decodes each frame
|
||||||
|
// into a UI Message and hands it to onMessage. Returns a function that cancels the
|
||||||
|
// subscription.
|
||||||
|
function subscribeRoomInternal(
|
||||||
|
roomID: string,
|
||||||
|
onMessage: (m: Message) => void,
|
||||||
|
): () => void {
|
||||||
|
const s = require_();
|
||||||
|
let unsub: (() => void) | null = null;
|
||||||
|
let closed = false;
|
||||||
|
s.client
|
||||||
|
.subscribe(roomID, (f: Frame, plaintext: Uint8Array) => {
|
||||||
|
onMessage(toMessage(s, f, plaintext));
|
||||||
|
})
|
||||||
|
.then((sub) => {
|
||||||
|
if (closed) void sub.unsubscribe();
|
||||||
|
else unsub = () => void sub.unsubscribe();
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
return () => {
|
||||||
|
closed = true;
|
||||||
|
if (unsub) unsub();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// subscribeRoomWithHistory is what ChatPanel opens for the conversation it is viewing:
|
||||||
|
// it seeds the room with its stored history (so a reload no longer loses the messages)
|
||||||
|
// and then keeps it live. History and live are deduplicated by frame id through a
|
||||||
|
// per-room `seen` set — a message can arrive both ways when it lands between the fetch
|
||||||
|
// and the subscription. To guarantee history shows first (oldest -> newest) regardless
|
||||||
|
// of timing, live messages are buffered until the history batch has been delivered,
|
||||||
|
// then flushed. If history fails or the endpoint is absent (404/500 on an older
|
||||||
|
// cluster), it is treated as empty and the room runs live-only, exactly as before.
|
||||||
|
function subscribeRoomWithHistory(
|
||||||
|
roomID: string,
|
||||||
|
onMessage: (m: Message) => void,
|
||||||
|
): () => void {
|
||||||
|
const s = require_();
|
||||||
|
const seen = new Set<string>();
|
||||||
|
let historyDone = false;
|
||||||
|
let pending: Message[] = [];
|
||||||
|
const deliver = (m: Message): void => {
|
||||||
|
if (seen.has(m.id)) return;
|
||||||
|
seen.add(m.id);
|
||||||
|
onMessage(m);
|
||||||
|
};
|
||||||
|
// Live is subscribed immediately so nothing published during the history fetch is
|
||||||
|
// missed; messages are buffered until the history batch lands, then delivered.
|
||||||
|
const liveUnsub = subscribeRoomInternal(roomID, (m) => {
|
||||||
|
if (historyDone) deliver(m);
|
||||||
|
else pending.push(m);
|
||||||
|
});
|
||||||
|
s.client
|
||||||
|
.history(roomID)
|
||||||
|
.then((items) => {
|
||||||
|
for (const { frame, plaintext } of items) deliver(toMessage(s, frame, plaintext));
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// No history endpoint yet, or a transient failure: fall back to live-only.
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
historyDone = true;
|
||||||
|
for (const m of pending) deliver(m);
|
||||||
|
pending = [];
|
||||||
|
});
|
||||||
|
return liveUnsub;
|
||||||
|
}
|
||||||
|
|
||||||
|
// connectSession opens the live bus connection (control plane + nats.ws data plane)
|
||||||
|
// for a wallet identity, WITHOUT touching persistence. The private key is used here
|
||||||
|
// in the browser and never leaves it.
|
||||||
|
async function connectSession(wallet: WalletIdentity, handle: string): Promise<User> {
|
||||||
|
const identity = toIdentity(wallet);
|
||||||
|
const endpoint = endpointID(identity.signPub);
|
||||||
|
const control = new ControlPlane(BUS_HTTP, identity);
|
||||||
|
const transport = await WsNatsTransport.connect([BUS_WS], identity);
|
||||||
|
const client = new BusClient(identity, transport, control);
|
||||||
|
session = { identity, handle, endpoint, control, transport, client };
|
||||||
|
directory = new Map(); // fresh identity: drop any prior session's handle map
|
||||||
|
resetRoomStore(); // drop any prior session's room store + metadata subscriptions
|
||||||
|
await loadDirectory(session); // best-effort; never blocks login on a directory error
|
||||||
|
return { id: endpoint, handle: handle || endpoint.slice(0, 8) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export const bus = {
|
||||||
|
// openSession connects to the bus AS this wallet user and persists the session so a
|
||||||
|
// reload does not force a password re-unlock. remember=true keeps it across closing
|
||||||
|
// the browser (localStorage, up to 30 days / 12 h idle); false keeps it only for the
|
||||||
|
// tab (sessionStorage, survives F5). The private key never leaves the browser — this
|
||||||
|
// is the fix for the old gateway model where the browser POSTed its private key.
|
||||||
|
async openSession(wallet: WalletIdentity, handle: string, remember = false): Promise<User> {
|
||||||
|
const user = await connectSession(wallet, handle);
|
||||||
|
saveSession(wallet, handle, remember);
|
||||||
|
return user;
|
||||||
|
},
|
||||||
|
|
||||||
|
// restoreSession re-opens a previously persisted session on page load, if one exists
|
||||||
|
// and has not expired (TTL/idle checked in loadSession). It does NOT re-save (so the
|
||||||
|
// absolute 30-day TTL is not renewed on every reload) — it only refreshes the idle
|
||||||
|
// timer. Returns the User on success, or null when there is nothing to restore.
|
||||||
|
async restoreSession(): Promise<User | null> {
|
||||||
|
const persisted = loadSession();
|
||||||
|
if (!persisted) return null;
|
||||||
|
try {
|
||||||
|
const user = await connectSession(persisted.wallet, persisted.handle);
|
||||||
|
touchSession(); // restart the idle window; keep createdAt (TTL) intact
|
||||||
|
return user;
|
||||||
|
} catch {
|
||||||
|
// Connection failed (offline, identity revoked, ...): drop the stale session so
|
||||||
|
// the router falls back to the password unlock rather than looping.
|
||||||
|
clearSession();
|
||||||
|
session = null;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// me returns the identity of the active session (was GET /api/me).
|
||||||
|
me(): MeInfo {
|
||||||
|
const s = require_();
|
||||||
|
return { endpoint: s.endpoint, sign_pub: "", handle: s.handle };
|
||||||
|
},
|
||||||
|
|
||||||
|
// displayName resolves a sender endpoint id to a readable name for the UI: the
|
||||||
|
// member's handle when the directory knows it, the session user's own handle for
|
||||||
|
// their own messages, and a short id fallback otherwise — NEVER the full long
|
||||||
|
// endpoint. Pure lookup over the in-memory directory; safe to call from render.
|
||||||
|
displayName(endpoint: string): string {
|
||||||
|
return displayNameOf(endpoint);
|
||||||
|
},
|
||||||
|
|
||||||
|
// logout closes the data-plane connection, drops the in-memory session, and clears
|
||||||
|
// the persisted session from both stores so it cannot be restored.
|
||||||
|
async logout(): Promise<void> {
|
||||||
|
clearSession();
|
||||||
|
resetRoomStore();
|
||||||
|
directory = new Map();
|
||||||
|
if (session) {
|
||||||
|
await session.transport.close().catch(() => {});
|
||||||
|
session = null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// watchRooms subscribes a listener to the sidebar room list and returns a function
|
||||||
|
// to detach it. The current snapshot is pushed immediately, so a component mounting
|
||||||
|
// mid-session renders the rooms it already has. Call loadRooms() to (re)populate.
|
||||||
|
watchRooms(listener: (rooms: Room[]) => void): () => void {
|
||||||
|
roomListeners.add(listener);
|
||||||
|
listener(snapshotRooms());
|
||||||
|
return () => {
|
||||||
|
roomListeners.delete(listener);
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
// loadRooms fetches the rooms this peer belongs to, replaces the store, opens a
|
||||||
|
// metadata subscription per room (so the sidebar shows the latest message/time and
|
||||||
|
// unread for rooms the user is not viewing), and notifies watchers.
|
||||||
|
async loadRooms(): Promise<void> {
|
||||||
|
const s = require_();
|
||||||
|
const wire = await s.control.listMemberRooms(s.endpoint);
|
||||||
|
untrackAllRooms();
|
||||||
|
roomList = wire.map((r) => ({
|
||||||
|
id: r.id,
|
||||||
|
name: r.subject,
|
||||||
|
encrypted: r.policy.encrypt,
|
||||||
|
lastMessage: "",
|
||||||
|
lastTs: 0,
|
||||||
|
unread: 0,
|
||||||
|
messages: [],
|
||||||
|
}));
|
||||||
|
for (const r of roomList) trackRoomMeta(r.id);
|
||||||
|
notifyRooms();
|
||||||
|
seedRoomPreviews(s); // fill each preview from history without blocking the render
|
||||||
|
},
|
||||||
|
|
||||||
|
// setActiveRoom marks the room the user is viewing: its unread count is cleared and
|
||||||
|
// future messages to it do not bump unread (see trackRoomMeta).
|
||||||
|
setActiveRoom(roomID: string): void {
|
||||||
|
activeRoomID = roomID;
|
||||||
|
const r = roomList.find((x) => x.id === roomID);
|
||||||
|
if (r) r.unread = 0;
|
||||||
|
notifyRooms();
|
||||||
|
},
|
||||||
|
|
||||||
|
// createRoom creates an encrypted, signed room owned by this peer (the Matrix-like
|
||||||
|
// default), then reconnects the data plane so the new room's subject enters this
|
||||||
|
// connection's ACL grant — otherwise publish/subscribe on a just-created room would
|
||||||
|
// silently not deliver until a reconnect/re-login. The reconnect drops every
|
||||||
|
// metadata subscription, so they are re-established here. Returns the UI Room.
|
||||||
|
async createRoom(subject: string): Promise<Room> {
|
||||||
|
const s = require_();
|
||||||
|
const { roomID } = await s.control.createRoom(subject, ModeMatrix);
|
||||||
|
await s.client.refresh(); // re-evaluate the per-subject ACL with the new room
|
||||||
|
await loadDirectory(s); // a new room may bring new members into the directory
|
||||||
|
touchSession();
|
||||||
|
const room: Room = {
|
||||||
|
id: roomID,
|
||||||
|
name: subject,
|
||||||
|
encrypted: true,
|
||||||
|
lastMessage: "",
|
||||||
|
lastTs: 0,
|
||||||
|
unread: 0,
|
||||||
|
messages: [],
|
||||||
|
};
|
||||||
|
if (!roomList.some((r) => r.id === roomID)) roomList = [room, ...roomList];
|
||||||
|
retrackRooms(); // refresh() dropped all data-plane subs; re-subscribe every room
|
||||||
|
notifyRooms();
|
||||||
|
return room;
|
||||||
|
},
|
||||||
|
|
||||||
|
// send publishes a plaintext message to a room; the SDK seals + signs it per the
|
||||||
|
// room policy before it hits the wire.
|
||||||
|
async send(roomID: string, body: string): Promise<void> {
|
||||||
|
const s = require_();
|
||||||
|
await s.client.publish(roomID, new TextEncoder().encode(body));
|
||||||
|
touchSession(); // user activity: restart the idle auto-lock window
|
||||||
|
},
|
||||||
|
|
||||||
|
// subscribeRoom delivers a room's stored history followed by its live messages, both
|
||||||
|
// decrypted, verified and deduplicated by id (replaces the old SSE streamRoom).
|
||||||
|
// Returns an unsubscribe function. ChatPanel uses this for the open conversation, so
|
||||||
|
// reloading the page no longer loses the conversation; the sidebar metadata uses the
|
||||||
|
// live-only core (subscribeRoomInternal) and seeds its preview from history separately.
|
||||||
|
subscribeRoom(roomID: string, onMessage: (m: Message) => void): () => void {
|
||||||
|
return subscribeRoomWithHistory(roomID, onMessage);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// hasSession reports whether a bus session is currently open (for the router).
|
||||||
|
export function hasSession(): boolean {
|
||||||
|
return session !== null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
// Session persistence for the SPA. The bus session (the unlocked wallet identity)
|
||||||
|
// normally lives only in memory, so a page reload — even an F5 — drops it and forces
|
||||||
|
// a password re-unlock. This module keeps the session usable across reloads without
|
||||||
|
// ever sending anything to the network.
|
||||||
|
//
|
||||||
|
// Storage choice and its trade-off:
|
||||||
|
// - By DEFAULT the session is kept in sessionStorage: it survives an F5 but is
|
||||||
|
// cleared when the tab/window closes. This already fixes the "logs out on
|
||||||
|
// refresh" annoyance at minimal risk.
|
||||||
|
// - When the user ticks "keep me signed in" (remember=true) it is kept in
|
||||||
|
// localStorage instead: it survives closing the tab and the browser, until it
|
||||||
|
// EXPIRES or the user logs out.
|
||||||
|
//
|
||||||
|
// We never use a cookie: the wallet's private key must not travel to any server, and
|
||||||
|
// a cookie rides every request. The persisted value (the decrypted hex identity)
|
||||||
|
// stays on the device and is read only by this origin's own code.
|
||||||
|
//
|
||||||
|
// Two time bounds keep the persisted private key from living unbounded on disk:
|
||||||
|
// - TTL: an absolute lifetime (30 days). After it, re-unlock with the password.
|
||||||
|
// - IDLE: an inactivity auto-lock (12 h). Activity calls touchSession(); after 12 h
|
||||||
|
// with no activity the session re-locks even if the TTL has not elapsed.
|
||||||
|
|
||||||
|
import type { WalletIdentity } from "./wallet/derive";
|
||||||
|
|
||||||
|
const KEY = "unibus-session";
|
||||||
|
const TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days absolute lifetime
|
||||||
|
const IDLE_MS = 12 * 60 * 60 * 1000; // 12 h inactivity auto-lock
|
||||||
|
|
||||||
|
interface PersistedSession {
|
||||||
|
// The decrypted wallet identity (hex), INCLUDING the private halves. This is the
|
||||||
|
// sensitive part that lives on the device so the user need not re-enter the
|
||||||
|
// password on every reload. Bounded by TTL_MS + IDLE_MS and cleared on logout.
|
||||||
|
wallet: WalletIdentity;
|
||||||
|
handle: string;
|
||||||
|
remember: boolean;
|
||||||
|
createdAt: number;
|
||||||
|
lastActivity: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stores(): Storage[] {
|
||||||
|
// Guard for SSR/tests where window is absent.
|
||||||
|
if (typeof window === "undefined") return [];
|
||||||
|
return [window.localStorage, window.sessionStorage];
|
||||||
|
}
|
||||||
|
|
||||||
|
// saveSession persists the unlocked identity. remember=true uses localStorage
|
||||||
|
// (survives closing the browser); false uses sessionStorage (cleared with the tab).
|
||||||
|
export function saveSession(wallet: WalletIdentity, handle: string, remember: boolean): void {
|
||||||
|
clearSession(); // never keep it in both stores at once
|
||||||
|
const target = remember ? window.localStorage : window.sessionStorage;
|
||||||
|
const s: PersistedSession = {
|
||||||
|
wallet,
|
||||||
|
handle,
|
||||||
|
remember,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
lastActivity: Date.now(),
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
target.setItem(KEY, JSON.stringify(s));
|
||||||
|
} catch {
|
||||||
|
/* storage full/blocked: fall back to memory-only (no persistence) */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadSession returns the persisted identity if one exists and is still valid (not
|
||||||
|
// past its TTL and not idle-expired), otherwise null. An expired entry is removed.
|
||||||
|
export function loadSession(): { wallet: WalletIdentity; handle: string; remember: boolean } | null {
|
||||||
|
for (const st of stores()) {
|
||||||
|
const raw = st.getItem(KEY);
|
||||||
|
if (!raw) continue;
|
||||||
|
try {
|
||||||
|
const s = JSON.parse(raw) as PersistedSession;
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - s.createdAt > TTL_MS || now - s.lastActivity > IDLE_MS) {
|
||||||
|
st.removeItem(KEY); // expired by TTL or idle auto-lock
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
return { wallet: s.wallet, handle: s.handle, remember: s.remember };
|
||||||
|
} catch {
|
||||||
|
st.removeItem(KEY); // corrupt entry
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// touchSession refreshes the last-activity timestamp so the idle auto-lock window
|
||||||
|
// restarts. Call it on meaningful user activity (sending, navigating rooms).
|
||||||
|
export function touchSession(): void {
|
||||||
|
for (const st of stores()) {
|
||||||
|
const raw = st.getItem(KEY);
|
||||||
|
if (!raw) continue;
|
||||||
|
try {
|
||||||
|
const s = JSON.parse(raw) as PersistedSession;
|
||||||
|
s.lastActivity = Date.now();
|
||||||
|
st.setItem(KEY, JSON.stringify(s));
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// clearSession removes the persisted session from both stores (logout / lock).
|
||||||
|
export function clearSession(): void {
|
||||||
|
for (const st of stores()) st.removeItem(KEY);
|
||||||
|
}
|
||||||
+1
-1
@@ -8,7 +8,7 @@ export interface User {
|
|||||||
|
|
||||||
export interface Message {
|
export interface Message {
|
||||||
id: string;
|
id: string;
|
||||||
sender: string; // endpoint id del remitente (handle legible es fase 2)
|
sender: string; // endpoint id del remitente; el nombre legible se resuelve con bus.displayName()
|
||||||
body: string;
|
body: string;
|
||||||
ts: number; // epoch ms
|
ts: number; // epoch ms
|
||||||
mine?: boolean;
|
mine?: boolean;
|
||||||
|
|||||||
Vendored
+12
@@ -0,0 +1,12 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
// Build-time configuration for the bus endpoints. Both are optional; busService
|
||||||
|
// falls back to a cluster node when unset.
|
||||||
|
interface ImportMetaEnv {
|
||||||
|
readonly VITE_BUS_HTTP?: string;
|
||||||
|
readonly VITE_BUS_WS?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImportMeta {
|
||||||
|
readonly env: ImportMetaEnv;
|
||||||
|
}
|
||||||
+15
-19
@@ -1,26 +1,24 @@
|
|||||||
// High-level wallet account operations shared by the join, recover and login
|
// High-level wallet account operations shared by the join, recover and login
|
||||||
// flows. These compose the low-level primitives (derive / crypto / store) with
|
// flows. These compose the low-level primitives (derive / crypto / store) with the
|
||||||
// the gateway API so the page components stay thin.
|
// browser-native bus session so the page components stay thin.
|
||||||
|
|
||||||
import { api } from "../api";
|
import { bus } from "../busService";
|
||||||
import type { MeInfo, User } from "../types";
|
import type { User } from "../types";
|
||||||
import { decryptJSON, encryptJSON } from "./crypto";
|
import { decryptJSON, encryptJSON } from "./crypto";
|
||||||
import type { WalletIdentity } from "./derive";
|
import type { WalletIdentity } from "./derive";
|
||||||
import { getIdentity, putIdentity, type StoredIdentity } from "./store";
|
import { getIdentity, putIdentity, type StoredIdentity } from "./store";
|
||||||
|
|
||||||
function toUser(me: MeInfo): User {
|
// saveAndOpen encrypts the identity under `password`, stores it on this device, and
|
||||||
return { id: me.endpoint, handle: me.handle || me.endpoint.slice(0, 8) };
|
// opens a bus session as that user. Used by join (new identity) and recover
|
||||||
}
|
// (re-derived identity): both end with a locally-encrypted key plus a live session.
|
||||||
|
// The mnemonic/seed is NOT touched here — only the derived keypair is persisted
|
||||||
// saveAndOpen encrypts the identity under `password`, stores it on this device,
|
// (encrypted). The private key is used to open the session IN THE BROWSER and is
|
||||||
// and opens a gateway session as that user. Used by join (new identity) and
|
// never sent to any server (unlike the old gateway model).
|
||||||
// recover (re-derived identity): both end with a locally-encrypted key plus a
|
|
||||||
// live per-user session. The mnemonic/seed is NOT touched here — only the derived
|
|
||||||
// keypair is persisted (encrypted).
|
|
||||||
export async function saveAndOpen(
|
export async function saveAndOpen(
|
||||||
identity: WalletIdentity,
|
identity: WalletIdentity,
|
||||||
handle: string,
|
handle: string,
|
||||||
password: string,
|
password: string,
|
||||||
|
remember = false,
|
||||||
): Promise<User> {
|
): Promise<User> {
|
||||||
const enc = await encryptJSON(identity, password);
|
const enc = await encryptJSON(identity, password);
|
||||||
await putIdentity({
|
await putIdentity({
|
||||||
@@ -30,19 +28,17 @@ export async function saveAndOpen(
|
|||||||
enc,
|
enc,
|
||||||
createdAt: Date.now(),
|
createdAt: Date.now(),
|
||||||
});
|
});
|
||||||
const me = await api.session(identity, handle);
|
return bus.openSession(identity, handle, remember);
|
||||||
return toUser(me);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// unlockAndOpen reads this device's stored identity, decrypts the private key with
|
// unlockAndOpen reads this device's stored identity, decrypts the private key with
|
||||||
// `password`, and opens a gateway session. Throws WrongPasswordError on a bad
|
// `password`, and opens a bus session locally. Throws WrongPasswordError on a bad
|
||||||
// password (GCM auth failure) and NoLocalIdentityError if the device has none.
|
// password (GCM auth failure) and NoLocalIdentityError if the device has none.
|
||||||
export async function unlockAndOpen(password: string): Promise<User> {
|
export async function unlockAndOpen(password: string, remember = false): Promise<User> {
|
||||||
const stored = await getIdentity();
|
const stored = await getIdentity();
|
||||||
if (!stored) throw new NoLocalIdentityError();
|
if (!stored) throw new NoLocalIdentityError();
|
||||||
const identity = await decryptJSON<WalletIdentity>(stored.enc, password);
|
const identity = await decryptJSON<WalletIdentity>(stored.enc, password);
|
||||||
const me = await api.session(identity, stored.handle);
|
return bus.openSession(identity, stored.handle, remember);
|
||||||
return toUser(me);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// localIdentity returns the device's stored identity record (or null), for the
|
// localIdentity returns the device's stored identity record (or null), for the
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
// IndexedDB persistence of the device-local wallet. Only the encrypted private
|
// IndexedDB persistence of the device-local wallet. Only the encrypted private
|
||||||
// key plus the public halves and the display handle are stored — never the
|
// key plus the public halves and the display handle are stored — never the
|
||||||
// password, never the BIP39 seed. The private key never leaves the device except
|
// password, never the BIP39 seed. The private key NEVER leaves the device at all:
|
||||||
// over TLS to the gateway to open a session (see api.session).
|
// the bus session is opened in the browser (see busService.openSession), which signs
|
||||||
|
// and decrypts locally — there is no server to send the key to.
|
||||||
//
|
//
|
||||||
// MVP: one active identity per device (keyed by a fixed id). Multi-account on a
|
// MVP: one active identity per device (keyed by a fixed id). Multi-account on a
|
||||||
// single device is a documented gap.
|
// single device is a documented gap.
|
||||||
|
|||||||
@@ -17,5 +17,6 @@
|
|||||||
"noUnusedParameters": false,
|
"noUnusedParameters": false,
|
||||||
"noFallthroughCasesInSwitch": true
|
"noFallthroughCasesInSwitch": true
|
||||||
},
|
},
|
||||||
"include": ["src"]
|
"include": ["src"],
|
||||||
|
"exclude": ["src/**/*.test.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
+9
-5
@@ -3,12 +3,16 @@ import react from "@vitejs/plugin-react";
|
|||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
// En dev, /api (REST + SSE) se proxea al gateway Go (cmd/webgw, puerto 8481).
|
// In production the SPA is served same-origin behind Caddy, which proxies /api and
|
||||||
// El proxy hace streaming, así que el SSE de /api/rooms/{id}/stream funciona a
|
// /nats to the cluster; those relative paths do not exist on the bare dev server, so
|
||||||
// través de él. En producción el gateway sirve el dist embebido y no hay proxy.
|
// `pnpm dev` must be pointed at a real cluster node with VITE_BUS_HTTP / VITE_BUS_WS
|
||||||
|
// (busService.ts uses them as overrides of the same-origin defaults). Example:
|
||||||
|
// VITE_BUS_HTTP=https://<node>:8470 VITE_BUS_WS=wss://<node>:8480 pnpm dev
|
||||||
|
// The dev server runs on 5174 (5173 is reserved for an unrelated local app). Add the
|
||||||
|
// dev origin (http://localhost:5174) to the node's --cors-origins allowlist. strictPort
|
||||||
|
// is left off, so Vite falls back to the next free port if 5174 is busy.
|
||||||
server: {
|
server: {
|
||||||
host: true,
|
host: true,
|
||||||
port: 5183,
|
port: 5174,
|
||||||
proxy: { "/api": "http://127.0.0.1:8481" },
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user