@@ -48,6 +48,20 @@ export interface DispatcherPluginConfig {
4848 */
4949 enforceProjectMembership ?: boolean ;
5050
51+ /**
52+ * Reject anonymous requests to `auth: true` service routes (AI, etc.) with
53+ * HTTP 401, mirroring the REST API's `requireAuth` gate. Must match the
54+ * REST plugin's `api.requireAuth` so `/ai` and `/meta` stay in lockstep
55+ * with `/data` — otherwise the AI routes' declared `auth: true` contract is
56+ * never enforced and anonymous callers reach adapter/model status routes.
57+ *
58+ * Defaults to `false` (backward-compatible: previously nothing enforced
59+ * `RouteDefinition.auth` here). Hosts pass their `api.requireAuth` through —
60+ * the framework `serve` command and the cloud apps do so from the same
61+ * stack `api` config the REST plugin reads.
62+ */
63+ requireAuth ?: boolean ;
64+
5165 /**
5266 * Security response headers. When provided, every response routed
5367 * through this plugin gets the headers merged in (route-specific
@@ -99,6 +113,10 @@ interface RouteDefinition {
99113 method : 'GET' | 'POST' | 'PATCH' | 'DELETE' ;
100114 path : string ;
101115 description : string ;
116+ /** Whether this route requires authentication (default: true). */
117+ auth ?: boolean ;
118+ /** Required permissions for accessing this route. */
119+ permissions ?: string [ ] ;
102120 handler : ( req : any ) => Promise < any > ;
103121}
104122
@@ -112,6 +130,7 @@ function mountRouteOnServer(
112130 routePath : string ,
113131 securityHeaders ?: Record < string , string > ,
114132 resolveUser ?: ( headers : Record < string , any > ) => Promise < any | undefined > ,
133+ requireAuth = false ,
115134) : boolean {
116135 const handler = async ( req : any , res : any ) => {
117136 try {
@@ -124,8 +143,26 @@ function mountRouteOnServer(
124143 try {
125144 user = await resolveUser ( req . headers ?? { } ) ;
126145 } catch {
127- /* fall through anonymous — route's `auth: true` guard runs separately */
146+ /* fall through anonymous — enforced just below */
147+ }
148+ }
149+
150+ // Enforce the route's declared `auth` contract. This used to be
151+ // assumed to run "separately"/upstream, but nothing did: an
152+ // anonymous caller reached `auth: true` handlers (e.g.
153+ // `GET /ai/status`) and got adapter/model config back. Gate here
154+ // when the deployment requires auth. Off (or `auth: false`) → the
155+ // handler runs as before.
156+ if ( requireAuth && route . auth !== false && ! user ) {
157+ res . status ( 401 ) ;
158+ if ( securityHeaders ) {
159+ for ( const [ k , v ] of Object . entries ( securityHeaders ) ) res . header ( k , v ) ;
128160 }
161+ res . json ( {
162+ error : 'unauthenticated' ,
163+ message : 'Authentication is required to access this endpoint.' ,
164+ } ) ;
165+ return ;
129166 }
130167
131168 const result = await route . handler ( {
@@ -377,8 +414,15 @@ export function createDispatcherPlugin(config: DispatcherPluginConfig = {}): Plu
377414 // Tests / single-tenant deploys can opt out via the explicit flag.
378415 const enforceMembership =
379416 config . enforceProjectMembership ?? ( config . scoping ?. enableProjectScoping ?? false ) ;
417+ // Secure-by-default alignment with the REST plugin's `requireAuth`.
418+ // The cloud apps pass the whole stack `api` block as `scoping`
419+ // (which carries `requireAuth`), so honour it there too; an explicit
420+ // top-level `requireAuth` wins. Off → unchanged (routes stay open).
421+ const requireAuth =
422+ config . requireAuth ?? ( config . scoping as { requireAuth ?: boolean } | undefined ) ?. requireAuth ?? false ;
380423 const dispatcher = new HttpDispatcher ( kernel , undefined , {
381424 enforceProjectMembership : enforceMembership ,
425+ requireAuth,
382426 } ) ;
383427 const prefix = config . prefix || '/api/v1' ;
384428
@@ -1095,11 +1139,11 @@ export function createDispatcherPlugin(config: DispatcherPluginConfig = {}): Plu
10951139
10961140 let count = 0 ;
10971141 if ( enableProjectScoping && projectResolution === 'required' ) {
1098- if ( mountRouteOnServer ( route , server , toScopedPath ( routePath ) , securityHeaders , resolveRequestUser ) ) count ++ ;
1142+ if ( mountRouteOnServer ( route , server , toScopedPath ( routePath ) , securityHeaders , resolveRequestUser , requireAuth ) ) count ++ ;
10991143 } else {
1100- if ( mountRouteOnServer ( route , server , routePath , securityHeaders , resolveRequestUser ) ) count ++ ;
1144+ if ( mountRouteOnServer ( route , server , routePath , securityHeaders , resolveRequestUser , requireAuth ) ) count ++ ;
11011145 if ( enableProjectScoping ) {
1102- if ( mountRouteOnServer ( route , server , toScopedPath ( routePath ) , securityHeaders , resolveRequestUser ) ) count ++ ;
1146+ if ( mountRouteOnServer ( route , server , toScopedPath ( routePath ) , securityHeaders , resolveRequestUser , requireAuth ) ) count ++ ;
11031147 }
11041148 }
11051149 return count ;
0 commit comments