go-http-routing-stub-auth-ordering
Root cause (C19 + C20): the auth gate was a global middleware wrapping the mux with no knowledge of stub routes, and /instance/* had no registration at all.
/instance/* existed in neither main.go's mux nor the shim mux → every request 404'd.PATCH /project/:id hit authMiddleware before the 501 stub → unauthenticated callers got 401 instead of 501.Fix (SPEC-017 §3.9): register + mount /instance/* as 501 stubs in both route tables, and replace the blunt "auth everything" gate with an ordering valve: auth is required only for GET on /project and /vcs. Everything else — /instance/* (fully public), non-GET stub calls, and unmounted paths — bypasses auth and lets the mux answer (501 or 404). This is the single point that reconciles the two suites: shim smoke (no-auth GET → 401) and full-contract (stubs → 501, unmounted → 404).
// isStubPath — auth-skip predicate (SPEC-017 §3.9):
// - /instance/* fully public, every method
// - /project/* & /vcs/*: GET auth-gated; non-GET reaches the 501 stub
func isStubPath(path, method string) bool {
if pathIn(path, "/instance") { return true }
if method == http.MethodGet { return false }
return pathIn(path, "/project") || pathIn(path, "/vcs")
}
// authRequired — the only auth gate in the stub stage.
func authRequired(path, method string) bool {
return method == http.MethodGet &&
(pathIn(path, "/project") || pathIn(path, "/vcs"))
}
func pathIn(path, prefix string) bool {
return path == prefix || strings.HasPrefix(path, prefix+"/")
}
// authMiddleware runs OUTSIDE the route table; the gate applies only to GET
// on /project|/vcs. PATCH /project/:id no longer hits 401 before the stub.
func authMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !authRequired(r.URL.Path, r.Method) {
next.ServeHTTP(w, r) // public / stub / unmounted: no auth check
return
}
if r.Header.Get("Authorization") != bearerToken {
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"})
return
}
next.ServeHTTP(w, r)
})
}
func stubHandler(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusNotImplemented, map[string]string{
"error": "not implemented", "method": r.Method, "path": r.URL.Path,
})
}
// C19: register + mount /instance/* in the PRODUCTION mux …
func newMux() *http.ServeMux {
mux := http.NewServeMux()
mux.HandleFunc("/project/", routeHandler)
mux.HandleFunc("/vcs/", routeHandler)
mux.HandleFunc("/instance/", stubHandler) // ← was missing → 404
return mux
}
// … and in the SHIM mux used by smoke tests.
func newShimMux() *http.ServeMux {
mux := http.NewServeMux()
mux.HandleFunc("/project/", routeHandler)
mux.HandleFunc("/vcs/", routeHandler)
mux.HandleFunc("/instance/", stubHandler) // ← was missing → 404
return mux
}
func newHandler() http.Handler { return authMiddleware(newMux()) }
func newShimHandler() http.Handler { return authMiddleware(newShimMux()) }
The fix is two lines of routing registration plus one ordering predicate — no stub-handler changes, no contract hacks.
Built as a runnable Go module (`go vet` clean, `gofmt` clean, `go test -race` clean). **13/13 tests pass**, plus a live `curl` smoke run against the real server: | Request (no auth) | Before | After | Contract | |---|---|---|---| | `GET /project/123` | 401 | **401** | shim smoke (GET auth) | | `GET /vcs/status` | 401 | **401** | shim smoke (GET auth) | | `PATCH /project/123` | 401 | **501** | C20 fixed — non-GET reaches stub | | `POST /vcs/branch` | 401 | **501** | non-GET reaches stub | | `GET /instance/abc` | 404 | **501** | C19 fixed — mounted, fully public | | `DELETE /instance/abc` | 404 | **501** | fully public, all methods | | `GET /instance` (exact) | 404 | 307→**501** | ServeMux canonical redirect, followed | | `GET /nope` | 404 | **404** | unmounted | Edge cases covered by the suite: - **Wrong token on a stub path** (`PATCH /project/1` with `Authorization: Bearer wrong`) → 501, not 401 — proves auth is *skipped*, not just relaxed. - **Wrong token on a gated path** (`GET /project/1` with bad token) → 401 — gate still active for GET. - **Prefix-boundary matching**: `/instancex`, `/projectx/1`, `/vcsx` → 404 (no leak into stub prefixes) for GET *and* PATCH, with and without auth. - **Both muxes**: shim mux returns 501 for `/instance/*` (C19 shim registration) and 401 for no-auth GET — the two suites now agree. - **Full ordering matrix** (12 combos): GET project/vcs no-auth→401, auth→501; non-GET project/vcs →501; instance any method →501. - **Race detector**: `go test -race` passes (no data races in middleware/handler state).
{"model": "deepseek-v4-flash", "problem_class": "go-http-routing-stub-auth-ordering", "result": "passed", "tests": 13}