/mcp's bearer was built in main.go and the buildMux tests used stubs, so dropping it would have kept CI green. buildMux now takes the MCP handler and wraps it with the same BearerMiddleware and credential as the REST API. "/mcp" and "GET /metrics" left the public allowlist, so passing either as a public route is refused. buildMux also registers GET /metrics and returns the metrics-wrapped handler, so main passes its result to ListenAndServe unchanged. Flip-tested: registering /mcp unwrapped turns TestBuildMuxRefusesMCPWithoutAValidToken red, and dropping the metrics wrap turns the four metrics tests red. Comments now claim only what is tested. The ListenAndServe line and the symlink tripwire wiring in main (#154 comment) are still untested and tracked in #182. Closes #154 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
109 lines
4.6 KiB
Go
109 lines
4.6 KiB
Go
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)
|
|
})
|
|
}
|