diff --git a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts index 5a91543ded0..d20536b8c5e 100644 --- a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts +++ b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts @@ -38,7 +38,11 @@ import { SeedlessOnboardingControllerErrorMessage, Web3AuthNetwork, } from './constants'; -import { PasswordSyncError, RecoveryError } from './errors'; +import { + PasswordSyncError, + RecoveryError, + SeedlessOnboardingError, +} from './errors'; import { projectLogger, createModuleLogger } from './logger'; import { SecretMetadata } from './SecretMetadata'; import type { @@ -437,8 +441,11 @@ export class SeedlessOnboardingController< return authenticationResult; } catch (error) { log('Error authenticating user', error); - throw new Error( + throw new SeedlessOnboardingError( SeedlessOnboardingControllerErrorMessage.AuthenticationError, + { + cause: error, + }, ); } }; diff --git a/packages/seedless-onboarding-controller/src/errors.test.ts b/packages/seedless-onboarding-controller/src/errors.test.ts index 0011a44c87f..795280022b0 100644 --- a/packages/seedless-onboarding-controller/src/errors.test.ts +++ b/packages/seedless-onboarding-controller/src/errors.test.ts @@ -1,7 +1,10 @@ import { TOPRFErrorCode } from '@metamask/toprf-secure-backup'; import { SeedlessOnboardingControllerErrorMessage } from './constants'; -import { getErrorMessageFromTOPRFErrorCode } from './errors'; +import { + getErrorMessageFromTOPRFErrorCode, + SeedlessOnboardingError, +} from './errors'; describe('getErrorMessageFromTOPRFErrorCode', () => { it('returns TooManyLoginAttempts for RateLimitExceeded', () => { @@ -49,3 +52,159 @@ describe('getErrorMessageFromTOPRFErrorCode', () => { ).toBe('fallback'); }); }); + +describe('SeedlessOnboardingError', () => { + describe('constructor', () => { + it('creates an error with just a message', () => { + const error = new SeedlessOnboardingError('Test error message'); + + expect(error.message).toBe('Test error message'); + expect(error.name).toBe('SeedlessOnboardingControllerError'); + expect(error.details).toBeUndefined(); + expect(error.cause).toBeUndefined(); + }); + + it('creates an error with a message from SeedlessOnboardingControllerErrorMessage enum', () => { + const error = new SeedlessOnboardingError( + SeedlessOnboardingControllerErrorMessage.AuthenticationError, + ); + + expect(error.message).toBe( + SeedlessOnboardingControllerErrorMessage.AuthenticationError, + ); + expect(error.name).toBe('SeedlessOnboardingControllerError'); + }); + + it('creates an error with message and details', () => { + const error = new SeedlessOnboardingError('Test error', { + details: 'Additional context for debugging', + }); + + expect(error.message).toBe('Test error'); + expect(error.details).toBe('Additional context for debugging'); + expect(error.cause).toBeUndefined(); + }); + + it('creates an error with an Error instance as cause', () => { + const originalError = new Error('Original error'); + const error = new SeedlessOnboardingError('Wrapped error', { + cause: originalError, + }); + + expect(error.message).toBe('Wrapped error'); + expect(error.cause).toBe(originalError); + }); + + it('creates an error with a string as cause', () => { + const error = new SeedlessOnboardingError('Test error', { + cause: 'String cause message', + }); + + expect(error.cause).toBeInstanceOf(Error); + expect(error.cause?.message).toBe('String cause message'); + }); + + it('creates an error with an object as cause (JSON serializable)', () => { + const causeObject = { code: 500, reason: 'Internal error' }; + const error = new SeedlessOnboardingError('Test error', { + cause: causeObject, + }); + + expect(error.cause).toBeInstanceOf(Error); + expect(error.cause?.message).toBe(JSON.stringify(causeObject)); + }); + + it('handles circular object as cause by using fallback message', () => { + const circularObject: Record = { name: 'circular' }; + circularObject.self = circularObject; + + const error = new SeedlessOnboardingError('Test error', { + cause: circularObject, + }); + + expect(error.cause).toBeInstanceOf(Error); + expect(error.cause?.message).toBe('Unknown error'); + }); + + it('creates an error with both details and cause', () => { + const originalError = new Error('Original'); + const error = new SeedlessOnboardingError('Test error', { + details: 'Some details', + cause: originalError, + }); + + expect(error.message).toBe('Test error'); + expect(error.details).toBe('Some details'); + expect(error.cause).toBe(originalError); + }); + }); + + describe('toJSON', () => { + it('serializes error with all properties', () => { + const originalError = new Error('Original error'); + const error = new SeedlessOnboardingError('Test error', { + details: 'Debug info', + cause: originalError, + }); + + const json = error.toJSON(); + + expect(json.name).toBe('SeedlessOnboardingControllerError'); + expect(json.message).toBe('Test error'); + expect(json.details).toBe('Debug info'); + expect(json.cause).toStrictEqual({ + name: 'Error', + message: 'Original error', + }); + expect(json.stack).toBeDefined(); + }); + + it('serializes error without optional properties', () => { + const error = new SeedlessOnboardingError('Simple error'); + + const json = error.toJSON(); + + expect(json.name).toBe('SeedlessOnboardingControllerError'); + expect(json.message).toBe('Simple error'); + expect(json.details).toBeUndefined(); + expect(json.cause).toBeUndefined(); + expect(json.stack).toBeDefined(); + }); + + it('serializes error with custom error type as cause', () => { + class CustomError extends Error { + constructor() { + super('Custom error message'); + this.name = 'CustomError'; + } + } + const customError = new CustomError(); + const error = new SeedlessOnboardingError('Wrapper', { + cause: customError, + }); + + const json = error.toJSON(); + + expect(json.cause).toStrictEqual({ + name: 'CustomError', + message: 'Custom error message', + }); + }); + }); + + describe('inheritance', () => { + it('is an instance of Error', () => { + const error = new SeedlessOnboardingError('Test'); + + expect(error).toBeInstanceOf(Error); + expect(error).toBeInstanceOf(SeedlessOnboardingError); + }); + + it('has a proper stack trace', () => { + const error = new SeedlessOnboardingError('Test'); + + expect(error.stack).toBeDefined(); + expect(error.stack).toContain('SeedlessOnboardingError'); + }); + }); +}); diff --git a/packages/seedless-onboarding-controller/src/errors.ts b/packages/seedless-onboarding-controller/src/errors.ts index 7f5c0224de8..8a6916ad05b 100644 --- a/packages/seedless-onboarding-controller/src/errors.ts +++ b/packages/seedless-onboarding-controller/src/errors.ts @@ -135,3 +135,75 @@ export class RecoveryError extends Error { return new RecoveryError(errorMessage, recoveryErrorData); } } + +/** + * Generic error class for SeedlessOnboardingController operations. + * + * Use this when you need to wrap an underlying error with additional context, + * or when none of the more specific error classes (PasswordSyncError, RecoveryError) apply. + * + * @example + * ```typescript + * throw new SeedlessOnboardingError( + * SeedlessOnboardingControllerErrorMessage.FailedToEncryptAndStoreSecretData, + * { details: 'Encryption failed during backup', cause: originalError } + * ); + * ``` + */ +export class SeedlessOnboardingError extends Error { + /** + * Additional context about the error beyond the message. + * Use this for human-readable details that help with debugging. + */ + public details: string | undefined; + + /** + * The underlying error that caused this error. + */ + public cause: Error | undefined; + + constructor( + message: string | SeedlessOnboardingControllerErrorMessage, + options?: { details?: string; cause?: unknown }, + ) { + super(message); + this.name = 'SeedlessOnboardingControllerError'; + this.details = options?.details; + if (options?.cause) { + if (options.cause instanceof Error) { + this.cause = options.cause; + } else { + let causeMessage: string; + if (typeof options.cause === 'string') { + causeMessage = options.cause; + } else { + try { + causeMessage = JSON.stringify(options.cause); + } catch { + causeMessage = 'Unknown error'; + } + } + this.cause = new Error(causeMessage); + } + } + } + + /** + * Serializes the error for logging/transmission. + * Ensures custom properties are included in JSON output. + * + * @returns A JSON-serializable representation of the error. + */ + toJSON(): Record { + return { + name: this.name, + message: this.message, + details: this.details, + cause: + this.cause instanceof Error + ? { name: this.cause.name, message: this.cause.message } + : this.cause, + stack: this.stack, + }; + } +} diff --git a/packages/seedless-onboarding-controller/src/index.ts b/packages/seedless-onboarding-controller/src/index.ts index 4d445795530..1cdde0d2a84 100644 --- a/packages/seedless-onboarding-controller/src/index.ts +++ b/packages/seedless-onboarding-controller/src/index.ts @@ -22,4 +22,4 @@ export { SecretType, } from './constants'; export { SecretMetadata } from './SecretMetadata'; -export { RecoveryError } from './errors'; +export { RecoveryError, SeedlessOnboardingError } from './errors';