feat: browser-native client — wire SPA to the SDK, delete the Go gateway

Phase 2 of issue 0001. uniweb becomes a pure frontend (web/ only), like
unibus_android: the SPA talks directly to the bus and the Go gateway is gone.

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

Onboarding by token is now admin-side (the bus has no self-register endpoint; the
gateway only mocked it). tsc + pnpm build + 19/19 unit green.
This commit is contained in:
agent
2026-06-14 11:39:06 +02:00
parent bf0884527e
commit 3f52167b04
26 changed files with 319 additions and 1964 deletions
+79 -74
View File
@@ -1,123 +1,128 @@
--- ---
name: uniweb name: uniweb
lang: go lang: ts
domain: infra domain: infra
version: 0.2.0 version: 0.3.0
description: "Frontend web del bus unibus: SPA de chat (React+Mantine) con wallet por usuario (BIP39) + gateway Go (REST+SSE) que actúa de peer del bus para el navegador." description: "Cliente web browser-nativo del bus unibus: SPA de chat (React+Mantine) con wallet por usuario (BIP39) que habla DIRECTO al bus (nats.ws + control-plane HTTPS firmado), sin gateway. La clave privada nunca sale del navegador."
tags: [service, messaging, web, frontend, e2e] tags: [messaging, web, frontend, e2e]
uses_functions: uses_functions: []
- generate_identity_go_cybersecurity
- seal_aead_go_cybersecurity
- open_aead_go_cybersecurity
- seal_key_box_go_cybersecurity
- open_key_box_go_cybersecurity
- sign_ed25519_go_cybersecurity
- verify_ed25519_go_cybersecurity
uses_types: [] uses_types: []
framework: "react" framework: "react"
entry_point: "cmd/webgw" entry_point: "web/src/main.tsx"
dir_path: "projects/message_bus/apps/uniweb" dir_path: "projects/message_bus/apps/uniweb"
repo_url: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/dataforge/uniweb" repo_url: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/dataforge/uniweb"
icon: icon:
phosphor: "chats-circle" phosphor: "chats-circle"
accent: "#6366f1" accent: "#6366f1"
service:
port: 8481
health_endpoint: null
health_timeout_s: 3
systemd_unit: null
systemd_scope: null
restart_policy: always
runtime: manual
pc_targets:
- lucas-linux
is_local_only: false
e2e_checks: e2e_checks:
- id: build - id: typecheck
cmd: "CGO_ENABLED=0 go build ./..." cmd: "cd web && pnpm install --frozen-lockfile && pnpm exec tsc --noEmit -p tsconfig.app.json"
timeout_s: 180 timeout_s: 180
- id: vet
cmd: "CGO_ENABLED=0 go vet ./..."
timeout_s: 120
- id: unit - id: unit
cmd: "CGO_ENABLED=0 go test ./..." cmd: "cd web && pnpm test"
timeout_s: 120 timeout_s: 120
- id: web_build - id: web_build
cmd: "cd web && pnpm install --frozen-lockfile && pnpm build" cmd: "cd web && pnpm build"
timeout_s: 180 timeout_s: 180
--- ---
## Qué es ## Qué es
`uniweb` es el frontend web del bus [unibus](../unibus/app.md): la interfaz que un humano `uniweb` es el **cliente web browser-nativo** del bus [unibus](../unibus/app.md): la interfaz
usa desde el navegador para hablar por el bus. Se separó de `unibus` (v0.13.0) para que el que un humano usa desde el navegador para hablar por el bus. Es **solo frontend** (`web/`) —
plano del bus (membresía, claves, librería cliente) quede limpio y el frontend tenga su una SPA, sin backend Go, sin gateway. Habla **directamente** con el bus, igual que
propia carpeta de servicio y su propio ciclo de release. `unibus_android` lo hace en Kotlin:
Tiene dos mitades que viven juntas: - **Control plane** — HTTPS firmado al `membershipd` (rooms, claves, miembros). Cada request
lleva la firma Ed25519 del usuario (cabeceras `X-Unibus-*`).
- **Data plane** — NATS sobre WebSocket (`nats.ws`), autenticado con el nkey derivado de la
identidad del usuario.
- **SPA (`web/`)** — React 18 + Vite + Mantine v9. Pantallas de chat y onboarding wallet Stack: React 18 + Vite + Mantine v9. La identidad criptográfica de cada usuario se deriva de
(join por invitación, login por passphrase local, recover por mnemónica). La identidad forma determinista de una frase BIP39 de 12 palabras y se cifra at-rest en el dispositivo
criptográfica de cada usuario se deriva de forma determinista de una frase BIP39 de 12 (AES-256-GCM). **La clave privada nunca sale del navegador**: firma, sella y descifra en el
palabras y se cifra at-rest en el dispositivo (AES-256-GCM); la clave privada nunca viaja cliente. No hay servidor al que enviarla.
al servidor en claro.
- **Gateway (`cmd/webgw`)** — binario Go (`package main`, REST + SSE) que actúa como peer
del bus en nombre del navegador. Mantiene una sesión wallet por usuario, registra claves
públicas por token de invitación, y traduce HTTP/SSE ↔ el protocolo del bus usando la
librería cliente de unibus.
## Cómo se acopla a unibus ## El SDK del bus (`web/src/bus/`)
`uniweb` consume `unibus` como **módulo Go**, no reimplementa nada del bus: El protocolo y el cifrado E2E del bus están **portados a TypeScript**, validados byte a byte
contra la implementación Go de referencia (vectores de `unibus cmd/busvectors`):
``` - `crypto.ts` — Ed25519, ChaCha20-Poly1305, sealed box (nonce BLAKE2b, igual que Go).
replace github.com/enmanuel/unibus => ../unibus # pkg/{busauth,client,frame,room} - `frame.ts` — wire format = `encoding/json` de Go byte a byte.
replace fn-registry => ../../../../ # functions/cybersecurity - `room.ts` — Policy (ModeNATS / ModeMatrix).
``` - `busauth.ts` — nkey NATS (base32 + crc16) + firma de requests del control-plane.
- `client.ts` — envelope de room + `BusClient` + `ControlPlane` HTTP firmado.
- `wstransport.ts` — transporte `nats.ws`.
Los `replace` no son transitivos en Go, así que `uniweb` (módulo principal) declara los dos: `busService.ts` es la capa de datos de la SPA sobre el SDK (reemplazó al viejo módulo `api`
el de `unibus` (de donde importa la librería cliente) y el de `fn-registry` (de donde que hablaba con el gateway). Ya **no depende de `unibus` como módulo Go**: el desacople es
`pkg/client` toma las primitivas de cifrado). Compila con `CGO_ENABLED=0` igual que unibus. total.
## Ejemplo ## Ejemplo
```bash ```bash
# 1. Backend: el control-plane del bus (en la carpeta de unibus) # El bus ya corre (cluster unibus con WebSocket habilitado, --ws-port). Apunta la SPA a un
cd ../unibus && CGO_ENABLED=0 go run ./cmd/membershipd # :8470 # nodo y arráncala en dev (puerto 5173, que coincide con la CORS allowlist del cluster):
cd web && pnpm install
VITE_BUS_HTTP=https://<nodo>:8470 VITE_BUS_WS=wss://<nodo>:8480 pnpm dev
# Navegador: http://localhost:5173
# 2. Build de la SPA # Producción: build estático y sirve web/dist con cualquier static server.
cd web && pnpm install && pnpm build # genera web/dist cd web && 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
``` ```
## Cuándo usarla ## Cuándo usarla
Cuando quieras que un humano hable por el bus desde un navegador, o cuando trabajes en la UI Cuando quieras que un humano hable por el bus desde un navegador, o cuando trabajes en la UI
de chat / el onboarding wallet. Para la lógica del bus en sí (membresía, claves, peers de chat / el onboarding wallet. Para la lógica del bus en sí (membresía, claves, peers
programáticos) ve a `unibus`; `uniweb` solo es la capa web encima. programáticos) ve a `unibus`; `uniweb` es el cliente web encima.
## Gotchas ## Gotchas
- El gateway necesita el control-plane de unibus vivo (`--ctrl-url`, por defecto - **`wss://` con CA self-signed**: el cluster sirve el WebSocket con el cert del bus (CA
`http://127.0.0.1:8470`); si no, las sesiones fallan al abrir el peer. propia). Un navegador rechaza `wss://` self-signed salvo que se importe la CA o se ponga un
- `--web-dir` es **opcional**: vacío = API-only (úsalo con el dev server de vite); apuntando a reverse proxy con cert válido (Let's Encrypt). En dev se puede aceptar el cert a mano.
`web/dist` = sirve la SPA buildeada. Un path inválido degrada a API-only con un WARN, no - **Onboarding admin-side**: el bus no tiene endpoint de auto-registro (el viejo gateway lo
peta. *mockeaba*). En `enforce`, una identidad nueva debe ser autorizada por un admin
- Build cross-repo: `uniweb` no compila si `../unibus` no está presente en disco (el `replace` (`membershipd user add`) antes de poder abrir sesión; el flujo de Join muestra la clave
es local). Para deploy hay que llevar ambos repos, o vendorizar unibus. pública del usuario para que un admin la autorice.
- **CORS**: 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 - La passphrase del wallet nunca se guarda ni se envía; perderla en un dispositivo sin la
mnemónica BIP39 = identidad irrecuperable en ese dispositivo (recuperable en otro con las 12 mnemónica BIP39 = identidad irrecuperable en ese dispositivo (recuperable en otro con las 12
palabras). palabras).
## Capability growth log ## Capability growth log
- v0.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: - 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 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, de depender del gateway Go. Módulos: `crypto.ts` (Ed25519, ChaCha20-Poly1305,
-246
View File
@@ -1,246 +0,0 @@
package main
import (
"encoding/hex"
"fmt"
"strings"
"sync"
cs "fn-registry/functions/cybersecurity"
"github.com/enmanuel/unibus/pkg/busauth"
"github.com/enmanuel/unibus/pkg/client"
"github.com/enmanuel/unibus/pkg/frame"
"github.com/enmanuel/unibus/pkg/room"
)
// gateway is the live web gateway: it owns the operator's identity and a single
// connected unibus client, and turns the bus's crypto-bearing API into the plain
// REST/SSE surface the browser consumes. The browser never signs, never speaks
// NATS, and never sees a private key — the gateway is the legitimate room member
// that seals/opens payloads on the browser's behalf.
//
// TRUST MODEL: content stays end-to-end encrypted on the wire. The gateway can
// read plaintext because it acts AS the operator's client — a real member of
// each room, holding the room key K like any peer. It is the same trust a native
// desktop client has. In the wallet phase (per-browser WebCrypto identity) the
// decryption can move into the browser; today, for the single-operator MVP, the
// gateway decrypts server-side and pushes cleartext over a loopback/authenticated
// SSE channel.
type gateway struct {
id cs.Identity
endpoint string
cli *client.Client
refreshACL bool // call RefreshSession after a membership change (needed under a per-subject ACL bus)
mu sync.Mutex
hubs map[string]*roomHub // roomID -> live fan-out of decrypted frames to SSE clients
}
// gatewayConfig wires a live gateway.
type gatewayConfig struct {
Identity cs.Identity
NatsURL string
CtrlURL string
CtrlURLs []string
NatsURLs []string
CAPath string // bus CA; empty => plaintext dev connection (matches a loopback membershipd)
}
// newGateway connects the unibus client with the operator identity following the
// same posture seam every peer uses: a non-empty CA path means TLS + nkey, empty
// means plaintext dev. When a CA is configured the bus is assumed to enforce a
// per-subject ACL, so membership changes trigger a session refresh.
func newGateway(cfg gatewayConfig) (*gateway, error) {
opts := client.Options{
CtrlURLs: cfg.CtrlURLs,
NatsServers: cfg.NatsURLs,
}
if cfg.CAPath != "" {
tlsCfg, err := busauth.LoadCATLSConfig(cfg.CAPath)
if err != nil {
return nil, fmt.Errorf("webgw: load bus CA %q: %w", cfg.CAPath, err)
}
opts.UseNkey = true
opts.TLS = tlsCfg
opts.CtrlTLS = tlsCfg
}
cli, err := client.NewWithOptions(cfg.NatsURL, cfg.CtrlURL, cfg.Identity, opts)
if err != nil {
return nil, fmt.Errorf("webgw: connect bus client: %w", err)
}
return &gateway{
id: cfg.Identity,
endpoint: frame.EndpointID(cfg.Identity.SignPub),
cli: cli,
refreshACL: cfg.CAPath != "",
hubs: map[string]*roomHub{},
}, nil
}
// Close stops every hub and releases the bus client connection.
func (g *gateway) Close() error {
g.mu.Lock()
for _, h := range g.hubs {
h.stop()
}
g.hubs = map[string]*roomHub{}
g.mu.Unlock()
if g.cli != nil {
return g.cli.Close()
}
return nil
}
// ---- wire types (browser-facing JSON) ------------------------------------
// meInfo is what GET /api/me returns: the operator identity the gateway acts as.
type meInfo struct {
Endpoint string `json:"endpoint"`
SignPub string `json:"sign_pub"`
}
// roomWire is the browser view of a room. It deliberately omits messages: those
// stream over SSE (GET /api/rooms/{id}/stream), not in the room list.
type roomWire struct {
ID string `json:"id"`
Subject string `json:"subject"`
Name string `json:"name"`
Epoch int `json:"epoch"`
Encrypt bool `json:"encrypt"`
Persist bool `json:"persist"`
SignMsgs bool `json:"sign_msgs"`
Role string `json:"role"`
}
// createRoomReq is the POST /api/rooms body. Encrypt/Persist/SignMsgs are
// pointers so an omitted field falls back to the chat default rather than to the
// Go zero value (false). The common case — the browser sending only {subject,
// encrypted} — maps encrypted onto all three (the Matrix-like chat policy).
type createRoomReq struct {
Subject string `json:"subject"`
Encrypted *bool `json:"encrypted,omitempty"`
Encrypt *bool `json:"encrypt,omitempty"`
Persist *bool `json:"persist,omitempty"`
SignMsgs *bool `json:"sign_msgs,omitempty"`
}
// policy resolves the requested policy. A bare {subject} defaults to the
// Matrix-like chat room (encrypted + persisted + signed) so a created room keeps
// durable, end-to-end-encrypted, authored history. Callers can override any leg.
func (r createRoomReq) policy() room.Policy {
enc, per, sig := true, true, true
if r.Encrypted != nil {
enc, per, sig = *r.Encrypted, *r.Encrypted, *r.Encrypted
}
if r.Encrypt != nil {
enc = *r.Encrypt
}
if r.Persist != nil {
per = *r.Persist
}
if r.SignMsgs != nil {
sig = *r.SignMsgs
}
return room.Policy{Encrypt: enc, Persist: per, SignMsgs: sig}
}
// sendReq is the POST /api/rooms/{id}/send body.
type sendReq struct {
Body string `json:"body"`
}
// msgWire is one decrypted message pushed over SSE.
type msgWire struct {
ID string `json:"id"`
Sender string `json:"sender"`
Body string `json:"body"`
TS int64 `json:"ts"` // epoch ms (decoded from the frame's ULID id)
Mine bool `json:"mine"`
}
// ---- operations -----------------------------------------------------------
func (g *gateway) me() meInfo {
return meInfo{Endpoint: g.endpoint, SignPub: hex.EncodeToString(g.id.SignPub)}
}
// subjectName derives a short, human-friendly room name from its bus subject by
// dropping the leading namespace segment (room., test., proc., agent.). It is a
// display nicety only; the canonical identity stays the subject/room id.
func subjectName(subject string) string {
for _, p := range []string{"room.", "test.", "proc.", "agent.", "rpc."} {
if strings.HasPrefix(subject, p) {
return strings.TrimPrefix(subject, p)
}
}
return subject
}
func (g *gateway) listRooms() ([]roomWire, error) {
rooms, err := g.cli.ListMyRooms()
if err != nil {
return nil, err
}
out := make([]roomWire, 0, len(rooms))
for _, rm := range rooms {
out = append(out, roomWire{
ID: rm.RoomID,
Subject: rm.Subject,
Name: subjectName(rm.Subject),
Epoch: rm.Epoch,
Encrypt: rm.Policy.Encrypt,
Persist: rm.Policy.Persist,
SignMsgs: rm.Policy.SignMsgs,
Role: rm.Role,
})
}
return out, nil
}
func (g *gateway) createRoom(req createRoomReq) (roomWire, error) {
subject := strings.TrimSpace(req.Subject)
if subject == "" {
return roomWire{}, fmt.Errorf("webgw: subject required")
}
p := req.policy()
roomID, err := g.cli.CreateRoom(subject, p)
if err != nil {
return roomWire{}, err
}
// Under a per-subject ACL the operator's frozen NATS permissions do not yet
// cover the new room's subject; refresh so subsequent data-plane use works. On
// a plaintext/non-ACL dev bus this is unnecessary and would needlessly drop any
// live SSE subscriptions, so it is gated on the secured posture.
if g.refreshACL {
_ = g.cli.RefreshSession()
}
return roomWire{
ID: roomID,
Subject: subject,
Name: subjectName(subject),
Epoch: 1,
Encrypt: p.Encrypt,
Persist: p.Persist,
SignMsgs: p.SignMsgs,
Role: "owner",
}, nil
}
// join resolves room metadata and (for encrypted rooms) fetches the room key so
// the gateway can later open payloads. Idempotent.
func (g *gateway) join(roomID string) error {
if err := g.cli.Join(roomID); err != nil {
return err
}
if g.refreshACL {
_ = g.cli.RefreshSession()
}
return nil
}
// send publishes plaintext to a room. The unibus client seals it with the room
// key (encrypted rooms) and signs it (signed rooms) before it leaves the process.
func (g *gateway) send(roomID, body string) error {
return g.cli.Publish(roomID, []byte(body))
}
-140
View File
@@ -1,140 +0,0 @@
package main
import (
"sync"
"github.com/enmanuel/unibus/pkg/client"
"github.com/enmanuel/unibus/pkg/frame"
"github.com/oklog/ulid/v2"
)
// roomHub multiplexes ONE unibus room subscription to MANY SSE clients. The
// unibus client derives a per-(room, endpoint) durable consumer name, so a
// second Subscribe for the same room from the same operator would contend for
// the same durable (load-balanced delivery) rather than each browser receiving
// every message. The hub holds a single subscription per room and fans each
// decrypted frame out to every connected browser, which also means the gateway
// opens at most one bus subscription per room regardless of how many tabs watch
// it.
type roomHub struct {
roomID string
myEndpoint string
sub *client.Sub
mu sync.Mutex
clients map[chan msgWire]struct{}
}
// frameTS decodes the millisecond timestamp embedded in a frame's ULID id. A
// malformed id (should not happen for bus-produced frames) yields 0, which the
// browser renders without crashing.
func frameTS(msgID string) int64 {
id, err := ulid.Parse(msgID)
if err != nil {
return 0
}
return int64(id.Time())
}
// newRoomHub opens the single bus subscription for roomID and starts fanning
// decrypted frames out to registered clients. The room must already be joined
// (so the gateway holds the room key) before this is called.
func newRoomHub(cli *client.Client, roomID, myEndpoint string) (*roomHub, error) {
h := &roomHub{
roomID: roomID,
myEndpoint: myEndpoint,
clients: map[chan msgWire]struct{}{},
}
sub, err := cli.Subscribe(roomID, func(f frame.Frame, plaintext []byte) {
m := msgWire{
ID: f.MsgID,
Sender: f.Sender,
Body: string(plaintext),
TS: frameTS(f.MsgID),
Mine: f.Sender == myEndpoint,
}
h.broadcast(m)
})
if err != nil {
return nil, err
}
h.sub = sub
return h, nil
}
// broadcast delivers a message to every registered client without blocking the
// NATS delivery goroutine: a client whose buffer is full (a stalled browser)
// drops this frame rather than stalling the whole room.
func (h *roomHub) broadcast(m msgWire) {
h.mu.Lock()
defer h.mu.Unlock()
for ch := range h.clients {
select {
case ch <- m:
default:
}
}
}
// add registers a new SSE client channel.
func (h *roomHub) add(ch chan msgWire) {
h.mu.Lock()
defer h.mu.Unlock()
h.clients[ch] = struct{}{}
}
// stop unsubscribes from the bus. Local delivery ends; for a persisted room the
// durable consumer's ack position stays on the server, so a later subscription
// with the same operator resumes from where it left off.
func (h *roomHub) stop() {
if h.sub != nil {
_ = h.sub.Unsubscribe()
}
}
// openStream joins the room (idempotent; fetches the room key for encrypted
// rooms), attaches an SSE client to the room's hub (creating it on first watcher),
// and returns the client's message channel plus a cleanup func. The cleanup
// detaches the client and, when it was the last watcher, tears down the room's
// single bus subscription.
func (g *gateway) openStream(roomID string) (chan msgWire, func(), error) {
if err := g.join(roomID); err != nil {
return nil, nil, err
}
g.mu.Lock()
h := g.hubs[roomID]
if h == nil {
var err error
h, err = newRoomHub(g.cli, roomID, g.endpoint)
if err != nil {
g.mu.Unlock()
return nil, nil, err
}
g.hubs[roomID] = h
}
g.mu.Unlock()
// Buffer so a brief render hitch in the browser does not drop live frames; a
// sustained stall still drops (broadcast is non-blocking) rather than wedging
// the room.
ch := make(chan msgWire, 64)
h.add(ch)
// cleanup takes g.mu before h.mu (the single, consistent lock order) so a
// concurrent openStream that re-creates the hub cannot race the teardown.
cleanup := func() {
g.mu.Lock()
defer g.mu.Unlock()
h.mu.Lock()
delete(h.clients, ch)
empty := len(h.clients) == 0
h.mu.Unlock()
if empty {
if cur := g.hubs[roomID]; cur == h {
delete(g.hubs, roomID)
h.stop()
}
}
}
return ch, cleanup, nil
}
-98
View File
@@ -1,98 +0,0 @@
package main
import (
"encoding/base64"
"encoding/json"
"fmt"
"os"
"os/exec"
cs "fn-registry/functions/cybersecurity"
)
// identityJSON mirrors the on-disk / pass-stored identity format shared across
// the unibus tooling: the four keypair halves, each std-base64. It is the SAME
// shape the bus client persists (pkg/client identity file) and the operator's
// `pass` entry unibus/operator-identity, so the web gateway loads the operator's
// identity without a divergent serialization. Kept in lockstep with
// unibus_admin/internal/admin/identity.go.
type identityJSON struct {
SignPub string `json:"sign_pub"`
SignPriv string `json:"sign_priv"`
KexPub string `json:"kex_pub"`
KexPriv string `json:"kex_priv"`
}
// decodeIdentity turns the JSON identity bytes into a cs.Identity. The private
// halves stay only in memory; this never writes them anywhere.
func decodeIdentity(raw []byte) (cs.Identity, error) {
var f identityJSON
if err := json.Unmarshal(raw, &f); err != nil {
return cs.Identity{}, fmt.Errorf("webgw: parse identity json: %w", err)
}
dec := base64.StdEncoding.DecodeString
signPub, err := dec(f.SignPub)
if err != nil {
return cs.Identity{}, fmt.Errorf("webgw: decode sign_pub: %w", err)
}
signPriv, err := dec(f.SignPriv)
if err != nil {
return cs.Identity{}, fmt.Errorf("webgw: decode sign_priv: %w", err)
}
kexPub, err := dec(f.KexPub)
if err != nil {
return cs.Identity{}, fmt.Errorf("webgw: decode kex_pub: %w", err)
}
kexPriv, err := dec(f.KexPriv)
if err != nil {
return cs.Identity{}, fmt.Errorf("webgw: decode kex_priv: %w", err)
}
if len(signPub) != 32 || len(signPriv) != 64 || len(kexPub) != 32 || len(kexPriv) != 32 {
return cs.Identity{}, fmt.Errorf("webgw: identity has wrong key sizes (sign_pub=%d sign_priv=%d kex_pub=%d kex_priv=%d)",
len(signPub), len(signPriv), len(kexPub), len(kexPriv))
}
return cs.Identity{SignPub: signPub, SignPriv: signPriv, KexPub: kexPub, KexPriv: kexPriv}, nil
}
// loadIdentityFromFile reads a 0600 identity JSON file (the same format the bus
// client writes) and decodes it. Used on a deploy host where `pass` is not
// available and the operator identity is delivered as a protected file.
func loadIdentityFromFile(path string) (cs.Identity, error) {
raw, err := os.ReadFile(path)
if err != nil {
return cs.Identity{}, fmt.Errorf("webgw: read identity file %q: %w", path, err)
}
return decodeIdentity(raw)
}
// loadIdentityFromPass shells out to `pass show <entry>` and decodes the JSON
// identity it returns. The secret is held only in memory; this process never
// writes it to disk or argv. Used in local operator workflows where the GNU
// password store holds unibus/operator-identity.
func loadIdentityFromPass(entry string) (cs.Identity, error) {
out, err := exec.Command("pass", "show", entry).Output()
if err != nil {
return cs.Identity{}, fmt.Errorf("webgw: pass show %q: %w", entry, err)
}
return decodeIdentity(out)
}
// loadPassValue returns the first line of a `pass show <entry>` for non-identity
// secrets (e.g. the unlock passphrase). Empty entry yields an empty string and
// no error, so callers can treat "no pass entry configured" as "not set".
func loadPassValue(entry string) (string, error) {
if entry == "" {
return "", nil
}
out, err := exec.Command("pass", "show", entry).Output()
if err != nil {
return "", fmt.Errorf("webgw: pass show %q: %w", entry, err)
}
s := string(out)
for i := 0; i < len(s); i++ {
if s[i] == '\n' || s[i] == '\r' {
return s[:i], nil
}
}
return s, nil
}
-199
View File
@@ -1,199 +0,0 @@
// Command webgw is the web gateway for the unibus chat SPA. It is a single Go
// binary that holds the operator's bus identity, connects to the bus as a real
// authenticated peer (pkg/client), and exposes a small REST + SSE API the
// browser consumes. The browser never signs, never speaks NATS, and never sees a
// private key: it authenticates to the gateway with a passphrase and thereafter
// holds only an opaque session cookie.
//
// TRUST MODEL (MVP, single operator): room content stays end-to-end encrypted on
// the bus. The gateway can read plaintext because it acts AS the operator's
// client — a legitimate member of each room holding the room key. Decryption
// happens server-side in this process; cleartext then crosses an authenticated
// (loopback or TLS-fronted) SSE channel to the browser. The wallet phase (issue:
// per-browser WebCrypto identity) can move decryption into the browser; see the
// report for the FASE 2 plan.
//
// # local dev against a loopback membershipd (plaintext), operator from pass:
// webgw --identity-pass unibus/operator-identity \
// --ctrl-url http://127.0.0.1:8470 --nats-url nats://127.0.0.1:4250
//
// # secured cluster (TLS + nkey on both planes), identity from a 0600 file:
// webgw --ca ca.crt --identity-file operator.id \
// --ctrl-url https://node-a:8470 --nats-url nats://node-a:4250 \
// --ctrl-urls https://node-b:8470,https://node-c:8470 \
// --nats-urls nats://node-b:4250,nats://node-c:4250
package main
import (
"context"
"flag"
"log"
"net/http"
"os"
"os/signal"
"path/filepath"
"strings"
"syscall"
"time"
cs "fn-registry/functions/cybersecurity"
)
func main() {
var (
bind = flag.String("bind", "127.0.0.1", "interface to bind the gateway HTTP server to (loopback by default)")
port = flag.String("port", "8481", "gateway HTTP port")
ctrlURL = flag.String("ctrl-url", "http://127.0.0.1:8470", "primary unibus control-plane base URL")
ctrlURLs = flag.String("ctrl-urls", "", "comma-separated ADDITIONAL control-plane base URLs (cluster failover)")
natsURL = flag.String("nats-url", "nats://127.0.0.1:4250", "primary NATS URL")
natsURLs = flag.String("nats-urls", "", "comma-separated ADDITIONAL NATS seed URLs (cluster failover)")
caPath = flag.String("ca", "", "bus CA cert path; set to talk TLS+nkey to a secured bus (empty = plaintext dev)")
identityFile = flag.String("identity-file", "", "path to the operator identity JSON file (0600). Mutually exclusive with --identity-pass")
identityPass = flag.String("identity-pass", "", "pass(1) entry holding the operator identity JSON, e.g. unibus/operator-identity")
unlockPass = flag.String("unlock-pass", "", "literal passphrase the browser must send to unlock a LEGACY operator session (dev). Prefer --unlock-pass-entry")
unlockEntry = flag.String("unlock-pass-entry", "unibus/admin-panel-password", "pass(1) entry holding the operator unlock passphrase (used when --unlock-pass is empty)")
registerURL = flag.String("register-url", "", "bus POST /register URL for wallet onboarding. Empty = derive from --ctrl-url (<ctrl-url>/register)")
mockTokens = flag.String("mock-tokens", "", "DEV ONLY: comma-separated one-shot invite tokens for local testing, 'token=handle:role'. Empty in production (real invites come from the bus). Example: demo=demo:member")
webDir = flag.String("web-dir", "", "OPTIONAL path to the built SPA (web/dist) to serve. Empty = API only (use vite dev server)")
)
flag.Parse()
log.SetFlags(log.LstdFlags | log.Lmsgprefix)
log.SetPrefix("[webgw] ")
id, err := loadIdentity(*identityFile, *identityPass)
if err != nil {
log.Fatalf("%v", err)
}
unlock := *unlockPass
if unlock == "" {
unlock, err = loadPassValue(*unlockEntry)
if err != nil {
log.Fatalf("resolve unlock passphrase: %v", err)
}
}
if unlock == "" {
log.Fatalf("an unlock passphrase is required: set --unlock-pass or a non-empty --unlock-pass-entry (default unibus/admin-panel-password)")
}
resolvedWebDir := resolveWebDir(*webDir)
// busTemplate is the connection config every bus client uses. The operator
// gateway uses it as-is; each wallet session clones it and overrides Identity
// with the logged-in user's keypair.
busTemplate := gatewayConfig{
Identity: id,
NatsURL: *natsURL,
CtrlURL: *ctrlURL,
CtrlURLs: splitCSV(*ctrlURLs),
NatsURLs: splitCSV(*natsURLs),
CAPath: *caPath,
}
gw, err := newGateway(busTemplate)
if err != nil {
log.Fatalf("%v", err)
}
defer gw.Close()
// Wallet onboarding backend: POST /api/register targets the bus's /register
// (added by the user-accounts work). When --register-url is empty we derive it
// from --ctrl-url; --mock-tokens supplies one-shot invites for local testing
// before that endpoint is deployed.
regURL := *registerURL
if regURL == "" {
regURL = strings.TrimRight(*ctrlURL, "/") + "/register"
}
registrar := newRegistrar(regURL, *mockTokens)
log.Printf("operator endpoint: %s", gw.endpoint)
log.Printf("control plane: %s (+%d failover)", *ctrlURL, len(splitCSV(*ctrlURLs)))
tls := "OFF (plaintext dev)"
if *caPath != "" {
tls = "ON (CA " + *caPath + ")"
}
log.Printf("bus TLS+nkey: %s", tls)
if resolvedWebDir != "" {
log.Printf("serving SPA from: %s", resolvedWebDir)
} else {
log.Printf("API only (no --web-dir): use the vite dev server with a /api+stream proxy")
}
log.Printf("wallet register: %s (mock tokens: %d)", regURL, mockTokenCount(*mockTokens))
srv := newServer(gw, busTemplate, registrar, unlock, resolvedWebDir)
addr := *bind + ":" + *port
httpSrv := &http.Server{
Addr: addr,
Handler: srv,
// No global write timeout: SSE streams are long-lived. Header timeout still
// bounds slowloris on the request line/headers.
ReadHeaderTimeout: 10 * time.Second,
}
go func() {
log.Printf("web gateway: http://%s", addr)
if err := httpSrv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("http server: %v", err)
}
}()
stop := make(chan os.Signal, 1)
signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM)
<-stop
log.Printf("shutting down...")
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_ = httpSrv.Shutdown(ctx)
log.Printf("bye")
}
// loadIdentity resolves the operator identity from exactly one of --identity-file
// or --identity-pass.
func loadIdentity(file, passEntry string) (cs.Identity, error) {
switch {
case file != "" && passEntry != "":
return cs.Identity{}, errFlag("set only one of --identity-file or --identity-pass")
case file != "":
return loadIdentityFromFile(file)
case passEntry != "":
return loadIdentityFromPass(passEntry)
default:
return cs.Identity{}, errFlag("an identity is required: pass --identity-file <path> or --identity-pass <entry>")
}
}
// resolveWebDir validates the --web-dir flag. An empty flag means API-only. A
// non-empty dir is kept only if it actually holds an index.html, so a typo logs
// "API only" rather than serving 404s.
func resolveWebDir(dir string) string {
if dir == "" {
return ""
}
abs, err := filepath.Abs(dir)
if err != nil {
log.Printf("WARN --web-dir %q: %v; serving API only", dir, err)
return ""
}
if !statFile(filepath.Join(abs, "index.html")) {
log.Printf("WARN --web-dir %q has no index.html; serving API only", abs)
return ""
}
return abs
}
type flagErr string
func (e flagErr) Error() string { return string(e) }
func errFlag(s string) error { return flagErr("webgw: " + s) }
func splitCSV(s string) []string {
var out []string
for _, p := range strings.Split(s, ",") {
if p = strings.TrimSpace(p); p != "" {
out = append(out, p)
}
}
return out
}
-193
View File
@@ -1,193 +0,0 @@
package main
import (
"bytes"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"sync"
"time"
)
// registerReq is the POST /api/register body. It mirrors the bus contract exactly
// (token + the two PUBLIC key halves, each 64 hex chars). The private key never
// appears here — registration only publishes the public identity. The handle and
// role are NOT accepted from the client; they are fixed by the invite the token
// belongs to (no privilege escalation).
type registerReq struct {
Token string `json:"token"`
SignPub string `json:"sign_pub"`
KexPub string `json:"kex_pub"`
}
// registerResp is what we return to the browser on success. The bus's /register
// (issue: user-accounts) decides handle/role from the invite; in mock mode the
// gateway echoes the configured pair so the SPA can greet the new user.
type registerResp struct {
Handle string `json:"handle"`
Role string `json:"role"`
}
// registrar fulfils POST /api/register. It targets the bus's POST /register
// endpoint (added by the user-accounts work, bus >= 0.12.0). Until that endpoint
// is rolled out, a built-in mock validates against a configured set of one-shot
// tokens so the whole wallet flow is testable locally. Mock tokens are checked
// first; anything else is proxied to the real bus when --register-url is set.
type registrar struct {
mu sync.Mutex
registerURL string // bus POST /register; empty => mock-only
httpc *http.Client // for proxying to the bus
mockTokens map[string]*mockToken // configured one-shot invites for local testing
}
// mockToken is a local stand-in for a bus invite: a token that maps to a fixed
// handle+role and can be consumed exactly once.
type mockToken struct {
handle string
role string
used bool
}
// newRegistrar parses the --mock-tokens spec ("tok=handle:role,tok2=h2:role2")
// and configures the optional proxy target.
func newRegistrar(registerURL, mockSpec string) *registrar {
r := &registrar{
registerURL: strings.TrimSpace(registerURL),
httpc: &http.Client{Timeout: 10 * time.Second},
mockTokens: map[string]*mockToken{},
}
for _, part := range strings.Split(mockSpec, ",") {
part = strings.TrimSpace(part)
if part == "" {
continue
}
// tok=handle:role (role optional, defaults to member)
eq := strings.IndexByte(part, '=')
if eq < 0 {
continue
}
tok := strings.TrimSpace(part[:eq])
hr := strings.TrimSpace(part[eq+1:])
handle, role := hr, "member"
if c := strings.IndexByte(hr, ':'); c >= 0 {
handle, role = strings.TrimSpace(hr[:c]), strings.TrimSpace(hr[c+1:])
}
if tok != "" && handle != "" {
r.mockTokens[tok] = &mockToken{handle: handle, role: role}
}
}
return r
}
// mockTokenCount counts configured mock tokens in a --mock-tokens spec (for the
// startup log line).
func mockTokenCount(spec string) int {
n := 0
for _, part := range strings.Split(spec, ",") {
if p := strings.TrimSpace(part); p != "" && strings.ContainsRune(p, '=') {
n++
}
}
return n
}
// validHexKey reports whether s is exactly 64 lowercase/uppercase hex chars (a
// 32-byte key). Both sign_pub and kex_pub are 32-byte keys.
func validHexKey(s string) bool {
if len(s) != 64 {
return false
}
_, err := hex.DecodeString(s)
return err == nil
}
// handleRegister validates the keys and consumes the token. Order of resolution:
// 1. strict validation of the public keys (defends both mock and proxy paths);
// 2. mock token (one-shot) if configured;
// 3. proxy to the bus /register if --register-url is set;
// 4. otherwise reject with a clear error.
func (s *server) handleRegister(w http.ResponseWriter, r *http.Request) {
var req registerReq
if !decode(w, r, &req) {
return
}
req.Token = strings.TrimSpace(req.Token)
if req.Token == "" {
writeErr(w, http.StatusBadRequest, "token required")
return
}
if !validHexKey(req.SignPub) {
writeErr(w, http.StatusBadRequest, "sign_pub must be 64 hex chars (32 bytes)")
return
}
if !validHexKey(req.KexPub) {
writeErr(w, http.StatusBadRequest, "kex_pub must be 64 hex chars (32 bytes)")
return
}
reg := s.registrar
// 2) mock one-shot token.
reg.mu.Lock()
mt, isMock := reg.mockTokens[req.Token]
if isMock {
if mt.used {
reg.mu.Unlock()
writeErr(w, http.StatusConflict, "invite already used")
return
}
mt.used = true
handle, role := mt.handle, mt.role
reg.mu.Unlock()
writeJSON(w, http.StatusCreated, registerResp{Handle: handle, Role: role})
return
}
reg.mu.Unlock()
// 3) proxy to the real bus /register when configured.
if reg.registerURL != "" {
s.proxyRegister(w, req)
return
}
// 4) no mock match, no proxy target.
writeErr(w, http.StatusBadRequest, "invalid or unknown token (and no bus /register configured)")
}
// proxyRegister forwards the registration to the bus's POST /register. The bus
// validates the invite (existence, not-used, not-expired) and adds the public
// identity to the allowlist with the invite's handle+role. This is unsigned by
// design: the TOKEN authorizes the call, not an admin signature.
func (s *server) proxyRegister(w http.ResponseWriter, req registerReq) {
body, _ := json.Marshal(req)
resp, err := s.registrar.httpc.Post(
s.registrar.registerURL,
"application/json",
bytes.NewReader(body),
)
if err != nil {
writeErr(w, http.StatusBadGateway, "bus register unreachable: "+err.Error())
return
}
defer resp.Body.Close()
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
// On success, try to pass through the bus's handle/role if it returned them;
// otherwise a bare 201 is still success.
if resp.StatusCode == http.StatusCreated || resp.StatusCode == http.StatusOK {
var rr registerResp
_ = json.Unmarshal(raw, &rr)
writeJSON(w, http.StatusCreated, rr)
return
}
// Forward the bus's error verbatim where possible.
msg := strings.TrimSpace(string(raw))
if msg == "" {
msg = fmt.Sprintf("bus register failed (HTTP %d)", resp.StatusCode)
}
writeErr(w, resp.StatusCode, msg)
}
-327
View File
@@ -1,327 +0,0 @@
package main
import (
"crypto/rand"
"crypto/subtle"
"encoding/hex"
"encoding/json"
"net/http"
"os"
"path/filepath"
"strings"
"time"
)
// sessionCookie is the name of the gateway's session cookie. The browser sends
// it automatically on same-origin fetches AND on EventSource (SSE) connections —
// EventSource cannot set custom headers, so a cookie is the only way to
// authenticate the stream. It is HttpOnly so page JS can never read the token.
const sessionCookie = "unibus_session"
// server is the gateway's HTTP surface: a small REST/SSE API under /api plus an
// optional static file server for the built SPA.
//
// Two ways to get a session:
// - POST /api/session — the WALLET model. The browser hands its own bus
// identity (unlocked from its local encrypted key) and the gateway connects a
// dedicated bus client AS that user. Per-user, the primary path.
// - POST /api/login — the legacy operator passphrase. Binds the session to the
// single shared operator gateway. Kept for backward compatibility.
// - POST /api/register — the WALLET onboarding. Unauthenticated (the invite
// token authorizes), it consumes a token and publishes the new user's PUBLIC
// identity to the bus allowlist.
type server struct {
operatorGW *gateway // shared operator client (legacy passphrase login)
busTemplate gatewayConfig // bus connection config; Identity is overridden per user session
registrar *registrar // POST /api/register backend (mock + proxy)
unlock string // passphrase that unlocks an operator session (constant-time compare)
webDir string // optional path to the built SPA (web/dist); empty = API only
mux *http.ServeMux
sessions *sessionStore
}
func newServer(operatorGW *gateway, busTemplate gatewayConfig, registrar *registrar, unlock, webDir string) *server {
s := &server{
operatorGW: operatorGW,
busTemplate: busTemplate,
registrar: registrar,
unlock: unlock,
webDir: webDir,
mux: http.NewServeMux(),
sessions: newSessionStore(),
}
s.routes()
return s
}
func (s *server) ServeHTTP(w http.ResponseWriter, r *http.Request) { s.mux.ServeHTTP(w, r) }
func (s *server) routes() {
// Liveness, unauthenticated (systemd / deploy smoke).
s.mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
})
// Unauthenticated onboarding / auth routes.
s.mux.HandleFunc("POST /api/register", s.handleRegister) // invite token authorizes
s.mux.HandleFunc("POST /api/session", s.handleSession) // wallet: per-user identity
s.mux.HandleFunc("POST /api/login", s.handleLogin) // legacy operator passphrase
// Session-gated routes.
s.mux.HandleFunc("POST /api/logout", s.auth(s.handleLogout))
s.mux.HandleFunc("GET /api/me", s.auth(s.handleMe))
s.mux.HandleFunc("GET /api/rooms", s.auth(s.handleListRooms))
s.mux.HandleFunc("POST /api/rooms", s.auth(s.handleCreateRoom))
s.mux.HandleFunc("POST /api/rooms/{id}/join", s.auth(s.handleJoin))
s.mux.HandleFunc("POST /api/rooms/{id}/send", s.auth(s.handleSend))
s.mux.HandleFunc("GET /api/rooms/{id}/stream", s.auth(s.handleStream))
// Everything else is the SPA (when --web-dir is set). Registered last.
if s.webDir != "" {
s.mux.Handle("/", s.spaHandler())
}
}
// meResp is the identity view returned by /api/session, /api/login and /api/me:
// the bus endpoint the session acts as, its signing public key, and the display
// handle.
type meResp struct {
Endpoint string `json:"endpoint"`
SignPub string `json:"sign_pub"`
Handle string `json:"handle"`
}
// ---- auth -----------------------------------------------------------------
// auth wraps a handler so it runs only with a valid session cookie, resolving the
// session (and thus the per-user gateway) it belongs to. A missing or unknown
// token yields 401, which the SPA treats as "show the login screen".
func (s *server) auth(next func(http.ResponseWriter, *http.Request, *session)) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
c, err := r.Cookie(sessionCookie)
if err != nil {
writeErr(w, http.StatusUnauthorized, "not authenticated")
return
}
sess, ok := s.sessions.get(c.Value)
if !ok {
writeErr(w, http.StatusUnauthorized, "not authenticated")
return
}
next(w, r, sess)
}
}
// handleLogin is the legacy operator passphrase login: it unlocks a session bound
// to the shared operator gateway. The wallet path (POST /api/session) is
// preferred; this remains for backward compatibility with the single-operator MVP.
func (s *server) handleLogin(w http.ResponseWriter, r *http.Request) {
var req struct {
Passphrase string `json:"passphrase"`
}
if !decode(w, r, &req) {
return
}
// Constant-time compare so a wrong passphrase cannot be timed character by
// character. An empty configured passphrase never matches.
if s.unlock == "" || subtle.ConstantTimeCompare([]byte(req.Passphrase), []byte(s.unlock)) != 1 {
writeErr(w, http.StatusUnauthorized, "wrong passphrase")
return
}
tok := newToken()
handle := s.operatorGW.endpoint
if len(handle) > 8 {
handle = handle[:8]
}
s.sessions.put(tok, &session{gw: s.operatorGW, owned: false, handle: handle, issuedAt: time.Now()})
http.SetCookie(w, &http.Cookie{
Name: sessionCookie,
Value: tok,
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
})
writeJSON(w, http.StatusOK, meResp{Endpoint: s.operatorGW.endpoint, SignPub: hex.EncodeToString(s.operatorGW.id.SignPub), Handle: handle})
}
func (s *server) handleLogout(w http.ResponseWriter, r *http.Request, _ *session) {
if c, err := r.Cookie(sessionCookie); err == nil {
if sess, ok := s.sessions.drop(c.Value); ok && sess.owned && sess.gw != nil {
// Per-user session: tear down its bus client so the private key and the
// NATS connection do not outlive the session.
_ = sess.gw.Close()
}
}
http.SetCookie(w, &http.Cookie{Name: sessionCookie, Value: "", Path: "/", MaxAge: -1, HttpOnly: true})
writeJSON(w, http.StatusOK, map[string]string{"status": "logged_out"})
}
func (s *server) handleMe(w http.ResponseWriter, _ *http.Request, sess *session) {
writeJSON(w, http.StatusOK, meResp{
Endpoint: sess.gw.endpoint,
SignPub: hex.EncodeToString(sess.gw.id.SignPub),
Handle: sess.handle,
})
}
// ---- rooms ----------------------------------------------------------------
func (s *server) handleListRooms(w http.ResponseWriter, _ *http.Request, sess *session) {
rooms, err := sess.gw.listRooms()
if err != nil {
writeErr(w, http.StatusBadGateway, err.Error())
return
}
writeJSON(w, http.StatusOK, rooms)
}
func (s *server) handleCreateRoom(w http.ResponseWriter, r *http.Request, sess *session) {
var req createRoomReq
if !decode(w, r, &req) {
return
}
rv, err := sess.gw.createRoom(req)
if err != nil {
writeErr(w, http.StatusBadGateway, err.Error())
return
}
writeJSON(w, http.StatusCreated, rv)
}
func (s *server) handleJoin(w http.ResponseWriter, r *http.Request, sess *session) {
if err := sess.gw.join(r.PathValue("id")); err != nil {
writeErr(w, http.StatusBadGateway, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "joined"})
}
func (s *server) handleSend(w http.ResponseWriter, r *http.Request, sess *session) {
var req sendReq
if !decode(w, r, &req) {
return
}
if strings.TrimSpace(req.Body) == "" {
writeErr(w, http.StatusBadRequest, "body required")
return
}
if err := sess.gw.send(r.PathValue("id"), req.Body); err != nil {
writeErr(w, http.StatusBadGateway, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "sent"})
}
// handleStream is the SSE endpoint: it joins the room, attaches to the session's
// fan-out hub, and streams each decrypted message as a `data:` event. For a
// persisted room the hub's underlying subscription delivers history first
// (scrollback) and then live messages; for an ephemeral room only live messages
// flow. The stream ends when the browser disconnects (ctx cancelled).
func (s *server) handleStream(w http.ResponseWriter, r *http.Request, sess *session) {
flusher, ok := w.(http.Flusher)
if !ok {
writeErr(w, http.StatusInternalServerError, "streaming unsupported")
return
}
ch, cleanup, err := sess.gw.openStream(r.PathValue("id"))
if err != nil {
writeErr(w, http.StatusBadGateway, err.Error())
return
}
defer cleanup()
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.Header().Set("X-Accel-Buffering", "no") // disable proxy buffering (nginx/caddy)
w.WriteHeader(http.StatusOK)
// An initial comment opens the stream immediately so the browser's
// EventSource fires `onopen` without waiting for the first message.
_, _ = w.Write([]byte(": connected\n\n"))
flusher.Flush()
ctx := r.Context()
ping := time.NewTicker(25 * time.Second)
defer ping.Stop()
for {
select {
case <-ctx.Done():
return
case <-ping.C:
// Comment line keeps idle proxies from closing the connection.
if _, err := w.Write([]byte(": ping\n\n")); err != nil {
return
}
flusher.Flush()
case m := <-ch:
b, err := json.Marshal(m)
if err != nil {
continue
}
if _, err := w.Write([]byte("data: " + string(b) + "\n\n")); err != nil {
return
}
flusher.Flush()
}
}
}
// ---- SPA serving (optional) -----------------------------------------------
// spaHandler serves the built SPA from s.webDir. A request for an existing asset
// is served directly; any other path (a client-side route) falls back to
// index.html so the SPA router can take over. /api and /healthz are matched first.
func (s *server) spaHandler() http.Handler {
root := http.Dir(s.webDir)
fileServer := http.FileServer(root)
index := filepath.Join(s.webDir, "index.html")
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
p := strings.TrimPrefix(r.URL.Path, "/")
if p == "" {
http.ServeFile(w, r, index)
return
}
if f, err := root.Open(p); err == nil {
_ = f.Close()
fileServer.ServeHTTP(w, r)
return
}
http.ServeFile(w, r, index) // unknown path -> SPA client-side routing
})
}
// ---- helpers --------------------------------------------------------------
func newToken() string {
b := make([]byte, 32)
_, _ = rand.Read(b)
return hex.EncodeToString(b)
}
func writeJSON(w http.ResponseWriter, code int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
_ = json.NewEncoder(w).Encode(v)
}
func writeErr(w http.ResponseWriter, code int, msg string) {
writeJSON(w, code, map[string]string{"error": msg})
}
// decode reads a JSON body into v, writing a 400 and returning false on failure.
func decode(w http.ResponseWriter, r *http.Request, v any) bool {
defer r.Body.Close()
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(v); err != nil {
writeErr(w, http.StatusBadRequest, "bad json: "+err.Error())
return false
}
return true
}
// statFile reports whether path exists and is a regular file (used to validate
// --web-dir at startup so a typo surfaces as a clear log line, not 404s later).
func statFile(path string) bool {
fi, err := os.Stat(path)
return err == nil && !fi.IsDir()
}
-146
View File
@@ -1,146 +0,0 @@
package main
import (
"encoding/hex"
"fmt"
"net/http"
"sync"
"time"
cs "fn-registry/functions/cybersecurity"
)
// session is one logged-in browser. In the wallet model each session carries the
// user's OWN bus identity: the browser unlocks its locally-encrypted private key
// and hands the full keypair to the gateway over TLS, and the gateway spins up a
// dedicated bus client (a *gateway) that acts AS that user. The private key lives
// only in this process's memory for the life of the session — it is never written
// to disk and is dropped when the session ends.
//
// A session may instead point at the shared operator gateway (the legacy
// passphrase login); `owned` distinguishes the two so logout only closes the bus
// client it created.
type session struct {
gw *gateway
owned bool // true => gw was built for this session and must be Closed on logout
handle string
issuedAt time.Time
}
// sessionStore is the gateway's set of live browser sessions, keyed by the opaque
// cookie token. It is independent of any single bus identity.
type sessionStore struct {
mu sync.Mutex
m map[string]*session
}
func newSessionStore() *sessionStore { return &sessionStore{m: map[string]*session{}} }
func (st *sessionStore) put(token string, s *session) {
st.mu.Lock()
st.m[token] = s
st.mu.Unlock()
}
func (st *sessionStore) get(token string) (*session, bool) {
st.mu.Lock()
defer st.mu.Unlock()
s, ok := st.m[token]
return s, ok
}
// drop removes a session and returns it so the caller can close an owned gateway.
func (st *sessionStore) drop(token string) (*session, bool) {
st.mu.Lock()
defer st.mu.Unlock()
s, ok := st.m[token]
if ok {
delete(st.m, token)
}
return s, ok
}
// closeAll closes every owned per-user gateway (used at shutdown). The shared
// operator gateway is owned by main and closed separately.
func (st *sessionStore) closeAll() {
st.mu.Lock()
defer st.mu.Unlock()
for tok, s := range st.m {
if s.owned && s.gw != nil {
_ = s.gw.Close()
}
delete(st.m, tok)
}
}
// identityFromHex builds a cs.Identity from the four hex halves the browser sends
// on POST /api/session. It enforces the exact key sizes (sign_pub 32, sign_priv
// 64, kex_pub 32, kex_priv 32) so a malformed body cannot produce a half-built
// identity that fails opaquely deep in the bus client.
func identityFromHex(signPub, signPriv, kexPub, kexPriv string) (cs.Identity, error) {
sp, err := hex.DecodeString(signPub)
if err != nil {
return cs.Identity{}, fmt.Errorf("sign_pub: %w", err)
}
spriv, err := hex.DecodeString(signPriv)
if err != nil {
return cs.Identity{}, fmt.Errorf("sign_priv: %w", err)
}
kp, err := hex.DecodeString(kexPub)
if err != nil {
return cs.Identity{}, fmt.Errorf("kex_pub: %w", err)
}
kpriv, err := hex.DecodeString(kexPriv)
if err != nil {
return cs.Identity{}, fmt.Errorf("kex_priv: %w", err)
}
if len(sp) != 32 || len(spriv) != 64 || len(kp) != 32 || len(kpriv) != 32 {
return cs.Identity{}, fmt.Errorf("wrong key sizes (sign_pub=%d sign_priv=%d kex_pub=%d kex_priv=%d; want 32/64/32/32)",
len(sp), len(spriv), len(kp), len(kpriv))
}
return cs.Identity{SignPub: sp, SignPriv: spriv, KexPub: kp, KexPriv: kpriv}, nil
}
// sessionReq is the POST /api/session body: the user's full wallet identity (hex)
// plus a display handle. The private halves arrive only over TLS and are held in
// memory for the session; they are never persisted server-side.
type sessionReq struct {
Handle string `json:"handle"`
SignPub string `json:"sign_pub"`
SignPriv string `json:"sign_priv"`
KexPub string `json:"kex_pub"`
KexPriv string `json:"kex_priv"`
}
// handleSession opens a per-user session. It builds the user's bus identity from
// the posted keypair, connects a dedicated bus client as that user, and issues a
// session cookie bound to it. This is the wallet-model replacement for the
// operator passphrase login.
func (s *server) handleSession(w http.ResponseWriter, r *http.Request) {
var req sessionReq
if !decode(w, r, &req) {
return
}
id, err := identityFromHex(req.SignPub, req.SignPriv, req.KexPub, req.KexPriv)
if err != nil {
writeErr(w, http.StatusBadRequest, "bad identity: "+err.Error())
return
}
cfg := s.busTemplate
cfg.Identity = id
gw, err := newGateway(cfg)
if err != nil {
writeErr(w, http.StatusBadGateway, "connect bus as user: "+err.Error())
return
}
tok := newToken()
s.sessions.put(tok, &session{gw: gw, owned: true, handle: req.Handle, issuedAt: time.Now()})
http.SetCookie(w, &http.Cookie{
Name: sessionCookie,
Value: tok,
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
})
writeJSON(w, http.StatusOK, meResp{Endpoint: gw.endpoint, SignPub: req.SignPub, Handle: req.Handle})
}
-114
View File
@@ -1,114 +0,0 @@
package main
import (
"encoding/json"
"net/http/httptest"
"strings"
"testing"
)
// fixed wallet vector derived in the browser from the mnemonic
// "legal winner thank year wave sausage worth useful legal winner thank yellow"
// using the unibus-sign-v1 / unibus-kex-v1 HKDF scheme. Used to assert the Go
// side accepts the browser-derived key sizes.
const (
fixSignPub = "3d594317212e53a3685b305539f6789eb8c538579e350ca795278b180ebb53db"
fixSignPriv = "94485d66ac958e23546be2e3b7575a47e1264bdf082e09abb7ad02ab32fcd55e3d594317212e53a3685b305539f6789eb8c538579e350ca795278b180ebb53db"
fixKexPub = "f3561ca116e4444b8880b8c0a35f2c9e85804d8628006facd84b1a6146208257"
fixKexPriv = "f6ffdf15e5ee2af0494897ff43e61a06d632af425a0372cb53a7c3e0f84c2bb2"
)
func TestIdentityFromHex(t *testing.T) {
id, err := identityFromHex(fixSignPub, fixSignPriv, fixKexPub, fixKexPriv)
if err != nil {
t.Fatalf("identityFromHex valid vector: %v", err)
}
if len(id.SignPub) != 32 || len(id.SignPriv) != 64 || len(id.KexPub) != 32 || len(id.KexPriv) != 32 {
t.Fatalf("wrong sizes: %d/%d/%d/%d", len(id.SignPub), len(id.SignPriv), len(id.KexPub), len(id.KexPriv))
}
// Wrong sign_priv size (32 instead of 64) must be rejected.
if _, err := identityFromHex(fixSignPub, fixSignPub, fixKexPub, fixKexPriv); err == nil {
t.Fatalf("expected error for short sign_priv")
}
// Non-hex must be rejected.
if _, err := identityFromHex("zz", fixSignPriv, fixKexPub, fixKexPriv); err == nil {
t.Fatalf("expected error for non-hex sign_pub")
}
}
func TestValidHexKey(t *testing.T) {
if !validHexKey(fixSignPub) {
t.Fatalf("fixSignPub should be a valid 32-byte hex key")
}
if validHexKey("abcd") {
t.Fatalf("short key should be invalid")
}
if validHexKey(strings.Repeat("z", 64)) {
t.Fatalf("non-hex key should be invalid")
}
}
func TestNewRegistrarParsesMockTokens(t *testing.T) {
r := newRegistrar("", "demo=demo:member, bob=bob, alice=alice:admin")
if len(r.mockTokens) != 3 {
t.Fatalf("want 3 mock tokens, got %d", len(r.mockTokens))
}
if r.mockTokens["demo"].role != "member" || r.mockTokens["demo"].handle != "demo" {
t.Fatalf("demo token parsed wrong: %+v", r.mockTokens["demo"])
}
if r.mockTokens["bob"].role != "member" {
t.Fatalf("bob should default to role member, got %q", r.mockTokens["bob"].role)
}
if r.mockTokens["alice"].role != "admin" {
t.Fatalf("alice should be admin, got %q", r.mockTokens["alice"].role)
}
}
// post builds a server with only a registrar (the register path does not touch a
// gateway) and runs one POST /api/register, returning status + decoded body.
func postRegister(t *testing.T, s *server, body string) (int, map[string]string) {
t.Helper()
req := httptest.NewRequest("POST", "/api/register", strings.NewReader(body))
w := httptest.NewRecorder()
s.handleRegister(w, req)
var m map[string]string
_ = json.Unmarshal(w.Body.Bytes(), &m)
return w.Code, m
}
func TestHandleRegisterMockSingleUse(t *testing.T) {
s := &server{registrar: newRegistrar("", "demo=demo:member")}
// 1) valid token + valid keys => 201 with the invite's handle/role.
code, body := postRegister(t, s, `{"token":"demo","sign_pub":"`+fixSignPub+`","kex_pub":"`+fixKexPub+`"}`)
if code != 201 {
t.Fatalf("first register: want 201, got %d (%v)", code, body)
}
if body["handle"] != "demo" || body["role"] != "member" {
t.Fatalf("first register body: %v", body)
}
// 2) same token again => 409 (single-use consumed).
code, _ = postRegister(t, s, `{"token":"demo","sign_pub":"`+fixSignPub+`","kex_pub":"`+fixKexPub+`"}`)
if code != 409 {
t.Fatalf("reused token: want 409, got %d", code)
}
}
func TestHandleRegisterValidation(t *testing.T) {
s := &server{registrar: newRegistrar("", "demo=demo:member")}
// bad sign_pub (too short) => 400
if code, _ := postRegister(t, s, `{"token":"demo","sign_pub":"abcd","kex_pub":"`+fixKexPub+`"}`); code != 400 {
t.Fatalf("short sign_pub: want 400, got %d", code)
}
// missing token => 400
if code, _ := postRegister(t, s, `{"sign_pub":"`+fixSignPub+`","kex_pub":"`+fixKexPub+`"}`); code != 400 {
t.Fatalf("missing token: want 400, got %d", code)
}
// unknown token with no mock match and no register-url => 400
if code, _ := postRegister(t, s, `{"token":"nope","sign_pub":"`+fixSignPub+`","kex_pub":"`+fixKexPub+`"}`); code != 400 {
t.Fatalf("unknown token: want 400, got %d", code)
}
}
-37
View File
@@ -1,37 +0,0 @@
module github.com/enmanuel/uniweb
go 1.26.4
replace fn-registry => ../../../../
replace github.com/enmanuel/unibus => ../unibus
require (
fn-registry v0.0.0-00010101000000-000000000000
github.com/enmanuel/unibus v0.0.0-00010101000000-000000000000
github.com/oklog/ulid/v2 v2.1.0
)
require (
github.com/antithesishq/antithesis-sdk-go v0.6.0-default-no-op // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/go-tpm v0.9.8 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/klauspost/compress v1.18.4 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/minio/highwayhash v1.0.4-0.20251030100505-070ab1a87a76 // indirect
github.com/nats-io/jwt/v2 v2.8.1 // indirect
github.com/nats-io/nats-server/v2 v2.11.15 // indirect
github.com/nats-io/nats.go v1.49.0 // indirect
github.com/nats-io/nkeys v0.4.15 // indirect
github.com/nats-io/nuid v1.0.1 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/crypto v0.51.0 // indirect
golang.org/x/sys v0.44.0 // indirect
golang.org/x/time v0.15.0 // indirect
modernc.org/libc v1.70.0 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.47.0 // indirect
)
-77
View File
@@ -1,77 +0,0 @@
github.com/antithesishq/antithesis-sdk-go v0.6.0-default-no-op h1:kpBdlEPbRvff0mDD1gk7o9BhI16b9p5yYAXRlidpqJE=
github.com/antithesishq/antithesis-sdk-go v0.6.0-default-no-op/go.mod h1:IUpT2DPAKh6i/YhSbt6Gl3v2yvUZjmKncl7U91fup7E=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c=
github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/minio/highwayhash v1.0.4-0.20251030100505-070ab1a87a76 h1:KGuD/pM2JpL9FAYvBrnBBeENKZNh6eNtjqytV6TYjnk=
github.com/minio/highwayhash v1.0.4-0.20251030100505-070ab1a87a76/go.mod h1:GGYsuwP/fPD6Y9hMiXuapVvlIUEhFhMTh0rxU3ik1LQ=
github.com/nats-io/jwt/v2 v2.8.1 h1:V0xpGuD/N8Mi+fQNDynXohVvp7ZztevW5io8CUWlPmU=
github.com/nats-io/jwt/v2 v2.8.1/go.mod h1:nWnOEEiVMiKHQpnAy4eXlizVEtSfzacZ1Q43LIRavZg=
github.com/nats-io/nats-server/v2 v2.11.15 h1:StSf9TINInaZtr4oww2+kXmfwa9SkN//g/LwS19/UJ0=
github.com/nats-io/nats-server/v2 v2.11.15/go.mod h1:zwhv8Y0PE3KHyKgznJc/9Xoai638SaJd83zzJ5GJn74=
github.com/nats-io/nats.go v1.49.0 h1:yh/WvY59gXqYpgl33ZI+XoVPKyut/IcEaqtsiuTJpoE=
github.com/nats-io/nats.go v1.49.0/go.mod h1:fDCn3mN5cY8HooHwE2ukiLb4p4G4ImmzvXyJt+tGwdw=
github.com/nats-io/nkeys v0.4.15 h1:JACV5jRVO9V856KOapQ7x+EY8Jo3qw1vJt/9Jpwzkk4=
github.com/nats-io/nkeys v0.4.15/go.mod h1:CpMchTXC9fxA5zrMo4KpySxNjiDVvr8ANOSZdiNfUrs=
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/oklog/ulid/v2 v2.1.0 h1:+9lhoxAP56we25tyYETBBY1YLA2SaoLvUFgrP2miPJU=
github.com/oklog/ulid/v2 v2.1.0/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ=
github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.32.0 h1:hjG66bI/kqIPX1b2yT6fr/jt+QedtP2fqojG2VrFuVw=
modernc.org/ccgo/v4 v4.32.0/go.mod h1:6F08EBCx5uQc38kMGl+0Nm0oWczoo1c7cgpzEry7Uc0=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.70.0 h1:U58NawXqXbgpZ/dcdS9kMshu08aiA6b7gusEusqzNkw=
modernc.org/libc v1.70.0/go.mod h1:OVmxFGP1CI/Z4L3E0Q3Mf1PDE0BucwMkcXjjLntvHJo=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.47.0 h1:R1XyaNpoW4Et9yly+I2EeX7pBza/w+pmYee/0HJDyKk=
modernc.org/sqlite v1.47.0/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
View File
+7 -14
View File
@@ -5,7 +5,7 @@ import { Join } from "./Join";
import { Recover } from "./Recover"; import { Recover } from "./Recover";
import { WalletLogin } from "./WalletLogin"; import { WalletLogin } from "./WalletLogin";
import { Welcome } from "./Welcome"; import { Welcome } from "./Welcome";
import { api } from "./api"; import { bus } from "./busService";
import { localIdentity } from "./wallet/account"; import { localIdentity } from "./wallet/account";
import type { User } from "./types"; import type { User } from "./types";
@@ -31,9 +31,11 @@ export function App() {
const [token, setToken] = useState(""); const [token, setToken] = useState("");
const [storedHandle, setStoredHandle] = useState(""); const [storedHandle, setStoredHandle] = useState("");
// Decide the entry screen on mount: an invite link goes straight to join; a live // Decide the entry screen on mount: an invite link goes straight to join; a device
// gateway session resumes the chat; a device with a stored identity shows the // with a stored identity shows the password unlock; an empty device shows the
// password unlock; an empty device shows the welcome chooser. // 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(() => { useEffect(() => {
const t = readJoinToken(); const t = readJoinToken();
if (t) { if (t) {
@@ -43,15 +45,6 @@ export function App() {
} }
let cancelled = false; let cancelled = false;
(async () => { (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(); const stored = await localIdentity();
if (cancelled) return; if (cancelled) return;
if (stored) { if (stored) {
@@ -73,7 +66,7 @@ export function App() {
}; };
const logout = () => { const logout = () => {
void api.logout().catch(() => {}); void bus.logout().catch(() => {});
setUser(null); setUser(null);
// Keep the encrypted identity on the device: logging out returns to the // Keep the encrypted identity on the device: logging out returns to the
// password unlock, not a full reset. // password unlock, not a full reset.
+5 -5
View File
@@ -19,7 +19,7 @@ import {
IconDotsVertical, IconDotsVertical,
IconPaperclip, IconPaperclip,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import { api, streamRoom } from "./api"; import { bus } from "./busService";
import type { Message, Room } from "./types"; import type { Message, Room } from "./types";
function initials(s: string) { function initials(s: string) {
@@ -68,7 +68,7 @@ export function ChatPanel({ room }: { room: Room | undefined }) {
setMessages([]); setMessages([]);
setSendError(null); setSendError(null);
if (!room) return; if (!room) return;
const close = streamRoom(room.id, (m) => { const close = bus.subscribeRoom(room.id, (m) => {
setMessages((prev) => setMessages((prev) =>
prev.some((p) => p.id === m.id) ? prev : [...prev, m], prev.some((p) => p.id === m.id) ? prev : [...prev, m],
); );
@@ -94,9 +94,9 @@ export function ChatPanel({ room }: { room: Room | undefined }) {
setDraft(""); setDraft("");
setSendError(null); setSendError(null);
try { try {
// No optimista: el mensaje propio vuelve por SSE con su id real (mine:true), // No optimista: el mensaje propio vuelve por la suscripción con su id real
// evitando duplicados. // (mine:true), evitando duplicados.
await api.send(room.id, body); await bus.send(room.id, body);
} catch (e) { } catch (e) {
setDraft(body); // restaura el borrador si el envío falló setDraft(body); // restaura el borrador si el envío falló
setSendError(e instanceof Error ? e.message : "No se pudo enviar"); setSendError(e instanceof Error ? e.message : "No se pudo enviar");
+2 -2
View File
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useState } from "react";
import { Flex, Box, Center, Loader, Stack, Text, Button } from "@mantine/core"; import { Flex, Box, Center, Loader, Stack, Text, Button } from "@mantine/core";
import { Sidebar } from "./Sidebar"; import { Sidebar } from "./Sidebar";
import { ChatPanel } from "./ChatPanel"; import { ChatPanel } from "./ChatPanel";
import { api } from "./api"; import { bus } from "./busService";
import type { Room, User } from "./types"; import type { Room, User } from "./types";
export function ChatShell({ export function ChatShell({
@@ -19,7 +19,7 @@ export function ChatShell({
const load = useCallback(() => { const load = useCallback(() => {
setLoading(true); setLoading(true);
api bus
.listRooms() .listRooms()
.then((rs) => { .then((rs) => {
setRooms(rs); setRooms(rs);
+13 -6
View File
@@ -21,7 +21,7 @@ import {
IconKey, IconKey,
IconShieldLock, IconShieldLock,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import { api, ApiError } from "./api"; import { SessionError } from "./busService";
import { AuthCard, AuthHeader } from "./AuthShell"; import { AuthCard, AuthHeader } from "./AuthShell";
import type { User } from "./types"; import type { User } from "./types";
import { newMnemonic, mnemonicWords } from "./wallet/bip39"; import { newMnemonic, mnemonicWords } from "./wallet/bip39";
@@ -124,14 +124,21 @@ export function Join({
setStep("joining"); setStep("joining");
setError(null); setError(null);
try { try {
// Register the PUBLIC identity with the bus (token authorizes), then // The bus has no token-register endpoint (that was a gateway mock): a
// encrypt the private key locally and open the per-user session. // browser cannot self-register on an enforce cluster. The identity must be
const res = await api.register(token, identity.signPub, identity.kexPub); // allow-listed by an admin first. We persist it locally and try to open the
const user = await saveAndOpen(identity, res.handle, password); // session; if the identity is not yet authorized, openSession fails and we
// tell the user to have an admin authorize their public key.
const handle = identity.signPub.slice(0, 8);
const user = await saveAndOpen(identity, handle, password);
onJoined(user); onJoined(user);
} catch (e) { } catch (e) {
const base =
e instanceof SessionError || e instanceof Error
? e.message
: "No se pudo completar el alta.";
setError( setError(
e instanceof ApiError ? e.message : "No se pudo completar el alta.", `${base}. Pide a un administrador que autorice tu clave pública: ${identity.signPub}`,
); );
setStep("password"); setStep("password");
} }
-89
View File
@@ -1,89 +0,0 @@
import { useState } from "react";
import {
Button,
Card,
Center,
PasswordInput,
Stack,
Text,
TextInput,
ThemeIcon,
Title,
} from "@mantine/core";
import { IconShieldLock, IconKey } from "@tabler/icons-react";
import { api, ApiError } from "./api";
import type { User } from "./types";
export function Login({ onLogin }: { onLogin: (u: User) => void }) {
const [handle, setHandle] = useState("");
const [password, setPassword] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const ready = handle.trim().length > 0 && password.length > 0;
const connect = async () => {
if (!ready || busy) return;
setBusy(true);
setError(null);
try {
// La contraseña desbloquea la sesión del gateway (passphrase del operador).
// El handle es solo el nombre a mostrar en esta iteración (wallet = fase 2).
const me = await api.login(password);
const h = handle.trim() || me.endpoint.slice(0, 8);
onLogin({ id: me.endpoint, handle: h });
} catch (e) {
setError(e instanceof ApiError ? e.message : "No se pudo conectar al gateway");
setBusy(false);
}
};
return (
<Center h="100vh" bg="dark.9">
<Card w={380} p="xl" radius="lg" withBorder bg="dark.7">
<Stack align="center" gap="lg">
<ThemeIcon size={60} radius="xl" variant="light" color="brand">
<IconShieldLock size={32} />
</ThemeIcon>
<Stack gap={2} align="center">
<Title order={2}>unibus</Title>
<Text c="dimmed" size="sm">
Mensajería cifrada de extremo a extremo
</Text>
</Stack>
<TextInput
w="100%"
label="Identidad"
placeholder="tu-handle"
value={handle}
onChange={(e) => setHandle(e.currentTarget.value)}
onKeyDown={(e) => e.key === "Enter" && connect()}
data-autofocus
/>
<PasswordInput
w="100%"
label="Contraseña"
description="Desbloquea tu identidad cifrada en este dispositivo"
placeholder="••••••••"
leftSection={<IconKey size={16} />}
value={password}
onChange={(e) => setPassword(e.currentTarget.value)}
onKeyDown={(e) => e.key === "Enter" && void connect()}
/>
{error && (
<Text c="red" size="sm" ta="center">
{error}
</Text>
)}
<Button
w="100%"
size="md"
onClick={() => void connect()}
disabled={!ready}
loading={busy}
>
Conectar
</Button>
</Stack>
</Card>
</Center>
);
}
+2 -2
View File
@@ -13,7 +13,7 @@ import {
} from "@mantine/core"; } from "@mantine/core";
import { IconKey, IconRotateClockwise } from "@tabler/icons-react"; import { IconKey, IconRotateClockwise } from "@tabler/icons-react";
import { AuthCard, AuthHeader } from "./AuthShell"; import { AuthCard, AuthHeader } from "./AuthShell";
import { ApiError } from "./api"; import { SessionError } from "./busService";
import type { User } from "./types"; import type { User } from "./types";
import { isValidMnemonic, mnemonicWords, normalizeMnemonic } from "./wallet/bip39"; import { isValidMnemonic, mnemonicWords, normalizeMnemonic } from "./wallet/bip39";
import { deriveIdentity } from "./wallet/derive"; import { deriveIdentity } from "./wallet/derive";
@@ -112,7 +112,7 @@ export function Recover({
onRecovered(user); onRecovered(user);
} catch (e) { } catch (e) {
setError( setError(
e instanceof ApiError e instanceof SessionError || e instanceof Error
? e.message ? e.message
: "No se pudo abrir la sesión con la identidad recuperada.", : "No se pudo abrir la sesión con la identidad recuperada.",
); );
+4 -3
View File
@@ -2,7 +2,7 @@ import { useState } from "react";
import { Anchor, Button, Group, PasswordInput, Text } from "@mantine/core"; import { Anchor, Button, Group, PasswordInput, Text } from "@mantine/core";
import { IconKey, IconWallet } from "@tabler/icons-react"; import { IconKey, IconWallet } from "@tabler/icons-react";
import { AuthCard, AuthHeader } from "./AuthShell"; import { AuthCard, AuthHeader } from "./AuthShell";
import { ApiError } from "./api"; import { SessionError } from "./busService";
import type { User } from "./types"; import type { User } from "./types";
import { unlockAndOpen } from "./wallet/account"; import { unlockAndOpen } from "./wallet/account";
import { WrongPasswordError } from "./wallet/crypto"; import { WrongPasswordError } from "./wallet/crypto";
@@ -33,10 +33,11 @@ export function WalletLogin({
} catch (e) { } catch (e) {
if (e instanceof WrongPasswordError) { if (e instanceof WrongPasswordError) {
setError("Contraseña incorrecta."); setError("Contraseña incorrecta.");
} else if (e instanceof ApiError) { } else if (e instanceof SessionError) {
setError(e.message); setError(e.message);
} else { } else {
setError("No se pudo abrir tu identidad."); // A connection/authorization failure (e.g. identity not yet allow-listed).
setError(e instanceof Error ? e.message : "No se pudo abrir tu identidad.");
} }
setBusy(false); setBusy(false);
} }
-167
View File
@@ -1,167 +0,0 @@
// La única capa por la que la SPA habla con el bus. Cada llamada va al gateway Go
// bajo /api; el gateway mantiene la sesión `pkg/client` (peer autenticado del
// bus), cifra/descifra por room y traduce a REST/SSE. El navegador nunca firma,
// nunca habla NATS y nunca ve una clave privada: solo guarda una cookie de
// sesión opaca (HttpOnly) que el gateway emite tras el login.
import type {
MeInfo,
Message,
MsgWire,
RegisterResult,
Room,
RoomWire,
} from "./types";
import type { WalletIdentity } from "./wallet/derive";
export class ApiError extends Error {
status: number;
constructor(message: string, status: number) {
super(message);
this.status = status;
}
}
async function req<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(path, {
// same-origin envía la cookie de sesión automáticamente (también detrás del
// proxy de vite en dev).
credentials: "same-origin",
headers: { "Content-Type": "application/json" },
...init,
});
const text = await res.text();
let body: unknown = null;
if (text) {
try {
body = JSON.parse(text);
} catch {
body = text;
}
}
if (!res.ok) {
const msg =
body && typeof body === "object" && "error" in body
? String((body as { error: unknown }).error)
: `HTTP ${res.status}`;
throw new ApiError(msg, res.status);
}
return body as T;
}
// roomFromWire mapea la fila del gateway al tipo Room que consume la UI. Los
// mensajes NO viven aquí: llegan por stream(). lastMessage/lastTs/unread se
// rellenan de forma neutra para no inventar datos (la cabecera de la sidebar se
// alimentará del stream en una iteración futura).
export function roomFromWire(r: RoomWire): Room {
return {
id: r.id,
name: r.name || r.subject,
encrypted: r.encrypt,
lastMessage: "",
lastTs: 0,
unread: 0,
messages: [],
};
}
// messageFromWire mapea un frame descifrado del SSE al tipo Message de la UI.
export function messageFromWire(m: MsgWire): Message {
return {
id: m.id,
sender: m.sender,
body: m.body,
ts: m.ts,
mine: m.mine,
};
}
export const api = {
// ---- onboarding wallet --------------------------------------------------
// register publica la identidad PÚBLICA del nuevo usuario en el allowlist del
// bus usando el token del enlace de invitación. NO requiere sesión: el token
// autoriza. El handle y el rol los fija el invite, no el cliente. La clave
// privada NUNCA se envía aquí.
register: (token: string, signPub: string, kexPub: string) =>
req<RegisterResult>("/api/register", {
method: "POST",
body: JSON.stringify({ token, sign_pub: signPub, kex_pub: kexPub }),
}),
// session abre una sesión POR USUARIO: el navegador entrega su identidad wallet
// completa (incluida la privada, solo por TLS) y el gateway conecta un cliente
// del bus que actúa COMO ese usuario. La privada vive en memoria del gateway
// mientras dure la sesión; no se persiste en el servidor.
session: (id: WalletIdentity, handle: string) =>
req<MeInfo>("/api/session", {
method: "POST",
body: JSON.stringify({
handle,
sign_pub: id.signPub,
sign_priv: id.signPriv,
kex_pub: id.kexPub,
kex_priv: id.kexPriv,
}),
}),
// ---- sesión (legacy operador) ------------------------------------------
// login desbloquea una sesión ligada al gateway del operador con su passphrase.
// El camino principal ahora es el wallet (session); login se mantiene por
// compatibilidad con el MVP de operador único.
login: (passphrase: string) =>
req<MeInfo>("/api/login", {
method: "POST",
body: JSON.stringify({ passphrase }),
}),
logout: () => req<{ status: string }>("/api/logout", { method: "POST" }),
me: () => req<MeInfo>("/api/me"),
// ---- rooms --------------------------------------------------------------
listRooms: async (): Promise<Room[]> => {
const wire = await req<RoomWire[]>("/api/rooms");
return wire.map(roomFromWire);
},
// createRoom: {subject, encrypted} basta — el gateway deriva la policy
// Matrix-like (cifrada + persistida + firmada) por defecto.
createRoom: async (subject: string, encrypted = true): Promise<Room> => {
const r = await req<RoomWire>("/api/rooms", {
method: "POST",
body: JSON.stringify({ subject, encrypted }),
});
return roomFromWire(r);
},
join: (roomID: string) =>
req<{ status: string }>(
`/api/rooms/${encodeURIComponent(roomID)}/join`,
{ method: "POST" },
),
send: (roomID: string, body: string) =>
req<{ status: string }>(
`/api/rooms/${encodeURIComponent(roomID)}/send`,
{ method: "POST", body: JSON.stringify({ body }) },
),
};
// streamRoom abre el SSE de una room y llama onMessage por cada frame descifrado
// (historia primero en rooms persistidas, luego en vivo). Devuelve una función
// de cierre. EventSource manda la cookie de sesión automáticamente y reconecta
// solo si la conexión cae; onError se invoca en cada corte para que la UI pueda
// reflejar el estado.
export function streamRoom(
roomID: string,
onMessage: (m: Message) => void,
onError?: (e: Event) => void,
): () => void {
const es = new EventSource(
`/api/rooms/${encodeURIComponent(roomID)}/stream`,
);
es.onmessage = (ev) => {
try {
const wire = JSON.parse(ev.data) as MsgWire;
onMessage(messageFromWire(wire));
} catch {
// frame malformado: se ignora, el stream sigue.
}
};
if (onError) es.onerror = onError;
return () => es.close();
}
+20
View File
@@ -172,6 +172,14 @@ interface MemberJSON {
sign_pub: string; // base64 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 // ControlPlane is the signed HTTP client for the membershipd control plane. Every
// request carries the X-Unibus-* auth headers (busauth.signedHeaders). It pins no // request carries the X-Unibus-* auth headers (busauth.signedHeaders). It pins no
// host so it can target any cluster node. // host so it can target any cluster node.
@@ -261,6 +269,18 @@ export class ControlPlane {
return { key, epoch: resp.epoch }; return { key, epoch: resp.epoch };
} }
// listMemberRooms returns the rooms a peer belongs to (GET /members/{endpoint}/rooms),
// mapping the wire shape (room_id, snake_case policy) to the SDK Room type.
async listMemberRooms(endpoint: string): Promise<Room[]> {
const wire = await this.request<MemberRoomWire[]>("GET", `/members/${endpoint}/rooms`);
return wire.map((r) => ({
id: r.room_id,
subject: r.subject,
epoch: r.epoch,
policy: { encrypt: r.policy.encrypt, persist: r.policy.persist, signMsgs: r.policy.sign_msgs },
}));
}
// listMembers returns the room's members keyed by endpoint, so a receiver can find // listMembers returns the room's members keyed by endpoint, so a receiver can find
// a sender's signing public key to verify message signatures. // a sender's signing public key to verify message signatures.
async signerKeys(roomID: string): Promise<Map<string, Uint8Array>> { async signerKeys(roomID: string): Promise<Map<string, Uint8Array>> {
+154
View File
@@ -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<User> {
const identity = toIdentity(wallet);
const endpoint = endpointID(identity.signPub);
const control = new ControlPlane(BUS_HTTP, identity);
const transport = await WsNatsTransport.connect([BUS_WS], identity);
const client = new BusClient(identity, transport, control);
session = { identity, handle, endpoint, control, transport, client };
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<void> {
if (session) {
await session.transport.close().catch(() => {});
session = null;
}
},
// listRooms lists the rooms this peer belongs to.
async listRooms(): Promise<Room[]> {
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<Room> {
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<void> {
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;
}
+12
View File
@@ -0,0 +1,12 @@
/// <reference types="vite/client" />
// Build-time configuration for the bus endpoints. Both are optional; busService
// falls back to a cluster node when unset.
interface ImportMetaEnv {
readonly VITE_BUS_HTTP?: string;
readonly VITE_BUS_WS?: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}
+13 -18
View File
@@ -1,22 +1,19 @@
// High-level wallet account operations shared by the join, recover and login // High-level wallet account operations shared by the join, recover and login
// flows. These compose the low-level primitives (derive / crypto / store) with // flows. These compose the low-level primitives (derive / crypto / store) with the
// the gateway API so the page components stay thin. // browser-native bus session so the page components stay thin.
import { api } from "../api"; import { bus } from "../busService";
import type { MeInfo, User } from "../types"; import type { User } from "../types";
import { decryptJSON, encryptJSON } from "./crypto"; import { decryptJSON, encryptJSON } from "./crypto";
import type { WalletIdentity } from "./derive"; import type { WalletIdentity } from "./derive";
import { getIdentity, putIdentity, type StoredIdentity } from "./store"; import { getIdentity, putIdentity, type StoredIdentity } from "./store";
function toUser(me: MeInfo): User { // saveAndOpen encrypts the identity under `password`, stores it on this device, and
return { id: me.endpoint, handle: me.handle || me.endpoint.slice(0, 8) }; // opens a bus session as that user. Used by join (new identity) and recover
} // (re-derived identity): both end with a locally-encrypted key plus a live session.
// The mnemonic/seed is NOT touched here — only the derived keypair is persisted
// saveAndOpen encrypts the identity under `password`, stores it on this device, // (encrypted). The private key is used to open the session IN THE BROWSER and is
// and opens a gateway session as that user. Used by join (new identity) and // never sent to any server (unlike the old gateway model).
// recover (re-derived identity): both end with a locally-encrypted key plus a
// live per-user session. The mnemonic/seed is NOT touched here — only the derived
// keypair is persisted (encrypted).
export async function saveAndOpen( export async function saveAndOpen(
identity: WalletIdentity, identity: WalletIdentity,
handle: string, handle: string,
@@ -30,19 +27,17 @@ export async function saveAndOpen(
enc, enc,
createdAt: Date.now(), createdAt: Date.now(),
}); });
const me = await api.session(identity, handle); return bus.openSession(identity, handle);
return toUser(me);
} }
// unlockAndOpen reads this device's stored identity, decrypts the private key with // unlockAndOpen reads this device's stored identity, decrypts the private key with
// `password`, and opens a gateway session. Throws WrongPasswordError on a bad // `password`, and opens a bus session locally. Throws WrongPasswordError on a bad
// password (GCM auth failure) and NoLocalIdentityError if the device has none. // password (GCM auth failure) and NoLocalIdentityError if the device has none.
export async function unlockAndOpen(password: string): Promise<User> { export async function unlockAndOpen(password: string): Promise<User> {
const stored = await getIdentity(); const stored = await getIdentity();
if (!stored) throw new NoLocalIdentityError(); if (!stored) throw new NoLocalIdentityError();
const identity = await decryptJSON<WalletIdentity>(stored.enc, password); const identity = await decryptJSON<WalletIdentity>(stored.enc, password);
const me = await api.session(identity, stored.handle); return bus.openSession(identity, stored.handle);
return toUser(me);
} }
// localIdentity returns the device's stored identity record (or null), for the // localIdentity returns the device's stored identity record (or null), for the
+3 -2
View File
@@ -1,7 +1,8 @@
// IndexedDB persistence of the device-local wallet. Only the encrypted private // IndexedDB persistence of the device-local wallet. Only the encrypted private
// key plus the public halves and the display handle are stored — never the // key plus the public halves and the display handle are stored — never the
// password, never the BIP39 seed. The private key never leaves the device except // password, never the BIP39 seed. The private key NEVER leaves the device at all:
// over TLS to the gateway to open a session (see api.session). // the bus session is opened in the browser (see busService.openSession), which signs
// and decrypts locally — there is no server to send the key to.
// //
// MVP: one active identity per device (keyed by a fixed id). Multi-account on a // MVP: one active identity per device (keyed by a fixed id). Multi-account on a
// single device is a documented gap. // single device is a documented gap.
+5 -5
View File
@@ -3,12 +3,12 @@ import react from "@vitejs/plugin-react";
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [react()],
// En dev, /api (REST + SSE) se proxea al gateway Go (cmd/webgw, puerto 8481). // The SPA talks DIRECTLY to the bus (signed HTTPS control plane + nats.ws data
// El proxy hace streaming, así que el SSE de /api/rooms/{id}/stream funciona a // plane), so there is no gateway and no /api proxy. The dev server runs on 5173 to
// través de él. En producción el gateway sirve el dist embebido y no hay proxy. // 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: { server: {
host: true, host: true,
port: 5183, port: 5173,
proxy: { "/api": "http://127.0.0.1:8481" },
}, },
}); });