feat(events): dev-mode debug-bar notice when ?reload=true is refused#3313
feat(events): dev-mode debug-bar notice when ?reload=true is refused#3313bpamiri wants to merge 1 commit into
Conversation
Since the fail-closed reload gate (4.0.4, #3062), a refused ?reload=true is a silent no-op in the browser, which keeps producing 'reload is broken' support reports from users whose 3.x muscle memory is an empty reloadPassword. The reload gate in all four public/Application.cfc template copies now records why a requested reload did not fire in request.wheels.reloadRefusedReason (emptyPassword, missingPasswordParam, or the deliberately generic refused for wrong-password/rate-limited — no oracle on top of $secureCompare), and the debug bar renders a development-only banner with the matching fix inline. Message text and the development gate live framework-side so wording can change without template drift. Other environments are unchanged (silent no-op plus wheels_security.log). Pinned by a runtime render spec (DebugBarReloadRefusedNoticeSpec) and a structural 4-copy parity spec (ReloadRefusedNoticeParitySpec). Also documents the fail-closed contract in the 3x-to-4x troubleshooting guide. Closes #3311 Signed-off-by: Peter Amiri <petera@pai.com>
There was a problem hiding this comment.
Wheels Bot — Reviewer
TL;DR — This PR turns the fail-closed ?reload=true gate (#3062) from a silent browser no-op into a development-only debug-bar notice that explains why the reload was refused, addressing #3311. The recording lives request-scoped in all four public/Application.cfc copies; the message text and dev-only gate live framework-side in debug.cfm. I traced every refusal path and reviewed both specs against their prior art. The change is correct, well-scoped, thoroughly tested, and cleanly documented. Verdict: approve.
Correctness
Traced each local.reloadAuthorized == false branch of the recording block (public/Application.cfc:342-352) against the gate above it:
- empty/unconfigured
reloadPassword->emptyPassword - configured password, no
passwordURL param ->missingPasswordParam - configured password, param present but wrong or rate-limited ->
refused
The rate-limited case correctly collapses into the generic refused: when local.reloadRateLimited is true, both the authorize block and the failure-tracking else if are skipped (both guarded by !local.reloadRateLimited), so recording falls through to the same reason a wrong-password attempt yields — no oracle on top of $secureCompare(), exactly as the PR claims. The recording is correctly gated behind local.reloadRequested (line 286), so a normal request never sets the flag, and the environmentSwitchAlreadyApplied no-op path (line 271) records nothing.
StructKeyExists(request.wheels, "reloadRefusedReason") in debug.cfm:96 is safe: $initializeRequestScope() runs at public/Application.cfc:218 before the gate, and debug.cfm already dereferences request.wheels.execution.total unconditionally at line 52 — request.wheels is guaranteed present.
Cross-engine
No new engine hazards. The IIFE loop-capture in ReloadRefusedNoticeParitySpec.cfc and the try/finally + $includeAndReturnOutput render helper in DebugBarReloadRefusedNoticeSpec.cfc are byte-for-byte the established patterns in the sibling ReloadPasswordGateParitySpec.cfc and DebugBarEnvQuickSwitchSpec.cfc, both of which already pass the full cross-engine matrix. No closures-as-constructor-args, no attributeCollection, no Left(str,0), no reserved-scope shadowing. $get("environment") IS "development" (debug.cfm:96) is case-insensitive EQ on every engine, so it matches the file's own LCase(...) comparisons functionally.
Tests
Strong. DebugBarReloadRefusedNoticeSpec (runtime) covers all three reasons, asserts the generic notice is oracle-free (REFindNoCase("wrong|incorrect|rate.?limit", output) == 0), and asserts no notice outside development and no notice without a recorded reason. ReloadRefusedNoticeParitySpec (structural) pins all four copies to exactly three reason assignments, none leaking wrong-vs-rate. TDD sequence documented (3 runtime + 8 parity failures first). Both extend wheels.WheelsTest (BDD), no RocketUnit.
Security
EncodeForHTMLAttribute() on the rendered reason value (debug.cfm:97) even though it's a fixed enum — defensive and correct. Message bodies are static text. The notice is double-gated to development (the $get("environment") cfif plus the debug bar itself only rendering when showDebugInformation is on), so no reason string reaches a production browser. The generic-refused collapse preserves the constant-time-compare guarantee.
Docs
Changelog fragment changelog.d/reload-refused-dev-notice.added.md uses the correct <slug>.added.md shape (no direct CHANGELOG.md edit). The 3x-to-4x upgrade guide gains an accurate ?reload=true does nothing troubleshooting bullet.
Commits
Single commit feat(events): dev-mode debug-bar notice when ?reload=true is refused (67 chars, valid type/scope, not ALL-CAPS). Body explains the why (3.x muscle memory) and carries Signed-off-by:.
Nice work — no changes requested.
Summary
Implements #3311: since the fail-closed reload gate (4.0.4, #3062), a refused
?reload=trueis a complete silent no-op in the browser — which keeps producing "reload is broken" support reports from long-time users whose 3.x muscle memory is an emptyreloadPassword(latest: community Slack, 2026-07-14).What changed
public/Application.cfccopies (CLI app template, repo demo app, starter-app, tweet) records why a requested reload did not fire inrequest.wheels.reloadRefusedReason:emptyPassword— no non-emptyreloadPasswordconfigured (URL reload disabled)missingPasswordParam— password configured, nopasswordURL parameterrefused— deliberately generic for wrong-password and rate-limited, so the notice adds no oracle on top of$secureCompare()vendor/wheels/events/onrequestend/debug.cfmrenders a banner above the debug bar — development environment only — with the matching fix inline (theset(reloadPassword=env('WHEELS_RELOAD_PASSWORD', ''))snippet for the empty-password case). Message text lives framework-side so wording can improve without template drift.wheels_security.log); the gate's fail-closed/logging/rate-limit behavior is untouched.?reload=truedoes nothing"; changelog fragment.Tests
DebugBarReloadRefusedNoticeSpec(runtime): renders debug.cfm for all three reasons, asserts the generic notice stays oracle-free, and asserts no notice outside development / without a recorded reason.ReloadRefusedNoticeParitySpec(structural): all four copies record exactly the three contract reasons — same lineage asReloadPasswordGateParitySpec.?reload=true→ banner withmissingPasswordParam;?reload=true&password=wrong→ banner withrefused; password-gated reload still restarts the app (exercised by the test harness warm-up).Cross-engine: changes are plain tag-CFML/script with no closure, scope-name, or attributeCollection patterns from the anti-pattern list.
Closes #3311
🤖 Generated with Claude Code