22 Commits

Author SHA1 Message Date
egutierrez 59705b5a4f Merge branch 'issue/room-history'
feat(uniweb): load each room's history on open (GET /api/rooms/{id}/history),
deduped vs live, so reloading the page no longer loses the messages. Sidebar
preview also seeded from history. Bump v0.6.0.
2026-06-14 19:40:20 +02:00
egutierrez 63ebc1eed9 docs(uniweb): bump to v0.6.0 + growth log (room history)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 19:40:12 +02:00
egutierrez 893df42d29 feat(uniweb): seed each room from history on open, deduped vs live
When a room is opened, load its stored history and keep it live so reloading no
longer loses the conversation.

- bus.subscribeRoom (used by ChatPanel) now runs subscribeRoomWithHistory: it
  subscribes live immediately but buffers live messages until the history batch
  (oldest -> newest) is delivered, guaranteeing history-first order regardless of
  timing; both halves are deduplicated by frame id via a per-room Set. If the
  history endpoint is absent (404/500), it falls back to live-only as before.
- toMessage maps an opened frame to the UI Message using ulidTime(msgID) for ts
  (not arrival time), so history and live share one clock and sort correctly;
  ChatPanel keeps its list ordered by ts.
- Sidebar previews: loadRooms seeds each room's last message/time from
  history(id, 1) in the background, without blocking the render and without
  overwriting a newer live message; empty rooms keep the "—" placeholder.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 19:40:08 +02:00
egutierrez c142b3a025 feat(uniweb): room history client (fetchHistory + BusClient.history)
NATS delivers live only, so reloading the page lost a room's history. Add the
client half of the new history endpoint:

- ControlPlane.fetchHistory(roomID, limit): signed GET /rooms/{id}/history?limit=N,
  decoding each base64-std frame to the raw bytes the live subscription delivers.
- BusClient.history(roomID, limit): opens each replayed frame (verify + decrypt)
  exactly like subscribe, dropping any that fail, oldest -> newest.
- Extract BusClient.openFrame as the shared envelope-opening core for subscribe
  and history (no duplication; subscribe behavior unchanged).
- ulidTime(id): decode the ms-epoch a ULID encodes in its first 10 Crockford
  chars (inverse of newULID), so a frame's timestamp comes from its id (the wire
  carries none). Covered by ulid.test.ts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 19:39:57 +02:00
egutierrez 45d12e03aa Merge branch 'issue/names-cleanup'
Readable handle in messages (GET /api/directory), sidebar shows real
last message + time per room, pnpm dev usable after same-origin switch,
dedup growth log (v0.5.0).
2026-06-14 15:40:26 +02:00
egutierrez 3049265230 docs(uniweb): dedup growth log + bump to v0.5.0
A merge left the v0.2.0 and v0.1.0 growth-log entries duplicated. Keep one
entry per version in descending order and add the v0.5.0 line covering this
release: readable handles in messages, sidebar with real last message/time,
and the documented `pnpm dev` setup. Frontmatter version 0.4.0 -> 0.5.0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 15:34:02 +02:00
egutierrez 6c4baf1397 chore(uniweb): make pnpm dev usable after the same-origin switch
Same-origin (Caddy) means the SPA reaches /api and /nats through its own
origin in production, but those relative paths do not exist on the bare Vite
dev server, so `pnpm dev` no longer connects. busService already reads
VITE_BUS_HTTP / VITE_BUS_WS as overrides of the same-origin defaults — this
documents that path (Option A, no proxy code) and moves the dev server off the
port reserved by an unrelated local app.

- vite.config: dev server port 5173 -> 5174 (5173 is in use by another local
  app). strictPort left off so Vite falls back to the next free port. Comment
  explains the same-origin/dev split and the env-var override.
- app.md: Ejemplo and the CORS gotcha document the exact dev command
  (VITE_BUS_HTTP/WS pointing at a cluster node) on :5174 and the same-origin
  production model.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 15:32:12 +02:00
egutierrez 5fbf319172 feat(uniweb): sidebar shows the real last message and time per room
The sidebar showed room.lastTs/lastMessage that busService created as 0/""
on every load, so the time rendered as the epoch-0 "01:00" and there was no
preview. busService now owns a room store that keeps these fields live.

- busService gains a room store: the room list plus a per-room metadata
  subscription. Since the wire has no message history (NATS delivers live
  only), staying subscribed to every room is the only way to know each room's
  latest message and to count unread for rooms the user is not viewing. Each
  delivered message updates the room's lastTs, lastMessage (a "name: body"
  preview, truncated, reusing the directory resolver) and bumps unread when
  the room is not active.
- New surface: watchRooms(listener) to mirror the store into React,
  loadRooms() to (re)populate and subscribe, setActiveRoom(id) to clear a
  room's unread. createRoom adds the new room to the store and, because its
  data-plane refresh() drops all subscriptions, re-subscribes every room.
  subscribeRoom now shares the same decode core (subscribeRoomInternal) used
  by the metadata subscription. The store is reset on login/logout.
- ChatShell mirrors the store via watchRooms instead of a one-shot listRooms,
  selects a room through setActiveRoom (clearing its unread), and auto-selects
  the first room once the list loads.
- Sidebar: timeShort renders an em dash for a room with no message yet instead
  of the epoch-0 "01:00".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 15:31:08 +02:00
egutierrez 5e9bf4e777 feat(uniweb): readable handle instead of endpoint id in messages
Resolve a message sender's endpoint id to a human handle using a new
control-plane directory endpoint.

- ControlPlane.fetchDirectory(): signed GET /api/directory, mapped to
  DirectoryEntry { signPub, endpoint, handle, role }. The server's endpoint
  matches endpointID(signPub) byte for byte.
- busService keeps an endpoint -> handle Map, loaded once after a session
  opens and refreshed after createRoom (where the ACL is already refreshed).
  Exposes a pure displayName(endpoint) resolver: handle when known, the
  session user's own handle for their messages, short id fallback otherwise.
- Resilience: loadDirectory never throws. A missing endpoint (404 on older
  clusters) or a transient error leaves the map empty and the UI falls back to
  the short id, so the chat keeps working exactly as before.
- ChatPanel renders displayName(msg.sender) in the message header and derives
  the avatar initials from the handle; the raw endpoint stays in a title
  tooltip for debugging.
- types: Message.sender comment updated (this is the "phase 2" readable name).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 15:27:15 +02:00
egutierrez 103a7f2f05 feat: persistent session (no re-unlock on reload) + reconnect ACL after createRoom
Session persistence (web/src/session.ts): the unlocked wallet identity is kept
across reloads so an F5 no longer forces a password re-unlock. By default it lives
in sessionStorage (survives F5, cleared with the tab); with 'keep me signed in' it
lives in localStorage (survives closing the browser) bounded by a 30-day absolute
TTL and a 12-hour inactivity auto-lock. logout clears it; activity (send/createRoom)
refreshes the idle timer. No cookie is ever used — the private key never travels to
any server. WalletLogin gains the 'keep me signed in' checkbox; Recover/Join keep
the session by default (recovering/creating on a device implies it is yours).
App.tsx restores the session on mount before falling back to the unlock screen.

ACL reconnect: a room created while connected was not in the NATS per-subject ACL
grant (subjects are frozen at connect time), so its first messages silently did not
deliver until a re-login. WsNatsTransport gains reconnect(); BusClient.refresh()
calls it; busService.createRoom reconnects after creating so the new room is usable
immediately. Bumps uniweb to 0.4.0.
2026-06-14 13:58:06 +02:00
egutierrez 1dc8b6257a Merge branch 'issue/caddy-same-origin' 2026-06-14 13:49:23 +02:00
egutierrez f8b2bf8e9e Merge branch 'issue/ui-rooms' 2026-06-14 13:49:23 +02:00
egutierrez e8850d8965 feat(uniweb): crear rooms y chatear desde la UI
Añade un control "Nueva room" en el header del sidebar (botón "+") y CTAs
en los estados vacíos del sidebar y del panel. Abren un modal que pide el
asunto, crea la room con bus.createRoom contra el bus real, la inserta en
la lista (dedup por id, sin recargar) y la activa.

