diff --git a/app.md b/app.md index b957d002..9374a940 100644 --- a/app.md +++ b/app.md @@ -2,7 +2,7 @@ name: unibus lang: go domain: infra -version: 0.14.0 +version: 0.15.0 description: "Bus de mensajería unificado sobre NATS+JetStream con cifrado E2E por room (megolm/olm reducido): service de membresía/claves, librería cliente y peers demo." tags: [service, messaging, nats, e2e] uses_functions: @@ -158,6 +158,59 @@ Para apuntar a un NATS externo en producción: `--nats-url nats://host:4222` en `cybersecurity` del registry compila limpio con `CGO_ENABLED=0`. NO requiere `fts5` ni `gcc`. +## Directorio de nombres (endpoint → handle) + +Cada frame del bus lleva el **endpoint id** del remitente +(`base64url(sha256(signPub))`, sin padding — `frame.EndpointID`), no un nombre +legible. Para que un cliente muestre nombres en vez de hashes, el control-plane +expone `GET /api/directory`: + +- **Auth:** el mismo middleware de firma que el resto del control-plane + (cabeceras `X-Unibus-Pub/Ts/Nonce/Sig` sobre `CanonicalRequest`). NO es + admin-only: cualquier usuario activo del bus (member o admin) puede leerlo. En + modo `enforce`, una request sin firmar recibe 401 antes de llegar al handler. +- **Respuesta** `{ "members": [ { "sign_pub", "endpoint", "handle", "role" } ] }`, + solo usuarios `status=active`. El `endpoint` lo computa el servidor desde el + `sign_pub` con la misma derivación que el bus, así que casa byte a byte con el + sender id que el cliente ya tiene en cada mensaje. +- CORS: cubierto por la allowlist `--cors-origins` existente (mismas cabeceras + que el resto de rutas, sin caso especial). + +## Provisioning de bots / unibots + +Dar de alta una identidad para un proceso automatizado es **un solo comando**. +Antes había que derivar un keypair a mano y pasar el `sign_pub` a `user add`; +ahora `bot add` lo hace todo: mintea una identidad de bus fresca (Ed25519 + +X25519, la misma derivación `cs.GenerateIdentity` que usan `worker`/`chat`), +registra su `sign_pub` en el allowlist con `handle` y `role`, y escribe las +credenciales a un fichero 0600 que el proceso lee para conectar. + +```bash +# 1. Provisionar el bot (store sqlite local; usa --store kv contra un cluster vivo). +membershipd bot add --handle notifier --out ./local_files/notifier.id +# provisioned bot "notifier" role=member +# sign_pub: 97d5a903...b1d4 +# endpoint: HU85l2onjrK4EoTLoBfJVkGEXMw9LAjNEjPWiDS8YwM +# credentials: ./local_files/notifier.id (0600) + +# 2. El proceso arranca como ese usuario leyendo el --out (formato canónico +# pkg/client.LoadIdentity, sin conversión): el worker demo lo consume directo. +worker --id-file ./local_files/notifier.id --nats-url nats://127.0.0.1:4250 \ + --ctrl-url http://127.0.0.1:8470 + +# 3. (opcional) Verlo en el directorio / en user list. +membershipd user list +``` + +Las credenciales (`--out`) quedan en el fichero indicado, con permisos 0600. Es +el secreto del bot: contiene las claves privadas, trátalo como una clave SSH +(ver Gotcha "Identidad = secreto crítico"). `bot add` rehúsa sobrescribir un +`--out` existente, y registra al usuario ANTES de escribir el fichero, de modo +que un fallo nunca deja un bot a medias. + +Flags: `--handle` y `--out` obligatorios; `--role admin|member` (default member); +`--store sqlite|kv` y el resto de flags de conexión idénticos a `user add`. + ## Convención de subjects ``` @@ -169,6 +222,18 @@ agent..{in,out} inbox/outbox de agente LLM (agent.scout.in) ## Capability growth log +- v0.15.0 (2026-06-14) — nombres legibles + provisioning de bots de un comando. + (1) Nuevo `GET /api/directory` en el control-plane: cualquier usuario activo del + bus (member o admin), autenticado con la misma firma Ed25519 que el resto de + rutas, resuelve endpoint id → handle. Devuelve `{members:[{sign_pub, endpoint, + handle, role}]}` solo de usuarios activos; el endpoint lo deriva el servidor con + `frame.EndpointID`, casando byte a byte con el sender id de cada frame (paridad + verificada contra el vector de `cmd/busvectors`). (2) Nuevo `membershipd bot add + --handle --out [--role] [--store]`: mintea identidad, la registra en + el allowlist y escribe credenciales 0600 en formato `client.LoadIdentity`, de modo + que un proceso (worker/clientcheck) conecta como ese usuario sin pasos manuales. + Nuevo helper exportado `pkg/client.WriteNewIdentity` (no sobrescribe ficheros + existentes). Todo aditivo; build/vet/test verdes. - v0.14.0 (2026-06-13) — prep para el cliente browser-nativo `uniweb` (issue uniweb/0001, Fase 0), todo aditivo y opt-in: (1) el nats-server embebido puede exponer un listener WebSocket (`WebsocketConfig`) para que un navegador hable el diff --git a/cmd/membershipd/bot_cli.go b/cmd/membershipd/bot_cli.go new file mode 100644 index 00000000..026d845a --- /dev/null +++ b/cmd/membershipd/bot_cli.go @@ -0,0 +1,159 @@ +package main + +import ( + "encoding/hex" + "errors" + "flag" + "fmt" + "os" + + cs "fn-registry/functions/cybersecurity" + + "github.com/enmanuel/unibus/pkg/client" + "github.com/enmanuel/unibus/pkg/frame" + "github.com/enmanuel/unibus/pkg/membership" +) + +// runBotCLI implements `membershipd bot add ...`, one-command provisioning of a +// bus identity for an automated process. Where `user add` requires the operator +// to derive a keypair by hand and pass the public key, `bot add` mints the +// identity, registers its signing key in the allowlist, AND writes the bot's +// credentials to a 0600 file the process reads to connect — no manual key +// derivation, no second step. It shares the SQLite/KV store plumbing with the +// user CLI, so `--store kv` provisions against a live cluster the same way. +// +// Like the user CLI it never returns: it exits non-zero on error so it composes +// in shell scripts and systemd ExecStartPre hooks. +func runBotCLI(args []string) { + if len(args) == 0 { + botUsage() + os.Exit(2) + } + sub, rest := args[0], args[1:] + switch sub { + case "add": + botAdd(rest) + case "-h", "--help", "help": + botUsage() + os.Exit(0) + default: + fmt.Fprintf(os.Stderr, "membershipd bot: unknown subcommand %q\n\n", sub) + botUsage() + os.Exit(2) + } +} + +func botUsage() { + fmt.Fprint(os.Stderr, `usage: membershipd bot add [flags] + +Provision a bus identity for an automated process (a "unibot") in one command: +mint a fresh Ed25519+X25519 identity, register its signing key in the allowlist, +and write the credentials to a 0600 file the process loads to connect. + +required flags: + --handle human-readable name for the bot (shown in the directory) + --out where to write the bot credentials (refused if it exists) + +optional flags: + --role admin or member (default member) + --store sqlite (local DB, default) | kv (the live cluster's allowlist) + --db SQLite database path (--store sqlite; default ./local_files/unibus.db) + +--store kv flags (defaults assume an on-node invocation): + --nats-url cluster NATS (default nats://127.0.0.1:4250) + --internal-id-file persisted internal service identity (default /opt/unibus/secrets/internal.id) + --ca CA cert pinning the data-plane TLS (default /opt/unibus/tls/ca.crt) + --kv-replicas KV replication factor, match the cluster (default 3) + +examples: + membershipd bot add --handle notifier --out ./local_files/notifier.id + membershipd bot add --store kv --handle relay --role member --out /opt/unibus/secrets/relay.id + +The --out file is the canonical identity format read by the worker/clientcheck +clients (pkg/client.LoadIdentity), so the provisioned bot connects with no extra +conversion: point the process at it (e.g. worker --id-file ) and it joins +the bus as this user. +`) +} + +func botAdd(args []string) { + fs := flag.NewFlagSet("bot add", flag.ExitOnError) + handle := fs.String("handle", "", "human-readable bot name (required)") + role := fs.String("role", membership.RoleMember, "role: admin or member") + out := fs.String("out", "", "path to write the bot credentials, 0600 (required)") + dbPath := fs.String("db", defaultDBPath, "SQLite database path") + kf := registerKVFlags(fs) + _ = fs.Parse(args) + + if *handle == "" || *out == "" { + fmt.Fprintln(os.Stderr, "membershipd bot add: --handle and --out are required") + os.Exit(2) + } + + store, kv, closeStore := resolveStore("bot add", kf, *dbPath) + defer closeStore() + + signPubHex, endpoint, err := provisionBot(store, *handle, *role, *out) + if err != nil { + fmt.Fprintf(os.Stderr, "membershipd bot add: %v\n", err) + os.Exit(1) + } + fmt.Printf("provisioned bot %q role=%s\n", *handle, *role) + fmt.Printf(" sign_pub: %s\n", signPubHex) + fmt.Printf(" endpoint: %s\n", endpoint) + fmt.Printf(" credentials: %s (0600)\n", *out) + if kv != nil { + reportKVReplication(kv.js) + } +} + +// provisionBot mints a fresh bus identity and provisions it. It is the generating +// half; provisionBotWithIdentity does the registration + persistence so a test can +// inject a known identity (e.g. to exercise the already-registered error path). +func provisionBot(store membership.Store, handle, role, out string) (signPubHex, endpoint string, err error) { + id, err := cs.GenerateIdentity() + if err != nil { + return "", "", fmt.Errorf("generate bot identity: %w", err) + } + return provisionBotWithIdentity(store, id, handle, role, out) +} + +// provisionBotWithIdentity registers id's signing key under handle/role and writes +// id's credentials to out. It returns the lowercase-hex signing key and the +// derived endpoint id. +// +// Ordering is deliberate so a failure never leaves a half-provisioned bot: +// 1. refuse if out already exists, BEFORE the store is touched (no orphan user); +// 2. register the user — an already-registered key is a clear error, not a panic; +// 3. only then write the 0600 credentials file. +// +// A write failure after a successful register is reported with the registered key +// so the operator can revoke it; this is the one residual non-atomic seam (a +// local admin command, acceptable per KISS). +func provisionBotWithIdentity(store membership.Store, id cs.Identity, handle, role, out string) (signPubHex, endpoint string, err error) { + if handle == "" || out == "" { + return "", "", fmt.Errorf("handle and out are required") + } + if role == "" { + role = membership.RoleMember + } + if _, statErr := os.Stat(out); statErr == nil { + return "", "", fmt.Errorf("out file %q already exists; refusing to overwrite bot credentials", out) + } else if !os.IsNotExist(statErr) { + return "", "", fmt.Errorf("stat out %q: %w", out, statErr) + } + + signPubHex = hex.EncodeToString(id.SignPub) + endpoint = frame.EndpointID(id.SignPub) + + if err := store.AddUser(signPubHex, handle, role); err != nil { + if errors.Is(err, membership.ErrUserExists) { + return "", "", fmt.Errorf("sign_pub %s already registered; revoke it first to replace", signPubHex) + } + return "", "", fmt.Errorf("register bot user: %w", err) + } + if err := client.WriteNewIdentity(out, id); err != nil { + return "", "", fmt.Errorf("write bot credentials to %q (user %s WAS registered — revoke it to retry): %w", out, signPubHex, err) + } + return signPubHex, endpoint, nil +} diff --git a/cmd/membershipd/bot_cli_test.go b/cmd/membershipd/bot_cli_test.go new file mode 100644 index 00000000..187fe5f3 --- /dev/null +++ b/cmd/membershipd/bot_cli_test.go @@ -0,0 +1,149 @@ +package main + +import ( + "encoding/hex" + "os" + "path/filepath" + "testing" + + cs "fn-registry/functions/cybersecurity" + + "github.com/enmanuel/unibus/pkg/client" + "github.com/enmanuel/unibus/pkg/frame" + "github.com/enmanuel/unibus/pkg/membership" +) + +// openTestStore opens a fresh SQLite membership store in a temp dir. +func openTestStore(t *testing.T) membership.Store { + t.Helper() + store, err := membership.Open(filepath.Join(t.TempDir(), "unibus.db")) + if err != nil { + t.Fatalf("open store: %v", err) + } + t.Cleanup(func() { store.Close() }) + return store +} + +// TestProvisionBotGolden is the happy path: provisioning a bot registers it in the +// allowlist with the right handle and role, AND writes a 0600 credentials file +// that LoadIdentity reconstructs into the same identity — so a worker/clientcheck +// binary pointed at the file connects as exactly this user with no extra step. +func TestProvisionBotGolden(t *testing.T) { + store := openTestStore(t) + out := filepath.Join(t.TempDir(), "notifier.id") + + signPubHex, endpoint, err := provisionBot(store, "notifier", membership.RoleMember, out) + if err != nil { + t.Fatalf("provisionBot: %v", err) + } + + // Registered in the allowlist with the right handle/role/status. + u, err := store.GetUser(signPubHex) + if err != nil { + t.Fatalf("get provisioned user: %v", err) + } + if u.Handle != "notifier" || u.Role != membership.RoleMember || u.Status != membership.StatusActive { + t.Fatalf("provisioned user row wrong: %+v", u) + } + + // And it shows up in user list (the `user list` surface). + users, err := store.ListUsers() + if err != nil { + t.Fatalf("list users: %v", err) + } + found := false + for _, x := range users { + if x.SignPub == signPubHex { + found = true + } + } + if !found { + t.Fatalf("provisioned bot missing from user list: %+v", users) + } + + // Credentials file exists, is 0600, and round-trips through LoadIdentity to the + // same signing key + endpoint (no-friction contract). + info, err := os.Stat(out) + if err != nil { + t.Fatalf("stat out file: %v", err) + } + if perm := info.Mode().Perm(); perm != 0o600 { + t.Fatalf("out file perms = %o, want 600", perm) + } + id, err := client.LoadIdentity(out) + if err != nil { + t.Fatalf("LoadIdentity(out): %v", err) + } + if got := hex.EncodeToString(id.SignPub); got != signPubHex { + t.Fatalf("loaded sign_pub %q != provisioned %q", got, signPubHex) + } + if got := frame.EndpointID(id.SignPub); got != endpoint { + t.Fatalf("loaded endpoint %q != reported %q", got, endpoint) + } +} + +// TestProvisionBotDefaultRole: an empty role defaults to member. +func TestProvisionBotDefaultRole(t *testing.T) { + store := openTestStore(t) + out := filepath.Join(t.TempDir(), "bot.id") + signPubHex, _, err := provisionBot(store, "defrole", "", out) + if err != nil { + t.Fatalf("provisionBot: %v", err) + } + u, err := store.GetUser(signPubHex) + if err != nil { + t.Fatalf("get user: %v", err) + } + if u.Role != membership.RoleMember { + t.Fatalf("empty role should default to member, got %q", u.Role) + } +} + +// TestProvisionBotSignPubAlreadyRegistered is the error path: provisioning an +// identity whose signing key is already in the allowlist fails with a clear error +// (not a panic) AND does not write a credentials file (no half-provisioned bot). +func TestProvisionBotSignPubAlreadyRegistered(t *testing.T) { + store := openTestStore(t) + + // Pre-register a key, then try to provision a bot with that SAME identity. + id, err := cs.GenerateIdentity() + if err != nil { + t.Fatalf("generate identity: %v", err) + } + signPubHex := hex.EncodeToString(id.SignPub) + if err := store.AddUser(signPubHex, "preexisting", membership.RoleMember); err != nil { + t.Fatalf("pre-register: %v", err) + } + + out := filepath.Join(t.TempDir(), "dup.id") + _, _, err = provisionBotWithIdentity(store, id, "dupbot", membership.RoleMember, out) + if err == nil { + t.Fatalf("provisioning an already-registered key should error") + } + if _, statErr := os.Stat(out); !os.IsNotExist(statErr) { + t.Fatalf("credentials file must NOT be written on a duplicate-key failure (stat err = %v)", statErr) + } +} + +// TestProvisionBotOutExists is the other error path: an existing --out file is +// refused BEFORE the store is mutated, so the run leaves no orphan user behind. +func TestProvisionBotOutExists(t *testing.T) { + store := openTestStore(t) + out := filepath.Join(t.TempDir(), "taken.id") + if err := os.WriteFile(out, []byte("preexisting credentials"), 0o600); err != nil { + t.Fatalf("seed out file: %v", err) + } + + _, _, err := provisionBot(store, "clobber", membership.RoleMember, out) + if err == nil { + t.Fatalf("provisioning over an existing out file should error") + } + // The store must be untouched: no user was registered. + users, err := store.ListUsers() + if err != nil { + t.Fatalf("list users: %v", err) + } + if len(users) != 0 { + t.Fatalf("no user should be registered when out exists, got %+v", users) + } +} diff --git a/cmd/membershipd/main.go b/cmd/membershipd/main.go index e28a7283..5670677f 100644 --- a/cmd/membershipd/main.go +++ b/cmd/membershipd/main.go @@ -47,6 +47,14 @@ func main() { runMigrateCLI(os.Args[2:]) return } + // `membershipd bot add ...` provisions a bus identity for an automated process + // in one command (mint identity + register + write 0600 credentials). It shares + // the same trusted-host model and store plumbing as the user CLI, so it is + // dispatched here before the server flag set parses os.Args. + if len(os.Args) > 1 && os.Args[1] == "bot" { + runBotCLI(os.Args[2:]) + return + } var ( bind = flag.String("bind", "127.0.0.1", "network interface to bind the HTTP API and the embedded NATS to; use 0.0.0.0 to accept LAN/remote peers") diff --git a/pkg/client/identity.go b/pkg/client/identity.go index 2a2aa537..742db717 100644 --- a/pkg/client/identity.go +++ b/pkg/client/identity.go @@ -75,6 +75,22 @@ func LoadOrCreateIdentity(path string) (cs.Identity, error) { return id, nil } +// WriteNewIdentity writes id to path in the canonical identity-file format read +// by LoadIdentity, but REFUSES to overwrite an existing file: provisioning a new +// identity must never silently clobber another process's private keys. The file +// is created 0600 (it holds private keys). It is the write half of one-command +// bot provisioning (`membershipd bot add --out `): the freshly minted +// identity it writes is exactly what LoadIdentity reconstructs, so a bot binary +// (worker/clientcheck) consumes the credentials with no extra conversion step. +func WriteNewIdentity(path string, id cs.Identity) error { + if _, err := os.Stat(path); err == nil { + return fmt.Errorf("client: identity file %q already exists; refusing to overwrite", path) + } else if !os.IsNotExist(err) { + return fmt.Errorf("client: stat identity %q: %w", path, err) + } + return saveIdentity(path, id) +} + func saveIdentity(path string, id cs.Identity) error { if dir := filepath.Dir(path); dir != "" { if err := os.MkdirAll(dir, 0o755); err != nil { diff --git a/pkg/membership/directory_test.go b/pkg/membership/directory_test.go new file mode 100644 index 00000000..5a495178 --- /dev/null +++ b/pkg/membership/directory_test.go @@ -0,0 +1,153 @@ +package membership + +import ( + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "net/http" + "testing" + + cs "fn-registry/functions/cybersecurity" + + "github.com/enmanuel/unibus/pkg/frame" +) + +// directory signs a GET /api/directory as id and decodes the response envelope. +func directory(t *testing.T, h *authHarness, id cs.Identity, n int) (int, directoryResp) { + t.Helper() + code, body := signedJSON(t, h, "GET", "/api/directory", nil, id, n) + var resp directoryResp + if code == http.StatusOK { + if err := json.Unmarshal([]byte(body), &resp); err != nil { + t.Fatalf("decode directory: %v (%s)", err, body) + } + } + return code, resp +} + +// findMember returns the directory entry for a signing key (case-insensitive). +func findMember(members []directoryMember, signPub string) (directoryMember, bool) { + want := normalizeSignPub(signPub) + for _, m := range members { + if normalizeSignPub(m.SignPub) == want { + return m, true + } + } + return directoryMember{}, false +} + +// TestDirectoryGolden is the happy path: an authenticated bus user (here the seed +// admin alice, plus a registered member bob) reads the directory and gets every +// active user's handle, role, and an endpoint derived server-side from the +// sign_pub with the bus's own construction (frame.EndpointID). Two users in -> +// 200 with both handles and correct endpoints. +func TestDirectoryGolden(t *testing.T) { + h := newAuthHarness(t, AuthEnforce) + + bob, _ := cs.GenerateIdentity() + register(t, h, bob, "bob") // role member + bobPub := hex.EncodeToString(bob.SignPub) + + code, resp := directory(t, h, h.alice, 1) + if code != http.StatusOK { + t.Fatalf("directory should be 200 for an authenticated user, got %d", code) + } + + aliceRow, ok := findMember(resp.Members, h.alicePub) + if !ok { + t.Fatalf("seed admin alice missing from directory: %+v", resp.Members) + } + if aliceRow.Handle != "alice" || aliceRow.Role != RoleAdmin { + t.Fatalf("alice row wrong: %+v", aliceRow) + } + if want := frame.EndpointID(h.alice.SignPub); aliceRow.Endpoint != want { + t.Fatalf("alice endpoint = %q, want %q", aliceRow.Endpoint, want) + } + + bobRow, ok := findMember(resp.Members, bobPub) + if !ok { + t.Fatalf("registered member bob missing from directory: %+v", resp.Members) + } + if bobRow.Handle != "bob" || bobRow.Role != RoleMember { + t.Fatalf("bob row wrong: %+v", bobRow) + } + if want := frame.EndpointID(bob.SignPub); bobRow.Endpoint != want { + t.Fatalf("bob endpoint = %q, want %q", bobRow.Endpoint, want) + } +} + +// TestDirectoryUnauthenticatedRejected is the auth contract: under enforce an +// unsigned GET /api/directory is rejected with 401 by the middleware, before the +// handler ever runs — the directory is not public. +func TestDirectoryUnauthenticatedRejected(t *testing.T) { + h := newAuthHarness(t, AuthEnforce) + req, _ := http.NewRequest("GET", h.ts.URL+"/api/directory", nil) + code, _ := do(t, req) + if code != http.StatusUnauthorized { + t.Fatalf("unsigned directory request under enforce should be 401, got %d", code) + } +} + +// TestDirectoryExcludesRevoked: a revoked user must not appear in the directory +// (status=active filter), while active users still do. +func TestDirectoryExcludesRevoked(t *testing.T) { + h := newAuthHarness(t, AuthEnforce) + + gone, _ := cs.GenerateIdentity() + register(t, h, gone, "gone") + gonePub := hex.EncodeToString(gone.SignPub) + if err := h.store.RevokeUser(gonePub); err != nil { + t.Fatalf("revoke gone: %v", err) + } + + code, resp := directory(t, h, h.alice, 1) + if code != http.StatusOK { + t.Fatalf("directory should be 200, got %d", code) + } + if _, ok := findMember(resp.Members, gonePub); ok { + t.Fatalf("revoked user must not appear in directory: %+v", resp.Members) + } + if _, ok := findMember(resp.Members, h.alicePub); !ok { + t.Fatalf("active admin alice should still appear: %+v", resp.Members) + } +} + +// TestDirectoryEndpointParity pins the server-side endpoint derivation to the +// cross-language parity vector emitted by cmd/busvectors (and consumed by the +// uniweb crypto.ts endpointID test): for a FIXED sign_pub the directory must +// return the exact base64url(sha256(signPub)) endpoint, byte-for-byte. The +// expected value is recomputed here independently of frame.EndpointID so the test +// fails if the handler ever diverges from the canonical construction. +func TestDirectoryEndpointParity(t *testing.T) { + // Vector from cmd/busvectors (seed 000102..1f -> Ed25519 public key). + const vectorSignPub = "03a107bff3ce10be1d70dd18e74bc09967e4d6309ba50d5f1ddc8664125531b8" + const vectorEndpoint = "Vkdap1RjR0wChd9dvyvKtz2mUTWIOem3dIGy6rEHcIw" + + // Independent recomputation: base64url(sha256(raw signPub bytes)), unpadded. + raw, err := hex.DecodeString(vectorSignPub) + if err != nil { + t.Fatalf("decode vector sign_pub: %v", err) + } + sum := sha256.Sum256(raw) + if got := base64.RawURLEncoding.EncodeToString(sum[:]); got != vectorEndpoint { + t.Fatalf("vector self-check: recomputed endpoint %q != pinned %q", got, vectorEndpoint) + } + + h := newAuthHarness(t, AuthEnforce) + if err := h.store.AddUser(vectorSignPub, "vectorbot", RoleMember); err != nil { + t.Fatalf("add vector user: %v", err) + } + + code, resp := directory(t, h, h.alice, 1) + if code != http.StatusOK { + t.Fatalf("directory should be 200, got %d", code) + } + row, ok := findMember(resp.Members, vectorSignPub) + if !ok { + t.Fatalf("vector user missing from directory: %+v", resp.Members) + } + if row.Endpoint != vectorEndpoint { + t.Fatalf("endpoint parity broken: directory returned %q, want %q", row.Endpoint, vectorEndpoint) + } +} diff --git a/pkg/membership/server.go b/pkg/membership/server.go index bd7b7f4e..8d39d877 100644 --- a/pkg/membership/server.go +++ b/pkg/membership/server.go @@ -3,6 +3,7 @@ package membership import ( "bytes" "context" + "encoding/hex" "encoding/json" "errors" "fmt" @@ -414,6 +415,12 @@ func (s *Server) routes() { s.mux.HandleFunc("GET /users", s.handleListUsers) s.mux.HandleFunc("POST /users", s.handleAddUser) s.mux.HandleFunc("POST /users/{signpub}/revoke", s.handleRevokeUser) + // Member directory — any authenticated bus user (member or admin) may map an + // endpoint id back to its human handle, so clients can render readable sender + // names instead of raw endpoint hashes. Unlike /users it is NOT admin-only and + // returns only active users; under enforce the auth middleware already rejects + // an unauthenticated caller with 401 before this handler runs (uniweb/0002). + s.mux.HandleFunc("GET /api/directory", s.handleDirectory) } // ---- wire types ----------------------------------------------------------- @@ -512,6 +519,24 @@ type addUserReq struct { Role string `json:"role"` } +// directoryMember is one entry of the GET /api/directory response: enough for a +// client to map a message's endpoint id (which the bus stamps on every frame) +// back to a readable handle. endpoint is derived server-side from sign_pub with +// the SAME construction the bus uses (frame.EndpointID = base64url(sha256(signPub)), +// unpadded), so it matches the sender id a client already has byte-for-byte. +type directoryMember struct { + SignPub string `json:"sign_pub"` + Endpoint string `json:"endpoint"` + Handle string `json:"handle"` + Role string `json:"role"` +} + +// directoryResp is the GET /api/directory response envelope. The members key is a +// stable contract consumed by the browser client; do not rename it. +type directoryResp struct { + Members []directoryMember `json:"members"` +} + // ---- helpers -------------------------------------------------------------- func writeJSON(w http.ResponseWriter, code int, v any) { @@ -857,6 +882,41 @@ func (s *Server) handleListUsers(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, out) } +// handleDirectory returns the active bus user directory so a client can resolve a +// sender's endpoint id to a readable handle. Unlike handleListUsers it is NOT +// admin-only: every authenticated bus user may read it (the auth middleware has +// already verified the caller is an active user under enforce, and rejected an +// unauthenticated one with 401). Only active users are listed, and each endpoint +// is computed server-side from the user's sign_pub with frame.EndpointID — the +// exact derivation the bus stamps on every frame, so the returned endpoint matches +// the sender id a client already holds. A user with a malformed sign_pub (which +// the add path rejects, so this is defensive) is skipped rather than failing the +// whole listing. +func (s *Server) handleDirectory(w http.ResponseWriter, r *http.Request) { + users, err := s.store.ListUsers() + if err != nil { + writeServerErr(w, r, http.StatusInternalServerError, "internal error", err) + return + } + out := make([]directoryMember, 0, len(users)) + for _, u := range users { + if u.Status != StatusActive { + continue + } + signPub, err := hex.DecodeString(u.SignPub) + if err != nil || len(signPub) != 32 { + continue + } + out = append(out, directoryMember{ + SignPub: u.SignPub, + Endpoint: frame.EndpointID(signPub), + Handle: u.Handle, + Role: u.Role, + }) + } + writeJSON(w, http.StatusOK, directoryResp{Members: out}) +} + // handleAddUser registers a new bus user from an admin-supplied Ed25519 signing // key. It mirrors the `membershipd user add` CLI: the key must be 64-hex, the // role must be admin or member (empty defaults to member), and re-adding an