Updated dependency version (and made appropriate changes) + added new @PATCH decorators for HTTP PATCH routes + dded generic @AuthenticatedRoute decorator
All checks were successful
Publish to Private NPM Registry / publish (push) Successful in 32s

This commit is contained in:
Alan Bridgeman 2026-02-18 21:42:15 -06:00
parent 472e5256ec
commit 48f2e0dc02
8 changed files with 399 additions and 647 deletions

View file

@ -39,7 +39,7 @@
"create-ba-web-app": "node ./bin/create-project.js"
},
"dependencies": {
"@BridgemanAccessible/ba-auth": "^1.0.28",
"@BridgemanAccessible/ba-auth": "^1.0.31",
"@BridgemanAccessible/ba-logging": "^1.0.1",
"express": "^4.19.2",
"fs-extra": "^11.2.0",

View file

@ -10,6 +10,7 @@ import { ErrorController } from './controllers/ErrorController.js';
import { GET_METADATA_KEY, GET_FORCE_PREFIX_METADATA_KEY } from './decorators/GET.js';
import { POST_METADATA_KEY, POST_FORCE_PREFIX_METADATA_KEY } from './decorators/POST.js';
import { PUT_METADATA_KEY, PUT_FORCE_PREFIX_METADATA_KEY } from './decorators/PUT.js';
import { PATCH_METADATA_KEY, PATCH_FORCE_PREFIX_METADATA_KEY } from './decorators/PATCH.js';
import { DELETE_METADATA_KEY, DELETE_FORCE_PREFIX_METADATA_KEY } from './decorators/DELETE.js';
import { CHILD_CONTROLLER_METADATA_KEY } from './decorators/ChildController.js';
@ -237,6 +238,21 @@ export class Router {
return path;
});
// Loop over all the methods in the provided class looking for methods that use the `@PATCH` decorator
const pathsPATCH = Object.getOwnPropertyNames(controller.prototype)
// Find all methods that have a `Patch` metadata key (`@PATCH` decorator)
.filter((method) => typeof Reflect.getMetadata(PATCH_METADATA_KEY, controller.prototype, method) !== 'undefined')
.map((method) => {
// Get the method
const fn = controller.prototype[method];
// Get the metadata object (which contains a path and middleware)
const patchRoute = Reflect.getMetadata(PATCH_METADATA_KEY, controller.prototype, method);
const path = this.resolvePath(controller, patchRoute.path, Reflect.getMetadata(PATCH_FORCE_PREFIX_METADATA_KEY, controller.prototype, method), inheritedPrefix);
return path;
});
// Loop over all the methods in the provided class looking for methods that use the `@DELETE` decorator
const pathsDELETE = Object.getOwnPropertyNames(controller.prototype)
@ -257,6 +273,7 @@ export class Router {
GET: pathsGET,
POST: pathsPOST,
PUT: pathsPUT,
PATCH: pathsPATCH,
DELETE: pathsDELETE
}
}
@ -282,7 +299,7 @@ export class Router {
//console.log(`Processing ${(typeof controller).toString()} - ${typeof controller !== 'undefined' && controller !== null && typeof controller.name !== 'undefined' ? controller.name : 'unknown'} (${(typeof decoratedController).toString()} - ${typeof decoratedController !== 'undefined' && decoratedController !== null && typeof decoratedController.name !== 'undefined' ? decoratedController.name : 'unknown'})...`);
let routes: { GET: string[], POST: string[], PUT: string[], DELETE: string[] } = { GET: [], POST: [], PUT: [], DELETE: [] };
let routes: { GET: string[], POST: string[], PUT: string[], PATCH: string[], DELETE: string[] } = { GET: [], POST: [], PUT: [], PATCH: [], DELETE: [] };
try {
routes = this.getRoutesInController(controller, inheritedPrefix);
}
@ -301,6 +318,10 @@ export class Router {
routes.PUT.forEach((path) => {
addedRoutes.push(`PUT ${path} from ${(new controller()).constructor.name}`);
});
routes.PATCH.forEach((path) => {
addedRoutes.push(`PATCH ${path} from ${(new controller()).constructor.name}`);
});
routes.DELETE.forEach((path) => {
addedRoutes.push(`DELETE ${path} from ${(new controller()).constructor.name}`);

View file

@ -0,0 +1,51 @@
import type { Request, Response, NextFunction } from 'express';
/**
* Decorator that allows abstracting away the need for pass/fail checks based on the details of the request.
*
* By providing an array of custom predicate functions that take in the request and return a boolean indicating whether the request meets the necessary requirements to access the route.
* This allows for great flexibility, as you can use this decorator to check for any request-related requirements you want (ex. if the request contains a certain object like a user, if the request has a specific header, etc.) without having to repeat that logic in every route handler.
* Quick Typescript note, because the type checker doesn't deal with these meta-programming patterns very well, you will likely need to use type assertions inside the route to avoid type errors related to already knowing the assertion is satisfied.
*
* If multiple predicate functions are provided (ex. [PREDICATE_1, PREDICATE_2]), then the user must meet ANY of the provided predicates in order to access the route (i.e. the predicates are ORed together, not ANDed).
* This is because you can already achieve AND logic by chaining/having multiple `@AuthenticatedRoute` decorators on the same route handler.
* So, it makes more sense for multiple predicates within the same `@AuthenticatedRoute` decorator to be ORed together since if you wanted them to be ANDed together you could just put them in separate `@AuthenticatedRoute` decorators.
* Moreover, we don't support any kind of specialty NOT logic within the same `@AuthenticatedRoute` decorator because if you have complex logic like that, it would be better to just create a custom predicate function that implements that logic and then pass that single predicate function to the `@AuthenticatedRoute` decorator for clarity and maintainability (as opposed to trying to cram complex logic into multiple predicates within the same `@AuthenticatedRoute` decorator which could get confusing).
* Plus as a matter of theory, by allowing both OR and AND you should be able to achieve virtually any kind of logic (ex. NOT of an AND is an OR and NOT of an OR is an AND, etc.) by just structuring your predicates and `@AuthenticatedRoute` decorators in the right way.
*
* @param predicates An array of functions that takes in the request and returns a boolean indicating whether the request meets the necessary requirements to access the route.
* If multiple predicate functions are provided (ex. [PREDICATE_1, PREDICATE_2]), then the user must meet ANY of the provided predicates in order to access the route (i.e. the predicates are ORed together, not ANDed).
* @param redirect An optional string that specifies where to redirect the user if they don't meet the necessary requirements to access the route. Note, there is a special placeholder `{originalUrl}` that can be used in the redirect string which will be replaced with the original URL the user was trying to access (encoded using `encodeURIComponent`) before being redirected.
* If `redirect` is not provided, then the default behavior is to return a 403 Forbidden response if the user doesn't meet the necessary requirements to access the route.
* @returns A decorator function that can be applied to route handlers to enforce the specified user requirements.
*/
export function AuthenticatedRoute(predicates: ((req: Request) => boolean | Promise<boolean>)[], redirect?: string) {
return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
// Get the contents of the decorated function
const original = descriptor.value;
// Replace the function with the decorator wrapper
descriptor.value = async function (req: Request, res: Response, next: NextFunction) {
const predicatesResult = await Promise.all(predicates.map(async predicate => await predicate(req)));
// Check if the user meets AT LEAST ONE of the necessary requirements to access the route using the provided predicate functions.
// If they don't meet the requirements, either redirect them to the provided URL or return a 403 Forbidden response (based on the value of `redirect`)
if(!predicatesResult.includes(true)) {
if(typeof redirect === 'undefined') {
// Because the route indicated we shouldn't redirect, we just return a 403 Forbidden response instead to indicate the user doesn't have access to the route
res.status(403);
throw new Error('Unauthorized');
}
else {
// If the user didn't meet the requirements (ex. isn't logged in), redirect them to the login page
// Passing the original URL as a query parameter so we can redirect them back after they log in
res.redirect(redirect.replace('{originalUrl}', encodeURIComponent(req.originalUrl)));
return;
}
}
// Call the decorated function
await original.call(this, req, res, next);
}
}
}

View file

@ -6,6 +6,7 @@ import { CHILD_CONTROLLER_METADATA_KEY } from './ChildController.js';
import { GET_METADATA_KEY, GET_FORCE_PREFIX_METADATA_KEY } from './GET.js';
import { POST_METADATA_KEY, POST_FORCE_PREFIX_METADATA_KEY } from './POST.js';
import { PUT_METADATA_KEY, PUT_FORCE_PREFIX_METADATA_KEY } from './PUT.js';
import { PATCH_METADATA_KEY, PATCH_FORCE_PREFIX_METADATA_KEY } from './PATCH.js';
import { DELETE_METADATA_KEY, DELETE_FORCE_PREFIX_METADATA_KEY } from './DELETE.js';
/**
@ -261,6 +262,53 @@ export function Controller<T extends { new (...args: any[]): BaseController }>(b
]);
});
// Loop over all the methods in the decorated class looking for methods that use the `@PATCH` decorator
Object.getOwnPropertyNames(target.prototype)
// Find all methods that have the associated metadata key (`@PATCH` decorator)
.filter((method) => typeof Reflect.getMetadata(PATCH_METADATA_KEY, target.prototype, method) !== 'undefined')
.map((method) => {
// Get the method
const fn = target.prototype[method];
// Get the metadata object (which contains a path and middleware)
const patchRoute = Reflect.getMetadata(PATCH_METADATA_KEY, target.prototype, method);
const path = patchRoute.path;
const middleware = patchRoute.middleware;
const fullPath = Reflect.getMetadata(PATCH_FORCE_PREFIX_METADATA_KEY, target.prototype, method) ? BaseController.joinPaths(currentPrefix, path) : path;
// Bind the method to the class instance
app.patch(fullPath, ...[
...(Reflect.getMetadata('middlewares', target) as RequestHandler[]),
...middleware,
async (req: Request, res: Response, next: NextFunction) => {
//console.log('[Controller.setup.<PATCH request>] Request:', req);
//console.log('[Controller.setup.<PATCH request>] Response:', res);
//console.log('[Controller.setup.<PATCH request>] Next:', next);
try {
await Promise.all([fn.bind(controller)(req, res, next)]);
//console.log('[Controller.setup.<PATCH request>] Successfully executed PATCH method:', method);
}
catch(error) {
//console.log('[Controller.setup.<PATCH request>] Error:', error);
if(typeof next !== 'undefined') {
//console.log('[Controller.setup.<PATCH request>] Calling next with error:', error);
next(error);
return;
}
else {
//console.log('[Controller.setup.<PATCH request>] No next function defined, rejecting promise with error:', error);
// Because next is undefined and we still want to have Express handle the error we reject the promise
return Promise.reject(error);
}
}
}
]);
});
// Loop over all the methods in the decorated class looking for methods that use the `@DELETE` decorator
Object.getOwnPropertyNames(target.prototype)
// Find all methods that have the associated metadata key (`@DELETE` decorator)

24
src/decorators/PATCH.ts Normal file
View file

@ -0,0 +1,24 @@
import type { NextFunction } from 'express';
import type { NextHandleFunction } from 'connect';
export const PATCH_METADATA_KEY = 'Patch';
export const PATCH_FORCE_PREFIX_METADATA_KEY = 'PatchForcePrefix';
/**
* Method decorator intended to be used on methods of a class decorated with the `@Controller` decorator.
*
* This in conjunction with the `@Controller` decorator will automatically setup a PATCH route that executes the decorated method.
* The specific path for the route is defined by the path parameter.
* Controller authors can also specify middleware to be used with the PATCH route (because a middleware is commonly required to parse the body of a PATCH request).
*
* @param path The path for the PATCH route.
* @param forcePrefix Whether to force the appropriate prefix to be applied to the route's path. Default is true.
* @param middleware The middleware to use with the PATCH route.
*/
export function PATCH(path: string = '', forcePrefix: boolean = true, ...middleware: (NextHandleFunction | NextFunction)[]) {
return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
// Define a metadata key with the path and middleware as the value on the target's propertyKey
Reflect.defineMetadata(PATCH_METADATA_KEY, { path, middleware }, target, propertyKey);
Reflect.defineMetadata(PATCH_FORCE_PREFIX_METADATA_KEY, forcePrefix, target, propertyKey);
};
}

View file

@ -1,19 +1,33 @@
// Controller decorators
import { Controller } from './Controller.js';
import { ChildController } from './ChildController.js';
import { Page } from './Page.js';
// Error handling decorator
import { ErrorHandler } from './ErrorHandler.js';
// HTTP method decorators
import { GET } from './GET.js';
import { POST } from './POST.js';
import { PUT } from './PUT.js';
import { PATCH } from './PATCH.js';
import { DELETE } from './DELETE.js';
import { ErrorHandler } from './ErrorHandler.js';
// Other decorators (convenience etc...)
import { Page } from './Page.js';
import { AuthenticatedRoute } from './AuthenticatedRoute.js';
export {
Controller,
ChildController,
Page,
ErrorHandler,
GET,
POST,
PUT,
PATCH,
DELETE,
ErrorHandler
Page,
AuthenticatedRoute
};

View file

@ -11,9 +11,6 @@ import { Initializer } from '../Initializer.js';
import { getValueFromEnvironmentVariable } from '../utils/env-vars.js';
import type { BridgemanAccessibleAppClaims } from './types/BridgemanAccessibleAppClaims.js';
import type { AppSubscriptionTier } from './types/AppSubscriptionTier.js';
import type { Addon } from './types/addons/Addon.js';
import type { Webhook } from './types/Webhook.js';
interface BasicOAuthAppOptions {
// ------------------
@ -46,8 +43,8 @@ interface BasicOAuthAppOptions {
// Optional Mechanical Details (how the OAuth app works)
// -----------------------------------------------------
/** The type of vault to use for the keystore */
vault_type?: "azure" | "hashicorp" | "file",
/** The type of keystore to use for managing keys */
keystoreType?: string,
/** The default method for authentication */
auth_default_method?: "query" | "form_post" | "PAR",
@ -140,7 +137,7 @@ export class OAuthApp<TCustomClaims extends BridgemanAccessibleAppClaims> extend
* @param options.appName The name of the app
* @param options.contacts The email addresses of the contacts for the app
* @param options.scopes The scopes an app token COULD ask for (token scopes would have to ask for this or a subset of this list)
* @param options.vault_type The type of vault to use for the keystore
* @param options.keystoreType The type of keystore to use for managing keys
* @param options.auth_default_method The default method for authentication
* @param options.auth_default_use_JWT Whether to use JWT as the default authentication method
* @param options.auth_default_response_mode The default response mode for authentication
@ -216,7 +213,7 @@ export class OAuthApp<TCustomClaims extends BridgemanAccessibleAppClaims> extend
logo_url: this.options.logo_url,
tos_url: this.options.tos_url,
policy_url: this.options.policy_url,
vault_type: this.options.vault_type,
keystoreType: this.options.keystoreType,
auth_default_method: this.options.auth_default_method,
auth_default_use_JWT: this.options.auth_default_use_JWT,
auth_default_response_mode: this.options.auth_default_response_mode,

865
yarn.lock

File diff suppressed because it is too large Load diff