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:
@@ -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,
|
||||||
|
|||||||
@@ -1,246 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/hex"
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
|
|
||||||
cs "fn-registry/functions/cybersecurity"
|
|
||||||
|
|
||||||
"github.com/enmanuel/unibus/pkg/busauth"
|
|
||||||
"github.com/enmanuel/unibus/pkg/client"
|
|
||||||
"github.com/enmanuel/unibus/pkg/frame"
|
|
||||||
"github.com/enmanuel/unibus/pkg/room"
|
|
||||||
)
|
|
||||||
|
|
||||||
// gateway is the live web gateway: it owns the operator's identity and a single
|
|
||||||
// connected unibus client, and turns the bus's crypto-bearing API into the plain
|
|
||||||
// REST/SSE surface the browser consumes. The browser never signs, never speaks
|
|
||||||
// NATS, and never sees a private key — the gateway is the legitimate room member
|
|
||||||
// that seals/opens payloads on the browser's behalf.
|
|
||||||
//
|
|
||||||
// TRUST MODEL: content stays end-to-end encrypted on the wire. The gateway can
|
|
||||||
// read plaintext because it acts AS the operator's client — a real member of
|
|
||||||
// each room, holding the room key K like any peer. It is the same trust a native
|
|
||||||
// desktop client has. In the wallet phase (per-browser WebCrypto identity) the
|
|
||||||
// decryption can move into the browser; today, for the single-operator MVP, the
|
|
||||||
// gateway decrypts server-side and pushes cleartext over a loopback/authenticated
|
|
||||||
// SSE channel.
|
|
||||||
type gateway struct {
|
|
||||||
id cs.Identity
|
|
||||||
endpoint string
|
|
||||||
cli *client.Client
|
|
||||||
refreshACL bool // call RefreshSession after a membership change (needed under a per-subject ACL bus)
|
|
||||||
|
|
||||||
mu sync.Mutex
|
|
||||||
hubs map[string]*roomHub // roomID -> live fan-out of decrypted frames to SSE clients
|
|
||||||
}
|
|
||||||
|
|
||||||
// gatewayConfig wires a live gateway.
|
|
||||||
type gatewayConfig struct {
|
|
||||||
Identity cs.Identity
|
|
||||||
NatsURL string
|
|
||||||
CtrlURL string
|
|
||||||
CtrlURLs []string
|
|
||||||
NatsURLs []string
|
|
||||||
CAPath string // bus CA; empty => plaintext dev connection (matches a loopback membershipd)
|
|
||||||
}
|
|
||||||
|
|
||||||
// newGateway connects the unibus client with the operator identity following the
|
|
||||||
// same posture seam every peer uses: a non-empty CA path means TLS + nkey, empty
|
|
||||||
// means plaintext dev. When a CA is configured the bus is assumed to enforce a
|
|
||||||
// per-subject ACL, so membership changes trigger a session refresh.
|
|
||||||
func newGateway(cfg gatewayConfig) (*gateway, error) {
|
|
||||||
opts := client.Options{
|
|
||||||
CtrlURLs: cfg.CtrlURLs,
|
|
||||||
NatsServers: cfg.NatsURLs,
|
|
||||||
}
|
|
||||||
if cfg.CAPath != "" {
|
|
||||||
tlsCfg, err := busauth.LoadCATLSConfig(cfg.CAPath)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("webgw: load bus CA %q: %w", cfg.CAPath, err)
|
|
||||||
}
|
|
||||||
opts.UseNkey = true
|
|
||||||
opts.TLS = tlsCfg
|
|
||||||
opts.CtrlTLS = tlsCfg
|
|
||||||
}
|
|
||||||
cli, err := client.NewWithOptions(cfg.NatsURL, cfg.CtrlURL, cfg.Identity, opts)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("webgw: connect bus client: %w", err)
|
|
||||||
}
|
|
||||||
return &gateway{
|
|
||||||
id: cfg.Identity,
|
|
||||||
endpoint: frame.EndpointID(cfg.Identity.SignPub),
|
|
||||||
cli: cli,
|
|
||||||
refreshACL: cfg.CAPath != "",
|
|
||||||
hubs: map[string]*roomHub{},
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close stops every hub and releases the bus client connection.
|
|
||||||
func (g *gateway) Close() error {
|
|
||||||
g.mu.Lock()
|
|
||||||
for _, h := range g.hubs {
|
|
||||||
h.stop()
|
|
||||||
}
|
|
||||||
g.hubs = map[string]*roomHub{}
|
|
||||||
g.mu.Unlock()
|
|
||||||
if g.cli != nil {
|
|
||||||
return g.cli.Close()
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- wire types (browser-facing JSON) ------------------------------------
|
|
||||||
|
|
||||||
// meInfo is what GET /api/me returns: the operator identity the gateway acts as.
|
|
||||||
type meInfo struct {
|
|
||||||
Endpoint string `json:"endpoint"`
|
|
||||||
SignPub string `json:"sign_pub"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// roomWire is the browser view of a room. It deliberately omits messages: those
|
|
||||||
// stream over SSE (GET /api/rooms/{id}/stream), not in the room list.
|
|
||||||
type roomWire struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
Subject string `json:"subject"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
Epoch int `json:"epoch"`
|
|
||||||
Encrypt bool `json:"encrypt"`
|
|
||||||
Persist bool `json:"persist"`
|
|
||||||
SignMsgs bool `json:"sign_msgs"`
|
|
||||||
Role string `json:"role"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// createRoomReq is the POST /api/rooms body. Encrypt/Persist/SignMsgs are
|
|
||||||
// pointers so an omitted field falls back to the chat default rather than to the
|
|
||||||
// Go zero value (false). The common case — the browser sending only {subject,
|
|
||||||
// encrypted} — maps encrypted onto all three (the Matrix-like chat policy).
|
|
||||||
type createRoomReq struct {
|
|
||||||
Subject string `json:"subject"`
|
|
||||||
Encrypted *bool `json:"encrypted,omitempty"`
|
|
||||||
Encrypt *bool `json:"encrypt,omitempty"`
|
|
||||||
Persist *bool `json:"persist,omitempty"`
|
|
||||||
SignMsgs *bool `json:"sign_msgs,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// policy resolves the requested policy. A bare {subject} defaults to the
|
|
||||||
// Matrix-like chat room (encrypted + persisted + signed) so a created room keeps
|
|
||||||
// durable, end-to-end-encrypted, authored history. Callers can override any leg.
|
|
||||||
func (r createRoomReq) policy() room.Policy {
|
|
||||||
enc, per, sig := true, true, true
|
|
||||||
if r.Encrypted != nil {
|
|
||||||
enc, per, sig = *r.Encrypted, *r.Encrypted, *r.Encrypted
|
|
||||||
}
|
|
||||||
if r.Encrypt != nil {
|
|
||||||
enc = *r.Encrypt
|
|
||||||
}
|
|
||||||
if r.Persist != nil {
|
|
||||||
per = *r.Persist
|
|
||||||
}
|
|
||||||
if r.SignMsgs != nil {
|
|
||||||
sig = *r.SignMsgs
|
|
||||||
}
|
|
||||||
return room.Policy{Encrypt: enc, Persist: per, SignMsgs: sig}
|
|
||||||
}
|
|
||||||
|
|
||||||
// sendReq is the POST /api/rooms/{id}/send body.
|
|
||||||
type sendReq struct {
|
|
||||||
Body string `json:"body"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// msgWire is one decrypted message pushed over SSE.
|
|
||||||
type msgWire struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
Sender string `json:"sender"`
|
|
||||||
Body string `json:"body"`
|
|
||||||
TS int64 `json:"ts"` // epoch ms (decoded from the frame's ULID id)
|
|
||||||
Mine bool `json:"mine"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- operations -----------------------------------------------------------
|
|
||||||
|
|
||||||
func (g *gateway) me() meInfo {
|
|
||||||
return meInfo{Endpoint: g.endpoint, SignPub: hex.EncodeToString(g.id.SignPub)}
|
|
||||||
}
|
|
||||||
|
|
||||||
// subjectName derives a short, human-friendly room name from its bus subject by
|
|
||||||
// dropping the leading namespace segment (room., test., proc., agent.). It is a
|
|
||||||
// display nicety only; the canonical identity stays the subject/room id.
|
|
||||||
func subjectName(subject string) string {
|
|
||||||
for _, p := range []string{"room.", "test.", "proc.", "agent.", "rpc."} {
|
|
||||||
if strings.HasPrefix(subject, p) {
|
|
||||||
return strings.TrimPrefix(subject, p)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return subject
|
|
||||||
}
|
|
||||||
|
|
||||||
func (g *gateway) listRooms() ([]roomWire, error) {
|
|
||||||
rooms, err := g.cli.ListMyRooms()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
out := make([]roomWire, 0, len(rooms))
|
|
||||||
for _, rm := range rooms {
|
|
||||||
out = append(out, roomWire{
|
|
||||||
ID: rm.RoomID,
|
|
||||||
Subject: rm.Subject,
|
|
||||||
Name: subjectName(rm.Subject),
|
|
||||||
Epoch: rm.Epoch,
|
|
||||||
Encrypt: rm.Policy.Encrypt,
|
|
||||||
Persist: rm.Policy.Persist,
|
|
||||||
SignMsgs: rm.Policy.SignMsgs,
|
|
||||||
Role: rm.Role,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (g *gateway) createRoom(req createRoomReq) (roomWire, error) {
|
|
||||||
subject := strings.TrimSpace(req.Subject)
|
|
||||||
if subject == "" {
|
|
||||||
return roomWire{}, fmt.Errorf("webgw: subject required")
|
|
||||||
}
|
|
||||||
p := req.policy()
|
|
||||||
roomID, err := g.cli.CreateRoom(subject, p)
|
|
||||||
if err != nil {
|
|
||||||
return roomWire{}, err
|
|
||||||
}
|
|
||||||
// Under a per-subject ACL the operator's frozen NATS permissions do not yet
|
|
||||||
// cover the new room's subject; refresh so subsequent data-plane use works. On
|
|
||||||
// a plaintext/non-ACL dev bus this is unnecessary and would needlessly drop any
|
|
||||||
// live SSE subscriptions, so it is gated on the secured posture.
|
|
||||||
if g.refreshACL {
|
|
||||||
_ = g.cli.RefreshSession()
|
|
||||||
}
|
|
||||||
return roomWire{
|
|
||||||
ID: roomID,
|
|
||||||
Subject: subject,
|
|
||||||
Name: subjectName(subject),
|
|
||||||
Epoch: 1,
|
|
||||||
Encrypt: p.Encrypt,
|
|
||||||
Persist: p.Persist,
|
|
||||||
SignMsgs: p.SignMsgs,
|
|
||||||
Role: "owner",
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// join resolves room metadata and (for encrypted rooms) fetches the room key so
|
|
||||||
// the gateway can later open payloads. Idempotent.
|
|
||||||
func (g *gateway) join(roomID string) error {
|
|
||||||
if err := g.cli.Join(roomID); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if g.refreshACL {
|
|
||||||
_ = g.cli.RefreshSession()
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// send publishes plaintext to a room. The unibus client seals it with the room
|
|
||||||
// key (encrypted rooms) and signs it (signed rooms) before it leaves the process.
|
|
||||||
func (g *gateway) send(roomID, body string) error {
|
|
||||||
return g.cli.Publish(roomID, []byte(body))
|
|
||||||
}
|
|
||||||
@@ -1,140 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"sync"
|
|
||||||
|
|
||||||
"github.com/enmanuel/unibus/pkg/client"
|
|
||||||
"github.com/enmanuel/unibus/pkg/frame"
|
|
||||||
"github.com/oklog/ulid/v2"
|
|
||||||
)
|
|
||||||
|
|
||||||
// roomHub multiplexes ONE unibus room subscription to MANY SSE clients. The
|
|
||||||
// unibus client derives a per-(room, endpoint) durable consumer name, so a
|
|
||||||
// second Subscribe for the same room from the same operator would contend for
|
|
||||||
// the same durable (load-balanced delivery) rather than each browser receiving
|
|
||||||
// every message. The hub holds a single subscription per room and fans each
|
|
||||||
// decrypted frame out to every connected browser, which also means the gateway
|
|
||||||
// opens at most one bus subscription per room regardless of how many tabs watch
|
|
||||||
// it.
|
|
||||||
type roomHub struct {
|
|
||||||
roomID string
|
|
||||||
myEndpoint string
|
|
||||||
sub *client.Sub
|
|
||||||
|
|
||||||
mu sync.Mutex
|
|
||||||
clients map[chan msgWire]struct{}
|
|
||||||
}
|
|
||||||
|
|
||||||
// frameTS decodes the millisecond timestamp embedded in a frame's ULID id. A
|
|
||||||
// malformed id (should not happen for bus-produced frames) yields 0, which the
|
|
||||||
// browser renders without crashing.
|
|
||||||
func frameTS(msgID string) int64 {
|
|
||||||
id, err := ulid.Parse(msgID)
|
|
||||||
if err != nil {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
return int64(id.Time())
|
|
||||||
}
|
|
||||||
|
|
||||||
// newRoomHub opens the single bus subscription for roomID and starts fanning
|
|
||||||
// decrypted frames out to registered clients. The room must already be joined
|
|
||||||
// (so the gateway holds the room key) before this is called.
|
|
||||||
func newRoomHub(cli *client.Client, roomID, myEndpoint string) (*roomHub, error) {
|
|
||||||
h := &roomHub{
|
|
||||||
roomID: roomID,
|
|
||||||
myEndpoint: myEndpoint,
|
|
||||||
clients: map[chan msgWire]struct{}{},
|
|
||||||
}
|
|
||||||
sub, err := cli.Subscribe(roomID, func(f frame.Frame, plaintext []byte) {
|
|
||||||
m := msgWire{
|
|
||||||
ID: f.MsgID,
|
|
||||||
Sender: f.Sender,
|
|
||||||
Body: string(plaintext),
|
|
||||||
TS: frameTS(f.MsgID),
|
|
||||||
Mine: f.Sender == myEndpoint,
|
|
||||||
}
|
|
||||||
h.broadcast(m)
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
h.sub = sub
|
|
||||||
return h, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// broadcast delivers a message to every registered client without blocking the
|
|
||||||
// NATS delivery goroutine: a client whose buffer is full (a stalled browser)
|
|
||||||
// drops this frame rather than stalling the whole room.
|
|
||||||
func (h *roomHub) broadcast(m msgWire) {
|
|
||||||
h.mu.Lock()
|
|
||||||
defer h.mu.Unlock()
|
|
||||||
for ch := range h.clients {
|
|
||||||
select {
|
|
||||||
case ch <- m:
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// add registers a new SSE client channel.
|
|
||||||
func (h *roomHub) add(ch chan msgWire) {
|
|
||||||
h.mu.Lock()
|
|
||||||
defer h.mu.Unlock()
|
|
||||||
h.clients[ch] = struct{}{}
|
|
||||||
}
|
|
||||||
|
|
||||||
// stop unsubscribes from the bus. Local delivery ends; for a persisted room the
|
|
||||||
// durable consumer's ack position stays on the server, so a later subscription
|
|
||||||
// with the same operator resumes from where it left off.
|
|
||||||
func (h *roomHub) stop() {
|
|
||||||
if h.sub != nil {
|
|
||||||
_ = h.sub.Unsubscribe()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// openStream joins the room (idempotent; fetches the room key for encrypted
|
|
||||||
// rooms), attaches an SSE client to the room's hub (creating it on first watcher),
|
|
||||||
// and returns the client's message channel plus a cleanup func. The cleanup
|
|
||||||
// detaches the client and, when it was the last watcher, tears down the room's
|
|
||||||
// single bus subscription.
|
|
||||||
func (g *gateway) openStream(roomID string) (chan msgWire, func(), error) {
|
|
||||||
if err := g.join(roomID); err != nil {
|
|
||||||
return nil, nil, err
|
|
||||||
}
|
|
||||||
g.mu.Lock()
|
|
||||||
h := g.hubs[roomID]
|
|
||||||
if h == nil {
|
|
||||||
var err error
|
|
||||||
h, err = newRoomHub(g.cli, roomID, g.endpoint)
|
|
||||||
if err != nil {
|
|
||||||
g.mu.Unlock()
|
|
||||||
return nil, nil, err
|
|
||||||
}
|
|
||||||
g.hubs[roomID] = h
|
|
||||||
}
|
|
||||||
g.mu.Unlock()
|
|
||||||
|
|
||||||
// Buffer so a brief render hitch in the browser does not drop live frames; a
|
|
||||||
// sustained stall still drops (broadcast is non-blocking) rather than wedging
|
|
||||||
// the room.
|
|
||||||
ch := make(chan msgWire, 64)
|
|
||||||
h.add(ch)
|
|
||||||
|
|
||||||
// cleanup takes g.mu before h.mu (the single, consistent lock order) so a
|
|
||||||
// concurrent openStream that re-creates the hub cannot race the teardown.
|
|
||||||
cleanup := func() {
|
|
||||||
g.mu.Lock()
|
|
||||||
defer g.mu.Unlock()
|
|
||||||
h.mu.Lock()
|
|
||||||
delete(h.clients, ch)
|
|
||||||
empty := len(h.clients) == 0
|
|
||||||
h.mu.Unlock()
|
|
||||||
if empty {
|
|
||||||
if cur := g.hubs[roomID]; cur == h {
|
|
||||||
delete(g.hubs, roomID)
|
|
||||||
h.stop()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ch, cleanup, nil
|
|
||||||
}
|
|
||||||
@@ -1,98 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/base64"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"os/exec"
|
|
||||||
|
|
||||||
cs "fn-registry/functions/cybersecurity"
|
|
||||||
)
|
|
||||||
|
|
||||||
// identityJSON mirrors the on-disk / pass-stored identity format shared across
|
|
||||||
// the unibus tooling: the four keypair halves, each std-base64. It is the SAME
|
|
||||||
// shape the bus client persists (pkg/client identity file) and the operator's
|
|
||||||
// `pass` entry unibus/operator-identity, so the web gateway loads the operator's
|
|
||||||
// identity without a divergent serialization. Kept in lockstep with
|
|
||||||
// unibus_admin/internal/admin/identity.go.
|
|
||||||
type identityJSON struct {
|
|
||||||
SignPub string `json:"sign_pub"`
|
|
||||||
SignPriv string `json:"sign_priv"`
|
|
||||||
KexPub string `json:"kex_pub"`
|
|
||||||
KexPriv string `json:"kex_priv"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// decodeIdentity turns the JSON identity bytes into a cs.Identity. The private
|
|
||||||
// halves stay only in memory; this never writes them anywhere.
|
|
||||||
func decodeIdentity(raw []byte) (cs.Identity, error) {
|
|
||||||
var f identityJSON
|
|
||||||
if err := json.Unmarshal(raw, &f); err != nil {
|
|
||||||
return cs.Identity{}, fmt.Errorf("webgw: parse identity json: %w", err)
|
|
||||||
}
|
|
||||||
dec := base64.StdEncoding.DecodeString
|
|
||||||
signPub, err := dec(f.SignPub)
|
|
||||||
if err != nil {
|
|
||||||
return cs.Identity{}, fmt.Errorf("webgw: decode sign_pub: %w", err)
|
|
||||||
}
|
|
||||||
signPriv, err := dec(f.SignPriv)
|
|
||||||
if err != nil {
|
|
||||||
return cs.Identity{}, fmt.Errorf("webgw: decode sign_priv: %w", err)
|
|
||||||
}
|
|
||||||
kexPub, err := dec(f.KexPub)
|
|
||||||
if err != nil {
|
|
||||||
return cs.Identity{}, fmt.Errorf("webgw: decode kex_pub: %w", err)
|
|
||||||
}
|
|
||||||
kexPriv, err := dec(f.KexPriv)
|
|
||||||
if err != nil {
|
|
||||||
return cs.Identity{}, fmt.Errorf("webgw: decode kex_priv: %w", err)
|
|
||||||
}
|
|
||||||
if len(signPub) != 32 || len(signPriv) != 64 || len(kexPub) != 32 || len(kexPriv) != 32 {
|
|
||||||
return cs.Identity{}, fmt.Errorf("webgw: identity has wrong key sizes (sign_pub=%d sign_priv=%d kex_pub=%d kex_priv=%d)",
|
|
||||||
len(signPub), len(signPriv), len(kexPub), len(kexPriv))
|
|
||||||
}
|
|
||||||
return cs.Identity{SignPub: signPub, SignPriv: signPriv, KexPub: kexPub, KexPriv: kexPriv}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// loadIdentityFromFile reads a 0600 identity JSON file (the same format the bus
|
|
||||||
// client writes) and decodes it. Used on a deploy host where `pass` is not
|
|
||||||
// available and the operator identity is delivered as a protected file.
|
|
||||||
func loadIdentityFromFile(path string) (cs.Identity, error) {
|
|
||||||
raw, err := os.ReadFile(path)
|
|
||||||
if err != nil {
|
|
||||||
return cs.Identity{}, fmt.Errorf("webgw: read identity file %q: %w", path, err)
|
|
||||||
}
|
|
||||||
return decodeIdentity(raw)
|
|
||||||
}
|
|
||||||
|
|
||||||
// loadIdentityFromPass shells out to `pass show <entry>` and decodes the JSON
|
|
||||||
// identity it returns. The secret is held only in memory; this process never
|
|
||||||
// writes it to disk or argv. Used in local operator workflows where the GNU
|
|
||||||
// password store holds unibus/operator-identity.
|
|
||||||
func loadIdentityFromPass(entry string) (cs.Identity, error) {
|
|
||||||
out, err := exec.Command("pass", "show", entry).Output()
|
|
||||||
if err != nil {
|
|
||||||
return cs.Identity{}, fmt.Errorf("webgw: pass show %q: %w", entry, err)
|
|
||||||
}
|
|
||||||
return decodeIdentity(out)
|
|
||||||
}
|
|
||||||
|
|
||||||
// loadPassValue returns the first line of a `pass show <entry>` for non-identity
|
|
||||||
// secrets (e.g. the unlock passphrase). Empty entry yields an empty string and
|
|
||||||
// no error, so callers can treat "no pass entry configured" as "not set".
|
|
||||||
func loadPassValue(entry string) (string, error) {
|
|
||||||
if entry == "" {
|
|
||||||
return "", nil
|
|
||||||
}
|
|
||||||
out, err := exec.Command("pass", "show", entry).Output()
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("webgw: pass show %q: %w", entry, err)
|
|
||||||
}
|
|
||||||
s := string(out)
|
|
||||||
for i := 0; i < len(s); i++ {
|
|
||||||
if s[i] == '\n' || s[i] == '\r' {
|
|
||||||
return s[:i], nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return s, nil
|
|
||||||
}
|
|
||||||
@@ -1,199 +0,0 @@
|
|||||||
// Command webgw is the web gateway for the unibus chat SPA. It is a single Go
|
|
||||||
// binary that holds the operator's bus identity, connects to the bus as a real
|
|
||||||
// authenticated peer (pkg/client), and exposes a small REST + SSE API the
|
|
||||||
// browser consumes. The browser never signs, never speaks NATS, and never sees a
|
|
||||||
// private key: it authenticates to the gateway with a passphrase and thereafter
|
|
||||||
// holds only an opaque session cookie.
|
|
||||||
//
|
|
||||||
// TRUST MODEL (MVP, single operator): room content stays end-to-end encrypted on
|
|
||||||
// the bus. The gateway can read plaintext because it acts AS the operator's
|
|
||||||
// client — a legitimate member of each room holding the room key. Decryption
|
|
||||||
// happens server-side in this process; cleartext then crosses an authenticated
|
|
||||||
// (loopback or TLS-fronted) SSE channel to the browser. The wallet phase (issue:
|
|
||||||
// per-browser WebCrypto identity) can move decryption into the browser; see the
|
|
||||||
// report for the FASE 2 plan.
|
|
||||||
//
|
|
||||||
// # local dev against a loopback membershipd (plaintext), operator from pass:
|
|
||||||
// webgw --identity-pass unibus/operator-identity \
|
|
||||||
// --ctrl-url http://127.0.0.1:8470 --nats-url nats://127.0.0.1:4250
|
|
||||||
//
|
|
||||||
// # secured cluster (TLS + nkey on both planes), identity from a 0600 file:
|
|
||||||
// webgw --ca ca.crt --identity-file operator.id \
|
|
||||||
// --ctrl-url https://node-a:8470 --nats-url nats://node-a:4250 \
|
|
||||||
// --ctrl-urls https://node-b:8470,https://node-c:8470 \
|
|
||||||
// --nats-urls nats://node-b:4250,nats://node-c:4250
|
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"flag"
|
|
||||||
"log"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
"os/signal"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"syscall"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
cs "fn-registry/functions/cybersecurity"
|
|
||||||
)
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
var (
|
|
||||||
bind = flag.String("bind", "127.0.0.1", "interface to bind the gateway HTTP server to (loopback by default)")
|
|
||||||
port = flag.String("port", "8481", "gateway HTTP port")
|
|
||||||
ctrlURL = flag.String("ctrl-url", "http://127.0.0.1:8470", "primary unibus control-plane base URL")
|
|
||||||
ctrlURLs = flag.String("ctrl-urls", "", "comma-separated ADDITIONAL control-plane base URLs (cluster failover)")
|
|
||||||
natsURL = flag.String("nats-url", "nats://127.0.0.1:4250", "primary NATS URL")
|
|
||||||
natsURLs = flag.String("nats-urls", "", "comma-separated ADDITIONAL NATS seed URLs (cluster failover)")
|
|
||||||
caPath = flag.String("ca", "", "bus CA cert path; set to talk TLS+nkey to a secured bus (empty = plaintext dev)")
|
|
||||||
identityFile = flag.String("identity-file", "", "path to the operator identity JSON file (0600). Mutually exclusive with --identity-pass")
|
|
||||||
identityPass = flag.String("identity-pass", "", "pass(1) entry holding the operator identity JSON, e.g. unibus/operator-identity")
|
|
||||||
unlockPass = flag.String("unlock-pass", "", "literal passphrase the browser must send to unlock a LEGACY operator session (dev). Prefer --unlock-pass-entry")
|
|
||||||
unlockEntry = flag.String("unlock-pass-entry", "unibus/admin-panel-password", "pass(1) entry holding the operator unlock passphrase (used when --unlock-pass is empty)")
|
|
||||||
registerURL = flag.String("register-url", "", "bus POST /register URL for wallet onboarding. Empty = derive from --ctrl-url (<ctrl-url>/register)")
|
|
||||||
mockTokens = flag.String("mock-tokens", "", "DEV ONLY: comma-separated one-shot invite tokens for local testing, 'token=handle:role'. Empty in production (real invites come from the bus). Example: demo=demo:member")
|
|
||||||
webDir = flag.String("web-dir", "", "OPTIONAL path to the built SPA (web/dist) to serve. Empty = API only (use vite dev server)")
|
|
||||||
)
|
|
||||||
flag.Parse()
|
|
||||||
|
|
||||||
log.SetFlags(log.LstdFlags | log.Lmsgprefix)
|
|
||||||
log.SetPrefix("[webgw] ")
|
|
||||||
|
|
||||||
id, err := loadIdentity(*identityFile, *identityPass)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("%v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
unlock := *unlockPass
|
|
||||||
if unlock == "" {
|
|
||||||
unlock, err = loadPassValue(*unlockEntry)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("resolve unlock passphrase: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if unlock == "" {
|
|
||||||
log.Fatalf("an unlock passphrase is required: set --unlock-pass or a non-empty --unlock-pass-entry (default unibus/admin-panel-password)")
|
|
||||||
}
|
|
||||||
|
|
||||||
resolvedWebDir := resolveWebDir(*webDir)
|
|
||||||
|
|
||||||
// busTemplate is the connection config every bus client uses. The operator
|
|
||||||
// gateway uses it as-is; each wallet session clones it and overrides Identity
|
|
||||||
// with the logged-in user's keypair.
|
|
||||||
busTemplate := gatewayConfig{
|
|
||||||
Identity: id,
|
|
||||||
NatsURL: *natsURL,
|
|
||||||
CtrlURL: *ctrlURL,
|
|
||||||
CtrlURLs: splitCSV(*ctrlURLs),
|
|
||||||
NatsURLs: splitCSV(*natsURLs),
|
|
||||||
CAPath: *caPath,
|
|
||||||
}
|
|
||||||
|
|
||||||
gw, err := newGateway(busTemplate)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("%v", err)
|
|
||||||
}
|
|
||||||
defer gw.Close()
|
|
||||||
|
|
||||||
// Wallet onboarding backend: POST /api/register targets the bus's /register
|
|
||||||
// (added by the user-accounts work). When --register-url is empty we derive it
|
|
||||||
// from --ctrl-url; --mock-tokens supplies one-shot invites for local testing
|
|
||||||
// before that endpoint is deployed.
|
|
||||||
regURL := *registerURL
|
|
||||||
if regURL == "" {
|
|
||||||
regURL = strings.TrimRight(*ctrlURL, "/") + "/register"
|
|
||||||
}
|
|
||||||
registrar := newRegistrar(regURL, *mockTokens)
|
|
||||||
|
|
||||||
log.Printf("operator endpoint: %s", gw.endpoint)
|
|
||||||
log.Printf("control plane: %s (+%d failover)", *ctrlURL, len(splitCSV(*ctrlURLs)))
|
|
||||||
tls := "OFF (plaintext dev)"
|
|
||||||
if *caPath != "" {
|
|
||||||
tls = "ON (CA " + *caPath + ")"
|
|
||||||
}
|
|
||||||
log.Printf("bus TLS+nkey: %s", tls)
|
|
||||||
if resolvedWebDir != "" {
|
|
||||||
log.Printf("serving SPA from: %s", resolvedWebDir)
|
|
||||||
} else {
|
|
||||||
log.Printf("API only (no --web-dir): use the vite dev server with a /api+stream proxy")
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Printf("wallet register: %s (mock tokens: %d)", regURL, mockTokenCount(*mockTokens))
|
|
||||||
|
|
||||||
srv := newServer(gw, busTemplate, registrar, unlock, resolvedWebDir)
|
|
||||||
addr := *bind + ":" + *port
|
|
||||||
httpSrv := &http.Server{
|
|
||||||
Addr: addr,
|
|
||||||
Handler: srv,
|
|
||||||
// No global write timeout: SSE streams are long-lived. Header timeout still
|
|
||||||
// bounds slowloris on the request line/headers.
|
|
||||||
ReadHeaderTimeout: 10 * time.Second,
|
|
||||||
}
|
|
||||||
|
|
||||||
go func() {
|
|
||||||
log.Printf("web gateway: http://%s", addr)
|
|
||||||
if err := httpSrv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
|
||||||
log.Fatalf("http server: %v", err)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
stop := make(chan os.Signal, 1)
|
|
||||||
signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM)
|
|
||||||
<-stop
|
|
||||||
log.Printf("shutting down...")
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
_ = httpSrv.Shutdown(ctx)
|
|
||||||
log.Printf("bye")
|
|
||||||
}
|
|
||||||
|
|
||||||
// loadIdentity resolves the operator identity from exactly one of --identity-file
|
|
||||||
// or --identity-pass.
|
|
||||||
func loadIdentity(file, passEntry string) (cs.Identity, error) {
|
|
||||||
switch {
|
|
||||||
case file != "" && passEntry != "":
|
|
||||||
return cs.Identity{}, errFlag("set only one of --identity-file or --identity-pass")
|
|
||||||
case file != "":
|
|
||||||
return loadIdentityFromFile(file)
|
|
||||||
case passEntry != "":
|
|
||||||
return loadIdentityFromPass(passEntry)
|
|
||||||
default:
|
|
||||||
return cs.Identity{}, errFlag("an identity is required: pass --identity-file <path> or --identity-pass <entry>")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// resolveWebDir validates the --web-dir flag. An empty flag means API-only. A
|
|
||||||
// non-empty dir is kept only if it actually holds an index.html, so a typo logs
|
|
||||||
// "API only" rather than serving 404s.
|
|
||||||
func resolveWebDir(dir string) string {
|
|
||||||
if dir == "" {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
abs, err := filepath.Abs(dir)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("WARN --web-dir %q: %v; serving API only", dir, err)
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
if !statFile(filepath.Join(abs, "index.html")) {
|
|
||||||
log.Printf("WARN --web-dir %q has no index.html; serving API only", abs)
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return abs
|
|
||||||
}
|
|
||||||
|
|
||||||
type flagErr string
|
|
||||||
|
|
||||||
func (e flagErr) Error() string { return string(e) }
|
|
||||||
func errFlag(s string) error { return flagErr("webgw: " + s) }
|
|
||||||
|
|
||||||
func splitCSV(s string) []string {
|
|
||||||
var out []string
|
|
||||||
for _, p := range strings.Split(s, ",") {
|
|
||||||
if p = strings.TrimSpace(p); p != "" {
|
|
||||||
out = append(out, p)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
@@ -1,193 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"encoding/hex"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// registerReq is the POST /api/register body. It mirrors the bus contract exactly
|
|
||||||
// (token + the two PUBLIC key halves, each 64 hex chars). The private key never
|
|
||||||
// appears here — registration only publishes the public identity. The handle and
|
|
||||||
// role are NOT accepted from the client; they are fixed by the invite the token
|
|
||||||
// belongs to (no privilege escalation).
|
|
||||||
type registerReq struct {
|
|
||||||
Token string `json:"token"`
|
|
||||||
SignPub string `json:"sign_pub"`
|
|
||||||
KexPub string `json:"kex_pub"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// registerResp is what we return to the browser on success. The bus's /register
|
|
||||||
// (issue: user-accounts) decides handle/role from the invite; in mock mode the
|
|
||||||
// gateway echoes the configured pair so the SPA can greet the new user.
|
|
||||||
type registerResp struct {
|
|
||||||
Handle string `json:"handle"`
|
|
||||||
Role string `json:"role"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// registrar fulfils POST /api/register. It targets the bus's POST /register
|
|
||||||
// endpoint (added by the user-accounts work, bus >= 0.12.0). Until that endpoint
|
|
||||||
// is rolled out, a built-in mock validates against a configured set of one-shot
|
|
||||||
// tokens so the whole wallet flow is testable locally. Mock tokens are checked
|
|
||||||
// first; anything else is proxied to the real bus when --register-url is set.
|
|
||||||
type registrar struct {
|
|
||||||
mu sync.Mutex
|
|
||||||
|
|
||||||
registerURL string // bus POST /register; empty => mock-only
|
|
||||||
httpc *http.Client // for proxying to the bus
|
|
||||||
mockTokens map[string]*mockToken // configured one-shot invites for local testing
|
|
||||||
}
|
|
||||||
|
|
||||||
// mockToken is a local stand-in for a bus invite: a token that maps to a fixed
|
|
||||||
// handle+role and can be consumed exactly once.
|
|
||||||
type mockToken struct {
|
|
||||||
handle string
|
|
||||||
role string
|
|
||||||
used bool
|
|
||||||
}
|
|
||||||
|
|
||||||
// newRegistrar parses the --mock-tokens spec ("tok=handle:role,tok2=h2:role2")
|
|
||||||
// and configures the optional proxy target.
|
|
||||||
func newRegistrar(registerURL, mockSpec string) *registrar {
|
|
||||||
r := ®istrar{
|
|
||||||
registerURL: strings.TrimSpace(registerURL),
|
|
||||||
httpc: &http.Client{Timeout: 10 * time.Second},
|
|
||||||
mockTokens: map[string]*mockToken{},
|
|
||||||
}
|
|
||||||
for _, part := range strings.Split(mockSpec, ",") {
|
|
||||||
part = strings.TrimSpace(part)
|
|
||||||
if part == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
// tok=handle:role (role optional, defaults to member)
|
|
||||||
eq := strings.IndexByte(part, '=')
|
|
||||||
if eq < 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
tok := strings.TrimSpace(part[:eq])
|
|
||||||
hr := strings.TrimSpace(part[eq+1:])
|
|
||||||
handle, role := hr, "member"
|
|
||||||
if c := strings.IndexByte(hr, ':'); c >= 0 {
|
|
||||||
handle, role = strings.TrimSpace(hr[:c]), strings.TrimSpace(hr[c+1:])
|
|
||||||
}
|
|
||||||
if tok != "" && handle != "" {
|
|
||||||
r.mockTokens[tok] = &mockToken{handle: handle, role: role}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return r
|
|
||||||
}
|
|
||||||
|
|
||||||
// mockTokenCount counts configured mock tokens in a --mock-tokens spec (for the
|
|
||||||
// startup log line).
|
|
||||||
func mockTokenCount(spec string) int {
|
|
||||||
n := 0
|
|
||||||
for _, part := range strings.Split(spec, ",") {
|
|
||||||
if p := strings.TrimSpace(part); p != "" && strings.ContainsRune(p, '=') {
|
|
||||||
n++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return n
|
|
||||||
}
|
|
||||||
|
|
||||||
// validHexKey reports whether s is exactly 64 lowercase/uppercase hex chars (a
|
|
||||||
// 32-byte key). Both sign_pub and kex_pub are 32-byte keys.
|
|
||||||
func validHexKey(s string) bool {
|
|
||||||
if len(s) != 64 {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
_, err := hex.DecodeString(s)
|
|
||||||
return err == nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleRegister validates the keys and consumes the token. Order of resolution:
|
|
||||||
// 1. strict validation of the public keys (defends both mock and proxy paths);
|
|
||||||
// 2. mock token (one-shot) if configured;
|
|
||||||
// 3. proxy to the bus /register if --register-url is set;
|
|
||||||
// 4. otherwise reject with a clear error.
|
|
||||||
func (s *server) handleRegister(w http.ResponseWriter, r *http.Request) {
|
|
||||||
var req registerReq
|
|
||||||
if !decode(w, r, &req) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
req.Token = strings.TrimSpace(req.Token)
|
|
||||||
if req.Token == "" {
|
|
||||||
writeErr(w, http.StatusBadRequest, "token required")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if !validHexKey(req.SignPub) {
|
|
||||||
writeErr(w, http.StatusBadRequest, "sign_pub must be 64 hex chars (32 bytes)")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if !validHexKey(req.KexPub) {
|
|
||||||
writeErr(w, http.StatusBadRequest, "kex_pub must be 64 hex chars (32 bytes)")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
reg := s.registrar
|
|
||||||
|
|
||||||
// 2) mock one-shot token.
|
|
||||||
reg.mu.Lock()
|
|
||||||
mt, isMock := reg.mockTokens[req.Token]
|
|
||||||
if isMock {
|
|
||||||
if mt.used {
|
|
||||||
reg.mu.Unlock()
|
|
||||||
writeErr(w, http.StatusConflict, "invite already used")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
mt.used = true
|
|
||||||
handle, role := mt.handle, mt.role
|
|
||||||
reg.mu.Unlock()
|
|
||||||
writeJSON(w, http.StatusCreated, registerResp{Handle: handle, Role: role})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
reg.mu.Unlock()
|
|
||||||
|
|
||||||
// 3) proxy to the real bus /register when configured.
|
|
||||||
if reg.registerURL != "" {
|
|
||||||
s.proxyRegister(w, req)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4) no mock match, no proxy target.
|
|
||||||
writeErr(w, http.StatusBadRequest, "invalid or unknown token (and no bus /register configured)")
|
|
||||||
}
|
|
||||||
|
|
||||||
// proxyRegister forwards the registration to the bus's POST /register. The bus
|
|
||||||
// validates the invite (existence, not-used, not-expired) and adds the public
|
|
||||||
// identity to the allowlist with the invite's handle+role. This is unsigned by
|
|
||||||
// design: the TOKEN authorizes the call, not an admin signature.
|
|
||||||
func (s *server) proxyRegister(w http.ResponseWriter, req registerReq) {
|
|
||||||
body, _ := json.Marshal(req)
|
|
||||||
resp, err := s.registrar.httpc.Post(
|
|
||||||
s.registrar.registerURL,
|
|
||||||
"application/json",
|
|
||||||
bytes.NewReader(body),
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
writeErr(w, http.StatusBadGateway, "bus register unreachable: "+err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
|
||||||
|
|
||||||
// On success, try to pass through the bus's handle/role if it returned them;
|
|
||||||
// otherwise a bare 201 is still success.
|
|
||||||
if resp.StatusCode == http.StatusCreated || resp.StatusCode == http.StatusOK {
|
|
||||||
var rr registerResp
|
|
||||||
_ = json.Unmarshal(raw, &rr)
|
|
||||||
writeJSON(w, http.StatusCreated, rr)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// Forward the bus's error verbatim where possible.
|
|
||||||
msg := strings.TrimSpace(string(raw))
|
|
||||||
if msg == "" {
|
|
||||||
msg = fmt.Sprintf("bus register failed (HTTP %d)", resp.StatusCode)
|
|
||||||
}
|
|
||||||
writeErr(w, resp.StatusCode, msg)
|
|
||||||
}
|
|
||||||
@@ -1,327 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"crypto/rand"
|
|
||||||
"crypto/subtle"
|
|
||||||
"encoding/hex"
|
|
||||||
"encoding/json"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// sessionCookie is the name of the gateway's session cookie. The browser sends
|
|
||||||
// it automatically on same-origin fetches AND on EventSource (SSE) connections —
|
|
||||||
// EventSource cannot set custom headers, so a cookie is the only way to
|
|
||||||
// authenticate the stream. It is HttpOnly so page JS can never read the token.
|
|
||||||
const sessionCookie = "unibus_session"
|
|
||||||
|
|
||||||
// server is the gateway's HTTP surface: a small REST/SSE API under /api plus an
|
|
||||||
// optional static file server for the built SPA.
|
|
||||||
//
|
|
||||||
// Two ways to get a session:
|
|
||||||
// - POST /api/session — the WALLET model. The browser hands its own bus
|
|
||||||
// identity (unlocked from its local encrypted key) and the gateway connects a
|
|
||||||
// dedicated bus client AS that user. Per-user, the primary path.
|
|
||||||
// - POST /api/login — the legacy operator passphrase. Binds the session to the
|
|
||||||
// single shared operator gateway. Kept for backward compatibility.
|
|
||||||
// - POST /api/register — the WALLET onboarding. Unauthenticated (the invite
|
|
||||||
// token authorizes), it consumes a token and publishes the new user's PUBLIC
|
|
||||||
// identity to the bus allowlist.
|
|
||||||
type server struct {
|
|
||||||
operatorGW *gateway // shared operator client (legacy passphrase login)
|
|
||||||
busTemplate gatewayConfig // bus connection config; Identity is overridden per user session
|
|
||||||
registrar *registrar // POST /api/register backend (mock + proxy)
|
|
||||||
unlock string // passphrase that unlocks an operator session (constant-time compare)
|
|
||||||
webDir string // optional path to the built SPA (web/dist); empty = API only
|
|
||||||
mux *http.ServeMux
|
|
||||||
sessions *sessionStore
|
|
||||||
}
|
|
||||||
|
|
||||||
func newServer(operatorGW *gateway, busTemplate gatewayConfig, registrar *registrar, unlock, webDir string) *server {
|
|
||||||
s := &server{
|
|
||||||
operatorGW: operatorGW,
|
|
||||||
busTemplate: busTemplate,
|
|
||||||
registrar: registrar,
|
|
||||||
unlock: unlock,
|
|
||||||
webDir: webDir,
|
|
||||||
mux: http.NewServeMux(),
|
|
||||||
sessions: newSessionStore(),
|
|
||||||
}
|
|
||||||
s.routes()
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *server) ServeHTTP(w http.ResponseWriter, r *http.Request) { s.mux.ServeHTTP(w, r) }
|
|
||||||
|
|
||||||
func (s *server) routes() {
|
|
||||||
// Liveness, unauthenticated (systemd / deploy smoke).
|
|
||||||
s.mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) {
|
|
||||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
|
||||||
})
|
|
||||||
|
|
||||||
// Unauthenticated onboarding / auth routes.
|
|
||||||
s.mux.HandleFunc("POST /api/register", s.handleRegister) // invite token authorizes
|
|
||||||
s.mux.HandleFunc("POST /api/session", s.handleSession) // wallet: per-user identity
|
|
||||||
s.mux.HandleFunc("POST /api/login", s.handleLogin) // legacy operator passphrase
|
|
||||||
|
|
||||||
// Session-gated routes.
|
|
||||||
s.mux.HandleFunc("POST /api/logout", s.auth(s.handleLogout))
|
|
||||||
s.mux.HandleFunc("GET /api/me", s.auth(s.handleMe))
|
|
||||||
s.mux.HandleFunc("GET /api/rooms", s.auth(s.handleListRooms))
|
|
||||||
s.mux.HandleFunc("POST /api/rooms", s.auth(s.handleCreateRoom))
|
|
||||||
s.mux.HandleFunc("POST /api/rooms/{id}/join", s.auth(s.handleJoin))
|
|
||||||
s.mux.HandleFunc("POST /api/rooms/{id}/send", s.auth(s.handleSend))
|
|
||||||
s.mux.HandleFunc("GET /api/rooms/{id}/stream", s.auth(s.handleStream))
|
|
||||||
|
|
||||||
// Everything else is the SPA (when --web-dir is set). Registered last.
|
|
||||||
if s.webDir != "" {
|
|
||||||
s.mux.Handle("/", s.spaHandler())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// meResp is the identity view returned by /api/session, /api/login and /api/me:
|
|
||||||
// the bus endpoint the session acts as, its signing public key, and the display
|
|
||||||
// handle.
|
|
||||||
type meResp struct {
|
|
||||||
Endpoint string `json:"endpoint"`
|
|
||||||
SignPub string `json:"sign_pub"`
|
|
||||||
Handle string `json:"handle"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- auth -----------------------------------------------------------------
|
|
||||||
|
|
||||||
// auth wraps a handler so it runs only with a valid session cookie, resolving the
|
|
||||||
// session (and thus the per-user gateway) it belongs to. A missing or unknown
|
|
||||||
// token yields 401, which the SPA treats as "show the login screen".
|
|
||||||
func (s *server) auth(next func(http.ResponseWriter, *http.Request, *session)) http.HandlerFunc {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
c, err := r.Cookie(sessionCookie)
|
|
||||||
if err != nil {
|
|
||||||
writeErr(w, http.StatusUnauthorized, "not authenticated")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
sess, ok := s.sessions.get(c.Value)
|
|
||||||
if !ok {
|
|
||||||
writeErr(w, http.StatusUnauthorized, "not authenticated")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
next(w, r, sess)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleLogin is the legacy operator passphrase login: it unlocks a session bound
|
|
||||||
// to the shared operator gateway. The wallet path (POST /api/session) is
|
|
||||||
// preferred; this remains for backward compatibility with the single-operator MVP.
|
|
||||||
func (s *server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
|
||||||
var req struct {
|
|
||||||
Passphrase string `json:"passphrase"`
|
|
||||||
}
|
|
||||||
if !decode(w, r, &req) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// Constant-time compare so a wrong passphrase cannot be timed character by
|
|
||||||
// character. An empty configured passphrase never matches.
|
|
||||||
if s.unlock == "" || subtle.ConstantTimeCompare([]byte(req.Passphrase), []byte(s.unlock)) != 1 {
|
|
||||||
writeErr(w, http.StatusUnauthorized, "wrong passphrase")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
tok := newToken()
|
|
||||||
handle := s.operatorGW.endpoint
|
|
||||||
if len(handle) > 8 {
|
|
||||||
handle = handle[:8]
|
|
||||||
}
|
|
||||||
s.sessions.put(tok, &session{gw: s.operatorGW, owned: false, handle: handle, issuedAt: time.Now()})
|
|
||||||
|
|
||||||
http.SetCookie(w, &http.Cookie{
|
|
||||||
Name: sessionCookie,
|
|
||||||
Value: tok,
|
|
||||||
Path: "/",
|
|
||||||
HttpOnly: true,
|
|
||||||
SameSite: http.SameSiteLaxMode,
|
|
||||||
})
|
|
||||||
writeJSON(w, http.StatusOK, meResp{Endpoint: s.operatorGW.endpoint, SignPub: hex.EncodeToString(s.operatorGW.id.SignPub), Handle: handle})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *server) handleLogout(w http.ResponseWriter, r *http.Request, _ *session) {
|
|
||||||
if c, err := r.Cookie(sessionCookie); err == nil {
|
|
||||||
if sess, ok := s.sessions.drop(c.Value); ok && sess.owned && sess.gw != nil {
|
|
||||||
// Per-user session: tear down its bus client so the private key and the
|
|
||||||
// NATS connection do not outlive the session.
|
|
||||||
_ = sess.gw.Close()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
http.SetCookie(w, &http.Cookie{Name: sessionCookie, Value: "", Path: "/", MaxAge: -1, HttpOnly: true})
|
|
||||||
writeJSON(w, http.StatusOK, map[string]string{"status": "logged_out"})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *server) handleMe(w http.ResponseWriter, _ *http.Request, sess *session) {
|
|
||||||
writeJSON(w, http.StatusOK, meResp{
|
|
||||||
Endpoint: sess.gw.endpoint,
|
|
||||||
SignPub: hex.EncodeToString(sess.gw.id.SignPub),
|
|
||||||
Handle: sess.handle,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- rooms ----------------------------------------------------------------
|
|
||||||
|
|
||||||
func (s *server) handleListRooms(w http.ResponseWriter, _ *http.Request, sess *session) {
|
|
||||||
rooms, err := sess.gw.listRooms()
|
|
||||||
if err != nil {
|
|
||||||
writeErr(w, http.StatusBadGateway, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeJSON(w, http.StatusOK, rooms)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *server) handleCreateRoom(w http.ResponseWriter, r *http.Request, sess *session) {
|
|
||||||
var req createRoomReq
|
|
||||||
if !decode(w, r, &req) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
rv, err := sess.gw.createRoom(req)
|
|
||||||
if err != nil {
|
|
||||||
writeErr(w, http.StatusBadGateway, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeJSON(w, http.StatusCreated, rv)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *server) handleJoin(w http.ResponseWriter, r *http.Request, sess *session) {
|
|
||||||
if err := sess.gw.join(r.PathValue("id")); err != nil {
|
|
||||||
writeErr(w, http.StatusBadGateway, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeJSON(w, http.StatusOK, map[string]string{"status": "joined"})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *server) handleSend(w http.ResponseWriter, r *http.Request, sess *session) {
|
|
||||||
var req sendReq
|
|
||||||
if !decode(w, r, &req) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(req.Body) == "" {
|
|
||||||
writeErr(w, http.StatusBadRequest, "body required")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := sess.gw.send(r.PathValue("id"), req.Body); err != nil {
|
|
||||||
writeErr(w, http.StatusBadGateway, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeJSON(w, http.StatusOK, map[string]string{"status": "sent"})
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleStream is the SSE endpoint: it joins the room, attaches to the session's
|
|
||||||
// fan-out hub, and streams each decrypted message as a `data:` event. For a
|
|
||||||
// persisted room the hub's underlying subscription delivers history first
|
|
||||||
// (scrollback) and then live messages; for an ephemeral room only live messages
|
|
||||||
// flow. The stream ends when the browser disconnects (ctx cancelled).
|
|
||||||
func (s *server) handleStream(w http.ResponseWriter, r *http.Request, sess *session) {
|
|
||||||
flusher, ok := w.(http.Flusher)
|
|
||||||
if !ok {
|
|
||||||
writeErr(w, http.StatusInternalServerError, "streaming unsupported")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
ch, cleanup, err := sess.gw.openStream(r.PathValue("id"))
|
|
||||||
if err != nil {
|
|
||||||
writeErr(w, http.StatusBadGateway, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer cleanup()
|
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "text/event-stream")
|
|
||||||
w.Header().Set("Cache-Control", "no-cache")
|
|
||||||
w.Header().Set("Connection", "keep-alive")
|
|
||||||
w.Header().Set("X-Accel-Buffering", "no") // disable proxy buffering (nginx/caddy)
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
// An initial comment opens the stream immediately so the browser's
|
|
||||||
// EventSource fires `onopen` without waiting for the first message.
|
|
||||||
_, _ = w.Write([]byte(": connected\n\n"))
|
|
||||||
flusher.Flush()
|
|
||||||
|
|
||||||
ctx := r.Context()
|
|
||||||
ping := time.NewTicker(25 * time.Second)
|
|
||||||
defer ping.Stop()
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
return
|
|
||||||
case <-ping.C:
|
|
||||||
// Comment line keeps idle proxies from closing the connection.
|
|
||||||
if _, err := w.Write([]byte(": ping\n\n")); err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
flusher.Flush()
|
|
||||||
case m := <-ch:
|
|
||||||
b, err := json.Marshal(m)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if _, err := w.Write([]byte("data: " + string(b) + "\n\n")); err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
flusher.Flush()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- SPA serving (optional) -----------------------------------------------
|
|
||||||
|
|
||||||
// spaHandler serves the built SPA from s.webDir. A request for an existing asset
|
|
||||||
// is served directly; any other path (a client-side route) falls back to
|
|
||||||
// index.html so the SPA router can take over. /api and /healthz are matched first.
|
|
||||||
func (s *server) spaHandler() http.Handler {
|
|
||||||
root := http.Dir(s.webDir)
|
|
||||||
fileServer := http.FileServer(root)
|
|
||||||
index := filepath.Join(s.webDir, "index.html")
|
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
p := strings.TrimPrefix(r.URL.Path, "/")
|
|
||||||
if p == "" {
|
|
||||||
http.ServeFile(w, r, index)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if f, err := root.Open(p); err == nil {
|
|
||||||
_ = f.Close()
|
|
||||||
fileServer.ServeHTTP(w, r)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
http.ServeFile(w, r, index) // unknown path -> SPA client-side routing
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- helpers --------------------------------------------------------------
|
|
||||||
|
|
||||||
func newToken() string {
|
|
||||||
b := make([]byte, 32)
|
|
||||||
_, _ = rand.Read(b)
|
|
||||||
return hex.EncodeToString(b)
|
|
||||||
}
|
|
||||||
|
|
||||||
func writeJSON(w http.ResponseWriter, code int, v any) {
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
w.WriteHeader(code)
|
|
||||||
_ = json.NewEncoder(w).Encode(v)
|
|
||||||
}
|
|
||||||
|
|
||||||
func writeErr(w http.ResponseWriter, code int, msg string) {
|
|
||||||
writeJSON(w, code, map[string]string{"error": msg})
|
|
||||||
}
|
|
||||||
|
|
||||||
// decode reads a JSON body into v, writing a 400 and returning false on failure.
|
|
||||||
func decode(w http.ResponseWriter, r *http.Request, v any) bool {
|
|
||||||
defer r.Body.Close()
|
|
||||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(v); err != nil {
|
|
||||||
writeErr(w, http.StatusBadRequest, "bad json: "+err.Error())
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// statFile reports whether path exists and is a regular file (used to validate
|
|
||||||
// --web-dir at startup so a typo surfaces as a clear log line, not 404s later).
|
|
||||||
func statFile(path string) bool {
|
|
||||||
fi, err := os.Stat(path)
|
|
||||||
return err == nil && !fi.IsDir()
|
|
||||||
}
|
|
||||||
@@ -1,146 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/hex"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
cs "fn-registry/functions/cybersecurity"
|
|
||||||
)
|
|
||||||
|
|
||||||
// session is one logged-in browser. In the wallet model each session carries the
|
|
||||||
// user's OWN bus identity: the browser unlocks its locally-encrypted private key
|
|
||||||
// and hands the full keypair to the gateway over TLS, and the gateway spins up a
|
|
||||||
// dedicated bus client (a *gateway) that acts AS that user. The private key lives
|
|
||||||
// only in this process's memory for the life of the session — it is never written
|
|
||||||
// to disk and is dropped when the session ends.
|
|
||||||
//
|
|
||||||
// A session may instead point at the shared operator gateway (the legacy
|
|
||||||
// passphrase login); `owned` distinguishes the two so logout only closes the bus
|
|
||||||
// client it created.
|
|
||||||
type session struct {
|
|
||||||
gw *gateway
|
|
||||||
owned bool // true => gw was built for this session and must be Closed on logout
|
|
||||||
handle string
|
|
||||||
issuedAt time.Time
|
|
||||||
}
|
|
||||||
|
|
||||||
// sessionStore is the gateway's set of live browser sessions, keyed by the opaque
|
|
||||||
// cookie token. It is independent of any single bus identity.
|
|
||||||
type sessionStore struct {
|
|
||||||
mu sync.Mutex
|
|
||||||
m map[string]*session
|
|
||||||
}
|
|
||||||
|
|
||||||
func newSessionStore() *sessionStore { return &sessionStore{m: map[string]*session{}} }
|
|
||||||
|
|
||||||
func (st *sessionStore) put(token string, s *session) {
|
|
||||||
st.mu.Lock()
|
|
||||||
st.m[token] = s
|
|
||||||
st.mu.Unlock()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (st *sessionStore) get(token string) (*session, bool) {
|
|
||||||
st.mu.Lock()
|
|
||||||
defer st.mu.Unlock()
|
|
||||||
s, ok := st.m[token]
|
|
||||||
return s, ok
|
|
||||||
}
|
|
||||||
|
|
||||||
// drop removes a session and returns it so the caller can close an owned gateway.
|
|
||||||
func (st *sessionStore) drop(token string) (*session, bool) {
|
|
||||||
st.mu.Lock()
|
|
||||||
defer st.mu.Unlock()
|
|
||||||
s, ok := st.m[token]
|
|
||||||
if ok {
|
|
||||||
delete(st.m, token)
|
|
||||||
}
|
|
||||||
return s, ok
|
|
||||||
}
|
|
||||||
|
|
||||||
// closeAll closes every owned per-user gateway (used at shutdown). The shared
|
|
||||||
// operator gateway is owned by main and closed separately.
|
|
||||||
func (st *sessionStore) closeAll() {
|
|
||||||
st.mu.Lock()
|
|
||||||
defer st.mu.Unlock()
|
|
||||||
for tok, s := range st.m {
|
|
||||||
if s.owned && s.gw != nil {
|
|
||||||
_ = s.gw.Close()
|
|
||||||
}
|
|
||||||
delete(st.m, tok)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// identityFromHex builds a cs.Identity from the four hex halves the browser sends
|
|
||||||
// on POST /api/session. It enforces the exact key sizes (sign_pub 32, sign_priv
|
|
||||||
// 64, kex_pub 32, kex_priv 32) so a malformed body cannot produce a half-built
|
|
||||||
// identity that fails opaquely deep in the bus client.
|
|
||||||
func identityFromHex(signPub, signPriv, kexPub, kexPriv string) (cs.Identity, error) {
|
|
||||||
sp, err := hex.DecodeString(signPub)
|
|
||||||
if err != nil {
|
|
||||||
return cs.Identity{}, fmt.Errorf("sign_pub: %w", err)
|
|
||||||
}
|
|
||||||
spriv, err := hex.DecodeString(signPriv)
|
|
||||||
if err != nil {
|
|
||||||
return cs.Identity{}, fmt.Errorf("sign_priv: %w", err)
|
|
||||||
}
|
|
||||||
kp, err := hex.DecodeString(kexPub)
|
|
||||||
if err != nil {
|
|
||||||
return cs.Identity{}, fmt.Errorf("kex_pub: %w", err)
|
|
||||||
}
|
|
||||||
kpriv, err := hex.DecodeString(kexPriv)
|
|
||||||
if err != nil {
|
|
||||||
return cs.Identity{}, fmt.Errorf("kex_priv: %w", err)
|
|
||||||
}
|
|
||||||
if len(sp) != 32 || len(spriv) != 64 || len(kp) != 32 || len(kpriv) != 32 {
|
|
||||||
return cs.Identity{}, fmt.Errorf("wrong key sizes (sign_pub=%d sign_priv=%d kex_pub=%d kex_priv=%d; want 32/64/32/32)",
|
|
||||||
len(sp), len(spriv), len(kp), len(kpriv))
|
|
||||||
}
|
|
||||||
return cs.Identity{SignPub: sp, SignPriv: spriv, KexPub: kp, KexPriv: kpriv}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// sessionReq is the POST /api/session body: the user's full wallet identity (hex)
|
|
||||||
// plus a display handle. The private halves arrive only over TLS and are held in
|
|
||||||
// memory for the session; they are never persisted server-side.
|
|
||||||
type sessionReq struct {
|
|
||||||
Handle string `json:"handle"`
|
|
||||||
SignPub string `json:"sign_pub"`
|
|
||||||
SignPriv string `json:"sign_priv"`
|
|
||||||
KexPub string `json:"kex_pub"`
|
|
||||||
KexPriv string `json:"kex_priv"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleSession opens a per-user session. It builds the user's bus identity from
|
|
||||||
// the posted keypair, connects a dedicated bus client as that user, and issues a
|
|
||||||
// session cookie bound to it. This is the wallet-model replacement for the
|
|
||||||
// operator passphrase login.
|
|
||||||
func (s *server) handleSession(w http.ResponseWriter, r *http.Request) {
|
|
||||||
var req sessionReq
|
|
||||||
if !decode(w, r, &req) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
id, err := identityFromHex(req.SignPub, req.SignPriv, req.KexPub, req.KexPriv)
|
|
||||||
if err != nil {
|
|
||||||
writeErr(w, http.StatusBadRequest, "bad identity: "+err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
cfg := s.busTemplate
|
|
||||||
cfg.Identity = id
|
|
||||||
gw, err := newGateway(cfg)
|
|
||||||
if err != nil {
|
|
||||||
writeErr(w, http.StatusBadGateway, "connect bus as user: "+err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
tok := newToken()
|
|
||||||
s.sessions.put(tok, &session{gw: gw, owned: true, handle: req.Handle, issuedAt: time.Now()})
|
|
||||||
http.SetCookie(w, &http.Cookie{
|
|
||||||
Name: sessionCookie,
|
|
||||||
Value: tok,
|
|
||||||
Path: "/",
|
|
||||||
HttpOnly: true,
|
|
||||||
SameSite: http.SameSiteLaxMode,
|
|
||||||
})
|
|
||||||
writeJSON(w, http.StatusOK, meResp{Endpoint: gw.endpoint, SignPub: req.SignPub, Handle: req.Handle})
|
|
||||||
}
|
|
||||||
@@ -1,114 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"net/http/httptest"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
// fixed wallet vector derived in the browser from the mnemonic
|
|
||||||
// "legal winner thank year wave sausage worth useful legal winner thank yellow"
|
|
||||||
// using the unibus-sign-v1 / unibus-kex-v1 HKDF scheme. Used to assert the Go
|
|
||||||
// side accepts the browser-derived key sizes.
|
|
||||||
const (
|
|
||||||
fixSignPub = "3d594317212e53a3685b305539f6789eb8c538579e350ca795278b180ebb53db"
|
|
||||||
fixSignPriv = "94485d66ac958e23546be2e3b7575a47e1264bdf082e09abb7ad02ab32fcd55e3d594317212e53a3685b305539f6789eb8c538579e350ca795278b180ebb53db"
|
|
||||||
fixKexPub = "f3561ca116e4444b8880b8c0a35f2c9e85804d8628006facd84b1a6146208257"
|
|
||||||
fixKexPriv = "f6ffdf15e5ee2af0494897ff43e61a06d632af425a0372cb53a7c3e0f84c2bb2"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestIdentityFromHex(t *testing.T) {
|
|
||||||
id, err := identityFromHex(fixSignPub, fixSignPriv, fixKexPub, fixKexPriv)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("identityFromHex valid vector: %v", err)
|
|
||||||
}
|
|
||||||
if len(id.SignPub) != 32 || len(id.SignPriv) != 64 || len(id.KexPub) != 32 || len(id.KexPriv) != 32 {
|
|
||||||
t.Fatalf("wrong sizes: %d/%d/%d/%d", len(id.SignPub), len(id.SignPriv), len(id.KexPub), len(id.KexPriv))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wrong sign_priv size (32 instead of 64) must be rejected.
|
|
||||||
if _, err := identityFromHex(fixSignPub, fixSignPub, fixKexPub, fixKexPriv); err == nil {
|
|
||||||
t.Fatalf("expected error for short sign_priv")
|
|
||||||
}
|
|
||||||
// Non-hex must be rejected.
|
|
||||||
if _, err := identityFromHex("zz", fixSignPriv, fixKexPub, fixKexPriv); err == nil {
|
|
||||||
t.Fatalf("expected error for non-hex sign_pub")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestValidHexKey(t *testing.T) {
|
|
||||||
if !validHexKey(fixSignPub) {
|
|
||||||
t.Fatalf("fixSignPub should be a valid 32-byte hex key")
|
|
||||||
}
|
|
||||||
if validHexKey("abcd") {
|
|
||||||
t.Fatalf("short key should be invalid")
|
|
||||||
}
|
|
||||||
if validHexKey(strings.Repeat("z", 64)) {
|
|
||||||
t.Fatalf("non-hex key should be invalid")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNewRegistrarParsesMockTokens(t *testing.T) {
|
|
||||||
r := newRegistrar("", "demo=demo:member, bob=bob, alice=alice:admin")
|
|
||||||
if len(r.mockTokens) != 3 {
|
|
||||||
t.Fatalf("want 3 mock tokens, got %d", len(r.mockTokens))
|
|
||||||
}
|
|
||||||
if r.mockTokens["demo"].role != "member" || r.mockTokens["demo"].handle != "demo" {
|
|
||||||
t.Fatalf("demo token parsed wrong: %+v", r.mockTokens["demo"])
|
|
||||||
}
|
|
||||||
if r.mockTokens["bob"].role != "member" {
|
|
||||||
t.Fatalf("bob should default to role member, got %q", r.mockTokens["bob"].role)
|
|
||||||
}
|
|
||||||
if r.mockTokens["alice"].role != "admin" {
|
|
||||||
t.Fatalf("alice should be admin, got %q", r.mockTokens["alice"].role)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// post builds a server with only a registrar (the register path does not touch a
|
|
||||||
// gateway) and runs one POST /api/register, returning status + decoded body.
|
|
||||||
func postRegister(t *testing.T, s *server, body string) (int, map[string]string) {
|
|
||||||
t.Helper()
|
|
||||||
req := httptest.NewRequest("POST", "/api/register", strings.NewReader(body))
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
s.handleRegister(w, req)
|
|
||||||
var m map[string]string
|
|
||||||
_ = json.Unmarshal(w.Body.Bytes(), &m)
|
|
||||||
return w.Code, m
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestHandleRegisterMockSingleUse(t *testing.T) {
|
|
||||||
s := &server{registrar: newRegistrar("", "demo=demo:member")}
|
|
||||||
|
|
||||||
// 1) valid token + valid keys => 201 with the invite's handle/role.
|
|
||||||
code, body := postRegister(t, s, `{"token":"demo","sign_pub":"`+fixSignPub+`","kex_pub":"`+fixKexPub+`"}`)
|
|
||||||
if code != 201 {
|
|
||||||
t.Fatalf("first register: want 201, got %d (%v)", code, body)
|
|
||||||
}
|
|
||||||
if body["handle"] != "demo" || body["role"] != "member" {
|
|
||||||
t.Fatalf("first register body: %v", body)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2) same token again => 409 (single-use consumed).
|
|
||||||
code, _ = postRegister(t, s, `{"token":"demo","sign_pub":"`+fixSignPub+`","kex_pub":"`+fixKexPub+`"}`)
|
|
||||||
if code != 409 {
|
|
||||||
t.Fatalf("reused token: want 409, got %d", code)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestHandleRegisterValidation(t *testing.T) {
|
|
||||||
s := &server{registrar: newRegistrar("", "demo=demo:member")}
|
|
||||||
|
|
||||||
// bad sign_pub (too short) => 400
|
|
||||||
if code, _ := postRegister(t, s, `{"token":"demo","sign_pub":"abcd","kex_pub":"`+fixKexPub+`"}`); code != 400 {
|
|
||||||
t.Fatalf("short sign_pub: want 400, got %d", code)
|
|
||||||
}
|
|
||||||
// missing token => 400
|
|
||||||
if code, _ := postRegister(t, s, `{"sign_pub":"`+fixSignPub+`","kex_pub":"`+fixKexPub+`"}`); code != 400 {
|
|
||||||
t.Fatalf("missing token: want 400, got %d", code)
|
|
||||||
}
|
|
||||||
// unknown token with no mock match and no register-url => 400
|
|
||||||
if code, _ := postRegister(t, s, `{"token":"nope","sign_pub":"`+fixSignPub+`","kex_pub":"`+fixKexPub+`"}`); code != 400 {
|
|
||||||
t.Fatalf("unknown token: want 400, got %d", code)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
module github.com/enmanuel/uniweb
|
|
||||||
|
|
||||||
go 1.26.4
|
|
||||||
|
|
||||||
replace fn-registry => ../../../../
|
|
||||||
|
|
||||||
replace github.com/enmanuel/unibus => ../unibus
|
|
||||||
|
|
||||||
require (
|
|
||||||
fn-registry v0.0.0-00010101000000-000000000000
|
|
||||||
github.com/enmanuel/unibus v0.0.0-00010101000000-000000000000
|
|
||||||
github.com/oklog/ulid/v2 v2.1.0
|
|
||||||
)
|
|
||||||
|
|
||||||
require (
|
|
||||||
github.com/antithesishq/antithesis-sdk-go v0.6.0-default-no-op // indirect
|
|
||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
|
||||||
github.com/google/go-tpm v0.9.8 // indirect
|
|
||||||
github.com/google/uuid v1.6.0 // indirect
|
|
||||||
github.com/klauspost/compress v1.18.4 // indirect
|
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
|
||||||
github.com/minio/highwayhash v1.0.4-0.20251030100505-070ab1a87a76 // indirect
|
|
||||||
github.com/nats-io/jwt/v2 v2.8.1 // indirect
|
|
||||||
github.com/nats-io/nats-server/v2 v2.11.15 // indirect
|
|
||||||
github.com/nats-io/nats.go v1.49.0 // indirect
|
|
||||||
github.com/nats-io/nkeys v0.4.15 // indirect
|
|
||||||
github.com/nats-io/nuid v1.0.1 // indirect
|
|
||||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
|
||||||
golang.org/x/crypto v0.51.0 // indirect
|
|
||||||
golang.org/x/sys v0.44.0 // indirect
|
|
||||||
golang.org/x/time v0.15.0 // indirect
|
|
||||||
modernc.org/libc v1.70.0 // indirect
|
|
||||||
modernc.org/mathutil v1.7.1 // indirect
|
|
||||||
modernc.org/memory v1.11.0 // indirect
|
|
||||||
modernc.org/sqlite v1.47.0 // indirect
|
|
||||||
)
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
github.com/antithesishq/antithesis-sdk-go v0.6.0-default-no-op h1:kpBdlEPbRvff0mDD1gk7o9BhI16b9p5yYAXRlidpqJE=
|
|
||||||
github.com/antithesishq/antithesis-sdk-go v0.6.0-default-no-op/go.mod h1:IUpT2DPAKh6i/YhSbt6Gl3v2yvUZjmKncl7U91fup7E=
|
|
||||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
|
||||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
|
||||||
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
|
|
||||||
github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
|
|
||||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
|
||||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
|
||||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
|
||||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
|
||||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
|
||||||
github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c=
|
|
||||||
github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
|
|
||||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
|
||||||
github.com/minio/highwayhash v1.0.4-0.20251030100505-070ab1a87a76 h1:KGuD/pM2JpL9FAYvBrnBBeENKZNh6eNtjqytV6TYjnk=
|
|
||||||
github.com/minio/highwayhash v1.0.4-0.20251030100505-070ab1a87a76/go.mod h1:GGYsuwP/fPD6Y9hMiXuapVvlIUEhFhMTh0rxU3ik1LQ=
|
|
||||||
github.com/nats-io/jwt/v2 v2.8.1 h1:V0xpGuD/N8Mi+fQNDynXohVvp7ZztevW5io8CUWlPmU=
|
|
||||||
github.com/nats-io/jwt/v2 v2.8.1/go.mod h1:nWnOEEiVMiKHQpnAy4eXlizVEtSfzacZ1Q43LIRavZg=
|
|
||||||
github.com/nats-io/nats-server/v2 v2.11.15 h1:StSf9TINInaZtr4oww2+kXmfwa9SkN//g/LwS19/UJ0=
|
|
||||||
github.com/nats-io/nats-server/v2 v2.11.15/go.mod h1:zwhv8Y0PE3KHyKgznJc/9Xoai638SaJd83zzJ5GJn74=
|
|
||||||
github.com/nats-io/nats.go v1.49.0 h1:yh/WvY59gXqYpgl33ZI+XoVPKyut/IcEaqtsiuTJpoE=
|
|
||||||
github.com/nats-io/nats.go v1.49.0/go.mod h1:fDCn3mN5cY8HooHwE2ukiLb4p4G4ImmzvXyJt+tGwdw=
|
|
||||||
github.com/nats-io/nkeys v0.4.15 h1:JACV5jRVO9V856KOapQ7x+EY8Jo3qw1vJt/9Jpwzkk4=
|
|
||||||
github.com/nats-io/nkeys v0.4.15/go.mod h1:CpMchTXC9fxA5zrMo4KpySxNjiDVvr8ANOSZdiNfUrs=
|
|
||||||
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
|
|
||||||
github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
|
|
||||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
|
||||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
|
||||||
github.com/oklog/ulid/v2 v2.1.0 h1:+9lhoxAP56we25tyYETBBY1YLA2SaoLvUFgrP2miPJU=
|
|
||||||
github.com/oklog/ulid/v2 v2.1.0/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ=
|
|
||||||
github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o=
|
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
|
||||||
golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
|
|
||||||
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
|
|
||||||
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
|
|
||||||
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
|
|
||||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
|
||||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|
||||||
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
|
||||||
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
|
|
||||||
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
|
||||||
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
|
||||||
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
|
||||||
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
|
|
||||||
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
|
|
||||||
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
|
|
||||||
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
|
||||||
modernc.org/ccgo/v4 v4.32.0 h1:hjG66bI/kqIPX1b2yT6fr/jt+QedtP2fqojG2VrFuVw=
|
|
||||||
modernc.org/ccgo/v4 v4.32.0/go.mod h1:6F08EBCx5uQc38kMGl+0Nm0oWczoo1c7cgpzEry7Uc0=
|
|
||||||
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
|
|
||||||
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
|
|
||||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
|
||||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
|
||||||
modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
|
|
||||||
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
|
||||||
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
|
||||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
|
||||||
modernc.org/libc v1.70.0 h1:U58NawXqXbgpZ/dcdS9kMshu08aiA6b7gusEusqzNkw=
|
|
||||||
modernc.org/libc v1.70.0/go.mod h1:OVmxFGP1CI/Z4L3E0Q3Mf1PDE0BucwMkcXjjLntvHJo=
|
|
||||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
|
||||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
|
||||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
|
||||||
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
|
||||||
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
|
||||||
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
|
||||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
|
||||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
|
||||||
modernc.org/sqlite v1.47.0 h1:R1XyaNpoW4Et9yly+I2EeX7pBza/w+pmYee/0HJDyKk=
|
|
||||||
modernc.org/sqlite v1.47.0/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig=
|
|
||||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
|
||||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
|
||||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
|
||||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
|
||||||
+7
-14
@@ -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.
|
||||||
|
|||||||
@@ -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,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
@@ -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");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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.",
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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
@@ -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();
|
|
||||||
}
|
|
||||||
@@ -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>> {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
Vendored
+12
@@ -0,0 +1,12 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
// Build-time configuration for the bus endpoints. Both are optional; busService
|
||||||
|
// falls back to a cluster node when unset.
|
||||||
|
interface ImportMetaEnv {
|
||||||
|
readonly VITE_BUS_HTTP?: string;
|
||||||
|
readonly VITE_BUS_WS?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImportMeta {
|
||||||
|
readonly env: ImportMetaEnv;
|
||||||
|
}
|
||||||
+13
-18
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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" },
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user