fix(membership): register directory route as /directory, not /api/directory

Caddy strips /api via `handle_path /api/*` before forwarding to membershipd,
so the SPA's GET /api/directory arrives as GET /directory. The route was
registered with the /api prefix, so the stripped request hit no route and
returned 404 in production: the directory never resolved and uniweb fell back
to short ids. Every other control-plane route is registered without the prefix;
this aligns directory with them.

The unit test passed despite the bug because it requested /api/directory, the
same wrong path as the registration. Corrected the request paths to /directory
so the test now exercises the real production path (verified: reverting the
registration to /api/directory now makes TestDirectoryGolden fail with 404).

Bump 0.15.0 -> 0.15.1.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-14 16:05:00 +02:00
parent e48b092135
commit 8c3ddaa294
3 changed files with 18 additions and 9 deletions
+6 -4
View File
@@ -13,10 +13,12 @@ import (
"github.com/enmanuel/unibus/pkg/frame"
)
// directory signs a GET /api/directory as id and decodes the response envelope.
// directory signs a GET /directory as id and decodes the response envelope. The
// path has no /api prefix: Caddy strips /api before forwarding to membershipd, so
// the route is registered (and hit here) as /directory, matching production.
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)
code, body := signedJSON(t, h, "GET", "/directory", nil, id, n)
var resp directoryResp
if code == http.StatusOK {
if err := json.Unmarshal([]byte(body), &resp); err != nil {
@@ -78,11 +80,11 @@ func TestDirectoryGolden(t *testing.T) {
}
// TestDirectoryUnauthenticatedRejected is the auth contract: under enforce an
// unsigned GET /api/directory is rejected with 401 by the middleware, before the
// unsigned GET /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)
req, _ := http.NewRequest("GET", h.ts.URL+"/directory", nil)
code, _ := do(t, req)
if code != http.StatusUnauthorized {
t.Fatalf("unsigned directory request under enforce should be 401, got %d", code)
+6 -3
View File
@@ -420,7 +420,10 @@ func (s *Server) routes() {
// 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)
// Registered without the /api prefix like every other control-plane route:
// Caddy strips /api via handle_path /api/* before forwarding to membershipd,
// so the SPA's GET /api/directory arrives here as GET /directory.
s.mux.HandleFunc("GET /directory", s.handleDirectory)
}
// ---- wire types -----------------------------------------------------------
@@ -519,7 +522,7 @@ type addUserReq struct {
Role string `json:"role"`
}
// directoryMember is one entry of the GET /api/directory response: enough for a
// directoryMember is one entry of the GET /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)),
@@ -531,7 +534,7 @@ type directoryMember struct {
Role string `json:"role"`
}
// directoryResp is the GET /api/directory response envelope. The members key is a
// directoryResp is the GET /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"`