From 0748f90138ccf0c6b64d8227243f5b1525330019 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 16 Aug 2026 15:53:58 +0000 Subject: [PATCH 1/7] feat: add route-scoped middleware via existing interceptor points Router.middleware() attaches middleware to a route at a ColdBox interception point (preProcess by default, or postProcess) instead of introducing a parallel middleware subsystem. A target can be an inline closure, a WireBox ID (resolved via getInstance() on every call, so it respects the mapping's own declared scope), or any object - WireBox managed or not - that exposes a method named after the point, the same duck-typed convention ColdBox interceptors already use. group({ middleware: [...] }, body) shares a chain across every route registered in the body, ahead of each route's own middleware(), tracked on its own stack so nested groups compose correctly independent of group()'s existing withClosure/onGroup nesting limitation. RoutingService.runRouteMiddleware() executes the current route's middleware for a given point, wired into Bootstrap.cfc right after the global preProcess announce and right before postProcess - route-scoped middleware runs closest to the handler, global interceptors stay the outermost layer. A target returning true short-circuits the remaining middleware at that point for that route, mirroring InterceptorState.processSync()'s existing short-circuit contract; it does not by itself skip the handler or render, matching how a normal preProcess/postProcess interceptor works today. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_016kCmPkBvNZZhU6iuDcG4NR --- system/Bootstrap.cfc | 4 + system/web/routing/Router.cfc | 95 ++++++++++- system/web/services/RoutingService.cfc | 66 ++++++++ tests/resources/routing/SampleMiddleware.cfc | 28 ++++ tests/specs/web/routing/RouterTest.cfc | 93 +++++++++++ .../specs/web/routing/RoutingServiceTest.cfc | 151 ++++++++++++++++++ 6 files changed, 430 insertions(+), 7 deletions(-) create mode 100644 tests/resources/routing/SampleMiddleware.cfc diff --git a/system/Bootstrap.cfc b/system/Bootstrap.cfc index 4332fdf18..0373dceb3 100644 --- a/system/Bootstrap.cfc +++ b/system/Bootstrap.cfc @@ -233,6 +233,8 @@ component serializable="false" accessors="true" { // ****** PRE PROCESS *******/ interceptorService.announce( "preProcess" ); + // Route-scoped middleware runs after the global preProcess chain, closest to the handler + cbController.getRoutingService().runRouteMiddleware( event, "preProcess" ); if ( len( cbController.getSetting( "RequestStartHandler" ) ) ) { cbController.runEvent( event : cbController.getSetting( "RequestStartHandler" ), @@ -410,6 +412,8 @@ component serializable="false" accessors="true" { prePostExempt = true ); } + // Route-scoped middleware runs before the global postProcess chain, closest to the handler + cbController.getRoutingService().runRouteMiddleware( event, "postProcess" ); interceptorService.announce( "postProcess" ); // ****** FLASH AUTO-SAVE *******/ diff --git a/system/web/routing/Router.cfc b/system/web/routing/Router.cfc index 221ea3c59..76361e8a0 100644 --- a/system/web/routing/Router.cfc +++ b/system/web/routing/Router.cfc @@ -137,13 +137,15 @@ component /************************************** FLUENT CONSTRUCTS *********************************************/ // With closure - variables.withClosure = {}; + variables.withClosure = {}; // Module closure - variables.thisModule = ""; + variables.thisModule = ""; // Groupt Pivot - variables.onGroup = false; + variables.onGroup = false; + // Stack of group-level middleware arrays, outermost first, so nested groups accumulate in order + variables.groupMiddlewareStack = []; // Routing pointer - variables.thisRoute = initRouteDefinition(); + variables.thisRoute = initRouteDefinition(); /************************************** CONSTANTS *********************************************/ @@ -510,7 +512,7 @@ component * } ) * * - * @options The route options that match routing, look at the addRoute() method + * @options The route options that match routing, look at the addRoute() method. A `middleware` array (same target values middleware() accepts) applies to every route registered within the body, ahead of any middleware the route registers for itself. * @body The closure or lambda to contain all the routing methods to be grouped with the options data. */ function group( struct options = {}, body ){ @@ -519,10 +521,24 @@ component // set the withClosure variables.withClosure.append( arguments.options ); + // Push this group's middleware onto the stack - arrays aren't part of the withClosure + // default/prefix merge, so they're inherited via their own stack instead. Pushed even when + // empty so the stack depth always matches the current group nesting depth. Entries are + // normalized to the same { target, point } shape middleware() produces. + var groupMiddleware = structKeyExists( arguments.options, "middleware" ) ? arguments.options.middleware : []; + variables.groupMiddlewareStack.append( + groupMiddleware.map( ( entry ) => { + return ( isStruct( entry ) && entry.keyExists( "target" ) ) ? entry : { + "target" : entry, + "point" : "preProcess" + }; + } ) + ); // Execute the body arguments.body( arguments.options ); // Pivot out of the group and do cleanup + variables.groupMiddlewareStack.deleteAt( variables.groupMiddlewareStack.len() ); variables.onGroup = false; variables.withClosure = {}; @@ -797,7 +813,8 @@ component boolean ai = "false", any aiRunnable = "", boolean mcp = "false", - string mcpServer = "" + string mcpServer = "", + array middleware = [] ){ // The route construct we will save var thisRoute = {}; @@ -816,6 +833,18 @@ component // Process all incoming arguments into the route to store thisRoute.append( arguments ); + // Inherit group-level middleware (outermost group first), followed by this route's own + // entries registered via .middleware(). Arrays don't participate in processWith()'s + // default/prefix merge, so group inheritance is tracked explicitly on its own stack. + if ( variables.groupMiddlewareStack.len() ) { + var inheritedMiddleware = []; + for ( var groupEntries in variables.groupMiddlewareStack ) { + inheritedMiddleware.append( groupEntries, true ); + } + inheritedMiddleware.append( thisRoute.middleware, true ); + thisRoute.middleware = inheritedMiddleware; + } + // Cleanup Route: Add trailing / to make it easier to parse if ( right( thisRoute.pattern, 1 ) IS NOT "/" ) { thisRoute.pattern = thisRoute.pattern & "/"; @@ -1176,6 +1205,7 @@ component "layout" : "", // The layout to proxy to "layoutModule" : "", // If the layout comes from a module "meta" : {}, // Route metadata if any + "middleware" : [], // Route-scoped middleware entries: [ { target, point } ] "module" : "", // The module event we must execute "moduleRouting" : "", // This routes to a module "name" : "", // The named route @@ -1378,6 +1408,57 @@ component /* MODIFIERS */ /****************************************************************************************************************************/ + /** + * Attach route-scoped middleware. Middleware runs at a ColdBox interception point (`preProcess` + * by default, or `postProcess`), but only for requests that matched this route - it is + * InterceptorState's point-based dispatch, scoped to one route instead of the whole app. + * + * A target can be: + * - A closure/lambda: `function( event, rc, prc ){ ... }` + * - A WireBox ID: resolved via `getInstance()` on every request, so it respects whatever scope + * (singleton, prototype, etc) the mapping was registered with. + * - Any object, WireBox-managed or not, that has a method named after the point (`preProcess()`/ + * `postProcess()`) - the same duck-typed convention ColdBox interceptors themselves already use. + * No base class or interface is required. + * + * Returning `true` from a target short-circuits the remaining middleware for this route at this + * point - it does not, by itself, skip the handler or the render. To actually stop the request, + * call `event.relocate()`, `event.renderData().noExecution()`, `event.etag()`, etc, exactly as you + * would from any other preProcess/postProcess interceptor. + * + *
+	 * // inline closure
+	 * route( "/admin/:action" ).middleware( function( event, rc, prc ){
+	 *     if ( !auth.isLoggedIn() ) {
+	 *         event.relocate( "login" );
+	 *         return true;
+	 *     }
+	 * } ).toHandler( "admin" );
+	 *
+	 * // a WireBox ID, or any class with a preProcess()/postProcess() method
+	 * route( "/api/reports" ).middleware( "AuditLog", "postProcess" ).to( "reports.index" );
+	 *
+	 * // multiple targets in one call, all on the same point
+	 * route( "/api/orders" ).middleware( [ "RateLimiter", "RequireApiKey" ] ).toHandler( "orders" );
+	 * 
+ * + * @target A closure/lambda, a WireBox ID, an object instance, or an array of any mix of those. + * @point The interception point to run this middleware at. Defaults to `preProcess`. + */ + function middleware( required any target, string point = "preProcess" ){ + // process a with closure if not empty + if ( !variables.withClosure.isEmpty() ) { + processWith( arguments ); + } + + var targets = isArray( arguments.target ) ? arguments.target : [ arguments.target ]; + for ( var thisTarget in targets ) { + variables.thisRoute.middleware.append( { "target" : thisTarget, "point" : arguments.point } ); + } + + return this; + } + /** * Add a header to a route *
@@ -2402,7 +2483,7 @@ component
 
 		// Inline response closure: resolves the server name and delegates to MCPRequestProcessor
 		var mcpResponseClosure = ( event, rc, prc ) => {
-			var resolvedServerName                              = rc.keyExists( "mcpServer" ) ? rc.mcpServer : serverName
+			var resolvedServerName = rc.keyExists( "mcpServer" ) ? rc.mcpServer : serverName
 			return bxModules.bxai.models.mcp.MCPRequestProcessor::processHttp( resolvedServerName );
 		};
 
diff --git a/system/web/services/RoutingService.cfc b/system/web/services/RoutingService.cfc
index cb7c66974..8b37821f9 100644
--- a/system/web/services/RoutingService.cfc
+++ b/system/web/services/RoutingService.cfc
@@ -440,6 +440,72 @@ component extends="coldbox.system.web.services.BaseService" accessors="true" {
 		return discoveredEvent;
 	}
 
+	/**
+	 * Run the currently matched route's middleware (`Router.middleware()`) for the given interception
+	 * point. A no-op if no route matched, or the matched route registered no middleware for this point.
+	 *
+	 * Mirrors `InterceptorState.processSync()`'s short-circuit contract: a target returning `true`
+	 * stops the remaining middleware at this point for this route. It does not, by itself, skip the
+	 * handler or the render - the target must do that explicitly (`event.relocate()`,
+	 * `event.renderData().noExecution()`, etc), exactly like any other preProcess/postProcess interceptor.
+	 *
+	 * @event The ColdBox Request context
+	 * @point The interception point to run middleware for, e.g. `preProcess` or `postProcess`
+	 *
+	 * @return True if a middleware target short-circuited the chain by returning true; false otherwise
+	 */
+	boolean function runRouteMiddleware( required event, required string point ){
+		var routeRecord = arguments.event.getCurrentRouteRecord();
+
+		if ( !structKeyExists( routeRecord, "middleware" ) || !routeRecord.middleware.len() ) {
+			return false;
+		}
+
+		var invocationArgs = {
+			"event" : arguments.event,
+			"rc"    : arguments.event.getCollection(),
+			"prc"   : arguments.event.getPrivateCollection()
+		};
+
+		for ( var entry in routeRecord.middleware ) {
+			if ( entry.point != arguments.point ) {
+				continue;
+			}
+
+			var target  = resolveMiddlewareTarget( entry.target );
+			var results = "";
+
+			if ( isClosure( target ) || isCustomFunction( target ) ) {
+				results = target( argumentCollection = invocationArgs );
+			} else if ( structKeyExists( target, arguments.point ) ) {
+				results = invoke( target, arguments.point, invocationArgs );
+			} else {
+				// No method matching this point on the target - nothing to run
+				continue;
+			}
+
+			if ( !isNull( local.results ) && isBoolean( results ) && results ) {
+				return true;
+			}
+		}
+
+		return false;
+	}
+
+	/**
+	 * Resolve a route middleware target: a WireBox ID string is resolved via `getInstance()` on every
+	 * call so it respects the mapping's own declared scope; anything else (a closure or an already
+	 * built object instance) is returned as-is.
+	 *
+	 * @target The middleware target to resolve
+	 */
+	private function resolveMiddlewareTarget( required target ){
+		if ( isSimpleValue( arguments.target ) ) {
+			return variables.wirebox.getInstance( arguments.target );
+		}
+		return arguments.target;
+	}
+
 	/****************************************************************************************************************************/
 	/* 											ROUTE DISPATCHING METHODS														*/
 	/****************************************************************************************************************************/
diff --git a/tests/resources/routing/SampleMiddleware.cfc b/tests/resources/routing/SampleMiddleware.cfc
new file mode 100644
index 000000000..35b54c32c
--- /dev/null
+++ b/tests/resources/routing/SampleMiddleware.cfc
@@ -0,0 +1,28 @@
+/**
+ * A plain object with no base class or interface - used to prove that route-scoped middleware
+ * (Router.middleware()) works by duck-typed method name, the same convention ColdBox interceptors
+ * already use, not by inheritance.
+ */
+component accessors="true" {
+
+	property name="wasCalled";
+	property name="shortCircuit";
+
+	function init(){
+		variables.wasCalled    = false;
+		variables.shortCircuit = false;
+		return this;
+	}
+
+	function preProcess( event, rc, prc ){
+		variables.wasCalled = true;
+		if ( variables.shortCircuit ) {
+			return true;
+		}
+	}
+
+	function postProcess( event, rc, prc ){
+		variables.wasCalled = true;
+	}
+
+}
diff --git a/tests/specs/web/routing/RouterTest.cfc b/tests/specs/web/routing/RouterTest.cfc
index df96adc4a..33d869ed3 100644
--- a/tests/specs/web/routing/RouterTest.cfc
+++ b/tests/specs/web/routing/RouterTest.cfc
@@ -356,6 +356,99 @@ component extends="coldbox.system.testing.BaseModelTest" {
 				} );
 			} );
 
