Files
documenso/packages/lib/errors/app-error.ts
T
Lucas Smith 6a99b40cba fix: improve action auth flow for passwordless users (#3358)
Selecting password auth failed with a generic "Unauthorized" for users
who signed up via OAuth or passkey, with no way to set one.

Detect the missing password and email the existing reset link from the
signing dialog and security settings. Require a 2FA code and rate limit
update-password.
2026-09-11 14:17:34 +10:00

357 lines
13 KiB
TypeScript

import { match } from 'ts-pattern';
import { z } from 'zod';
/**
* Generic application error codes.
*/
export enum AppErrorCode {
ALREADY_EXISTS = 'ALREADY_EXISTS',
EXPIRED_CODE = 'EXPIRED_CODE',
INVALID_BODY = 'INVALID_BODY',
INVALID_REQUEST = 'INVALID_REQUEST',
RECIPIENT_EXPIRED = 'RECIPIENT_EXPIRED',
LIMIT_EXCEEDED = 'LIMIT_EXCEEDED',
NOT_FOUND = 'NOT_FOUND',
NOT_IMPLEMENTED = 'NOT_IMPLEMENTED',
NOT_SETUP = 'NOT_SETUP',
MISSING_ENV_VAR = 'MISSING_ENV_VAR',
INVALID_CAPTCHA = 'INVALID_CAPTCHA',
UNAUTHORIZED = 'UNAUTHORIZED',
FORBIDDEN = 'FORBIDDEN',
UNKNOWN_ERROR = 'UNKNOWN_ERROR',
RETRY_EXCEPTION = 'RETRY_EXCEPTION',
SCHEMA_FAILED = 'SCHEMA_FAILED',
TOO_MANY_REQUESTS = 'TOO_MANY_REQUESTS',
TWO_FACTOR_AUTH_FAILED = 'TWO_FACTOR_AUTH_FAILED',
TWO_FACTOR_SETUP_REQUIRED = 'TWO_FACTOR_SETUP_REQUIRED',
TWO_FACTOR_MISSING_SECRET = 'TWO_FACTOR_MISSING_SECRET',
TWO_FACTOR_MISSING_CREDENTIALS = 'TWO_FACTOR_MISSING_CREDENTIALS',
INCORRECT_TWO_FACTOR_CODE = 'INCORRECT_TWO_FACTOR_CODE',
NO_PASSWORD = 'NO_PASSWORD',
INCORRECT_PASSWORD = 'INCORRECT_PASSWORD',
SAME_PASSWORD = 'SAME_PASSWORD',
WEBHOOK_INVALID_REQUEST = 'WEBHOOK_INVALID_REQUEST',
ENVELOPE_DRAFT = 'ENVELOPE_DRAFT',
ENVELOPE_COMPLETED = 'ENVELOPE_COMPLETED',
ENVELOPE_REJECTED = 'ENVELOPE_REJECTED',
ENVELOPE_CANCELLED = 'ENVELOPE_CANCELLED',
ENVELOPE_LEGACY = 'ENVELOPE_LEGACY',
/**
* Authoring mutation rejected because the envelope is an AES/QES envelope
* past DRAFT — the TSP mutation lock fires at distribution to preserve
* WYSIWYS. SES envelopes never hit this code.
*/
ENVELOPE_TSP_LOCKED = 'ENVELOPE_TSP_LOCKED',
/**
* A completion request was made for a recipient that has already signed.
* Thrown for retried, stale or concurrent duplicate submissions so callers
* can resolve them idempotently instead of surfacing an error.
*/
RECIPIENT_ALREADY_SIGNED = 'RECIPIENT_ALREADY_SIGNED',
/**
* A completion request was made for a recipient that still has required
* fields which have not been inserted. Usually indicates the client's field
* state is out of sync with the server (e.g. a field insert failed to
* persist before submission).
*/
RECIPIENT_HAS_UNSIGNED_FIELDS = 'RECIPIENT_HAS_UNSIGNED_FIELDS',
/**
* A completion request was made by a recipient in a sequential signing flow
* before the preceding recipients have signed.
*/
RECIPIENT_OUT_OF_TURN = 'RECIPIENT_OUT_OF_TURN',
/**
* A signer recipient does not have a signature field assigned. Thrown when
* distributing an envelope or using a direct template where at least one
* signer has no signature field.
*/
MISSING_SIGNATURE_FIELD = 'MISSING_SIGNATURE_FIELD',
/**
* CSC (Cloud Signature Consortium) error codes. See the CSC QES V1 spec
* for the recovery taxonomy.
*/
CSC_INSTANCE_MODE_MISMATCH = 'CSC_INSTANCE_MODE_MISMATCH',
CSC_UNLICENSED = 'CSC_UNLICENSED',
CSC_PROVIDER_INFO_FAILED = 'CSC_PROVIDER_INFO_FAILED',
CSC_PROVIDER_NO_TSA = 'CSC_PROVIDER_NO_TSA',
CSC_CREDENTIAL_LIST_EMPTY = 'CSC_CREDENTIAL_LIST_EMPTY',
CSC_CERT_INVALID = 'CSC_CERT_INVALID',
CSC_ALGORITHM_REFUSED = 'CSC_ALGORITHM_REFUSED',
CSC_SAD_EXPIRED_PRE_SIGN = 'CSC_SAD_EXPIRED_PRE_SIGN',
CSC_TSP_TIMEOUT = 'CSC_TSP_TIMEOUT',
CSC_EMBED_FAILED = 'CSC_EMBED_FAILED',
CSC_BASE_DOCUMENT_MUTATED = 'CSC_BASE_DOCUMENT_MUTATED',
/**
* Generic catch-all for CSC HTTP transport failures — network error, non-2xx
* response without a more specific semantic match, malformed JSON, or
* response schema mismatch. Carries the TSP's HTTP status in `statusCode`
* and the TSP's `error` / `error_description` in the message when available.
*/
CSC_REQUEST_FAILED = 'CSC_REQUEST_FAILED',
}
export const genericErrorCodeToTrpcErrorCodeMap: Record<string, { code: string; status: number }> = {
[AppErrorCode.ALREADY_EXISTS]: { code: 'BAD_REQUEST', status: 400 },
[AppErrorCode.RECIPIENT_EXPIRED]: { code: 'BAD_REQUEST', status: 400 },
[AppErrorCode.EXPIRED_CODE]: { code: 'BAD_REQUEST', status: 400 },
[AppErrorCode.INVALID_BODY]: { code: 'BAD_REQUEST', status: 400 },
[AppErrorCode.INVALID_REQUEST]: { code: 'BAD_REQUEST', status: 400 },
[AppErrorCode.INVALID_CAPTCHA]: { code: 'BAD_REQUEST', status: 400 },
[AppErrorCode.NOT_FOUND]: { code: 'NOT_FOUND', status: 404 },
[AppErrorCode.NOT_IMPLEMENTED]: { code: 'INTERNAL_SERVER_ERROR', status: 501 },
[AppErrorCode.NOT_SETUP]: { code: 'BAD_REQUEST', status: 400 },
[AppErrorCode.MISSING_ENV_VAR]: { code: 'INTERNAL_SERVER_ERROR', status: 500 },
[AppErrorCode.UNAUTHORIZED]: { code: 'UNAUTHORIZED', status: 401 },
[AppErrorCode.FORBIDDEN]: { code: 'FORBIDDEN', status: 403 },
[AppErrorCode.UNKNOWN_ERROR]: { code: 'INTERNAL_SERVER_ERROR', status: 500 },
[AppErrorCode.RETRY_EXCEPTION]: { code: 'INTERNAL_SERVER_ERROR', status: 500 },
[AppErrorCode.SCHEMA_FAILED]: { code: 'INTERNAL_SERVER_ERROR', status: 500 },
[AppErrorCode.TOO_MANY_REQUESTS]: { code: 'TOO_MANY_REQUESTS', status: 429 },
[AppErrorCode.TWO_FACTOR_AUTH_FAILED]: { code: 'UNAUTHORIZED', status: 401 },
[AppErrorCode.ENVELOPE_DRAFT]: { code: 'BAD_REQUEST', status: 400 },
[AppErrorCode.ENVELOPE_COMPLETED]: { code: 'BAD_REQUEST', status: 400 },
[AppErrorCode.ENVELOPE_REJECTED]: { code: 'BAD_REQUEST', status: 400 },
[AppErrorCode.ENVELOPE_CANCELLED]: { code: 'BAD_REQUEST', status: 400 },
[AppErrorCode.ENVELOPE_LEGACY]: { code: 'BAD_REQUEST', status: 400 },
[AppErrorCode.ENVELOPE_TSP_LOCKED]: { code: 'BAD_REQUEST', status: 400 },
[AppErrorCode.MISSING_SIGNATURE_FIELD]: { code: 'BAD_REQUEST', status: 400 },
[AppErrorCode.RECIPIENT_HAS_UNSIGNED_FIELDS]: { code: 'BAD_REQUEST', status: 400 },
[AppErrorCode.RECIPIENT_OUT_OF_TURN]: { code: 'BAD_REQUEST', status: 400 },
[AppErrorCode.CSC_INSTANCE_MODE_MISMATCH]: { code: 'BAD_REQUEST', status: 400 },
[AppErrorCode.CSC_UNLICENSED]: { code: 'FORBIDDEN', status: 403 },
[AppErrorCode.CSC_PROVIDER_INFO_FAILED]: { code: 'INTERNAL_SERVER_ERROR', status: 500 },
[AppErrorCode.CSC_PROVIDER_NO_TSA]: { code: 'INTERNAL_SERVER_ERROR', status: 500 },
[AppErrorCode.CSC_CREDENTIAL_LIST_EMPTY]: { code: 'BAD_REQUEST', status: 400 },
[AppErrorCode.CSC_CERT_INVALID]: { code: 'BAD_REQUEST', status: 400 },
[AppErrorCode.CSC_ALGORITHM_REFUSED]: { code: 'BAD_REQUEST', status: 400 },
[AppErrorCode.CSC_SAD_EXPIRED_PRE_SIGN]: { code: 'BAD_REQUEST', status: 400 },
[AppErrorCode.CSC_TSP_TIMEOUT]: { code: 'TIMEOUT', status: 408 },
[AppErrorCode.CSC_EMBED_FAILED]: { code: 'BAD_REQUEST', status: 400 },
[AppErrorCode.CSC_BASE_DOCUMENT_MUTATED]: { code: 'INTERNAL_SERVER_ERROR', status: 500 },
// Generic transport failure — the TSP is upstream so server-side from our
// perspective; 500 keeps the caller surface conservative. The TSP's actual
// HTTP status rides along in AppError.statusCode for the few callers that
// need to discriminate (e.g. 401 → re-auth, 429 → backoff).
[AppErrorCode.CSC_REQUEST_FAILED]: { code: 'INTERNAL_SERVER_ERROR', status: 500 },
};
export const ZAppErrorJsonSchema = z.object({
code: z.string(),
message: z.string().optional(),
userMessage: z.string().optional(),
statusCode: z.number().optional(),
});
export type TAppErrorJsonSchema = z.infer<typeof ZAppErrorJsonSchema>;
type AppErrorOptions = {
/**
* An internal message for logging.
*/
message?: string;
/**
* A message which can be potientially displayed to the user.
*/
userMessage?: string;
/**
* The status code to be associated with the error.
*
* Mainly used for API -> Frontend communication and logging filtering.
*/
statusCode?: number;
/**
* Optional headers to include when this error is returned in an API response.
*/
headers?: Record<string, string>;
};
export class AppError extends Error {
/**
* The error code.
*/
code: string;
/**
* An error message which can be displayed to the user.
*/
userMessage?: string;
/**
* The status code to be associated with the error.
*/
statusCode?: number;
headers?: Record<string, string>;
name = 'AppError';
/**
* Create a new AppError.
*
* @param errorCode A string representing the error code.
* @param message An internal error message.
* @param userMessage A error message which can be displayed to the user.
*/
public constructor(errorCode: string, options?: AppErrorOptions) {
super(options?.message || errorCode);
this.code = errorCode;
this.userMessage = options?.userMessage;
this.statusCode = options?.statusCode;
this.headers = options?.headers;
}
/**
* Parse an unknown value into an AppError.
*
* @param error An unknown type.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
static parseError(error: any): AppError {
if (error instanceof AppError) {
return error;
}
// Handle TRPC errors.
if (error?.name === 'TRPCClientError') {
const parsedJsonError = AppError.parseFromJSON(error.data?.appError);
const fallbackError = new AppError(AppErrorCode.UNKNOWN_ERROR, {
message: error?.message,
});
return parsedJsonError || fallbackError;
}
// Handle completely unknown errors.
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const { code, message, userMessage, statusCode } = error as {
code: unknown;
message: unknown;
statusCode: unknown;
userMessage: unknown;
};
const validCode: string | null = typeof code === 'string' ? code : AppErrorCode.UNKNOWN_ERROR;
const validMessage: string | undefined = typeof message === 'string' ? message : undefined;
const validUserMessage: string | undefined = typeof userMessage === 'string' ? userMessage : undefined;
const validStatusCode = typeof statusCode === 'number' ? statusCode : undefined;
const options: AppErrorOptions = {
message: validMessage,
userMessage: validUserMessage,
statusCode: validStatusCode,
};
return new AppError(validCode, options);
}
/**
* Convert an AppError into a JSON object which represents the error.
*
* @param appError The AppError to convert to JSON.
* @returns A JSON object representing the AppError.
*/
static toJSON({ code, message, userMessage, statusCode }: AppError): TAppErrorJsonSchema {
const data: TAppErrorJsonSchema = {
code,
};
// Explicity only set values if it exists, since TRPC will add meta for undefined
// values which clutters up API responses.
if (message) {
data.message = message;
}
if (userMessage) {
data.userMessage = userMessage;
}
if (statusCode) {
data.statusCode = statusCode;
}
return data;
}
/**
* Convert an AppError into a JSON string containing the relevant information.
*
* @param appError The AppError to stringify.
* @returns A JSON string representing the AppError.
*/
static toJSONString(appError: AppError): string {
return JSON.stringify(AppError.toJSON(appError));
}
static parseFromJSON(value: unknown): AppError | null {
try {
const parsed = ZAppErrorJsonSchema.safeParse(value);
if (!parsed.success) {
return null;
}
const { message, userMessage, statusCode } = parsed.data;
return new AppError(parsed.data.code, {
message,
userMessage,
statusCode,
});
} catch {
return null;
}
}
static toRestAPIError(err: unknown): {
status: 400 | 401 | 403 | 404 | 500 | 501;
body: { message: string };
} {
const error = AppError.parseError(err);
const status = match(error.code)
.with(
AppErrorCode.INVALID_BODY,
AppErrorCode.INVALID_REQUEST,
AppErrorCode.ENVELOPE_DRAFT,
AppErrorCode.ENVELOPE_COMPLETED,
AppErrorCode.ENVELOPE_REJECTED,
AppErrorCode.ENVELOPE_CANCELLED,
AppErrorCode.ENVELOPE_LEGACY,
AppErrorCode.ENVELOPE_TSP_LOCKED,
AppErrorCode.MISSING_SIGNATURE_FIELD,
AppErrorCode.RECIPIENT_HAS_UNSIGNED_FIELDS,
AppErrorCode.RECIPIENT_OUT_OF_TURN,
AppErrorCode.CSC_INSTANCE_MODE_MISMATCH,
AppErrorCode.CSC_CREDENTIAL_LIST_EMPTY,
AppErrorCode.CSC_CERT_INVALID,
AppErrorCode.CSC_ALGORITHM_REFUSED,
AppErrorCode.CSC_SAD_EXPIRED_PRE_SIGN,
AppErrorCode.CSC_EMBED_FAILED,
() => 400 as const,
)
.with(AppErrorCode.UNAUTHORIZED, () => 401 as const)
.with(AppErrorCode.FORBIDDEN, AppErrorCode.CSC_UNLICENSED, () => 403 as const)
.with(AppErrorCode.NOT_FOUND, () => 404 as const)
.with(AppErrorCode.NOT_IMPLEMENTED, () => 501 as const)
.otherwise(() => 500 as const);
return {
status,
body: {
message: status !== 500 ? error.message : 'Something went wrong',
},
};
}
}