feat: initial scaffold of uniweb — unibus web frontend (SPA + gateway)

Extracted from unibus v0.13.0: the chat SPA (web/, React+Mantine, per-user
BIP39 wallet) and the web gateway (cmd/webgw, REST+SSE) that acts as a bus
peer for the browser. Consumes unibus as a Go module via replace => ../unibus,
keeping its own replace fn-registry for the cybersecurity primitives.

go build/vet/test and pnpm build green in the new location.
This commit is contained in:
agent
2026-06-13 21:23:10 +02:00
commit e8e37d77fe
42 changed files with 5439 additions and 0 deletions
+246
View File
@@ -0,0 +1,246 @@
package main
import (
"encoding/hex"
"fmt"
"strings"
"sync"
cs "fn-registry/functions/cybersecurity"
"github.com/enmanuel/unibus/pkg/busauth"
"github.com/enmanuel/unibus/pkg/client"
"github.com/enmanuel/unibus/pkg/frame"
"github.com/enmanuel/unibus/pkg/room"
)
// gateway is the live web gateway: it owns the operator's identity and a single
// connected unibus client, and turns the bus's crypto-bearing API into the plain
// REST/SSE surface the browser consumes. The browser never signs, never speaks
// NATS, and never sees a private key — the gateway is the legitimate room member
// that seals/opens payloads on the browser's behalf.
//
// TRUST MODEL: content stays end-to-end encrypted on the wire. The gateway can
// read plaintext because it acts AS the operator's client — a real member of
// each room, holding the room key K like any peer. It is the same trust a native
// desktop client has. In the wallet phase (per-browser WebCrypto identity) the
// decryption can move into the browser; today, for the single-operator MVP, the
// gateway decrypts server-side and pushes cleartext over a loopback/authenticated
// SSE channel.
type gateway struct {
id cs.Identity
endpoint string
cli *client.Client
refreshACL bool // call RefreshSession after a membership change (needed under a per-subject ACL bus)
mu sync.Mutex
hubs map[string]*roomHub // roomID -> live fan-out of decrypted frames to SSE clients
}
// gatewayConfig wires a live gateway.
type gatewayConfig struct {
Identity cs.Identity
NatsURL string
CtrlURL string
CtrlURLs []string
NatsURLs []string
CAPath string // bus CA; empty => plaintext dev connection (matches a loopback membershipd)
}
// newGateway connects the unibus client with the operator identity following the
// same posture seam every peer uses: a non-empty CA path means TLS + nkey, empty
// means plaintext dev. When a CA is configured the bus is assumed to enforce a
// per-subject ACL, so membership changes trigger a session refresh.
func newGateway(cfg gatewayConfig) (*gateway, error) {
opts := client.Options{
CtrlURLs: cfg.CtrlURLs,
NatsServers: cfg.NatsURLs,
}
if cfg.CAPath != "" {
tlsCfg, err := busauth.LoadCATLSConfig(cfg.CAPath)
if err != nil {
return nil, fmt.Errorf("webgw: load bus CA %q: %w", cfg.CAPath, err)
}
opts.UseNkey = true
opts.TLS = tlsCfg
opts.CtrlTLS = tlsCfg
}
cli, err := client.NewWithOptions(cfg.NatsURL, cfg.CtrlURL, cfg.Identity, opts)
if err != nil {
return nil, fmt.Errorf("webgw: connect bus client: %w", err)
}
return &gateway{
id: cfg.Identity,
endpoint: frame.EndpointID(cfg.Identity.SignPub),
cli: cli,
refreshACL: cfg.CAPath != "",
hubs: map[string]*roomHub{},
}, nil
}
// Close stops every hub and releases the bus client connection.
func (g *gateway) Close() error {
g.mu.Lock()
for _, h := range g.hubs {
h.stop()
}
g.hubs = map[string]*roomHub{}
g.mu.Unlock()
if g.cli != nil {
return g.cli.Close()
}
return nil
}
// ---- wire types (browser-facing JSON) ------------------------------------
// meInfo is what GET /api/me returns: the operator identity the gateway acts as.
type meInfo struct {
Endpoint string `json:"endpoint"`
SignPub string `json:"sign_pub"`
}
// roomWire is the browser view of a room. It deliberately omits messages: those
// stream over SSE (GET /api/rooms/{id}/stream), not in the room list.
type roomWire struct {
ID string `json:"id"`
Subject string `json:"subject"`
Name string `json:"name"`
Epoch int `json:"epoch"`
Encrypt bool `json:"encrypt"`
Persist bool `json:"persist"`
SignMsgs bool `json:"sign_msgs"`
Role string `json:"role"`
}
// createRoomReq is the POST /api/rooms body. Encrypt/Persist/SignMsgs are
// pointers so an omitted field falls back to the chat default rather than to the
// Go zero value (false). The common case — the browser sending only {subject,
// encrypted} — maps encrypted onto all three (the Matrix-like chat policy).
type createRoomReq struct {
Subject string `json:"subject"`
Encrypted *bool `json:"encrypted,omitempty"`
Encrypt *bool `json:"encrypt,omitempty"`
Persist *bool `json:"persist,omitempty"`
SignMsgs *bool `json:"sign_msgs,omitempty"`
}
// policy resolves the requested policy. A bare {subject} defaults to the
// Matrix-like chat room (encrypted + persisted + signed) so a created room keeps
// durable, end-to-end-encrypted, authored history. Callers can override any leg.
func (r createRoomReq) policy() room.Policy {
enc, per, sig := true, true, true
if r.Encrypted != nil {
enc, per, sig = *r.Encrypted, *r.Encrypted, *r.Encrypted
}
if r.Encrypt != nil {
enc = *r.Encrypt
}
if r.Persist != nil {
per = *r.Persist
}
if r.SignMsgs != nil {
sig = *r.SignMsgs
}
return room.Policy{Encrypt: enc, Persist: per, SignMsgs: sig}
}
// sendReq is the POST /api/rooms/{id}/send body.
type sendReq struct {
Body string `json:"body"`
}
// msgWire is one decrypted message pushed over SSE.
type msgWire struct {
ID string `json:"id"`
Sender string `json:"sender"`
Body string `json:"body"`
TS int64 `json:"ts"` // epoch ms (decoded from the frame's ULID id)
Mine bool `json:"mine"`
}
// ---- operations -----------------------------------------------------------
func (g *gateway) me() meInfo {
return meInfo{Endpoint: g.endpoint, SignPub: hex.EncodeToString(g.id.SignPub)}
}
// subjectName derives a short, human-friendly room name from its bus subject by
// dropping the leading namespace segment (room., test., proc., agent.). It is a
// display nicety only; the canonical identity stays the subject/room id.
func subjectName(subject string) string {
for _, p := range []string{"room.", "test.", "proc.", "agent.", "rpc."} {
if strings.HasPrefix(subject, p) {
return strings.TrimPrefix(subject, p)
}
}
return subject
}
func (g *gateway) listRooms() ([]roomWire, error) {
rooms, err := g.cli.ListMyRooms()
if err != nil {
return nil, err
}
out := make([]roomWire, 0, len(rooms))
for _, rm := range rooms {
out = append(out, roomWire{
ID: rm.RoomID,
Subject: rm.Subject,
Name: subjectName(rm.Subject),
Epoch: rm.Epoch,
Encrypt: rm.Policy.Encrypt,
Persist: rm.Policy.Persist,
SignMsgs: rm.Policy.SignMsgs,
Role: rm.Role,
})
}
return out, nil
}
func (g *gateway) createRoom(req createRoomReq) (roomWire, error) {
subject := strings.TrimSpace(req.Subject)
if subject == "" {
return roomWire{}, fmt.Errorf("webgw: subject required")
}
p := req.policy()
roomID, err := g.cli.CreateRoom(subject, p)
if err != nil {
return roomWire{}, err
}
// Under a per-subject ACL the operator's frozen NATS permissions do not yet
// cover the new room's subject; refresh so subsequent data-plane use works. On
// a plaintext/non-ACL dev bus this is unnecessary and would needlessly drop any
// live SSE subscriptions, so it is gated on the secured posture.
if g.refreshACL {
_ = g.cli.RefreshSession()
}
return roomWire{
ID: roomID,
Subject: subject,
Name: subjectName(subject),
Epoch: 1,
Encrypt: p.Encrypt,
Persist: p.Persist,
SignMsgs: p.SignMsgs,
Role: "owner",
}, nil
}
// join resolves room metadata and (for encrypted rooms) fetches the room key so
// the gateway can later open payloads. Idempotent.
func (g *gateway) join(roomID string) error {
if err := g.cli.Join(roomID); err != nil {
return err
}
if g.refreshACL {
_ = g.cli.RefreshSession()
}
return nil
}
// send publishes plaintext to a room. The unibus client seals it with the room
// key (encrypted rooms) and signs it (signed rooms) before it leaves the process.
func (g *gateway) send(roomID, body string) error {
return g.cli.Publish(roomID, []byte(body))
}
+140
View File
@@ -0,0 +1,140 @@
package main
import (
"sync"
"github.com/enmanuel/unibus/pkg/client"
"github.com/enmanuel/unibus/pkg/frame"
"github.com/oklog/ulid/v2"
)
// roomHub multiplexes ONE unibus room subscription to MANY SSE clients. The
// unibus client derives a per-(room, endpoint) durable consumer name, so a
// second Subscribe for the same room from the same operator would contend for
// the same durable (load-balanced delivery) rather than each browser receiving
// every message. The hub holds a single subscription per room and fans each
// decrypted frame out to every connected browser, which also means the gateway
// opens at most one bus subscription per room regardless of how many tabs watch
// it.
type roomHub struct {
roomID string
myEndpoint string
sub *client.Sub
mu sync.Mutex
clients map[chan msgWire]struct{}
}
// frameTS decodes the millisecond timestamp embedded in a frame's ULID id. A
// malformed id (should not happen for bus-produced frames) yields 0, which the
// browser renders without crashing.
func frameTS(msgID string) int64 {
id, err := ulid.Parse(msgID)
if err != nil {
return 0
}
return int64(id.Time())
}
// newRoomHub opens the single bus subscription for roomID and starts fanning
// decrypted frames out to registered clients. The room must already be joined
// (so the gateway holds the room key) before this is called.
func newRoomHub(cli *client.Client, roomID, myEndpoint string) (*roomHub, error) {
h := &roomHub{
roomID: roomID,
myEndpoint: myEndpoint,
clients: map[chan msgWire]struct{}{},
}
sub, err := cli.Subscribe(roomID, func(f frame.Frame, plaintext []byte) {
m := msgWire{
ID: f.MsgID,
Sender: f.Sender,
Body: string(plaintext),
TS: frameTS(f.MsgID),
Mine: f.Sender == myEndpoint,
}
h.broadcast(m)
})
if err != nil {
return nil, err
}
h.sub = sub
return h, nil
}
// broadcast delivers a message to every registered client without blocking the
// NATS delivery goroutine: a client whose buffer is full (a stalled browser)
// drops this frame rather than stalling the whole room.
func (h *roomHub) broadcast(m msgWire) {
h.mu.Lock()
defer h.mu.Unlock()
for ch := range h.clients {
select {
case ch <- m:
default:
}
}
}
// add registers a new SSE client channel.
func (h *roomHub) add(ch chan msgWire) {
h.mu.Lock()
defer h.mu.Unlock()
h.clients[ch] = struct{}{}
}
// stop unsubscribes from the bus. Local delivery ends; for a persisted room the
// durable consumer's ack position stays on the server, so a later subscription
// with the same operator resumes from where it left off.
func (h *roomHub) stop() {
if h.sub != nil {
_ = h.sub.Unsubscribe()
}
}
// openStream joins the room (idempotent; fetches the room key for encrypted
// rooms), attaches an SSE client to the room's hub (creating it on first watcher),
// and returns the client's message channel plus a cleanup func. The cleanup
// detaches the client and, when it was the last watcher, tears down the room's
// single bus subscription.
func (g *gateway) openStream(roomID string) (chan msgWire, func(), error) {
if err := g.join(roomID); err != nil {
return nil, nil, err
}
g.mu.Lock()
h := g.hubs[roomID]
if h == nil {
var err error
h, err = newRoomHub(g.cli, roomID, g.endpoint)
if err != nil {
g.mu.Unlock()
return nil, nil, err
}
g.hubs[roomID] = h
}
g.mu.Unlock()
// Buffer so a brief render hitch in the browser does not drop live frames; a
// sustained stall still drops (broadcast is non-blocking) rather than wedging
// the room.
ch := make(chan msgWire, 64)
h.add(ch)
// cleanup takes g.mu before h.mu (the single, consistent lock order) so a
// concurrent openStream that re-creates the hub cannot race the teardown.
cleanup := func() {
g.mu.Lock()
defer g.mu.Unlock()
h.mu.Lock()
delete(h.clients, ch)
empty := len(h.clients) == 0
h.mu.Unlock()
if empty {
if cur := g.hubs[roomID]; cur == h {
delete(g.hubs, roomID)
h.stop()
}
}
}
return ch, cleanup, nil
}
+98
View File
@@ -0,0 +1,98 @@
package main
import (
"encoding/base64"
"encoding/json"
"fmt"
"os"
"os/exec"
cs "fn-registry/functions/cybersecurity"
)
// identityJSON mirrors the on-disk / pass-stored identity format shared across
// the unibus tooling: the four keypair halves, each std-base64. It is the SAME
// shape the bus client persists (pkg/client identity file) and the operator's
// `pass` entry unibus/operator-identity, so the web gateway loads the operator's
// identity without a divergent serialization. Kept in lockstep with
// unibus_admin/internal/admin/identity.go.
type identityJSON struct {
SignPub string `json:"sign_pub"`
SignPriv string `json:"sign_priv"`
KexPub string `json:"kex_pub"`
KexPriv string `json:"kex_priv"`
}
// decodeIdentity turns the JSON identity bytes into a cs.Identity. The private
// halves stay only in memory; this never writes them anywhere.
func decodeIdentity(raw []byte) (cs.Identity, error) {
var f identityJSON
if err := json.Unmarshal(raw, &f); err != nil {
return cs.Identity{}, fmt.Errorf("webgw: parse identity json: %w", err)
}
dec := base64.StdEncoding.DecodeString
signPub, err := dec(f.SignPub)
if err != nil {
return cs.Identity{}, fmt.Errorf("webgw: decode sign_pub: %w", err)
}
signPriv, err := dec(f.SignPriv)
if err != nil {
return cs.Identity{}, fmt.Errorf("webgw: decode sign_priv: %w", err)
}
kexPub, err := dec(f.KexPub)
if err != nil {
return cs.Identity{}, fmt.Errorf("webgw: decode kex_pub: %w", err)
}
kexPriv, err := dec(f.KexPriv)
if err != nil {
return cs.Identity{}, fmt.Errorf("webgw: decode kex_priv: %w", err)
}
if len(signPub) != 32 || len(signPriv) != 64 || len(kexPub) != 32 || len(kexPriv) != 32 {
return cs.Identity{}, fmt.Errorf("webgw: identity has wrong key sizes (sign_pub=%d sign_priv=%d kex_pub=%d kex_priv=%d)",
len(signPub), len(signPriv), len(kexPub), len(kexPriv))
}
return cs.Identity{SignPub: signPub, SignPriv: signPriv, KexPub: kexPub, KexPriv: kexPriv}, nil
}
// loadIdentityFromFile reads a 0600 identity JSON file (the same format the bus
// client writes) and decodes it. Used on a deploy host where `pass` is not
// available and the operator identity is delivered as a protected file.
func loadIdentityFromFile(path string) (cs.Identity, error) {
raw, err := os.ReadFile(path)
if err != nil {
return cs.Identity{}, fmt.Errorf("webgw: read identity file %q: %w", path, err)
}
return decodeIdentity(raw)
}
// loadIdentityFromPass shells out to `pass show <entry>` and decodes the JSON
// identity it returns. The secret is held only in memory; this process never
// writes it to disk or argv. Used in local operator workflows where the GNU
// password store holds unibus/operator-identity.
func loadIdentityFromPass(entry string) (cs.Identity, error) {
out, err := exec.Command("pass", "show", entry).Output()
if err != nil {
return cs.Identity{}, fmt.Errorf("webgw: pass show %q: %w", entry, err)
}
return decodeIdentity(out)
}
// loadPassValue returns the first line of a `pass show <entry>` for non-identity
// secrets (e.g. the unlock passphrase). Empty entry yields an empty string and
// no error, so callers can treat "no pass entry configured" as "not set".
func loadPassValue(entry string) (string, error) {
if entry == "" {
return "", nil
}
out, err := exec.Command("pass", "show", entry).Output()
if err != nil {
return "", fmt.Errorf("webgw: pass show %q: %w", entry, err)
}
s := string(out)
for i := 0; i < len(s); i++ {
if s[i] == '\n' || s[i] == '\r' {
return s[:i], nil
}
}
return s, nil
}
+199
View File
@@ -0,0 +1,199 @@
// Command webgw is the web gateway for the unibus chat SPA. It is a single Go
// binary that holds the operator's bus identity, connects to the bus as a real
// authenticated peer (pkg/client), and exposes a small REST + SSE API the
// browser consumes. The browser never signs, never speaks NATS, and never sees a
// private key: it authenticates to the gateway with a passphrase and thereafter
// holds only an opaque session cookie.
//
// TRUST MODEL (MVP, single operator): room content stays end-to-end encrypted on
// the bus. The gateway can read plaintext because it acts AS the operator's
// client — a legitimate member of each room holding the room key. Decryption
// happens server-side in this process; cleartext then crosses an authenticated
// (loopback or TLS-fronted) SSE channel to the browser. The wallet phase (issue:
// per-browser WebCrypto identity) can move decryption into the browser; see the
// report for the FASE 2 plan.
//
// # local dev against a loopback membershipd (plaintext), operator from pass:
// webgw --identity-pass unibus/operator-identity \
// --ctrl-url http://127.0.0.1:8470 --nats-url nats://127.0.0.1:4250
//
// # secured cluster (TLS + nkey on both planes), identity from a 0600 file:
// webgw --ca ca.crt --identity-file operator.id \
// --ctrl-url https://node-a:8470 --nats-url nats://node-a:4250 \
// --ctrl-urls https://node-b:8470,https://node-c:8470 \
// --nats-urls nats://node-b:4250,nats://node-c:4250
package main
import (
"context"
"flag"
"log"
"net/http"
"os"
"os/signal"
"path/filepath"
"strings"
"syscall"
"time"
cs "fn-registry/functions/cybersecurity"
)
func main() {
var (
bind = flag.String("bind", "127.0.0.1", "interface to bind the gateway HTTP server to (loopback by default)")
port = flag.String("port", "8481", "gateway HTTP port")
ctrlURL = flag.String("ctrl-url", "http://127.0.0.1:8470", "primary unibus control-plane base URL")
ctrlURLs = flag.String("ctrl-urls", "", "comma-separated ADDITIONAL control-plane base URLs (cluster failover)")
natsURL = flag.String("nats-url", "nats://127.0.0.1:4250", "primary NATS URL")
natsURLs = flag.String("nats-urls", "", "comma-separated ADDITIONAL NATS seed URLs (cluster failover)")
caPath = flag.String("ca", "", "bus CA cert path; set to talk TLS+nkey to a secured bus (empty = plaintext dev)")
identityFile = flag.String("identity-file", "", "path to the operator identity JSON file (0600). Mutually exclusive with --identity-pass")
identityPass = flag.String("identity-pass", "", "pass(1) entry holding the operator identity JSON, e.g. unibus/operator-identity")
unlockPass = flag.String("unlock-pass", "", "literal passphrase the browser must send to unlock a LEGACY operator session (dev). Prefer --unlock-pass-entry")
unlockEntry = flag.String("unlock-pass-entry", "unibus/admin-panel-password", "pass(1) entry holding the operator unlock passphrase (used when --unlock-pass is empty)")
registerURL = flag.String("register-url", "", "bus POST /register URL for wallet onboarding. Empty = derive from --ctrl-url (<ctrl-url>/register)")
mockTokens = flag.String("mock-tokens", "", "DEV ONLY: comma-separated one-shot invite tokens for local testing, 'token=handle:role'. Empty in production (real invites come from the bus). Example: demo=demo:member")
webDir = flag.String("web-dir", "", "OPTIONAL path to the built SPA (web/dist) to serve. Empty = API only (use vite dev server)")
)
flag.Parse()
log.SetFlags(log.LstdFlags | log.Lmsgprefix)
log.SetPrefix("[webgw] ")
id, err := loadIdentity(*identityFile, *identityPass)
if err != nil {
log.Fatalf("%v", err)
}
unlock := *unlockPass
if unlock == "" {
unlock, err = loadPassValue(*unlockEntry)
if err != nil {
log.Fatalf("resolve unlock passphrase: %v", err)
}
}
if unlock == "" {
log.Fatalf("an unlock passphrase is required: set --unlock-pass or a non-empty --unlock-pass-entry (default unibus/admin-panel-password)")
}
resolvedWebDir := resolveWebDir(*webDir)
// busTemplate is the connection config every bus client uses. The operator
// gateway uses it as-is; each wallet session clones it and overrides Identity
// with the logged-in user's keypair.
busTemplate := gatewayConfig{
Identity: id,
NatsURL: *natsURL,
CtrlURL: *ctrlURL,
CtrlURLs: splitCSV(*ctrlURLs),
NatsURLs: splitCSV(*natsURLs),
CAPath: *caPath,
}
gw, err := newGateway(busTemplate)
if err != nil {
log.Fatalf("%v", err)
}
defer gw.Close()
// Wallet onboarding backend: POST /api/register targets the bus's /register
// (added by the user-accounts work). When --register-url is empty we derive it
// from --ctrl-url; --mock-tokens supplies one-shot invites for local testing
// before that endpoint is deployed.
regURL := *registerURL
if regURL == "" {
regURL = strings.TrimRight(*ctrlURL, "/") + "/register"
}
registrar := newRegistrar(regURL, *mockTokens)
log.Printf("operator endpoint: %s", gw.endpoint)
log.Printf("control plane: %s (+%d failover)", *ctrlURL, len(splitCSV(*ctrlURLs)))
tls := "OFF (plaintext dev)"
if *caPath != "" {
tls = "ON (CA " + *caPath + ")"
}
log.Printf("bus TLS+nkey: %s", tls)
if resolvedWebDir != "" {
log.Printf("serving SPA from: %s", resolvedWebDir)
} else {
log.Printf("API only (no --web-dir): use the vite dev server with a /api+stream proxy")
}
log.Printf("wallet register: %s (mock tokens: %d)", regURL, mockTokenCount(*mockTokens))
srv := newServer(gw, busTemplate, registrar, unlock, resolvedWebDir)
addr := *bind + ":" + *port
httpSrv := &http.Server{
Addr: addr,
Handler: srv,
// No global write timeout: SSE streams are long-lived. Header timeout still
// bounds slowloris on the request line/headers.
ReadHeaderTimeout: 10 * time.Second,
}
go func() {
log.Printf("web gateway: http://%s", addr)
if err := httpSrv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("http server: %v", err)
}
}()
stop := make(chan os.Signal, 1)
signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM)
<-stop
log.Printf("shutting down...")
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_ = httpSrv.Shutdown(ctx)
log.Printf("bye")
}
// loadIdentity resolves the operator identity from exactly one of --identity-file
// or --identity-pass.
func loadIdentity(file, passEntry string) (cs.Identity, error) {
switch {
case file != "" && passEntry != "":
return cs.Identity{}, errFlag("set only one of --identity-file or --identity-pass")
case file != "":
return loadIdentityFromFile(file)
case passEntry != "":
return loadIdentityFromPass(passEntry)
default:
return cs.Identity{}, errFlag("an identity is required: pass --identity-file <path> or --identity-pass <entry>")
}
}
// resolveWebDir validates the --web-dir flag. An empty flag means API-only. A
// non-empty dir is kept only if it actually holds an index.html, so a typo logs
// "API only" rather than serving 404s.
func resolveWebDir(dir string) string {
if dir == "" {
return ""
}
abs, err := filepath.Abs(dir)
if err != nil {
log.Printf("WARN --web-dir %q: %v; serving API only", dir, err)
return ""
}
if !statFile(filepath.Join(abs, "index.html")) {
log.Printf("WARN --web-dir %q has no index.html; serving API only", abs)
return ""
}
return abs
}
type flagErr string
func (e flagErr) Error() string { return string(e) }
func errFlag(s string) error { return flagErr("webgw: " + s) }
func splitCSV(s string) []string {
var out []string
for _, p := range strings.Split(s, ",") {
if p = strings.TrimSpace(p); p != "" {
out = append(out, p)
}
}
return out
}
+193
View File
@@ -0,0 +1,193 @@
package main
import (
"bytes"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"sync"
"time"
)
// registerReq is the POST /api/register body. It mirrors the bus contract exactly
// (token + the two PUBLIC key halves, each 64 hex chars). The private key never
// appears here — registration only publishes the public identity. The handle and
// role are NOT accepted from the client; they are fixed by the invite the token
// belongs to (no privilege escalation).
type registerReq struct {
Token string `json:"token"`
SignPub string `json:"sign_pub"`
KexPub string `json:"kex_pub"`
}
// registerResp is what we return to the browser on success. The bus's /register
// (issue: user-accounts) decides handle/role from the invite; in mock mode the
// gateway echoes the configured pair so the SPA can greet the new user.
type registerResp struct {
Handle string `json:"handle"`
Role string `json:"role"`
}
// registrar fulfils POST /api/register. It targets the bus's POST /register
// endpoint (added by the user-accounts work, bus >= 0.12.0). Until that endpoint
// is rolled out, a built-in mock validates against a configured set of one-shot
// tokens so the whole wallet flow is testable locally. Mock tokens are checked
// first; anything else is proxied to the real bus when --register-url is set.
type registrar struct {
mu sync.Mutex
registerURL string // bus POST /register; empty => mock-only
httpc *http.Client // for proxying to the bus
mockTokens map[string]*mockToken // configured one-shot invites for local testing
}
// mockToken is a local stand-in for a bus invite: a token that maps to a fixed
// handle+role and can be consumed exactly once.
type mockToken struct {
handle string
role string
used bool
}
// newRegistrar parses the --mock-tokens spec ("tok=handle:role,tok2=h2:role2")
// and configures the optional proxy target.
func newRegistrar(registerURL, mockSpec string) *registrar {
r := &registrar{
registerURL: strings.TrimSpace(registerURL),
httpc: &http.Client{Timeout: 10 * time.Second},
mockTokens: map[string]*mockToken{},
}
for _, part := range strings.Split(mockSpec, ",") {
part = strings.TrimSpace(part)
if part == "" {
continue
}
// tok=handle:role (role optional, defaults to member)
eq := strings.IndexByte(part, '=')
if eq < 0 {
continue
}
tok := strings.TrimSpace(part[:eq])
hr := strings.TrimSpace(part[eq+1:])
handle, role := hr, "member"
if c := strings.IndexByte(hr, ':'); c >= 0 {
handle, role = strings.TrimSpace(hr[:c]), strings.TrimSpace(hr[c+1:])
}
if tok != "" && handle != "" {
r.mockTokens[tok] = &mockToken{handle: handle, role: role}
}
}
return r
}
// mockTokenCount counts configured mock tokens in a --mock-tokens spec (for the
// startup log line).
func mockTokenCount(spec string) int {
n := 0
for _, part := range strings.Split(spec, ",") {
if p := strings.TrimSpace(part); p != "" && strings.ContainsRune(p, '=') {
n++
}
}
return n
}
// validHexKey reports whether s is exactly 64 lowercase/uppercase hex chars (a
// 32-byte key). Both sign_pub and kex_pub are 32-byte keys.
func validHexKey(s string) bool {
if len(s) != 64 {
return false
}
_, err := hex.DecodeString(s)
return err == nil
}
// handleRegister validates the keys and consumes the token. Order of resolution:
// 1. strict validation of the public keys (defends both mock and proxy paths);
// 2. mock token (one-shot) if configured;
// 3. proxy to the bus /register if --register-url is set;
// 4. otherwise reject with a clear error.
func (s *server) handleRegister(w http.ResponseWriter, r *http.Request) {
var req registerReq
if !decode(w, r, &req) {
return
}
req.Token = strings.TrimSpace(req.Token)
if req.Token == "" {
writeErr(w, http.StatusBadRequest, "token required")
return
}
if !validHexKey(req.SignPub) {
writeErr(w, http.StatusBadRequest, "sign_pub must be 64 hex chars (32 bytes)")
return
}
if !validHexKey(req.KexPub) {
writeErr(w, http.StatusBadRequest, "kex_pub must be 64 hex chars (32 bytes)")
return
}
reg := s.registrar
// 2) mock one-shot token.
reg.mu.Lock()
mt, isMock := reg.mockTokens[req.Token]
if isMock {
if mt.used {
reg.mu.Unlock()
writeErr(w, http.StatusConflict, "invite already used")
return
}
mt.used = true
handle, role := mt.handle, mt.role
reg.mu.Unlock()
writeJSON(w, http.StatusCreated, registerResp{Handle: handle, Role: role})
return
}
reg.mu.Unlock()
// 3) proxy to the real bus /register when configured.
if reg.registerURL != "" {
s.proxyRegister(w, req)
return
}
// 4) no mock match, no proxy target.
writeErr(w, http.StatusBadRequest, "invalid or unknown token (and no bus /register configured)")
}
// proxyRegister forwards the registration to the bus's POST /register. The bus
// validates the invite (existence, not-used, not-expired) and adds the public
// identity to the allowlist with the invite's handle+role. This is unsigned by
// design: the TOKEN authorizes the call, not an admin signature.
func (s *server) proxyRegister(w http.ResponseWriter, req registerReq) {
body, _ := json.Marshal(req)
resp, err := s.registrar.httpc.Post(
s.registrar.registerURL,
"application/json",
bytes.NewReader(body),
)
if err != nil {
writeErr(w, http.StatusBadGateway, "bus register unreachable: "+err.Error())
return
}
defer resp.Body.Close()
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
// On success, try to pass through the bus's handle/role if it returned them;
// otherwise a bare 201 is still success.
if resp.StatusCode == http.StatusCreated || resp.StatusCode == http.StatusOK {
var rr registerResp
_ = json.Unmarshal(raw, &rr)
writeJSON(w, http.StatusCreated, rr)
return
}
// Forward the bus's error verbatim where possible.
msg := strings.TrimSpace(string(raw))
if msg == "" {
msg = fmt.Sprintf("bus register failed (HTTP %d)", resp.StatusCode)
}
writeErr(w, resp.StatusCode, msg)
}
+327
View File
@@ -0,0 +1,327 @@
package main
import (
"crypto/rand"
"crypto/subtle"
"encoding/hex"
"encoding/json"
"net/http"
"os"
"path/filepath"
"strings"
"time"
)
// sessionCookie is the name of the gateway's session cookie. The browser sends
// it automatically on same-origin fetches AND on EventSource (SSE) connections —
// EventSource cannot set custom headers, so a cookie is the only way to
// authenticate the stream. It is HttpOnly so page JS can never read the token.
const sessionCookie = "unibus_session"
// server is the gateway's HTTP surface: a small REST/SSE API under /api plus an
// optional static file server for the built SPA.
//
// Two ways to get a session:
// - POST /api/session — the WALLET model. The browser hands its own bus
// identity (unlocked from its local encrypted key) and the gateway connects a
// dedicated bus client AS that user. Per-user, the primary path.
// - POST /api/login — the legacy operator passphrase. Binds the session to the
// single shared operator gateway. Kept for backward compatibility.
// - POST /api/register — the WALLET onboarding. Unauthenticated (the invite
// token authorizes), it consumes a token and publishes the new user's PUBLIC
// identity to the bus allowlist.
type server struct {
operatorGW *gateway // shared operator client (legacy passphrase login)
busTemplate gatewayConfig // bus connection config; Identity is overridden per user session
registrar *registrar // POST /api/register backend (mock + proxy)
unlock string // passphrase that unlocks an operator session (constant-time compare)
webDir string // optional path to the built SPA (web/dist); empty = API only
mux *http.ServeMux
sessions *sessionStore
}
func newServer(operatorGW *gateway, busTemplate gatewayConfig, registrar *registrar, unlock, webDir string) *server {
s := &server{
operatorGW: operatorGW,
busTemplate: busTemplate,
registrar: registrar,
unlock: unlock,
webDir: webDir,
mux: http.NewServeMux(),
sessions: newSessionStore(),
}
s.routes()
return s
}
func (s *server) ServeHTTP(w http.ResponseWriter, r *http.Request) { s.mux.ServeHTTP(w, r) }
func (s *server) routes() {
// Liveness, unauthenticated (systemd / deploy smoke).
s.mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
})
// Unauthenticated onboarding / auth routes.
s.mux.HandleFunc("POST /api/register", s.handleRegister) // invite token authorizes
s.mux.HandleFunc("POST /api/session", s.handleSession) // wallet: per-user identity
s.mux.HandleFunc("POST /api/login", s.handleLogin) // legacy operator passphrase
// Session-gated routes.
s.mux.HandleFunc("POST /api/logout", s.auth(s.handleLogout))
s.mux.HandleFunc("GET /api/me", s.auth(s.handleMe))
s.mux.HandleFunc("GET /api/rooms", s.auth(s.handleListRooms))
s.mux.HandleFunc("POST /api/rooms", s.auth(s.handleCreateRoom))
s.mux.HandleFunc("POST /api/rooms/{id}/join", s.auth(s.handleJoin))
s.mux.HandleFunc("POST /api/rooms/{id}/send", s.auth(s.handleSend))
s.mux.HandleFunc("GET /api/rooms/{id}/stream", s.auth(s.handleStream))
// Everything else is the SPA (when --web-dir is set). Registered last.
if s.webDir != "" {
s.mux.Handle("/", s.spaHandler())
}
}
// meResp is the identity view returned by /api/session, /api/login and /api/me:
// the bus endpoint the session acts as, its signing public key, and the display
// handle.
type meResp struct {
Endpoint string `json:"endpoint"`
SignPub string `json:"sign_pub"`
Handle string `json:"handle"`
}
// ---- auth -----------------------------------------------------------------
// auth wraps a handler so it runs only with a valid session cookie, resolving the
// session (and thus the per-user gateway) it belongs to. A missing or unknown
// token yields 401, which the SPA treats as "show the login screen".
func (s *server) auth(next func(http.ResponseWriter, *http.Request, *session)) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
c, err := r.Cookie(sessionCookie)
if err != nil {
writeErr(w, http.StatusUnauthorized, "not authenticated")
return
}
sess, ok := s.sessions.get(c.Value)
if !ok {
writeErr(w, http.StatusUnauthorized, "not authenticated")
return
}
next(w, r, sess)
}
}
// handleLogin is the legacy operator passphrase login: it unlocks a session bound
// to the shared operator gateway. The wallet path (POST /api/session) is
// preferred; this remains for backward compatibility with the single-operator MVP.
func (s *server) handleLogin(w http.ResponseWriter, r *http.Request) {
var req struct {
Passphrase string `json:"passphrase"`
}
if !decode(w, r, &req) {
return
}
// Constant-time compare so a wrong passphrase cannot be timed character by
// character. An empty configured passphrase never matches.
if s.unlock == "" || subtle.ConstantTimeCompare([]byte(req.Passphrase), []byte(s.unlock)) != 1 {
writeErr(w, http.StatusUnauthorized, "wrong passphrase")
return
}
tok := newToken()
handle := s.operatorGW.endpoint
if len(handle) > 8 {
handle = handle[:8]
}
s.sessions.put(tok, &session{gw: s.operatorGW, owned: false, handle: handle, issuedAt: time.Now()})
http.SetCookie(w, &http.Cookie{
Name: sessionCookie,
Value: tok,
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
})
writeJSON(w, http.StatusOK, meResp{Endpoint: s.operatorGW.endpoint, SignPub: hex.EncodeToString(s.operatorGW.id.SignPub), Handle: handle})
}
func (s *server) handleLogout(w http.ResponseWriter, r *http.Request, _ *session) {
if c, err := r.Cookie(sessionCookie); err == nil {
if sess, ok := s.sessions.drop(c.Value); ok && sess.owned && sess.gw != nil {
// Per-user session: tear down its bus client so the private key and the
// NATS connection do not outlive the session.
_ = sess.gw.Close()
}
}
http.SetCookie(w, &http.Cookie{Name: sessionCookie, Value: "", Path: "/", MaxAge: -1, HttpOnly: true})
writeJSON(w, http.StatusOK, map[string]string{"status": "logged_out"})
}
func (s *server) handleMe(w http.ResponseWriter, _ *http.Request, sess *session) {
writeJSON(w, http.StatusOK, meResp{
Endpoint: sess.gw.endpoint,
SignPub: hex.EncodeToString(sess.gw.id.SignPub),
Handle: sess.handle,
})
}
// ---- rooms ----------------------------------------------------------------
func (s *server) handleListRooms(w http.ResponseWriter, _ *http.Request, sess *session) {
rooms, err := sess.gw.listRooms()
if err != nil {
writeErr(w, http.StatusBadGateway, err.Error())
return
}
writeJSON(w, http.StatusOK, rooms)
}
func (s *server) handleCreateRoom(w http.ResponseWriter, r *http.Request, sess *session) {
var req createRoomReq
if !decode(w, r, &req) {
return
}
rv, err := sess.gw.createRoom(req)
if err != nil {
writeErr(w, http.StatusBadGateway, err.Error())
return
}
writeJSON(w, http.StatusCreated, rv)
}
func (s *server) handleJoin(w http.ResponseWriter, r *http.Request, sess *session) {
if err := sess.gw.join(r.PathValue("id")); err != nil {
writeErr(w, http.StatusBadGateway, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "joined"})
}
func (s *server) handleSend(w http.ResponseWriter, r *http.Request, sess *session) {
var req sendReq
if !decode(w, r, &req) {
return
}
if strings.TrimSpace(req.Body) == "" {
writeErr(w, http.StatusBadRequest, "body required")
return
}
if err := sess.gw.send(r.PathValue("id"), req.Body); err != nil {
writeErr(w, http.StatusBadGateway, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "sent"})
}
// handleStream is the SSE endpoint: it joins the room, attaches to the session's
// fan-out hub, and streams each decrypted message as a `data:` event. For a
// persisted room the hub's underlying subscription delivers history first
// (scrollback) and then live messages; for an ephemeral room only live messages
// flow. The stream ends when the browser disconnects (ctx cancelled).
func (s *server) handleStream(w http.ResponseWriter, r *http.Request, sess *session) {
flusher, ok := w.(http.Flusher)
if !ok {
writeErr(w, http.StatusInternalServerError, "streaming unsupported")
return
}
ch, cleanup, err := sess.gw.openStream(r.PathValue("id"))
if err != nil {
writeErr(w, http.StatusBadGateway, err.Error())
return
}
defer cleanup()
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.Header().Set("X-Accel-Buffering", "no") // disable proxy buffering (nginx/caddy)
w.WriteHeader(http.StatusOK)
// An initial comment opens the stream immediately so the browser's
// EventSource fires `onopen` without waiting for the first message.
_, _ = w.Write([]byte(": connected\n\n"))
flusher.Flush()
ctx := r.Context()
ping := time.NewTicker(25 * time.Second)
defer ping.Stop()
for {
select {
case <-ctx.Done():
return
case <-ping.C:
// Comment line keeps idle proxies from closing the connection.
if _, err := w.Write([]byte(": ping\n\n")); err != nil {
return
}
flusher.Flush()
case m := <-ch:
b, err := json.Marshal(m)
if err != nil {
continue
}
if _, err := w.Write([]byte("data: " + string(b) + "\n\n")); err != nil {
return
}
flusher.Flush()
}
}
}
// ---- SPA serving (optional) -----------------------------------------------
// spaHandler serves the built SPA from s.webDir. A request for an existing asset
// is served directly; any other path (a client-side route) falls back to
// index.html so the SPA router can take over. /api and /healthz are matched first.
func (s *server) spaHandler() http.Handler {
root := http.Dir(s.webDir)
fileServer := http.FileServer(root)
index := filepath.Join(s.webDir, "index.html")
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
p := strings.TrimPrefix(r.URL.Path, "/")
if p == "" {
http.ServeFile(w, r, index)
return
}
if f, err := root.Open(p); err == nil {
_ = f.Close()
fileServer.ServeHTTP(w, r)
return
}
http.ServeFile(w, r, index) // unknown path -> SPA client-side routing
})
}
// ---- helpers --------------------------------------------------------------
func newToken() string {
b := make([]byte, 32)
_, _ = rand.Read(b)
return hex.EncodeToString(b)
}
func writeJSON(w http.ResponseWriter, code int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
_ = json.NewEncoder(w).Encode(v)
}
func writeErr(w http.ResponseWriter, code int, msg string) {
writeJSON(w, code, map[string]string{"error": msg})
}
// decode reads a JSON body into v, writing a 400 and returning false on failure.
func decode(w http.ResponseWriter, r *http.Request, v any) bool {
defer r.Body.Close()
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(v); err != nil {
writeErr(w, http.StatusBadRequest, "bad json: "+err.Error())
return false
}
return true
}
// statFile reports whether path exists and is a regular file (used to validate
// --web-dir at startup so a typo surfaces as a clear log line, not 404s later).
func statFile(path string) bool {
fi, err := os.Stat(path)
return err == nil && !fi.IsDir()
}
+146
View File
@@ -0,0 +1,146 @@
package main
import (
"encoding/hex"
"fmt"
"net/http"
"sync"
"time"
cs "fn-registry/functions/cybersecurity"
)
// session is one logged-in browser. In the wallet model each session carries the
// user's OWN bus identity: the browser unlocks its locally-encrypted private key
// and hands the full keypair to the gateway over TLS, and the gateway spins up a
// dedicated bus client (a *gateway) that acts AS that user. The private key lives
// only in this process's memory for the life of the session — it is never written
// to disk and is dropped when the session ends.
//
// A session may instead point at the shared operator gateway (the legacy
// passphrase login); `owned` distinguishes the two so logout only closes the bus
// client it created.
type session struct {
gw *gateway
owned bool // true => gw was built for this session and must be Closed on logout
handle string
issuedAt time.Time
}
// sessionStore is the gateway's set of live browser sessions, keyed by the opaque
// cookie token. It is independent of any single bus identity.
type sessionStore struct {
mu sync.Mutex
m map[string]*session
}
func newSessionStore() *sessionStore { return &sessionStore{m: map[string]*session{}} }
func (st *sessionStore) put(token string, s *session) {
st.mu.Lock()
st.m[token] = s
st.mu.Unlock()
}
func (st *sessionStore) get(token string) (*session, bool) {
st.mu.Lock()
defer st.mu.Unlock()
s, ok := st.m[token]
return s, ok
}
// drop removes a session and returns it so the caller can close an owned gateway.
func (st *sessionStore) drop(token string) (*session, bool) {
st.mu.Lock()
defer st.mu.Unlock()
s, ok := st.m[token]
if ok {
delete(st.m, token)
}
return s, ok
}
// closeAll closes every owned per-user gateway (used at shutdown). The shared
// operator gateway is owned by main and closed separately.
func (st *sessionStore) closeAll() {
st.mu.Lock()
defer st.mu.Unlock()
for tok, s := range st.m {
if s.owned && s.gw != nil {
_ = s.gw.Close()
}
delete(st.m, tok)
}
}
// identityFromHex builds a cs.Identity from the four hex halves the browser sends
// on POST /api/session. It enforces the exact key sizes (sign_pub 32, sign_priv
// 64, kex_pub 32, kex_priv 32) so a malformed body cannot produce a half-built
// identity that fails opaquely deep in the bus client.
func identityFromHex(signPub, signPriv, kexPub, kexPriv string) (cs.Identity, error) {
sp, err := hex.DecodeString(signPub)
if err != nil {
return cs.Identity{}, fmt.Errorf("sign_pub: %w", err)
}
spriv, err := hex.DecodeString(signPriv)
if err != nil {
return cs.Identity{}, fmt.Errorf("sign_priv: %w", err)
}
kp, err := hex.DecodeString(kexPub)
if err != nil {
return cs.Identity{}, fmt.Errorf("kex_pub: %w", err)
}
kpriv, err := hex.DecodeString(kexPriv)
if err != nil {
return cs.Identity{}, fmt.Errorf("kex_priv: %w", err)
}
if len(sp) != 32 || len(spriv) != 64 || len(kp) != 32 || len(kpriv) != 32 {
return cs.Identity{}, fmt.Errorf("wrong key sizes (sign_pub=%d sign_priv=%d kex_pub=%d kex_priv=%d; want 32/64/32/32)",
len(sp), len(spriv), len(kp), len(kpriv))
}
return cs.Identity{SignPub: sp, SignPriv: spriv, KexPub: kp, KexPriv: kpriv}, nil
}
// sessionReq is the POST /api/session body: the user's full wallet identity (hex)
// plus a display handle. The private halves arrive only over TLS and are held in
// memory for the session; they are never persisted server-side.
type sessionReq struct {
Handle string `json:"handle"`
SignPub string `json:"sign_pub"`
SignPriv string `json:"sign_priv"`
KexPub string `json:"kex_pub"`
KexPriv string `json:"kex_priv"`
}
// handleSession opens a per-user session. It builds the user's bus identity from
// the posted keypair, connects a dedicated bus client as that user, and issues a
// session cookie bound to it. This is the wallet-model replacement for the
// operator passphrase login.
func (s *server) handleSession(w http.ResponseWriter, r *http.Request) {
var req sessionReq
if !decode(w, r, &req) {
return
}
id, err := identityFromHex(req.SignPub, req.SignPriv, req.KexPub, req.KexPriv)
if err != nil {
writeErr(w, http.StatusBadRequest, "bad identity: "+err.Error())
return
}
cfg := s.busTemplate
cfg.Identity = id
gw, err := newGateway(cfg)
if err != nil {
writeErr(w, http.StatusBadGateway, "connect bus as user: "+err.Error())
return
}
tok := newToken()
s.sessions.put(tok, &session{gw: gw, owned: true, handle: req.Handle, issuedAt: time.Now()})
http.SetCookie(w, &http.Cookie{
Name: sessionCookie,
Value: tok,
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
})
writeJSON(w, http.StatusOK, meResp{Endpoint: gw.endpoint, SignPub: req.SignPub, Handle: req.Handle})
}
+114
View File
@@ -0,0 +1,114 @@
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)
}
}