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:
@@ -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