package main import ( "errors" "fmt" "net/http" chassisauth "git.d-ma.be/mathias/mcp-chassis/auth" "github.com/mathiasbq/hyperguild/ingestion/internal/api" "github.com/mathiasbq/hyperguild/ingestion/internal/metrics" ) // publicRoutes is the complete set of patterns a caller may register on the // outer mux, outside the REST API's auth (hyperguild#149). Each authenticates // itself or is meant to be open: /capture (bearer/JWT), the webhook (HMAC), // /oauth/token (client secret) and OAuth metadata (open). /mcp and /metrics // are not here: buildMux builds them itself (hyperguild#154). A REST pattern // added here would beat the authenticated catch-all by specificity and ship // open, which is why buildMux refuses anything else. var publicRoutes = map[string]bool{ "POST /capture": true, "POST /webhooks/brain-sync": true, "GET /.well-known/oauth-protected-resource": true, "GET /.well-known/oauth-authorization-server": true, "POST /oauth/token": true, } // restAuth is the credential the REST API checks: the same as /mcp's. type restAuth struct { token string validator *chassisauth.JWTValidator realm string resourceMetadataURL string } // buildMux builds the handler main passes to ListenAndServe unchanged. The // REST API is mounted at "/" and /mcp is registered, both behind bearer auth // that buildMux constructs itself from auth (one credential), so a caller // cannot pass a no-op. GET /metrics serves reg, and the whole router is // wrapped in reg's middleware. Other public handlers are registered as given, // and only for patterns on the publicRoutes allowlist. It returns an // http.Handler, not the *ServeMux, so nothing can register a route on the // result. What stays untested is main's own line: a handler main built around // this one before ListenAndServe would not be seen by these tests. func buildMux( auth restAuth, h *api.Handler, mcpHandler http.Handler, reg *metrics.Registry, public map[string]http.Handler, ) (http.Handler, error) { if auth.token == "" { return nil, errors.New("buildMux: REST auth needs a token") } if mcpHandler == nil || reg == nil { return nil, errors.New("buildMux: needs the MCP handler and the metrics registry") } mux := http.NewServeMux() for pattern, handler := range public { if !publicRoutes[pattern] { return nil, fmt.Errorf("buildMux: %q is not an allowed public route", pattern) } mux.Handle(pattern, handler) } bearer := func(next http.Handler) http.Handler { return chassisauth.BearerMiddleware(auth.token, auth.validator, auth.realm, auth.resourceMetadataURL, next) } mux.Handle("/mcp", bearer(mcpHandler)) // /metrics is registered on the mux and so observed by its own // middleware, like any route. mux.Handle("GET /metrics", reg.Handler()) mountREST(mux, h, bearer) return reg.Middleware(mux), nil } // mountREST registers the REST API on its own mux and mounts it at "/" behind // auth: anything not registered as public lands here and is authenticated, // including routes added later. func mountREST(mux *http.ServeMux, h *api.Handler, auth func(http.Handler) http.Handler) { rest := http.NewServeMux() rest.HandleFunc("POST /query", h.Query) rest.HandleFunc("POST /write", h.Write) rest.HandleFunc("POST /index", h.Index) rest.HandleFunc("POST /ingest", h.Ingest) rest.HandleFunc("POST /ingest-path", h.IngestPath) rest.HandleFunc("POST /ingest-raw", h.IngestRaw) rest.HandleFunc("POST /backfill-refs", h.BackfillRefs) rest.HandleFunc("GET /pending", h.Pending) rest.HandleFunc("POST /promote", h.Promote) rest.HandleFunc("POST /backfill-embeddings", h.BackfillEmbeddings) rest.HandleFunc("GET /pass-rate", h.PassRate) mux.Handle("/", labelRESTPattern(rest, auth(rest))) } // labelRESTPattern sets the request's Pattern to the REST route it addresses // before auth runs, so the metrics middleware (which reads Pattern from the // same *http.Request) labels a refused request by its route rather than by // the "/" mount (hyperguild#155). A refused request to a path no route // matches keeps "/", so for refused requests the label set stays bounded by // the registered routes. That bound does not hold for authenticated // requests: an unregistered path reaches the REST mux, which 404s with an // empty Pattern, and the metrics middleware falls back to the raw URL path // (hyperguild#185). func labelRESTPattern(rest *http.ServeMux, next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if _, pattern := rest.Handler(r); pattern != "" { r.Pattern = pattern } next.ServeHTTP(w, r) }) }