diff --git a/src/api/http.ts b/src/api/http.ts index 25379e12..961c9157 100644 --- a/src/api/http.ts +++ b/src/api/http.ts @@ -50,10 +50,15 @@ import { setEntityEndpointsUpdater, setEventEndpointsUpdater, setRelationshipEndpointsUpdater, - UnauthorisedError, } from '../runtime/defs.js'; import { evaluate } from '../runtime/interpreter.js'; import { Config } from '../runtime/state.js'; +import { + getErrorCode, + httpStatusFromError, + logEntityRouteError, + resolveEntityErrorMessage, +} from '../runtime/errors/http-error.js'; import { findFileByFilename, createFileRecord, @@ -434,22 +439,34 @@ function ok(res: Response) { } function statusFromErrorType(err: any): number { - if (err instanceof UnauthorisedError) { - return 401; - } else if (err instanceof BadRequestError) { - return 400; - } else { - return 500; - } + return httpStatusFromError(err); } -function internalError(res: Response) { +function entityRouteError(res: Response, moduleName: string, entryName: string) { return (reason: any) => { - logger.error(reason); - res.status(statusFromErrorType(reason)).send(reason.message); + const code = getErrorCode(reason); + const defaultMsg = reason instanceof Error ? reason.message : String(reason ?? 'Unknown error'); + const message = resolveEntityErrorMessage(moduleName, entryName, code, defaultMsg); + logEntityRouteError(reason, code); + res.status(httpStatusFromError(reason)).json({ code, message }); }; } +function sendAuthRequired(res: Response, moduleName: string, entryName: string) { + const code = 'AL_HTTP_AUTH_REQUIRED'; + const defaultMsg = 'Authorization required'; + const message = resolveEntityErrorMessage(moduleName, entryName, code, defaultMsg); + res.status(401).json({ code, message }); +} + +function sendEntityCatch(res: Response, moduleName: string, entryName: string, err: any) { + const code = getErrorCode(err); + const defaultMsg = err instanceof Error ? err.message : String(err); + const message = resolveEntityErrorMessage(moduleName, entryName, code, defaultMsg); + logEntityRouteError(err, code); + res.status(httpStatusFromError(err)).json({ code, message }); +} + function formatAttrValue(v: any, n: string): string { let av = isString(v) ? `"${v}"` : v; if (av instanceof Object) { @@ -565,7 +582,7 @@ async function handleEventPost( try { const sessionInfo = await verifyAuth(moduleName, eventName, req.headers.authorization); if (isNoSession(sessionInfo)) { - res.status(401).send('Authorization required'); + sendAuthRequired(res, moduleName, eventName); return; } const inst: Instance = makeInstance( @@ -573,10 +590,11 @@ async function handleEventPost( eventName, objectAsInstanceAttributes(req.body) ).setAuthContext(sessionInfo); - evaluate(inst).then(ok(res)).catch(internalError(res)); + evaluate(inst) + .then(ok(res)) + .catch(entityRouteError(res, moduleName, eventName)); } catch (err: any) { - logger.error(err); - res.status(500).send(err.toString()); + sendEntityCatch(res, moduleName, eventName, err); } } @@ -586,10 +604,12 @@ async function handleEntityPost( req: Request, res: Response ): Promise { + const routeModule = moduleName; + const routeEntry = entityName; try { const sessionInfo = await verifyAuth(moduleName, entityName, req.headers.authorization); if (isNoSession(sessionInfo)) { - res.status(401).send('Authorization required'); + sendAuthRequired(res, routeModule, routeEntry); return; } const entityFqName = makeFqName(moduleName, entityName); @@ -601,10 +621,11 @@ async function handleEntityPost( objectAsInstanceAttributes(req.body), entityFqName ); - parseAndEvaluateStatement(pattern, sessionInfo.userId).then(ok(res)).catch(internalError(res)); + parseAndEvaluateStatement(pattern, sessionInfo.userId) + .then(ok(res)) + .catch(entityRouteError(res, routeModule, routeEntry)); } catch (err: any) { - logger.error(err); - res.status(500).send(err.toString()); + sendEntityCatch(res, routeModule, routeEntry, err); } } @@ -614,11 +635,13 @@ async function handleEntityGet( req: Request, res: Response ): Promise { + const routeModule = moduleName; + const routeEntry = entityName; try { const path = pathFromRequest(moduleName, entityName, req); const sessionInfo = await verifyAuth(moduleName, entityName, req.headers.authorization); if (isNoSession(sessionInfo)) { - res.status(401).send('Authorization required'); + sendAuthRequired(res, routeModule, routeEntry); return; } let pattern = ''; @@ -627,10 +650,11 @@ async function handleEntityGet( } else { pattern = queryPatternFromPath(path, req); } - parseAndEvaluateStatement(pattern, sessionInfo.userId).then(ok(res)).catch(internalError(res)); + parseAndEvaluateStatement(pattern, sessionInfo.userId) + .then(ok(res)) + .catch(entityRouteError(res, routeModule, routeEntry)); } catch (err: any) { - logger.error(err); - res.status(500).send(err.toString()); + sendEntityCatch(res, routeModule, routeEntry, err); } } @@ -740,11 +764,13 @@ async function handleEntityPut( req: Request, res: Response ): Promise { + const routeModule = moduleName; + const routeEntry = entityName; try { const path = pathFromRequest(moduleName, entityName, req); const sessionInfo = await verifyAuth(moduleName, entityName, req.headers.authorization); if (isNoSession(sessionInfo)) { - res.status(401).send('Authorization required'); + sendAuthRequired(res, routeModule, routeEntry); return; } const attrs = objectAsInstanceAttributes(req.body); @@ -753,10 +779,11 @@ async function handleEntityPut( moduleName = r[0]; entityName = r[1]; const pattern = patternFromAttributes(moduleName, entityName, attrs); - parseAndEvaluateStatement(pattern, sessionInfo.userId).then(ok(res)).catch(internalError(res)); + parseAndEvaluateStatement(pattern, sessionInfo.userId) + .then(ok(res)) + .catch(entityRouteError(res, routeModule, routeEntry)); } catch (err: any) { - logger.error(err); - res.status(500).send(err.toString()); + sendEntityCatch(res, routeModule, routeEntry, err); } } @@ -766,19 +793,22 @@ async function handleEntityDelete( req: Request, res: Response ): Promise { + const routeModule = moduleName; + const routeEntry = entityName; try { const path = pathFromRequest(moduleName, entityName, req); const sessionInfo = await verifyAuth(moduleName, entityName, req.headers.authorization); if (isNoSession(sessionInfo)) { - res.status(401).send('Authorization required'); + sendAuthRequired(res, routeModule, routeEntry); return; } const cmd = req.query.purge == 'true' ? 'purge' : 'delete'; const pattern = `${cmd} ${queryPatternFromPath(path, req)}`; - parseAndEvaluateStatement(pattern, sessionInfo.userId).then(ok(res)).catch(internalError(res)); + parseAndEvaluateStatement(pattern, sessionInfo.userId) + .then(ok(res)) + .catch(entityRouteError(res, routeModule, routeEntry)); } catch (err: any) { - logger.error(err); - res.status(500).send(err.toString()); + sendEntityCatch(res, routeModule, routeEntry, err); } } @@ -792,7 +822,7 @@ async function handleBetweenRelationshipLinking( try { const sessionInfo = await verifyAuth(moduleName, betweenRelName, req.headers.authorization); if (isNoSession(sessionInfo)) { - res.status(401).send('Authorization required'); + sendAuthRequired(res, moduleName, betweenRelName); return; } const path = await linkInstancesEvent(moduleName, betweenRelName, unlink); @@ -801,10 +831,11 @@ async function handleBetweenRelationshipLinking( path.getEntryName(), objectAsInstanceAttributes(req.body) ); - parseAndEvaluateStatement(pattern, sessionInfo.userId).then(ok(res)).catch(internalError(res)); + parseAndEvaluateStatement(pattern, sessionInfo.userId) + .then(ok(res)) + .catch(entityRouteError(res, moduleName, betweenRelName)); } catch (err: any) { - logger.error(err); - res.status(500).send(err.toString()); + sendEntityCatch(res, moduleName, betweenRelName, err); } } @@ -858,7 +889,7 @@ function createChildPattern(moduleName: string, entityName: string, req: Request ); return `{${parentFqname} {${PathAttributeNameQuery} "${parentPath}"}, ${relName} ${cp}}`; } catch (err: any) { - throw new BadRequestError(err.message); + throw new BadRequestError(err.message, { agentlangCode: 'AL_HTTP_CHILD_PATTERN_INVALID' }); } } diff --git a/src/cli/main.ts b/src/cli/main.ts index 41a29c76..38b888a3 100644 --- a/src/cli/main.ts +++ b/src/cli/main.ts @@ -28,6 +28,7 @@ import { registerOpenApiModule } from '../runtime/openapi.js'; import { initDatabase, resetDefaultDatabase } from '../runtime/resolvers/sqldb/database.js'; import { runInitFunctions } from '../runtime/util.js'; import { startServer } from '../api/http.js'; +import { initErrorMessageOverrides } from '../runtime/errors/http-error.js'; import { enableExecutionGraph } from '../runtime/exec-graph.js'; import { importModule } from '../runtime/jsmodules.js'; import { @@ -142,8 +143,18 @@ export const parseAndValidate = async (fileName: string): Promise => { }; let LastActiveConfig: Config | undefined; - -export async function runPostInitTasks(appSpec?: ApplicationSpec, config?: Config) { +let LastConfigDir: string | undefined; + +export async function runPostInitTasks( + appSpec?: ApplicationSpec, + config?: Config, + configDir?: string +) { + if (configDir !== undefined) { + LastConfigDir = configDir; + } + const effectiveConfigDir = configDir ?? LastConfigDir; + await initErrorMessageOverrides(effectiveConfigDir, config?.customErrorMessages); await initDatabase(config?.store); await importModule('../runtime/api.js', 'agentlang'); await runInitFunctions(); @@ -164,7 +175,7 @@ export async function runPostInitTasks(appSpec?: ApplicationSpec, config?: Confi } export async function runPostInitTasksWithLastActiveConfig() { - await runPostInitTasks(undefined, LastActiveConfig); + await runPostInitTasks(undefined, LastActiveConfig, LastConfigDir); } let execGraphEnabled = false; @@ -256,7 +267,7 @@ export const runModule = async (fileName: string, releaseDb: boolean = false): P } try { await load(fileName, undefined, async (appSpec?: ApplicationSpec) => { - await runPostInitTasks(appSpec, config); + await runPostInitTasks(appSpec, config, configDir); }); } catch (err: any) { if (isNodeEnv && chalk) { diff --git a/src/runtime/auth/cognito.ts b/src/runtime/auth/cognito.ts index a757e6f6..74fb68f4 100644 --- a/src/runtime/auth/cognito.ts +++ b/src/runtime/auth/cognito.ts @@ -32,6 +32,7 @@ import { CodeMismatchError, BadRequestError, } from '../defs.js'; +import { createCodedError } from '../errors/coded-error.js'; let fromEnv: any = undefined; let CognitoIdentityProviderClient: any = undefined; @@ -105,154 +106,227 @@ function handleCognitoError(err: any, context: string): never { switch (err.name) { case 'UserNotFoundException': logger.debug(`User not found in context: ${context}`); - throw new UserNotFoundError('User account not found. Please check your email or sign up.'); + throw new UserNotFoundError('User account not found. Please check your email or sign up.', { + agentlangCode: 'AL_COGNITO_USER_NOT_FOUND_EXCEPTION', + }); case 'NotAuthorizedException': // Check if this is a password-related error vs other auth issues if (err.message && err.message.includes('password')) { logger.debug(`Invalid password attempt in context: ${context}`); - throw new UnauthorisedError('Invalid password. Please try again.'); + throw new UnauthorisedError('Invalid password. Please try again.', { + agentlangCode: 'AL_COGNITO_INVALID_PASSWORD', + }); } else if (err.message && err.message.includes('not confirmed')) { logger.debug(`User not confirmed in context: ${context}`); - throw new UserNotConfirmedError(); + throw new UserNotConfirmedError(undefined, { + agentlangCode: 'AL_COGNITO_NOT_CONFIRMED_MSG', + }); } else { logger.debug(`Authentication failed in context: ${context}`); - throw new UnauthorisedError('Authentication failed. Please check your credentials.'); + throw new UnauthorisedError('Authentication failed. Please check your credentials.', { + agentlangCode: 'AL_COGNITO_AUTH_FAILED', + }); } case 'UserNotConfirmedException': logger.debug(`User not confirmed in context: ${context}`); - throw new UserNotConfirmedError(); + throw new UserNotConfirmedError(undefined, { + agentlangCode: 'AL_COGNITO_USER_NOT_CONFIRMED_EX', + }); case 'PasswordResetRequiredException': logger.debug(`Password reset required in context: ${context}`); - throw new PasswordResetRequiredError(); + throw new PasswordResetRequiredError(undefined, { + agentlangCode: 'AL_COGNITO_PASSWORD_RESET_REQUIRED_EX', + }); case 'TooManyRequestsException': logger.warn(`Rate limit exceeded in context: ${context}`); - throw new TooManyRequestsError(); + throw new TooManyRequestsError(undefined, { agentlangCode: 'AL_COGNITO_TOO_MANY_REQUESTS' }); case 'TooManyFailedAttemptsException': logger.warn(`Too many failed attempts in context: ${context}`); - throw new TooManyRequestsError('Too many failed login attempts. Please try again later.'); + throw new TooManyRequestsError('Too many failed login attempts. Please try again later.', { + agentlangCode: 'AL_COGNITO_TOO_MANY_FAILED_ATTEMPTS', + }); case 'InvalidParameterException': logger.debug(`Invalid parameters in context: ${context}`); throw new InvalidParameterError( - sanitizeErrorMessage(err.message) || 'Invalid parameters provided' + sanitizeErrorMessage(err.message) || 'Invalid parameters provided', + { agentlangCode: 'AL_COGNITO_INVALID_PARAMETER_EX' } ); case 'ExpiredCodeException': logger.debug(`Expired code in context: ${context}`); - throw new ExpiredCodeError(); + throw new ExpiredCodeError(undefined, { agentlangCode: 'AL_COGNITO_EXPIRED_CODE_EX' }); case 'CodeMismatchException': logger.debug(`Code mismatch in context: ${context}`); - throw new CodeMismatchError(); + throw new CodeMismatchError(undefined, { agentlangCode: 'AL_COGNITO_CODE_MISMATCH_EX' }); case 'UsernameExistsException': logger.debug(`Username exists in context: ${context}`); - throw new BadRequestError('An account with this email already exists.'); + throw new BadRequestError('An account with this email already exists.', { + agentlangCode: 'AL_COGNITO_USERNAME_EXISTS', + }); case 'InvalidPasswordException': logger.debug(`Invalid password format in context: ${context}`); throw new BadRequestError( - 'Password does not meet requirements. It must be at least 8 characters long and contain uppercase, lowercase, numbers, and special characters.' + 'Password does not meet requirements. It must be at least 8 characters long and contain uppercase, lowercase, numbers, and special characters.', + { agentlangCode: 'AL_COGNITO_INVALID_PASSWORD_FORMAT' } ); case 'LimitExceededException': logger.warn(`Service limit exceeded in context: ${context}`); - throw new TooManyRequestsError('Service limit exceeded. Please try again later.'); + throw new TooManyRequestsError('Service limit exceeded. Please try again later.', { + agentlangCode: 'AL_COGNITO_LIMIT_EXCEEDED', + }); case 'InternalErrorException': logger.error(`Internal Cognito error in context: ${context}`); - throw new Error('Authentication service is temporarily unavailable. Please try again later.'); + throw createCodedError( + 'Authentication service is temporarily unavailable. Please try again later.', + 'AL_COGNITO_INTERNAL_ERROR_EX' + ); case 'ResourceNotFoundException': logger.error(`Resource not found in context: ${context}`); - throw new Error('Authentication service configuration error. Please contact support.'); + throw createCodedError( + 'Authentication service configuration error. Please contact support.', + 'AL_COGNITO_RESOURCE_NOT_FOUND_EX' + ); case 'AliasExistsException': logger.debug(`Alias exists in context: ${context}`); - throw new BadRequestError('An account with this email already exists.'); + throw new BadRequestError('An account with this email already exists.', { + agentlangCode: 'AL_COGNITO_ALIAS_EXISTS', + }); case 'InvalidEmailRoleAccessPolicyException': logger.error(`Invalid email role access policy in context: ${context}`); - throw new Error('Email service configuration error. Please contact support.'); + throw createCodedError( + 'Email service configuration error. Please contact support.', + 'AL_COGNITO_INVALID_EMAIL_ROLE_POLICY' + ); case 'UserLambdaValidationException': logger.error(`User lambda validation error in context: ${context}`); - throw new BadRequestError('User validation failed. Please check your input and try again.'); + throw new BadRequestError('User validation failed. Please check your input and try again.', { + agentlangCode: 'AL_COGNITO_USER_LAMBDA_VALIDATION', + }); case 'UnsupportedUserStateException': logger.debug(`Unsupported user state in context: ${context}`); throw new UserNotConfirmedError( - 'User account is in an unsupported state. Please contact support.' + 'User account is in an unsupported state. Please contact support.', + { agentlangCode: 'AL_COGNITO_UNSUPPORTED_USER_STATE' } ); case 'MFAMethodNotFoundException': logger.debug(`MFA method not found in context: ${context}`); - throw new BadRequestError('MFA method not found. Please set up MFA and try again.'); + throw new BadRequestError('MFA method not found. Please set up MFA and try again.', { + agentlangCode: 'AL_COGNITO_MFA_NOT_FOUND', + }); case 'CodeDeliveryFailureException': logger.error(`Code delivery failure in context: ${context}`); - throw new Error('Unable to deliver verification code. Please try again later.'); + throw createCodedError( + 'Unable to deliver verification code. Please try again later.', + 'AL_COGNITO_CODE_DELIVERY_FAILURE' + ); case 'DuplicateProviderException': logger.error(`Duplicate provider in context: ${context}`); - throw new BadRequestError('Authentication provider already exists.'); + throw new BadRequestError('Authentication provider already exists.', { + agentlangCode: 'AL_COGNITO_DUPLICATE_PROVIDER', + }); case 'EnableSoftwareTokenMFAException': logger.debug(`Software token MFA required in context: ${context}`); - throw new BadRequestError('Software token MFA setup required.'); + throw new BadRequestError('Software token MFA setup required.', { + agentlangCode: 'AL_COGNITO_ENABLE_SOFTWARE_TOKEN_MFA', + }); case 'ForbiddenException': logger.warn(`Forbidden access in context: ${context}`); - throw new UnauthorisedError('Access forbidden. Please check your permissions.'); + throw new UnauthorisedError('Access forbidden. Please check your permissions.', { + agentlangCode: 'AL_COGNITO_FORBIDDEN', + }); case 'GroupExistsException': logger.debug(`Group exists in context: ${context}`); - throw new BadRequestError('Group already exists.'); + throw new BadRequestError('Group already exists.', { + agentlangCode: 'AL_COGNITO_GROUP_EXISTS', + }); case 'InvalidLambdaResponseException': logger.error(`Invalid lambda response in context: ${context}`); - throw new Error('Authentication service error. Please try again later.'); + throw createCodedError( + 'Authentication service error. Please try again later.', + 'AL_COGNITO_INVALID_LAMBDA_RESPONSE' + ); case 'InvalidOAuthFlowException': logger.error(`Invalid OAuth flow in context: ${context}`); - throw new BadRequestError('Invalid OAuth flow. Please try again.'); + throw new BadRequestError('Invalid OAuth flow. Please try again.', { + agentlangCode: 'AL_COGNITO_INVALID_OAUTH_FLOW', + }); case 'InvalidSmsRoleAccessPolicyException': logger.error(`Invalid SMS role access policy in context: ${context}`); - throw new Error('SMS service configuration error. Please contact support.'); + throw createCodedError( + 'SMS service configuration error. Please contact support.', + 'AL_COGNITO_INVALID_SMS_ROLE_POLICY' + ); case 'InvalidSmsRoleTrustRelationshipException': logger.error(`Invalid SMS role trust relationship in context: ${context}`); - throw new Error('SMS service configuration error. Please contact support.'); + throw createCodedError( + 'SMS service configuration error. Please contact support.', + 'AL_COGNITO_INVALID_SMS_TRUST' + ); case 'InvalidUserPoolConfigurationException': logger.error(`Invalid user pool configuration in context: ${context}`); - throw new Error('Authentication service configuration error. Please contact support.'); + throw createCodedError( + 'Authentication service configuration error. Please contact support.', + 'AL_COGNITO_INVALID_USER_POOL_CONFIG' + ); case 'PreconditionNotMetException': logger.debug(`Precondition not met in context: ${context}`); - throw new BadRequestError('Precondition not met. Please check your request and try again.'); + throw new BadRequestError('Precondition not met. Please check your request and try again.', { + agentlangCode: 'AL_COGNITO_PRECONDITION_NOT_MET', + }); case 'ScopeDoesNotExistException': logger.error(`Scope does not exist in context: ${context}`); - throw new BadRequestError('Invalid scope. Please check your request.'); + throw new BadRequestError('Invalid scope. Please check your request.', { + agentlangCode: 'AL_COGNITO_SCOPE_MISSING', + }); case 'UnexpectedLambdaException': logger.error(`Unexpected lambda exception in context: ${context}`); - throw new Error('Authentication service error. Please try again later.'); + throw createCodedError( + 'Authentication service error. Please try again later.', + 'AL_COGNITO_UNEXPECTED_LAMBDA' + ); case 'UserImportInProgressException': logger.warn(`User import in progress in context: ${context}`); - throw new TooManyRequestsError('User import in progress. Please try again later.'); + throw new TooManyRequestsError('User import in progress. Please try again later.', { + agentlangCode: 'AL_COGNITO_USER_IMPORT_IN_PROGRESS', + }); case 'UserPoolTaggingException': logger.error(`User pool tagging exception in context: ${context}`); - throw new Error('Authentication service configuration error. Please contact support.'); + throw createCodedError( + 'Authentication service configuration error. Please contact support.', + 'AL_COGNITO_USER_POOL_TAGGING' + ); default: // For any other errors, throw a generic error with sanitized message @@ -261,8 +335,9 @@ function handleCognitoError(err: any, context: string): never { errorCode: err.code, context: context, }); - throw new Error( - `Authentication error: ${sanitizeErrorMessage(err.message) || 'An unexpected error occurred'}` + throw createCodedError( + `Authentication error: ${sanitizeErrorMessage(err.message) || 'An unexpected error occurred'}`, + 'AL_COGNITO_UNHANDLED' ); } } @@ -337,7 +412,7 @@ export class CognitoAuth implements AgentlangAuth { if (id) { return id; } - throw new Error(`${k} is not set`); + throw createCodedError(`${k} is not set`, 'AL_COGNITO_CONFIG_KEY_MISSING'); } async signUp( @@ -404,7 +479,12 @@ export class CognitoAuth implements AgentlangAuth { username: username, statusCode: response.$metadata.httpStatusCode, }); - throw new BadRequestError(`Signup failed with status ${response.$metadata.httpStatusCode}`); + throw new BadRequestError( + `Signup failed with status ${response.$metadata.httpStatusCode}`, + { + agentlangCode: 'AL_COGNITO_SIGNUP_HTTP_STATUS', + } + ); } } catch (err: any) { if (err instanceof BadRequestError) throw err; @@ -565,12 +645,13 @@ export class CognitoAuth implements AgentlangAuth { }, mfaRequired: (challengeName: any, _challengeParameters: any) => { logger.info(`MFA required for user ${username}: ${challengeName}`); - authError = new Error('MFA authentication required'); + authError = createCodedError('MFA authentication required', 'AL_COGNITO_MFA_REQUIRED'); }, newPasswordRequired: (_userAttributes: any, _requiredAttributes: any) => { logger.info(`New password required for user ${username}`); authError = new PasswordResetRequiredError( - 'New password required. Please reset your password.' + 'New password required. Please reset your password.', + { agentlangCode: 'AL_COGNITO_NEW_PASSWORD_REQUIRED_CHALLENGE' } ); }, }); @@ -627,7 +708,9 @@ export class CognitoAuth implements AgentlangAuth { cb(sessInfo); } else { logger.error(`Login failed for ${username} - no result received`); - throw new UnauthorisedError('Login failed. Please try again.'); + throw new UnauthorisedError('Login failed. Please try again.', { + agentlangCode: 'AL_COGNITO_LOGIN_NO_RESULT_SDK', + }); } } else { // Cognito not configured, fall back to local authentication @@ -659,12 +742,16 @@ export class CognitoAuth implements AgentlangAuth { }, mfaRequired: (challengeName: any, _challengeParameters: any) => { logger.info(`MFA required for user ${username}: ${challengeName}`); - authError = new Error('MFA authentication required'); + authError = createCodedError( + 'MFA authentication required', + 'AL_COGNITO_MFA_REQUIRED_LOCAL' + ); }, newPasswordRequired: (_userAttributes: any, _requiredAttributes: any) => { logger.info(`New password required for user ${username}`); authError = new PasswordResetRequiredError( - 'New password required. Please reset your password.' + 'New password required. Please reset your password.', + { agentlangCode: 'AL_COGNITO_NEW_PASSWORD_REQUIRED_CHALLENGE_LOCAL' } ); }, }); @@ -706,7 +793,9 @@ export class CognitoAuth implements AgentlangAuth { cb(sessInfo); } else { logger.error(`Login failed for ${username} - no result received`); - throw new UnauthorisedError('Login failed. Please try again.'); + throw new UnauthorisedError('Login failed. Please try again.', { + agentlangCode: 'AL_COGNITO_LOGIN_NO_RESULT_LOCAL', + }); } } } @@ -813,7 +902,7 @@ export class CognitoAuth implements AgentlangAuth { if (this.userPool) { return this.userPool; } - throw new Error('UserPool not initialized'); + throw createCodedError('UserPool not initialized', 'AL_COGNITO_USER_POOL_NOT_INIT'); } async verifyToken(token: string): Promise { @@ -834,20 +923,29 @@ export class CognitoAuth implements AgentlangAuth { // Handle specific token verification errors if (err.message && err.message.includes('expired')) { - throw new UnauthorisedError('Token has expired. Please login again.'); + throw new UnauthorisedError('Token has expired. Please login again.', { + agentlangCode: 'AL_COGNITO_TOKEN_EXPIRED', + }); } if (err.message && err.message.includes('invalid')) { - throw new UnauthorisedError('Invalid token format.'); + throw new UnauthorisedError('Invalid token format.', { + agentlangCode: 'AL_COGNITO_TOKEN_INVALID', + }); } if (err.message && err.message.includes('not before')) { - throw new UnauthorisedError('Token is not yet valid.'); + throw new UnauthorisedError('Token is not yet valid.', { + agentlangCode: 'AL_COGNITO_TOKEN_NOT_YET_VALID', + }); } if (err.message && err.message.includes('audience')) { - throw new UnauthorisedError('Token audience mismatch.'); + throw new UnauthorisedError('Token audience mismatch.', { + agentlangCode: 'AL_COGNITO_TOKEN_AUDIENCE', + }); } throw new UnauthorisedError( - `Token verification failed: ${sanitizeErrorMessage(err.message) || 'Invalid token'}` + `Token verification failed: ${sanitizeErrorMessage(err.message) || 'Invalid token'}`, + { agentlangCode: 'AL_COGNITO_TOKEN_VERIFY_FAILED' } ); } } @@ -855,7 +953,9 @@ export class CognitoAuth implements AgentlangAuth { async getUser(userId: string, env: Environment): Promise { const localUser = await findUser(userId, env); if (!localUser) { - throw new UserNotFoundError(`User ${userId} not found in local database`); + throw new UserNotFoundError(`User ${userId} not found in local database`, { + agentlangCode: 'AL_COGNITO_USER_NOT_FOUND_LOCAL_ID', + }); } const userEmail = localUser.lookup('email'); @@ -928,7 +1028,9 @@ export class CognitoAuth implements AgentlangAuth { async getUserByEmail(email: string, env: Environment): Promise { const localUser = await findUserByEmail(email, env); if (!localUser) { - throw new UserNotFoundError(`User with email ${email} not found in local database`); + throw new UserNotFoundError(`User with email ${email} not found in local database`, { + agentlangCode: 'AL_COGNITO_USER_NOT_FOUND_LOCAL_EMAIL', + }); } const userId = localUser.lookup('id'); @@ -1017,7 +1119,9 @@ export class CognitoAuth implements AgentlangAuth { const response = await client.send(command); if (!response.AuthenticationResult) { - throw new UnauthorisedError('Token refresh failed'); + throw new UnauthorisedError('Token refresh failed', { + agentlangCode: 'AL_COGNITO_TOKEN_REFRESH_NO_RESULT', + }); } const newIdToken = response.AuthenticationResult.IdToken!; @@ -1059,7 +1163,9 @@ export class CognitoAuth implements AgentlangAuth { } catch (err: any) { logger.error(`Refresh token operation failed: ${err.message}`); if (err.name === 'NotAuthorizedException') { - throw new UnauthorisedError('Invalid or expired refresh token'); + throw new UnauthorisedError('Invalid or expired refresh token', { + agentlangCode: 'AL_COGNITO_REFRESH_NOT_AUTHORIZED', + }); } handleCognitoError(err, 'refreshToken'); throw err; // This line won't be reached due to handleCognitoError throwing @@ -1158,7 +1264,8 @@ export class CognitoAuth implements AgentlangAuth { } ); throw new BadRequestError( - `User invitation failed with status ${response.$metadata.httpStatusCode}` + `User invitation failed with status ${response.$metadata.httpStatusCode}`, + { agentlangCode: 'AL_COGNITO_INVITE_HTTP_STATUS' } ); } } catch (err: any) { @@ -1185,7 +1292,9 @@ export class CognitoAuth implements AgentlangAuth { await client.send(getUserCommand); } catch (err: any) { if (err.name === 'UserNotFoundException') { - throw new UserNotFoundError(`User ${email} not found. Cannot resend invitation.`); + throw new UserNotFoundError(`User ${email} not found. Cannot resend invitation.`, { + agentlangCode: 'AL_COGNITO_RESEND_USER_NOT_FOUND', + }); } throw err; } @@ -1211,7 +1320,8 @@ export class CognitoAuth implements AgentlangAuth { } ); throw new BadRequestError( - `Failed to resend invitation with status ${response.$metadata.httpStatusCode}` + `Failed to resend invitation with status ${response.$metadata.httpStatusCode}`, + { agentlangCode: 'AL_COGNITO_RESEND_INVITE_HTTP_STATUS' } ); } } catch (err: any) { @@ -1263,7 +1373,10 @@ export class CognitoAuth implements AgentlangAuth { await client.send(respond); logger.info(`User invitation accepted successfully for: ${email}`); } else { - throw new Error(`Unexpected challenge: ${initResponse.ChallengeName}`); + throw createCodedError( + `Unexpected challenge: ${initResponse.ChallengeName}`, + 'AL_COGNITO_UNEXPECTED_CHALLENGE' + ); } } catch (err: any) { logger.error(`Accept invitation failed for ${email}: ${sanitizeErrorMessage(err.message)}`); @@ -1274,7 +1387,10 @@ export class CognitoAuth implements AgentlangAuth { async callback(code: string, env: Environment, cb: LoginCallback): Promise { try { if (!isNodeEnv) { - throw new Error('Callback authentication is only supported in Node.js environment'); + throw createCodedError( + 'Callback authentication is only supported in Node.js environment', + 'AL_COGNITO_CALLBACK_NODE_ONLY' + ); } const clientId = this.fetchConfig('ClientId'); @@ -1303,13 +1419,16 @@ export class CognitoAuth implements AgentlangAuth { if (!response.ok) { const errorText = await response.text(); - throw new Error(`Token exchange failed: ${response.status} ${errorText}`); + throw createCodedError( + `Token exchange failed: ${response.status} ${errorText}`, + 'AL_COGNITO_TOKEN_EXCHANGE_FAILED' + ); } const tokenData = await response.json(); if (!tokenData.access_token || !tokenData.id_token || !tokenData.refresh_token) { - throw new Error('Missing required tokens in response'); + throw createCodedError('Missing required tokens in response', 'AL_COGNITO_MISSING_TOKENS'); } const { @@ -1325,7 +1444,10 @@ export class CognitoAuth implements AgentlangAuth { const userGroups = idTokenPayload['cognito:groups']; if (!userEmail) { - throw new Error('Email not found in ID attributes'); + throw createCodedError( + 'Email not found in ID attributes', + 'AL_COGNITO_EMAIL_MISSING_IN_ID' + ); } let localUser = await findUserByEmail(userEmail, env); @@ -1379,7 +1501,7 @@ export class CognitoAuth implements AgentlangAuth { return JSON.parse(jsonPayload); } catch (err) { logger.error(`Failed to decode JWT payload: ${err}`); - throw new Error('Invalid JWT token'); + throw createCodedError('Invalid JWT token', 'AL_COGNITO_INVALID_JWT_PAYLOAD'); } } diff --git a/src/runtime/defs.ts b/src/runtime/defs.ts index 527f8a77..17834479 100644 --- a/src/runtime/defs.ts +++ b/src/runtime/defs.ts @@ -23,63 +23,96 @@ function asUnauthMessage(obj: string | UnautInfo): string { } } +/** Optional `agentlangCode` is stripped before passing to `Error` (not a standard ErrorOptions field). */ +export type AgentlangErrorOptions = ErrorOptions & { agentlangCode?: string }; + +function baseErrorOptions(opts?: AgentlangErrorOptions): ErrorOptions | undefined { + if (!opts) return undefined; + const { agentlangCode: _c, ...rest } = opts; + return Object.keys(rest).length > 0 ? rest : undefined; +} + export class UnauthorisedError extends Error { - constructor(message?: string | UnautInfo, options?: ErrorOptions) { + readonly agentlangCode: string; + constructor(message?: string | UnautInfo, options?: AgentlangErrorOptions) { super( message ? asUnauthMessage(message) : 'User not authorised to perform this operation', - options + baseErrorOptions(options) ); + this.agentlangCode = options?.agentlangCode ?? 'AL_UNAUTHORIZED'; } } export class BadRequestError extends Error { - constructor(message?: string, options?: ErrorOptions) { - super(message ? asUnauthMessage(message) : 'BadRequest', options); + readonly agentlangCode: string; + constructor(message?: string, options?: AgentlangErrorOptions) { + super(message ? asUnauthMessage(message) : 'BadRequest', baseErrorOptions(options)); + this.agentlangCode = options?.agentlangCode ?? 'AL_BAD_REQUEST'; } } export class UserNotFoundError extends Error { - constructor(message?: string, options?: ErrorOptions) { - super(message || 'User not found', options); + readonly agentlangCode: string; + constructor(message?: string, options?: AgentlangErrorOptions) { + super(message || 'User not found', baseErrorOptions(options)); + this.agentlangCode = options?.agentlangCode ?? 'AL_USER_NOT_FOUND'; } } export class UserNotConfirmedError extends Error { - constructor(message?: string, options?: ErrorOptions) { + readonly agentlangCode: string; + constructor(message?: string, options?: AgentlangErrorOptions) { super( message || 'User account is not confirmed. Please check your email for verification code.', - options + baseErrorOptions(options) ); + this.agentlangCode = options?.agentlangCode ?? 'AL_USER_NOT_CONFIRMED'; } } export class PasswordResetRequiredError extends Error { - constructor(message?: string, options?: ErrorOptions) { - super(message || 'Password reset is required for this account', options); + readonly agentlangCode: string; + constructor(message?: string, options?: AgentlangErrorOptions) { + super(message || 'Password reset is required for this account', baseErrorOptions(options)); + this.agentlangCode = options?.agentlangCode ?? 'AL_PASSWORD_RESET_REQUIRED'; } } export class TooManyRequestsError extends Error { - constructor(message?: string, options?: ErrorOptions) { - super(message || 'Too many requests. Please try again later.', options); + readonly agentlangCode: string; + constructor(message?: string, options?: AgentlangErrorOptions) { + super(message || 'Too many requests. Please try again later.', baseErrorOptions(options)); + this.agentlangCode = options?.agentlangCode ?? 'AL_TOO_MANY_REQUESTS'; } } export class InvalidParameterError extends Error { - constructor(message?: string, options?: ErrorOptions) { - super(message || 'Invalid parameters provided', options); + readonly agentlangCode: string; + constructor(message?: string, options?: AgentlangErrorOptions) { + super(message || 'Invalid parameters provided', baseErrorOptions(options)); + this.agentlangCode = options?.agentlangCode ?? 'AL_INVALID_PARAMETER'; } } export class ExpiredCodeError extends Error { - constructor(message?: string, options?: ErrorOptions) { - super(message || 'The verification code has expired. Please request a new one.', options); + readonly agentlangCode: string; + constructor(message?: string, options?: AgentlangErrorOptions) { + super( + message || 'The verification code has expired. Please request a new one.', + baseErrorOptions(options) + ); + this.agentlangCode = options?.agentlangCode ?? 'AL_EXPIRED_CODE'; } } export class CodeMismatchError extends Error { - constructor(message?: string, options?: ErrorOptions) { - super(message || 'The verification code is incorrect. Please try again.', options); + readonly agentlangCode: string; + constructor(message?: string, options?: AgentlangErrorOptions) { + super( + message || 'The verification code is incorrect. Please try again.', + baseErrorOptions(options) + ); + this.agentlangCode = options?.agentlangCode ?? 'AL_CODE_MISMATCH'; } } diff --git a/src/runtime/errors/coded-error.ts b/src/runtime/errors/coded-error.ts new file mode 100644 index 00000000..c21dd9bf --- /dev/null +++ b/src/runtime/errors/coded-error.ts @@ -0,0 +1,18 @@ +/** Stable bucket for errors without an explicit code. */ +export const AL_RUNTIME_UNHANDLED = 'AL_RUNTIME_UNHANDLED'; + +export type CodedError = Error & { agentlangCode: string }; + +export function createCodedError(message: string, code: string): CodedError { + const e = new Error(message) as CodedError; + e.agentlangCode = code; + return e; +} + +export function isCodedError(err: unknown): err is CodedError { + return ( + err instanceof Error && + typeof (err as CodedError).agentlangCode === 'string' && + (err as CodedError).agentlangCode.length > 0 + ); +} diff --git a/src/runtime/errors/http-error.ts b/src/runtime/errors/http-error.ts new file mode 100644 index 00000000..d943c466 --- /dev/null +++ b/src/runtime/errors/http-error.ts @@ -0,0 +1,253 @@ +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; +import { AgentCancelledException } from '../modules/ai.js'; +import { + BadRequestError, + CodeMismatchError, + ExpiredCodeError, + InvalidParameterError, + PasswordResetRequiredError, + TooManyRequestsError, + UnauthorisedError, + UserNotConfirmedError, + UserNotFoundError, +} from '../defs.js'; +import { logger } from '../logger.js'; +import { AppConfig } from '../state.js'; +import { AL_RUNTIME_UNHANDLED, isCodedError } from './coded-error.js'; + +/** Registry of Tier-1 HTTP-facing error codes (documentation / consistency). */ +export const AgentlangErrorCodes = { + AL_RUNTIME_UNHANDLED, + AL_HTTP_AUTH_REQUIRED: 'AL_HTTP_AUTH_REQUIRED', + AL_HTTP_HANDLER_EXCEPTION: 'AL_HTTP_HANDLER_EXCEPTION', +} as const; + +const DEFAULT_ERRORS_FILE = 'errors.json'; + +/** Universal: error code -> template string. Entity: `module/Entry` -> code -> template. */ +export type ParsedErrorMessages = { + universal: Record; + byEntity: Record>; +}; + +let parsedErrorMessages: ParsedErrorMessages = { universal: {}, byEntity: {} }; + +export function getParsedErrorMessages(): ParsedErrorMessages { + return parsedErrorMessages; +} + +/** Replace `{{code}}` and `{{message}}` with runtime values (iterates until stable). */ +export function applyErrorMessageTemplate( + template: string, + code: string, + originalMessage: string +): string { + let out = template; + const maxPasses = 10; + for (let i = 0; i < maxPasses; i++) { + const next = out.replace(/\{\{code\}\}/g, code).replace(/\{\{message\}\}/g, originalMessage); + if (next === out) break; + out = next; + } + return out; +} + +function validateOverridesJson(data: unknown): ParsedErrorMessages { + if (data === null || typeof data !== 'object' || Array.isArray(data)) { + throw new Error('errors.json must be a JSON object at the root'); + } + const universal: Record = {}; + const byEntity: Record> = {}; + for (const [topKey, val] of Object.entries(data as Record)) { + if (typeof val === 'string') { + universal[topKey] = val; + continue; + } + if (typeof val === 'object' && val !== null && !Array.isArray(val)) { + const codes: Record = {}; + for (const [code, msg] of Object.entries(val as Record)) { + if (typeof msg !== 'string') { + throw new Error(`errors.json: "${topKey}" / "${code}" must map to a string message`); + } + codes[code] = msg; + } + byEntity[topKey] = codes; + continue; + } + throw new Error( + `errors.json: value for "${topKey}" must be a string (global code) or an object (per-entity map)` + ); + } + return { universal, byEntity }; +} + +function resolveCustomErrorFilePath(configDir: string, fileName: string | undefined): string { + const name = fileName?.trim() || DEFAULT_ERRORS_FILE || DEFAULT_ERRORS_FILE; + const resolved = path.resolve(configDir, name); + const root = path.resolve(configDir); + const rel = path.relative(root, resolved); + if (rel.startsWith('..') || path.isAbsolute(rel)) { + throw new Error( + 'customErrorMessages.fileName must resolve within the application config directory' + ); + } + return resolved; +} + +export type CustomErrorMessagesConfig = { + enabled?: boolean; + fileName?: string; +}; + +/** + * Load custom error messages when `customErrorMessages.enabled` is true. + * Clears overrides when disabled or when configDir is omitted (and disabled). + */ +export async function initErrorMessageOverrides( + configDir: string | undefined, + customErrorMessages: CustomErrorMessagesConfig | undefined +): Promise { + parsedErrorMessages = { universal: {}, byEntity: {} }; + if (!customErrorMessages?.enabled) { + return; + } + if (!configDir) { + throw new Error( + 'customErrorMessages.enabled is true but application config directory is not available' + ); + } + const filePath = resolveCustomErrorFilePath(configDir, customErrorMessages.fileName); + const raw = await fs.readFile(filePath, 'utf8'); + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch (e: any) { + throw new Error(`Custom errors file is not valid JSON: ${e?.message ?? e}`); + } + parsedErrorMessages = validateOverridesJson(parsed); +} + +function customMessagesEnabled(): boolean { + return AppConfig?.customErrorMessages?.enabled === true; +} + +export function getErrorCode(err: unknown): string { + if (isCodedError(err)) { + return err.agentlangCode; + } + if (err instanceof UnauthorisedError) { + return err.agentlangCode; + } + if (err instanceof BadRequestError) { + return err.agentlangCode; + } + if (err instanceof UserNotFoundError) { + return err.agentlangCode; + } + if (err instanceof UserNotConfirmedError) { + return err.agentlangCode; + } + if (err instanceof PasswordResetRequiredError) { + return err.agentlangCode; + } + if (err instanceof TooManyRequestsError) { + return err.agentlangCode; + } + if (err instanceof InvalidParameterError) { + return err.agentlangCode; + } + if (err instanceof ExpiredCodeError) { + return err.agentlangCode; + } + if (err instanceof CodeMismatchError) { + return err.agentlangCode; + } + if (err instanceof AgentCancelledException) { + return err.agentlangCode; + } + return AL_RUNTIME_UNHANDLED; +} + +export function resolveEntityErrorMessage( + moduleName: string, + entryName: string, + code: string, + defaultMessage: string +): string { + if (!customMessagesEnabled()) { + return defaultMessage; + } + const entityKey = `${moduleName}/${entryName}`; + const entityTemplate = parsedErrorMessages.byEntity[entityKey]?.[code]; + const universalTemplate = parsedErrorMessages.universal[code]; + const template = entityTemplate ?? universalTemplate; + if (template === undefined) { + return defaultMessage; + } + return applyErrorMessageTemplate(template, code, defaultMessage); +} + +export function httpStatusFromError(err: unknown): number { + const ec = getErrorCode(err); + if (ec === 'AL_DB_UNIQUE_VIOLATION') { + return 409; + } + if ( + ec === 'AL_DB_FOREIGN_KEY_VIOLATION' || + ec === 'AL_DB_NOT_NULL_VIOLATION' || + ec === 'AL_DB_CHECK_VIOLATION' + ) { + return 400; + } + if ( + ec === 'AL_DB_DEADLOCK' || + ec === 'AL_DB_LOCK_WAIT_TIMEOUT' || + ec === 'AL_DB_SERIALIZATION_FAILURE' + ) { + return 503; + } + if (ec === 'AL_DB_SYNTAX_ERROR' || ec === 'AL_DB_QUERY_FAILED') { + return 500; + } + if (err instanceof UserNotFoundError) { + return 404; + } + if (err instanceof UnauthorisedError) { + return 401; + } + if (err instanceof TooManyRequestsError) { + return 429; + } + if (err instanceof BadRequestError || err instanceof InvalidParameterError) { + return 400; + } + if (err instanceof ExpiredCodeError || err instanceof CodeMismatchError) { + return 400; + } + if (err instanceof UserNotConfirmedError || err instanceof PasswordResetRequiredError) { + return 403; + } + if (err instanceof Error && err.message) { + if ( + err.message.includes('temporarily unavailable') || + err.message.includes('service error') || + err.message.includes('configuration error') + ) { + return 503; + } + if (err.message.includes('contact support')) { + return 500; + } + } + return 500; +} + +export function logEntityRouteError(reason: unknown, agentlangCode: string): void { + if (reason instanceof Error) { + const stack = reason.stack ? `\n${reason.stack}` : ''; + logger.error(`[${agentlangCode}] ${reason.name}: ${reason.message}${stack}`); + } else { + logger.error(`[${agentlangCode}] ${String(reason)}`); + } +} diff --git a/src/runtime/interpreter.ts b/src/runtime/interpreter.ts index 75d908b3..1f74b9c2 100644 --- a/src/runtime/interpreter.ts +++ b/src/runtime/interpreter.ts @@ -128,6 +128,7 @@ import { detailedDiff } from 'deep-object-diff'; import { callMcpTool, mcpClientNameFromToolEvent } from './mcpclient.js'; import { isNodeEnv } from '../utils/runtime.js'; import Handlebars from 'handlebars'; +import { createCodedError } from './errors/coded-error.js'; export type Result = any; @@ -446,7 +447,11 @@ export class Environment extends Instance { setActiveEvent(eventInst: Instance | undefined): Environment { if (eventInst) { - if (!isEventInstance(eventInst)) throw new Error(`Not an event instance - ${eventInst.name}`); + if (!isEventInstance(eventInst)) + throw createCodedError( + `Not an event instance - ${eventInst.name}`, + 'AL_INT_NOT_EVENT_INSTANCE' + ); this.bindInstance(eventInst); this.activeModule = eventInst.moduleName; this.activeEventInstance = eventInst; @@ -532,7 +537,7 @@ export class Environment extends Instance { if (this.suspensionId) { return this.suspensionId; } else { - throw new Error('SuspensionId is not set'); + throw createCodedError('SuspensionId is not set', 'AL_INT_SUSPENSION_ID_NOT_SET'); } } @@ -682,7 +687,7 @@ export class Environment extends Instance { this.activeTransactions.set(n, txnId); return txnId; } else { - throw new Error(`Failed to start transaction for ${n}`); + throw createCodedError(`Failed to start transaction for ${n}`, 'AL_INT_TXN_START_FAILED'); } } } @@ -774,7 +779,7 @@ export class Environment extends Instance { popHandlers(): CatchHandlers { const r = this.activeCatchHandlers.pop(); if (r === undefined) { - throw new Error(`No more handlers to pop`); + throw createCodedError(`No more handlers to pop`, 'AL_INT_NO_HANDLERS_TO_POP'); } return r; } @@ -1000,7 +1005,7 @@ export let evaluate = async function ( return null; } } else { - throw new Error('Not an event - ' + eventInstance.name); + throw createCodedError('Not an event - ' + eventInstance.name, 'AL_INT_NOT_AN_EVENT'); } } catch (err) { if (env && env.hasHandlers()) { @@ -1097,7 +1102,10 @@ async function evaluateAsyncPattern( if (s.$cstNode) { return s.$cstNode.text; } else { - throw new Error('failed to extract code for suspension statement'); + throw createCodedError( + 'failed to extract code for suspension statement', + 'AL_INT_SUSPENSION_CODE_EXTRACT' + ); } }), env @@ -1387,7 +1395,7 @@ export async function evaluatePattern( async function evaluateThrowError(throwErr: ThrowError, env: Environment) { await evaluateExpression(throwErr.reason, env); - throw new Error(env.getLastResult()); + throw createCodedError(String(env.getLastResult()), 'AL_INT_USER_THROW'); } async function evaluateFullTextSearch(fts: FullTextSearch, env: Environment): Promise { @@ -1397,7 +1405,10 @@ async function evaluateFullTextSearch(fts: FullTextSearch, env: Environment): Pr if (inst) { n = makeFqName(inst.moduleName, n); } else { - throw new Error(`Fully qualified name required for full-text-search in ${n}`); + throw createCodedError( + `Fully qualified name required for full-text-search in ${n}`, + 'AL_INT_FTS_FQN_REQUIRED' + ); } } const path = nameToPath(n); @@ -1407,7 +1418,10 @@ async function evaluateFullTextSearch(fts: FullTextSearch, env: Environment): Pr await evaluateLiteral(fts.query, env); const q = env.getLastResult(); if (!isString(q)) { - throw new Error(`Full text search query must be a string - ${q}`); + throw createCodedError( + `Full text search query must be a string - ${q}`, + 'AL_INT_FTS_QUERY_STRING' + ); } let options: Map | undefined; if (fts.options) { @@ -1527,7 +1541,10 @@ async function patternToInstance( let aname: string = a.name; if (aname.endsWith(QuerySuffix)) { if (isQueryAll) { - throw new Error(`Cannot specifiy query attribute ${aname} here`); + throw createCodedError( + `Cannot specifiy query attribute ${aname} here`, + 'AL_INT_QUERY_ATTR_FORBIDDEN' + ); } if (qattrs === undefined) qattrs = newInstanceAttributes(); if (qattrVals === undefined) qattrVals = newInstanceAttributes(); @@ -1570,11 +1587,15 @@ async function instanceFromSource(crud: CrudMap, env: Environment): Promise { } if (qopts.into) { if (attrs.size > 0) { - throw new Error( - `Query pattern for ${entryName} with 'into' clause cannot be used to update attributes` + throw createCodedError( + `Query pattern for ${entryName} with 'into' clause cannot be used to update attributes`, + 'AL_INT_INTO_WITH_ATTRS' ); } if (qattrs === undefined && !isQueryAll) { - throw new Error(`Pattern for ${entryName} with 'into' clause must be a query`); + throw createCodedError( + `Pattern for ${entryName} with 'into' clause must be a query`, + 'AL_INT_INTO_MUST_BE_QUERY' + ); } if (qopts.joins && qopts.joins.length > 0) { await evaluateJoinQuery(qopts.joins, qopts.into, qopts.where, inst, distinct, env); @@ -1914,7 +1939,10 @@ async function handleDocEvent(inst: Instance, env: Environment): Promise { const url = inst.lookup('url'); if (typeof url === 'string' && url.startsWith('s3://')) { if (!isNodeEnv) { - throw new Error('Document fetching is only available in Node.js environment'); + throw createCodedError( + 'Document fetching is only available in Node.js environment', + 'AL_INT_DOC_FETCH_NODE_ONLY' + ); } const title = inst.lookup('title'); const retrievalConfig = inst.lookup('retrievalConfig'); @@ -2213,7 +2241,10 @@ async function walkJoinQueryPattern( }); return joinsSpec; } else { - throw new Error(`Expected a query for relationship ${rp.name}`); + throw createCodedError( + `Expected a query for relationship ${rp.name}`, + 'AL_INT_RELATIONSHIP_QUERY_EXPECTED' + ); } } @@ -2338,10 +2369,13 @@ async function agentInvoke(agent: AgentInstance, msg: string, env: Environment): } } } else { - throw new Error(`Agent ${agent.name} failed to generate a response`); + throw createCodedError( + `Agent ${agent.name} failed to generate a response`, + 'AL_INT_AGENT_NO_RESPONSE' + ); } if (agentInternalError !== undefined) { - throw new Error(agentInternalError); + throw createCodedError(agentInternalError, 'AL_INT_AGENT_INTERNAL'); } } @@ -2469,7 +2503,10 @@ async function iterateOnFlow( while (step != 'DONE' && !executedSteps.has(step)) { await checkCancelled(iterId); if (stepc > MaxFlowSteps) { - throw new Error(`Flow execution exceeded maximum steps limit`); + throw createCodedError( + `Flow execution exceeded maximum steps limit`, + 'AL_INT_FLOW_MAX_STEPS' + ); } executedSteps.add(step); ++stepc; @@ -2531,7 +2568,10 @@ async function iterateOnFlow( ++fullFlowRetries; continue; } else { - throw new Error(reason); + throw createCodedError( + typeof reason === 'string' ? reason : String(reason), + 'AL_INT_FLOW_FATAL' + ); } } finally { env.decrementMonitor().revokeLastResult().setMonitorFlowResult(); @@ -2739,7 +2779,7 @@ export async function evaluateExpression(expr: Expr, env: Environment): Promise< }); break; default: - throw new Error(`Unrecognized binary operator: ${expr.op}`); + throw createCodedError(`Unrecognized binary operator: ${expr.op}`, 'AL_INT_BAD_BINARY_OP'); } } else if (isNegExpr(expr)) { await evaluateExpression(expr.ne, env); @@ -2817,7 +2857,10 @@ async function followReference(env: Environment, s: string): Promise { async function dereferencePath(path: string, env: Environment): Promise { const fqName = fqNameFromPath(path); if (fqName === undefined) { - throw new Error(`Failed to deduce entry-name from path - ${path}`); + throw createCodedError( + `Failed to deduce entry-name from path - ${path}`, + 'AL_INT_DEDUCE_ENTRY_NAME' + ); } const newEnv = new Environment('path-deref', env); await parseAndEvaluateStatement( @@ -2925,7 +2968,7 @@ async function runPrePostEvents( if (env.hasHandlers()) { throw reason; } else { - throw new Error(`${prefix}: ${reason}`); + throw createCodedError(`${prefix}: ${reason}`, 'AL_INT_PREPOST_EVENT_FAILED'); } }; if (trigInfo.async) { diff --git a/src/runtime/module.ts b/src/runtime/module.ts index f270e517..fdb8face 100644 --- a/src/runtime/module.ts +++ b/src/runtime/module.ts @@ -1,4 +1,5 @@ import chalk from 'chalk'; +import { createCodedError } from './errors/coded-error.js'; import { AttributeDefinition, Expr, @@ -2631,7 +2632,11 @@ export class Module { getEntry(entryName: string): ModuleEntry { const idx: number = this.getEntryIndex(entryName); - if (idx < 0) throw new Error(`Entry ${entryName} not found in module ${this.name}`); + if (idx < 0) + throw createCodedError( + `Entry ${entryName} not found in module ${this.name}`, + 'AL_MOD_ENTRY_NOT_FOUND' + ); return this.entries[idx]; } @@ -2646,7 +2651,10 @@ export class Module { if (e instanceof Record) { return e as Record; } - throw new Error(`${recordName} is not a record in module ${this.name}`); + throw createCodedError( + `${recordName} is not a record in module ${this.name}`, + 'AL_MOD_NOT_A_RECORD' + ); } removeEntry(entryName: string): boolean { @@ -2942,7 +2950,7 @@ export function isModule(name: string): boolean { export function fetchModule(moduleName: string): Module { const module: Module | undefined = getModuleDb().get(moduleName); if (module === undefined) { - throw new Error(`Module not found - ${moduleName}`); + throw createCodedError(`Module not found - ${moduleName}`, 'AL_MOD_MODULE_NOT_FOUND'); } return module; } @@ -3481,7 +3489,7 @@ export function getEntity(name: string, moduleName: string): Entity | undefined export function fetchEntity(path: Path): Entity { const e = getEntity(path.getEntryName(), path.getModuleName()); if (e === undefined) { - throw new Error(`Entity not found - ${path.asFqName()}`); + throw createCodedError(`Entity not found - ${path.asFqName()}`, 'AL_MOD_ENTITY_NOT_FOUND'); } return e; } @@ -3513,7 +3521,10 @@ export function getEvent(name: string, moduleName: string): Event { if (fr.module.isEvent(fr.entryName)) { return fr.module.getEntry(fr.entryName) as Event; } - throw new Error(`Event ${fr.entryName} not found in module ${fr.moduleName}`); + throw createCodedError( + `Event ${fr.entryName} not found in module ${fr.moduleName}`, + 'AL_MOD_EVENT_NOT_FOUND' + ); } export function maybeGetEvent(name: string, moduleName: string): Event | undefined { @@ -3529,7 +3540,10 @@ export function getRecord(name: string, moduleName: string): Record { if (fr.module.isRecord(fr.entryName)) { return fr.module.getEntry(fr.entryName) as Record; } - throw new Error(`Record ${fr.entryName} not found in module ${fr.moduleName}`); + throw createCodedError( + `Record ${fr.entryName} not found in module ${fr.moduleName}`, + 'AL_MOD_RECORD_NOT_FOUND' + ); } export function getRelationship(name: string, moduleName: string): Relationship { @@ -3537,7 +3551,10 @@ export function getRelationship(name: string, moduleName: string): Relationship if (fr.module.isRelationship(fr.entryName)) { return fr.module.getEntry(fr.entryName) as Relationship; } - throw new Error(`Relationship ${fr.entryName} not found in module ${fr.moduleName}`); + throw createCodedError( + `Relationship ${fr.entryName} not found in module ${fr.moduleName}`, + 'AL_MOD_RELATIONSHIP_NOT_FOUND' + ); } export function getAllBetweenRelationships(): Relationship[] { @@ -4307,7 +4324,10 @@ export function makeInstance( if (schema.size > 0) { attributes.forEach((value: any, key: string) => { if (!schema.has(key)) { - throw new Error(`Invalid attribute '${key}' specified for ${moduleName}/${entryName}`); + throw createCodedError( + `Invalid attribute '${key}' specified for ${moduleName}/${entryName}`, + 'AL_MOD_INVALID_ATTR' + ); } const spec: AttributeSpec = getAttributeSpec(schema, key); if (value !== null && value !== undefined) validateType(key, value, spec); diff --git a/src/runtime/modules/ai.ts b/src/runtime/modules/ai.ts index 8f0e5f65..8823a634 100644 --- a/src/runtime/modules/ai.ts +++ b/src/runtime/modules/ai.ts @@ -85,6 +85,7 @@ const AgentEvalType = 'eval'; // --- Agent cancellation infrastructure --- export class AgentCancelledException extends Error { + readonly agentlangCode = 'AL_AGENT_CANCELLED'; constructor(chatId: string) { super(`Agent cancelled for chatId: ${chatId}`); this.name = 'AgentCancelledException'; diff --git a/src/runtime/modules/auth.ts b/src/runtime/modules/auth.ts index 29e091c8..7ebe9727 100644 --- a/src/runtime/modules/auth.ts +++ b/src/runtime/modules/auth.ts @@ -27,6 +27,7 @@ import { set_getUserTenantId, PathAttributeName, } from '../defs.js'; +import { createCodedError } from '../errors/coded-error.js'; import { DbContext, getManyByRawQuery, @@ -1045,7 +1046,7 @@ function fetchAuthImpl(): AgentlangAuth { if (runtimeAuth) { return runtimeAuth; } else { - throw new Error('Auth not initialized'); + throw createCodedError('Auth not initialized', 'AL_AUTH_NOT_INITIALIZED'); } } @@ -1115,7 +1116,9 @@ export async function forgotPasswordUser(username: string, env: Environment): Pr const email = username.toLowerCase(); const allowed = await fetchAuthImpl().userExistsInIdentityProvider(email, env); if (!allowed) { - throw new UserNotFoundError('Email not registered'); + throw new UserNotFoundError('Email not registered', { + agentlangCode: 'AL_AUTH_EMAIL_NOT_REGISTERED', + }); } await fetchAuthImpl().forgotPassword(email, env); return { status: 'ok', message: 'Password reset code sent' }; @@ -1265,7 +1268,9 @@ export async function changePassword( return undefined; } } else { - throw new UnauthorisedError(`No active session for user ${user}`); + throw new UnauthorisedError(`No active session for user ${user}`, { + agentlangCode: 'AL_AUTH_NO_ACTIVE_SESSION_CHANGE_PW', + }); } } @@ -1292,7 +1297,9 @@ async function verifyJwtToken(token: string, env?: Environment): Promise 0) { const pending = pendingQueries.join('\n '); - throw new Error( + throw createCodedError( `Schema mismatch detected: the app model does not match the database schema. ` + - `Run migrations before starting in production mode.\n Pending changes:\n ${pending}` + `Run migrations before starting in production mode.\n Pending changes:\n ${pending}`, + 'AL_DB_SCHEMA_MISMATCH_PROD' ); } } @@ -431,8 +434,9 @@ async function maybeHandleMigrations(dataSource: DataSource) { const simulation = await simulateMigration(dataSource); if (!simulation.success) { logger.error(`Migration simulation failed:\n ${simulation.errors.join('\n ')}`); - throw new Error( - `Migration aborted: simulation failed.\n ${simulation.errors.join('\n ')}` + throw createCodedError( + `Migration aborted: simulation failed.\n ${simulation.errors.join('\n ')}`, + 'AL_DB_MIGRATION_SIMULATION_FAILED' ); } logger.info('Migration simulation passed.'); @@ -443,8 +447,9 @@ async function maybeHandleMigrations(dataSource: DataSource) { const simulation = await simulateMigration(dataSource); if (!simulation.success) { logger.error(`Migration simulation failed:\n ${simulation.errors.join('\n ')}`); - throw new Error( - `Migration aborted: simulation failed.\n ${simulation.errors.join('\n ')}` + throw createCodedError( + `Migration aborted: simulation failed.\n ${simulation.errors.join('\n ')}`, + 'AL_DB_MIGRATION_SIMULATION_FAILED' ); } logger.info('Migration simulation passed, applying changes...'); @@ -530,7 +535,10 @@ function getDsFunction( case 'sqljs': return makeSqljsDataSource; default: - throw new Error(`Unsupported database type - ${config?.type}`); + throw createCodedError( + `Unsupported database type - ${config?.type}`, + 'AL_DB_UNSUPPORTED_TYPE' + ); } } @@ -595,7 +603,10 @@ export async function initDatabase(config: DatabaseConfig | undefined) { await initVectorStore(vectEnts, DbContext.getGlobalContext()); } } else { - throw new Error(`Unsupported database type - ${getDbType(AppConfig?.store)}`); + throw createCodedError( + `Unsupported database type - ${getDbType(AppConfig?.store)}`, + 'AL_DB_UNSUPPORTED_TYPE_INIT' + ); } } } @@ -617,9 +628,13 @@ async function insertRowsHelper( ctx: DbContext, doUpsert: boolean ): Promise { - const repo = getDatasourceForTransaction(ctx.txnId).getRepository(tableName); - if (doUpsert) await repo.save(rows); - else await repo.insert(rows); + try { + const repo = getDatasourceForTransaction(ctx.txnId).getRepository(tableName); + if (doUpsert) await repo.save(rows); + else await repo.insert(rows); + } catch (err) { + mapDatabaseError(err); + } } export async function addRowForFullTextSearch( @@ -884,7 +899,10 @@ export async function insertRows( } } } else { - throw new UnauthorisedError({ opr: 'insert', entity: tableName }); + throw new UnauthorisedError( + { opr: 'insert', entity: tableName }, + { agentlangCode: 'AL_DB_INSERT_UNAUTHORIZED' } + ); } } @@ -926,7 +944,10 @@ export async function insertBetweenRow( const row = Object.fromEntries(attrs); await insertRow(n, row, ctx.clone().setNeedAuthCheck(false), false); } else { - throw new UnauthorisedError({ opr: 'insert', entity: n }); + throw new UnauthorisedError( + { opr: 'insert', entity: n }, + { agentlangCode: 'AL_DB_BETWEEN_INSERT_UNAUTHORIZED' } + ); } } @@ -1046,12 +1067,16 @@ export async function updateRow( updateObj: object, ctx: DbContext ): Promise { - await getDatasourceForTransaction(ctx.txnId) - .createQueryBuilder() - .update(tableName) - .set(updateObj) - .where(objectToWhereClause(queryObj, queryVals), queryVals) - .execute(); + try { + await getDatasourceForTransaction(ctx.txnId) + .createQueryBuilder() + .update(tableName) + .set(updateObj) + .where(objectToWhereClause(queryObj, queryVals), queryVals) + .execute(); + } catch (err) { + mapDatabaseError(err); + } return true; } @@ -1069,12 +1094,16 @@ function queryObjectAsWhereClause(qobj: QueryObject): string { export async function hardDeleteRow(tableName: string, queryObject: QueryObject, ctx: DbContext) { const clause = queryObjectAsWhereClause(queryObject); - await getDatasourceForTransaction(ctx.txnId) - .createQueryBuilder() - .delete() - .from(tableName) - .where(clause, Object.fromEntries(queryObject)) - .execute(); + try { + await getDatasourceForTransaction(ctx.txnId) + .createQueryBuilder() + .delete() + .from(tableName) + .where(clause, Object.fromEntries(queryObject)) + .execute(); + } catch (err) { + mapDatabaseError(err); + } return true; } @@ -1090,7 +1119,7 @@ function mkBetweenClause(tableName: string | undefined, k: string, queryVals: an delete queryVals[k]; return s; } else { - throw new Error(`between requires an array argument, not ${ov}`); + throw createCodedError(`between requires an array argument, not ${ov}`, 'AL_DB_BETWEEN_ARRAY'); } } @@ -1112,7 +1141,10 @@ function objectToWhereClause(queryObj: object, queryVals: any, tableName?: strin } else if (op === '<>' || op === '!=') { op = 'IS NOT'; } else { - throw new Error(`Operator ${op} cannot be appplied to SQL NULL`); + throw createCodedError( + `Operator ${op} cannot be appplied to SQL NULL`, + 'AL_DB_NULL_OPERATOR' + ); } } const v = isnullcheck ? 'NULL' : `:${k}`; @@ -1143,7 +1175,10 @@ function objectToRawWhereClause(queryObj: object, queryVals: any, tableName?: st } else if (op === '<>' || op === '!=') { op = 'IS NOT'; } else { - throw new Error(`Operator ${op} cannot be appplied to SQL NULL`); + throw createCodedError( + `Operator ${op} cannot be appplied to SQL NULL`, + 'AL_DB_NULL_OPERATOR' + ); } } let clause = ''; @@ -1274,8 +1309,12 @@ export async function getMany( qb.skip(querySpec.offset); } qb.where(queryStr, querySpec.queryVals); - if (hasAggregates) return await qb.getRawMany(); - else return await qb.getMany(); + try { + if (hasAggregates) return await qb.getRawMany(); + else return await qb.getMany(); + } catch (err) { + mapDatabaseError(err); + } } export async function getManyByRawQuery( @@ -1287,7 +1326,11 @@ export async function getManyByRawQuery( .getRepository(tableName) .createQueryBuilder(); qb.where('', querySpec.queryVals); - return await qb.getMany(); + try { + return await qb.getMany(); + } catch (err) { + mapDatabaseError(err); + } } export async function getManyByJoin( @@ -1348,7 +1391,7 @@ export async function getManyByJoin( } }); if (querySpec.intoSpec === undefined) { - throw new Error('SELECT-INTO pattern is missing'); + throw createCodedError('SELECT-INTO pattern is missing', 'AL_DB_SELECT_INTO_MISSING'); } const intos = querySpec.intoSpec.size > 0 ? intoSpecToSql(querySpec.intoSpec) : ''; const intos_sep = intos.length === 0 ? '' : ','; @@ -1370,7 +1413,11 @@ export async function getManyByJoin( } logger.debug(`Join Query: ${sql}`); const qb = getDatasourceForTransaction(ctx.txnId).getRepository(tableName).manager; - return await qb.query(sql); + try { + return await qb.query(sql); + } catch (err) { + mapDatabaseError(err); + } } function intoSpecToSql(intoSpec: Map): string { @@ -1440,7 +1487,11 @@ export async function getAllConnected( connAlias, buildQueryFromConnnectionInfo(connAlias, alias, connInfo) ); - return await qb.getRawMany(); + try { + return await qb.getRawMany(); + } catch (err) { + mapDatabaseError(err); + } } const transactionsDb: Map = new Map(); @@ -1453,7 +1504,7 @@ export async function startDbTransaction(): Promise { transactionsDb.set(txnId, queryRunner); return txnId; } else { - throw new Error('Database not initialized'); + throw createCodedError('Database not initialized', 'AL_DB_NOT_INITIALIZED'); } } @@ -1461,13 +1512,13 @@ function getDatasourceForTransaction(txnId: string | undefined): DataSource | En if (txnId) { const qr: QueryRunner | undefined = transactionsDb.get(txnId); if (qr === undefined) { - throw new Error(`Transaction not found - ${txnId}`); + throw createCodedError(`Transaction not found - ${txnId}`, 'AL_DB_TXN_NOT_FOUND'); } else { return qr.manager; } } else { if (defaultDataSource !== undefined) return defaultDataSource; - else throw new Error('No default datasource is initialized'); + else throw createCodedError('No default datasource is initialized', 'AL_DB_NO_DATASOURCE'); } } diff --git a/src/runtime/state.ts b/src/runtime/state.ts index e990b627..da54a714 100644 --- a/src/runtime/state.ts +++ b/src/runtime/state.ts @@ -140,6 +140,13 @@ export const ConfigSchema = z.object({ }) ) .optional(), + customErrorMessages: z + .object({ + enabled: z.boolean().default(false), + /** Relative to the app config directory. Defaults to `errors.json` when omitted. */ + fileName: z.string().optional(), + }) + .optional(), }); export type Config = z.infer; diff --git a/test/api/http.test.ts b/test/api/http.test.ts index 9f144c0b..bada8668 100644 --- a/test/api/http.test.ts +++ b/test/api/http.test.ts @@ -110,6 +110,10 @@ describe('Entity Create - POST', () => { test('POST with duplicate @id should fail', async () => { const res = await post('/HRT/User', { id: 1, name: 'Duplicate', email: 'dup@test.com' }); assert(!res.ok, 'Duplicate id should fail'); + assert(res.status === 409, `expected 409 Conflict for unique violation, got ${res.status}`); + const body = await res.json(); + assert(body.code === 'AL_DB_UNIQUE_VIOLATION', `expected AL_DB_UNIQUE_VIOLATION, got ${body.code}`); + assert(typeof body.message === 'string' && body.message.length > 0); }); test('POST /Module/Post creates a Post entity', async () => { @@ -551,6 +555,19 @@ describe('Error Handling', () => { assert(!res.ok, 'Should fail for non-existent entity'); }); + test('POST with unknown attribute returns JSON error code', async () => { + const res = await post('/HRT/User', { + id: 888, + name: 'BadAttr', + email: 'badattr@test.com', + notAValidAttribute: true, + }); + assert(!res.ok, 'Invalid attribute should fail'); + const body = await res.json(); + assert(body.code === 'AL_MOD_INVALID_ATTR', `got ${body.code}`); + assert(typeof body.message === 'string'); + }); + test('GET / returns application info', async () => { const res = await get('/'); assert(res.ok); diff --git a/test/runtime/error-messages.test.ts b/test/runtime/error-messages.test.ts new file mode 100644 index 00000000..73b8e02e --- /dev/null +++ b/test/runtime/error-messages.test.ts @@ -0,0 +1,147 @@ +import { assert, describe, test, beforeEach, afterEach } from 'vitest'; +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import { ConfigSchema, setAppConfig, AppConfig, type Config } from '../../src/runtime/state.js'; +import { + initErrorMessageOverrides, + resolveEntityErrorMessage, + applyErrorMessageTemplate, +} from '../../src/runtime/errors/http-error.js'; + +describe('custom error messages (errors.json)', () => { + let prevConfig: Config | undefined; + let tmpDir: string; + + beforeEach(async () => { + prevConfig = AppConfig; + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'agentlang-err-')); + }); + + afterEach(async () => { + await initErrorMessageOverrides(undefined, { enabled: false }); + if (prevConfig !== undefined) { + setAppConfig(prevConfig); + } + }); + + test('resolveEntityErrorMessage uses per-entity map when enabled', async () => { + await fs.writeFile( + path.join(tmpDir, 'errors.json'), + JSON.stringify({ + 'HRT/User': { + AL_MOD_NOT_A_RECORD: 'Custom: unknown entity for this app', + }, + }), + 'utf8' + ); + const cfg = ConfigSchema.parse({ + service: { port: 8080, httpFileHandling: false }, + customErrorMessages: { enabled: true }, + }); + setAppConfig(cfg); + await initErrorMessageOverrides(tmpDir, { enabled: true }); + const msg = resolveEntityErrorMessage( + 'HRT', + 'User', + 'AL_MOD_NOT_A_RECORD', + 'default message' + ); + assert(msg === 'Custom: unknown entity for this app'); + }); + + test('universal code mapping applies to any entity route', async () => { + await fs.writeFile( + path.join(tmpDir, 'errors.json'), + JSON.stringify({ + AL_MOD_INVALID_ATTR: 'Global invalid field message', + }), + 'utf8' + ); + const cfg = ConfigSchema.parse({ + service: { port: 8080, httpFileHandling: false }, + customErrorMessages: { enabled: true }, + }); + setAppConfig(cfg); + await initErrorMessageOverrides(tmpDir, { enabled: true }); + const msg = resolveEntityErrorMessage('HRT', 'User', 'AL_MOD_INVALID_ATTR', 'original'); + assert(msg === 'Global invalid field message'); + }); + + test('per-entity entry wins over universal for same code', async () => { + await fs.writeFile( + path.join(tmpDir, 'errors.json'), + JSON.stringify({ + AL_FOO: 'universal', + 'HRT/User': { AL_FOO: 'entity-specific' }, + }), + 'utf8' + ); + setAppConfig( + ConfigSchema.parse({ + service: { port: 8080, httpFileHandling: false }, + customErrorMessages: { enabled: true }, + }) + ); + await initErrorMessageOverrides(tmpDir, { enabled: true }); + assert(resolveEntityErrorMessage('HRT', 'User', 'AL_FOO', 'def') === 'entity-specific'); + assert(resolveEntityErrorMessage('HRT', 'Post', 'AL_FOO', 'def') === 'universal'); + }); + + test('template substitutes {{code}} and {{message}}', async () => { + await fs.writeFile( + path.join(tmpDir, 'errors.json'), + JSON.stringify({ + AL_X: 'cognito error: {{code}}, message: {{message}}', + }), + 'utf8' + ); + setAppConfig( + ConfigSchema.parse({ + service: { port: 8080, httpFileHandling: false }, + customErrorMessages: { enabled: true }, + }) + ); + await initErrorMessageOverrides(tmpDir, { enabled: true }); + const msg = resolveEntityErrorMessage('m', 'e', 'AL_X', 'something failed'); + assert(msg === 'cognito error: AL_X, message: something failed'); + }); + + test('applyErrorMessageTemplate handles repeated placeholders', () => { + assert( + applyErrorMessageTemplate('{{code}} / {{code}}', 'C', 'm') === 'C / C', + 'repeated {{code}}' + ); + }); + + test('custom fileName loads alternate JSON file', async () => { + await fs.writeFile( + path.join(tmpDir, 'abc.json'), + JSON.stringify({ AL_Z: 'from abc' }), + 'utf8' + ); + setAppConfig( + ConfigSchema.parse({ + service: { port: 8080, httpFileHandling: false }, + customErrorMessages: { enabled: true, fileName: 'abc.json' }, + }) + ); + await initErrorMessageOverrides(tmpDir, { enabled: true, fileName: 'abc.json' }); + assert(resolveEntityErrorMessage('a', 'b', 'AL_Z', 'd') === 'from abc'); + }); + + test('initErrorMessageOverrides throws when file missing and enabled', async () => { + const cfg = ConfigSchema.parse({ + service: { port: 8080, httpFileHandling: false }, + customErrorMessages: { enabled: true }, + }); + setAppConfig(cfg); + let threw = false; + try { + await initErrorMessageOverrides(tmpDir, { enabled: true }); + } catch { + threw = true; + } + assert(threw, 'expected missing errors.json to throw'); + }); +});