From 3f52167b049b0175e8ada49ad37ddfde054e5025 Mon Sep 17 00:00:00 2001 From: agent Date: Sun, 14 Jun 2026 11:39:06 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20browser-native=20client=20=E2=80=94=20w?= =?UTF-8?q?ire=20SPA=20to=20the=20SDK,=20delete=20the=20Go=20gateway?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- app.md | 153 +++++++++--------- cmd/webgw/gateway.go | 246 ---------------------------- cmd/webgw/hub.go | 140 ---------------- cmd/webgw/identity.go | 98 ------------ cmd/webgw/main.go | 199 ----------------------- cmd/webgw/register.go | 193 ---------------------- cmd/webgw/server.go | 327 -------------------------------------- cmd/webgw/session.go | 146 ----------------- cmd/webgw/webgw_test.go | 114 ------------- go.mod | 37 ----- go.sum | 77 --------- registry.db | 0 web/src/App.tsx | 21 +-- web/src/ChatPanel.tsx | 10 +- web/src/ChatShell.tsx | 4 +- web/src/Join.tsx | 19 ++- web/src/Login.tsx | 89 ----------- web/src/Recover.tsx | 4 +- web/src/WalletLogin.tsx | 7 +- web/src/api.ts | 167 ------------------- web/src/bus/client.ts | 20 +++ web/src/busService.ts | 154 ++++++++++++++++++ web/src/vite-env.d.ts | 12 ++ web/src/wallet/account.ts | 31 ++-- web/src/wallet/store.ts | 5 +- web/vite.config.ts | 10 +- 26 files changed, 319 insertions(+), 1964 deletions(-) delete mode 100644 cmd/webgw/gateway.go delete mode 100644 cmd/webgw/hub.go delete mode 100644 cmd/webgw/identity.go delete mode 100644 cmd/webgw/main.go delete mode 100644 cmd/webgw/register.go delete mode 100644 cmd/webgw/server.go delete mode 100644 cmd/webgw/session.go delete mode 100644 cmd/webgw/webgw_test.go delete mode 100644 go.mod delete mode 100644 go.sum delete mode 100644 registry.db delete mode 100644 web/src/Login.tsx delete mode 100644 web/src/api.ts create mode 100644 web/src/busService.ts create mode 100644 web/src/vite-env.d.ts diff --git a/app.md b/app.md index 7db5496..fdbe3ba 100644 --- a/app.md +++ b/app.md @@ -1,123 +1,128 @@ --- name: uniweb -lang: go +lang: ts domain: infra -version: 0.2.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." -tags: [service, messaging, web, frontend, e2e] -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 +version: 0.3.0 +description: "Cliente web browser-nativo del bus unibus: SPA de chat (React+Mantine) con wallet por usuario (BIP39) que habla DIRECTO al bus (nats.ws + control-plane HTTPS firmado), sin gateway. La clave privada nunca sale del navegador." +tags: [messaging, web, frontend, e2e] +uses_functions: [] uses_types: [] framework: "react" -entry_point: "cmd/webgw" +entry_point: "web/src/main.tsx" dir_path: "projects/message_bus/apps/uniweb" repo_url: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/dataforge/uniweb" icon: phosphor: "chats-circle" accent: "#6366f1" -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: - - id: build - cmd: "CGO_ENABLED=0 go build ./..." + - id: typecheck + cmd: "cd web && pnpm install --frozen-lockfile && pnpm exec tsc --noEmit -p tsconfig.app.json" timeout_s: 180 - - id: vet - cmd: "CGO_ENABLED=0 go vet ./..." - timeout_s: 120 - id: unit - cmd: "CGO_ENABLED=0 go test ./..." + cmd: "cd web && pnpm test" timeout_s: 120 - id: web_build - cmd: "cd web && pnpm install --frozen-lockfile && pnpm build" + cmd: "cd web && pnpm build" timeout_s: 180 --- ## Qué es -`uniweb` es el frontend web del bus [unibus](../unibus/app.md): la interfaz que un humano -usa desde el navegador para hablar por el bus. Se separó de `unibus` (v0.13.0) para que el -plano del bus (membresía, claves, librería cliente) quede limpio y el frontend tenga su -propia carpeta de servicio y su propio ciclo de release. +`uniweb` es el **cliente web browser-nativo** del bus [unibus](../unibus/app.md): la interfaz +que un humano usa desde el navegador para hablar por el bus. Es **solo frontend** (`web/`) — +una SPA, sin backend Go, sin gateway. Habla **directamente** con el bus, igual que +`unibus_android` lo hace en Kotlin: -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 - (join por invitación, login por passphrase local, recover por mnemónica). La identidad - criptográfica de cada usuario se deriva de forma determinista de una frase BIP39 de 12 - palabras y se cifra at-rest en el dispositivo (AES-256-GCM); la clave privada nunca viaja - 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. +Stack: React 18 + Vite + Mantine v9. La identidad criptográfica de cada usuario se deriva de +forma determinista de una frase BIP39 de 12 palabras y se cifra at-rest en el dispositivo +(AES-256-GCM). **La clave privada nunca sale del navegador**: firma, sella y descifra en el +cliente. No hay servidor al que enviarla. -## 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`): -``` -replace github.com/enmanuel/unibus => ../unibus # pkg/{busauth,client,frame,room} -replace fn-registry => ../../../../ # functions/cybersecurity -``` +- `crypto.ts` — Ed25519, ChaCha20-Poly1305, sealed box (nonce BLAKE2b, igual que Go). +- `frame.ts` — wire format = `encoding/json` de Go byte a byte. +- `room.ts` — Policy (ModeNATS / ModeMatrix). +- `busauth.ts` — nkey NATS (base32 + crc16) + firma de requests del control-plane. +- `client.ts` — envelope de room + `BusClient` + `ControlPlane` HTTP firmado. +- `wstransport.ts` — transporte `nats.ws`. -Los `replace` no son transitivos en Go, así que `uniweb` (módulo principal) declara los dos: -el de `unibus` (de donde importa la librería cliente) y el de `fn-registry` (de donde -`pkg/client` toma las primitivas de cifrado). Compila con `CGO_ENABLED=0` igual que unibus. +`busService.ts` es la capa de datos de la SPA sobre el SDK (reemplazó al viejo módulo `api` +que hablaba con el gateway). Ya **no depende de `unibus` como módulo Go**: el desacople es +total. ## Ejemplo ```bash -# 1. Backend: el control-plane del bus (en la carpeta de unibus) -cd ../unibus && CGO_ENABLED=0 go run ./cmd/membershipd # :8470 +# El bus ya corre (cluster unibus con WebSocket habilitado, --ws-port). Apunta la SPA a un +# nodo y arráncala en dev (puerto 5173, que coincide con la CORS allowlist del cluster): +cd web && pnpm install +VITE_BUS_HTTP=https://:8470 VITE_BUS_WS=wss://:8480 pnpm dev +# Navegador: http://localhost:5173 -# 2. Build de la SPA -cd web && pnpm install && pnpm build # genera web/dist - -# 3. Gateway sirviendo la SPA + API contra el control-plane -cd .. && CGO_ENABLED=0 go run ./cmd/webgw \ - --port 8481 --ctrl-url http://127.0.0.1:8470 --web-dir web/dist -# Navegador: http://127.0.0.1:8481 - -# 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 +# Producción: build estático y sirve web/dist con cualquier static server. +cd web && pnpm build # genera web/dist ``` ## Cuándo usarla Cuando quieras que un humano hable por el bus desde un navegador, o cuando trabajes en la UI de chat / el onboarding wallet. Para la lógica del bus en sí (membresía, claves, peers -programáticos) ve a `unibus`; `uniweb` solo es la capa web encima. +programáticos) ve a `unibus`; `uniweb` es el cliente web encima. ## Gotchas -- El gateway necesita el control-plane de unibus vivo (`--ctrl-url`, por defecto - `http://127.0.0.1:8470`); si no, las sesiones fallan al abrir el peer. -- `--web-dir` es **opcional**: vacío = API-only (úsalo con el dev server de vite); apuntando a - `web/dist` = sirve la SPA buildeada. Un path inválido degrada a API-only con un WARN, no - peta. -- Build cross-repo: `uniweb` no compila si `../unibus` no está presente en disco (el `replace` - es local). Para deploy hay que llevar ambos repos, o vendorizar unibus. +- **`wss://` con CA self-signed**: el cluster sirve el WebSocket con el cert del bus (CA + propia). Un navegador rechaza `wss://` self-signed salvo que se importe la CA o se ponga un + reverse proxy con cert válido (Let's Encrypt). En dev se puede aceptar el cert a mano. +- **Onboarding admin-side**: el bus no tiene endpoint de auto-registro (el viejo gateway lo + *mockeaba*). En `enforce`, una identidad nueva debe ser autorizada por un admin + (`membershipd user add`) antes de poder abrir sesión; el flujo de Join muestra la clave + pública del usuario para que un admin la autorice. +- **CORS**: el dev server corre en `http://localhost:5173` para coincidir con la + `--cors-origins` del cluster. Otro origen necesita añadirse a esa allowlist. - La passphrase del wallet nunca se guarda ni se envía; perderla en un dispositivo sin la mnemónica BIP39 = identidad irrecuperable en ese dispositivo (recuperable en otro con las 12 palabras). ## Capability growth log +- v0.3.0 (2026-06-14) — `uniweb` se vuelve **cliente browser-nativo puro** (issue 0001, Fase + 2): la SPA se cablea al SDK del bus (`busService.ts` reemplaza el módulo `api`) y se + **elimina el gateway Go** (`cmd/webgw`, `go.mod`, `go.sum`). `uniweb` queda como solo `web/`, + sin nada de Go, sin dependencia de `unibus` como módulo. La clave privada se usa solo en el + navegador (`saveAndOpen`/`unlockAndOpen` abren la sesión localmente; ya NO se hace + `POST /api/session` con la privada — se cierra el agujero E2E del modelo gateway). Validado + end-to-end contra el cluster descentralizado real (Fase 3): identidad registrada conecta por + `nats.ws` y hace round-trip de un mensaje cifrado (crear room → publicar → recibir + descifrado + firma verificada). El onboarding por token queda admin-side (el bus no tiene + auto-registro). `tsc` + `pnpm build` + 19/19 unit verdes. +- v0.2.0 (2026-06-13) — SDK del bus en TypeScript (`web/src/bus/`), issue 0001 Fase 1: + el protocolo y el cifrado E2E del bus portados al navegador para que `uniweb` deje + de depender del gateway Go. Módulos: `crypto.ts` (Ed25519, ChaCha20-Poly1305, + sealed box con nonce BLAKE2b igual que Go), `frame.ts` (wire format = `encoding/json` + de Go byte a byte), `room.ts` (Policy), `busauth.ts` (nkey NATS + firma de requests + del control-plane), `client.ts` (envelope de room puro + `BusClient` sobre una + interfaz de transporte + cliente HTTP firmado) y `wstransport.ts` (adaptador + `nats.ws`). Paridad cross-language verificada contra vectores Go (`cmd/busvectors`): + **19/19 tests verdes** — endpoint id, firma Ed25519, AEAD, sealed box, frame + marshal/sign, nkey y canonical request. La clave privada del usuario nunca se + serializa hacia la red. La conexión `nats.ws` + control-plane reales se validan en + la Fase 3 (E2E) por requerir un unibus vivo con WebSocket. +- v0.1.0 (2026-06-13) — scaffold inicial: extracción de la SPA (`web/`) y el gateway + (`cmd/webgw`) desde `unibus` v0.13.0 a su propia app/sub-repo. Sin cambios de capacidad + respecto a lo que ya vivía en unibus 0.12.0 (wallet BIP39 + sesiones por usuario); solo + cambia la ubicación y el módulo Go. go build/vet/test + pnpm build verdes en la nueva + ubicación con los `replace` cross-repo. + - 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, diff --git a/cmd/webgw/gateway.go b/cmd/webgw/gateway.go deleted file mode 100644 index 738f761..0000000 --- a/cmd/webgw/gateway.go +++ /dev/null @@ -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)) -} diff --git a/cmd/webgw/hub.go b/cmd/webgw/hub.go deleted file mode 100644 index 523dea0..0000000 --- a/cmd/webgw/hub.go +++ /dev/null @@ -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 -} diff --git a/cmd/webgw/identity.go b/cmd/webgw/identity.go deleted file mode 100644 index 41ee3b0..0000000 --- a/cmd/webgw/identity.go +++ /dev/null @@ -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 ` 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 ` 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 -} diff --git a/cmd/webgw/main.go b/cmd/webgw/main.go deleted file mode 100644 index ae94e23..0000000 --- a/cmd/webgw/main.go +++ /dev/null @@ -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 (/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 or --identity-pass ") - } -} - -// 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 -} diff --git a/cmd/webgw/register.go b/cmd/webgw/register.go deleted file mode 100644 index ac45a5e..0000000 --- a/cmd/webgw/register.go +++ /dev/null @@ -1,193 +0,0 @@ -package main - -import ( - "bytes" - "encoding/hex" - "encoding/json" - "fmt" - "io" - "net/http" - "strings" - "sync" - "time" -) - -// registerReq is the POST /api/register body. It mirrors the bus contract exactly -// (token + the two PUBLIC key halves, each 64 hex chars). The private key never -// appears here — registration only publishes the public identity. The handle and -// role are NOT accepted from the client; they are fixed by the invite the token -// belongs to (no privilege escalation). -type registerReq struct { - Token string `json:"token"` - SignPub string `json:"sign_pub"` - KexPub string `json:"kex_pub"` -} - -// registerResp is what we return to the browser on success. The bus's /register -// (issue: user-accounts) decides handle/role from the invite; in mock mode the -// gateway echoes the configured pair so the SPA can greet the new user. -type registerResp struct { - Handle string `json:"handle"` - Role string `json:"role"` -} - -// registrar fulfils POST /api/register. It targets the bus's POST /register -// endpoint (added by the user-accounts work, bus >= 0.12.0). Until that endpoint -// is rolled out, a built-in mock validates against a configured set of one-shot -// tokens so the whole wallet flow is testable locally. Mock tokens are checked -// first; anything else is proxied to the real bus when --register-url is set. -type registrar struct { - mu sync.Mutex - - registerURL string // bus POST /register; empty => mock-only - httpc *http.Client // for proxying to the bus - mockTokens map[string]*mockToken // configured one-shot invites for local testing -} - -// mockToken is a local stand-in for a bus invite: a token that maps to a fixed -// handle+role and can be consumed exactly once. -type mockToken struct { - handle string - role string - used bool -} - -// newRegistrar parses the --mock-tokens spec ("tok=handle:role,tok2=h2:role2") -// and configures the optional proxy target. -func newRegistrar(registerURL, mockSpec string) *registrar { - r := ®istrar{ - registerURL: strings.TrimSpace(registerURL), - httpc: &http.Client{Timeout: 10 * time.Second}, - mockTokens: map[string]*mockToken{}, - } - for _, part := range strings.Split(mockSpec, ",") { - part = strings.TrimSpace(part) - if part == "" { - continue - } - // tok=handle:role (role optional, defaults to member) - eq := strings.IndexByte(part, '=') - if eq < 0 { - continue - } - tok := strings.TrimSpace(part[:eq]) - hr := strings.TrimSpace(part[eq+1:]) - handle, role := hr, "member" - if c := strings.IndexByte(hr, ':'); c >= 0 { - handle, role = strings.TrimSpace(hr[:c]), strings.TrimSpace(hr[c+1:]) - } - if tok != "" && handle != "" { - r.mockTokens[tok] = &mockToken{handle: handle, role: role} - } - } - return r -} - -// mockTokenCount counts configured mock tokens in a --mock-tokens spec (for the -// startup log line). -func mockTokenCount(spec string) int { - n := 0 - for _, part := range strings.Split(spec, ",") { - if p := strings.TrimSpace(part); p != "" && strings.ContainsRune(p, '=') { - n++ - } - } - return n -} - -// validHexKey reports whether s is exactly 64 lowercase/uppercase hex chars (a -// 32-byte key). Both sign_pub and kex_pub are 32-byte keys. -func validHexKey(s string) bool { - if len(s) != 64 { - return false - } - _, err := hex.DecodeString(s) - return err == nil -} - -// handleRegister validates the keys and consumes the token. Order of resolution: -// 1. strict validation of the public keys (defends both mock and proxy paths); -// 2. mock token (one-shot) if configured; -// 3. proxy to the bus /register if --register-url is set; -// 4. otherwise reject with a clear error. -func (s *server) handleRegister(w http.ResponseWriter, r *http.Request) { - var req registerReq - if !decode(w, r, &req) { - return - } - req.Token = strings.TrimSpace(req.Token) - if req.Token == "" { - writeErr(w, http.StatusBadRequest, "token required") - return - } - if !validHexKey(req.SignPub) { - writeErr(w, http.StatusBadRequest, "sign_pub must be 64 hex chars (32 bytes)") - return - } - if !validHexKey(req.KexPub) { - writeErr(w, http.StatusBadRequest, "kex_pub must be 64 hex chars (32 bytes)") - return - } - - reg := s.registrar - - // 2) mock one-shot token. - reg.mu.Lock() - mt, isMock := reg.mockTokens[req.Token] - if isMock { - if mt.used { - reg.mu.Unlock() - writeErr(w, http.StatusConflict, "invite already used") - return - } - mt.used = true - handle, role := mt.handle, mt.role - reg.mu.Unlock() - writeJSON(w, http.StatusCreated, registerResp{Handle: handle, Role: role}) - return - } - reg.mu.Unlock() - - // 3) proxy to the real bus /register when configured. - if reg.registerURL != "" { - s.proxyRegister(w, req) - return - } - - // 4) no mock match, no proxy target. - writeErr(w, http.StatusBadRequest, "invalid or unknown token (and no bus /register configured)") -} - -// proxyRegister forwards the registration to the bus's POST /register. The bus -// validates the invite (existence, not-used, not-expired) and adds the public -// identity to the allowlist with the invite's handle+role. This is unsigned by -// design: the TOKEN authorizes the call, not an admin signature. -func (s *server) proxyRegister(w http.ResponseWriter, req registerReq) { - body, _ := json.Marshal(req) - resp, err := s.registrar.httpc.Post( - s.registrar.registerURL, - "application/json", - bytes.NewReader(body), - ) - if err != nil { - writeErr(w, http.StatusBadGateway, "bus register unreachable: "+err.Error()) - return - } - defer resp.Body.Close() - raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) - - // On success, try to pass through the bus's handle/role if it returned them; - // otherwise a bare 201 is still success. - if resp.StatusCode == http.StatusCreated || resp.StatusCode == http.StatusOK { - var rr registerResp - _ = json.Unmarshal(raw, &rr) - writeJSON(w, http.StatusCreated, rr) - return - } - // Forward the bus's error verbatim where possible. - msg := strings.TrimSpace(string(raw)) - if msg == "" { - msg = fmt.Sprintf("bus register failed (HTTP %d)", resp.StatusCode) - } - writeErr(w, resp.StatusCode, msg) -} diff --git a/cmd/webgw/server.go b/cmd/webgw/server.go deleted file mode 100644 index 2eb0d5a..0000000 --- a/cmd/webgw/server.go +++ /dev/null @@ -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() -} diff --git a/cmd/webgw/session.go b/cmd/webgw/session.go deleted file mode 100644 index 2583d2b..0000000 --- a/cmd/webgw/session.go +++ /dev/null @@ -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}) -} diff --git a/cmd/webgw/webgw_test.go b/cmd/webgw/webgw_test.go deleted file mode 100644 index ca3df95..0000000 --- a/cmd/webgw/webgw_test.go +++ /dev/null @@ -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) - } -} diff --git a/go.mod b/go.mod deleted file mode 100644 index 1439de0..0000000 --- a/go.mod +++ /dev/null @@ -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 -) diff --git a/go.sum b/go.sum deleted file mode 100644 index f761849..0000000 --- a/go.sum +++ /dev/null @@ -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= diff --git a/registry.db b/registry.db deleted file mode 100644 index e69de29..0000000 diff --git a/web/src/App.tsx b/web/src/App.tsx index 5c8e799..07519d0 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -5,7 +5,7 @@ import { Join } from "./Join"; import { Recover } from "./Recover"; import { WalletLogin } from "./WalletLogin"; import { Welcome } from "./Welcome"; -import { api } from "./api"; +import { bus } from "./busService"; import { localIdentity } from "./wallet/account"; import type { User } from "./types"; @@ -31,9 +31,11 @@ export function App() { const [token, setToken] = useState(""); const [storedHandle, setStoredHandle] = useState(""); - // Decide the entry screen on mount: an invite link goes straight to join; a live - // gateway session resumes the chat; a device with a stored identity shows the - // password unlock; an empty device shows the welcome chooser. + // Decide the entry screen on mount: an invite link goes straight to join; a device + // with a stored identity shows the password unlock; an empty device shows the + // welcome chooser. There is no "resume session" step: the bus session lives in + // memory (the SDK runs in the browser), so a reload always re-unlocks locally + // rather than resuming a server-side cookie session. useEffect(() => { const t = readJoinToken(); if (t) { @@ -43,15 +45,6 @@ export function App() { } let cancelled = false; (async () => { - try { - const me = await api.me(); - if (cancelled) return; - setUser({ id: me.endpoint, handle: me.handle || me.endpoint.slice(0, 8) }); - setRoute("chat"); - return; - } catch { - // no live session — fall through - } const stored = await localIdentity(); if (cancelled) return; if (stored) { @@ -73,7 +66,7 @@ export function App() { }; const logout = () => { - void api.logout().catch(() => {}); + void bus.logout().catch(() => {}); setUser(null); // Keep the encrypted identity on the device: logging out returns to the // password unlock, not a full reset. diff --git a/web/src/ChatPanel.tsx b/web/src/ChatPanel.tsx index 8e669b5..228a065 100644 --- a/web/src/ChatPanel.tsx +++ b/web/src/ChatPanel.tsx @@ -19,7 +19,7 @@ import { IconDotsVertical, IconPaperclip, } from "@tabler/icons-react"; -import { api, streamRoom } from "./api"; +import { bus } from "./busService"; import type { Message, Room } from "./types"; function initials(s: string) { @@ -68,7 +68,7 @@ export function ChatPanel({ room }: { room: Room | undefined }) { setMessages([]); setSendError(null); if (!room) return; - const close = streamRoom(room.id, (m) => { + const close = bus.subscribeRoom(room.id, (m) => { setMessages((prev) => prev.some((p) => p.id === m.id) ? prev : [...prev, m], ); @@ -94,9 +94,9 @@ export function ChatPanel({ room }: { room: Room | undefined }) { setDraft(""); setSendError(null); try { - // No optimista: el mensaje propio vuelve por SSE con su id real (mine:true), - // evitando duplicados. - await api.send(room.id, body); + // No optimista: el mensaje propio vuelve por la suscripción con su id real + // (mine:true), evitando duplicados. + await bus.send(room.id, body); } catch (e) { setDraft(body); // restaura el borrador si el envío falló setSendError(e instanceof Error ? e.message : "No se pudo enviar"); diff --git a/web/src/ChatShell.tsx b/web/src/ChatShell.tsx index 550c652..b8595e0 100644 --- a/web/src/ChatShell.tsx +++ b/web/src/ChatShell.tsx @@ -2,7 +2,7 @@ import { useCallback, useEffect, useState } from "react"; import { Flex, Box, Center, Loader, Stack, Text, Button } from "@mantine/core"; import { Sidebar } from "./Sidebar"; import { ChatPanel } from "./ChatPanel"; -import { api } from "./api"; +import { bus } from "./busService"; import type { Room, User } from "./types"; export function ChatShell({ @@ -19,7 +19,7 @@ export function ChatShell({ const load = useCallback(() => { setLoading(true); - api + bus .listRooms() .then((rs) => { setRooms(rs); diff --git a/web/src/Join.tsx b/web/src/Join.tsx index cab85ed..235153a 100644 --- a/web/src/Join.tsx +++ b/web/src/Join.tsx @@ -21,7 +21,7 @@ import { IconKey, IconShieldLock, } from "@tabler/icons-react"; -import { api, ApiError } from "./api"; +import { SessionError } from "./busService"; import { AuthCard, AuthHeader } from "./AuthShell"; import type { User } from "./types"; import { newMnemonic, mnemonicWords } from "./wallet/bip39"; @@ -124,14 +124,21 @@ export function Join({ setStep("joining"); setError(null); try { - // Register the PUBLIC identity with the bus (token authorizes), then - // encrypt the private key locally and open the per-user session. - const res = await api.register(token, identity.signPub, identity.kexPub); - const user = await saveAndOpen(identity, res.handle, password); + // The bus has no token-register endpoint (that was a gateway mock): a + // browser cannot self-register on an enforce cluster. The identity must be + // allow-listed by an admin first. We persist it locally and try to open the + // 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); + const user = await saveAndOpen(identity, handle, password); onJoined(user); } catch (e) { + const base = + e instanceof SessionError || e instanceof Error + ? e.message + : "No se pudo completar el alta."; 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"); } diff --git a/web/src/Login.tsx b/web/src/Login.tsx deleted file mode 100644 index 731bfe6..0000000 --- a/web/src/Login.tsx +++ /dev/null @@ -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(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 ( -
- - - - - - - unibus - - Mensajería cifrada de extremo a extremo - - - setHandle(e.currentTarget.value)} - onKeyDown={(e) => e.key === "Enter" && connect()} - data-autofocus - /> - } - value={password} - onChange={(e) => setPassword(e.currentTarget.value)} - onKeyDown={(e) => e.key === "Enter" && void connect()} - /> - {error && ( - - {error} - - )} - - - -
- ); -} diff --git a/web/src/Recover.tsx b/web/src/Recover.tsx index 991de7d..33fc8e1 100644 --- a/web/src/Recover.tsx +++ b/web/src/Recover.tsx @@ -13,7 +13,7 @@ import { } from "@mantine/core"; import { IconKey, IconRotateClockwise } from "@tabler/icons-react"; import { AuthCard, AuthHeader } from "./AuthShell"; -import { ApiError } from "./api"; +import { SessionError } from "./busService"; import type { User } from "./types"; import { isValidMnemonic, mnemonicWords, normalizeMnemonic } from "./wallet/bip39"; import { deriveIdentity } from "./wallet/derive"; @@ -112,7 +112,7 @@ export function Recover({ onRecovered(user); } catch (e) { setError( - e instanceof ApiError + e instanceof SessionError || e instanceof Error ? e.message : "No se pudo abrir la sesión con la identidad recuperada.", ); diff --git a/web/src/WalletLogin.tsx b/web/src/WalletLogin.tsx index 44b7c73..cd7ff98 100644 --- a/web/src/WalletLogin.tsx +++ b/web/src/WalletLogin.tsx @@ -2,7 +2,7 @@ import { useState } from "react"; import { Anchor, Button, Group, PasswordInput, Text } from "@mantine/core"; import { IconKey, IconWallet } from "@tabler/icons-react"; import { AuthCard, AuthHeader } from "./AuthShell"; -import { ApiError } from "./api"; +import { SessionError } from "./busService"; import type { User } from "./types"; import { unlockAndOpen } from "./wallet/account"; import { WrongPasswordError } from "./wallet/crypto"; @@ -33,10 +33,11 @@ export function WalletLogin({ } catch (e) { if (e instanceof WrongPasswordError) { setError("Contraseña incorrecta."); - } else if (e instanceof ApiError) { + } else if (e instanceof SessionError) { setError(e.message); } 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); } diff --git a/web/src/api.ts b/web/src/api.ts deleted file mode 100644 index 39fa7d1..0000000 --- a/web/src/api.ts +++ /dev/null @@ -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(path: string, init?: RequestInit): Promise { - 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("/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("/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("/api/login", { - method: "POST", - body: JSON.stringify({ passphrase }), - }), - logout: () => req<{ status: string }>("/api/logout", { method: "POST" }), - me: () => req("/api/me"), - - // ---- rooms -------------------------------------------------------------- - listRooms: async (): Promise => { - const wire = await req("/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 => { - const r = await req("/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(); -} diff --git a/web/src/bus/client.ts b/web/src/bus/client.ts index 45b5c8b..c74cf7f 100644 --- a/web/src/bus/client.ts +++ b/web/src/bus/client.ts @@ -172,6 +172,14 @@ interface MemberJSON { sign_pub: string; // base64 } +// 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. @@ -261,6 +269,18 @@ export class ControlPlane { 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 { + const wire = await this.request("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 }, + })); + } + // 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> { diff --git a/web/src/busService.ts b/web/src/busService.ts new file mode 100644 index 0000000..00d8b67 --- /dev/null +++ b/web/src/busService.ts @@ -0,0 +1,154 @@ +// 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, + type Identity, + type Frame, + ModeMatrix, +} from "./bus/index"; +import type { WalletIdentity } from "./wallet/derive"; +import type { MeInfo, Message, Room, User } from "./types"; + +// Bus endpoints. A browser cannot open a raw TCP NATS socket, so the data plane is +// reached over WebSocket; the control plane is the signed HTTPS API. Both default to +// a cluster node and can be overridden at build time (VITE_BUS_HTTP / VITE_BUS_WS). +const BUS_HTTP = import.meta.env.VITE_BUS_HTTP ?? "https://51.91.100.142:8470"; +const BUS_WS = import.meta.env.VITE_BUS_WS ?? "wss://51.91.100.142:8480"; + +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; + +function require_(): Session { + if (!session) throw new SessionError("no active bus session"); + return session; +} + +export const bus = { + // openSession connects to the bus AS this wallet user: it builds the signed + // control-plane client and the nats.ws data-plane connection in the browser. The + // private key never leaves — this is the fix for the old gateway model where the + // browser POSTed its private key to /api/session. + async openSession(wallet: WalletIdentity, handle: string): Promise { + 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 }; + return { id: endpoint, handle: handle || endpoint.slice(0, 8) }; + }, + + // 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 }; + }, + + // logout closes the data-plane connection and drops the session. + async logout(): Promise { + if (session) { + await session.transport.close().catch(() => {}); + session = null; + } + }, + + // listRooms lists the rooms this peer belongs to. + async listRooms(): Promise { + const s = require_(); + const wire = await s.control.listMemberRooms(s.endpoint); + return wire.map((r) => ({ + id: r.id, + name: r.subject, + encrypted: r.policy.encrypt, + lastMessage: "", + lastTs: 0, + unread: 0, + messages: [], + })); + }, + + // createRoom creates an encrypted, signed room owned by this peer (the Matrix-like + // default). Returns the UI Room. + async createRoom(subject: string): Promise { + const s = require_(); + const { roomID } = await s.control.createRoom(subject, ModeMatrix); + return { id: roomID, name: subject, encrypted: true, lastMessage: "", lastTs: 0, unread: 0, messages: [] }; + }, + + // 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 { + const s = require_(); + await s.client.publish(roomID, new TextEncoder().encode(body)); + }, + + // subscribeRoom delivers decrypted, verified messages for a room (replaces the old + // SSE streamRoom). Returns an unsubscribe function. + subscribeRoom(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({ + id: f.msgID, + sender: f.sender, + body: new TextDecoder().decode(plaintext), + ts: Date.now(), + mine: f.sender === s.endpoint, + }); + }) + .then((sub) => { + if (closed) void sub.unsubscribe(); + else unsub = () => void sub.unsubscribe(); + }) + .catch(() => {}); + return () => { + closed = true; + if (unsub) unsub(); + }; + }, +}; + +// hasSession reports whether a bus session is currently open (for the router). +export function hasSession(): boolean { + return session !== null; +} diff --git a/web/src/vite-env.d.ts b/web/src/vite-env.d.ts new file mode 100644 index 0000000..68ea58f --- /dev/null +++ b/web/src/vite-env.d.ts @@ -0,0 +1,12 @@ +/// + +// 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; +} diff --git a/web/src/wallet/account.ts b/web/src/wallet/account.ts index 63ba0b8..f0dd1de 100644 --- a/web/src/wallet/account.ts +++ b/web/src/wallet/account.ts @@ -1,22 +1,19 @@ // High-level wallet account operations shared by the join, recover and login -// flows. These compose the low-level primitives (derive / crypto / store) with -// the gateway API so the page components stay thin. +// flows. These compose the low-level primitives (derive / crypto / store) with the +// browser-native bus session so the page components stay thin. -import { api } from "../api"; -import type { MeInfo, User } from "../types"; +import { bus } from "../busService"; +import type { User } from "../types"; import { decryptJSON, encryptJSON } from "./crypto"; import type { WalletIdentity } from "./derive"; import { getIdentity, putIdentity, type StoredIdentity } from "./store"; -function toUser(me: MeInfo): User { - return { id: me.endpoint, handle: me.handle || me.endpoint.slice(0, 8) }; -} - -// saveAndOpen encrypts the identity under `password`, stores it on this device, -// and opens a gateway session as that user. Used by join (new identity) and -// 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). +// saveAndOpen encrypts the identity under `password`, stores it on this device, and +// 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 +// (encrypted). The private key is used to open the session IN THE BROWSER and is +// never sent to any server (unlike the old gateway model). export async function saveAndOpen( identity: WalletIdentity, handle: string, @@ -30,19 +27,17 @@ export async function saveAndOpen( enc, createdAt: Date.now(), }); - const me = await api.session(identity, handle); - return toUser(me); + return bus.openSession(identity, handle); } // 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. export async function unlockAndOpen(password: string): Promise { const stored = await getIdentity(); if (!stored) throw new NoLocalIdentityError(); const identity = await decryptJSON(stored.enc, password); - const me = await api.session(identity, stored.handle); - return toUser(me); + return bus.openSession(identity, stored.handle); } // localIdentity returns the device's stored identity record (or null), for the diff --git a/web/src/wallet/store.ts b/web/src/wallet/store.ts index 516905f..4d0b1cb 100644 --- a/web/src/wallet/store.ts +++ b/web/src/wallet/store.ts @@ -1,7 +1,8 @@ // IndexedDB persistence of the device-local wallet. Only the encrypted private // 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 -// over TLS to the gateway to open a session (see api.session). +// password, never the BIP39 seed. The private key NEVER leaves the device at all: +// 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 // single device is a documented gap. diff --git a/web/vite.config.ts b/web/vite.config.ts index f9a7aa1..96ac07d 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -3,12 +3,12 @@ import react from "@vitejs/plugin-react"; export default defineConfig({ plugins: [react()], - // En dev, /api (REST + SSE) se proxea al gateway Go (cmd/webgw, puerto 8481). - // El proxy hace streaming, así que el SSE de /api/rooms/{id}/stream funciona a - // través de él. En producción el gateway sirve el dist embebido y no hay proxy. + // The SPA talks DIRECTLY to the bus (signed HTTPS control plane + nats.ws data + // plane), so there is no gateway and no /api proxy. The dev server runs on 5173 to + // match the bus CORS allowlist (--cors-origins http://localhost:5173). Point the + // SPA at a cluster node with VITE_BUS_HTTP / VITE_BUS_WS (see busService.ts). server: { host: true, - port: 5183, - proxy: { "/api": "http://127.0.0.1:8481" }, + port: 5173, }, });