Skip to content

feat(events): dev-mode debug-bar notice when ?reload=true is refused#3313

Open
bpamiri wants to merge 1 commit into
developfrom
peter/3311-reload-refused-notice
Open

feat(events): dev-mode debug-bar notice when ?reload=true is refused#3313
bpamiri wants to merge 1 commit into
developfrom
peter/3311-reload-refused-notice

Conversation

@bpamiri

@bpamiri bpamiri commented Jul 14, 2026

Copy link
Copy Markdown
Collaborator

Summary

Implements #3311: since the fail-closed reload gate (4.0.4, #3062), a refused ?reload=true is 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 empty reloadPassword (latest: community Slack, 2026-07-14).

What changed

  • Recording (template-side): the reload gate in all four public/Application.cfc copies (CLI app template, repo demo app, starter-app, tweet) records why a requested reload did not fire in request.wheels.reloadRefusedReason:
    • emptyPassword — no non-empty reloadPassword configured (URL reload disabled)
    • missingPasswordParam — password configured, no password URL parameter
    • refused — deliberately generic for wrong-password and rate-limited, so the notice adds no oracle on top of $secureCompare()
  • Rendering (framework-side): vendor/wheels/events/onrequestend/debug.cfm renders a banner above the debug bar — development environment only — with the matching fix inline (the set(reloadPassword=env('WHEELS_RELOAD_PASSWORD', '')) snippet for the empty-password case). Message text lives framework-side so wording can improve without template drift.
  • Unchanged: testing/production/maintenance stay exactly as before (silent no-op + wheels_security.log); the gate's fail-closed/logging/rate-limit behavior is untouched.
  • Docs: new 3x-to-4x troubleshooting bullet for "?reload=true does 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 as ReloadPasswordGateParitySpec.
  • TDD: both specs written first and watched fail (3 runtime + 8 parity failures), then implementation. Full core suite: 4706 passed (Lucee 7 + SQLite).
  • End-to-end against the running demo app: ?reload=true → banner with missingPasswordParam; ?reload=true&password=wrong → banner with refused; 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

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>

@wheels-bot wheels-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 password URL 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Dev-mode inline notice when ?reload=true is refused (empty reloadPassword / missing or wrong password)

1 participant