Loading
chore(server): S01 middleware chain (Step 3)
Summary
Introduces the S01 middleware chain at internal/server/middleware.go: four middlewares occupying spec positions #5/#6 (closed)/#7 (closed) plus the ServeMux response interceptor that rewrites stdlib 404/405 plain-text fallbacks into the S01 JSON envelope.
HeaderHardeningMiddleware— setsX-Content-Type-Options: nosniffon every response (AC #17 (closed)).BodySizeMiddleware— wraps the request body withhttp.MaxBytesReader, early-rejects onContent-Length > MaxBodySize(AC #13 (closed)), and stashes the pre-wrap body under a private context key so upload handlers can callOriginalBody(r)to retrieve it for their own size-limit management.RoutePatternMiddleware— propagates the matched ServeMux pattern viahttpserver.SetRoutePatternso LabKit's tracing middleware (chain position #2) uses a low-cardinality span name.ResponseInterceptorMiddleware— captures stdlib mux's plain-text 404/405 and rewrites them as JSON envelopes viatransport.WriteError, preserving theAllowheader on 405 (ACs #8 (closed)/#9). The interceptor implementsUnwrapsohttp.NewResponseControllercan reach the underlying writer forHijack/Flush/SetReadDeadline/SetWriteDeadline(S01 §"ResponseWriter unwrapping").OriginalBody(r io.Reader) io.ReadCloser— exported accessor; returns nil when the middleware chain didn't run.
Leaf code; no callers on this branch. Step 5 (server.New) wires these into the chain on top of LabKit positions #1-#4 (closed).
- Plan: docs/plans/2026-05-20-s01-http-server-and-routing.md — Step 3
- Spec: docs/specs/S01-http-server-and-routing.md — sections "Middleware chain", "Router", "Request body size limiting", "ResponseWriter unwrapping"
- Issue: closes part of #89 (closed)
Acceptance criteria
| AC | Covered by |
|---|---|
| #8 (closed) (unknown route → 404 envelope) | TestResponseInterceptor_Rewrites404PlainText |
| #9 (wrong method → 405 envelope + Allow preserved) | TestResponseInterceptor_Rewrites405PlainText, TestResponseInterceptor_PreservesAllowHeader |
| #13 (closed) (oversized body → 413; Content-Length early-reject AND chunked MaxBytesReader) | TestBodySizeMiddleware_RejectsOversizedContentLength, TestBodySizeMiddleware_RejectsOversizedChunked |
#17 (closed) (X-Content-Type-Options: nosniff on every response) |
TestHeaderHardeningMiddleware_SetsNosniff (table-driven 200/500) |
| D9 (handler-set non-text Content-Type passes through interceptor) | TestResponseInterceptor_DoesNotRewriteHandlerJSON, TestResponseInterceptor_DoesNotRewriteHandlerNon404OrNon405 |
Additional behaviors pinned:
TestBodySizeMiddleware_OriginalBodyAccessor—OriginalBody(r)returns the pre-wrap body.TestRoutePatternMiddleware_PropagatesPattern— pattern propagates viahttpserver.SetRoutePattern.TestResponseInterceptor_UnwrapReachesUnderlying—http.NewResponseController(interceptor).Flush()reaches the underlying writer.
Test plan
-
go test -race -short -count=1 ./internal/server/...— green -
golangci-lint v2.12.0— 0 issues -
goimports -l— clean -
validate-log-fields v2.6.1— no deprecated logging fields - All 10 plan-listed tests present + 1 polish-added test (
UnwrapReachesUnderlying) -
/validate-stepsecond pass: READY TO PUSH (all 5 categories PASS after /review-branch polish)
Reviewability notes
- MR size: 698 LOC across 2 files (222 production + 476 test = 68% tests). Exceeds the 500 LOC ceiling in docs/dev/development-model.md, but per the project's test-first contract documented in docs/dev/agentic-development.md, the test surface is authored by a separate subagent before implementation. The production payload (222 LOC) is well within budget; splitting tests into a separate MR would defeat the test-first verification flow. Recommended reading order: test file first (it's the contract), then implementation.
- Chore framing. This MR introduces no user-visible behavior until Step 5 wires the middleware into
server.New. The components sit unwired ininternal/server/after merge — hencechore(server)andtype::maintenancerather thanfeat/type::feature(consistent with !265 (merged)'s posture). responseInterceptor.Unwrap()was added per/review-branchfinding B1 (BLOCKING spec drift). S01 §"ResponseWriter unwrapping" mandates Unwrap on every wrapper sohttp.NewResponseControllercan reach Hijack/Flush/SetReadDeadline/SetWriteDeadline. Without it, upload handlers (S10+) callingSetReadDeadlineper S01 §"Upload timeout extension" would silently receivehttp.ErrNotSupported. The newTestResponseInterceptor_UnwrapReachesUnderlyingpins the contract.- Sibling MR (!265 (merged), Step 4). Both this MR and !265 (merged) add files to
internal/server/. No conflict — different files (middleware.govsshutdown.go). Go tolerates the byte-identical package doc-comments. - Forward notes (/review-branch OBSERVATIONs deferred):
- O3:
OriginalBody's no-stash branch (returns nil) isn't tested. Three-line follow-up. - O4:
httpserver.SetRoutePattern's return value is discarded. Benign on the production path where LabKit's tracing seeds a holder. - O5:
originalBodyCtxKeydoc-comment doesn't explain the empty-struct context-key idiom (staticcheck SA1029). - O6:
chorevsfeatcommit type — same posture as !254 (merged)/!261 (merged)/!265 (merged) per operator decision.
- O3:
Related
- Step 1 (
internal/config): !254 (merged) — merged - Step 2 (
internal/transport): !261 (merged) — merged - Step 4 (
internal/server/shutdown): !265 (merged) — in review (sibling, parallel) - Step 5 (
server.New) and Step 6 (composition root) consume these middlewares