From 8c3ddaa294c34f5e63582a66b01e1b46350dbd3e Mon Sep 17 00:00:00 2001 From: Egutierrez Date: Sun, 14 Jun 2026 16:05:00 +0200 Subject: [PATCH] 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) --- app.md | 8 ++++++-- pkg/membership/directory_test.go | 10 ++++++---- pkg/membership/server.go | 9 ++++++--- 3 files changed, 18 insertions(+), 9 deletions(-) diff --git a/app.md b/app.md index 9374a940..9f4daf4e 100644 --- a/app.md +++ b/app.md @@ -2,7 +2,7 @@ name: unibus lang: go domain: infra -version: 0.15.0 +version: 0.15.1 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: @@ -163,7 +163,10 @@ Para apuntar a un NATS externo en producción: `--nats-url nats://host:4222` en 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`: +expone la ruta del directorio. La SPA la llama como `GET /api/directory`, pero +Caddy hace `handle_path /api/*` y **stripea `/api`** antes de reenviar a +`membershipd`, así que el servidor la registra (como todas las rutas del +control-plane) SIN el prefijo: `GET /directory`: - **Auth:** el mismo middleware de firma que el resto del control-plane (cabeceras `X-Unibus-Pub/Ts/Nonce/Sig` sobre `CanonicalRequest`). NO es @@ -222,6 +225,7 @@ agent..{in,out} inbox/outbox de agente LLM (agent.scout.in) ## Capability growth log +- v0.15.1 (2026-06-14) — fix: la ruta del directorio se registraba con prefijo /api y Caddy lo stripeaba (404 en prod); corregida a /directory. - 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 diff --git a/pkg/membership/directory_test.go b/pkg/membership/directory_test.go index 5a495178..e9cee41e 100644 --- a/pkg/membership/directory_test.go +++ b/pkg/membership/directory_test.go @@ -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) diff --git a/pkg/membership/server.go b/pkg/membership/server.go index 8d39d877..01a182f8 100644 --- a/pkg/membership/server.go +++ b/pkg/membership/server.go @@ -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"`