// The error vocabulary of service NACKs (EVENT_NACK, REP_* errors), as GSF // 8.15.x serializes it: genesis-messages' GenesisError family // (global.genesis.message.core.error.GenesisError.kt) and the ErrorCode enum // (ErrorCode.kt). Every item names its class in `@type`, carries a string // CODE, TEXT and a " " STATUS_CODE, plus the class's own // fields; the envelope carries WARNING next to ERROR. Checked against frames // recorded from a GSF 8.15.29 app (test/conformance/recordings). // // The router's MSG_NACK is a different family (no @type, an integer CODE, // DETAILS {ERROR, STATUS_CODE}) — see msgNack.ts. // HttpStatusCode.kt's status lines ("$code $description") for the statuses // ErrorCode uses. const BAD_REQUEST = '400 Bad Request'; const UNAUTHORIZED = '401 Unauthorized'; const FORBIDDEN = '403 Forbidden'; const NOT_FOUND = '404 Not Found'; const REQUEST_TIMEOUT = '408 Request Timeout'; const TOO_MANY_REQUESTS = '429 Too Many Requests'; const INTERNAL_SERVER_ERROR = '500 Internal Server Error'; const SERVICE_UNAVAILABLE = '503 Service Unavailable'; // [readable text, HTTP status line] per ErrorCode.kt entry, in the enum's order. // The readable text is what StandardError(ErrorCode.X) sends as TEXT when the // caller gives none. const ERROR_CODES = { GENERIC_ERROR: ['Something is wrong', INTERNAL_SERVER_ERROR], MISSING_FIELD: ['Field is missing', BAD_REQUEST], VALIDATION_ERROR: ['Message failed validation', BAD_REQUEST], INVALID_MESSAGE: ['Invalid message or invalid message format', BAD_REQUEST], NOT_SUPPORTED_ENUM_VALUE: ['Enum value not supported', BAD_REQUEST], NOT_AUTHORISED: ['Not authorised', FORBIDDEN], DUPLICATE_KEY: [ 'Record conflicts with unique index already stored in the database', INTERNAL_SERVER_ERROR, ], INVALID_MESSAGE_TYPE: ['Invalid message type', BAD_REQUEST], INVALID_DATASOURCE: ['Invalid DATASOURCE_NAME', BAD_REQUEST], INVALID_PARAMETER: ["MOVING_VIEW can't be true if maxView is set to 0", BAD_REQUEST], LOGIN_ERROR: ['There was a problem logging in to the SOAP WebService', UNAUTHORIZED], MULTIPLE_TABLES: ['Only one table can be referred', INTERNAL_SERVER_ERROR], MISSING_KEY: ['Key name must be provided', BAD_REQUEST], UNKNOWN_TABLE: ['No such table', BAD_REQUEST], UNKNOWN_FIELD: ['No such field on table', BAD_REQUEST], UNKNOWN: ['Unknown error', INTERNAL_SERVER_ERROR], NO_MESSAGE_TYPE: ['No message type provided', BAD_REQUEST], NO_SOURCE_REF: ['No source ref provided', BAD_REQUEST], NO_USER_NAME: ['No user name provided', BAD_REQUEST], UNAVAILABLE: ['Process is currently unavailable', SERVICE_UNAVAILABLE], UNKNOWN_MESSAGE_TYPE: ['Message type not supported', BAD_REQUEST], FEATURE_NOT_PROVIDED: ['Feature not provided', BAD_REQUEST], FEATURE_NOT_FOUND: ['Feature not found', NOT_FOUND], JSON_SCHEMA_NOT_FOUND: ['JSON schema not found', NOT_FOUND], REJECT_RULE_DOES_NOT_EXIST: ['Failed to find Dynamic Rule', NOT_FOUND], ERROR_CHECKING_EXISTING_RULE: ['Error while checking for rule', BAD_REQUEST], NO_DS_NAME: ['No data source provided', BAD_REQUEST], INVALID_DS_NAME: ['Data source does not exist', NOT_FOUND], INVALID_INDEX: ['Invalid index', BAD_REQUEST], REJECT_RULE_MISSING: ['Rule does not exist', NOT_FOUND], ERROR_MODIFYING_RULE: ['Error encountered trying to modify rule', INTERNAL_SERVER_ERROR], INVALID_CRITERIA: ['Criteria has failed validation', BAD_REQUEST], MAX_LOGON_LIMIT: ['Tried to connect too many times', TOO_MANY_REQUESTS], RECORD_NOT_FOUND: ['No record found for given key data', NOT_FOUND], SERVICE_NOT_FOUND: ['No service found', NOT_FOUND], DATABASE_FAILURE: ['There was an internal database error', INTERNAL_SERVER_ERROR], DATABASE_ERROR: ['Database error', INTERNAL_SERVER_ERROR], OPERATION_TIMEOUT: ['Server timed out', REQUEST_TIMEOUT], DEPENDENT_RECORD_FOUND: [ 'Dependency found on other records for changed value(s)', INTERNAL_SERVER_ERROR, ], REQUIRES_APPROVAL: ['Transaction requires approval', FORBIDDEN], APPROVAL_MESSAGE_MISSING: [ 'Transaction requires approval, but the approval message is missing', BAD_REQUEST, ], INTERNAL_ERROR: ['Unexpected server error', INTERNAL_SERVER_ERROR], GATEWAY_ERROR: ['Notify gateway error', BAD_REQUEST], REQUEST_FAILED: ['Problem handling request', BAD_REQUEST], UNABLE_TO_UPDATE_APPROVAL: [ 'Pending approval error, unable to update approval', INTERNAL_SERVER_ERROR, ], APPROVAL_SAME_USER_CANNOT_ACCEPT: [ 'Pending approval error, same user cannot accept', BAD_REQUEST, ], APPROVAL_RECORD_NOT_FOUND: ['Pending approval record not found', NOT_FOUND], REJECTED_BY_SERVICE: ['Pending approval rejected by service', INTERNAL_SERVER_ERROR], APPROVAL_DIFF_USER_CANNOT_CANCEL: ['Pending approval different user cannot cancel', BAD_REQUEST], APPROVAL_WRONG_STATUS_CANNOT_CANCEL: [ 'Pending approval error, wrong status cannot cancel', BAD_REQUEST, ], APPROVAL_WRONG_STATUS_CANNOT_ACCEPT: [ 'Pending approval error, wrong status cannot accept', BAD_REQUEST, ], APPROVAL_SAME_USER_CANNOT_REJECT: [ 'Pending approval error, same user cannot reject', BAD_REQUEST, ], APPROVAL_WRONG_STATUS_CANNOT_REJECT: [ 'Pending approval error, wrong status cannot reject', BAD_REQUEST, ], MISSING_HOSTNAME: ['Missing hostname', BAD_REQUEST], NUMBER_OF_RECORDS_DOES_NOT_MATCH: [ 'Event nack, Number of keys and records does not match', BAD_REQUEST, ], OPTIMISTIC_CONCURRENCY_ERROR: ['Optimistic concurrency error', BAD_REQUEST], } as const satisfies Record; export type ErrorCodeName = keyof typeof ERROR_CODES; // GSF's ErrorCode names, for CODE values: ErrorCode.DUPLICATE_KEY === // 'DUPLICATE_KEY'. CODE is a plain string on the wire, so a project's own // codes work too (their STATUS_CODE defaults to 500, as StandardError's does). export const ErrorCode = Object.fromEntries( Object.keys(ERROR_CODES).map((name) => [name, name]), ) as { readonly [Name in ErrorCodeName]: Name }; // Codes that aren't in ErrorCode.kt but do appear in platform NACKs. const EXTRA_STATUSES: Record = { INCORRECT_CREDENTIALS: UNAUTHORIZED, // platform-auth's LoginError }; function knownCode(code: string): readonly [string, string] | undefined { return Object.hasOwn(ERROR_CODES, code) ? ERROR_CODES[code as ErrorCodeName] : undefined; } // The " " STATUS_CODE for a CODE: the ErrorCode's own status, // or 500 for codes GSF doesn't know (GenesisError's default statusCode). export function statusCodeFor(code: string): string { return knownCode(code)?.[1] ?? EXTRA_STATUSES[code] ?? INTERNAL_SERVER_ERROR; } // ErrorCode's readable text, used as TEXT when an error gives none. export function defaultTextFor(code: string): string | undefined { return knownCode(code)?.[0]; } // PasswordValidationError: auth's password checks (PasswordService), which a // change of password NACKs with as they are. export type GenesisErrorType = | 'StandardError' | 'FieldError' | 'TableStandardError' | 'LoginError' | 'PasswordValidationError'; // What a handler throws (inside a NackError) or returns as a warning. Keys // are accepted in either case. Without '@type', the class is inferred: TABLE // makes a TableStandardError, FIELD (or PATH) a FieldError, anything else a // StandardError. Without CODE, a FieldError is VALIDATION_ERROR and anything // else REQUEST_FAILED — what a failed GPAL require() sends (an // IllegalArgumentException; EventHandlerUtils.handleError). GPAL's // nack("text") is INTERNAL_ERROR instead: see NackError.nack. export interface NackErrorInput { '@type'?: GenesisErrorType; CODE?: string; code?: string; TEXT?: string; text?: string; STATUS_CODE?: string; statusCode?: string; FIELD?: string; field?: string; PATH?: string | null; path?: string | null; TABLE?: string; table?: string; } // One wire error item. '@type' and STATUS_CODE are always present by default; // they are optional here only because fidelity: 'legacy' sends the pre-15.48 // {CODE, TEXT, FIELD?} items. export interface NackErrorEntry { '@type'?: GenesisErrorType; CODE: string; TEXT: string; STATUS_CODE?: string; FIELD?: string | null; PATH?: string | null; TABLE?: string | null; DETAILS?: null; } const LEGACY_DEFAULT_CODE = 'VALIDATION_ERROR'; const UNKNOWN_TEXT = 'Unknown error'; function inferType(input: NackErrorInput): GenesisErrorType { if (input['@type']) return input['@type']; if ((input.TABLE ?? input.table) !== undefined) return 'TableStandardError'; const hasField = (input.FIELD ?? input.field) !== undefined; const hasPath = (input.PATH ?? input.path) !== undefined; return hasField || hasPath ? 'FieldError' : 'StandardError'; } // Renders one input as GSF serializes the matching GenesisError subclass, // keys in the same order. Nullable class fields are sent as null, as // FieldError's PATH is on the wire. export function toNackEntry(input: NackErrorInput): NackErrorEntry { const type = inferType(input); const CODE = input.CODE ?? input.code ?? (type === 'FieldError' ? ErrorCode.VALIDATION_ERROR : ErrorCode.REQUEST_FAILED); const TEXT = input.TEXT ?? input.text ?? defaultTextFor(CODE) ?? UNKNOWN_TEXT; const entry: NackErrorEntry = { '@type': type, CODE, TEXT, STATUS_CODE: input.STATUS_CODE ?? input.statusCode ?? statusCodeFor(CODE), }; const field = input.FIELD ?? input.field; switch (type) { case 'FieldError': entry.FIELD = field ?? ''; entry.PATH = input.PATH ?? input.path ?? null; break; case 'TableStandardError': entry.TABLE = input.TABLE ?? input.table ?? null; entry.FIELD = field ?? null; break; case 'LoginError': entry.DETAILS = null; break; default: if (field !== undefined) entry.FIELD = field; } return entry; } // The {CODE, TEXT, FIELD?} item this engine sent up to 15.47, for // fidelity: 'legacy' (a helper's code still lends its readable text). export function toLegacyNackEntry(input: NackErrorInput): NackErrorEntry { const field = input.FIELD ?? input.field; const code = input.CODE ?? input.code; return { CODE: code ?? LEGACY_DEFAULT_CODE, TEXT: input.TEXT ?? input.text ?? (code && defaultTextFor(code)) ?? UNKNOWN_TEXT, ...(field ? { FIELD: field } : {}), }; } function asList(inputs: string | NackErrorInput | NackErrorInput[] | undefined): NackErrorInput[] { if (inputs === undefined) return []; if (typeof inputs === 'string') return [{ TEXT: inputs }]; return Array.isArray(inputs) ? inputs : [inputs]; } export interface NackErrorOptions { // Sent in the NACK's WARNING list. A thrown NackError always fails the // event, IGNORE_WARNINGS or not: return warnings from the handler instead // ({ warnings }) for ones that should give way to it. warnings?: NackErrorInput | NackErrorInput[]; // The NACK's MESSAGE_TYPE when it isn't EVENT_NACK: a typed handler's own // (auth's CREATE_MFA_SECRET_NACK). messageType?: string; } // Throw this from an event handler or request-reply resolver for a // protocol-correct NACK: ERROR is always an array of items, never // {error, details} (lowercase, non-array), which crashes real clients calling // .forEach on it. Pass a string for a failed GPAL require() (StandardError // REQUEST_FAILED, 400), one input, or several to report every failing field // at once, as a handler's EventNack(error = listOf(...)) does. The static // helpers build the platform's common errors, nack() included. export class NackError extends Error { readonly errors: NackErrorEntry[]; readonly warnings: NackErrorEntry[]; readonly inputs: readonly NackErrorInput[]; readonly warningInputs: readonly NackErrorInput[]; readonly messageType: string | undefined; constructor(errors: string | NackErrorInput | NackErrorInput[], options: NackErrorOptions = {}) { const inputs = asList(errors); const warningInputs = asList(options.warnings); const entries = inputs.map(toNackEntry); const warnings = warningInputs.map(toNackEntry); super([...entries, ...warnings].map((entry) => entry.TEXT).join(', ') || UNKNOWN_TEXT); this.name = 'NackError'; this.inputs = inputs; this.warningInputs = warningInputs; this.errors = entries; this.warnings = warnings; this.messageType = options.messageType; } // A FieldError: VALIDATION_ERROR (400) naming the field. PATH is the JSON // path the error was found at — null (the default) for a handler's own // check, '$.DETAILS...' for a schema failure. Forms highlight FIELD/PATH, // and the AI assistant reads a null PATH as "the app said no". static field( field: string, text: string, path: string | null = null, code: string = ErrorCode.VALIDATION_ERROR, ): NackError { return new NackError({ '@type': 'FieldError', CODE: code, TEXT: text, FIELD: field, PATH: path, }); } // A StandardError with a GSF ErrorCode (or a project's own code), TEXT // defaulting to the code's readable text: NackError.standard('NOT_AUTHORISED'). static standard(code: string, text?: string): NackError { return new NackError({ '@type': 'StandardError', CODE: code, TEXT: text }); } // The insert that hits an existing key: TableStandardError DUPLICATE_KEY // (500), FIELD ' -> ', e.g. // NackError.duplicateKey('COUNTERPARTY', 'COUNTERPARTY_BY_ID', 'CP001'). static duplicateKey(table: string, index: string, value: unknown, text?: string): NackError { return new NackError({ '@type': 'TableStandardError', CODE: ErrorCode.DUPLICATE_KEY, TEXT: text, TABLE: table, FIELD: `${index} -> ${String(value)}`, }); } // A modify or delete of a record that isn't there: TableStandardError // RECORD_NOT_FOUND (404). GSF names the unique index in FIELD for a modify // and sends '' for a delete, so `index` defaults to ''. static recordNotFound(table: string, index = '', text?: string): NackError { return new NackError({ '@type': 'TableStandardError', CODE: ErrorCode.RECORD_NOT_FOUND, TEXT: text, TABLE: table, FIELD: index, }); } // GPAL's nack("text"): GSF wraps the text in an Exception, which // EventHandlerUtils.handleError answers with StandardError INTERNAL_ERROR // (500) carrying the text. (A failed require() is `new NackError(text)`.) static nack(text: string): NackError { return new NackError({ '@type': 'StandardError', CODE: ErrorCode.INTERNAL_ERROR, TEXT: text }); } // A NACK that only warns: ERROR stays empty, WARNING carries these. Thrown, // it NACKs even under IGNORE_WARNINGS (the handler stopped there); check // ctx.ignoreWarnings first, or return { warnings } instead. static warning(warnings: string | NackErrorInput | NackErrorInput[]): NackError { return new NackError([], { warnings: asList(warnings) }); } } // The ERROR/WARNING part of a service NACK for anything a handler threw. A // NackError gives its own items; anything else is StandardError // INTERNAL_ERROR with the error's message, as EventHandlerUtils.handleError // answers an unexpected exception. Legacy mode sends the pre-15.48 items // (no @type/STATUS_CODE, VALIDATION_ERROR by default) and no WARNING: it had // no warnings, so they go in ERROR, after the errors. export function nackPayload( error: unknown, legacy: boolean, ): { ERROR: NackErrorEntry[]; WARNING?: NackErrorEntry[] } { if (error instanceof NackError) { return legacy ? { ERROR: [...error.inputs, ...error.warningInputs].map(toLegacyNackEntry) } : { ERROR: error.errors, WARNING: error.warnings }; } const message = error instanceof Error ? error.message : String(error); if (legacy) return { ERROR: [{ CODE: LEGACY_DEFAULT_CODE, TEXT: message }] }; return { ERROR: [ toNackEntry({ '@type': 'StandardError', CODE: ErrorCode.INTERNAL_ERROR, TEXT: message || undefined, }), ], WARNING: [], }; } // The payload for warnings a handler returned instead of throwing. Legacy // mode had no WARNING list, so they are sent as its {CODE, TEXT} errors. export function warningPayload( warnings: NackErrorInput[], legacy: boolean, ): { ERROR: NackErrorEntry[]; WARNING?: NackErrorEntry[] } { if (legacy) return { ERROR: warnings.map(toLegacyNackEntry) }; return { ERROR: [], WARNING: warnings.map(toNackEntry) }; }