Merge branch 'issue/names-bot-provisioning'
Integra GET /api/directory (endpoint->handle resolution) y el provisioning one-command de bots (membershipd bot add).
This commit is contained in:
@@ -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.<nombre>.{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 <name> --out <path> [--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
|
||||
|
||||
@@ -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 <name> human-readable name for the bot (shown in the directory)
|
||||
--out <path> where to write the bot credentials (refused if it exists)
|
||||
|
||||
optional flags:
|
||||
--role <role> admin or member (default member)
|
||||
--store <kind> sqlite (local DB, default) | kv (the live cluster's allowlist)
|
||||
--db <path> SQLite database path (--store sqlite; default ./local_files/unibus.db)
|
||||
|
||||
--store kv flags (defaults assume an on-node invocation):
|
||||
--nats-url <url> cluster NATS (default nats://127.0.0.1:4250)
|
||||
--internal-id-file <path> persisted internal service identity (default /opt/unibus/secrets/internal.id)
|
||||
--ca <path> CA cert pinning the data-plane TLS (default /opt/unibus/tls/ca.crt)
|
||||
--kv-replicas <n> 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 <path>) 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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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 <path>`): 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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user