diff --git a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts index 5a91543ded0..5b3c11f0001 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, + }, ); } }; @@ -664,8 +671,11 @@ export class SeedlessOnboardingController< ); } catch (error) { log('Error changing password', error); - throw new Error( + throw new SeedlessOnboardingError( SeedlessOnboardingControllerErrorMessage.FailedToChangePassword, + { + cause: error, + }, ); } }); @@ -941,8 +951,11 @@ export class SeedlessOnboardingController< }) .catch((error) => { log('Error fetching auth pub key', error); - throw new Error( + throw new SeedlessOnboardingError( SeedlessOnboardingControllerErrorMessage.FailedToFetchAuthPubKey, + { + cause: error, + }, ); }); globalAuthPubKey = authPubKey; @@ -1031,8 +1044,11 @@ export class SeedlessOnboardingController< throw error; } log('Error persisting local encryption key', error); - throw new Error( + throw new SeedlessOnboardingError( SeedlessOnboardingControllerErrorMessage.FailedToPersistOprfKey, + { + cause: error, + }, ); } } @@ -1195,8 +1211,11 @@ export class SeedlessOnboardingController< if (this.#isAuthTokenError(error)) { throw error; } - throw new Error( + throw new SeedlessOnboardingError( SeedlessOnboardingControllerErrorMessage.FailedToFetchSecretMetadata, + { + cause: error, + }, ); } @@ -1805,8 +1824,11 @@ export class SeedlessOnboardingController< }) .catch((error) => { log('Error fetching auth pub key', error); - throw new Error( + throw new SeedlessOnboardingError( SeedlessOnboardingControllerErrorMessage.FailedToFetchAuthPubKey, + { + cause: error, + }, ); }); const isPasswordOutdated = await this.checkIsPasswordOutdated({ @@ -1844,8 +1866,11 @@ export class SeedlessOnboardingController< refreshToken, }).catch((error) => { log('Error refreshing JWT tokens', error); - throw new Error( + throw new SeedlessOnboardingError( SeedlessOnboardingControllerErrorMessage.FailedToRefreshJWTTokens, + { + cause: error, + }, ); }); @@ -1866,8 +1891,11 @@ export class SeedlessOnboardingController< }); } catch (error) { log('Error refreshing node auth tokens', 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..e090e16a648 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,205 @@ 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', + }); + }); + + it('serializes SeedlessOnboardingError cause with details preserved', () => { + const innerError = new SeedlessOnboardingError('Inner error', { + details: 'Inner debugging context', + }); + const outerError = new SeedlessOnboardingError('Outer error', { + details: 'Outer debugging context', + cause: innerError, + }); + + const json = outerError.toJSON(); + + expect(json.name).toBe('SeedlessOnboardingControllerError'); + expect(json.message).toBe('Outer error'); + expect(json.details).toBe('Outer debugging context'); + expect(json.cause).toStrictEqual({ + name: 'SeedlessOnboardingControllerError', + message: 'Inner error', + details: 'Inner debugging context', + cause: undefined, + stack: innerError.stack, + }); + }); + + it('serializes deeply nested SeedlessOnboardingError chain', () => { + const rootError = new Error('Root cause'); + const level1 = new SeedlessOnboardingError('Level 1', { + details: 'Level 1 details', + cause: rootError, + }); + const level2 = new SeedlessOnboardingError('Level 2', { + details: 'Level 2 details', + cause: level1, + }); + + const json = level2.toJSON(); + + expect(json.details).toBe('Level 2 details'); + const level1Json = json.cause as Record; + expect(level1Json.message).toBe('Level 1'); + expect(level1Json.details).toBe('Level 1 details'); + expect(level1Json.cause).toStrictEqual({ + name: 'Error', + message: 'Root cause', + }); + }); + }); + + 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('SeedlessOnboardingControllerError'); + }); + }); +}); diff --git a/packages/seedless-onboarding-controller/src/errors.ts b/packages/seedless-onboarding-controller/src/errors.ts index 7f5c0224de8..005a614cdb6 100644 --- a/packages/seedless-onboarding-controller/src/errors.ts +++ b/packages/seedless-onboarding-controller/src/errors.ts @@ -135,3 +135,87 @@ 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 cause error for JSON output. + * + * @returns A JSON-serializable representation of the cause. + */ + #serializeCause(): Record | undefined { + if (this.cause instanceof SeedlessOnboardingError) { + return this.cause.toJSON(); + } + if (this.cause instanceof Error) { + return { name: this.cause.name, message: this.cause.message }; + } + return undefined; + } + + /** + * 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.#serializeCause(), + 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';