feat: more extension controller decorators (#2272)
Docker Image CI / build-and-push-image (push) Has been cancelled
Maintain Release Merge PR / update-release-pr (push) Has been cancelled
release-please / release-please (push) Has been cancelled
test / test-backend (24.x) (push) Has been cancelled
test / API tests (node env, api-test) (24.x) (push) Has been cancelled
test / puterjs (node env, vitest) (24.x) (push) Has been cancelled

This commit is contained in:
Daniel Salazar
2026-01-13 00:32:03 -08:00
committed by GitHub
parent f4c37d4cf4
commit cdb422659c
2 changed files with 31 additions and 4 deletions
+1 -1
View File
@@ -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;
@@ -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 <This>(
path: string,
options?: EndpointOptions,
routeOptions?: EndpointOptions & { allowedAppIds?: string[] },
adminUsernames?: string[],
) => {
const { allowedAppIds, ...options } = routeOptions ?? {};
return <
P extends Record<string, string | undefined> = 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 ) {