- NewRoomModal: modal de Mantine con loading, manejo de SessionError/Error
  en español, crear con Enter o botón, formulario limpio en cada apertura.
- ChatShell: estado del modal con useDisclosure, handleRoomCreated centraliza
  inserción + selección, empty state del panel rediseñado con botón crear.
- Sidebar: prop onNewRoom, botón "+" con tooltip, empty state distingue
  "sin resultados" de "sin rooms" (con CTA crear primera room).

No toca la capa de datos (busService.ts ni web/src/bus/): usa los métodos de
bus tal como están. Verificado end-to-end contra el cluster real: crear room
desde la UI, enviar mensaje y verlo aparecer por la suscripción.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 12:35:33 +02:00
egutierrez e12894099f feat(uniweb): point the bus at the same-origin proxy, drop the IP fallback
The SPA is now served behind a same-origin reverse proxy (Caddy) that
fronts both bus planes, so the data layer reaches them through the page's
own origin instead of a hardcoded cluster node IP. This removes CORS
entirely and hides the cluster IPs behind the proxy.

- BUS_HTTP falls back to the relative path /api (the signed HTTPS control
  plane), resolved against the page origin by ControlPlane's fetch.
- BUS_WS falls back to a wss URL derived from window.location (same host,
  scheme mirroring https/http, path /nats), since a browser WebSocket needs
  an absolute ws(s) URL.
