Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 0 additions & 4 deletions packages/mobile-sdk-alpha/src/browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,6 @@ export type {
MRZValidation,
NetworkAdapter,
Progress,
ProofHandle,
ProofRequest,
RegistrationInput,
RegistrationStatus,
ScanMode,
ScanOpts,
ScanResult,
Expand Down
92 changes: 35 additions & 57 deletions packages/mobile-sdk-alpha/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,34 +8,16 @@ 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,
Progress,
ProofHandle,
ProofRequest,
RegistrationInput,
RegistrationStatus,
ScanOpts,
ScanResult,
SelfClient,
Unsubscribe,
ValidationInput,
ValidationResult,
} from './types/public';
import { SDKEvent, SDKEventMap } from './types/events';
import type { Adapters, Config, ScanOpts, ScanResult, SelfClient, Unsubscribe, ValidationInput, ValidationResult } from './types/public';
import { TrackEventParams } from './types/public';
import { isPassportDataValid } from './validation/document';
/**
* Optional adapter implementations used when a consumer does not provide their
* own. These defaults are intentionally minimal no-ops suitable for tests and
* non-production environments.
*/
const optionalDefaults: Required<Pick<Adapters, 'storage' | 'clock' | 'logger'>> = {
storage: {
get: async () => null,
set: async () => {},
remove: async () => {},
},
const optionalDefaults: Required<Pick<Adapters, 'clock' | 'logger'>> = {
clock: {
now: () => Date.now(),
sleep: async (ms: number) => {
Expand Down Expand Up @@ -115,39 +97,14 @@ export function createSelfClient({
}

async function scanDocument(opts: ScanOpts & { signal?: AbortSignal }): Promise<ScanResult> {
return _adapters.scanner.scan(opts);
}

async function validateDocument(_input: ValidationInput): Promise<ValidationResult> {
return { ok: false, reason: 'SELF_ERR_VALIDATION_STUB' };
}

async function checkRegistration(_input: RegistrationInput): Promise<RegistrationStatus> {
return { registered: false, reason: 'SELF_REG_STATUS_STUB' };
}

async function registerDocument(_input: RegistrationInput): Promise<RegistrationStatus> {
return { registered: false, reason: 'SELF_REG_STATUS_STUB' };
}
// Apply scanner timeout from config if no signal provided
if (!opts.signal && cfg.timeouts.scanMs) {
const controller = new AbortController();
setTimeout(() => controller.abort(), cfg.timeouts.scanMs);
return _adapters.scanner.scan({ ...opts, signal: controller.signal });
}

async function generateProof(
_req: ProofRequest,
opts: {
signal?: AbortSignal;
onProgress?: (p: Progress) => void;
timeoutMs?: number;
} = {},
): Promise<ProofHandle> {
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(SdkEvents.ERROR, new Error('timeout')));
return {
id: 'stub',
status: 'pending',
result: async () => ({ ok: false, reason: 'SELF_ERR_PROOF_STUB' }),
cancel: () => {},
};
return _adapters.scanner.scan(opts);
Comment on lines +99 to +106
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue

Prevent leaked timers and late aborts in scanDocument

The timeout-created AbortController is never cleaned up. If scan resolves fast, the pending timer still fires and aborts after completion, which can cause adapter-side races and needless wakeups. Wrap with try/finally and clear the timer.

Apply this diff:

-  // Apply scanner timeout from config if no signal provided
-  if (!opts.signal && cfg.timeouts.scanMs) {
-    const controller = new AbortController();
-    setTimeout(() => controller.abort(), cfg.timeouts.scanMs);
-    return _adapters.scanner.scan({ ...opts, signal: controller.signal });
-  }
-
-  return _adapters.scanner.scan(opts);
+  // Apply scanner timeout from config if no signal provided
+  if (!opts.signal && cfg.timeouts.scanMs) {
+    const controller = new AbortController();
+    const timer = setTimeout(() => controller.abort(), cfg.timeouts.scanMs);
+    try {
+      return await _adapters.scanner.scan({ ...opts, signal: controller.signal });
+    } finally {
+      clearTimeout(timer);
+    }
+  }
+
+  return _adapters.scanner.scan(opts);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Apply scanner timeout from config if no signal provided
if (!opts.signal && cfg.timeouts.scanMs) {
const controller = new AbortController();
setTimeout(() => controller.abort(), cfg.timeouts.scanMs);
return _adapters.scanner.scan({ ...opts, signal: controller.signal });
}
async function generateProof(
_req: ProofRequest,
opts: {
signal?: AbortSignal;
onProgress?: (p: Progress) => void;
timeoutMs?: number;
} = {},
): Promise<ProofHandle> {
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(SdkEvents.ERROR, new Error('timeout')));
return {
id: 'stub',
status: 'pending',
result: async () => ({ ok: false, reason: 'SELF_ERR_PROOF_STUB' }),
cancel: () => {},
};
return _adapters.scanner.scan(opts);
// Apply scanner timeout from config if no signal provided
if (!opts.signal && cfg.timeouts.scanMs) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), cfg.timeouts.scanMs);
try {
return await _adapters.scanner.scan({ ...opts, signal: controller.signal });
} finally {
clearTimeout(timer);
}
}
return _adapters.scanner.scan(opts);
🤖 Prompt for AI Agents
In packages/mobile-sdk-alpha/src/client.ts around lines 100 to 107, the
AbortController timer created for scan timeout is never cleared which can fire
after scan resolves; change the logic to create the controller and timer, call
the scanner with await (no early return), and wrap the await in try/finally
where finally calls clearTimeout(timerId) so the timer is cancelled if scan
completes early (leave the abort() only in the timer handler so it only runs if
not cleared).

}

async function trackEvent(event: string, payload?: TrackEventParams): Promise<void> {
Expand Down Expand Up @@ -175,15 +132,36 @@ export function createSelfClient({
}
}

async function validateDocument(input: ValidationInput): Promise<ValidationResult> {
try {
const { scan } = input;

if (scan.mode !== 'nfc') {
return { ok: false, reason: 'Only NFC scan results can be validated' };
}

const isValid = isPassportDataValid(scan.passportData);

if (isValid) {
return { ok: true };
} else {
return { ok: false, reason: 'Document validation failed' };
}
} catch (error) {
_adapters.logger.log('error', 'Document validation error', { error });
return {
ok: false,
reason: error instanceof Error ? error.message : 'Unknown validation error'
};
}
}

return {
scanDocument,
validateDocument,
trackEvent,
getPrivateKey,
hasPrivateKey,
checkRegistration,
registerDocument,
generateProof,
extractMRZInfo: parseMRZInfo,
on,
emit,
Expand Down
5 changes: 2 additions & 3 deletions packages/mobile-sdk-alpha/src/config/defaults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,7 @@
import type { Config } from '../types/public';

export const defaultConfig: Required<Config> = {
endpoints: { api: '', teeWs: '', artifactsCdn: '' },
timeouts: { httpMs: 30000, wsMs: 60000, scanMs: 60000, proofMs: 120000 },
timeouts: { scanMs: 60000 },
// in future this can be used to enable/disable experimental features
features: {},
tlsPinning: { enabled: false },
};
2 changes: 0 additions & 2 deletions packages/mobile-sdk-alpha/src/config/merge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,7 @@ export function mergeConfig(base: Required<Config>, override: Config): Required<
return {
...base,
...override,
endpoints: { ...base.endpoints, ...(override.endpoints ?? {}) },
timeouts: { ...base.timeouts, ...(override.timeouts ?? {}) },
features: { ...base.features, ...(override.features ?? {}) },
tlsPinning: { ...base.tlsPinning, ...(override.tlsPinning ?? {}) },
};
}
4 changes: 0 additions & 4 deletions packages/mobile-sdk-alpha/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,6 @@ export type {
MRZValidation,
NetworkAdapter,
Progress,
ProofHandle,
ProofRequest,
RegistrationInput,
RegistrationStatus,
ScanMode,
ScanOpts,
ScanResult,
Expand Down
54 changes: 52 additions & 2 deletions packages/mobile-sdk-alpha/src/types/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,65 @@ import { DocumentCategory } from '@selfxyz/common/types';
import type { Progress } from './public';

export enum SdkEvents {
/**
* Emitted when an error occurs during SDK operations, including timeouts.
*
* **Required:** Handle this event to provide error feedback to users.
* **Recommended:** Log errors for debugging and show appropriate user-friendly error messages.
*/
ERROR = 'ERROR',

/**
* Emitted to provide progress updates during long-running operations.
*
* **Recommended:** Use this to show progress indicators or loading states to improve user experience.
*/
PROGRESS = 'PROGRESS',
STATE = 'STATE',

/**
* Emitted when no passport data is found on the device during initialization.
*
* **Required:** Navigate users to a document scanning/setup screen to capture their passport data.
* **Recommended:** Provide clear instructions on how to scan and register their document properly.
*/
PROVING_PASSPORT_DATA_NOT_FOUND = 'PROVING_PASSPORT_DATA_NOT_FOUND',

/**
* Emitted when identity verification completes successfully.
*
* **Required:** Show success confirmation to the user that their identity was verified.
* **Recommended:** Navigate to your app's main screen or success page after a brief delay (3 seconds)
* to allow users to see the success state.
*/
PROVING_ACCOUNT_VERIFIED_SUCCESS = 'PROVING_ACCOUNT_VERIFIED_SUCCESS',

/**
* Emitted when document registration fails or encounters an error.
*
* **Required:** Handle navigation based on the `hasValidDocument` parameter:
* - If `hasValidDocument` is `true`: Navigate to your app's home screen (user has other valid documents registered)
* - If `hasValidDocument` is `false`: Navigate to launch/onboarding screen (user needs to register documents)
* **Recommended:** Show appropriate error messages and implement a brief delay (3 seconds) before navigation.
*/
PROVING_REGISTER_ERROR_OR_FAILURE = 'PROVING_REGISTER_ERROR_OR_FAILURE',

/**
* Emitted when a passport from an unsupported country or document type is detected during validation.
*
* **Required:** Inform users that their document is not currently supported.
* **Recommended:** Navigate to an unsupported document screen showing the detected country code and
* document category, and provide guidance on alternative verification methods if available.
*/
PROVING_PASSPORT_NOT_SUPPORTED = 'PROVING_PASSPORT_NOT_SUPPORTED',

/**
* Emitted when account recovery is required because the passport was registered with different credentials.
* This happens when a document's nullifier is found on-chain but not registered with the current user's secret.
*
* **Required:** Navigate users to an account recovery screen with recovery options or instructions if the have originally registered with a differnt self app.
* **Recommended:** Explain that their passport was previously registered with different account credentials
* and guide them through the recovery process to regain access.
*/
PROVING_ACCOUNT_RECOVERY_REQUIRED = 'PROVING_ACCOUNT_RECOVERY_REQUIRED',
}

Expand All @@ -31,7 +82,6 @@ export interface SDKEventMap {
[SdkEvents.PROVING_ACCOUNT_RECOVERY_REQUIRED]: undefined;

[SdkEvents.PROGRESS]: Progress;
[SdkEvents.STATE]: string;
[SdkEvents.ERROR]: Error;
}

Expand Down
36 changes: 0 additions & 36 deletions packages/mobile-sdk-alpha/src/types/public.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,10 @@ import { SDKEvent, SDKEventMap } from './events';
export type { PassportValidationCallbacks } from '../validation/document';
export type { DocumentCatalog, PassportData };
export interface Config {
endpoints?: { api?: string; teeWs?: string; artifactsCdn?: string };
timeouts?: {
httpMs?: number;
wsMs?: number;
scanMs?: number;
proofMs?: number;
};
features?: Record<string, boolean>;
tlsPinning?: { enabled: boolean; pins?: string[] };
}
export interface CryptoAdapter {
hash(input: Uint8Array, algo?: 'sha256'): Promise<Uint8Array>;
Expand Down Expand Up @@ -107,35 +102,15 @@ export interface Adapters {
documents: DocumentsAdapter;
}

export interface ProofHandle {
id: string;
status: 'pending' | 'completed' | 'failed';
result: () => Promise<{ ok: boolean; reason?: string }>;
cancel: () => void;
}
export interface LoggerAdapter {
log(level: LogLevel, message: string, fields?: Record<string, unknown>): void;
}

export interface ProofRequest {
type: 'register' | 'dsc' | 'disclose';
payload: unknown;
}
export interface NetworkAdapter {
http: HttpAdapter;
ws: WsAdapter;
}

export interface RegistrationInput {
docId?: string;
scan: ScanResult;
}

export interface RegistrationStatus {
registered: boolean;
reason?: string;
}

export type ScanMode = 'mrz' | 'nfc' | 'qr';

export type ScanOpts =
Expand Down Expand Up @@ -186,17 +161,6 @@ export interface DocumentsAdapter {

export interface SelfClient {
scanDocument(opts: ScanOpts & { signal?: AbortSignal }): Promise<ScanResult>;
validateDocument(input: ValidationInput): Promise<ValidationResult>;
checkRegistration(input: RegistrationInput): Promise<RegistrationStatus>;
registerDocument(input: RegistrationInput): Promise<RegistrationStatus>;
generateProof(
req: ProofRequest,
opts?: {
signal?: AbortSignal;
onProgress?: (p: Progress) => void;
timeoutMs?: number;
},
): Promise<ProofHandle>;
extractMRZInfo(mrz: string): MRZInfo;
trackEvent(event: string, payload?: TrackEventParams): void;
getPrivateKey(): Promise<string | null>;
Expand Down
1 change: 0 additions & 1 deletion packages/mobile-sdk-alpha/tests/client-mrz.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ describe('createSelfClient API', () => {
const client = createSelfClient({ config: {}, adapters: mockAdapters, listeners: new Map() });

expect(typeof client.extractMRZInfo).toBe('function');
expect(typeof client.registerDocument).toBe('function');
expect(typeof client.validateDocument).toBe('function');
});

Expand Down
32 changes: 4 additions & 28 deletions packages/mobile-sdk-alpha/tests/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,10 @@ describe('createSelfClient', () => {
});
const result = await client.scanDocument({ mode: 'qr' });
expect(result).toEqual({ mode: 'qr', data: 'self://ok' });
expect(scanMock).toHaveBeenCalledWith({ mode: 'qr' });
expect(scanMock).toHaveBeenCalledWith(expect.objectContaining({
mode: 'qr',
signal: expect.any(AbortSignal)
}));
});

it('propagates scanner errors', async () => {
Expand All @@ -78,22 +81,6 @@ describe('createSelfClient', () => {
await expect(client.scanDocument({ mode: 'qr' })).rejects.toBe(err);
});

it('returns stub proof handle when adapters provided', async () => {
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 },
listeners: new Map(),
});
const handle = await client.generateProof({ type: 'register', payload: {} });
expect(handle.id).toBe('stub');
expect(handle.status).toBe('pending');
expect(await handle.result()).toEqual({ ok: false, reason: 'SELF_ERR_PROOF_STUB' });
expect(() => handle.cancel()).not.toThrow();
});

it('emits and unsubscribes events', () => {
const listeners = createListenersMap();

Expand Down Expand Up @@ -144,17 +131,6 @@ describe('createSelfClient', () => {
expect(info.validation?.overall).toBe(true);
});

it('returns stub registration status', async () => {
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',
});
});
describe('when analytics adapter is given', () => {
it('calls that adapter for trackEvent', () => {
const trackEvent = vi.fn();
Expand Down
Loading
Loading