Remove navigationRef from provingMachine (#1011)

This commit is contained in:
Leszek Stachowski
2025-09-09 16:21:08 +02:00
committed by GitHub
parent 5de4aa8c9e
commit 99c5612e04
15 changed files with 380 additions and 93 deletions

View File

@@ -21,8 +21,6 @@ export type {
ProofRequest,
RegistrationInput,
RegistrationStatus,
SDKEvent,
SDKEventMap,
ScanMode,
ScanOpts,
ScanResult,
@@ -43,9 +41,11 @@ export type { QRProofOptions } from './qr';
export type { SdkErrorCategory } from './errors';
export { SCANNER_ERROR_CODES, notImplemented, sdkError } from './errors';
export { SdkEvents } from './types/events';
export { SelfClientContext, SelfClientProvider, useSelfClient } from './context';
export { createSelfClient } from './client';
export { createListenersMap, createSelfClient } from './client';
export { defaultConfig } from './config/defaults';

View File

@@ -8,6 +8,7 @@ import { defaultConfig } from './config/defaults';
import { mergeConfig } from './config/merge';
import { notImplemented } from './errors';
import { extractMRZInfo as parseMRZInfo } from './processing/mrz';
import { SDKEvent, SDKEventMap, SdkEvents } from './types/events';
import type {
Adapters,
Config,
@@ -18,15 +19,12 @@ import type {
RegistrationStatus,
ScanOpts,
ScanResult,
SDKEvent,
SDKEventMap,
SelfClient,
Unsubscribe,
ValidationInput,
ValidationResult,
} from './types/public';
import { TrackEventParams } from './types/public';
/**
* Optional adapter implementations used when a consumer does not provide their
* own. These defaults are intentionally minimal no-ops suitable for tests and
@@ -51,6 +49,21 @@ const optionalDefaults: Required<Pick<Adapters, 'storage' | 'clock' | 'logger'>>
const REQUIRED_ADAPTERS = ['auth', 'scanner', 'network', 'crypto', 'documents'] as const;
export const createListenersMap = (): {
map: Map<SDKEvent, Set<(p: any) => void>>;
addListener: <E extends SDKEvent>(event: E, cb: (payload: SDKEventMap[E]) => any) => void;
} => {
const map = new Map<SDKEvent, Set<(p: any) => void>>();
const addListener = <E extends SDKEvent>(event: E, cb: (payload: SDKEventMap[E]) => void) => {
const set = map.get(event) ?? new Set();
set.add(cb as any);
map.set(event, set);
};
return { map, addListener };
};
/**
* Creates a fully configured {@link SelfClient} instance.
*
@@ -58,7 +71,15 @@ const REQUIRED_ADAPTERS = ['auth', 'scanner', 'network', 'crypto', 'documents']
* provided configuration with sensible defaults. Missing optional adapters are
* filled with benign no-op implementations.
*/
export function createSelfClient({ config, adapters }: { config: Config; adapters: Adapters }): SelfClient {
export function createSelfClient({
config,
adapters,
listeners,
}: {
config: Config;
adapters: Adapters;
listeners: Map<SDKEvent, Set<(p: any) => void>>;
}): SelfClient {
const cfg = mergeConfig(defaultConfig, config);
for (const name of REQUIRED_ADAPTERS) {
@@ -66,17 +87,17 @@ export function createSelfClient({ config, adapters }: { config: Config; adapter
}
const _adapters = { ...optionalDefaults, ...adapters };
const listeners = new Map<SDKEvent, Set<(p: any) => void>>();
const _listeners = new Map<SDKEvent, Set<(p: any) => void>>();
function on<E extends SDKEvent>(event: E, cb: (payload: SDKEventMap[E]) => void): Unsubscribe {
const set = listeners.get(event) ?? new Set();
const set = _listeners.get(event) ?? new Set();
set.add(cb as any);
listeners.set(event, set);
_listeners.set(event, set);
return () => set.delete(cb as any);
}
function emit<E extends SDKEvent>(event: E, payload: SDKEventMap[E]): void {
const set = listeners.get(event);
const set = _listeners.get(event);
if (!set) return;
for (const cb of Array.from(set)) {
try {
@@ -87,6 +108,12 @@ export function createSelfClient({ config, adapters }: { config: Config; adapter
}
}
for (const [event, set] of listeners ?? []) {
for (const cb of Array.from(set)) {
on(event, cb);
}
}
async function scanDocument(opts: ScanOpts & { signal?: AbortSignal }): Promise<ScanResult> {
return _adapters.scanner.scan(opts);
}
@@ -114,7 +141,7 @@ export function createSelfClient({ config, adapters }: { config: Config; adapter
if (!adapters.network) throw notImplemented('network');
if (!adapters.crypto) throw notImplemented('crypto');
const timeoutMs = opts.timeoutMs ?? cfg.timeouts?.proofMs ?? defaultConfig.timeouts.proofMs;
void _adapters.clock.sleep(timeoutMs!, opts.signal).then(() => emit('error', new Error('timeout')));
void _adapters.clock.sleep(timeoutMs!, opts.signal).then(() => emit(SdkEvents.ERROR, new Error('timeout')));
return {
id: 'stub',
status: 'pending',

View File

@@ -5,6 +5,7 @@
import { createContext, type PropsWithChildren, useContext, useMemo } from 'react';
import { createSelfClient } from './client';
import { SdkEvents } from './types/events';
import type { Adapters, Config, SelfClient } from './types/public';
/**
@@ -29,6 +30,10 @@ export interface SelfClientProviderProps {
* be replaced with default no-op implementations.
*/
adapters: Adapters;
/**
* Map of event listeners.
*/
listeners: Map<SdkEvents, Set<(p: any) => void>>;
}
export { SelfClientContext };
@@ -40,8 +45,13 @@ export { SelfClientContext };
* Consumers should ensure that `config` and `adapters` are referentially stable
* (e.g. wrapped in `useMemo`) to avoid recreating the client on every render.
*/
export function SelfClientProvider({ config, adapters, children }: PropsWithChildren<SelfClientProviderProps>) {
const client = useMemo(() => createSelfClient({ config, adapters }), [config, adapters]);
export function SelfClientProvider({
config,
adapters,
listeners,
children,
}: PropsWithChildren<SelfClientProviderProps>) {
const client = useMemo(() => createSelfClient({ config, adapters, listeners }), [config, adapters, listeners]);
return <SelfClientContext.Provider value={client}>{children}</SelfClientContext.Provider>;
}

View File

@@ -5,16 +5,18 @@
import type { ReactNode } from 'react';
import { SelfClientProvider } from '../context';
import { SDKEvent } from '../types/events';
import type { Adapters, Config } from '../types/public';
export interface SelfMobileSdkProps {
config: Config;
adapters: Adapters;
listeners: Map<SDKEvent, Set<(p: any) => void>>;
children?: ReactNode;
}
export const SelfMobileSdk = ({ config, adapters, children }: SelfMobileSdkProps) => (
<SelfClientProvider config={config} adapters={adapters}>
export const SelfMobileSdk = ({ config, adapters, listeners, children }: SelfMobileSdkProps) => (
<SelfClientProvider config={config} adapters={adapters} listeners={listeners}>
{children}
</SelfClientProvider>
);

View File

@@ -22,8 +22,6 @@ export type {
ProofRequest,
RegistrationInput,
RegistrationStatus,
SDKEvent,
SDKEventMap,
ScanMode,
ScanOpts,
ScanResult,
@@ -71,13 +69,15 @@ export { PassportCameraScreen } from './components/screens/PassportCameraScreen'
export { QRCodeScreen } from './components/screens/QRCodeScreen';
export { SdkEvents } from './types/events';
// Context and Client
export { SelfClientContext, SelfClientProvider, useSelfClient } from './context';
// Components
export { SelfMobileSdk } from './entry';
export { createSelfClient } from './client';
export { createListenersMap, createSelfClient } from './client';
export { defaultConfig } from './config/defaults';

View File

@@ -0,0 +1,35 @@
// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc.
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
import type { PassportData, Progress } from './public';
export enum SdkEvents {
ERROR = 'ERROR',
PROGRESS = 'PROGRESS',
STATE = 'STATE',
PROVING_PASSPORT_DATA_NOT_FOUND = 'PROVING_PASSPORT_DATA_NOT_FOUND',
PROVING_ACCOUNT_VERIFIED_SUCCESS = 'PROVING_ACCOUNT_VERIFIED_SUCCESS',
PROVING_REGISTER_ERROR_OR_FAILURE = 'PROVING_REGISTER_ERROR_OR_FAILURE',
PROVING_PASSPORT_NOT_SUPPORTED = 'PROVING_PASSPORT_NOT_SUPPORTED',
PROVING_ACCOUNT_RECOVERY_REQUIRED = 'PROVING_ACCOUNT_RECOVERY_REQUIRED',
}
export interface SDKEventMap {
[SdkEvents.PROVING_PASSPORT_DATA_NOT_FOUND]: undefined;
[SdkEvents.PROVING_ACCOUNT_VERIFIED_SUCCESS]: undefined;
[SdkEvents.PROVING_REGISTER_ERROR_OR_FAILURE]: {
hasValidDocument: boolean;
};
[SdkEvents.PROVING_PASSPORT_NOT_SUPPORTED]: {
passportData: PassportData;
};
[SdkEvents.PROVING_ACCOUNT_RECOVERY_REQUIRED]: undefined;
[SdkEvents.PROGRESS]: Progress;
[SdkEvents.STATE]: string;
[SdkEvents.ERROR]: Error;
}
export type SDKEvent = keyof SDKEventMap;

View File

@@ -4,6 +4,8 @@
import type { DocumentCatalog, PassportData } from '@selfxyz/common/utils/types';
import { SDKEvent, SDKEventMap } from './events';
export type { PassportValidationCallbacks } from '../validation/document';
export type { DocumentCatalog, PassportData };
export interface Config {
@@ -134,13 +136,6 @@ export interface RegistrationStatus {
reason?: string;
}
export interface SDKEventMap {
progress: Progress;
state: string;
error: Error;
}
export type SDKEvent = keyof SDKEventMap;
export type ScanMode = 'mrz' | 'nfc' | 'qr';
export type ScanOpts =
@@ -202,8 +197,8 @@ export interface SelfClient {
trackEvent(event: string, payload?: TrackEventParams): void;
getPrivateKey(): Promise<string | null>;
hasPrivateKey(): Promise<boolean>;
on<E extends SDKEvent>(event: E, cb: (payload: SDKEventMap[E]) => void): Unsubscribe;
emit<E extends SDKEvent>(event: E, payload: SDKEventMap[E]): void;
on<E extends SDKEvent>(event: E, cb: (payload?: SDKEventMap[E]) => void): Unsubscribe;
emit<E extends SDKEvent>(event: E, payload?: SDKEventMap[E]): void;
loadDocumentCatalog(): Promise<DocumentCatalog>;
loadDocumentById(id: string): Promise<PassportData | null>;

View File

@@ -9,7 +9,7 @@ import { badCheckDigitsMRZ, expectedMRZResult, invalidMRZ, mockAdapters, sampleM
describe('createSelfClient API', () => {
it('creates a client instance with expected methods', () => {
const client = createSelfClient({ config: {}, adapters: mockAdapters });
const client = createSelfClient({ config: {}, adapters: mockAdapters, listeners: new Map() });
expect(typeof client.extractMRZInfo).toBe('function');
expect(typeof client.registerDocument).toBe('function');
@@ -17,7 +17,7 @@ describe('createSelfClient API', () => {
});
it('parses MRZ data correctly', () => {
const client = createSelfClient({ config: {}, adapters: mockAdapters });
const client = createSelfClient({ config: {}, adapters: mockAdapters, listeners: new Map() });
const info = client.extractMRZInfo(sampleMRZ);
expect(info.documentNumber).toBe(expectedMRZResult.documentNumber);
@@ -28,6 +28,7 @@ describe('createSelfClient API', () => {
const clientWithAllAdapters = createSelfClient({
config: {},
adapters: mockAdapters,
listeners: new Map(),
});
expect(clientWithAllAdapters).toBeDefined();
@@ -35,12 +36,12 @@ describe('createSelfClient API', () => {
});
it('throws MrzParseError for malformed MRZ input', () => {
const client = createSelfClient({ config: {}, adapters: mockAdapters });
const client = createSelfClient({ config: {}, adapters: mockAdapters, listeners: new Map() });
expect(() => client.extractMRZInfo(invalidMRZ)).toThrowError(MrzParseError);
});
it('flags invalid check digits', () => {
const client = createSelfClient({ config: {}, adapters: mockAdapters });
const client = createSelfClient({ config: {}, adapters: mockAdapters, listeners: new Map() });
const info = client.extractMRZInfo(badCheckDigitsMRZ);
expect(info.validation?.overall).toBe(false);
});

View File

@@ -5,8 +5,8 @@
import { describe, expect, it, vi } from 'vitest';
import type { CryptoAdapter, DocumentsAdapter, NetworkAdapter, ScannerAdapter } from '../src';
import { createSelfClient } from '../src/index';
import { AuthAdapter } from '../src/types/public';
import { createListenersMap, createSelfClient, SdkEvents } from '../src/index';
import { AuthAdapter, PassportData } from '../src/types/public';
describe('createSelfClient', () => {
// Test eager validation during client creation
@@ -47,7 +47,11 @@ describe('createSelfClient', () => {
});
it('creates client successfully with all required adapters', () => {
const client = createSelfClient({ config: {}, adapters: { scanner, network, crypto, documents, auth } });
const client = createSelfClient({
config: {},
adapters: { scanner, network, crypto, documents, auth },
listeners: new Map(),
});
expect(client).toBeTruthy();
});
@@ -56,6 +60,7 @@ describe('createSelfClient', () => {
const client = createSelfClient({
config: {},
adapters: { scanner: { scan: scanMock }, network, crypto, documents, auth },
listeners: new Map(),
});
const result = await client.scanDocument({ mode: 'qr' });
expect(result).toEqual({ mode: 'qr', data: 'self://ok' });
@@ -68,6 +73,7 @@ describe('createSelfClient', () => {
const client = createSelfClient({
config: {},
adapters: { scanner: { scan: scanMock }, network, crypto, documents, auth },
listeners: new Map(),
});
await expect(client.scanDocument({ mode: 'qr' })).rejects.toBe(err);
});
@@ -76,7 +82,11 @@ describe('createSelfClient', () => {
const network = { http: { fetch: vi.fn() }, ws: { connect: vi.fn() } } as any;
const crypto = { hash: vi.fn(), sign: vi.fn() } as any;
const scanner = { scan: vi.fn() } as any;
const client = createSelfClient({ config: {}, adapters: { network, crypto, scanner, documents, auth } });
const client = createSelfClient({
config: {},
adapters: { network, crypto, scanner, documents, auth },
listeners: new Map(),
});
const handle = await client.generateProof({ type: 'register', payload: {} });
expect(handle.id).toBe('stub');
expect(handle.status).toBe('pending');
@@ -85,26 +95,49 @@ describe('createSelfClient', () => {
});
it('emits and unsubscribes events', () => {
const client = createSelfClient({ config: {}, adapters: { scanner, network, crypto, documents, auth } });
const cb = vi.fn();
const originalSet = Map.prototype.set;
let eventSet: Set<(p: any) => void> | undefined;
Map.prototype.set = function (key: any, value: any) {
if (key === 'progress') eventSet = value;
return originalSet.call(this, key, value);
};
const unsub = client.on('progress', cb);
Map.prototype.set = originalSet;
const listeners = createListenersMap();
eventSet?.forEach(fn => fn({ step: 'one' }));
expect(cb).toHaveBeenCalledWith({ step: 'one' });
unsub();
eventSet?.forEach(fn => fn({ step: 'two' }));
expect(cb).toHaveBeenCalledTimes(1);
const passportNotSupportedListener = vi.fn();
const accountRecoveryChoiceListener = vi.fn();
const anotherAccountRecoveryChoiceListener = vi.fn();
listeners.addListener(SdkEvents.PROVING_PASSPORT_NOT_SUPPORTED, passportNotSupportedListener);
listeners.addListener(SdkEvents.PROVING_ACCOUNT_RECOVERY_REQUIRED, accountRecoveryChoiceListener);
listeners.addListener(SdkEvents.PROVING_ACCOUNT_RECOVERY_REQUIRED, anotherAccountRecoveryChoiceListener);
const client = createSelfClient({
config: {},
adapters: { scanner, network, crypto, documents, auth },
listeners: listeners.map,
});
client.emit(SdkEvents.PROVING_PASSPORT_NOT_SUPPORTED, { passportData: { mrz: 'test' } as PassportData });
client.emit(SdkEvents.PROVING_ACCOUNT_RECOVERY_REQUIRED);
client.emit(SdkEvents.PROVING_REGISTER_ERROR_OR_FAILURE, { hasValidDocument: true });
expect(accountRecoveryChoiceListener).toHaveBeenCalledTimes(1);
expect(accountRecoveryChoiceListener).toHaveBeenCalledWith(undefined);
expect(anotherAccountRecoveryChoiceListener).toHaveBeenCalledTimes(1);
expect(anotherAccountRecoveryChoiceListener).toHaveBeenCalledWith(undefined);
expect(passportNotSupportedListener).toHaveBeenCalledWith({ passportData: { mrz: 'test' } });
expect(passportNotSupportedListener).toHaveBeenCalledTimes(1);
client.emit(SdkEvents.PROVING_PASSPORT_NOT_SUPPORTED, { passportData: { mrz: 'test' } as PassportData });
client.emit(SdkEvents.PROVING_ACCOUNT_RECOVERY_REQUIRED);
client.emit(SdkEvents.PROVING_REGISTER_ERROR_OR_FAILURE, { hasValidDocument: true });
expect(passportNotSupportedListener).toHaveBeenCalledTimes(2);
expect(accountRecoveryChoiceListener).toHaveBeenCalledTimes(2);
expect(anotherAccountRecoveryChoiceListener).toHaveBeenCalledTimes(2);
});
it('parses MRZ via client', () => {
const client = createSelfClient({ config: {}, adapters: { scanner, network, crypto, documents, auth } });
const client = createSelfClient({
config: {},
adapters: { scanner, network, crypto, documents, auth },
listeners: new Map(),
});
const sample = `P<UTOERIKSSON<<ANNA<MARIA<<<<<<<<<<<<<<<<<<<\nL898902C36UTO7408122F1204159ZE184226B<<<<<10`;
const info = client.extractMRZInfo(sample);
expect(info.documentNumber).toBe('L898902C3');
@@ -112,7 +145,11 @@ describe('createSelfClient', () => {
});
it('returns stub registration status', async () => {
const client = createSelfClient({ config: {}, adapters: { scanner, network, crypto, documents, auth } });
const client = createSelfClient({
config: {},
adapters: { scanner, network, crypto, documents, auth },
listeners: new Map(),
});
await expect(client.registerDocument({} as any)).resolves.toEqual({
registered: false,
reason: 'SELF_REG_STATUS_STUB',
@@ -131,6 +168,7 @@ describe('createSelfClient', () => {
analytics: { trackEvent },
auth: { getPrivateKey: () => Promise.resolve('stubbed-private-key') },
},
listeners: new Map(),
});
client.trackEvent('test_event');
@@ -146,6 +184,7 @@ describe('createSelfClient', () => {
const client = createSelfClient({
config: {},
adapters: { scanner, network, crypto, documents, auth: { getPrivateKey } },
listeners: new Map(),
});
await expect(client.getPrivateKey()).resolves.toBe('stubbed-private-key');
@@ -155,6 +194,7 @@ describe('createSelfClient', () => {
const client = createSelfClient({
config: {},
adapters: { scanner, network, crypto, documents, auth: { getPrivateKey } },
listeners: new Map(),
});
await expect(client.hasPrivateKey()).resolves.toBe(true);
});

View File

@@ -11,6 +11,7 @@ import { createSelfClient, defaultConfig, DocumentsAdapter, loadSelectedDocument
const createMockSelfClientWithDocumentsAdapter = (documentsAdapter: DocumentsAdapter): SelfClient => {
return createSelfClient({
config: defaultConfig,
listeners: new Map(),
adapters: {
auth: {
getPrivateKey: async () => null,

View File

@@ -20,7 +20,7 @@ function Consumer() {
describe('SelfMobileSdk Entry Component', () => {
it('provides client to children and enables MRZ parsing', () => {
render(
<SelfMobileSdk config={{}} adapters={mockAdapters}>
<SelfMobileSdk config={{}} adapters={mockAdapters} listeners={new Map()}>
<Consumer />
</SelfMobileSdk>,
);
@@ -31,7 +31,7 @@ describe('SelfMobileSdk Entry Component', () => {
it('renders children correctly', () => {
const testMessage = 'Test Child Component';
render(
<SelfMobileSdk config={{}} adapters={mockAdapters}>
<SelfMobileSdk config={{}} adapters={mockAdapters} listeners={new Map()}>
<div>{testMessage}</div>
</SelfMobileSdk>,
);

View File

@@ -15,7 +15,7 @@ import { renderHook } from '@testing-library/react';
describe('SelfClientProvider Context', () => {
it('provides client through context with MRZ parsing capability', () => {
const wrapper = ({ children }: { children: ReactNode }) => (
<SelfClientProvider config={{}} adapters={mockAdapters}>
<SelfClientProvider config={{}} adapters={mockAdapters} listeners={new Map()}>
{children}
</SelfClientProvider>
);
@@ -38,8 +38,10 @@ describe('SelfClientProvider Context', () => {
const spy = vi.spyOn(clientModule, 'createSelfClient');
const config = {};
const adapters = mockAdapters;
const listeners = new Map();
const wrapper = ({ children }: { children: ReactNode }) => (
<SelfClientProvider config={config} adapters={adapters}>
<SelfClientProvider config={config} adapters={adapters} listeners={listeners}>
{children}
</SelfClientProvider>
);