Skip to content
Merged
Show file tree
Hide file tree
Changes from 18 commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
8651e07
fix(middleware): short-circuit OPTIONS preflight in dispatch when COR…
github-actions[bot] May 15, 2026
ee2579f
docs(web/guides): note that OPTIONS preflight short-circuit requires …
github-actions[bot] May 15, 2026
4b7036f
fix(middleware): address Reviewer A/B consensus findings (round 1)
github-actions[bot] May 15, 2026
45b3f8c
chore(web): refresh visual baseline(s) (blog)
bpamiri May 15, 2026
901bae8
fix(middleware): address Reviewer A round-2 nits
bpamiri May 15, 2026
a7b3ecb
docs(middleware): clarify preflight-context comment in Dispatch.cfc
bpamiri May 15, 2026
9a6993a
Merge remote-tracking branch 'origin/develop' into fix/bot-2703-wheel…
bpamiri May 15, 2026
0614457
Merge remote-tracking branch 'origin/develop' into fix/bot-2703-wheel…
bpamiri May 16, 2026
68381e3
Merge remote-tracking branch 'origin/develop' into fix/bot-2703-wheel…
bpamiri May 16, 2026
f72d8d9
Merge remote-tracking branch 'origin/develop' into fix/bot-2703-wheel…
bpamiri May 16, 2026
2409683
Merge remote-tracking branch 'origin/develop' into fix/bot-2703-wheel…
bpamiri May 16, 2026
03ce081
chore(web): refresh visual baseline(s) (blog)
bpamiri May 16, 2026
65c46fe
Merge remote-tracking branch 'origin/develop' into fix/bot-2703-wheel…
bpamiri May 16, 2026
ebb6df4
Merge remote-tracking branch 'origin/develop' into fix/bot-2703-wheel…
bpamiri May 16, 2026
279bb93
chore(web): refresh visual baseline(s) (blog)
bpamiri May 16, 2026
1438257
Merge branch 'develop' into fix/bot-2703-wheels-middleware-cors-canno…
bpamiri May 16, 2026
13bae60
chore(web): refresh visual baseline(s) (blog)
bpamiri May 16, 2026
c8e84b7
docs(middleware): address Reviewer A/B consensus findings (round 2)
github-actions[bot] May 16, 2026
c6b6787
Merge remote-tracking branch 'origin/develop' into fix/bot-2703-wheel…
bpamiri May 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ All historical references to "CFWheels" in this changelog have been preserved fo

### Fixed

