110 lines
4.6 KiB
Go
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)
|
|
})
|
|
}
|