Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions apps/edge-api/cmd/server/gated_routes.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,14 @@ import (
// serves and assert the denial, so removing or swapping a gate turns red.

// registerRAGRoutes attaches the RAG surface to mux behind FeatureRAG.
func registerRAGRoutes(mux *http.ServeMux, gate *featuregate.Gate, ragHandler http.Handler) {
func registerRAGRoutes(mux httpMux, gate *featuregate.Gate, ragHandler http.Handler) {
mux.Handle("/v1/rag/", gate.Require(featuregate.FeatureRAG)(ragHandler))
}

// registerAgentTaskRoutes attaches the agent-task lifecycle surface to mux
// behind FeatureCowork. Both the exact path and the subtree share one gated
// handler so a request to either is denied identically.
func registerAgentTaskRoutes(mux *http.ServeMux, gate *featuregate.Gate, taskHandler http.Handler) {
func registerAgentTaskRoutes(mux httpMux, gate *featuregate.Gate, taskHandler http.Handler) {
gated := gate.Require(featuregate.FeatureCowork)(taskHandler)
mux.Handle("/v1/agent/tasks", gated)
mux.Handle("/v1/agent/tasks/", gated)
Expand All @@ -35,7 +35,7 @@ func registerAgentTaskRoutes(mux *http.ServeMux, gate *featuregate.Gate, taskHan
// CRUD surface to mux behind FeatureCowork: deployments (tenants) without
// Cowork enabled get the same 403 the task routes return, never a 404, and
// removing or swapping this gate turns gated_routes_test.go red.
func registerAgentScheduleRoutes(mux *http.ServeMux, gate *featuregate.Gate, scheduleHandler http.Handler) {
func registerAgentScheduleRoutes(mux httpMux, gate *featuregate.Gate, scheduleHandler http.Handler) {
gated := gate.Require(featuregate.FeatureCowork)(scheduleHandler)
mux.Handle("/v1/agent/schedules", gated)
mux.Handle("/v1/agent/schedules/", gated)
Expand Down
31 changes: 26 additions & 5 deletions apps/edge-api/cmd/server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -144,8 +144,11 @@ func main() {
// Initialize Prometheus metrics registry for edge-api.
edgeMetrics, promRegistry := proxy.NewEdgeMetrics()

// Create the main mux
mux := http.NewServeMux()
// Create the main mux. routeRecorder (route_recorder.go) records every
// pattern registered through it, so the boot-time assertMatrixCoverage
// call below can catch a route shipped with zero support-matrix.json
// coverage, without a hand-kept parallel list of routes.
mux := newRouteRecorder()

// Infrastructure routes (no unsupported middleware). /metrics is not among
// them; it is served on metricsListenAddr instead.
Expand Down Expand Up @@ -280,7 +283,7 @@ func main() {
// roster here is what keeps Open WebUI's get_available_voices from falling
// back to its hardcoded alloy-style list (#996); gating it would silently
// reinstate that fallback. See audio.VoicesHandler for the full rationale.
mux.Handle("/v1/audio/voices", audio.VoicesHandler())
registerAudioVoicesRoute(mux)

log.Printf("S3 storage enabled: images=%s, files=%s", storageCfg.ImagesBucket, storageCfg.FilesBucket)

Expand Down Expand Up @@ -577,6 +580,14 @@ func main() {
artifactsHandler.Register(mux)
}

// Boot-time route/matrix drift guard (route_recorder.go). Refuses to
// start rather than silently 404 a shipped route: see
// assertMatrixCoverage's doc comment for exactly what this does and does
// not catch.
if err := assertMatrixCoverage(mux.Patterns(), m); err != nil {
log.Fatal(err)
}

var handler http.Handler = mux
handler = middleware.UnsupportedEndpointMiddleware(m)(handler)
// budgetGate resolves the workspace identity from the API-key bearer
Expand Down Expand Up @@ -685,7 +696,7 @@ const metricsListenAddr = ":9102"
// inverted that polarity, so a fully healthy edge-api reported 503 and an
// actual control-plane outage reported 200 -- a second lie in the exact fix
// meant to stop this endpoint from lying.
func registerInfraRoutes(mux *http.ServeMux, specPath string, degraded func() bool) {
func registerInfraRoutes(mux httpMux, specPath string, degraded func() bool) {
mux.HandleFunc("/health", handleHealth(degraded))
mux.Handle("/docs/", docs.SwaggerHandler(specPath))
}
Expand Down Expand Up @@ -777,7 +788,7 @@ const (
// #293: Voice had a gate constant but no route ever called it). Images,
// files, and batches are ungated here by design — their own gate keys, if
// any, are out of this step's scope.
func registerMediaFileBatchRoutes(mux *http.ServeMux, imagesHandler, audioHandler, filesHandler, batchesHandler http.Handler, voiceMW func(http.Handler) http.Handler) {
func registerMediaFileBatchRoutes(mux httpMux, imagesHandler, audioHandler, filesHandler, batchesHandler http.Handler, voiceMW func(http.Handler) http.Handler) {
images := http.MaxBytesHandler(imagesHandler, imagesMaxBody)
audio := voiceMW(http.MaxBytesHandler(audioHandler, audioMaxBody))
mux.Handle("/v1/images/generations", images)
Expand All @@ -794,6 +805,16 @@ func registerMediaFileBatchRoutes(mux *http.ServeMux, imagesHandler, audioHandle
mux.Handle("/v1/batches/", batchesHandler)
}

// registerAudioVoicesRoute attaches GET /v1/audio/voices. Extracted (issue
// #1079 shipped this as a bare inline mux.Handle call, which is exactly what
// left it with no support-matrix.json entry) so route_matrix_guard_test.go
// can register it in isolation and exercise assertMatrixCoverage against it.
// See the call site in main() for why this route deliberately sits outside
// every auth gate.
func registerAudioVoicesRoute(mux httpMux) {
mux.Handle("/v1/audio/voices", audio.VoicesHandler())
}

func jwtAwareChatHandler(jwtHandler, apiKeyHandler http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if user, ok := auth.UserFrom(r.Context()); ok && user != nil {
Expand Down
84 changes: 84 additions & 0 deletions apps/edge-api/cmd/server/route_matrix_guard_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
package main

import (
"net/http"
"os"
"testing"

"github.com/sakibsadmanshajib/hive/apps/edge-api/internal/artifacts"
"github.com/sakibsadmanshajib/hive/apps/edge-api/internal/featuregate"
"github.com/sakibsadmanshajib/hive/apps/edge-api/internal/matrix"
)

// TestAssertMatrixCoverage_RealRegistrations drives the real,
// production route-registration functions -- the exact ones main() calls,
// unmodified -- against a routeRecorder, then checks the patterns they
// register against the real committed support-matrix.json.
//
// This is what replaces unsupported_integration_test.go's hand-kept case
// list for the routes it reaches: a new mux.Handle/HandleFunc call inside
// any of these functions needs no test-file edit to be checked here, because
// the check reads the registration code itself, not a parallel list of
// paths someone remembered to type in.
//
// Scope: this test reaches every Hive-proprietary route family (RAG, agent
// tasks, agent schedules, audio voices, artifacts, feature-gate) plus the
// infra and media/file/batch groups -- every registration function main()
// calls with dependencies cheap enough to fake. It does not reach the
// long-stable OpenAI-compatible surface wired inline in main() against real
// provider/DB/routing clients (chat/completions, models, messages, and
// friends): those have never been the site of this defect, and a unit test
// has no business constructing that graph. main()'s own boot-time
// assertMatrixCoverage call (see main.go) is what reaches literally
// everything, provider clients included, every time the process starts.
func TestAssertMatrixCoverage_RealRegistrations(t *testing.T) {
matrixPath := "../../../../packages/openai-contract/matrix/support-matrix.json"
if override := os.Getenv("HIVE_MATRIX_PATH_FOR_TEST"); override != "" {
matrixPath = override
}
m, err := matrix.LoadMatrix(matrixPath)
if err != nil {
t.Fatalf("loading support matrix from %s: %v", matrixPath, err)
}

spy := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
})
identityMW := func(h http.Handler) http.Handler { return h }
gate := featuregate.New(featuregate.Config{
ControlPlaneURL: stubControlPlane(t, featuregate.FeatureRAG, featuregate.FeatureCowork, featuregate.FeatureVoice),
})

mux := newRouteRecorder()
registerInfraRoutes(mux, "openapi.yaml", func() bool { return false })
registerRAGRoutes(mux, gate, spy)
registerAgentTaskRoutes(mux, gate, spy)
registerAgentScheduleRoutes(mux, gate, spy)
registerAudioVoicesRoute(mux)
registerMediaFileBatchRoutes(mux, spy, spy, spy, spy, identityMW)
mux.Handle("/v1/featuregate", featuregate.NewStateHandler(gate))
artifacts.NewHandler(nil, nil, "", nil, "", nil).Register(mux)

if err := assertMatrixCoverage(mux.Patterns(), m); err != nil {
t.Error(err)
}
}

// TestAssertMatrixCoverage_CatchesAnUnlistedRoute is the negative case: a
// pattern with genuinely zero matrix coverage must be reported, so a
// regression in assertMatrixCoverage itself (e.g. an overly permissive
// prefix match) does not silently stop catching the exact bug this guard
// exists for.
func TestAssertMatrixCoverage_CatchesAnUnlistedRoute(t *testing.T) {
m, err := matrix.LoadMatrixFromBytes([]byte(`{"version":"0","generated":"","endpoints":[
{"method":"GET","path":"/v1/models","status":"supported_now","phase":null,"notes":""}
]}`))
if err != nil {
t.Fatalf("loading fixture matrix: %v", err)
}

err = assertMatrixCoverage([]string{"/v1/models", "/v1/audio/voices"}, m)
if err == nil {
t.Fatal("expected an error for /v1/audio/voices, got nil")
}
}
87 changes: 87 additions & 0 deletions apps/edge-api/cmd/server/route_recorder.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
package main

import (
"fmt"
"net/http"
"strings"

"github.com/sakibsadmanshajib/hive/apps/edge-api/internal/matrix"
)

// httpMux is the subset of *http.ServeMux's API that route registration
// helpers below use. Accepting this instead of the concrete type lets main
// swap in routeRecorder and have every registration recorded, at zero cost
// to any existing call site: *http.ServeMux already satisfies it, so
// production code and the existing tests that build a plain http.NewServeMux()
// (gated_routes_test.go) keep compiling unchanged.
type httpMux interface {
Handle(pattern string, handler http.Handler)
HandleFunc(pattern string, handler func(http.ResponseWriter, *http.Request))
}

// routeRecorder wraps a real *http.ServeMux, recording every pattern
// registered through it. main() serves through it exactly like a plain mux;
// assertMatrixCoverage then reads Patterns() back, so the source of truth
// for "what routes does edge-api actually serve" is the registration code
// itself, never a parallel hand-kept list.
//
// Buglog entry matrix-missing-proprietary-endpoints (2026-07-17) fixed a
// whole family of proprietary routes missing from support-matrix.json with
// hand-added matrix entries plus a hand-listed regression test
// (unsupported_integration_test.go). The same defect shipped again for
// GET /v1/audio/voices (#1079) and the /v1/agent/schedules family (#1081):
// the hand list only ever covered the routes someone remembered to add to
// it. routeRecorder exists so a new mux.Handle/HandleFunc call on the main
// server mux can no longer ship with zero matrix coverage: main() asserts
// against what this recorder actually saw, not against anyone's memory of
// the route set.
type routeRecorder struct {
*http.ServeMux
patterns []string
}

func newRouteRecorder() *routeRecorder {
return &routeRecorder{ServeMux: http.NewServeMux()}
}

func (r *routeRecorder) Handle(pattern string, handler http.Handler) {
r.patterns = append(r.patterns, pattern)
r.ServeMux.Handle(pattern, handler)
}

func (r *routeRecorder) HandleFunc(pattern string, handler func(http.ResponseWriter, *http.Request)) {
r.patterns = append(r.patterns, pattern)
r.ServeMux.HandleFunc(pattern, handler)
}

// Patterns returns every pattern registered through r so far.
func (r *routeRecorder) Patterns() []string {
return append([]string(nil), r.patterns...)
}

// assertMatrixCoverage returns an error naming every /v1/-prefixed pattern
// in patterns that m has no entry for at all (see SupportMatrix.HasCoverage).
// Non-/v1/ patterns are skipped: UnsupportedEndpointMiddleware only ever
// checks /v1/ paths, so the matrix has no opinion on anything else.
//
// This catches a route family shipping with zero matrix awareness, the
// shape that has recurred twice. It cannot catch a single new suffix added
// inside a handler's own internal path dispatch under an already-covered
// prefix (see HasCoverage's doc comment); that still needs a human to add
// the matrix entry by hand.
func assertMatrixCoverage(patterns []string, m *matrix.SupportMatrix) error {
var missing []string
for _, p := range patterns {
if !strings.HasPrefix(p, "/v1/") {
continue
}
if !m.HasCoverage(p) {
missing = append(missing, p)
}
}
if len(missing) == 0 {
return nil
}
return fmt.Errorf("support-matrix.json has no entry for %d registered route(s), edge-api refuses to start: %s",
len(missing), strings.Join(missing, ", "))
}
10 changes: 9 additions & 1 deletion apps/edge-api/internal/artifacts/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -83,8 +83,16 @@ func NewHandler(store Store, blobs BlobStorage, bucket string, claimsParser Clai
}
}

// muxHandleFunc is the subset of *http.ServeMux's API Register needs.
// Accepting this instead of the concrete type lets a caller (edge-api's
// boot-time route/matrix coverage guard, cmd/server/route_recorder.go) pass
// a recording wrapper without Register knowing anything about that.
type muxHandleFunc interface {
HandleFunc(pattern string, handler func(http.ResponseWriter, *http.Request))
}

// Register mounts every artifacts route on mux.
func (h *Handler) Register(mux *http.ServeMux) {
func (h *Handler) Register(mux muxHandleFunc) {
mux.HandleFunc("/v1/artifacts", h.handleCreate)
mux.HandleFunc("/v1/artifacts/", h.routeManage)
mux.HandleFunc("/artifacts/", h.routeServe)
Expand Down
42 changes: 42 additions & 0 deletions apps/edge-api/internal/matrix/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,48 @@ func (m *SupportMatrix) Lookup(method, path string) EndpointStatus {
return StatusUnknown
}

// HasCoverage reports whether m has any entry at all for a raw mux
// registration pattern: an exact path match, or, for a trailing-slash
// subtree pattern such as "/v1/agent/schedules/", any entry whose path
// falls under that subtree. It is deliberately coarser than Lookup, which
// answers "is this exact request allowed" for one method+path; HasCoverage
// answers "does support-matrix.json know this route exists at all",
// regardless of method or status, which is what a boot-time drift guard
// over registered mux patterns can meaningfully ask (see
// assertMatrixCoverage in apps/edge-api/cmd/server). It cannot see past a
// registered prefix into a handler's own internal path-suffix dispatch
// (routeItem/routeTaskByID-style switches), so a new suffix added under an
// already-covered prefix still needs its own matrix entry added by hand.
//
// The subtree/exact distinction must key off the mux pattern's own trailing
// slash, not off a trimmed copy: only a pattern registered as a genuine
// subtree ("/v1/foo/") may match a descendant entry ("/v1/foo/{id}"), and an
// exact pattern ("/v1/foo") must never be satisfied by a descendant entry it
// cannot actually dispatch. Symmetrically, a subtree pattern must never be
// satisfied by an entry that sits exactly at the trimmed prefix ("/v1/foo")
// instead of under it, since Lookup itself draws that same line: a request
// to "/v1/foo" never matches a matrix entry with the extra "{id}" segment,
// and a request under "/v1/foo/" never matches an entry sitting bare at
// "/v1/foo". Diverging from Lookup here is exactly the failure mode this
// guard exists to prevent, so the non-subtree arm below reuses
// pathMatchesTemplate, the same matcher Lookup itself uses.
func (m *SupportMatrix) HasCoverage(pattern string) bool {
if strings.HasSuffix(pattern, "/") {
for _, ep := range m.Endpoints {
if strings.HasPrefix(ep.Path, pattern) {
return true
}
}
return false
}
for _, ep := range m.Endpoints {
if pathMatchesTemplate(ep.Path, pattern) {
return true
}
}
return false
}

// buildLookup constructs the internal lookup map from the endpoints slice.
func (m *SupportMatrix) buildLookup() {
m.lookup = make(map[string]EndpointStatus, len(m.Endpoints))
Expand Down
Loading
Loading