Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
646 changes: 266 additions & 380 deletions README.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
"build": "tsc -p tsconfig.json",
"buildtests": "tsc -p tsconfig.test.json",
"lint": "tslint --project tsconfig.base.json",
"mocha": "nyc mocha \"./test/index.test.ts\" --timeout 15000",
"mocha": "TS_NODE_TRANSPILE_ONLY=1 nyc mocha \"./test/index.test.ts\" --timeout 15000",
"coverage": "nyc report --reporter=text-lcov | coveralls",
"tsc": "tsc",
"tslint-check": "tslint-config-prettier-check ./tslint.json",
Expand Down
218 changes: 98 additions & 120 deletions src/core.ts
Original file line number Diff line number Diff line change
@@ -1,67 +1,75 @@
import Boom from '@hapi/boom';
import {
AttachDataReqsSatisfiedOptional,
DoWorkReqsSatisfiedOptional,
FinalAuthReqsSatisfied,
HasAllNotRequireds,
HasAllRequireds,
HasAllStagesNotOptionals,
HasAttachData,
HasDoWork,
HipBadInputs,
HipError,
HipForbidden,
HipInternal,
HipNotFound,
HipRedirect,
isHipError,
} from './errors';
import {
LoadResourcesDepsMet,
ExecuteDepsMet,
FinalAuthorizeDepsMet,
OptionalStagesShape,
HasRequiredStages,
HasAllStagesDefined,
HasLoadResources,
HasExecute,
HasExtractInputs,
HasFinalAuthorize,
HasInitPreContext,
HasExtractAmbient,
HasPreAuthorize,
HasSanitizeInputs,
HasSanitizeResponse,
MightHaveFinalAuthorize,
MightHavePreAuthorize,
MightHaveSanitizeResponse,
OptionallyHasAttachData,
OptionallyHasDoWork,
HasRedactResponse,
OptionallyHasFinalAuthorize,
OptionallyHasPreAuthorize,
OptionallyHasRedactResponse,
OptionallyHasLoadResources,
OptionallyHasExecute,
OptionallyHasExtractInputs,
OptionallyHasInitPreContext,
OptionallyHasExtractAmbient,
OptionallyHasSanitizeInputs,
PreAuthReqsSatisfied,
PreAuthorizeDepsMet,
PromiseOrSync,
PromiseResolveOrSync,
SanitizeResponseReqsSatisfied,
RedactResponseDepsMet,
} from './types';

export function withDefaultImplementations<
TStrategy extends HasAllNotRequireds &
HasAllRequireds &
PreAuthReqsSatisfied<TStrategy> &
AttachDataReqsSatisfiedOptional<TStrategy> &
FinalAuthReqsSatisfied<TStrategy> &
DoWorkReqsSatisfiedOptional<TStrategy> &
SanitizeResponseReqsSatisfied<TStrategy>
>(strategy: TStrategy): HasAllStagesNotOptionals {
TStrategy extends OptionalStagesShape &
HasRequiredStages &
PreAuthorizeDepsMet<TStrategy> &
LoadResourcesDepsMet<TStrategy> &
FinalAuthorizeDepsMet<TStrategy> &
ExecuteDepsMet<TStrategy> &
RedactResponseDepsMet<TStrategy>
>(strategy: TStrategy): HasAllStagesDefined {
return {
...(strategy as any),
initPreContext:
strategy.initPreContext ||
extractAmbient:
strategy.extractAmbient ||
(() => {
return {};
}),
extractInputs: strategy.extractInputs || ((raw: any) => raw),
sanitizeInputs: strategy.sanitizeInputs,
preAuthorize: strategy.preAuthorize,
attachData:
strategy.attachData ||
loadResources:
strategy.loadResources ||
(() => {
return {};
}),
finalAuthorize: strategy.finalAuthorize,
doWork: strategy.doWork,
sanitizeResponse: strategy.sanitizeResponse,
execute: strategy.execute,
redactResponse: strategy.redactResponse,
};
}

export function isHasInitPreContext<TContextIn, TContextOut>(
thing: OptionallyHasInitPreContext<TContextIn, TContextOut>
): thing is HasInitPreContext<TContextIn, TContextOut> {
return !!(thing && thing.initPreContext);
export function isHasExtractAmbient<TContextIn, TContextOut>(
thing: OptionallyHasExtractAmbient<TContextIn, TContextOut>
): thing is HasExtractAmbient<TContextIn, TContextOut> {
return !!(thing && thing.extractAmbient);
}

export function isHasExtractInputs<TContextIn, TContextOut>(
Expand All @@ -77,60 +85,53 @@ export function isHasSanitizeInputs<TContextIn, TContextOut>(
}

export function isHasPreAuthorize<TContextIn, TContextOut>(
thing: MightHavePreAuthorize<TContextIn, TContextOut>
thing: OptionallyHasPreAuthorize<TContextIn, TContextOut>
): thing is HasPreAuthorize<TContextIn, TContextOut> {
return !!(thing && thing.preAuthorize);
}

export function isHasAttachData<TContextIn, TContextOut>(
thing: OptionallyHasAttachData<TContextIn, TContextOut>
): thing is HasAttachData<TContextIn, TContextOut> {
return !!(thing && thing.attachData);
export function isHasLoadResources<TContextIn, TContextOut>(
thing: OptionallyHasLoadResources<TContextIn, TContextOut>
): thing is HasLoadResources<TContextIn, TContextOut> {
return !!(thing && thing.loadResources);
}

export function isHasFinalAuthorize<TContextIn, TContextOut>(
thing: MightHaveFinalAuthorize<TContextIn, TContextOut>
thing: OptionallyHasFinalAuthorize<TContextIn, TContextOut>
): thing is HasFinalAuthorize<TContextIn, TContextOut> {
return !!(thing && thing.finalAuthorize);
}

export function isHasDoWork<TContextIn, TContextOut>(
thing: OptionallyHasDoWork<TContextIn, TContextOut>
): thing is HasDoWork<TContextIn, TContextOut> {
return !!(thing && thing.doWork);
export function isHasExecute<TContextIn, TContextOut>(
thing: OptionallyHasExecute<TContextIn, TContextOut>
): thing is HasExecute<TContextIn, TContextOut> {
return !!(thing && thing.execute);
}

export function isHasSanitizeResponse<TContextIn, TContextOut>(
thing: MightHaveSanitizeResponse<TContextIn, TContextOut>
): thing is HasSanitizeResponse<TContextIn, TContextOut> {
return !!(thing && thing.sanitizeResponse);
export function isHasRedactResponse<TContextIn, TContextOut>(
thing: OptionallyHasRedactResponse<TContextIn, TContextOut>
): thing is HasRedactResponse<TContextIn, TContextOut> {
return !!(thing && thing.redactResponse);
}

// An authorization stage passes by returning `true` or ANY object (an object
// also contributes its keys to the shared context). Only `false` (or a falsy
// non-object) denies. An empty object `{}` PASSES — it just contributes nothing.
export function authorizationPassed<TAuthOut extends boolean | object>(
authOut: TAuthOut
) {
return (
authOut === true ||
(authOut && typeof authOut === 'object' && Object.keys(authOut).length > 0)
);
}

export class HipRedirectException {
constructor(
public readonly redirectUrl: string,
public readonly redirectCode = 302
) {}
return authOut === true || (!!authOut && typeof authOut === 'object');
}

function transformThrowSync<TOrigFn extends (param: any) => any>(
toThrow: any,
toThrow: HipError,
origFn: TOrigFn,
origParam: Parameters<TOrigFn>[0]
): ReturnType<TOrigFn> {
try {
return origFn(origParam);
} catch (exception) {
if (exception instanceof HipRedirectException || Boom.isBoom(exception)) {
if (exception instanceof HipRedirect || isHipError(exception)) {
throw exception;
} else {
throw toThrow;
Expand All @@ -141,63 +142,46 @@ function transformThrowSync<TOrigFn extends (param: any) => any>(
function transformThrowPossiblyAsync<
TOrigFn extends (param: any) => PromiseOrSync<any>
>(
toThrow: any,
toThrow: HipError,
origFn: TOrigFn,
origParam: Parameters<TOrigFn>[0]
): Promise<PromiseResolveOrSync<ReturnType<TOrigFn>>> {
return Promise.resolve(origFn(origParam)).catch(exception => {
if (exception instanceof HipRedirectException || Boom.isBoom(exception)) {
if (exception instanceof HipRedirect || isHipError(exception)) {
throw exception;
} else {
throw toThrow;
}
});
}

export type SuccessStatus<TCtx = any> = number | ((ctx: TCtx) => number);

export interface HasSuccessStatus<TCtx = any> {
successStatus?: SuccessStatus<TCtx>;
}

function resolveSuccessStatus(
successStatus: SuccessStatus | undefined,
ctx: any,
fallback: number
): number {
if (typeof successStatus === 'number') {
return successStatus;
}
if (typeof successStatus === 'function') {
return successStatus(ctx);
}
return fallback;
}

// Runs the full lifecycle and returns the safe response plus the final context
// (inputs/ambient/loaded resources/auth output/execute output/response). The
// context is transport-agnostic; adapters use it to derive their own response
// metadata (e.g. HTTP status/headers via `responseMeta`).
export async function executeHipthrustable<
TConf extends HasAllStagesNotOptionals &
PreAuthReqsSatisfied<TConf> &
AttachDataReqsSatisfiedOptional<TConf> &
FinalAuthReqsSatisfied<TConf> &
DoWorkReqsSatisfiedOptional<TConf> &
SanitizeResponseReqsSatisfied<TConf> &
HasSuccessStatus,
TConf extends HasAllStagesDefined &
PreAuthorizeDepsMet<TConf> &
LoadResourcesDepsMet<TConf> &
FinalAuthorizeDepsMet<TConf> &
ExecuteDepsMet<TConf> &
RedactResponseDepsMet<TConf>,
TRaw
>(requestHandler: TConf, raw: TRaw, defaultStatus: number = 200) {
const badDataThrow = Boom.badData('User input sanitization failure');
>(requestHandler: TConf, raw: TRaw) {
const badDataThrow = new HipBadInputs('User input sanitization failure');

const safePreContext = transformThrowSync(
const safeAmbient = transformThrowSync(
badDataThrow,
requestHandler.initPreContext,
requestHandler.extractAmbient,
raw
);

const preContextSlot = { preContext: safePreContext };
const ambientSlot = { ambient: safeAmbient };

const unsafeInputs = transformThrowSync(
badDataThrow,
requestHandler.extractInputs,
{ ...(raw as any), ...preContextSlot }
{ ...(raw as any), ...ambientSlot }
);

const safeInputs = transformThrowSync(
Expand All @@ -207,11 +191,11 @@ export async function executeHipthrustable<
);

const inputsContext = {
preContext: safePreContext,
ambient: safeAmbient,
inputs: safeInputs,
};

const forbiddenPreAuthThrow = Boom.forbidden(
const forbiddenPreAuthThrow = new HipForbidden(
'General pre-authorization lacking for this resource'
);

Expand All @@ -235,16 +219,16 @@ export async function executeHipthrustable<
...preAuthorizeContextOut,
};

const notFoundThrow = Boom.notFound('Resource not found');
const notFoundThrow = new HipNotFound('Resource not found');
const attachedDataContextOnly =
(await transformThrowPossiblyAsync(
notFoundThrow,
requestHandler.attachData,
requestHandler.loadResources,
preAuthContext
)) || {};
const attachedDataContext = { ...preAuthContext, ...attachedDataContextOnly };

const forbiddenFinalAuthThrow = Boom.forbidden(
const forbiddenFinalAuthThrow = new HipForbidden(
'General authorization lacking for this resource'
);

Expand All @@ -259,7 +243,7 @@ export async function executeHipthrustable<
);

if (!finalAuthorizePassed) {
throw forbiddenPreAuthThrow;
throw forbiddenFinalAuthThrow;
}

const finalAuthorizeContextOut =
Expand All @@ -272,12 +256,12 @@ export async function executeHipthrustable<

try {
const unsafeResponse = await Promise.resolve(
requestHandler.doWork(finalAuthContext)
requestHandler.execute(finalAuthContext)
);

const safeResponse = requestHandler.sanitizeResponse(unsafeResponse);
const safeResponse = requestHandler.redactResponse(unsafeResponse);

const successCtx =
const context =
unsafeResponse !== null && typeof unsafeResponse === 'object'
? {
...finalAuthContext,
Expand All @@ -286,31 +270,25 @@ export async function executeHipthrustable<
}
: { ...finalAuthContext, response: safeResponse };

const status = resolveSuccessStatus(
(requestHandler as HasSuccessStatus).successStatus,
successCtx,
defaultStatus
);

return { response: safeResponse, status };
return { response: safeResponse, context };
} catch (exception) {
if (exception instanceof HipRedirectException || Boom.isBoom(exception)) {
if (exception instanceof HipRedirect || isHipError(exception)) {
throw exception;
} else {
throw Boom.badImplementation('Uncaught exception');
throw new HipInternal('Uncaught exception');
}
}
}

export function assertHipthrustable(
requestHandler: HasAllRequireds & Record<string, any>
requestHandler: HasRequiredStages & Record<string, any>
) {
const requiredMethods = [
'sanitizeInputs',
'preAuthorize',
'finalAuthorize',
'doWork',
'sanitizeResponse',
'execute',
'redactResponse',
];
requiredMethods.forEach(method => {
if (
Expand Down
Loading