Files
hyperguild/ingestion/cmd/server/routes.go
T
mathiasandClaude Opus 5.5 b04a34837c
CI / Lint / Test / Vet (pull_request) Successful in 1m5s
CI / Mirror to GitHub (pull_request) Skipped
test(ingestion): /mcp's JWT validator and resource_metadata; narrow comments
PR #183 review (comment 15474):
- TestBuildMuxAnswers503ToAJWTOnMCPWhileTheIssuerIsUnresolved: building
  /mcp's middleware with a nil validator turns it red.
- TestBuildMuxMCP401CarriesResourceMetadataInWWWAuthenticate: building it
  with resourceMetadataURL "" turns it red.
- main.go, routes.go and the testMux comment now say exactly what is
  untested: main's restAuth fields and public map passed to buildMux, and
  ListenAndServe (hyperguild#182). Stale public-route list in the
  TestBuildMuxDoesNotWrapPublicRoutes comment corrected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 16:34:16 +02:00

110 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 call site: the restAuth fields and
// the public map main passes in, and ListenAndServe serving this handler
// unchanged (hyperguild#182). The tests build their own arguments.
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)
})
}