Skip to content

router: scope()/namespace() silently ignore callback= — routes in the callback never register and the unclosed scope swallows every subsequent route (documented in 5+ guides) #3072

Description

@bpamiri

Summary

.scope() does not accept a callback parameter (vendor/wheels/mapper/scoping.cfc:17-29), and namespace()/package()/controller() just forward to it. Only group() (scoping.cfc:166-192, executes the callback then auto-end()s — inherited by api()/version()) and resources() consume callbacks. CFML happily accepts the extra named argument, so the documented callback forms fail twice, both silently:

  1. The callback never executes — routes declared inside it simply don't exist (404 Wheels.RouteNotFound).
  2. The scope is never closed, so every subsequent route (resources, wildcard, even root) inherits the scope's path prefix AND middleware. The misrouted URLs even respond (e.g. /admin/posts 200 because the outer .resources("posts") got swallowed into the open namespace), which makes the missing public routes very hard to diagnose.

This is the single most-repeated broken pattern found by the batch-2 guide audit — three independent verifiers hit it from three different guides.

Repro (develop @ 840274b, Lucee 7 — mapper logic is engine-independent)

Rate-limiting guide shape:

mapper()
    .scope(
        path="/limited",
        middleware=[new wheels.middleware.RateLimiter(maxRequests=2, windowSeconds=15)],
        callback=function(map) {
            map.get(name="limitedIndex", pattern="/", to="limited##index");
        }
    )
    .wildcard()
    .root(method="get")
.end();

Observed: limitedIndex never created; .wildcard()/.root() nested under /limited (GET /limited/probe → 200 via the nested wildcard, GET /probe → 404). The whole app's top-level routing silently breaks.

Routing-guide shape (.namespace(name="admin", callback=...) followed by an outer .resources("posts")): /admin/posts → 200 only because the open namespace swallowed the outer resources; public /posts → 404. The manual .namespace("admin")...end() form works correctly (both URL families 200). Framework specs only ever use the manual form (vendor/wheels/tests/specs/mapperSpec.cfc:165).

CORS-guide shape (.scope(path="/api", middleware=[Cors], callback=...)): identical — /api/ping 404, /plain-ping 404, /api/plain-ping 200 with the leaked scoped CORS headers.

Root cause

scope() declares no callback argument; the extra named arg lands in arguments, is prepended onto the scope stack (scoping.cfc:91), and is never invoked — and nothing pops the stack. scoping.cfc's own forwarding comments say options should "not be silently dropped"; callback currently is, and worse, dropping it changes the meaning of everything declared after it.

Documented call sites teaching the broken form (all shipped since the v4 guides rewrite #2169)

  • web/sites/guides/src/content/docs/v4-0-0/digging-deeper/rate-limiting.mdx lines 183-201
  • web/sites/guides/src/content/docs/v4-0-0/digging-deeper/cors.mdx lines 94-114
  • web/sites/guides/src/content/docs/v4-0-0/digging-deeper/multi-tenancy.mdx line 271
  • web/sites/guides/src/content/docs/v4-0-0/digging-deeper/route-model-binding.mdx line 118
  • web/sites/guides/src/content/docs/v4-0-0/basics/routing.mdx "Namespaced sections" (.namespace(name="admin", callback=...))
  • repo CLAUDE.md Route Model Binding section (.scope(path="/api", binding=true) is fine, but check for callback forms)

Proposed direction

Preferred: implement callback= in scope() mirroring group() — exclude callback from the scope-stack entry, execute arguments.callback(this) after the prepend, then auto-end() (~6 lines, scoping.cfc:185-189 is the template). That makes namespace(), package(), and controller() consistent with group()/resources() and fixes every guide example as written.

Fallback: throw Wheels.InvalidArgument in development when scope() receives an unconsumed callback, and rewrite the five guide call sites to the explicit .end() (or .group()) form. Either way, silent acceptance is the worst of the three options.

Also worth fixing alongside: namespaced controllers under app/controllers/admin/ need extends="app.controllers.Controller" — bare extends="Controller" throws "invalid component definition" on Lucee from the subfolder; no guide mentions this.

Acceptance

  • .scope(path="/x", callback=function(map){ map.get(...); }) registers the callback routes and auto-closes the scope (or throws loudly in development), with specs covering scope/namespace/package callback forms and the no-swallow guarantee for routes declared after.
  • The five guide examples above work as written (or are rewritten in the same PR if the throw option is chosen).

Reported by the guide-behavioral-audit P1 batch 2 (work items p1-10-routing claims R11/R12, p1-11-ratelimit claim rl-22, p1-12-cors claim cors-17 — three independent live repros, develop @ 840274b).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions