Bringing local and remote repository in line with one another and adding Github Actions to publish package

This commit is contained in:
Alan Bridgeman 2025-04-29 12:06:39 -05:00
parent 28db152b87
commit 74997cb676
8 changed files with 479 additions and 0 deletions

53
.github/workflows/publish.yml vendored Normal file
View file

@ -0,0 +1,53 @@
name: Publish to Private NPM Registry
on:
push:
branches:
- main
jobs:
publish:
runs-on: ubuntu-latest
steps:
# Checkout the repository
- name: Checkout code
uses: actions/checkout@v3
# Set up NPM Auth Token
- name: Set up NPM Auth Token
run: echo "NODE_AUTH_TOKEN=${{ secrets.NPM_TOKEN }}" >> $GITHUB_ENV
# Set up Node.js
- name: Set up Node.js version
uses: actions/setup-node@v3
with:
# Taken from [Repo README](https://github.com/actions/setup-node#readme)
#
# > Version Spec of the version to use in SemVer notation.
# > It also admits such aliases as lts/*, latest, nightly and canary builds
# > Examples: 12.x, 10.15.1, >=10.15.0, lts/Hydrogen, 16-nightly, latest, node
node-version: 'latest'
# Taken from [Repo README](https://github.com/actions/setup-node#readme)
#
# > Optional registry to set up for auth. Will set the registry in a project level .npmrc and .yarnrc file,
# > and set up auth to read in from env.NODE_AUTH_TOKEN.
# > Default: ''
registry-url: 'https://npm.pkg.bridgemanaccessible.ca'
# Taken from [Repo README](https://github.com/actions/setup-node#readme)
#
# > Optional scope for authenticating against scoped registries.
# > Will fall back to the repository owner when using the GitHub Packages registry (https://npm.pkg.github.com/).
scope: '@BridgemanAccessible'
# Install dependencies
- name: Install dependencies
run: |
yarn install
yarn build
# Publish to private NPM registry
- name: Publish package
run: npm publish

198
src/OAuthApp.ts Normal file
View file

@ -0,0 +1,198 @@
import { Application } from 'express';
import { Scopes } from '@BridgemanAccessible/ba-auth';
import Client, { OnAuthCallback } from '@BridgemanAccessible/ba-auth/client';
import { App } from './App';
import { Initializer } from './Initializer';
import { getValueFromEnvironmentVariable } from './utils/env-vars';
type OAuthAppOptions = {
/** The base URL of the app */
baseAppUrl?: URL,
/** The abbreviation of the app */
appAbbrv?: string,
/** The name of the app */
appName?: string | {
/** Localized versions of the app name */
[language: string]: string
},
/** The email addresses of the contacts for the app */
contacts?: string[],
/** The scopes an app token COULD ask for (token scopes would have to ask for this or a subset of this list) */
scopes?: Scopes[],
/** The URL of the app's logo */
logo_url?: URL,
/** The URL of the app's terms of service */
tos_url?: URL,
/** The URL of the app's privacy policy */
policy_url?: URL,
/** The type of vault to use for the keystore */
vault_type?: "azure" | "hashicorp" | "file",
/** The default method for authentication */
auth_default_method?: "query" | "form_post" | "PAR",
/** Whether to use JWT as the default authentication method */
auth_default_use_JWT?: boolean,
/** The default response mode for authentication */
auth_default_response_mode?: 'query' | 'fragment' | 'form_post'
/** The client secret for the app (if this IS set registration WON'T be done. Because re-registering isn't supported) */
client_secret?: string
};
export class OAuthApp extends App {
private onAuth: OnAuthCallback;
private saveSecret: (secret: string) => void | Promise<void>;
private baseAppUrl?: URL;
private appAbbrv?: string;
private appName?: string | { [language: string]: string };
private contacts?: string[];
private scopes?: Scopes[];
private logo_url?: URL;
private tos_url?: URL;
private policy_url?: URL;
private vault_type?: "azure" | "hashicorp" | "file";
private auth_default_method?: "query" | "form_post" | "PAR";
private auth_default_use_JWT?: boolean;
private auth_default_response_mode?: 'query' | 'fragment' | 'form_post';
private client_secret?: string;
/**
* Create a new OAuth app
*
* This is a more specialized version of the `App` class that is designed to work with the Bridgeman Accessible OAuth system.
* The only required parameters are the `onAuth` and `saveSecret` callbacks which are called when a user logs in.
* And when the client secret is generated (and is to be saved) respectively.
* The `options` parameter allows for tweaking the OAuth details for the app.
*
* Note, the following table of environment variables that are used to set the OAuth details if they aren't provided in the `options` parameter:
*
* | Environment Variable | Is Required | Description |
* | -------------------- | ------------------------------------------------------- | ---------------------------------------------------------------------------- |
* | BASE_APP_URL | Required if `options.baseAppUrl` isn't provided | The base URL of the client (app) |
* | APP_ABBRV | Required if `options.appAbbrv` isn't provided | The abbreviation of the client (app) |
* | APP_NAME | Required if `options.appName` isn't provided | The name of the client (only a single non-localized name is supported) |
* | CONTACT_EMAIL | Required if `options.contacts` isn't provided | A comma-separated list of email addresses to list as contacts for the client |
* | LOGO_URL | Optional (used if `options.logo_url` isn't specified) | The URL of the client's (app's) logo |
* | TOS_URL | Optional (used if `options.tos_url` isn't specified) | The URL of the client's terms of service |
* | POLICY_URL | Optional (used if `options.policy_url` isn't specified) | The URL of the client's privacy policy |
* | SCOPES | Required if `options.scopes` isn't provided | A comma-separated list of scopes available to the client |
* | VAULT_TYPE | Required if in production (`NODE_ENV` is `production`) | The type of vault to use for the keystore (one of azure, hashicorp, or file) |
* | APP_SECRET | Optional (Determines if app should register or not) | The client secret for the app (if this IS set registration WON'T be done) |
*
* @param onAuth The callback to call when a user logs in
* @param saveSecret The callback to call to save the secret
* @param options The options for the OAuth app
* @param options.baseAppUrl The base URL of the app
* @param options.appAbbrv The abbreviation of the app (used for user properties associated with the app)
* @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.logo_url The URL of the app's logo
* @param options.tos_url The URL of the app's terms of service
* @param options.policy_url The URL of the app's privacy policy
* @param options.vault_type The type of vault to use for the keystore
* @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
* @param options.client_secret The client secret for the app (if this IS set registration WON'T be done. Because re-registering isn't supported)
*/
constructor(onAuth: OnAuthCallback, saveSecret: (secret: string) => void | Promise<void>, options?: OAuthAppOptions) {
super();
this.onAuth = onAuth;
this.saveSecret = saveSecret;
if(typeof options !== 'undefined') {
this.baseAppUrl = options.baseAppUrl;
this.appAbbrv = options.appAbbrv;
this.appName = options.appName;
this.contacts = options.contacts;
this.scopes = options.scopes;
this.logo_url = options.logo_url;
this.tos_url = options.tos_url;
this.policy_url = options.policy_url;
this.vault_type = options.vault_type;
this.auth_default_method = options.auth_default_method;
this.auth_default_use_JWT = options.auth_default_use_JWT;
this.auth_default_response_mode = options.auth_default_response_mode;
this.client_secret = options.client_secret;
}
}
/**
* Setup the OAuth client
*
* This is done here because we need the client to be serving/listening for request in order for the auth library stuff to work properly
* (mostly, the server needs to be able to get the client's keys which the client needs to be able to serve)
*
* @param app The app to setup the OAuth client for
*/
private async setupOAuthClient(app: Application) {
// If the base URL of the app isn't provided, get it from the environment variable
let baseAppUrl = this.baseAppUrl;
if(typeof baseAppUrl === 'undefined') {
baseAppUrl = new URL(getValueFromEnvironmentVariable('BASE_APP_URL', { description: 'app\'s base URL', blank_allowed: false }));
}
// The app abbreviation (used for user properties associated with the app)
let appAbbrv = this.appAbbrv;
if(typeof appAbbrv === 'undefined') {
appAbbrv = getValueFromEnvironmentVariable('APP_ABBRV', { description: 'app abbreviation', blank_allowed: false });
}
console.log(`Attempting to create/register the app:\n\tApp Base URL: ${baseAppUrl}\n\tApp Abbreviation: ${appAbbrv}\n\tApp Name: ${typeof this.appName === 'undefined' ? 'uses APP_NAME environment variable (' + process.env.APP_NAME + ')' : this.appName}\n\tScopes: ${typeof this.scopes === 'undefined' ? 'uses SCOPES environment variable (' + process.env.SCOPES + ')' : this.scopes.join(', ')}`);
await Client.setup(app, baseAppUrl, this.onAuth, this.saveSecret, appAbbrv, this.appName, this.scopes, {
contacts: this.contacts,
logo_url: this.logo_url,
tos_url: this.tos_url,
policy_url: this.policy_url,
vault_type: this.vault_type,
auth_default_method: this.auth_default_method,
auth_default_use_JWT: this.auth_default_use_JWT,
auth_default_response_mode: this.auth_default_response_mode,
client_secret: this.client_secret
});
}
/**
* The wrapper method around the `onStart` callback that differentiates the `OAuthApp` from the `App` class.
* That is, this (the `OAuthApp` class) sets up the OAuth client before calling the client's `onStart` callback.
* As opposed to the `App` class which just calls the `onStart` callback.
*
* It's done this way so that it's almost entirely transparent the difference between apps that have OAuth and those that don't.
*
* @param app The Express app object (that is now listening)
*/
async onStart(app: Application, callback?: (app: Application) => void | Promise<void>) {
try {
// Setup the OAuth client.
// This is done here because we need the client to be serving/listening for requests for the auth library stuff to work
// (mostly because the server needs to be able to get the client's keys which it needs to be able to serve)
await this.setupOAuthClient(app);
if(typeof callback !== 'undefined') {
await callback.bind(this)(app);
}
}
catch(err) {
console.error('Error setting up the BA User Auth');
console.error('---------------------------------');
console.error(err);
}
}
/**
* The main entry point for the app
*
* This largely just wraps the `run` method from the parent class (`App`).
* However, instead of invoking the provided callback immediately when the client starts.
* This initializes the app as an OAuth app first and then calls the callback.
* This lets the app just worry about the `onAuth` callback (what happens when a user logs in) and `saveSecret` callback (that saves the generated client secret, if applicable)
* And doesn't have to worry about the OAuth details to make this work.
* Though it does provide tweaking the OAuth details via options provided to the constructor.
*/
async run<T extends Initializer>(initializer?: T, callback?: (app: Application) => void | Promise<void>) {
await super.run(initializer, async (app: Application) => this.onStart(app, callback));
}
}

View file

@ -0,0 +1,9 @@
import { Request, Response, NextFunction } from 'express';
export abstract class ErrorController {
/** A human readable string to describe the error this controller handles. */
public handlesError?: string;
abstract handle(error: unknown, req: Request, res: Response, next: NextFunction): any | Promise<any>;
}

21
src/decorators/DELETE.ts Normal file
View file

@ -0,0 +1,21 @@
import { NextFunction } from 'express';
import { NextHandleFunction } from 'connect';
export const DELETE_METADATA_KEY = 'Delete';
/**
* 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 DELETE 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 DELETE route (because a middleware is commonly required to parse the body of a DELETE request).
*
* @param path The path for the DELETE route.
* @param middleware The middleware to use with the DELETE route.
*/
export function DELETE(path: string, ...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(DELETE_METADATA_KEY, { path, middleware }, target, propertyKey);
};
}

View file

@ -0,0 +1,62 @@
import { Request, Response, NextFunction } from 'express';
import { ErrorController } from '../controllers/ErrorController';
/**
* Class decorator to create custom error handling for the app.
*
* That is, so that custom error pages can easily be created and used in the app.
*
* @example
* The following example show how to use the Controller decorator to setup a path `/path` with a GET, POST, PUT and DELETE method.
* ```ts
* import { Request, Response, NextFunction } from 'express';
*
* import { ErrorController, ErrorHandler } from '@BridgemanAccessible/ba-web-framework';
*
* @ErrorHandler(404)
* export class Custom404PageController extends ErrorController {
* handle(error: unknown, req: Request, res: Response, next: NextFunction) {
* // ...
* }
* }
* ```
*
* @param errorCode The error code to handle. This is the status code that will be returned by the server.
* @param description A human readable string to describe the error this controller handles. This is used for debugging purposes only and is not used in the app itself.
*/
export function ErrorHandler<T extends { new(...args: any): ErrorController }>(errorCode: number, description?: string) {
// Technically the function we return here with the target parameter is the actual decorator itself but we wrap it in case we ever want to add parameters to the decorator
return function(target: T){
Reflect.defineMetadata('errorHandler', target, target);
if(typeof target.prototype.handlesError === 'undefined') {
// If the handlesError property is not set, we set it to the description passed to the decorator
target.prototype.handlesError = description || `${errorCode}`;
}
// We extend the class that is decorated and override the setup method to automatically setup the routes
return class extends target {
async handle(error: unknown, req: Request, res: Response, next: NextFunction) {
// Because the headers have already been sent, we cannot send a response again.
// So we check if the headers have been sent and if so, we call the next middleware (default) in the error chain.
if(res.headersSent) {
return next(error);
}
// Create an instance of the decorated (original) class
const controller: ErrorController = new (Reflect.getMetadata('errorHandler', target))();
// We only want to call the handle method if the error code matches the one denoted.
if(res.statusCode === errorCode) {
// Call the handle method of the controller and return the result
return await controller.handle.bind(controller)(error, req, res, next);
}
else {
// If the error code does not match, we call the next middleware in the error chain
return next(error);
}
}
}
}
}

21
src/decorators/PUT.ts Normal file
View file

@ -0,0 +1,21 @@
import { NextFunction } from 'express';
import { NextHandleFunction } from 'connect';
export const PUT_METADATA_KEY = 'Put';
/**
* 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 PUT 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 PUT route (because a middleware is commonly required to parse the body of a PUT request).
*
* @param path The path for the PUT route.
* @param middleware The middleware to use with the PUT route.
*/
export function PUT(path: string, ...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(PUT_METADATA_KEY, { path, middleware }, target, propertyKey);
};
}

View file

@ -0,0 +1,49 @@
import { Request, Response, NextFunction, RequestHandler } from 'express';
export enum HealthCheckStatus {
OK = 'ok',
ERROR = 'error'
}
export class HealthCheckMiddleware {
public static readonly HEALTH_CHECK_ENDPOINT = '/.well-known/health-check';
private static status: HealthCheckStatus;
constructor(initialStatus: HealthCheckStatus = HealthCheckStatus.OK) {
HealthCheckMiddleware.status = initialStatus;
}
static setStatus(status: HealthCheckStatus) {
HealthCheckMiddleware.status = status;
}
middleware(req: Request, res: Response, next: NextFunction) {
if(req.path === HealthCheckMiddleware.HEALTH_CHECK_ENDPOINT) {
switch(HealthCheckMiddleware.status) {
case HealthCheckStatus.OK:
return res.status(200).setHeader('Content-Type', 'application/health+json').json({ status: 'ok' });
break;
case HealthCheckStatus.ERROR:
return res.status(500).setHeader('Content-Type', 'application/health+json').json({ status: 'error' });
break;
}
}
next();
}
}
/**
* Middleware function to create a health check endpoint.
*
* This attempts to comply with the [Health Check Response Format for HTTP APIs](https://datatracker.ietf.org/doc/html/draft-inadarei-api-health-check-06) proposed standard.
*
* @param initialStatus The initial status of the health check.
* @returns The middleware function.
*/
export function healthCheckMiddleware(initialStatus: HealthCheckStatus = HealthCheckStatus.OK) {
const instance = new HealthCheckMiddleware(initialStatus);
return instance.middleware.bind(instance);
}

66
src/utils/env-vars.ts Normal file
View file

@ -0,0 +1,66 @@
/**
* Strip quotes from a string (if they are present)
*
* This is useful for environment variables because otherwise there can be unexpected behaviors between quoted and unquoted values
*
* @param value The value to strip quotes from
* @returns The value without quotes
*/
function stripQuotes(value: string): string {
// Note we check BOTH the start and end of the string for quotes
// This is because we don't want to strip quotes from the middle of the string
// Or if for one reason or another there are quotes at one end but not the other
//
// For instance, `abc"value"abc` should NOT be stripped to `abcvalueabc`
// Further, `"value"abc` should NOT be stripped to `valueabc`
// Similarly, `value"abc"` should NOT be stripped to `valueabc`
// But `"value"` SHOULD BE stripped to `value`
//
// In theory, the quotes should be used to avoid issues with spaces and other special characters and the encapsulated value should be treated as a single value and quotes removed
// But, for now, we'll just strip the quotes as described above
if(value.startsWith('"') && value.endsWith('"')) {
return value.slice(1, -1);
}
return value;
}
/**
* Get the value from an environment variable in a safe way
*
* @param varname The name of the environment variable to get the value from
* @param options Options for getting the value from the environment variable. Optional.
* @param options.description A human readable description of the environment variable (used for error messages). Optional.
* @param options.default The default value to use if the environment variable is not defined. Optional.
* @param options.blank_allowed Whether a blank value is allowed. Optional. Default is true (blanks are allowed).
* @throws If the environment variable is not defined
* @returns The value of the environment variable
*/
export function getValueFromEnvironmentVariable(varname: string, options?: { description?: string, default?: string, blank_allowed?: boolean }): string {
// Verify if the environment variable is defined
if(typeof process.env[varname] === 'undefined' || process.env[varname] === null) {
if(typeof options !== 'undefined' && typeof options.default !== 'undefined') {
// Because the variable is not defined, but a default value is provided, return the default value
return options.default;
}
else {
// Because the variable is not defined AND no default value is provided, throw an error
throw new Error(`The ${typeof options !== 'undefined' && typeof options.description !== 'undefined' ? options.description : ''} (${varname} environment variable) is not defined`);
}
}
// If the value is blank and blanks are EXPLICITLY not allowed, return the default or throw an error
if(typeof options !== 'undefined' && typeof options.blank_allowed !== 'undefined' && !options.blank_allowed && process.env[varname] === '') {
if(typeof options.default !== 'undefined') {
// Because the variable is blank, but blanks aren't allowed AND a default value is provided, return the default value
return options.default;
}
else {
// Because the variable is blank, but blanks aren't allowed AND no default value is provided, throw an error
throw new Error(`The ${typeof options.description !== 'undefined' ? options.description : ''} (${varname} environment variable) cannot be blank`);
}
}
// Strip quotes from the value before returning it
return stripQuotes(process.env[varname]);
}