From 8f70309004cd395ca3e27d12f0e68524d3d0e833 Mon Sep 17 00:00:00 2001 From: Juraj Uhlar Date: Tue, 22 Sep 2026 13:04:24 +0100 Subject: [PATCH 1/7] fix: throw SdkError for invalid arguments --- .changeset/sdk-error-for-invalid-arguments.md | 5 ++++ readme.md | 4 +-- src/errors/apiErrors.ts | 4 +++ src/serverApiClient.ts | 10 +++---- src/urlUtils.ts | 22 ++++++++++---- .../pathParamEncodingTests.spec.ts | 11 +++---- tests/unit-tests/serverApiClientTests.spec.ts | 10 +++---- tests/unit-tests/urlUtilsTests.spec.ts | 30 +++++++++---------- 8 files changed, 56 insertions(+), 40 deletions(-) create mode 100644 .changeset/sdk-error-for-invalid-arguments.md diff --git a/.changeset/sdk-error-for-invalid-arguments.md b/.changeset/sdk-error-for-invalid-arguments.md new file mode 100644 index 00000000..617a5ec9 --- /dev/null +++ b/.changeset/sdk-error-for-invalid-arguments.md @@ -0,0 +1,5 @@ +--- +'@fingerprint/node-sdk': patch +--- + +Throw `SdkError` instead of `TypeError` or `Error` for invalid arguments. Messages name the function argument (`eventId is not valid: ..`) instead of a path parameter. diff --git a/readme.md b/readme.md index af7f5ef0..14f2af3c 100644 --- a/readme.md +++ b/readme.md @@ -138,11 +138,11 @@ try { } ``` -Other errors, all subclasses of `SdkError`: +Other `SdkError`s: - `TooManyRequestsError` — a `ServerApiError` thrown when the request is throttled (HTTP 429). - `RequestError` — the base class of `ServerApiError`, thrown when the response doesn't match the Server API error shape (e.g. a proxy error). Its `errorCode` is a free-form `string` placeholder from `statusText`. Since `ServerApiError` extends it, `error instanceof RequestError` catches both. -- `SdkError` — the base of all SDK errors; also thrown for network failures and malformed responses. +- `SdkError` — the base of all SDK errors. Thrown directly for invalid arguments, network failures, and malformed responses. ### Webhooks diff --git a/src/errors/apiErrors.ts b/src/errors/apiErrors.ts index fa6c503e..8c5ca136 100644 --- a/src/errors/apiErrors.ts +++ b/src/errors/apiErrors.ts @@ -2,6 +2,10 @@ import { ErrorCode, ErrorResponse } from '../types' /** * Base class for all errors thrown by the SDK. + * + * Thrown directly for invalid arguments, network failures, and malformed + * responses. Subclasses such as {@link RequestError} cover HTTP error + * responses. */ export class SdkError extends Error { constructor( diff --git a/src/serverApiClient.ts b/src/serverApiClient.ts index 46c00a7f..d42ccb89 100644 --- a/src/serverApiClient.ts +++ b/src/serverApiClient.ts @@ -35,7 +35,7 @@ export class FingerprintServerApiClient implements FingerprintApi { */ constructor(options: Readonly) { if (!options.apiKey) { - throw Error('Api key is not set') + throw new SdkError('Api key is not set') } this.region = options.region ?? Region.Global @@ -91,7 +91,7 @@ export class FingerprintServerApiClient implements FingerprintApi { * */ public async getEvent(eventId: string, options?: GetEventOptions): Promise { if (!eventId) { - throw new TypeError('eventId is not set') + throw new SdkError('eventId is not set') } return this.callApi({ @@ -145,11 +145,11 @@ export class FingerprintServerApiClient implements FingerprintApi { // Runtime guard for untyped callers even though TypeScript treats body as required. // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition, @typescript-eslint/strict-boolean-expressions -- runtime validation if (!body) { - throw new TypeError('body is not set') + throw new SdkError('body is not set') } if (!eventId) { - throw new TypeError('eventId is not set') + throw new SdkError('eventId is not set') } return this.callApi({ @@ -194,7 +194,7 @@ export class FingerprintServerApiClient implements FingerprintApi { */ public async deleteVisitorData(visitorId: string): Promise { if (!visitorId) { - throw new TypeError('visitorId is not set') + throw new SdkError('visitorId is not set') } return this.callApi({ diff --git a/src/urlUtils.ts b/src/urlUtils.ts index bad0810a..505a9f21 100644 --- a/src/urlUtils.ts +++ b/src/urlUtils.ts @@ -1,6 +1,8 @@ import { Region } from './types' import { version } from '../package.json' import { paths } from './generatedApiTypes' +import { SdkError } from './errors/apiErrors' +import { toError } from './errors/toError' const apiVersion = 'v4' @@ -43,6 +45,13 @@ function serializeQueryStringParams(params: QueryStringParameters): string { return urlSearchParams.toString() } +/** + * `event_id` in the URL template is the `eventId` argument on the client method. + */ +function argumentName(placeholder: string): string { + return placeholder.replace(/_([a-z])/g, (_, letter: string) => letter.toUpperCase()) +} + /** * Confines a value to a single URL path segment. `.` is deliberately left unencoded because * the Server API does not decode path parameters, and valid parameter values can contain dots. @@ -51,10 +60,11 @@ function serializeQueryStringParams(params: QueryStringParameters): string { * encoded, so it cannot be expressed. See https://url.spec.whatwg.org/#double-dot-path-segment */ function encodePathParam(placeholder: string, value: unknown): string { + const name = argumentName(placeholder) // Coerce before comparing, because an untyped caller can pass something that is not a string // but stringifies to one. Both conversions throw on values only such a caller could pass: // `String` when the value has no primitive representation, `encodeURIComponent` on a lone - // surrogate. Neither should escape as its own error type. + // surrogate. Wrap either as `SdkError` so it does not escape as its own error type. let param: string let encoded: string try { @@ -62,15 +72,15 @@ function encodePathParam(placeholder: string, value: unknown): string { param = String(value ?? '') encoded = encodeURIComponent(param) } catch (cause) { - throw new TypeError(`Invalid path parameter for ${placeholder}`, { cause }) + throw new SdkError(`${name} is not valid`, undefined, toError(cause)) } if (param === '') { - throw new TypeError(`Missing path parameter for ${placeholder}`) + throw new SdkError(`${name} is not set`) } if (param === '.' || param === '..') { - throw new TypeError(`Invalid path parameter for ${placeholder}: ${param}`) + throw new SdkError(`${name} is not valid: ${param}`) } return encoded @@ -85,7 +95,7 @@ function getServerApiUrl(region: Region): string { case Region.Global: return globalRegionUrl default: - throw new Error('Unsupported region') + throw new SdkError('Unsupported region') } } @@ -142,7 +152,7 @@ export function getRequestPath({ url.search = serializeQueryStringParams(queryStringParameters) if (url.pathname !== `/${formattedPath}`) { - throw new TypeError('Invalid path: path changed during normalization') + throw new SdkError('Invalid path: path changed during normalization') } return url.toString() diff --git a/tests/mocked-responses-tests/pathParamEncodingTests.spec.ts b/tests/mocked-responses-tests/pathParamEncodingTests.spec.ts index 25508ad4..9e6f5eef 100644 --- a/tests/mocked-responses-tests/pathParamEncodingTests.spec.ts +++ b/tests/mocked-responses-tests/pathParamEncodingTests.spec.ts @@ -1,4 +1,4 @@ -import { FingerprintServerApiClient, Region } from '../../src' +import { FingerprintServerApiClient, Region, SdkError } from '../../src' import { getIntegrationInfo } from '../../src/urlUtils' import { describe, expect, it } from 'vitest' import { mockFetch } from './mockFetch' @@ -19,27 +19,24 @@ describe('[Mocked response] Path parameter encoding', () => { { name: 'getEvent', prefix: 'v4/events', - placeholder: 'event_id', paramName: 'eventId', call: (param: string) => client.getEvent(param), }, { name: 'updateEvent', prefix: 'v4/events', - placeholder: 'event_id', paramName: 'eventId', call: (param: string) => client.updateEvent(param, { suspect: true }), }, { name: 'deleteVisitorData', prefix: 'v4/visitors', - placeholder: 'visitor_id', paramName: 'visitorId', call: (param: string) => client.deleteVisitorData(param), }, ] as const - describe.each(operations)('$name', ({ prefix, placeholder, paramName, call }) => { + describe.each(operations)('$name', ({ prefix, paramName, call }) => { // The full encoding table lives in the unit tests; these are the cases INTER-2499 asks to // be pinned at the wire level for every method. it.each([ @@ -58,13 +55,13 @@ describe('[Mocked response] Path parameter encoding', () => { }) it.each(['.', '..'])('does not send a request for %j', async (param) => { - await expect(call(param)).rejects.toThrow(new TypeError(`Invalid path parameter for ${placeholder}: ${param}`)) + await expect(call(param)).rejects.toThrow(new SdkError(`${paramName} is not valid: ${param}`)) expect(mockFetch).not.toHaveBeenCalled() }) it('does not send a request for an empty parameter', async () => { - await expect(call('')).rejects.toThrow(new TypeError(`${paramName} is not set`)) + await expect(call('')).rejects.toThrow(new SdkError(`${paramName} is not set`)) expect(mockFetch).not.toHaveBeenCalled() }) diff --git a/tests/unit-tests/serverApiClientTests.spec.ts b/tests/unit-tests/serverApiClientTests.spec.ts index 1df284b1..456d3b86 100644 --- a/tests/unit-tests/serverApiClientTests.spec.ts +++ b/tests/unit-tests/serverApiClientTests.spec.ts @@ -14,7 +14,7 @@ describe('ServerApiClient', () => { it('should throw error if no token provided', () => { expect(() => { new FingerprintServerApiClient({} as Readonly) - }).toThrow('Api key is not set') + }).toThrow(new SdkError('Api key is not set')) }) it('should support passing custom fetch implementation', async () => { @@ -203,7 +203,7 @@ describe('ServerApiClient', () => { region: 'Global', }) - await expect(client.getEvent(undefined as unknown as string)).rejects.toThrow(new TypeError('eventId is not set')) + await expect(client.getEvent(undefined as unknown as string)).rejects.toThrow(new SdkError('eventId is not set')) }) it('should throw error when using updateEvent if body or eventId is empty', async () => { @@ -213,11 +213,11 @@ describe('ServerApiClient', () => { }) await expect(client.updateEvent('', undefined as unknown as EventUpdate)).rejects.toThrow( - new TypeError('body is not set') + new SdkError('body is not set') ) await expect(client.updateEvent(undefined as unknown as string, { linked_id: '' })).rejects.toThrow( - new TypeError('eventId is not set') + new SdkError('eventId is not set') ) }) @@ -228,7 +228,7 @@ describe('ServerApiClient', () => { }) await expect(client.deleteVisitorData(undefined as unknown as string)).rejects.toThrow( - new TypeError('visitorId is not set') + new SdkError('visitorId is not set') ) }) diff --git a/tests/unit-tests/urlUtilsTests.spec.ts b/tests/unit-tests/urlUtilsTests.spec.ts index a26d8939..7a812fc5 100644 --- a/tests/unit-tests/urlUtilsTests.spec.ts +++ b/tests/unit-tests/urlUtilsTests.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { Region, SearchEventsFilter } from '../../src' +import { Region, SdkError, SearchEventsFilter } from '../../src' import { version } from '../../package.json' import type { paths } from '../../src/generatedApiTypes' import { getRequestPath } from '../../src/urlUtils' @@ -172,7 +172,7 @@ describe('getRequestPath', () => { method: 'get', region: 'unknown' as Region, }) - }).toThrow('Unsupported region') + }).toThrow(new SdkError('Unsupported region')) }) it('throws error when required path param is missing', () => { @@ -182,7 +182,7 @@ describe('getRequestPath', () => { method: 'get', pathParams: [], }) - }).toThrow('Missing path parameter for event_id') + }).toThrow(new SdkError('eventId is not set')) }) it('disallows normalized path segments', () => { @@ -192,7 +192,7 @@ describe('getRequestPath', () => { method: 'get', pathParams: [], }) - }).toThrow('Invalid path: path changed during normalization') + }).toThrow(new SdkError('Invalid path: path changed during normalization')) }) it('encodes special characters', () => { @@ -244,15 +244,15 @@ describe('path parameter encoding', () => { }) it.each([ - ['.', '.', 'Invalid path parameter for event_id: .'], - ['..', '..', 'Invalid path parameter for event_id: ..'], - ['a String object', new String('..'), 'Invalid path parameter for event_id: ..'], - ['an object with a toString', { toString: () => '..' }, 'Invalid path parameter for event_id: ..'], - ['an array', ['..'], 'Invalid path parameter for event_id: ..'], + ['.', '.', 'eventId is not valid: .'], + ['..', '..', 'eventId is not valid: ..'], + ['a String object', new String('..'), 'eventId is not valid: ..'], + ['an object with a toString', { toString: () => '..' }, 'eventId is not valid: ..'], + ['an array', ['..'], 'eventId is not valid: ..'], // A lone surrogate makes `encodeURIComponent` throw a `URIError` - ['a lone surrogate', '\ud800', 'Invalid path parameter for event_id'], + ['a lone surrogate', '\ud800', 'eventId is not valid'], // These have no primitive representation, so `String` itself throws - ['an object without a prototype', Object.create(null), 'Invalid path parameter for event_id'], + ['an object without a prototype', Object.create(null), 'eventId is not valid'], [ 'an object whose toString throws', { @@ -260,10 +260,10 @@ describe('path parameter encoding', () => { throw new Error('boom') }, }, - 'Invalid path parameter for event_id', + 'eventId is not valid', ], ])('rejects %s', (_, param, message) => { - expect(() => eventPath(param)).toThrow(new TypeError(message)) + expect(() => eventPath(param)).toThrow(new SdkError(message)) }) it('preserves the cause when string coercion fails', () => { @@ -276,7 +276,7 @@ describe('path parameter encoding', () => { expect(() => eventPath(param)).toThrow( expect.objectContaining({ - message: 'Invalid path parameter for event_id', + message: 'eventId is not valid', cause, }) ) @@ -288,6 +288,6 @@ describe('path parameter encoding', () => { ['null', null], ['undefined', undefined], ])('rejects %s as missing', (_, param) => { - expect(() => eventPath(param)).toThrow(new TypeError('Missing path parameter for event_id')) + expect(() => eventPath(param)).toThrow(new SdkError('eventId is not set')) }) }) From 0c92eb0f6027af81e71242cfb3314e89d7fd87e1 Mon Sep 17 00:00:00 2001 From: Juraj Uhlar Date: Wed, 23 Sep 2026 11:51:35 +0100 Subject: [PATCH 2/7] fix: throw SdkError from unsealEventsResponse --- .changeset/sdk-error-for-invalid-arguments.md | 2 +- example/unsealResult.mjs | 10 +++++--- readme.md | 25 +++++++++++-------- src/errors/apiErrors.ts | 22 ++++++++++++---- src/errors/unsealError.ts | 12 +++++++-- src/sealedResults.ts | 19 +++++++++----- tests/unit-tests/sealedResults.spec.ts | 25 +++++++++---------- 7 files changed, 75 insertions(+), 40 deletions(-) diff --git a/.changeset/sdk-error-for-invalid-arguments.md b/.changeset/sdk-error-for-invalid-arguments.md index 617a5ec9..774d7b50 100644 --- a/.changeset/sdk-error-for-invalid-arguments.md +++ b/.changeset/sdk-error-for-invalid-arguments.md @@ -2,4 +2,4 @@ '@fingerprint/node-sdk': patch --- -Throw `SdkError` instead of `TypeError` or `Error` for invalid arguments. Messages name the function argument (`eventId is not valid: ..`) instead of a path parameter. +Throw `SdkError` instead of `TypeError` or `Error` for invalid arguments and sealed-result validation failures. `UnsealAggregateError` now extends `SdkError`. Messages name the function argument (`eventId is not valid: ..`) instead of a path parameter. diff --git a/example/unsealResult.mjs b/example/unsealResult.mjs index 1c4b2d7d..5c66e303 100644 --- a/example/unsealResult.mjs +++ b/example/unsealResult.mjs @@ -1,5 +1,5 @@ // @ts-check -import { unsealEventsResponse, DecryptionAlgorithm } from '@fingerprint/node-sdk' +import { unsealEventsResponse, DecryptionAlgorithm, SdkError } from '@fingerprint/node-sdk' import { config } from 'dotenv' config() @@ -19,7 +19,11 @@ try { }, ]) console.log(JSON.stringify(unsealedData, null, 2)) -} catch (e) { - console.error(e) +} catch (error) { + if (error instanceof SdkError) { + console.error(error.message) + } else { + console.error(error) + } process.exit(1) } diff --git a/readme.md b/readme.md index 14f2af3c..cddb60ee 100644 --- a/readme.md +++ b/readme.md @@ -142,7 +142,7 @@ Other `SdkError`s: - `TooManyRequestsError` — a `ServerApiError` thrown when the request is throttled (HTTP 429). - `RequestError` — the base class of `ServerApiError`, thrown when the response doesn't match the Server API error shape (e.g. a proxy error). Its `errorCode` is a free-form `string` placeholder from `statusText`. Since `ServerApiError` extends it, `error instanceof RequestError` catches both. -- `SdkError` — the base of all SDK errors. Thrown directly for invalid arguments, network failures, and malformed responses. +- `SdkError` — the base of all SDK errors. Thrown directly for invalid arguments, network failures, malformed responses, and sealed-result validation failures. `UnsealAggregateError` (every key failed to decrypt) also extends `SdkError`. ### Webhooks @@ -169,19 +169,24 @@ This SDK provides utility methods for decrypting [sealed results](https://docs.f Use the below code to unseal results: ```typescript -import { unsealEventsResponse, DecryptionAlgorithm } from '@fingerprint/node-sdk' +import { unsealEventsResponse, DecryptionAlgorithm, SdkError } from '@fingerprint/node-sdk' const sealedData = process.env.BASE64_SEALED_RESULT const decryptionKey = process.env.BASE64_KEY -const unsealedData = await unsealEventsResponse(Buffer.from(sealedData, 'base64'), [ - { - key: Buffer.from(decryptionKey, 'base64'), - algorithm: DecryptionAlgorithm.Aes256Gcm, - }, -]) - -console.log(JSON.stringify(unsealedData, null, 2)) +try { + const unsealedData = await unsealEventsResponse(Buffer.from(sealedData, 'base64'), [ + { + key: Buffer.from(decryptionKey, 'base64'), + algorithm: DecryptionAlgorithm.Aes256Gcm, + }, + ]) + console.log(JSON.stringify(unsealedData, null, 2)) +} catch (error) { + if (error instanceof SdkError) { + console.error(error.message) + } +} ``` To learn more, refer to the example located in [example/unsealResult.mjs](./example/unsealResult.mjs). diff --git a/src/errors/apiErrors.ts b/src/errors/apiErrors.ts index 8c5ca136..bf432816 100644 --- a/src/errors/apiErrors.ts +++ b/src/errors/apiErrors.ts @@ -1,11 +1,17 @@ import { ErrorCode, ErrorResponse } from '../types' /** - * Base class for all errors thrown by the SDK. + * Base class for errors thrown by the SDK. * - * Thrown directly for invalid arguments, network failures, and malformed - * responses. Subclasses such as {@link RequestError} cover HTTP error - * responses. + * Catch `SdkError` for failures from {@link FingerprintServerApiClient} and + * {@link unsealEventsResponse}. Narrow with {@link RequestError} or + * {@link ServerApiError} for HTTP errors, or {@link UnsealAggregateError} when + * every decryption key failed. + * + * Thrown directly for invalid arguments, network failures, malformed successful + * HTTP responses, and invalid sealed input. HTTP error responses use + * {@link RequestError} and its subclasses ({@link ServerApiError}, + * {@link TooManyRequestsError}). */ export class SdkError extends Error { constructor( @@ -30,6 +36,9 @@ export class SdkError extends Error { * (or one of its subclasses, such as {@link TooManyRequestsError}) is thrown * instead. Those errors narrow {@link errorCode} to the strongly typed * {@link ServerApiError.errorCode}. + * + * A 429 without that shape is still a {@link RequestError}, not + * {@link TooManyRequestsError}. */ export class RequestError extends SdkError { // HTTP Status code @@ -81,7 +90,10 @@ export class ServerApiError extends RequestError { constructor(body: ErrorResponse, response: Response) { diff --git a/src/errors/unsealError.ts b/src/errors/unsealError.ts index 6f38bbd6..53afb0c6 100644 --- a/src/errors/unsealError.ts +++ b/src/errors/unsealError.ts @@ -1,5 +1,10 @@ +import { SdkError } from './apiErrors' import { DecryptionKey } from '../sealedResults' +/** + * Decryption failure for one key. Collected on {@link UnsealAggregateError}; + * not thrown from {@link unsealEventsResponse}. + */ export class UnsealError extends Error { constructor( readonly key: DecryptionKey, @@ -16,10 +21,13 @@ export class UnsealError extends Error { } } -export class UnsealAggregateError extends Error { +/** + * Every decryption key failed. Catch as {@link SdkError}, then narrow here if + * you need per-key {@link UnsealError} details on {@link errors}. + */ +export class UnsealAggregateError extends SdkError { constructor(readonly errors: UnsealError[]) { super('Unable to decrypt sealed data') - this.name = 'UnsealAggregateError' } addError(error: UnsealError) { diff --git a/src/sealedResults.ts b/src/sealedResults.ts index 2dbb4eaa..122c8edb 100644 --- a/src/sealedResults.ts +++ b/src/sealedResults.ts @@ -2,6 +2,7 @@ import { createDecipheriv } from 'crypto' import { inflateRaw } from 'zlib' import { promisify } from 'util' import { Event } from './types' +import { SdkError } from './errors/apiErrors' import { UnsealAggregateError, UnsealError } from './errors/unsealError' import { toError } from './errors/toError' import { Buffer } from 'buffer' @@ -27,10 +28,15 @@ function isEventResponse(data: unknown): data is Event { * @private * */ export function parseEventsResponse(unsealed: string): Event { - const json: unknown = JSON.parse(unsealed) + let json: unknown + try { + json = JSON.parse(unsealed) + } catch (e) { + throw new SdkError('Sealed data is not valid events response', undefined, toError(e)) + } if (!isEventResponse(json)) { - throw new Error('Sealed data is not valid events response') + throw new SdkError('Sealed data is not valid events response') } return json @@ -40,8 +46,9 @@ export function parseEventsResponse(unsealed: string): Event { * Decrypts the sealed response with the provided keys. * The SDK will try to decrypt the result with each key until it succeeds. * To learn more about sealed results visit: https://dev.fingerprint.com/docs/sealed-client-results - * @throws UnsealAggregateError - * @throws Error + * + * @throws {SdkError} Any failure while unsealing. + * {@link UnsealAggregateError} when every decryption key fails. */ export async function unsealEventsResponse(sealedData: Buffer, decryptionKeys: DecryptionKey[]): Promise { const unsealed = await unseal(sealedData, decryptionKeys) @@ -54,7 +61,7 @@ export async function unsealEventsResponse(sealedData: Buffer, decryptionKeys: D * */ export async function unseal(sealedData: Buffer, decryptionKeys: DecryptionKey[]) { if (sealedData.subarray(0, SEALED_HEADER.length).toString('hex') !== SEALED_HEADER.toString('hex')) { - throw new Error('Invalid sealed data header') + throw new SdkError('Invalid sealed data header') } const errors = new UnsealAggregateError([]) @@ -73,7 +80,7 @@ export async function unseal(sealedData: Buffer, decryptionKeys: DecryptionKey[] } default: - throw new Error(`Unsupported decryption algorithm: ${String(decryptionKey.algorithm)}`) + throw new SdkError(`Unsupported decryption algorithm: ${String(decryptionKey.algorithm)}`) } } diff --git a/tests/unit-tests/sealedResults.spec.ts b/tests/unit-tests/sealedResults.spec.ts index 8ae8f0bd..5f7dedac 100644 --- a/tests/unit-tests/sealedResults.spec.ts +++ b/tests/unit-tests/sealedResults.spec.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest' import { DecryptionAlgorithm, parseEventsResponse, + SdkError, UnsealAggregateError, UnsealError, unsealEventsResponse, @@ -11,7 +12,7 @@ describe('Parse events response', () => { it('throws if response is not valid events response', () => { expect(() => { parseEventsResponse('{}') - }).toThrow('Sealed data is not valid events response') + }).toThrow(new SdkError('Sealed data is not valid events response')) }) }) @@ -62,18 +63,14 @@ describe('Unseal event response', () => { 'base64' ) - await expect( - unsealEventsResponse(invalidData, [ - { - key: invalidKey, - algorithm: DecryptionAlgorithm.Aes256Gcm, - }, - { - key: validKey, - algorithm: DecryptionAlgorithm.Aes256Gcm, - }, - ]) - ).rejects.toThrow('Invalid sealed data header') + const error = await unsealEventsResponse(invalidData, [ + { key: invalidKey, algorithm: DecryptionAlgorithm.Aes256Gcm }, + { key: validKey, algorithm: DecryptionAlgorithm.Aes256Gcm }, + ]).catch((e: unknown) => e) + + expect(error).toBeInstanceOf(SdkError) + expect(error).not.toBeInstanceOf(UnsealAggregateError) + expect(error).toMatchObject({ message: 'Invalid sealed data header' }) }) it('throws error if invalid algorithm is provided', async () => { @@ -148,9 +145,11 @@ describe('Unseal event response', () => { const error = await unsealEventsResponse(sealedData, keys).catch((e: unknown) => e) expect(error).toBeInstanceOf(UnsealAggregateError) + expect(error).toBeInstanceOf(SdkError) const { errors } = error as UnsealAggregateError expect(errors).toHaveLength(keys.length) expect(errors.every((e) => e instanceof UnsealError)).toBe(true) + expect(errors.every((e) => e instanceof SdkError)).toBe(false) expect(errors.map((e) => e.key)).toEqual(keys) }) From 2ed09f3e71a61a2126769ca54c9171e7b8b59360 Mon Sep 17 00:00:00 2001 From: Juraj Uhlar Date: Wed, 23 Sep 2026 11:52:27 +0100 Subject: [PATCH 3/7] refactor: rename apiErrors.ts to errors.ts --- src/errors/{apiErrors.ts => errors.ts} | 0 src/errors/unsealError.ts | 2 +- src/index.ts | 2 +- src/sealedResults.ts | 2 +- src/serverApiClient.ts | 2 +- src/urlUtils.ts | 2 +- 6 files changed, 5 insertions(+), 5 deletions(-) rename src/errors/{apiErrors.ts => errors.ts} (100%) diff --git a/src/errors/apiErrors.ts b/src/errors/errors.ts similarity index 100% rename from src/errors/apiErrors.ts rename to src/errors/errors.ts diff --git a/src/errors/unsealError.ts b/src/errors/unsealError.ts index 53afb0c6..882403c3 100644 --- a/src/errors/unsealError.ts +++ b/src/errors/unsealError.ts @@ -1,4 +1,4 @@ -import { SdkError } from './apiErrors' +import { SdkError } from './errors' import { DecryptionKey } from '../sealedResults' /** diff --git a/src/index.ts b/src/index.ts index 2c9f2954..dc5ff620 100644 --- a/src/index.ts +++ b/src/index.ts @@ -14,4 +14,4 @@ export { export * from './sealedResults' export * from './errors/unsealError' export * from './webhook' -export * from './errors/apiErrors' +export * from './errors/errors' diff --git a/src/sealedResults.ts b/src/sealedResults.ts index 122c8edb..9af68040 100644 --- a/src/sealedResults.ts +++ b/src/sealedResults.ts @@ -2,7 +2,7 @@ import { createDecipheriv } from 'crypto' import { inflateRaw } from 'zlib' import { promisify } from 'util' import { Event } from './types' -import { SdkError } from './errors/apiErrors' +import { SdkError } from './errors/errors' import { UnsealAggregateError, UnsealError } from './errors/unsealError' import { toError } from './errors/toError' import { Buffer } from 'buffer' diff --git a/src/serverApiClient.ts b/src/serverApiClient.ts index d42ccb89..b9dcff55 100644 --- a/src/serverApiClient.ts +++ b/src/serverApiClient.ts @@ -9,7 +9,7 @@ import { SearchEventsFilter, SearchEventsResponse, } from './types' -import { ServerApiError, RequestError, SdkError, TooManyRequestsError } from './errors/apiErrors' +import { ServerApiError, RequestError, SdkError, TooManyRequestsError } from './errors/errors' import { isErrorResponse } from './errors/handleErrorResponse' import { toError } from './errors/toError' diff --git a/src/urlUtils.ts b/src/urlUtils.ts index 505a9f21..a8f444a4 100644 --- a/src/urlUtils.ts +++ b/src/urlUtils.ts @@ -1,7 +1,7 @@ import { Region } from './types' import { version } from '../package.json' import { paths } from './generatedApiTypes' -import { SdkError } from './errors/apiErrors' +import { SdkError } from './errors/errors' import { toError } from './errors/toError' const apiVersion = 'v4' From 320b012374c1d1701590b04ef071ebdf2d3b0f9e Mon Sep 17 00:00:00 2001 From: Juraj Uhlar Date: Wed, 23 Sep 2026 14:06:48 +0100 Subject: [PATCH 4/7] fix: keep unseal errors separate from SdkError --- .changeset/sdk-error-for-invalid-arguments.md | 2 +- example/unsealResult.mjs | 10 +++----- readme.md | 25 ++++++++----------- src/errors/errors.ts | 14 ++++------- src/errors/unsealError.ts | 11 ++++---- src/sealedResults.ts | 19 +++++--------- tests/unit-tests/sealedResults.spec.ts | 25 ++++++++++--------- 7 files changed, 44 insertions(+), 62 deletions(-) diff --git a/.changeset/sdk-error-for-invalid-arguments.md b/.changeset/sdk-error-for-invalid-arguments.md index 774d7b50..617a5ec9 100644 --- a/.changeset/sdk-error-for-invalid-arguments.md +++ b/.changeset/sdk-error-for-invalid-arguments.md @@ -2,4 +2,4 @@ '@fingerprint/node-sdk': patch --- -Throw `SdkError` instead of `TypeError` or `Error` for invalid arguments and sealed-result validation failures. `UnsealAggregateError` now extends `SdkError`. Messages name the function argument (`eventId is not valid: ..`) instead of a path parameter. +Throw `SdkError` instead of `TypeError` or `Error` for invalid arguments. Messages name the function argument (`eventId is not valid: ..`) instead of a path parameter. diff --git a/example/unsealResult.mjs b/example/unsealResult.mjs index 5c66e303..1c4b2d7d 100644 --- a/example/unsealResult.mjs +++ b/example/unsealResult.mjs @@ -1,5 +1,5 @@ // @ts-check -import { unsealEventsResponse, DecryptionAlgorithm, SdkError } from '@fingerprint/node-sdk' +import { unsealEventsResponse, DecryptionAlgorithm } from '@fingerprint/node-sdk' import { config } from 'dotenv' config() @@ -19,11 +19,7 @@ try { }, ]) console.log(JSON.stringify(unsealedData, null, 2)) -} catch (error) { - if (error instanceof SdkError) { - console.error(error.message) - } else { - console.error(error) - } +} catch (e) { + console.error(e) process.exit(1) } diff --git a/readme.md b/readme.md index cddb60ee..611709d9 100644 --- a/readme.md +++ b/readme.md @@ -142,7 +142,7 @@ Other `SdkError`s: - `TooManyRequestsError` — a `ServerApiError` thrown when the request is throttled (HTTP 429). - `RequestError` — the base class of `ServerApiError`, thrown when the response doesn't match the Server API error shape (e.g. a proxy error). Its `errorCode` is a free-form `string` placeholder from `statusText`. Since `ServerApiError` extends it, `error instanceof RequestError` catches both. -- `SdkError` — the base of all SDK errors. Thrown directly for invalid arguments, network failures, malformed responses, and sealed-result validation failures. `UnsealAggregateError` (every key failed to decrypt) also extends `SdkError`. +- `SdkError` — the base of Server API client errors. Thrown directly for invalid arguments, network failures, and malformed responses. Unseal failures are `UnsealAggregateError` or `Error`, not `SdkError`. ### Webhooks @@ -169,24 +169,19 @@ This SDK provides utility methods for decrypting [sealed results](https://docs.f Use the below code to unseal results: ```typescript -import { unsealEventsResponse, DecryptionAlgorithm, SdkError } from '@fingerprint/node-sdk' +import { unsealEventsResponse, DecryptionAlgorithm } from '@fingerprint/node-sdk' const sealedData = process.env.BASE64_SEALED_RESULT const decryptionKey = process.env.BASE64_KEY -try { - const unsealedData = await unsealEventsResponse(Buffer.from(sealedData, 'base64'), [ - { - key: Buffer.from(decryptionKey, 'base64'), - algorithm: DecryptionAlgorithm.Aes256Gcm, - }, - ]) - console.log(JSON.stringify(unsealedData, null, 2)) -} catch (error) { - if (error instanceof SdkError) { - console.error(error.message) - } -} +const unsealedData = await unsealEventsResponse(Buffer.from(sealedData, 'base64'), [ + { + key: Buffer.from(decryptionKey, 'base64'), + algorithm: DecryptionAlgorithm.Aes256Gcm, + }, +]) + +console.log(JSON.stringify(unsealedData, null, 2)) ``` To learn more, refer to the example located in [example/unsealResult.mjs](./example/unsealResult.mjs). diff --git a/src/errors/errors.ts b/src/errors/errors.ts index bf432816..71028bbc 100644 --- a/src/errors/errors.ts +++ b/src/errors/errors.ts @@ -1,17 +1,13 @@ import { ErrorCode, ErrorResponse } from '../types' /** - * Base class for errors thrown by the SDK. + * Base class for {@link FingerprintServerApiClient} errors. * - * Catch `SdkError` for failures from {@link FingerprintServerApiClient} and - * {@link unsealEventsResponse}. Narrow with {@link RequestError} or - * {@link ServerApiError} for HTTP errors, or {@link UnsealAggregateError} when - * every decryption key failed. + * Thrown directly for invalid arguments, network failures, and malformed + * successful responses. HTTP error responses use {@link RequestError} and its + * subclasses ({@link ServerApiError}, {@link TooManyRequestsError}). * - * Thrown directly for invalid arguments, network failures, malformed successful - * HTTP responses, and invalid sealed input. HTTP error responses use - * {@link RequestError} and its subclasses ({@link ServerApiError}, - * {@link TooManyRequestsError}). + * Sealed-result decryption uses {@link UnsealAggregateError} and `Error`. */ export class SdkError extends Error { constructor( diff --git a/src/errors/unsealError.ts b/src/errors/unsealError.ts index 882403c3..1595dd51 100644 --- a/src/errors/unsealError.ts +++ b/src/errors/unsealError.ts @@ -1,4 +1,3 @@ -import { SdkError } from './errors' import { DecryptionKey } from '../sealedResults' /** @@ -16,18 +15,20 @@ export class UnsealError extends Error { msg = msg.concat(`: ${error.message}`) } - super(msg) + super(msg, { cause: error }) this.name = 'UnsealError' } } /** - * Every decryption key failed. Catch as {@link SdkError}, then narrow here if - * you need per-key {@link UnsealError} details on {@link errors}. + * Every decryption key failed. Inspect {@link errors} for per-key + * {@link UnsealError} details. Does not extend {@link SdkError}: unseal is + * local crypto, not a Server API request. */ -export class UnsealAggregateError extends SdkError { +export class UnsealAggregateError extends Error { constructor(readonly errors: UnsealError[]) { super('Unable to decrypt sealed data') + this.name = 'UnsealAggregateError' } addError(error: UnsealError) { diff --git a/src/sealedResults.ts b/src/sealedResults.ts index 9af68040..aea2d0b2 100644 --- a/src/sealedResults.ts +++ b/src/sealedResults.ts @@ -2,7 +2,6 @@ import { createDecipheriv } from 'crypto' import { inflateRaw } from 'zlib' import { promisify } from 'util' import { Event } from './types' -import { SdkError } from './errors/errors' import { UnsealAggregateError, UnsealError } from './errors/unsealError' import { toError } from './errors/toError' import { Buffer } from 'buffer' @@ -28,15 +27,10 @@ function isEventResponse(data: unknown): data is Event { * @private * */ export function parseEventsResponse(unsealed: string): Event { - let json: unknown - try { - json = JSON.parse(unsealed) - } catch (e) { - throw new SdkError('Sealed data is not valid events response', undefined, toError(e)) - } + const json: unknown = JSON.parse(unsealed) if (!isEventResponse(json)) { - throw new SdkError('Sealed data is not valid events response') + throw new Error('Sealed data is not valid events response') } return json @@ -46,9 +40,8 @@ export function parseEventsResponse(unsealed: string): Event { * Decrypts the sealed response with the provided keys. * The SDK will try to decrypt the result with each key until it succeeds. * To learn more about sealed results visit: https://dev.fingerprint.com/docs/sealed-client-results - * - * @throws {SdkError} Any failure while unsealing. - * {@link UnsealAggregateError} when every decryption key fails. + * @throws {UnsealAggregateError} When every decryption key fails. + * @throws {Error} Invalid sealed header, unsupported algorithm, or decrypted JSON is not an event. */ export async function unsealEventsResponse(sealedData: Buffer, decryptionKeys: DecryptionKey[]): Promise { const unsealed = await unseal(sealedData, decryptionKeys) @@ -61,7 +54,7 @@ export async function unsealEventsResponse(sealedData: Buffer, decryptionKeys: D * */ export async function unseal(sealedData: Buffer, decryptionKeys: DecryptionKey[]) { if (sealedData.subarray(0, SEALED_HEADER.length).toString('hex') !== SEALED_HEADER.toString('hex')) { - throw new SdkError('Invalid sealed data header') + throw new Error('Invalid sealed data header') } const errors = new UnsealAggregateError([]) @@ -80,7 +73,7 @@ export async function unseal(sealedData: Buffer, decryptionKeys: DecryptionKey[] } default: - throw new SdkError(`Unsupported decryption algorithm: ${String(decryptionKey.algorithm)}`) + throw new Error(`Unsupported decryption algorithm: ${String(decryptionKey.algorithm)}`) } } diff --git a/tests/unit-tests/sealedResults.spec.ts b/tests/unit-tests/sealedResults.spec.ts index 5f7dedac..8ae8f0bd 100644 --- a/tests/unit-tests/sealedResults.spec.ts +++ b/tests/unit-tests/sealedResults.spec.ts @@ -2,7 +2,6 @@ import { describe, expect, it } from 'vitest' import { DecryptionAlgorithm, parseEventsResponse, - SdkError, UnsealAggregateError, UnsealError, unsealEventsResponse, @@ -12,7 +11,7 @@ describe('Parse events response', () => { it('throws if response is not valid events response', () => { expect(() => { parseEventsResponse('{}') - }).toThrow(new SdkError('Sealed data is not valid events response')) + }).toThrow('Sealed data is not valid events response') }) }) @@ -63,14 +62,18 @@ describe('Unseal event response', () => { 'base64' ) - const error = await unsealEventsResponse(invalidData, [ - { key: invalidKey, algorithm: DecryptionAlgorithm.Aes256Gcm }, - { key: validKey, algorithm: DecryptionAlgorithm.Aes256Gcm }, - ]).catch((e: unknown) => e) - - expect(error).toBeInstanceOf(SdkError) - expect(error).not.toBeInstanceOf(UnsealAggregateError) - expect(error).toMatchObject({ message: 'Invalid sealed data header' }) + await expect( + unsealEventsResponse(invalidData, [ + { + key: invalidKey, + algorithm: DecryptionAlgorithm.Aes256Gcm, + }, + { + key: validKey, + algorithm: DecryptionAlgorithm.Aes256Gcm, + }, + ]) + ).rejects.toThrow('Invalid sealed data header') }) it('throws error if invalid algorithm is provided', async () => { @@ -145,11 +148,9 @@ describe('Unseal event response', () => { const error = await unsealEventsResponse(sealedData, keys).catch((e: unknown) => e) expect(error).toBeInstanceOf(UnsealAggregateError) - expect(error).toBeInstanceOf(SdkError) const { errors } = error as UnsealAggregateError expect(errors).toHaveLength(keys.length) expect(errors.every((e) => e instanceof UnsealError)).toBe(true) - expect(errors.every((e) => e instanceof SdkError)).toBe(false) expect(errors.map((e) => e.key)).toEqual(keys) }) From 12b95a668331ee833d8521c037e13a8340893bd1 Mon Sep 17 00:00:00 2001 From: Juraj Uhlar Date: Wed, 23 Sep 2026 14:13:22 +0100 Subject: [PATCH 5/7] chore: revert filename change --- src/errors/{errors.ts => apiErrors.ts} | 0 src/index.ts | 2 +- src/serverApiClient.ts | 2 +- src/urlUtils.ts | 2 +- 4 files changed, 3 insertions(+), 3 deletions(-) rename src/errors/{errors.ts => apiErrors.ts} (100%) diff --git a/src/errors/errors.ts b/src/errors/apiErrors.ts similarity index 100% rename from src/errors/errors.ts rename to src/errors/apiErrors.ts diff --git a/src/index.ts b/src/index.ts index dc5ff620..2c9f2954 100644 --- a/src/index.ts +++ b/src/index.ts @@ -14,4 +14,4 @@ export { export * from './sealedResults' export * from './errors/unsealError' export * from './webhook' -export * from './errors/errors' +export * from './errors/apiErrors' diff --git a/src/serverApiClient.ts b/src/serverApiClient.ts index b9dcff55..d42ccb89 100644 --- a/src/serverApiClient.ts +++ b/src/serverApiClient.ts @@ -9,7 +9,7 @@ import { SearchEventsFilter, SearchEventsResponse, } from './types' -import { ServerApiError, RequestError, SdkError, TooManyRequestsError } from './errors/errors' +import { ServerApiError, RequestError, SdkError, TooManyRequestsError } from './errors/apiErrors' import { isErrorResponse } from './errors/handleErrorResponse' import { toError } from './errors/toError' diff --git a/src/urlUtils.ts b/src/urlUtils.ts index a8f444a4..505a9f21 100644 --- a/src/urlUtils.ts +++ b/src/urlUtils.ts @@ -1,7 +1,7 @@ import { Region } from './types' import { version } from '../package.json' import { paths } from './generatedApiTypes' -import { SdkError } from './errors/errors' +import { SdkError } from './errors/apiErrors' import { toError } from './errors/toError' const apiVersion = 'v4' From aa5593d7a5cf5adbb0609d6f1f26176ef62e2c8b Mon Sep 17 00:00:00 2001 From: Juraj Uhlar Date: Wed, 23 Sep 2026 14:21:08 +0100 Subject: [PATCH 6/7] chore: tweak wording --- .changeset/sdk-error-for-invalid-arguments.md | 2 +- src/errors/apiErrors.ts | 5 ----- src/errors/unsealError.ts | 3 +-- 3 files changed, 2 insertions(+), 8 deletions(-) diff --git a/.changeset/sdk-error-for-invalid-arguments.md b/.changeset/sdk-error-for-invalid-arguments.md index 617a5ec9..75d1a777 100644 --- a/.changeset/sdk-error-for-invalid-arguments.md +++ b/.changeset/sdk-error-for-invalid-arguments.md @@ -2,4 +2,4 @@ '@fingerprint/node-sdk': patch --- -Throw `SdkError` instead of `TypeError` or `Error` for invalid arguments. Messages name the function argument (`eventId is not valid: ..`) instead of a path parameter. +Throw `SdkError` instead of `TypeError` or `Error` for invalid API client arguments. \ No newline at end of file diff --git a/src/errors/apiErrors.ts b/src/errors/apiErrors.ts index 71028bbc..bbc15a62 100644 --- a/src/errors/apiErrors.ts +++ b/src/errors/apiErrors.ts @@ -6,8 +6,6 @@ import { ErrorCode, ErrorResponse } from '../types' * Thrown directly for invalid arguments, network failures, and malformed * successful responses. HTTP error responses use {@link RequestError} and its * subclasses ({@link ServerApiError}, {@link TooManyRequestsError}). - * - * Sealed-result decryption uses {@link UnsealAggregateError} and `Error`. */ export class SdkError extends Error { constructor( @@ -32,9 +30,6 @@ export class SdkError extends Error { * (or one of its subclasses, such as {@link TooManyRequestsError}) is thrown * instead. Those errors narrow {@link errorCode} to the strongly typed * {@link ServerApiError.errorCode}. - * - * A 429 without that shape is still a {@link RequestError}, not - * {@link TooManyRequestsError}. */ export class RequestError extends SdkError { // HTTP Status code diff --git a/src/errors/unsealError.ts b/src/errors/unsealError.ts index 1595dd51..c631ed8f 100644 --- a/src/errors/unsealError.ts +++ b/src/errors/unsealError.ts @@ -22,8 +22,7 @@ export class UnsealError extends Error { /** * Every decryption key failed. Inspect {@link errors} for per-key - * {@link UnsealError} details. Does not extend {@link SdkError}: unseal is - * local crypto, not a Server API request. + * {@link UnsealError} details. */ export class UnsealAggregateError extends Error { constructor(readonly errors: UnsealError[]) { From 950addc0627b52785d7f1a030ff73fd47da8f0f8 Mon Sep 17 00:00:00 2001 From: Juraj Uhlar Date: Wed, 23 Sep 2026 14:48:55 +0100 Subject: [PATCH 7/7] fix: guard missing client options --- src/serverApiClient.ts | 4 +++- tests/unit-tests/serverApiClientTests.spec.ts | 9 ++++++--- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/src/serverApiClient.ts b/src/serverApiClient.ts index d42ccb89..e6fb0c92 100644 --- a/src/serverApiClient.ts +++ b/src/serverApiClient.ts @@ -34,7 +34,9 @@ export class FingerprintServerApiClient implements FingerprintApi { * @param {Options} options - Options for FingerprintJS server API client */ constructor(options: Readonly) { - if (!options.apiKey) { + // Runtime guard for untyped callers even though TypeScript requires options. + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- runtime validation + if (!options?.apiKey) { throw new SdkError('Api key is not set') } diff --git a/tests/unit-tests/serverApiClientTests.spec.ts b/tests/unit-tests/serverApiClientTests.spec.ts index 456d3b86..484e32f0 100644 --- a/tests/unit-tests/serverApiClientTests.spec.ts +++ b/tests/unit-tests/serverApiClientTests.spec.ts @@ -3,7 +3,6 @@ import { RequestError, FingerprintServerApiClient, Region, - Options, EventUpdate, SdkError, ErrorResponse, @@ -11,9 +10,13 @@ import { import { describe, expect, it, vi } from 'vitest' describe('ServerApiClient', () => { - it('should throw error if no token provided', () => { + it.each([ + { name: 'omitted', args: [] }, + { name: 'null', args: [null] }, + { name: 'missing an API key', args: [{}] }, + ])('should throw SdkError if options are $name', ({ args }) => { expect(() => { - new FingerprintServerApiClient({} as Readonly) + Reflect.construct(FingerprintServerApiClient, args) }).toThrow(new SdkError('Api key is not set')) })