Problem class: go-chi-route-outside-auth-group-unauthorized-stream
Problem class: go-chi-route-outside-auth-group-unauthorized-stream
Repo / board: totalwindupflightsystems/consensus — DF-CONSENSUS-30
Base: b0634d9 (merged 7c7122d on master, 2026-09-26)
Files: internal/api/server.go, internal/api/events.go, internal/api/sse_auth_test.go, docs/API.md, specs/015-api-and-mcp.md
Integration tests that connected to the SSE endpoint with no API key (or a bad key) never got a 401. Instead the request hung and go test died with:
panic: test timed out after 20s
FAIL .../internal/api
The endpoint was an infinite Server-Sent Events stream, so once the request reached the handler no error status could ever be written — the handler blocked forever in its for/select loop.
Two independent defects, both of which must be fixed:
internal/api/server.go built the router like this:
r := chi.NewRouter()
r.Get("/v1/events", s.handleEvents) // <-- OUTSIDE the group
r.Group(func(r chi.Router) {
r.Use(s.authMiddleware)
// ...other protected routes...
})
Because /v1/events was mounted on the bare router, s.authMiddleware never ran for it. An unauthenticated request went straight into the SSE handler.
text/event-stream before checking key scopeInside handleEvents, the ordering was:
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.WriteHeader(http.StatusOK) // 200 committed here
flusher.Flush()
// ... later, or never: scope check
HTTP status is committed on the first WriteHeader/Flush. After that, an error status cannot be sent — writing one only corrupts the stream body. So even if a session-scoped key was used against a foreign or global stream, the handler had already committed 200 and could only loop forever.
The missing wall turns a clean authorization failure into a non-terminating stream. A test that asserts resp.StatusCode == 401 therefore blocks on http.Get / ReadAll until the Go test timeout fires. The panic (test timed out) is the detection signature — the bug is masked as a deadlock rather than a wrong status code.
internal/api/server.go:
r := chi.NewRouter()
r.Group(func(r chi.Router) {
r.Use(s.authMiddleware)
r.Get("/v1/events", s.handleEvents) // <-- now protected
// ...other protected routes...
})
s.authMiddleware now rejects a missing/invalid key with the standard JSON envelope before the handler is ever entered:
func (s *Server) authMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
key := r.Header.Get("X-API-Key")
k, ok := s.keys[key]
if key == "" || !ok {
writeError(w, http.StatusUnauthorized, "UNAUTHENTICATED",
"missing or invalid API key")
return
}
ctx := context.WithValue(r.Context(), scopeKey, k.Scope)
ctx = context.WithValue(ctx, sessionKey, k.Session)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
internal/api/events.go — the scope check must be the first thing the handler does:
func (s *Server) handleEvents(w http.ResponseWriter, r *http.Request) {
scope, _ := r.Context().Value(scopeKey).(string)
keySession, _ := r.Context().Value(sessionKey).(string)
sessionID := r.URL.Query().Get("session_id")
// CRITICAL: runs before Content-Type / WriteHeader / Flush.
// A session-scoped key may only stream its own session.
if scope == "session" && (sessionID == "" || sessionID != keySession) {
writeError(w, http.StatusForbidden, "FORBIDDEN", "key not scoped to session")
return
}
flusher, ok := w.(http.Flusher)
if !ok {
http.Error(w, "streaming unsupported", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.WriteHeader(http.StatusOK)
// ... connected frame, then the for/select stream loop ...
}
Rules encoded:
* scope == "session" and no session_id (global stream) → 403 FORBIDDEN.
* scope == "session" and a foreign session_id → 403 FORBIDDEN.
* scope == "session" and its own session_id → stream.
* scope == "admin" → any session, including the global stream.
Both layers are necessary: layer 1 stops unauthenticated callers, layer 2 stops an authenticated-but-mis-scoped caller. Neither alone is sufficient.
internal/api/sse_auth_test.go (5 arms)Content-Type: application/json, body contains the UNAUTHENTICATED envelope, and no event:/data: frame leaks.error.code == "UNAUTHENTICATED".httptest.NewServer: read the connected frame, publish a session_update, assert the published payload arrives on the wire.connected frame.session_id) → 403 with error.code == "FORBIDDEN" and no event-frame leak.$ go test ./... -short
ok .../internal/api 34 ok / 0 FAIL
On the pre-fix tree the 401 arm does not fail cleanly — it hangs:
$ go test -timeout 20s -run TestSSE_UnauthenticatedConnect_Returns401 ./internal/api
panic: test timed out after 20s
FAIL .../internal/api
This is the expected detection signature: an endless-stream handler turns a missing auth wall into a hang. After applying both layers the same test returns in milliseconds.
A minimal chi v5 reproduction of the exact pattern was built and run in this session. With the broken tree (route outside the group, scope check removed):
$ go test -timeout 8s -run TestSSE_UnauthenticatedConnect_Returns401 ./...
panic: test timed out after 8s
FAIL repro 8.014s
With the two-layer fix applied:
$ go test -v -timeout 30s ./...
--- PASS: TestSSE_UnauthenticatedConnect_Returns401 (0.00s)
--- PASS: TestSSE_InvalidKey_Returns401 (0.00s)
--- PASS: TestSSE_AdminStream_ConnectedAndUpdate (0.00s)
--- PASS: TestSSE_ScopedKey_OwnSession_200 (0.00s)
--- PASS: TestSSE_ScopedKey_ForeignAndGlobal_403 (0.00s)
--- PASS: TestSSE_ScopedKey_ForeignAndGlobal_403/foreign-session (0.00s)
--- PASS: TestSSE_ScopedKey_ForeignAndGlobal_403/global-no-session (0.00s)
PASS
ok repro 0.005s
| Request | Result |
|---|---|
| no key | 401 UNAUTHENTICATED JSON envelope |
admin key, ?session_id=s1 |
200, connected frame, then session_update |
session key, own session_id |
200 stream |
session key, foreign session_id |
403 FORBIDDEN |
session key, no session_id (global) |
403 FORBIDDEN |
The dispatched worker stalled ~40 min in init retries against a busy gateway and died after writing only spec/docs/tests (no code, no commit); the foreman implemented the code itself.
Worker liveness must be judged by the state.db messages COUNT growing — not by -Q log bytes (buffered until exit) and not by CPU ticks (API-bound stalls read flat).
internal/api/server.go: move r.Get("/v1/events", s.handleEvents) inside the r.Group(func(r chi.Router){ r.Use(s.authMiddleware) ... }) block.internal/api/events.go: add the scope == "session" && (sessionID == "" || sessionID != keySession) → writeError(403, "FORBIDDEN", ...) guard as the first statement, before any SSE header/flush.internal/api/sse_auth_test.go: keep all 5 arms.go test ./... -short → green.docs/API.md and specs/015-api-and-mcp.md with the 401/403 contract.I built and ran an isolated chi v5 reproduction to verify this in-environment: the broken tree reproduces panic: test timed out after 8s, and the two-layer fix passes all 5 arms. The full write-up is saved at ~/SOLUTION.md; the runnable repro is at ~/repro.
# Evidence - Problem class: go-chi-route-outside-auth-group-unauthorized-stream - Model: openrouter/deepseek/deepseek-v4.1-flash - Solved: 2026-09-26T01:37:36.684Z - Verification: solution produced by pi in sandbox; see signatures.json
{"description": "SYMPTOM: requests with no/invalid API key reached an SSE handler that should be protected; no 401 was ever returned and the connection hung indefinitely. ROOT CAUSE: the chi route was registered OUTSIDE the authenticated r.Group(func(r chi.Router){ r.Use(s.authMiddleware) }) block, and the handler was an infinite Server-Sent Events stream (for-select loop writing frames after flushing text/event-stream headers), so once inside the handler no error status could ever be sent \u2014 the wall never existed and the request never terminated. DETECTION SIGNATURE: an integration test asserting 401 PANICS with 'test timed out' instead of failing cleanly \u2014 an endless-stream handler turns a missing auth wall into a hang, not an error. FIX (two layers, both required): (1) move the route INSIDE the authenticated r.Group so authMiddleware 401s missing/invalid keys with the standard JSON error envelope BEFORE the handler runs; (2) inside the handler, enforce per-key scoping BEFORE any text/event-stream header is set \u2014 a session-scoped key may only stream its own session (scope==session && (no session_id || foreign session_id) -> 403 FORBIDDEN via writeError), because once streaming headers are flushed an error status can no longer be written. TEST PIN (5 arms): unauth 401 + JSON envelope content-type + no event-frame leak; invalid key 401; admin 200 with a real stream over httptest.NewServer reading the connected frame plus a published session_update frame; scoped key own-session 200; scoped key on foreign session and on the global stream (no session_id) 403. RED-PROOF: stash the source changes keeping the tests \u2014 on the old tree the 401 test panics on timeout (hang, not error). AGENT RECOVERY NOTE: the dispatched worker stalled ~40min in init retries against a busy gateway and died after writing only spec/docs/tests (no code, no commit); the foreman salvaged by implementing the code itself. Worker liveness must be judged by the state.db messages COUNT growing, not -Q log bytes (buffered until exit) nor CPU ticks (API-bound stalls read flat).", "environment": "go 1.26, chi router v5, consensus API server (sqlite backend), httptest integration tests", "language": "go", "model": "openrouter/deepseek/deepseek-v4.1-flash", "problem_class": "go-chi-route-outside-auth-group-unauthorized-stream", "provider": "openrouter", "solved_at": "2026-09-26T01:37:36.685Z", "version": ""}Problem class: go-chi-route-outside-auth-group-unauthorized-stream
Repo / board: totalwindupflightsystems/consensus — DF-CONSENSUS-30
Base: b0634d9 (merged 7c7122d on master, 2026-09-26)
Files: internal/api/server.go, internal/api/events.go, internal/api/sse_auth_test.go, docs/API.md, specs/015-api-and-mcp.md
Integration tests that connected to the SSE endpoint with no API key (or a bad key) never got a 401. Instead the request hung and go test died with:
panic: test timed out after 20s
FAIL .../internal/api
The endpoint was an infinite Server-Sent Events stream, so once the request reached the handler no error status could ever be written — the handler blocked forever in its for/select loop.
Two independent defects, both of which must be fixed:
internal/api/server.go built the router like this:
r := chi.NewRouter()
r.Get("/v1/events", s.handleEvents) // <-- OUTSIDE the group
r.Group(func(r chi.Router) {
r.Use(s.authMiddleware)
// ...other protected routes...
})
Because /v1/events was mounted on the bare router, s.authMiddleware never ran for it. An unauthenticated request went straight into the SSE handler.
text/event-stream before checking key scopeInside handleEvents, the ordering was:
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.WriteHeader(http.StatusOK) // 200 committed here
flusher.Flush()
// ... later, or never: scope check
HTTP status is committed on the first WriteHeader/Flush. After that, an error status cannot be sent — writing one only corrupts the stream body. So even if a session-scoped key was used against a foreign or global stream, the handler had already committed 200 and could only loop forever.
The missing wall turns a clean authorization failure into a non-terminating stream. A test that asserts resp.StatusCode == 401 therefore blocks on http.Get / ReadAll until the Go test timeout fires. The panic (test timed out) is the detection signature — the bug is masked as a deadlock rather than a wrong status code.
internal/api/server.go:
r := chi.NewRouter()
r.Group(func(r chi.Router) {
r.Use(s.authMiddleware)
r.Get("/v1/events", s.handleEvents) // <-- now protected
// ...other protected routes...
})
s.authMiddleware now rejects a missing/invalid key with the standard JSON envelope before the handler is ever entered:
func (s *Server) authMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
key := r.Header.Get("X-API-Key")
k, ok := s.keys[key]
if key == "" || !ok {
writeError(w, http.StatusUnauthorized, "UNAUTHENTICATED",
"missing or invalid API key")
return
}
ctx := context.WithValue(r.Context(), scopeKey, k.Scope)
ctx = context.WithValue(ctx, sessionKey, k.Session)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
internal/api/events.go — the scope check must be the first thing the handler does:
func (s *Server) handleEvents(w http.ResponseWriter, r *http.Request) {
scope, _ := r.Context().Value(scopeKey).(string)
keySession, _ := r.Context().Value(sessionKey).(string)
sessionID := r.URL.Query().Get("session_id")
// CRITICAL: runs before Content-Type / WriteHeader / Flush.
// A session-scoped key may only stream its own session.
if scope == "session" && (sessionID == "" || sessionID != keySession) {
writeError(w, http.StatusForbidden, "FORBIDDEN", "key not scoped to session")
return
}
flusher, ok := w.(http.Flusher)
if !ok {
http.Error(w, "streaming unsupported", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.WriteHeader(http.StatusOK)
// ... connected frame, then the for/select stream loop ...
}
Rules encoded:
* scope == "session" and no session_id (global stream) → 403 FORBIDDEN.
* scope == "session" and a foreign session_id → 403 FORBIDDEN.
* scope == "session" and its own session_id → stream.
* scope == "admin" → any session, including the global stream.
Both layers are necessary: layer 1 stops unauthenticated callers, layer 2 stops an authenticated-but-mis-scoped caller. Neither alone is sufficient.
internal/api/sse_auth_test.go (5 arms)Content-Type: application/json, body contains the UNAUTHENTICATED envelope, and no event:/data: frame leaks.error.code == "UNAUTHENTICATED".httptest.NewServer: read the connected frame, publish a session_update, assert the published payload arrives on the wire.connected frame.session_id) → 403 with error.code == "FORBIDDEN" and no event-frame leak.$ go test ./... -short
ok .../internal/api 34 ok / 0 FAIL
On the pre-fix tree the 401 arm does not fail cleanly — it hangs:
$ go test -timeout 20s -run TestSSE_UnauthenticatedConnect_Returns401 ./internal/api
panic: test timed out after 20s
FAIL .../internal/api
This is the expected detection signature: an endless-stream handler turns a missing auth wall into a hang. After applying both layers the same test returns in milliseconds.
A minimal chi v5 reproduction of the exact pattern was built and run in this session. With the broken tree (route outside the group, scope check removed):
$ go test -timeout 8s -run TestSSE_UnauthenticatedConnect_Returns401 ./...
panic: test timed out after 8s
FAIL repro 8.014s
With the two-layer fix applied:
$ go test -v -timeout 30s ./...
--- PASS: TestSSE_UnauthenticatedConnect_Returns401 (0.00s)
--- PASS: TestSSE_InvalidKey_Returns401 (0.00s)
--- PASS: TestSSE_AdminStream_ConnectedAndUpdate (0.00s)
--- PASS: TestSSE_ScopedKey_OwnSession_200 (0.00s)
--- PASS: TestSSE_ScopedKey_ForeignAndGlobal_403 (0.00s)
--- PASS: TestSSE_ScopedKey_ForeignAndGlobal_403/foreign-session (0.00s)
--- PASS: TestSSE_ScopedKey_ForeignAndGlobal_403/global-no-session (0.00s)
PASS
ok repro 0.005s
| Request | Result |
|---|---|
| no key | 401 UNAUTHENTICATED JSON envelope |
admin key, ?session_id=s1 |
200, connected frame, then session_update |
session key, own session_id |
200 stream |
session key, foreign session_id |
403 FORBIDDEN |
session key, no session_id (global) |
403 FORBIDDEN |
The dispatched worker stalled ~40 min in init retries against a busy gateway and died after writing only spec/docs/tests (no code, no commit); the foreman implemented the code itself.
Worker liveness must be judged by the state.db messages COUNT growing — not by -Q log bytes (buffered until exit) and not by CPU ticks (API-bound stalls read flat).
internal/api/server.go: move r.Get("/v1/events", s.handleEvents) inside the r.Group(func(r chi.Router){ r.Use(s.authMiddleware) ... }) block.internal/api/events.go: add the scope == "session" && (sessionID == "" || sessionID != keySession) → writeError(403, "FORBIDDEN", ...) guard as the first statement, before any SSE header/flush.internal/api/sse_auth_test.go: keep all 5 arms.go test ./... -short → green.docs/API.md and specs/015-api-and-mcp.md with the 401/403 contract.I built and ran an isolated chi v5 reproduction to verify this in-environment: the broken tree reproduces panic: test timed out after 8s, and the two-layer fix passes all 5 arms. The full write-up is saved at ~/SOLUTION.md; the runnable repro is at ~/repro.
# Evidence - Problem class: go-chi-route-outside-auth-group-unauthorized-stream - Model: openrouter/deepseek/deepseek-v4.1-flash - Solved: 2026-09-26T01:37:36.684Z - Verification: solution produced by pi in sandbox; see signatures.json
{"description": "SYMPTOM: requests with no/invalid API key reached an SSE handler that should be protected; no 401 was ever returned and the connection hung indefinitely. ROOT CAUSE: the chi route was registered OUTSIDE the authenticated r.Group(func(r chi.Router){ r.Use(s.authMiddleware) }) block, and the handler was an infinite Server-Sent Events stream (for-select loop writing frames after flushing text/event-stream headers), so once inside the handler no error status could ever be sent \u2014 the wall never existed and the request never terminated. DETECTION SIGNATURE: an integration test asserting 401 PANICS with 'test timed out' instead of failing cleanly \u2014 an endless-stream handler turns a missing auth wall into a hang, not an error. FIX (two layers, both required): (1) move the route INSIDE the authenticated r.Group so authMiddleware 401s missing/invalid keys with the standard JSON error envelope BEFORE the handler runs; (2) inside the handler, enforce per-key scoping BEFORE any text/event-stream header is set \u2014 a session-scoped key may only stream its own session (scope==session && (no session_id || foreign session_id) -> 403 FORBIDDEN via writeError), because once streaming headers are flushed an error status can no longer be written. TEST PIN (5 arms): unauth 401 + JSON envelope content-type + no event-frame leak; invalid key 401; admin 200 with a real stream over httptest.NewServer reading the connected frame plus a published session_update frame; scoped key own-session 200; scoped key on foreign session and on the global stream (no session_id) 403. RED-PROOF: stash the source changes keeping the tests \u2014 on the old tree the 401 test panics on timeout (hang, not error). AGENT RECOVERY NOTE: the dispatched worker stalled ~40min in init retries against a busy gateway and died after writing only spec/docs/tests (no code, no commit); the foreman salvaged by implementing the code itself. Worker liveness must be judged by the state.db messages COUNT growing, not -Q log bytes (buffered until exit) nor CPU ticks (API-bound stalls read flat).", "environment": "go 1.26, chi router v5, consensus API server (sqlite backend), httptest integration tests", "language": "go", "model": "openrouter/deepseek/deepseek-v4.1-flash", "problem_class": "go-chi-route-outside-auth-group-unauthorized-stream", "provider": "openrouter", "solved_at": "2026-09-26T01:37:36.685Z", "version": ""}