- `wheels.middleware.Cors` now short-circuits unmatched `OPTIONS` preflight requests at the dispatch layer, preserving the legacy `set(allowCorsRequests=true)` contract under the new middleware pipeline. Previously, `$findMatchingRoute()` ran before middleware, so a preflight against a path that only declared `POST` (or any non-`OPTIONS` verb) 404'd with `Wheels.RouteNotFound` before the CORS middleware's preflight branch could fire — leaving the middleware strictly less capable than the 3.x global setting it was meant to replace and breaking cross-origin `POST`/`PUT`/`PATCH`/`DELETE` from configured browsers. `Dispatch.$request()` now checks for an `OPTIONS` verb plus a `wheels.middleware.Cors` instance in the global pipeline and, if both are present, runs the pipeline against a no-op core handler before route matching. Dispatch behavior for `OPTIONS` without CORS middleware (still 404s) and for non-`OPTIONS` verbs (still routed normally) is unchanged (#2703)
- `QueryBuilder.whereIn()` / `whereNotIn()` with an empty array no longer emit malformed SQL (`property IN ()`). Previously, passing an empty list or array to either method produced syntactically invalid SQL that surfaced as a generic JDBC syntax error from the database, with no pointer back to the call site that built the empty collection. `whereIn(prop, [])` now sets an `$alwaysEmpty` flag on the builder so every terminal method (`count`, `findAll`, `findOne`, `first`, `exists`, `updateAll`, `deleteAll`, `findEach`, `findInBatches`) short-circuits to the appropriate zero-row sentinel before going through the finder. `whereNotIn(prop, [])` is a no-op (exclude-none = match-all), so the chain proceeds normally. Matches the user-facing behaviour every mature ORM converged on (Rails, Sequel, Django, Laravel Eloquent: empty `IN` matches no rows, empty `NOT IN` matches every row). The flag-based design avoids a runtime trap from Wheels' WHERE-clause parser (`vendor/wheels/model/sql.cfc` runs a property-extraction regex over every clause it sees — a raw `1 = 0` literal would be parsed as property `1` and trip `Wheels.ColumnNotFound`). Fourteen new specs in `vendor/wheels/tests/specs/model/queryBuilderSpec.cfc` cover empty-array, empty-list, composition with other clauses, the `whereNotIn` mirrors, every patched terminal (`findAll`, `first` / `findOne`, `exists`, `count`, `updateAll`, `deleteAll`, `findEach`, `findInBatches`), and the documented `select()` / `include()` silent-ignore caveat on the short-circuit path. Both copies of the query-builder guide were updated to document the short-circuit in the methods table (#2736)
- `wheels mcp setup` now writes a stdio-based `.opencode.json` instead of one pointing at the deprecated HTTP MCP endpoint. `cli/src/templates/OpenCodeConfig.json` — the file the setup command actually reads from (`setup.cfc:53`) — still carried the pre-4.0 shape: `"url": "http://localhost:{PORT}/wheels/mcp", "type": "remote"`, with `{PORT}` left as an unsubstituted literal string. OpenCode users running `wheels mcp setup` ended up with a config trying to connect to a host called `{PORT}` against an endpoint that emits a deprecation warning on every call. The template now uses the same stdio form already shipped in `tools/build/base/.opencode.json`: `"type": "local", "command": ["wheels", "mcp", "wheels"]`. The companion monorepo reference copy at `app/snippets/OpenCodeConfig.json` (not read by the setup command, but kept in sync for consistency) was updated to match. The CHANGELOG entry from when the stdio shift originally landed claimed all template copies had been updated; this closes the two that were missed (#2735)
- `wheels packages --help` / `wheels packages help` / `wheels packages -h` now emit a module-owned help string that documents `add` as the canonical install verb and explains why typing `install` does not work (LuCLI's built-in extension installer intercepts the literal verb before dispatch reaches the module — same trap that hit `wheels browser install` → `wheels browser setup` in #2345). Previously the auto-introspected help drifted from the real CLI surface, advertising an `install <name> [--force]` row that never actually installed anything (#2713)
Expand Down
52 changes: 52 additions & 0 deletions vendor/wheels/Dispatch.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,43 @@ component output="false" extends="wheels.Global"{
$debugPoint("setup");
}

// CORS preflight short-circuit: when the global middleware pipeline contains
// a `wheels.middleware.Cors` instance, run OPTIONS through the pipeline
// before route matching so unmatched preflight verbs reach the CORS handler
// instead of 404ing in $findMatchingRoute. The legacy
// `set(allowCorsRequests=true)` path aborted OPTIONS in EventMethods.cfc
// before dispatch; this preserves that contract for middleware users.
// See issue #2703.
local.preflightMethod = "";
try {
local.preflightMethod = $getRequestMethod();
} catch (any e) {
// Swallow intentionally: when request.cgi is not yet populated
// (e.g. test contexts or unusual dispatch paths) we fail closed by
// leaving preflightMethod empty so the short-circuit guard below is
// skipped and normal routing proceeds.
}
if (UCase(local.preflightMethod) == "OPTIONS" && $hasPreflightCapableMiddleware()) {
request.wheels.params = {};
// Cors.handle() reads the verb from arguments.request.cgi.request_method
// rather than arguments.request.method, so we don't carry the method
// field on this context. Cors is the only middleware that gates on
// this path; once it short-circuits, middleware registered after it
// does not run. Middleware registered before Cors still executes.
local.preflightContext = {
params = {},
route = {},
pathInfo = arguments.pathInfo
};
local.preflightHandler = function(required struct request) {
return "";
};
return variables.$middlewarePipeline.run(
request = local.preflightContext,
coreHandler = local.preflightHandler
);
}

local.params = $paramParser(argumentCollection = arguments);

// Set params in the request scope as well so we can display it in the debug info outside of the controller context.
Expand Down Expand Up @@ -325,6 +362,21 @@ component output="false" extends="wheels.Global"{
}
}

/**
* Returns true if the global middleware pipeline contains a CORS middleware
* instance capable of handling an OPTIONS preflight short-circuit. Used to
* preserve the legacy `allowCorsRequests=true` short-circuit semantics in
* the new middleware pipeline. See issue #2703.
*/
private boolean function $hasPreflightCapableMiddleware() {
for (local.mw in variables.$middlewarePipeline.getMiddleware()) {
if (IsObject(local.mw) && IsInstanceOf(local.mw, "wheels.middleware.Cors")) {
return true;
}
}
return false;
}

/**
* Resolve route-scoped middleware from the matched route's `middleware` property.
* Returns an array of instantiated middleware components.
Expand Down
19 changes: 15 additions & 4 deletions vendor/wheels/middleware/Cors.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -116,13 +116,24 @@ component implements="wheels.middleware.MiddlewareInterface" output="false" {
}

// Handle preflight OPTIONS request — return empty response immediately.
// Prefer the request struct passed to the middleware (the canonical
// per-request context, mirroring how RateLimiter resolves remote_addr
// from arguments.request.cgi) and fall back to the engine CGI scope
// when the request context doesn't carry a method. The engine CGI
// scope is read-only on Lucee 7, so unit tests that need to exercise
// the OPTIONS branch must inject the verb through arguments.request.cgi
// — hence the lookup order.
local.requestMethod = "GET";
try {
local.requestMethod = cgi.request_method;
} catch (any e) {
if (StructKeyExists(arguments.request, "cgi") && StructKeyExists(arguments.request.cgi, "request_method")) {
local.requestMethod = arguments.request.cgi.request_method;
} else {
try {
local.requestMethod = cgi.request_method;
} catch (any e) {
}
}

if (local.requestMethod == "OPTIONS") {
if (UCase(local.requestMethod) == "OPTIONS") {
try {
cfheader(name = "Access-Control-Max-Age", value = variables.maxAge);
} catch (any e) {
Expand Down
116 changes: 116 additions & 0 deletions vendor/wheels/tests/specs/middleware/CorsPreflightDispatchSpec.cfc
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
/**
* Regression test for #2703: wheels.middleware.Cors cannot short-circuit
* OPTIONS preflight because middleware runs AFTER route dispatch.
*
* The legacy `set(allowCorsRequests=true)` path aborted OPTIONS in
* EventMethods.cfc BEFORE route matching. The new middleware pipeline must
* preserve that behavior for cross-origin POST/PUT/PATCH/DELETE preflight
* to function — browsers block the actual request when preflight 404s.
*/
component extends="wheels.WheelsTest" {

function run() {

describe("CORS preflight short-circuit in dispatch", () => {

beforeEach(() => {
_savedMiddleware = StructKeyExists(application.wheels, "middleware")
? Duplicate(application.wheels.middleware) : [];
_savedRoutes = Duplicate(application.wheels.routes);
_savedStaticRoutes = StructKeyExists(application.wheels, "staticRoutes")
? Duplicate(application.wheels.staticRoutes) : {};
_savedCgiMethod = request.cgi.request_method;
application.wheels.routes = [];
application.wheels.staticRoutes = {};
});

afterEach(() => {
application.wheels.middleware = _savedMiddleware;
application.wheels.routes = _savedRoutes;
application.wheels.staticRoutes = _savedStaticRoutes;
request.cgi["request_method"] = _savedCgiMethod;
});

it("does not 404 on OPTIONS preflight when CORS middleware is registered", () => {
// This test validates the dispatch-layer fix: OPTIONS preflight
// reaches the middleware pipeline instead of 404ing in
// $findMatchingRoute. The `result == ""` assertion is satisfied
// by Dispatch's no-op preflightHandler closure, not by Cors's
// own OPTIONS short-circuit branch — Cors.handle() reads
// `cgi.request_method` from the engine CGI scope (which is the
// GET from the test runner request), so the middleware falls
// through to next() and the no-op handler returns "". The
// Cors middleware's own OPTIONS branch is tested in CorsSpec.cfc.
application.wheels.middleware = [
new wheels.middleware.Cors(allowOrigins = "https://portal.pai.com")
];
request.cgi["request_method"] = "OPTIONS";

var d = application.wo.$createObjectFromRoot(
path = "wheels", fileName = "Dispatch", method = "$init"
);

var threw = false;
var result = "preflight-not-reached";
try {
result = d.$request(
pathInfo = "/api/v1/jvm",
scriptName = "",
formScope = {},
urlScope = {}
);
} catch (any e) {
threw = true;
}

expect(threw).toBeFalse();
expect(result).toBe("");
});

it("still 404s on OPTIONS request when no CORS middleware is registered", () => {
// Preserves existing dispatch behavior — we only short-circuit when
// a CORS instance is actually configured.
application.wheels.middleware = [];
request.cgi["request_method"] = "OPTIONS";

var d = application.wo.$createObjectFromRoot(
path = "wheels", fileName = "Dispatch", method = "$init"
);

expect(function() {
d.$request(
pathInfo = "/api/v1/jvm",
scriptName = "",
formScope = {},
urlScope = {}
);
}).toThrow("Wheels.RouteNotFound");
});

it("still routes non-OPTIONS requests through normal dispatch when CORS is registered", () => {
// Sanity check: GET requests should still go through $paramParser
// → $findMatchingRoute and 404 normally when unmatched.
application.wheels.middleware = [
new wheels.middleware.Cors(allowOrigins = "*")
];
request.cgi["request_method"] = "GET";

var d = application.wo.$createObjectFromRoot(
path = "wheels", fileName = "Dispatch", method = "$init"
);

expect(function() {
d.$request(
pathInfo = "/api/v1/jvm",
scriptName = "",
formScope = {},
urlScope = {}
);
}).toThrow("Wheels.RouteNotFound");
});

});

}

}
17 changes: 17 additions & 0 deletions vendor/wheels/tests/specs/middleware/CorsSpec.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,23 @@ component extends="wheels.WheelsTest" {
expect(local.result).toBe("same-origin");
});

it("short-circuits OPTIONS preflight with empty string instead of calling next", function() {
// Cors.handle() prefers request.cgi.request_method (when present)
// over the engine CGI scope, mirroring its http_origin lookup.
// If the OPTIONS branch fires, the result is "" (empty body).
// If the middleware falls through to next(), the closure below
// would return "should-not-reach" instead.
local.cors = new wheels.middleware.Cors(allowOrigins = "https://example.com");
local.reqCtx = {cgi = {request_method = "OPTIONS", http_origin = "https://example.com"}};
local.result = local.cors.handle(
request = local.reqCtx,
next = function(required struct request) {
return "should-not-reach";
}
);
expect(local.result).toBe("");
});

describe("Vary: Origin header", function() {

it("emits Vary: Origin when reflecting an allowed origin", function() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,12 @@ mapper()

Routes under `/api` get the Cors middleware. `resources("pages")` and everything outside the scope do not. Scope-level middleware composes with global middleware — both run.

<Aside type="caution">
The preflight short-circuit that prevents unmatched `OPTIONS` requests from reaching the route table applies only when `Cors` is registered in the **global** pipeline via `config/settings.cfm`. Route-scoped `Cors` declared inside `.scope()` in `config/routes.cfm` does not benefit: route matching runs before route-scoped middleware executes, so a browser preflight to a path that only declares `POST` will 404 with `Wheels.RouteNotFound` unless `Cors` is also in the global pipeline.

The dispatcher detects preflight-capable middleware with `IsInstanceOf(mw, "wheels.middleware.Cors")`, so the short-circuit fires for `wheels.middleware.Cors` **and any subclass that extends it**. A custom CORS middleware that implements `wheels.middleware.MiddlewareInterface` directly without extending `wheels.middleware.Cors` will not trigger the short-circuit — extend `wheels.middleware.Cors` (overriding `handle()` as needed) if you want the same dispatch-level OPTIONS handling.
</Aside>

## Debugging CORS failures

When a cross-origin request fails, work through this checklist:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,12 @@ mapper()

Routes under `/api` get the Cors middleware. `resources("pages")` and everything outside the scope do not. Scope-level middleware composes with global middleware — both run.

<Aside type="caution">
The preflight short-circuit that prevents unmatched `OPTIONS` requests from reaching the route table applies only when `Cors` is registered in the **global** pipeline via `config/settings.cfm`. Route-scoped `Cors` declared inside `.scope()` in `config/routes.cfm` does not benefit: route matching runs before route-scoped middleware executes, so a browser preflight to a path that only declares `POST` will 404 with `Wheels.RouteNotFound` unless `Cors` is also in the global pipeline.

The dispatcher detects preflight-capable middleware with `IsInstanceOf(mw, "wheels.middleware.Cors")`, so the short-circuit fires for `wheels.middleware.Cors` **and any subclass that extends it**. A custom CORS middleware that implements `wheels.middleware.MiddlewareInterface` directly without extending `wheels.middleware.Cors` will not trigger the short-circuit — extend `wheels.middleware.Cors` (overriding `handle()` as needed) if you want the same dispatch-level OPTIONS handling.
</Aside>

## Debugging CORS failures

When a cross-origin request fails, work through this checklist:
Expand Down
Loading