feat(membership): add GET /api/directory for endpoint->handle resolution
Authenticated bus users (member or admin) can now map a sender's endpoint id back to a readable handle. The endpoint is derived server-side from each user's sign_pub with frame.EndpointID (base64url(sha256(signPub)), unpadded), matching the bus's own construction byte-for-byte. Only active users are listed; under enforce the existing auth middleware rejects an unauthenticated caller with 401. Tests cover the golden path (two users -> 200 with handles + endpoints), the auth contract (unsigned -> 401), revoked-user exclusion, and endpoint parity against the cross-language vector from cmd/busvectors. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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