+			story( "I want to attach route-scoped middleware", function(){
+				given( "a single middleware target with no explicit point", function(){
+					then( "it defaults to preProcess and accumulates in order", function(){
+						var authCheck = function( event, rc, prc ){
+						};
+						router
+							.route( "/admin" )
+							.middleware( authCheck )
+							.middleware( "AuditLog", "postProcess" )
+							.toHandler( "admin" );
+
+						var middleware = router.getRoutes()[ 1 ].middleware;
+						expect( middleware ).toHaveLength( 2 );
+						expect( middleware[ 1 ].target ).toBe( authCheck );
+						expect( middleware[ 1 ].point ).toBe( "preProcess" );
+						expect( middleware[ 2 ].target ).toBe( "AuditLog" );
+						expect( middleware[ 2 ].point ).toBe( "postProcess" );
+					} );
+				} );
+
+				given( "an array of targets in a single call", function(){
+					then( "each target is registered individually on the same point", function(){
+						router
+							.route( "/api/orders" )
+							.middleware( [ "RateLimiter", "RequireApiKey" ] )
+							.toHandler( "orders" );
+
+						var middleware = router.getRoutes()[ 1 ].middleware;
+						expect( middleware ).toHaveLength( 2 );
+						expect( middleware[ 1 ].target ).toBe( "RateLimiter" );
+						expect( middleware[ 1 ].point ).toBe( "preProcess" );
+						expect( middleware[ 2 ].target ).toBe( "RequireApiKey" );
+						expect( middleware[ 2 ].point ).toBe( "preProcess" );
+					} );
+				} );
+
+				given( "a route with no middleware() calls", function(){
+					then( "it still carries the defaulted empty middleware array", function(){
+						router.route( "/plain" );
+						expect( router.getThisRoute().middleware ).toBeArray().toBeEmpty();
+					} );
+				} );
+
+				given( "a group with middleware options", function(){
+					then( "every route inside inherits it ahead of its own middleware", function(){
+						router.group( { pattern : "/api", middleware : [ "RequireApiKey" ] }, function( options ){
+							router
+								.route( "/users" )
+								.middleware( "RateLimiter" )
+								.toHandler( "users" );
+							router.route( "/products" ).toHandler( "products" );
+						} );
+
+						var routes = router.getRoutes();
+						expect( routes ).toHaveLength( 2 );
+
+						expect( routes[ 1 ].middleware ).toHaveLength( 2 );
+						expect( routes[ 1 ].middleware[ 1 ].target ).toBe( "RequireApiKey" );
+						expect( routes[ 1 ].middleware[ 2 ].target ).toBe( "RateLimiter" );
+
+						expect( routes[ 2 ].middleware ).toHaveLength( 1 );
+						expect( routes[ 2 ].middleware[ 1 ].target ).toBe( "RequireApiKey" );
+					} );
+				} );
+
+				given( "a route registered outside any group", function(){
+					then( "it does not inherit a previously-run group's middleware", function(){
+						router.group( { pattern : "/api", middleware : [ "RequireApiKey" ] }, function( options ){
+							router.route( "/users" ).toHandler( "users" );
+						} );
+						router.route( "/public" ).toHandler( "public" );
+
+						var routes = router.getRoutes();
+						expect( routes[ 2 ].middleware ).toBeArray().toBeEmpty();
+					} );
+				} );
+
+				given( "nested groups each contributing middleware", function(){
+					then( "the outer group's middleware runs before the inner group's", function(){
+						router.group( { pattern : "/api", middleware : [ "RequireApiKey" ] }, function( options ){
+							router.group( { pattern : "/admin", middleware : [ "RequireAdmin" ] }, function( innerOptions ){
+								router.route( "/users" ).toHandler( "users" );
+							} );
+						} );
+
+						var middleware = router.getRoutes()[ 1 ].middleware;
+						expect( middleware ).toHaveLength( 2 );
+						expect( middleware[ 1 ].target ).toBe( "RequireApiKey" );
+						expect( middleware[ 2 ].target ).toBe( "RequireAdmin" );
+					} );
+				} );
+			} );
+
 			story( "Router will throw exception if a non-closure or string is passed to the body of a toResponse()", function(){
 				given( "Anything but a closure or string to the toResponse() body", function(){
 					then( "an InvalidArgumentException will be thrown", function(){
diff --git a/tests/specs/web/routing/RoutingServiceTest.cfc b/tests/specs/web/routing/RoutingServiceTest.cfc
index 547b662dc..0b410b794 100755
--- a/tests/specs/web/routing/RoutingServiceTest.cfc
+++ b/tests/specs/web/routing/RoutingServiceTest.cfc
@@ -282,6 +282,157 @@
 				expect( discoveredEventPOST ).toBe( "api-v1:MyOtherHandler.create" );
 			} );
 		} );
+
+		describe( "route-scoped middleware (runRouteMiddleware())", function(){
+			beforeEach( function(){
+				mockEvent = createMock( "coldbox.system.web.context.RequestContext" ).init(
+					controller = getController(),
+					properties = {
+						defaultLayout : "Main.cfm",
+						defaultView   : "",
+						eventName     : "event",
+						modules       : {}
+					}
+				);
+			} );
+
+			it( "no-ops when no route matched", function(){
+				mockEvent.$( "getCurrentRouteRecord", {} );
+				expect( routingService.runRouteMiddleware( mockEvent, "preProcess" ) ).toBeFalse();
+			} );
+
+			it( "no-ops when the matched route has no middleware", function(){
+				mockEvent.$( "getCurrentRouteRecord", { middleware : [] } );
+				expect( routingService.runRouteMiddleware( mockEvent, "preProcess" ) ).toBeFalse();
+			} );
+
+			it( "invokes an inline closure target", function(){
+				var called = false;
+				var target = function( event, rc, prc ){
+					called = true;
+				};
+				mockEvent.$(
+					"getCurrentRouteRecord",
+					{ middleware : [ { target : target, point : "preProcess" } ] }
+				);
+
+				routingService.runRouteMiddleware( mockEvent, "preProcess" );
+
+				expect( called ).toBeTrue();
+			} );
+
+			it( "only runs middleware registered for the requested point", function(){
+				var preCalls  = 0;
+				var postCalls = 0;
+				mockEvent.$(
+					"getCurrentRouteRecord",
+					{
+						middleware : [
+							{
+								target : function( event, rc, prc ){
+									preCalls++;
+								},
+								point : "preProcess"
+							},
+							{
+								target : function( event, rc, prc ){
+									postCalls++;
+								},
+								point : "postProcess"
+							}
+						]
+					}
+				);
+
+				routingService.runRouteMiddleware( mockEvent, "preProcess" );
+
+				expect( preCalls ).toBe( 1 );
+				expect( postCalls ).toBe( 0 );
+			} );
+
+			it( "short-circuits the remaining middleware when a target returns true", function(){
+				var secondCalled = false;
+				mockEvent.$(
+					"getCurrentRouteRecord",
+					{
+						middleware : [
+							{
+								target : function( event, rc, prc ){
+									return true;
+								},
+								point : "preProcess"
+							},
+							{
+								target : function( event, rc, prc ){
+									secondCalled = true;
+								},
+								point : "preProcess"
+							}
+						]
+					}
+				);
+
+				var result = routingService.runRouteMiddleware( mockEvent, "preProcess" );
+
+				expect( result ).toBeTrue();
+				expect( secondCalled ).toBeFalse();
+			} );
+
+			it( "invokes a duck-typed object target with no base class by its point-named method", function(){
+				var target = new tests.resources.routing.SampleMiddleware();
+				mockEvent.$(
+					"getCurrentRouteRecord",
+					{ middleware : [ { target : target, point : "preProcess" } ] }
+				);
+
+				routingService.runRouteMiddleware( mockEvent, "preProcess" );
+
+				expect( target.getWasCalled() ).toBeTrue();
+			} );
+
+			it( "skips a target with no method matching the requested point", function(){
+				var target = new tests.resources.routing.SampleMiddleware();
+				mockEvent.$(
+					"getCurrentRouteRecord",
+					{ middleware : [ { target : target, point : "someOtherPoint" } ] }
+				);
+
+				expect( function(){
+					routingService.runRouteMiddleware( mockEvent, "someOtherPoint" );
+				} ).notToThrow();
+				expect( target.getWasCalled() ).toBeFalse();
+			} );
+
+			it( "resolves a string target as a WireBox ID on every call", function(){
+				getController()
+					.getWireBox()
+					.registerNewInstance(
+						name         = "RouteMiddlewareTestTarget",
+						instancePath = "tests.resources.routing.SampleMiddleware"
+					);
+
+				mockEvent.$(
+					"getCurrentRouteRecord",
+					{
+						middleware : [
+							{
+								target : "RouteMiddlewareTestTarget",
+								point  : "preProcess"
+							}
+						]
+					}
+				);
+
+				routingService.runRouteMiddleware( mockEvent, "preProcess" );
+
+				expect(
+					getController()
+						.getWireBox()
+						.getInstance( "RouteMiddlewareTestTarget" )
+						.getWasCalled()
+				).toBeTrue();
+			} );
+		} );
 	}
 
 	/**

From b1b294635faa91ded360d0df6905c2e1c3ea2675 Mon Sep 17 00:00:00 2001
From: Claude 
Date: Sun, 16 Aug 2026 16:04:18 +0000
Subject: [PATCH 2/7] fix: WireBox scope in middleware test + simplify group()
 normalization

The WireBox-ID resolution test registered its mapping with no explicit
scope, so getInstance() returned a fresh instance per call under the
default (non-singleton) scope - the test's post-hoc assertion never saw
the state the production code had mutated. Registers it as a singleton
explicitly, matching what the docstring already promised: resolution
respects the mapping's own declared scope.

Also replaces group()'s arrow-function/map() middleware normalization
with a plain for-loop, matching the rest of the file's established style
more closely and avoiding CI's older cfformat disagreeing with a newer
local run.

Co-Authored-By: Claude Sonnet 5 
Claude-Session: https://claude.ai/code/session_016kCmPkBvNZZhU6iuDcG4NR
---
 system/web/routing/Router.cfc                 | 19 ++++++++++---------
 .../specs/web/routing/RoutingServiceTest.cfc  |  7 ++++---
 2 files changed, 14 insertions(+), 12 deletions(-)

diff --git a/system/web/routing/Router.cfc b/system/web/routing/Router.cfc
index 76361e8a0..fbb97f917 100644
--- a/system/web/routing/Router.cfc
+++ b/system/web/routing/Router.cfc
@@ -525,15 +525,16 @@ component
 		// default/prefix merge, so they're inherited via their own stack instead. Pushed even when
 		// empty so the stack depth always matches the current group nesting depth. Entries are
 		// normalized to the same { target, point } shape middleware() produces.
-		var groupMiddleware = structKeyExists( arguments.options, "middleware" ) ? arguments.options.middleware : [];
-		variables.groupMiddlewareStack.append(
-			groupMiddleware.map( ( entry ) => {
-				return ( isStruct( entry ) && entry.keyExists( "target" ) ) ? entry : {
-					"target" : entry,
-					"point"  : "preProcess"
-				};
-			} )
-		);
+		var groupMiddleware   = structKeyExists( arguments.options, "middleware" ) ? arguments.options.middleware : [];
+		var normalizedGroupMW = [];
+		for ( var entry in groupMiddleware ) {
+			if ( isStruct( entry ) && entry.keyExists( "target" ) ) {
+				normalizedGroupMW.append( entry );
+			} else {
+				normalizedGroupMW.append( { "target" : entry, "point" : "preProcess" } );
+			}
+		}
+		variables.groupMiddlewareStack.append( normalizedGroupMW );
 		// Execute the body
 		arguments.body( arguments.options );
 
diff --git a/tests/specs/web/routing/RoutingServiceTest.cfc b/tests/specs/web/routing/RoutingServiceTest.cfc
index 0b410b794..eaf516794 100755
--- a/tests/specs/web/routing/RoutingServiceTest.cfc
+++ b/tests/specs/web/routing/RoutingServiceTest.cfc
@@ -404,12 +404,13 @@
 			} );
 
 			it( "resolves a string target as a WireBox ID on every call", function(){
-				getController()
-					.getWireBox()
+				var wirebox = getController().getWireBox();
+				wirebox
 					.registerNewInstance(
 						name         = "RouteMiddlewareTestTarget",
 						instancePath = "tests.resources.routing.SampleMiddleware"
-					);
+					)
+					.setScope( wirebox.getBinder().SCOPES.SINGLETON );
 
 				mockEvent.$(
 					"getCurrentRouteRecord",

From 2bf4b78ae66f3e35ba33c4f56732995e76d88565 Mon Sep 17 00:00:00 2001
From: Claude 
Date: Sun, 16 Aug 2026 16:07:22 +0000
Subject: [PATCH 3/7] fix: isolate groupMiddlewareStack init from the
 pre-existing alignment block

The new variables.groupMiddlewareStack line sat inside a comment-interrupted
block of variable initializers that cfformat column-aligns across all
lines. Its longer name pushed every other line's = column out, and local
(newer) cfformat and CI's (older) cfformat apparently disagree on how far
that realignment should propagate - CI kept flagging Router.cfc without
saying why. Moving the new line after a blank line keeps the original
four-line block byte-for-byte as it was before this feature, sidestepping
the disagreement entirely.

Co-Authored-By: Claude Sonnet 5 
Claude-Session: https://claude.ai/code/session_016kCmPkBvNZZhU6iuDcG4NR
---
 system/web/routing/Router.cfc | 11 ++++++-----
 1 file changed, 6 insertions(+), 5 deletions(-)

diff --git a/system/web/routing/Router.cfc b/system/web/routing/Router.cfc
index fbb97f917..ab6f09d94 100644
--- a/system/web/routing/Router.cfc
+++ b/system/web/routing/Router.cfc
@@ -137,15 +137,16 @@ component
 		/************************************** FLUENT CONSTRUCTS *********************************************/
 
 		// With closure
-		variables.withClosure          = {};
+		variables.withClosure = {};
 		// Module closure
-		variables.thisModule           = "";
+		variables.thisModule  = "";
 		// Groupt Pivot
-		variables.onGroup              = false;
+		variables.onGroup     = false;
+		// Routing pointer
+		variables.thisRoute   = initRouteDefinition();
+
 		// Stack of group-level middleware arrays, outermost first, so nested groups accumulate in order
 		variables.groupMiddlewareStack = [];
-		// Routing pointer
-		variables.thisRoute            = initRouteDefinition();
 
 		/************************************** CONSTANTS *********************************************/
 

From 10d2a3e7b59ab8d914e7e6e4df3f18c01d11ea89 Mon Sep 17 00:00:00 2001
From: Claude 
Date: Sun, 16 Aug 2026 16:12:02 +0000
Subject: [PATCH 4/7] fix: restore CI-expected alignment padding on the MCP
 resolvedServerName line

A background agent pulled the exact CI container image (cfml-ci-tools
1.0.12, CommandBox 5.8.0) and ran cfformat check --verbose against it,
producing the real diff: the file's earlier session had already padded
this line to satisfy CI's alignment.consecutive.assignments rule inside
the mcpResponseClosure arrow function, but a later `cfformat run
--overwrite` pass in this session (using a newer local cfformat that
disagrees on whether a nested var inside an arrow-function body joins the
outer assignment's alignment group) silently stripped that padding back
out. Restoring it - unrelated to this session's actual feature work,
same as the earlier documented instance of this exact drift.

Co-Authored-By: Claude Sonnet 5 
Claude-Session: https://claude.ai/code/session_016kCmPkBvNZZhU6iuDcG4NR
---
 system/web/routing/Router.cfc | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/system/web/routing/Router.cfc b/system/web/routing/Router.cfc
index ab6f09d94..887099a28 100644
--- a/system/web/routing/Router.cfc
+++ b/system/web/routing/Router.cfc
@@ -2485,7 +2485,7 @@ component
 
 		// Inline response closure: resolves the server name and delegates to MCPRequestProcessor
 		var mcpResponseClosure = ( event, rc, prc ) => {
-			var resolvedServerName = rc.keyExists( "mcpServer" ) ? rc.mcpServer : serverName
+			var resolvedServerName                              = rc.keyExists( "mcpServer" ) ? rc.mcpServer : serverName
 			return bxModules.bxai.models.mcp.MCPRequestProcessor::processHttp( resolvedServerName );
 		};
 

From 72747500664e9da611bb09966dc2cc800fdc3ccf Mon Sep 17 00:00:00 2001
From: Claude 
Date: Sun, 16 Aug 2026 17:37:05 +0000
Subject: [PATCH 5/7] fix: address Copilot review findings on group()
 middleware handling

- group()'s options.middleware is now normalized to an array before
  iterating - a single non-array target (e.g. a bare closure or WireBox
  ID string, not wrapped in []) would otherwise iterate its characters
  (if a string) or fail outright, instead of being treated as one entry.
- A struct entry in options.middleware that omits its own `point` key no
  longer throws later in RoutingService.runRouteMiddleware() when that
  key is read - it now defaults to "preProcess", same as every other
  middleware()-registered entry.
- group() now wraps body execution in try/finally: if the body throws,
  groupMiddlewareStack/onGroup/withClosure cleanup still runs, so a
  failed group registration can't leak its middleware/options into
  whatever gets registered next.

Co-Authored-By: Claude Sonnet 5 
Claude-Session: https://claude.ai/code/session_016kCmPkBvNZZhU6iuDcG4NR
---
 system/web/routing/Router.cfc          | 29 +++++++++++-----
 tests/specs/web/routing/RouterTest.cfc | 47 ++++++++++++++++++++++++++
 2 files changed, 67 insertions(+), 9 deletions(-)

diff --git a/system/web/routing/Router.cfc b/system/web/routing/Router.cfc
index 887099a28..0df143bd9 100644
--- a/system/web/routing/Router.cfc
+++ b/system/web/routing/Router.cfc
@@ -525,24 +525,35 @@ component
 		// Push this group's middleware onto the stack - arrays aren't part of the withClosure
 		// default/prefix merge, so they're inherited via their own stack instead. Pushed even when
 		// empty so the stack depth always matches the current group nesting depth. Entries are
-		// normalized to the same { target, point } shape middleware() produces.
-		var groupMiddleware   = structKeyExists( arguments.options, "middleware" ) ? arguments.options.middleware : [];
+		// normalized to the same { target, point } shape middleware() produces - options.middleware
+		// may be a single target (not wrapped in an array) or a struct missing its own `point`.
+		var groupMiddleware = structKeyExists( arguments.options, "middleware" ) ? arguments.options.middleware : [];
+		if ( !isArray( groupMiddleware ) ) {
+			groupMiddleware = [ groupMiddleware ];
+		}
 		var normalizedGroupMW = [];
 		for ( var entry in groupMiddleware ) {
 			if ( isStruct( entry ) && entry.keyExists( "target" ) ) {
-				normalizedGroupMW.append( entry );
+				normalizedGroupMW.append( {
+					"target" : entry.target,
+					"point"  : entry.keyExists( "point" ) ? entry.point : "preProcess"
+				} );
 			} else {
 				normalizedGroupMW.append( { "target" : entry, "point" : "preProcess" } );
 			}
 		}
 		variables.groupMiddlewareStack.append( normalizedGroupMW );
-		// Execute the body
-		arguments.body( arguments.options );
 
-		// Pivot out of the group and do cleanup
-		variables.groupMiddlewareStack.deleteAt( variables.groupMiddlewareStack.len() );
-		variables.onGroup     = false;
-		variables.withClosure = {};
+		try {
+			// Execute the body
+			arguments.body( arguments.options );
+		} finally {
+			// Pivot out of the group and do cleanup - always, even if the body threw, so a failed
+			// registration can't leak this group's middleware/options into whatever registers next.
+			variables.groupMiddlewareStack.deleteAt( variables.groupMiddlewareStack.len() );
+			variables.onGroup     = false;
+			variables.withClosure = {};
+		}
 
 		return this;
 	}
diff --git a/tests/specs/web/routing/RouterTest.cfc b/tests/specs/web/routing/RouterTest.cfc
index 33d869ed3..51d77be4f 100644
--- a/tests/specs/web/routing/RouterTest.cfc
+++ b/tests/specs/web/routing/RouterTest.cfc
@@ -447,6 +447,53 @@ component extends="coldbox.system.testing.BaseModelTest" {
 						expect( middleware[ 2 ].target ).toBe( "RequireAdmin" );
 					} );
 				} );