- The raw self-signed-IP fallback (https://51.91.100.142:8470, wss://...:8480)
  is gone. The VITE_BUS_HTTP / VITE_BUS_WS build-time overrides remain for a
  dev setup that points straight at a cluster node.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 12:32:47 +02:00
agent 3f52167b04 feat: browser-native client — wire SPA to the SDK, delete the Go gateway
Phase 2 of issue 0001. uniweb becomes a pure frontend (web/ only), like
unibus_android: the SPA talks directly to the bus and the Go gateway is gone.

- busService.ts: the new data layer over the bus SDK, replacing the old api module.
  It holds the user's wallet identity and a connected BusClient IN THE BROWSER and
  opens the session locally — the private key is never sent anywhere (closes the
  gateway-era hole where the browser POSTed its private key to /api/session).
- Wire account/App/ChatShell/ChatPanel/WalletLogin/Recover/Join to busService;
  subscribeRoom replaces the SSE streamRoom; ApiError -> SessionError.
- SDK: ControlPlane.createRoom + listMemberRooms, and fetchRoom mapped to the real
  control-plane wire shape (snake_case, no id) — all verified by the live round-trip.
- Delete cmd/webgw, go.mod, go.sum, src/api.ts and the orphan operator Login. uniweb
  now has zero Go and no dependency on unibus as a module.
- vite: drop the /api proxy, dev server on 5173 to match the bus CORS allowlist; add
  vite-env typings. app.md: lang ts, no uses_functions, e2e_checks are now web-only.
  Bump 0.3.0.

Onboarding by token is now admin-side (the bus has no self-register endpoint; the
gateway only mocked it). tsc + pnpm build + 19/19 unit green.
2026-06-14 11:39:06 +02:00
egutierrez bf0884527e chore: auto-commit (2 archivos)
- dev/
- registry.db

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-14 11:27:04 +02:00
agent 2960b0984a feat(bus): createRoom + control-plane shape fixes, verified by live E2E round-trip
Validating the SDK against the real cluster surfaced the control-plane wire shapes:
the room/policy JSON is snake_case (sign_msgs) and GET /rooms/{id} omits the id.
Fix ControlPlane.fetchRoom to map the wire shape to the SDK Room type, and add
ControlPlane.createRoom (mint a room key, seal it to the owner via sealed box, POST
/rooms) so a browser peer can own an encrypted room.

The live smoke now does a full end-to-end round-trip against the 3-node cluster:
create an encrypted+signed room, connect over nats.ws, publish, and receive the
SDK's own message decrypted with the signature verified. Verified 2026-06-14:
room 01KV2Q…, plaintext round-tripped intact. The whole seal/sign/open happens in
the client; the private key never leaves it.

Exclude *.test.ts from the app tsconfig so the Node-API integration test does not
break the production build (vitest transpiles tests independently). Issue 0001,
Phase 3.
2026-06-14 11:26:13 +02:00
agent b44aa02326 test(bus): prove a registered identity is accepted on the live cluster
Extend the live smoke: when a registered identity is present (its sign_pub added
to the bus allowlist), assert the SAME SDK that a fresh identity gets rejected with
is now ACCEPTED — control plane no longer 401s and the nats.ws data-plane
connection succeeds. Verified 2026-06-14 against the 3-node cluster: random
identity -> 401 / 'authorization violation'; registered identity -> 403 'not a
member of this room' / connected=true. The allowlist is the only gate; the SDK
speaks both planes correctly end-to-end. Issue 0001, Phase 3.
2026-06-14 11:21:38 +02:00
agent 024af306fe test(bus): live integration smoke against the real unibus cluster
A network smoke (self-skips unless BUS_HTTP/BUS_WS are set) that points the SDK
at the live 3-node cluster. With a fresh, unregistered identity it asserts BOTH
planes reject with an AUTHORIZATION error (not a signature/protocol error),
proving the SDK speaks the control plane (signed canonical request) and the data
plane (nats.ws + nkey) correctly end-to-end. Verified 2026-06-14 against datardos:
control-plane 401 'identity not authorized', data-plane 'authorization violation'.
Issue 0001, Phase 3.
2026-06-14 11:18:43 +02:00
agent b72976e06c feat(bus): complete TypeScript SDK — auth, room envelope, client, transport
Second half of the browser-native bus SDK (issue 0001, Phase 1), making uniweb a
peer of the bus in its own right (like unibus_android) without the Go gateway:

- busauth.ts: NATS user nkey from the Ed25519 key (base32 + crc16, no nkeys dep)
  and control-plane request signing (CanonicalRequest + X-Unibus-* headers).
- room.ts: Policy / Room types (ModeNATS, ModeMatrix).
- client.ts: the pure room ENVELOPE (sealRoomMessage/openRoomMessage — AEAD with
  the subject as AAD, Ed25519 sign, drop on verify/decrypt failure), a transport-
  agnostic BusClient, and a signed ControlPlane HTTP client (fetch room/key/members,
  open the sealed room key locally).
- wstransport.ts: concrete nats.ws WebSocket transport (validated E2E in Phase 3).
- index.ts: public SDK surface.

Parity pinned by vectors from unibus cmd/busvectors (extended with nkey + signed
control-request vectors): 19/19 green. The user's private key signs everything in
the browser and is never sent to any server. Bumps uniweb to 0.2.0.

Remaining for Phase 1 completion: the live nats.ws connection + control-plane,
which need a running unibus with the WebSocket listener — exercised in Phase 3.
2026-06-13 22:54:54 +02:00
agent 3d9b4ce392 feat(bus): TypeScript SDK crypto + frame, parity-verified against Go
First half of the browser-native bus SDK (issue 0001, Phase 1):

- crypto.ts: Ed25519 sign/verify (@noble), ChaCha20-Poly1305 AEAD (@noble),
  endpoint id (sha256+base64url), and the anonymous sealed box for room-key
  distribution. The sealed-box nonce is BLAKE2b-192 over ephPub||recipientPub,
  matching Go's nacl/box.SealAnonymous (NOT SHA-512) so a Go-sealed key opens here.
- frame.ts: the Frame wire format, reproducing Go encoding/json byte-for-byte —
  struct field order, omitempty rules, base64-std byte fields, and the default
  HTML escaping (<, >, &, U+2028/U+2029) — plus sign/verify over canonical bytes.

vectors.test.ts checks all of it against the golden vectors generated by unibus
cmd/busvectors. 11/11 green: endpoint id, Ed25519 (incl. frame signature),
AEAD seal+open, sealed box open + round-trip, and frame signing-bytes + wire
marshal. This pins cross-language interop with Go/Kotlin peers.

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