Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
},
);
}
};
Expand Down
161 changes: 160 additions & 1 deletion packages/seedless-onboarding-controller/src/errors.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -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<string, unknown> = { 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');
});
});
});
72 changes: 72 additions & 0 deletions packages/seedless-onboarding-controller/src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> {
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,
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Details lost in nested error serialization

Medium Severity

The toJSON method only serializes name and message from the cause property, losing the details property when the cause is itself a SeedlessOnboardingError. Since details provides "additional context about the error beyond the message" for debugging, this context is lost when errors are chained, undermining the debugging purpose of both the details and cause properties in logging and error tracking systems.

Fix in Cursor Fix in Web

stack: this.stack,
};
}
}
2 changes: 1 addition & 1 deletion packages/seedless-onboarding-controller/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,4 @@ export {
SecretType,
} from './constants';
export { SecretMetadata } from './SecretMetadata';
export { RecoveryError } from './errors';
export { RecoveryError, SeedlessOnboardingError } from './errors';
Loading