+
+				given( "a group middleware option that is a single target, not wrapped in an array", function(){
+					then( "it is normalized to a one-entry list rather than iterated as a collection", function(){
+						router.group( { pattern : "/api", middleware : "RequireApiKey" }, function( options ){
+							router.route( "/users" ).toHandler( "users" );
+						} );
+
+						var middleware = router.getRoutes()[ 1 ].middleware;
+						expect( middleware ).toHaveLength( 1 );
+						expect( middleware[ 1 ].target ).toBe( "RequireApiKey" );
+						expect( middleware[ 1 ].point ).toBe( "preProcess" );
+					} );
+				} );
+
+				given( "a group middleware entry given as a struct with no point key", function(){
+					then( "point defaults to preProcess instead of throwing later", function(){
+						router.group(
+							{
+								pattern    : "/api",
+								middleware : [ { target : "RequireApiKey" } ]
+							},
+							function( options ){
+								router.route( "/users" ).toHandler( "users" );
+							}
+						);
+
+						var middleware = router.getRoutes()[ 1 ].middleware;
+						expect( middleware[ 1 ].target ).toBe( "RequireApiKey" );
+						expect( middleware[ 1 ].point ).toBe( "preProcess" );
+					} );
+				} );
+
+				given( "a group body that throws", function(){
+					then( "group state is still cleaned up so it does not leak into later routes", function(){
+						expect( function(){
+							router.group( { pattern : "/api", middleware : [ "RequireApiKey" ] }, function( options ){
+								throw( message = "boom", type = "TestBoom" );
+							} );
+						} ).toThrow( type = "TestBoom" );
+
+						router.route( "/public" ).toHandler( "public" );
+
+						var routes = router.getRoutes();
+						expect( routes[ routes.len() ].middleware ).toBeArray().toBeEmpty();
+						expect( routes[ routes.len() ].pattern ).toBe( "public/" );
+					} );
+				} );
 			} );
 
 			story( "Router will throw exception if a non-closure or string is passed to the body of a toResponse()", function(){

From 1906e144b4333af9674d74cf28da50495ac36bbc Mon Sep 17 00:00:00 2001
From: Claude 
Date: Sun, 16 Aug 2026 21:35:18 +0000
Subject: [PATCH 6/7] feat: named middleware groups and withoutMiddleware()
 exclusion

Route-scoped middleware currently only inherits through literal group()
nesting, with no way to share a bundle across unrelated routes or opt a
single route out of an inherited one. Adds two Laravel-inspired pieces on
top of the existing flat preProcess/postProcess dispatch:

- middlewareGroup(name, [...]) registers a named, reusable bundle,
  referenced by name from either .middleware() or group({ middleware }).
  Groups are flat - a member can't itself be another group's name - so
  there's no cycle risk.
- withoutMiddleware(target) excludes middleware a route would otherwise
  inherit, matched by WireBox ID or by the middlewareGroup() name an
  entry was expanded from (dropping the whole bundle), or "*" for
  everything.

normalizeMiddlewareEntries() is the shared expansion point used by
.middleware(), group()'s middleware option, and middlewareGroup() itself,
tagging group-expanded entries with their source group name so
withoutMiddleware() can match on it.

Co-Authored-By: Claude Sonnet 5 
Claude-Session: https://claude.ai/code/session_016kCmPkBvNZZhU6iuDcG4NR
---
 system/web/routing/Router.cfc          | 171 ++++++++++++++++++++++---
 tests/specs/web/routing/RouterTest.cfc | 115 +++++++++++++++++
 2 files changed, 265 insertions(+), 21 deletions(-)

diff --git a/system/web/routing/Router.cfc b/system/web/routing/Router.cfc
index 0df143bd9..a991b40da 100644
--- a/system/web/routing/Router.cfc
+++ b/system/web/routing/Router.cfc
@@ -147,6 +147,8 @@ component
 
 		// Stack of group-level middleware arrays, outermost first, so nested groups accumulate in order
 		variables.groupMiddlewareStack = [];
+		// Named, reusable middleware bundles registered via middlewareGroup(), keyed by name
+		variables.middlewareGroups     = {};
 
 		/************************************** CONSTANTS *********************************************/
 
@@ -526,23 +528,10 @@ component
 		// default/prefix merge, so they're inherited via their own stack instead. Pushed even when
 		// empty so the stack depth always matches the current group nesting depth. Entries are
 		// normalized to the same { target, point } shape middleware() produces - options.middleware
-		// may be a single target (not wrapped in an array) or a struct missing its own `point`.
+		// may be a single target (not wrapped in an array), a struct missing its own `point`, or the
+		// name of a middlewareGroup() bundle, which normalizeMiddlewareEntries() expands in place.
 		var groupMiddleware = structKeyExists( arguments.options, "middleware" ) ? arguments.options.middleware : [];
-		if ( !isArray( groupMiddleware ) ) {
-			groupMiddleware = [ groupMiddleware ];
-		}
-		var normalizedGroupMW = [];
-		for ( var entry in groupMiddleware ) {
-			if ( isStruct( entry ) && entry.keyExists( "target" ) ) {
-				normalizedGroupMW.append( {
-					"target" : entry.target,
-					"point"  : entry.keyExists( "point" ) ? entry.point : "preProcess"
-				} );
-			} else {
-				normalizedGroupMW.append( { "target" : entry, "point" : "preProcess" } );
-			}
-		}
-		variables.groupMiddlewareStack.append( normalizedGroupMW );
+		variables.groupMiddlewareStack.append( normalizeMiddlewareEntries( groupMiddleware ) );
 
 		try {
 			// Execute the body
@@ -827,7 +816,8 @@ component
 		any aiRunnable                = "",
 		boolean mcp                   = "false",
 		string mcpServer              = "",
-		array middleware              = []
+		array middleware              = [],
+		array withoutMiddleware       = []
 	){
 		// The route construct we will save
 		var thisRoute = {};
@@ -858,6 +848,30 @@ component
 			thisRoute.middleware = inheritedMiddleware;
 		}
 
+		// Strip any middleware this route opted out of via withoutMiddleware() - matched by target
+		// name (a WireBox ID) or by the middlewareGroup() name an entry was expanded from. "*"
+		// strips everything, inherited or this route's own. Closures/objects have no name to match,
+		// so they can only be excluded by not attaching them in the first place.
+		if ( thisRoute.withoutMiddleware.len() ) {
+			if ( thisRoute.withoutMiddleware.findNoCase( "*" ) ) {
+				thisRoute.middleware = [];
+			} else {
+				var filteredMiddleware = [];
+				for ( var mwEntry in thisRoute.middleware ) {
+					var excludedByTarget = isSimpleValue( mwEntry.target ) && thisRoute.withoutMiddleware.findNoCase(
+						mwEntry.target
+					);
+					var excludedByGroup = mwEntry.keyExists( "group" ) && thisRoute.withoutMiddleware.findNoCase(
+						mwEntry.group
+					);
+					if ( !excludedByTarget && !excludedByGroup ) {
+						filteredMiddleware.append( mwEntry );
+					}
+				}
+				thisRoute.middleware = filteredMiddleware;
+			}
+		}
+
 		// Cleanup Route: Add trailing / to make it easier to parse
 		if ( right( thisRoute.pattern, 1 ) IS NOT "/" ) {
 			thisRoute.pattern = thisRoute.pattern & "/";
@@ -1240,6 +1254,7 @@ component
 			"view"                  : "", // The view to proxy to
 			"viewModule"            : "", // If the view comes from a module
 			"viewNoLayout"          : false, // If we use a layout or not
+			"withoutMiddleware"     : [], // Middleware target/group names excluded from this route
 			// AI Routing
 			"ai"                    : false, // Flag indicating this is an AI runnable route
 			"aiRunnable"            : "", // The AI runnable WireBox ID or instance
@@ -1439,6 +1454,9 @@ component
 	 * call `event.relocate()`, `event.renderData().noExecution()`, `event.etag()`, etc, exactly as you
 	 * would from any other preProcess/postProcess interceptor.
 	 *
+	 * A target may also be the name of a bundle registered via `middlewareGroup()` - it expands to
+	 * that bundle's own targets in place, at this call's point unless a member declares its own.
+	 *
 	 * 
 	 * // inline closure
 	 * route( "/admin/:action" ).middleware( function( event, rc, prc ){
@@ -1453,9 +1471,12 @@ component
 	 *
 	 * // multiple targets in one call, all on the same point
 	 * route( "/api/orders" ).middleware( [ "RateLimiter", "RequireApiKey" ] ).toHandler( "orders" );
+	 *
+	 * // a name registered via middlewareGroup() - expands to that bundle's targets
+	 * route( "/api/orders" ).middleware( "api" ).toHandler( "orders" );
 	 * 
* - * @target A closure/lambda, a WireBox ID, an object instance, or an array of any mix of those. + * @target A closure/lambda, a WireBox ID, an object instance, a `middlewareGroup()` name, or an array of any mix of those. * @point The interception point to run this middleware at. Defaults to `preProcess`. */ function middleware( required any target, string point = "preProcess" ){ @@ -1464,12 +1485,120 @@ component processWith( arguments ); } + variables.thisRoute.middleware.append( + normalizeMiddlewareEntries( arguments.target, arguments.point ), + true + ); + + return this; + } + + /** + * Register a named, reusable bundle of middleware that can be referenced by name from + * `.middleware()` or a `group( { middleware : [ ... ] } )` call, instead of repeating the same + * target list at every call site - the same role Laravel's `$middlewareGroups` plays. + * + * Groups are flat: an entry may not itself be the name of another group - a bundle is always a + * concrete list of closures/WireBox IDs/objects, never a pointer to another bundle. + * + *
+	 * middlewareGroup( "api", [ "RequireApiKey", "RateLimiter" ] );
+	 *
+	 * route( "/orders" ).middleware( "api" ).toHandler( "orders" );
+	 *
+	 * group( { pattern : "/api", middleware : [ "api" ] }, function(){
+	 *     route( "/users" ).toHandler( "users" );
+	 * } );
+	 * 
+ * + * @name The group name, referenced later as a middleware target. + * @middleware The middleware targets in this group - the same values `.middleware()` accepts. + * @point The interception point for any member that doesn't declare its own via `{ target, point }`. + */ + function middlewareGroup( + required string name, + required any middleware, + string point = "preProcess" + ){ + variables.middlewareGroups[ arguments.name ] = normalizeMiddlewareEntries( + arguments.middleware, + arguments.point + ); + return this; + } + + /** + * Exclude middleware this route would otherwise inherit - most commonly from an enclosing + * `group()` - from running for this specific route. Mirrors Laravel's `Route::withoutMiddleware()`. + * + * Matches by the same name used to attach the middleware: a WireBox ID, or the name of a + * `middlewareGroup()` - excluding a group name drops every member it expanded to, not just a + * same-named single target. Pass `"*"` to strip all middleware, inherited or this route's own. + * + * Closures and object instances have no name to match, so they can only be kept off a route by + * not attaching them in the first place - the same limitation Laravel has for anonymous middleware. + * + *
+	 * middlewareGroup( "api", [ "RequireApiKey", "RateLimiter" ] );
+	 *
+	 * group( { pattern : "/api", middleware : [ "api" ] }, function(){
+	 *     route( "/users" ).toHandler( "users" );                              // runs "api"
+	 *     route( "/health" ).withoutMiddleware( "api" ).toHandler( "health" ); // opts out
+	 * } );
+	 * 
+ * + * @target A middleware target name, a `middlewareGroup()` name, `"*"` for all, or an array of any mix. + */ + function withoutMiddleware( required any target ){ var targets = isArray( arguments.target ) ? arguments.target : [ arguments.target ]; - for ( var thisTarget in targets ) { - variables.thisRoute.middleware.append( { "target" : thisTarget, "point" : arguments.point } ); + variables.thisRoute.withoutMiddleware.append( targets, true ); + return this; + } + + /** + * Normalize a mixed set of middleware targets - closures, WireBox IDs, object instances, + * `{ target, point }` structs, or the name of a previously registered `middlewareGroup()` - into + * the canonical `{ target, point, group }` entry shape `addRoute()` and `runRouteMiddleware()` + * expect. A name that resolves to a registered group expands to that group's own entries, each + * tagged with the group name it came from so `withoutMiddleware()` can exclude the whole bundle + * later without knowing its individual members. + * + * Groups are flat - a group's own members are never expanded again here - so there's no risk of + * a group indirectly referencing itself. + * + * @entries A single target, or an array of any mix of the above. + * @defaultPoint The interception point to use for any entry that doesn't declare its own. + */ + private array function normalizeMiddlewareEntries( required any entries, string defaultPoint = "preProcess" ){ + var rawEntries = isArray( arguments.entries ) ? arguments.entries : [ arguments.entries ]; + var normalized = []; + + for ( var entry in rawEntries ) { + var target = entry; + var point = arguments.defaultPoint; + + if ( isStruct( entry ) && entry.keyExists( "target" ) ) { + target = entry.target; + point = entry.keyExists( "point" ) ? entry.point : arguments.defaultPoint; + } + + // A name that matches a registered middleware group expands to that group's members, + // each tagged with the group name so withoutMiddleware() can exclude it as a whole. + if ( isSimpleValue( target ) && variables.middlewareGroups.keyExists( target ) ) { + for ( var groupEntry in variables.middlewareGroups[ target ] ) { + normalized.append( { + "target" : groupEntry.target, + "point" : groupEntry.point, + "group" : target + } ); + } + continue; + } + + normalized.append( { "target" : target, "point" : point } ); } - return this; + return normalized; } /** diff --git a/tests/specs/web/routing/RouterTest.cfc b/tests/specs/web/routing/RouterTest.cfc index 51d77be4f..a1e1de6e9 100644 --- a/tests/specs/web/routing/RouterTest.cfc +++ b/tests/specs/web/routing/RouterTest.cfc @@ -494,6 +494,121 @@ component extends="coldbox.system.testing.BaseModelTest" { expect( routes[ routes.len() ].pattern ).toBe( "public/" ); } ); } ); + + given( "a middlewareGroup() referenced from .middleware()", function(){ + then( "it expands to the group's members, tagged with the group name", function(){ + router.middlewareGroup( "api", [ "RequireApiKey", "RateLimiter" ] ); + router + .route( "/orders" ) + .middleware( "api" ) + .toHandler( "orders" ); + + var middleware = router.getRoutes()[ 1 ].middleware; + expect( middleware ).toHaveLength( 2 ); + expect( middleware[ 1 ].target ).toBe( "RequireApiKey" ); + expect( middleware[ 1 ].point ).toBe( "preProcess" ); + expect( middleware[ 1 ].group ).toBe( "api" ); + expect( middleware[ 2 ].target ).toBe( "RateLimiter" ); + expect( middleware[ 2 ].group ).toBe( "api" ); + } ); + } ); + + given( "a middlewareGroup() referenced from a group()'s middleware option", function(){ + then( "every route in the body inherits the expanded group members", function(){ + router.middlewareGroup( "api", [ "RequireApiKey", "RateLimiter" ] ); + router.group( { pattern : "/api", middleware : [ "api" ] }, function( options ){ + router.route( "/users" ).toHandler( "users" ); + } ); + + var middleware = router.getRoutes()[ 1 ].middleware; + expect( middleware ).toHaveLength( 2 ); + expect( middleware[ 1 ].target ).toBe( "RequireApiKey" ); + expect( middleware[ 2 ].target ).toBe( "RateLimiter" ); + } ); + } ); + + given( "a middlewareGroup() entry given its own point", function(){ + then( "that member keeps its own point instead of the group's default", function(){ + router.middlewareGroup( + "audited", + [ + "RequireApiKey", + { target : "AuditLog", point : "postProcess" } + ] + ); + router + .route( "/orders" ) + .middleware( "audited" ) + .toHandler( "orders" ); + + var middleware = router.getRoutes()[ 1 ].middleware; + expect( middleware[ 1 ].point ).toBe( "preProcess" ); + expect( middleware[ 2 ].target ).toBe( "AuditLog" ); + expect( middleware[ 2 ].point ).toBe( "postProcess" ); + } ); + } ); + + given( "withoutMiddleware() naming a single WireBox ID target", function(){ + then( "only that target is stripped from the merged middleware list", function(){ + router.group( + { + pattern : "/api", + middleware : [ "RequireApiKey", "RateLimiter" ] + }, + function( options ){ + router + .route( "/health" ) + .withoutMiddleware( "RateLimiter" ) + .toHandler( "health" ); + } + ); + + var middleware = router.getRoutes()[ 1 ].middleware; + expect( middleware ).toHaveLength( 1 ); + expect( middleware[ 1 ].target ).toBe( "RequireApiKey" ); + } ); + } ); + + given( "withoutMiddleware() naming a middlewareGroup()", function(){ + then( "every member that group expanded to is stripped", function(){ + router.middlewareGroup( "api", [ "RequireApiKey", "RateLimiter" ] ); + router.group( { pattern : "/api", middleware : [ "api" ] }, function( options ){ + router.route( "/users" ).toHandler( "users" ); + router + .route( "/health" ) + .withoutMiddleware( "api" ) + .toHandler( "health" ); + } ); + + var routes = router.getRoutes(); + expect( routes[ 1 ].middleware ).toHaveLength( 2 ); + expect( routes[ 2 ].middleware ).toBeArray().toBeEmpty(); + } ); + } ); + + given( "withoutMiddleware( '*' )", function(){ + then( "every middleware for that route is stripped, inherited or its own", function(){ + router.group( { pattern : "/api", middleware : [ "RequireApiKey" ] }, function( options ){ + router + .route( "/health" ) + .middleware( "RateLimiter" ) + .withoutMiddleware( "*" ) + .toHandler( "health" ); + } ); + + expect( router.getRoutes()[ 1 ].middleware ).toBeArray().toBeEmpty(); + } ); + } ); + + given( "a route with no withoutMiddleware() calls", function(){ + then( "its middleware is unaffected", function(){ + router.group( { pattern : "/api", middleware : [ "RequireApiKey" ] }, function( options ){ + router.route( "/users" ).toHandler( "users" ); + } ); + + expect( router.getRoutes()[ 1 ].middleware ).toHaveLength( 1 ); + } ); + } ); } ); story( "Router will throw exception if a non-closure or string is passed to the body of a toResponse()", function(){ From 4aef45a1ac16a0ff98bba0baf3ed98a6a34be805 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 16 Aug 2026 23:05:00 +0000 Subject: [PATCH 7/7] docs: document and pin the middlewareGroup() registration-order requirement Final review of the named-groups/withoutMiddleware() work surfaced one real gap: group expansion happens immediately at registration time, so a name referenced before its middlewareGroup() call is silently treated as a literal target instead of being expanded - no error, just quietly wrong. Documents the requirement on both middleware() and middlewareGroup(), and adds a regression test pinning the current (silent-fallback) behavior so it stays visible rather than being an undiscovered trap. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_016kCmPkBvNZZhU6iuDcG4NR --- system/web/routing/Router.cfc | 7 +++++++ tests/specs/web/routing/RouterTest.cfc | 18 ++++++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/system/web/routing/Router.cfc b/system/web/routing/Router.cfc index a991b40da..65855b7a3 100644 --- a/system/web/routing/Router.cfc +++ b/system/web/routing/Router.cfc @@ -1456,6 +1456,8 @@ component * * A target may also be the name of a bundle registered via `middlewareGroup()` - it expands to * that bundle's own targets in place, at this call's point unless a member declares its own. + * The group must already be registered when this runs, since expansion happens immediately - + * referencing one too early silently treats the name as a literal target instead of expanding it. * *
 	 * // inline closure
@@ -1501,6 +1503,11 @@ component
 	 * Groups are flat: an entry may not itself be the name of another group - a bundle is always a
 	 * concrete list of closures/WireBox IDs/objects, never a pointer to another bundle.
 	 *
+	 * Register a group before any `.middleware()`/`group()` call that references it by name - name
+	 * resolution happens immediately, at registration time, not lazily at request time. A name that
+	 * doesn't match a registered group yet is silently treated as a literal target (e.g. a WireBox ID)
+	 * instead of being expanded, so referencing a group too early fails quietly rather than throwing.
+	 *
 	 * 
 	 * middlewareGroup( "api", [ "RequireApiKey", "RateLimiter" ] );
 	 *
diff --git a/tests/specs/web/routing/RouterTest.cfc b/tests/specs/web/routing/RouterTest.cfc
index a1e1de6e9..8162aebff 100644
--- a/tests/specs/web/routing/RouterTest.cfc
+++ b/tests/specs/web/routing/RouterTest.cfc
@@ -609,6 +609,24 @@ component extends="coldbox.system.testing.BaseModelTest" {
 						expect( router.getRoutes()[ 1 ].middleware ).toHaveLength( 1 );
 					} );
 				} );
+
+				given( "a middlewareGroup() name referenced before the group is registered", function(){
+					then( "it is treated as a literal target instead of being expanded", function(){
+						// Documents a known ordering requirement: expansion happens immediately at
+						// registration time, not lazily at request time, so a group must be
+						// registered before anything references it by name.
+						router
+							.route( "/z" )
+							.middleware( "lateGroup" )
+							.toHandler( "z" );
+						router.middlewareGroup( "lateGroup", [ "RequireApiKey" ] );
+
+						var middleware = router.getRoutes()[ 1 ].middleware;
+						expect( middleware ).toHaveLength( 1 );
+						expect( middleware[ 1 ].target ).toBe( "lateGroup" );
+						expect( middleware[ 1 ] ).notToHaveKey( "group" );
+					} );
+				} );
 			} );
 
 			story( "Router will throw exception if a non-closure or string is passed to the body of a toResponse()", function(){