From e2c2b1ce2bb7a3bce73bd2792a5fabe49d8fd368 Mon Sep 17 00:00:00 2001 From: Daniel Salazar Date: Mon, 12 Jan 2026 17:30:16 -0800 Subject: [PATCH] feat: more extension controller decorators --- extensions/api.d.ts | 2 +- .../src/ExtensionController.ts | 33 +++++++++++++++++-- 2 files changed, 31 insertions(+), 4 deletions(-) diff --git a/extensions/api.d.ts b/extensions/api.d.ts index bbed999906..2f2fc05427 100644 --- a/extensions/api.d.ts +++ b/extensions/api.d.ts @@ -24,7 +24,7 @@ declare global { string: T, ) => T extends keyof ServiceNameMap ? ServiceNameMap[T] : unknown; }; - actor: Actor; + actor?: Actor; rawBody: Buffer; /** @deprecated use actor instead */ user: IUser; diff --git a/extensions/extensionController/src/ExtensionController.ts b/extensions/extensionController/src/ExtensionController.ts index 6ce1acc3d5..f28f711b49 100644 --- a/extensions/extensionController/src/ExtensionController.ts +++ b/extensions/extensionController/src/ExtensionController.ts @@ -13,9 +13,11 @@ import type { export const Controller = ( prefix: string, adminUsernames?: string[], + allowedAppIds?: string[], ): ClassDecorator => { return (target: Function) => { target.prototype.__controllerPrefix = prefix; + target.prototype.__allowedAppIds = allowedAppIds; target.prototype.__adminUsernames = adminUsernames ? [...adminUsernames, 'admin', 'system'] : undefined; @@ -31,14 +33,16 @@ interface RouteMeta { options?: EndpointOptions | undefined; handler: RequestHandler; adminUsernames?: string[]; + allowedAppIds?: string[]; } const createMethodDecorator = (method: HttpMethod) => { return ( path: string, - options?: EndpointOptions, + routeOptions?: EndpointOptions & { allowedAppIds?: string[] }, adminUsernames?: string[], ) => { + const { allowedAppIds, ...options } = routeOptions ?? {}; return < P extends Record = Record< string, @@ -67,6 +71,7 @@ const createMethodDecorator = (method: HttpMethod) => { adminUsernames: adminUsernames ? [...adminUsernames, 'admin', 'system'] : undefined, + allowedAppIds, handler: target, }); }); @@ -97,6 +102,9 @@ export class ExtensionController { const adminsForController = Object.getPrototypeOf(this).__adminUsernames as | string[] | undefined; + const allowedAppIdsForController = Object.getPrototypeOf(this).__allowedAppIds as + | string[] + | undefined; const routes: RouteMeta[] = Object.getPrototypeOf(this).__routes || []; for ( const route of routes ) { const fullPath = `${prefix}/${route.path}`.replace(/\/+/g, '/'); @@ -107,6 +115,14 @@ export class ExtensionController { : adminsForController ? adminsForController : undefined; + const allowedAppIds = route.allowedAppIds + ? allowedAppIdsForController + ? allowedAppIdsForController.concat(route.allowedAppIds) + : route.allowedAppIds + : allowedAppIdsForController + ? allowedAppIdsForController + : undefined; + if ( ! extension[route.method] ) { throw new Error(`Unsupported HTTP method: ${route.method}`); } else { @@ -117,12 +133,23 @@ export class ExtensionController { route.options || {}, async (req, res, next) => { try { + if ( adminsForRoute || allowedAppIds ) { + if ( ! req.actor ) { + throw new HttpError(StatusCodes.UNAUTHORIZED, 'Unauthenticated'); + } + } if ( adminsForRoute ) { - if ( ! adminsForRoute.includes(req.actor.type.user.username) ) { - throw new HttpError(StatusCodes.UNAUTHORIZED, + if ( ! adminsForRoute.includes(req.actor!.type.user.username) ) { + throw new HttpError(StatusCodes.FORBIDDEN, 'Only admins may request this resource.'); } } + if ( allowedAppIds ) { + if ( ( req.actor!.type?.app?.uid && !allowedAppIds.includes(req.actor!.type.app.uid) ) ) { + throw new HttpError(StatusCodes.FORBIDDEN, + 'This app may not request this resource.'); + } + } await route.handler.bind(this)(req, res, next); } catch ( error ) { if ( error instanceof HttpError ) {