From ae8b65be1f6960ea236330cdb0e080d40a238157 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20=C5=BBydek?= Date: Tue, 22 Sep 2026 17:03:40 +0200 Subject: [PATCH 01/10] chore(gradle): bump fingerprint SDK version to 4.1.0 --- sdk/android/fingerprint.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sdk/android/fingerprint.gradle b/sdk/android/fingerprint.gradle index 48a1fe7e..59ec6719 100644 --- a/sdk/android/fingerprint.gradle +++ b/sdk/android/fingerprint.gradle @@ -1,4 +1,4 @@ -ext.fingerprint_native_sdk_version = "[4.0.0, 5.0.0)" +ext.fingerprint_native_sdk_version = "[4.1.0, 5.0.0)" task printFingerprintNativeSDKVersion { doLast { From b144925d1b53ec0cd2b1524b2b5d6bac92fdf197 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20=C5=BBydek?= Date: Tue, 22 Sep 2026 17:45:15 +0200 Subject: [PATCH 02/10] feat(sdk): add `validateTags` utility with tests --- sdk/__tests__/tags.test.ts | 190 +++++++++++++++++++++++++++++++++++ sdk/src/FingerprintClient.ts | 9 +- sdk/src/errors.ts | 21 ++++ sdk/src/index.ts | 10 +- sdk/src/tags.ts | 86 ++++++++++++++++ 5 files changed, 310 insertions(+), 6 deletions(-) create mode 100644 sdk/__tests__/tags.test.ts create mode 100644 sdk/src/tags.ts diff --git a/sdk/__tests__/tags.test.ts b/sdk/__tests__/tags.test.ts new file mode 100644 index 00000000..b9cb49f5 --- /dev/null +++ b/sdk/__tests__/tags.test.ts @@ -0,0 +1,190 @@ +import { validateTags } from '../src/tags' +import { InvalidArgumentError } from '../src' + +class NotJson { + public notJson = true +} + +describe('validateTags', () => { + it('accepts every JSON type, nested to depth', () => { + expect(() => { + validateTags({ + string: 'a', + int: 1, + double: 1.5, + bool: true, + null: null, + list: [1, 'a', null, { nested: true }], + map: { + deep: { + deeper: ['x'], + }, + }, + }) + }).not.toThrow() + }) + + it('accepts an empty root map and a nested empty list', () => { + expect(() => { + validateTags({}) + }).not.toThrow() + expect(() => { + validateTags({ + items: [], + }) + }).not.toThrow() + }) + + it('rejects a nested value with no JSON form', () => { + expect(() => { + validateTags({ value: new NotJson() }) + }).toThrow(InvalidArgumentError) + + try { + validateTags({ value: new NotJson() }) + throw new Error('Expected validateTags to throw') + } catch (error) { + expect(error).toBeInstanceOf(InvalidArgumentError) + if (error instanceof InvalidArgumentError) { + expect(error.message).toContain('JSON-compatible') + } + } + }) + + it('rejects a non-string nested map key', () => { + const nestedMap = new Map([[1, 'a']]) + expect(() => { + validateTags({ nested: nestedMap }) + }).toThrow(InvalidArgumentError) + + try { + validateTags({ nested: nestedMap }) + throw new Error('Expected validateTags to throw') + } catch (error) { + expect(error).toBeInstanceOf(InvalidArgumentError) + if (error instanceof InvalidArgumentError) { + expect(error.message).toContain('Tags must be JSON-compatible, got Map') + } + } + }) + + it('rejects a non-finite number, which has no JSON literal', () => { + expect(() => { + validateTags({ value: Number.NaN }) + }).toThrow(InvalidArgumentError) + expect(() => { + validateTags({ value: Number.POSITIVE_INFINITY }) + }).toThrow(InvalidArgumentError) + expect(() => { + validateTags({ value: Number.NEGATIVE_INFINITY }) + }).toThrow(InvalidArgumentError) + }) + + it('names the path to a rejected value inside a list', () => { + try { + validateTags({ + items: [1, new NotJson()], + }) + throw new Error('Expected validateTags to throw') + } catch (error) { + expect(error).toBeInstanceOf(InvalidArgumentError) + if (error instanceof InvalidArgumentError) { + expect(error.path).toBe("tags['items'][1]") + } + } + }) + + it('names the path to a rejected value inside a nested map', () => { + try { + validateTags({ + outer: { inner: new NotJson() }, + }) + throw new Error('Expected validateTags to throw') + } catch (error) { + expect(error).toBeInstanceOf(InvalidArgumentError) + if (error instanceof InvalidArgumentError) { + expect(error.path).toBe("tags['outer']['inner']") + } + } + }) + + it('rejects a non-string key nested in a list', () => { + const nestedMap = new Map([[2, 'a']]) + try { + validateTags({ + items: [nestedMap], + }) + throw new Error('Expected validateTags to throw') + } catch (error) { + expect(error).toBeInstanceOf(InvalidArgumentError) + if (error instanceof InvalidArgumentError) { + expect(error.path).toBe("tags['items'][0]") + } + } + }) + + it('accepts null, meaning no tags', () => { + expect(() => { + validateTags(null) + }).not.toThrow() + }) + + it('accepts undefined, meaning no tags', () => { + expect(() => { + validateTags(undefined) + }).not.toThrow() + }) + + it('rejects a list that contains itself', () => { + const cyclic: unknown[] = ['a'] + cyclic.push(cyclic) + + try { + validateTags({ items: cyclic }) + throw new Error('Expected validateTags to throw') + } catch (error) { + expect(error).toBeInstanceOf(InvalidArgumentError) + if (error instanceof InvalidArgumentError) { + expect(error.message).toContain('cannot contain themselves') + } + } + }) + + it('rejects a map that contains itself', () => { + const cyclic: Record = {} + cyclic.self = cyclic + + expect(() => { + validateTags(cyclic) + }).toThrow(InvalidArgumentError) + }) + + it('rejects a cycle that closes further down', () => { + const outer: Record = {} + outer.items = [{ back: outer }] + + try { + validateTags(outer) + throw new Error('Expected validateTags to throw') + } catch (error) { + expect(error).toBeInstanceOf(InvalidArgumentError) + if (error instanceof InvalidArgumentError) { + expect(error.path).toBe("tags['items'][0]['back']") + } + } + }) + + it('accepts the same collection twice when it is not a cycle', () => { + const shared = { nested: true } + + expect(() => { + validateTags({ left: shared, right: shared }) + }).not.toThrow() + + expect(() => { + validateTags({ + items: [shared, shared], + }) + }).not.toThrow() + }) +}) diff --git a/sdk/src/FingerprintClient.ts b/sdk/src/FingerprintClient.ts index 4f79960c..2d15bfd7 100644 --- a/sdk/src/FingerprintClient.ts +++ b/sdk/src/FingerprintClient.ts @@ -3,6 +3,7 @@ import RNFingerprint, { type NativeVisitorData } from './specs/NativeRNFingerpri import type { FingerprintClient, FingerprintResponse, GetOptions, StartOptions, TagsValue } from './types' import { unwrapError } from './unwrapError' import { isDefined, isTruthy } from './utils' +import { validateTags } from './tags' const packageVersion = '__VERSION__' @@ -66,11 +67,9 @@ class NativeFingerprintClient implements FingerprintClient { public async get(options?: GetOptions): Promise { try { - const data = await RNFingerprint.getVisitorData( - toNativeTag(options?.tags), - options?.linkedId ?? null, - options?.timeout ?? null - ) + const nativeTags = toNativeTag(options?.tags) + validateTags(nativeTags) + const data = await RNFingerprint.getVisitorData(nativeTags, options?.linkedId ?? null, options?.timeout ?? null) return normalizeResponse(data) } catch (error) { throw unwrapError(error) diff --git a/sdk/src/errors.ts b/sdk/src/errors.ts index 2b421633..cce4b8f5 100644 --- a/sdk/src/errors.ts +++ b/sdk/src/errors.ts @@ -120,3 +120,24 @@ export function isFingerprintError(error: unknown): error is FingerprintError { export function getErrorMessage(error: unknown) { return error instanceof Error ? error.message : String(error) } + +/** + * Validation error thrown when `tags` is invalid. + * + * Same string-keyed map on every platform. No size cap here. The server + * enforces [16 KB](https://docs.fingerprint.com/docs/tagging-information) + * as `payload_too_large`. + */ +export class InvalidArgumentError extends TypeError { + public override readonly name: string + public readonly path?: string + public readonly invalidValue?: unknown + + constructor(invalidValue: unknown, name?: string, message?: string) { + super(message) + this.name = 'ArgumentError' + this.path = name + this.invalidValue = invalidValue + Object.setPrototypeOf(this, InvalidArgumentError.prototype) + } +} diff --git a/sdk/src/index.ts b/sdk/src/index.ts index 3de67011..525ddee1 100644 --- a/sdk/src/index.ts +++ b/sdk/src/index.ts @@ -4,6 +4,14 @@ export * from './FingerprintContext' export * from './useVisitorData' export * from './useFingerprint' -export { FingerprintError, isFingerprintError, FingerprintContextNotAvailableError, ErrorCode } from './errors' +export { + FingerprintError, + isFingerprintError, + FingerprintContextNotAvailableError, + ErrorCode, + InvalidArgumentError, +} from './errors' + +export { validateTags } from './tags' export * from './types' diff --git a/sdk/src/tags.ts b/sdk/src/tags.ts new file mode 100644 index 00000000..0fed5d57 --- /dev/null +++ b/sdk/src/tags.ts @@ -0,0 +1,86 @@ +import { InvalidArgumentError } from './errors' +import { isDefined } from './utils' + +function getTypeName(value: unknown): string { + if (value === null) { + return 'null' + } + if (value === undefined) { + return 'undefined' + } + if (typeof value === 'object') { + if ('constructor' in value && typeof value.constructor === 'function') { + const name = value.constructor.name + if (typeof name === 'string' && name.length > 0 && name !== 'Object') { + return name + } + } + return 'Object' + } + return typeof value +} + +function isPlainObject(value: unknown): value is Record { + if (typeof value !== 'object' || value === null) { + return false + } + const proto: unknown = Object.getPrototypeOf(value) + return proto === null || proto === Object.prototype +} + +function checkNotEnclosing(collection: unknown, path: string, enclosing: unknown[]): void { + if (enclosing.includes(collection)) { + throw new InvalidArgumentError(collection, path, 'Tags cannot contain themselves') + } +} + +function validate(value: unknown, path: string, enclosing: unknown[]): void { + if (value === null || value === undefined || typeof value === 'string' || typeof value === 'boolean') { + return + } + + if (typeof value === 'number') { + if (!Number.isFinite(value)) { + throw new InvalidArgumentError(value, path, 'Tags cannot hold a non-finite number') + } + return + } + + if (Array.isArray(value)) { + checkNotEnclosing(value, path, enclosing) + enclosing.push(value) + for (let index = 0; index < value.length; index++) { + validate(value[index], `${path}[${index.toString()}]`, enclosing) + } + enclosing.pop() + return + } + + if (isPlainObject(value)) { + checkNotEnclosing(value, path, enclosing) + enclosing.push(value) + const symbols = Object.getOwnPropertySymbols(value) + if (symbols.length > 0) { + throw new InvalidArgumentError(symbols[0], path, `Tag map keys must be strings, got ${getTypeName(symbols[0])}`) + } + for (const [key, entryValue] of Object.entries(value)) { + validate(entryValue, `${path}['${key}']`, enclosing) + } + enclosing.pop() + return + } + + throw new InvalidArgumentError(value, path, `Tags must be JSON-compatible, got ${getTypeName(value)}`) +} + +/** + * Throws an {@link InvalidArgumentError} unless `tags` is recursively JSON-compatible. + * + * Root is a string-keyed map or primitive. Values may be string, number, boolean, null, + * array, or nested maps. Rejects non-JSON objects, non-string keys, non-finite numbers, and cycles. + */ +export function validateTags(tags?: unknown): void { + if (isDefined(tags)) { + validate(tags, 'tags', []) + } +} From 8b0dfad2581b033baa4752a93e7d1ac663f29681 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20=C5=BBydek?= Date: Tue, 22 Sep 2026 21:37:37 +0200 Subject: [PATCH 03/10] fix(sdk): handle `visitor_id` as optional in response --- sdk/src/FingerprintClient.ts | 2 +- sdk/src/types.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/sdk/src/FingerprintClient.ts b/sdk/src/FingerprintClient.ts index 2d15bfd7..8d5a9021 100644 --- a/sdk/src/FingerprintClient.ts +++ b/sdk/src/FingerprintClient.ts @@ -38,7 +38,7 @@ function toNativeTag(tag: TagsValue | undefined): CodegenTypes.UnsafeObject | nu function normalizeResponse(data: NativeVisitorData): FingerprintResponse { return { - visitor_id: data.visitorId, + visitor_id: isTruthy(data.visitorId) ? data.visitorId : undefined, event_id: data.eventId, suspect_score: data.suspectScore >= 0 ? data.suspectScore : undefined, sealed_result: isTruthy(data.sealedResult) ? data.sealedResult : null, diff --git a/sdk/src/types.ts b/sdk/src/types.ts index 74169a55..64f25002 100644 --- a/sdk/src/types.ts +++ b/sdk/src/types.ts @@ -208,9 +208,9 @@ export interface GetOptions { */ export interface FingerprintResponse { /** - * The visitor identifier. + * The visitor identifier. `undefined` if [zero-trust-mode](https://docs.fingerprint.com/docs/zero-trust-mode) is enabled. */ - visitor_id: string + visitor_id?: string /** * A unique id associated with the successful identification request. */ From bd5f70a3b508497fd0355e11464846f07e0acff0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20=C5=BBydek?= Date: Wed, 23 Sep 2026 11:42:58 +0200 Subject: [PATCH 04/10] fix(sdk): enforce rejection of nested undefined in tags --- sdk/__tests__/tags.test.ts | 8 ++++++++ sdk/src/tags.ts | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/sdk/__tests__/tags.test.ts b/sdk/__tests__/tags.test.ts index b9cb49f5..af512c4c 100644 --- a/sdk/__tests__/tags.test.ts +++ b/sdk/__tests__/tags.test.ts @@ -135,6 +135,14 @@ describe('validateTags', () => { }).not.toThrow() }) + it('rejects nested undefined, meaning no tags', () => { + expect(() => { + validateTags({ + value: undefined, + }) + }).toThrow(InvalidArgumentError) + }) + it('rejects a list that contains itself', () => { const cyclic: unknown[] = ['a'] cyclic.push(cyclic) diff --git a/sdk/src/tags.ts b/sdk/src/tags.ts index 0fed5d57..4ff838de 100644 --- a/sdk/src/tags.ts +++ b/sdk/src/tags.ts @@ -35,7 +35,7 @@ function checkNotEnclosing(collection: unknown, path: string, enclosing: unknown } function validate(value: unknown, path: string, enclosing: unknown[]): void { - if (value === null || value === undefined || typeof value === 'string' || typeof value === 'boolean') { + if (value === null || typeof value === 'string' || typeof value === 'boolean') { return } From 26604485ea44eff011cf9fec965c85d4d1ea9493 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20=C5=BBydek?= Date: Wed, 23 Sep 2026 11:44:26 +0200 Subject: [PATCH 05/10] fix(sdk): make `visitor_id` optional in zero-trust mode --- README.md | 2 +- sdk/README.md | 2 +- sdk/src/FingerprintClient.web.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 7e3071ce..4f428edb 100644 --- a/README.md +++ b/README.md @@ -284,7 +284,7 @@ The response is a flat, snake_case object that matches the Fingerprint Server AP ```typescript interface FingerprintResponse { - visitor_id: string + visitor_id?: string // `undefined` if [zero-trust-mode](https://docs.fingerprint.com/docs/zero-trust-mode) is enabled event_id: string suspect_score?: number // present only when Smart Signals are enabled sealed_result: string | null // base64 sealed result, or null when unavailable diff --git a/sdk/README.md b/sdk/README.md index 7e3071ce..4f428edb 100644 --- a/sdk/README.md +++ b/sdk/README.md @@ -284,7 +284,7 @@ The response is a flat, snake_case object that matches the Fingerprint Server AP ```typescript interface FingerprintResponse { - visitor_id: string + visitor_id?: string // `undefined` if [zero-trust-mode](https://docs.fingerprint.com/docs/zero-trust-mode) is enabled event_id: string suspect_score?: number // present only when Smart Signals are enabled sealed_result: string | null // base64 sealed result, or null when unavailable diff --git a/sdk/src/FingerprintClient.web.ts b/sdk/src/FingerprintClient.web.ts index 09ba42a8..017262d7 100644 --- a/sdk/src/FingerprintClient.web.ts +++ b/sdk/src/FingerprintClient.web.ts @@ -36,7 +36,7 @@ class WebFingerprintClient implements FingerprintClient { return { // `visitor_id` is only omitted by the backend in zeroTrust mode; default to '' to keep the // cross-platform shape stable. - visitor_id: result.visitor_id ?? '', + visitor_id: result.visitor_id ?? undefined, event_id: result.event_id, suspect_score: result.suspect_score, sealed_result: result.sealed_result ? result.sealed_result.base64() : null, From cab66a15948117a94175c03d302bf7f908b43023 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20=C5=BBydek?= Date: Wed, 23 Sep 2026 11:45:48 +0200 Subject: [PATCH 06/10] fix(sdk): reorder tag validation in `get` method --- sdk/src/FingerprintClient.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/sdk/src/FingerprintClient.ts b/sdk/src/FingerprintClient.ts index 8d5a9021..cc7b2b78 100644 --- a/sdk/src/FingerprintClient.ts +++ b/sdk/src/FingerprintClient.ts @@ -66,9 +66,9 @@ class NativeFingerprintClient implements FingerprintClient { } public async get(options?: GetOptions): Promise { + validateTags(options?.tags) + const nativeTags = toNativeTag(options?.tags) try { - const nativeTags = toNativeTag(options?.tags) - validateTags(nativeTags) const data = await RNFingerprint.getVisitorData(nativeTags, options?.linkedId ?? null, options?.timeout ?? null) return normalizeResponse(data) } catch (error) { From f040dd4c1ad6ca2570751c82795f9451bcb39820 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20=C5=BBydek?= Date: Wed, 23 Sep 2026 12:35:08 +0200 Subject: [PATCH 07/10] refactor: simplify tags validation --- sdk/__tests__/tags.test.ts | 195 ++----------------------------------- sdk/src/tags.ts | 94 ++++++------------ 2 files changed, 38 insertions(+), 251 deletions(-) diff --git a/sdk/__tests__/tags.test.ts b/sdk/__tests__/tags.test.ts index af512c4c..1f890468 100644 --- a/sdk/__tests__/tags.test.ts +++ b/sdk/__tests__/tags.test.ts @@ -1,198 +1,19 @@ import { validateTags } from '../src/tags' -import { InvalidArgumentError } from '../src' - -class NotJson { - public notJson = true -} describe('validateTags', () => { - it('accepts every JSON type, nested to depth', () => { - expect(() => { - validateTags({ - string: 'a', - int: 1, - double: 1.5, - bool: true, - null: null, - list: [1, 'a', null, { nested: true }], - map: { - deep: { - deeper: ['x'], - }, - }, - }) - }).not.toThrow() - }) - - it('accepts an empty root map and a nested empty list', () => { - expect(() => { - validateTags({}) - }).not.toThrow() - expect(() => { - validateTags({ - items: [], - }) - }).not.toThrow() - }) - - it('rejects a nested value with no JSON form', () => { - expect(() => { - validateTags({ value: new NotJson() }) - }).toThrow(InvalidArgumentError) - - try { - validateTags({ value: new NotJson() }) - throw new Error('Expected validateTags to throw') - } catch (error) { - expect(error).toBeInstanceOf(InvalidArgumentError) - if (error instanceof InvalidArgumentError) { - expect(error.message).toContain('JSON-compatible') - } - } - }) - - it('rejects a non-string nested map key', () => { - const nestedMap = new Map([[1, 'a']]) - expect(() => { - validateTags({ nested: nestedMap }) - }).toThrow(InvalidArgumentError) - - try { - validateTags({ nested: nestedMap }) - throw new Error('Expected validateTags to throw') - } catch (error) { - expect(error).toBeInstanceOf(InvalidArgumentError) - if (error instanceof InvalidArgumentError) { - expect(error.message).toContain('Tags must be JSON-compatible, got Map') - } - } - }) - - it('rejects a non-finite number, which has no JSON literal', () => { - expect(() => { - validateTags({ value: Number.NaN }) - }).toThrow(InvalidArgumentError) - expect(() => { - validateTags({ value: Number.POSITIVE_INFINITY }) - }).toThrow(InvalidArgumentError) - expect(() => { - validateTags({ value: Number.NEGATIVE_INFINITY }) - }).toThrow(InvalidArgumentError) - }) - - it('names the path to a rejected value inside a list', () => { - try { - validateTags({ - items: [1, new NotJson()], - }) - throw new Error('Expected validateTags to throw') - } catch (error) { - expect(error).toBeInstanceOf(InvalidArgumentError) - if (error instanceof InvalidArgumentError) { - expect(error.path).toBe("tags['items'][1]") - } - } - }) - - it('names the path to a rejected value inside a nested map', () => { - try { - validateTags({ - outer: { inner: new NotJson() }, - }) - throw new Error('Expected validateTags to throw') - } catch (error) { - expect(error).toBeInstanceOf(InvalidArgumentError) - if (error instanceof InvalidArgumentError) { - expect(error.path).toBe("tags['outer']['inner']") - } - } - }) - - it('rejects a non-string key nested in a list', () => { - const nestedMap = new Map([[2, 'a']]) - try { - validateTags({ - items: [nestedMap], - }) - throw new Error('Expected validateTags to throw') - } catch (error) { - expect(error).toBeInstanceOf(InvalidArgumentError) - if (error instanceof InvalidArgumentError) { - expect(error.path).toBe("tags['items'][0]") - } - } - }) - - it('accepts null, meaning no tags', () => { - expect(() => { - validateTags(null) - }).not.toThrow() - }) - - it('accepts undefined, meaning no tags', () => { - expect(() => { - validateTags(undefined) - }).not.toThrow() - }) - - it('rejects nested undefined, meaning no tags', () => { - expect(() => { - validateTags({ - value: undefined, - }) - }).toThrow(InvalidArgumentError) - }) - - it('rejects a list that contains itself', () => { - const cyclic: unknown[] = ['a'] - cyclic.push(cyclic) - - try { - validateTags({ items: cyclic }) - throw new Error('Expected validateTags to throw') - } catch (error) { - expect(error).toBeInstanceOf(InvalidArgumentError) - if (error instanceof InvalidArgumentError) { - expect(error.message).toContain('cannot contain themselves') - } - } - }) - - it('rejects a map that contains itself', () => { - const cyclic: Record = {} - cyclic.self = cyclic - + it.each([Infinity, -Infinity, NaN])('rejects invalid numeric values', (value) => { expect(() => { - validateTags(cyclic) - }).toThrow(InvalidArgumentError) + validateTags(value) + }).toThrow(new TypeError('tags must be a finite number')) }) - it('rejects a cycle that closes further down', () => { - const outer: Record = {} - outer.items = [{ back: outer }] - - try { - validateTags(outer) - throw new Error('Expected validateTags to throw') - } catch (error) { - expect(error).toBeInstanceOf(InvalidArgumentError) - if (error instanceof InvalidArgumentError) { - expect(error.path).toBe("tags['items'][0]['back']") - } - } - }) - - it('accepts the same collection twice when it is not a cycle', () => { - const shared = { nested: true } - - expect(() => { - validateTags({ left: shared, right: shared }) - }).not.toThrow() - + it.each([Infinity, -Infinity, NaN])('rejects nested invalid numeric values', (value) => { expect(() => { validateTags({ - items: [shared, shared], + value: { + numeric: value, + }, }) - }).not.toThrow() + }).toThrow(new TypeError("tags['value']['numeric'] must be a finite number")) }) }) diff --git a/sdk/src/tags.ts b/sdk/src/tags.ts index 4ff838de..049d599f 100644 --- a/sdk/src/tags.ts +++ b/sdk/src/tags.ts @@ -1,86 +1,52 @@ -import { InvalidArgumentError } from './errors' -import { isDefined } from './utils' - -function getTypeName(value: unknown): string { - if (value === null) { - return 'null' - } - if (value === undefined) { - return 'undefined' - } - if (typeof value === 'object') { - if ('constructor' in value && typeof value.constructor === 'function') { - const name = value.constructor.name - if (typeof name === 'string' && name.length > 0 && name !== 'Object') { - return name - } - } - return 'Object' - } - return typeof value -} - -function isPlainObject(value: unknown): value is Record { - if (typeof value !== 'object' || value === null) { - return false - } - const proto: unknown = Object.getPrototypeOf(value) - return proto === null || proto === Object.prototype -} +/** + * Rejects NaN / Infinity in `tags`. + * + * `TagsPrimitive` is `number`, so `{ score: Number('oops') }` type-checks. + * JSON has no literal for those values. Each platform rewrites them + * differently (`null` on web, `0` on iOS via `NSNumber.intValue`, usually + * `null` on Android) and identification still succeeds. Throw instead of + * storing the wrong tag. + * + * `Date`, `Map`, and class instances are already a type error on `TagsValue`. + * This walk does not reject them. The RN bridge and JS agent do not agree on + * those values (a `Date` is an ISO string on web and `{}` on native), but we + * choose to ignore that. + * + * Size is a server `payload_too_large`. + * https://docs.fingerprint.com/docs/tagging-information + */ -function checkNotEnclosing(collection: unknown, path: string, enclosing: unknown[]): void { - if (enclosing.includes(collection)) { - throw new InvalidArgumentError(collection, path, 'Tags cannot contain themselves') +export function validateTags(tags?: unknown): void { + if (tags === null || tags === undefined) { + return } + walk(tags, 'tags') } -function validate(value: unknown, path: string, enclosing: unknown[]): void { - if (value === null || typeof value === 'string' || typeof value === 'boolean') { +function walk(value: unknown, path: string): void { + if (value === null || value === undefined) { return } if (typeof value === 'number') { if (!Number.isFinite(value)) { - throw new InvalidArgumentError(value, path, 'Tags cannot hold a non-finite number') + throw new TypeError(`${path} must be a finite number`) } return } - if (Array.isArray(value)) { - checkNotEnclosing(value, path, enclosing) - enclosing.push(value) - for (let index = 0; index < value.length; index++) { - validate(value[index], `${path}[${index.toString()}]`, enclosing) - } - enclosing.pop() + if (typeof value !== 'object') { return } - if (isPlainObject(value)) { - checkNotEnclosing(value, path, enclosing) - enclosing.push(value) - const symbols = Object.getOwnPropertySymbols(value) - if (symbols.length > 0) { - throw new InvalidArgumentError(symbols[0], path, `Tag map keys must be strings, got ${getTypeName(symbols[0])}`) - } - for (const [key, entryValue] of Object.entries(value)) { - validate(entryValue, `${path}['${key}']`, enclosing) + if (Array.isArray(value)) { + for (const [index, entry] of value.entries()) { + walk(entry, `${path}[${String(index)}]`) } - enclosing.pop() return } - throw new InvalidArgumentError(value, path, `Tags must be JSON-compatible, got ${getTypeName(value)}`) -} - -/** - * Throws an {@link InvalidArgumentError} unless `tags` is recursively JSON-compatible. - * - * Root is a string-keyed map or primitive. Values may be string, number, boolean, null, - * array, or nested maps. Rejects non-JSON objects, non-string keys, non-finite numbers, and cycles. - */ -export function validateTags(tags?: unknown): void { - if (isDefined(tags)) { - validate(tags, 'tags', []) + for (const [key, entry] of Object.entries(value)) { + walk(entry, `${path}['${key}']`) } } From 9dc092d38418bb8191f763187954cfd6673da1ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20=C5=BBydek?= Date: Wed, 23 Sep 2026 12:37:57 +0200 Subject: [PATCH 08/10] refactor(sdk): remove unused `InvalidArgumentError` class --- sdk/src/errors.ts | 21 --------------------- sdk/src/index.ts | 8 +------- 2 files changed, 1 insertion(+), 28 deletions(-) diff --git a/sdk/src/errors.ts b/sdk/src/errors.ts index cce4b8f5..2b421633 100644 --- a/sdk/src/errors.ts +++ b/sdk/src/errors.ts @@ -120,24 +120,3 @@ export function isFingerprintError(error: unknown): error is FingerprintError { export function getErrorMessage(error: unknown) { return error instanceof Error ? error.message : String(error) } - -/** - * Validation error thrown when `tags` is invalid. - * - * Same string-keyed map on every platform. No size cap here. The server - * enforces [16 KB](https://docs.fingerprint.com/docs/tagging-information) - * as `payload_too_large`. - */ -export class InvalidArgumentError extends TypeError { - public override readonly name: string - public readonly path?: string - public readonly invalidValue?: unknown - - constructor(invalidValue: unknown, name?: string, message?: string) { - super(message) - this.name = 'ArgumentError' - this.path = name - this.invalidValue = invalidValue - Object.setPrototypeOf(this, InvalidArgumentError.prototype) - } -} diff --git a/sdk/src/index.ts b/sdk/src/index.ts index 525ddee1..51cdb9b1 100644 --- a/sdk/src/index.ts +++ b/sdk/src/index.ts @@ -4,13 +4,7 @@ export * from './FingerprintContext' export * from './useVisitorData' export * from './useFingerprint' -export { - FingerprintError, - isFingerprintError, - FingerprintContextNotAvailableError, - ErrorCode, - InvalidArgumentError, -} from './errors' +export { FingerprintError, isFingerprintError, FingerprintContextNotAvailableError, ErrorCode } from './errors' export { validateTags } from './tags' From 426fb5c8ab4e85ef45dbab27e113e039a783bc1d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20=C5=BBydek?= Date: Wed, 23 Sep 2026 13:21:03 +0200 Subject: [PATCH 09/10] fix(sdk): update default error code to `unknown_error` --- sdk/ios/FPError+React.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/sdk/ios/FPError+React.swift b/sdk/ios/FPError+React.swift index 30d87d89..a41bc314 100644 --- a/sdk/ios/FPError+React.swift +++ b/sdk/ios/FPError+React.swift @@ -72,10 +72,10 @@ extension APIError { // The SDK's `APIError.Code` has no explicit raw values, so `rawValue` is the camelCase case name // (e.g. `tooManyRequests`). Map it to the canonical snake_case codes shared with Android and // `@fingerprint/agent` so `FingerprintError.code` is identical across platforms. Falls back to - // the generic `failed` code when the server error carries no recognized code. + // the generic `unknown_error` code when the server error carries no recognized code. var reactCode: String { guard let code = self.errorDetails?.code else { - return "failed" + return "unknown_error" } switch code { case .requestCannotBeParsed: return "request_cannot_be_parsed" From 5b2a3387b70d75dc524a35c4659b7d4c449b293e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20=C5=BBydek?= Date: Wed, 23 Sep 2026 15:45:17 +0200 Subject: [PATCH 10/10] docs(sdk): clarify single client creation requirement --- README.md | 4 ++++ sdk/README.md | 4 ++++ sdk/src/FingerprintClient.ts | 4 ++++ sdk/src/FingerprintProvider.tsx | 4 ++++ 4 files changed, 16 insertions(+) diff --git a/README.md b/README.md index 4f428edb..9077bc86 100644 --- a/README.md +++ b/README.md @@ -187,6 +187,8 @@ To identify visitors, you need a Fingerprint Pro account (you can [sign up for f Configure the SDK by wrapping your application in `FingerprintProvider`. +> ⚠️ **Important**: Applications should create only one client across the entire app, either through `FingerprintProvider` or through the API client (`start()`). If you create another client, it will overwrite the underlying native client of the first one. + ```javascript // src/index.js import React from 'react'; @@ -249,6 +251,8 @@ export default function App() { Create a client with `start()` and call `get()`: +> ⚠️ **Important**: Applications should create only one client across the entire app, either through `FingerprintProvider` or through the API client (`start()`). If you create another client, it will overwrite the underlying native client of the first one. + ```javascript import React, { useEffect } from 'react'; import { start } from '@fingerprint/react-native'; diff --git a/sdk/README.md b/sdk/README.md index 4f428edb..9077bc86 100644 --- a/sdk/README.md +++ b/sdk/README.md @@ -187,6 +187,8 @@ To identify visitors, you need a Fingerprint Pro account (you can [sign up for f Configure the SDK by wrapping your application in `FingerprintProvider`. +> ⚠️ **Important**: Applications should create only one client across the entire app, either through `FingerprintProvider` or through the API client (`start()`). If you create another client, it will overwrite the underlying native client of the first one. + ```javascript // src/index.js import React from 'react'; @@ -249,6 +251,8 @@ export default function App() { Create a client with `start()` and call `get()`: +> ⚠️ **Important**: Applications should create only one client across the entire app, either through `FingerprintProvider` or through the API client (`start()`). If you create another client, it will overwrite the underlying native client of the first one. + ```javascript import React, { useEffect } from 'react'; import { start } from '@fingerprint/react-native'; diff --git a/sdk/src/FingerprintClient.ts b/sdk/src/FingerprintClient.ts index cc7b2b78..5255b763 100644 --- a/sdk/src/FingerprintClient.ts +++ b/sdk/src/FingerprintClient.ts @@ -80,6 +80,10 @@ class NativeFingerprintClient implements FingerprintClient { /** * Creates a Fingerprint client with the given options. * + * Applications should create only one client: either through the API client (`start()`) + * or through a single `FingerprintProvider`. Creating another client will overwrite the underlying native client of the + * previous one. + * * @group API Client approach * * @example diff --git a/sdk/src/FingerprintProvider.tsx b/sdk/src/FingerprintProvider.tsx index 24b34f35..3a5f5481 100644 --- a/sdk/src/FingerprintProvider.tsx +++ b/sdk/src/FingerprintProvider.tsx @@ -8,6 +8,10 @@ import { usePromiseStore } from './usePromiseStore' /** * Provides the {@link FingerprintContext} to its child components. * + * Applications should create only one client: either through a single `FingerprintProvider` + * at the root of the component tree or through the API client (`start()`). Creating another + * client will overwrite the underlying native client of the previous one. + * * @group Hooks approach * * @example