feat(http): guard middleware by default (ADR-0037) — fs-http 0.6.0#149
Conversation
register{Request,Response,ResponseError}Middleware now wrap the supplied
body in guarded() internally, so a side-effect throw cannot reject a
resolved 200 nor mask the real API error — without the consumer doing
anything. Per-call {guard:false} opt-out registers the raw body
(throws propagate); createHttpService(url, {onMiddlewareError}) routes
the loud signal for every auto-guarded middleware on the service. The
stored (possibly wrapped) fn is both pushed and what unregister removes
(reference identity). Interceptor loops stay sync-only and un-awaited.
Supersedes the consumer-side guard obligation in Architectural
Principle #8 (loud-swallow serves "be loud, never silent" strictly
better than a loud throw, which still corrupts the request outcome).
- fs-http 0.5.0 -> 0.6.0 (minor)
- peer cascade widened + patch-bumped: fs-loading 0.1.5->0.1.6,
fs-adapter-store 0.3.1->0.3.2, fs-cached-adapter-store 0.2.4->0.2.5,
fs-form 0.1.0->0.1.1 (all add ^0.6.0)
- new export: RegisterMiddlewareOptions; onMiddlewareError service option
- doctrine: fs-packages CLAUDE.md § Middleware Sync Contract flipped to
guard-by-default; http docs + README + CHANGELOG updated
- tests: default-guard swallow+surface, onMiddlewareError routing,
{guard:false} propagation, explicit {guard:true}, unregister
reference-identity (guarded + raw). 100% coverage, 97.47% mutation.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016K2oPf27cMgbUXWd48fmKX
Deploying fs-packages with
|
| Latest commit: |
774ef58
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://a16c77e1.fs-packages.pages.dev |
| Branch Preview URL: | https://engineer-fs-http-guard-by-de.fs-packages.pages.dev |
Goosterhof
left a comment
There was a problem hiding this comment.
Verdict: approve-worthy — self-authored, so COMMENT (GitHub blocks self-approve). 0 blockers · 0 majors · 1 minor (hardening) · confirmations below. All CI green (check, Cloudflare Pages, town-crier/gate). ADR-0037 implemented faithfully.
Confirmed correct (the load-bearing points)
- Reference identity holds.
http.tsstoresconst middleware = opts?.guard === false ? fn : guarded(fn, onMiddlewareError)and passes that same reference to bothpush()andunregister(). This is the trap the naive version falls into (pushguarded(fn), unregisterfn→ leak); it's handled correctly on all three registrars. - Default-on semantics are strict.
opts?.guard === false ? fn : guarded(...)— only an explicitfalseopts out;undefined/{}/{guard:true}all guard. Correct. onMiddlewareErrorwiring — service-level handler captured once, threaded into everyguarded()call,undefined ⇒ defaultOnError. Correct.- Peer cascade is additive — every sibling widens with
|| ^0.6.0(never drops a prior range) + patch-bumps. fs-form #148 is already merged (base = fs-form 0.1.0 / fs-http 0.5.0), so the 0.1.0→0.1.1 bump here is clean with no merge-order dependency.
Minor — guarded()'s own error handler is unguarded (hardening)
guarded.ts catches fn(arg) but calls onError(error) outside the try/catch:
return (arg: T) => {
try { fn(arg); }
catch (error) { onError(error); } // ← if onError throws, it escapes the wrapper
};The default defaultOnError (console.error) can't throw, so the default path is airtight. But this PR makes guarded() the fleet-wide default and documents onMiddlewareError as the hook to "route to an error tracker (Sentry, kendo-error-tracker)" — i.e. it actively steers consumers toward exactly the handlers most likely to throw (network, serialization, a null-deref in the reporter). A throwing onMiddlewareError re-opens the precise interceptor-chain corruption ADR-0037 exists to close, one level up.
The doc contract says "Must not re-throw" — but that's a documented obligation, not an enforced one, on a primitive whose entire value proposition is "never lets a throw corrupt the chain." Belt-and-suspenders: wrap the onError(error) call too (fall back to defaultOnError, or a silent no-op after a last-ditch console.error). Pre-existing code (shipped in 0.5.0), but this PR is what elevates it to the default path, so it's fair to raise here rather than defer. Non-blocking — the default is safe and the contract is documented; your call whether to harden now or leave it as a doc obligation.
Notes (no action)
- Guard-by-default is technically a behavior change (a middleware throw that previously propagated is now loud-swallowed) shipped as a pre-1.0 minor — correct call: it's backward-safe direction (a no-throw middleware sees zero change) and ADR-ratified, and pre-1.0 minor matches the peer-cap discipline.
- Redundant double-wraps on WR-0290 / WR-0078 consumer sites after they bump — confirmed harmless (inner catch fires, outer never sees a throw); deferred cleanup is the right disposition.
Automated war-room agent review — posted because this PR carries the Agent Review Requested label.
Town Crier Review · 7/10 · PASS · 🤝 Confirmfs-packages #149 · AC anchor: PR description / ADR-0037 (no kendo board) · head Tip Reviewed ADR-0037's default-guard change end-to-end: reference identity, default-on strictness, onMiddlewareError wiring, and the additive peer-range cascade to fs-form/fs-loading/fs-adapter-store/fs-cached-adapter-store all verify correct at head with CI green, corroborating Goosterhof/the-general's prior review (the same review reaching us on two channels). Their one flagged defect — guarded()'s onError call sits outside its own try/catch, letting a throwing custom onMiddlewareError handler escape into the interceptor loop — is real and was independently caught by our blind pass too; we tier it MAJOR rather than their self-assessed MINOR (this PR makes it the fleet-wide default and steers consumers toward throw-prone error-tracker handlers), but don't re-file it since it's already visible on the PR. No findings — clean against the review checklist. Bus thread · 1 prior review(s):
|
jasperboerhof
left a comment
There was a problem hiding this comment.
Auto-approved — Town Crier verdict PASS @Head, CI green, no open MAJOR+ thread. Our approval is our independent vote (approve-alongside): a peer's review / CHANGES_REQUESTED never withholds it — we verify every blocker ourselves, and a real one drops our own verdict below PASS. Any open 🟡 MINOR threads alongside this approval are non-blocking (TC-0036 R4) — author's choice: fold here or follow-up; if folding into this PR, say so in-thread and disarm auto-merge. See the verdict comment + inline notes.
Implements ADR-0037 — fs-http Middleware Guarding by Default (Accepted 2026-07-08). Ships as an fs-http minor (
0.5.0 → 0.6.0).What & why
fs-http's
register{Request,Response,ResponseError}Middlewarenow auto-wrap the supplied body in the existingguarded()by default — a side-effect throw can no longer reject a resolved 200 (success path) nor mask the real API error (error path). This reverses the old "consumers MUST guard their own middleware bodies" obligation (Principle #8): the loud-swallow reframe (a loud throw still corrupts the request; loud-swallow was the 3rd option unavailable in the 2026-05-13 discussion) + the WR-0290 fleet evidence justify the flip. The library stays loud (never silent) and sync-only.register*(fn)storesguarded(fn, onMiddlewareError). The wrapped fn is what's pushed and what the returnedunregisterremoves — reference identity holds.register*(fn, {guard: false})stores the raw body (throws propagate — deliberate escape hatch).createHttpService(url, {onMiddlewareError})option is theonErrorfor every auto-guarded middleware on that service; unset ⇒guarded()'s default loudconsole.error.RegisterMiddlewareOptions.Peer cascade (minor bump ⇒ widen + patch-bump each sibling)
… ‖ ^0.6.0… ‖ ^0.6.0… ‖ ^0.6.0^0.5.0 ‖ ^0.6.0Lock regenerated — all
@script-development/*resolvelink:trueto the workspace, no nested registry copies (General re-verified the lock, not justnpm ci).Verification (8-gate + audit, all green, judged by exit code)
build · typecheck · 627/627 tests · fs-http coverage 100% (all 4 axes) · lint · format · lint:pkg ·
npm audit· mutation 97.47% (≥90 floor). The loud-swallow paths the order expected to survive as equivalent mutants were killed — the new tests pin both the defaultconsole.errorand theonMiddlewareErrorroute. (2 residual survivors are pre-existing in the untouchedunregisterindex-guard, unrelated.)Doctrine
fs-packages
CLAUDE.md§ Middleware Sync Contract flipped to guard-by-default;docs/packages/http.md+ README + CHANGELOG updated to the new surface.guarded()stays exported (for{guard:false}manual cases + older-fs-http consumers).Consumer territories untouched — the redundant-explicit-guard strip (WR-0290 sites) is a separate, later, unhurried follow-up. War-room
CLAUDE.mdPrinciple #8 text flip + ADR-0037 status→shipped happen on merge (General).