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..65855b7a3 100644 --- a/system/web/routing/Router.cfc +++ b/system/web/routing/Router.cfc @@ -145,6 +145,11 @@ component // Routing pointer variables.thisRoute = initRouteDefinition(); + // 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 *********************************************/ // STATIC Valid Extensions @@ -510,7 +515,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,12 +524,25 @@ component // set the withClosure variables.withClosure.append( arguments.options ); - // Execute the body - arguments.body( arguments.options ); - - // Pivot out of the group and do cleanup - variables.onGroup = false; - variables.withClosure = {}; + // 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 - options.middleware + // 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 : []; + variables.groupMiddlewareStack.append( normalizeMiddlewareEntries( groupMiddleware ) ); + + 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; } @@ -797,7 +815,9 @@ component boolean ai = "false", any aiRunnable = "", boolean mcp = "false", - string mcpServer = "" + string mcpServer = "", + array middleware = [], + array withoutMiddleware = [] ){ // The route construct we will save var thisRoute = {}; @@ -816,6 +836,42 @@ 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; + } + + // 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 & "/"; @@ -1176,6 +1232,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 @@ -1197,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 @@ -1378,6 +1436,178 @@ 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. + * + * 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
+	 * 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" );
+	 *
+	 * // 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, 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" ){ + // process a with closure if not empty + if ( !variables.withClosure.isEmpty() ) { + 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. + * + * 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" ] );
+	 *
+	 * 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 ]; + 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 normalized; + } + /** * Add a header to a route *
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..8162aebff 100644
--- a/tests/specs/web/routing/RouterTest.cfc
+++ b/tests/specs/web/routing/RouterTest.cfc
@@ -356,6 +356,279 @@ 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" );
+					} );
+				} );
+
+				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/" );
+					} );
+				} );
+
+				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 );
+					} );
+				} );
+
+				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(){
 				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..eaf516794 100755
--- a/tests/specs/web/routing/RoutingServiceTest.cfc
+++ b/tests/specs/web/routing/RoutingServiceTest.cfc
@@ -282,6 +282,158 @@
 				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(){
+				var wirebox = getController().getWireBox();
+				wirebox
+					.registerNewInstance(
+						name         = "RouteMiddlewareTestTarget",
+						instancePath = "tests.resources.routing.SampleMiddleware"
+					)
+					.setScope( wirebox.getBinder().SCOPES.SINGLETON );
+
+				mockEvent.$(
+					"getCurrentRouteRecord",
+					{
+						middleware : [
+							{
+								target : "RouteMiddlewareTestTarget",
+								point  : "preProcess"
+							}
+						]
+					}
+				);
+
+				routingService.runRouteMiddleware( mockEvent, "preProcess" );
+
+				expect(
+					getController()
+						.getWireBox()
+						.getInstance( "RouteMiddlewareTestTarget" )
+						.getWasCalled()
+				).toBeTrue();
+			} );
+		} );
 	}
 
 	/**