@switchlabs/verify-ai-react-native 1.1.0 → 1.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/README.md +17 -4
  2. package/lib/client/index.d.ts +22 -2
  3. package/lib/client/index.js +132 -19
  4. package/lib/components/VerifyAIScanner.d.ts +7 -4
  5. package/lib/components/VerifyAIScanner.js +235 -18
  6. package/lib/hooks/useVerifyAI.d.ts +32 -10
  7. package/lib/hooks/useVerifyAI.js +246 -14
  8. package/lib/index.d.ts +5 -2
  9. package/lib/index.js +3 -0
  10. package/lib/ml/featureExtractor.d.ts +16 -0
  11. package/lib/ml/featureExtractor.js +123 -0
  12. package/lib/ml/imagePreprocessor.d.ts +2 -0
  13. package/lib/ml/imagePreprocessor.js +48 -0
  14. package/lib/ml/index.d.ts +5 -0
  15. package/lib/ml/index.js +4 -0
  16. package/lib/ml/inferenceEngine.d.ts +24 -0
  17. package/lib/ml/inferenceEngine.js +156 -0
  18. package/lib/ml/modelManager.d.ts +26 -0
  19. package/lib/ml/modelManager.js +207 -0
  20. package/lib/ml/policyEngine.d.ts +14 -0
  21. package/lib/ml/policyEngine.js +161 -0
  22. package/lib/ml/types.d.ts +84 -0
  23. package/lib/ml/types.js +4 -0
  24. package/lib/storage/offlineQueue.js +1 -1
  25. package/lib/telemetry/TelemetryContext.d.ts +4 -0
  26. package/lib/telemetry/TelemetryContext.js +5 -0
  27. package/lib/telemetry/TelemetryReporter.d.ts +24 -0
  28. package/lib/telemetry/TelemetryReporter.js +141 -0
  29. package/lib/types/index.d.ts +18 -0
  30. package/lib/version.d.ts +1 -0
  31. package/lib/version.js +1 -0
  32. package/package.json +23 -2
  33. package/src/client/index.ts +176 -25
  34. package/src/components/VerifyAIScanner.tsx +282 -21
  35. package/src/hooks/useVerifyAI.ts +332 -18
  36. package/src/index.ts +20 -1
  37. package/src/ml/featureExtractor.ts +160 -0
  38. package/src/ml/imagePreprocessor.ts +72 -0
  39. package/src/ml/index.ts +14 -0
  40. package/src/ml/inferenceEngine.ts +200 -0
  41. package/src/ml/modelManager.ts +265 -0
  42. package/src/ml/policyEngine.ts +201 -0
  43. package/src/ml/types.ts +104 -0
  44. package/src/storage/offlineQueue.ts +1 -1
  45. package/src/telemetry/TelemetryContext.tsx +8 -0
  46. package/src/telemetry/TelemetryReporter.ts +184 -0
  47. package/src/types/index.ts +20 -0
  48. package/src/version.ts +1 -0
@@ -0,0 +1,24 @@
1
+ export declare class TelemetryReporter {
2
+ private buffer;
3
+ private flushTimer;
4
+ private sessionId;
5
+ private baseUrl;
6
+ private apiKey;
7
+ private disposed;
8
+ private flushing;
9
+ constructor(apiKey: string, baseUrl: string);
10
+ /** Track an error event. Fire-and-forget — never throws. */
11
+ track(eventType: string, opts?: {
12
+ component?: string;
13
+ error?: unknown;
14
+ errorCode?: string;
15
+ metadata?: Record<string, string | number>;
16
+ }): void;
17
+ /** Flush all buffered events immediately. Returns a promise but never rejects. */
18
+ flush(): Promise<void>;
19
+ /** Dispose — flush remaining events and stop timers. */
20
+ dispose(): void;
21
+ private scheduleFlush;
22
+ private flushNow;
23
+ private clearFlushTimer;
24
+ }
@@ -0,0 +1,141 @@
1
+ import { Platform } from 'react-native';
2
+ import { SDK_VERSION } from '../version';
3
+ /** Event types that flush immediately (critical init failures). */
4
+ const CRITICAL_EVENTS = new Set([
5
+ 'camera_init_failure',
6
+ 'camera_preview_timeout',
7
+ 'camera_permission_denied',
8
+ ]);
9
+ export class TelemetryReporter {
10
+ constructor(apiKey, baseUrl) {
11
+ this.buffer = new Map();
12
+ this.flushTimer = null;
13
+ this.disposed = false;
14
+ this.flushing = false;
15
+ this.apiKey = apiKey;
16
+ this.baseUrl = baseUrl.replace(/\/$/, '');
17
+ this.sessionId = Math.random().toString(36).slice(2) + Date.now().toString(36);
18
+ }
19
+ /** Track an error event. Fire-and-forget — never throws. */
20
+ track(eventType, opts = {}) {
21
+ if (this.disposed)
22
+ return;
23
+ try {
24
+ const now = new Date().toISOString();
25
+ const errorObj = opts.error instanceof Error ? opts.error : null;
26
+ const errorMessage = errorObj?.message
27
+ ?? (typeof opts.error === 'string' ? opts.error : undefined);
28
+ const dedupKey = `${eventType}|${errorMessage ?? ''}|${opts.component ?? ''}`;
29
+ const existing = this.buffer.get(dedupKey);
30
+ if (existing) {
31
+ existing.event_count++;
32
+ existing.last_occurred_at = now;
33
+ // If the new occurrence is critical, flush immediately
34
+ if (CRITICAL_EVENTS.has(eventType)) {
35
+ this.flushNow();
36
+ }
37
+ return;
38
+ }
39
+ const event = {
40
+ event_type: eventType,
41
+ component: opts.component,
42
+ error_message: errorMessage?.slice(0, 1000),
43
+ error_stack: errorObj?.stack?.slice(0, 2000),
44
+ error_code: opts.errorCode,
45
+ metadata: opts.metadata,
46
+ sdk_platform: Platform.OS,
47
+ sdk_version: SDK_VERSION,
48
+ os_name: Platform.OS,
49
+ os_version: String(Platform.Version),
50
+ session_id: this.sessionId,
51
+ event_count: 1,
52
+ first_occurred_at: now,
53
+ last_occurred_at: now,
54
+ };
55
+ this.buffer.set(dedupKey, event);
56
+ if (CRITICAL_EVENTS.has(eventType)) {
57
+ this.flushNow();
58
+ }
59
+ else {
60
+ this.scheduleFlush();
61
+ }
62
+ }
63
+ catch {
64
+ // Never throw from telemetry
65
+ }
66
+ }
67
+ /** Flush all buffered events immediately. Returns a promise but never rejects. */
68
+ async flush() {
69
+ if (this.buffer.size === 0 || this.flushing)
70
+ return;
71
+ this.flushing = true;
72
+ const bufferedEntries = Array.from(this.buffer.entries());
73
+ const events = bufferedEntries.map(([, event]) => event);
74
+ this.buffer.clear();
75
+ this.clearFlushTimer();
76
+ const controller = new AbortController();
77
+ const timeout = setTimeout(() => controller.abort(), 10000);
78
+ try {
79
+ const response = await fetch(`${this.baseUrl}/telemetry`, {
80
+ method: 'POST',
81
+ headers: {
82
+ 'Content-Type': 'application/json',
83
+ 'X-API-Key': this.apiKey,
84
+ },
85
+ body: JSON.stringify({ events }),
86
+ signal: controller.signal,
87
+ });
88
+ if (!response.ok) {
89
+ throw new Error(`Telemetry request failed with status ${response.status}`);
90
+ }
91
+ }
92
+ catch {
93
+ for (const [dedupKey, event] of bufferedEntries) {
94
+ const existing = this.buffer.get(dedupKey);
95
+ if (existing) {
96
+ existing.event_count += event.event_count;
97
+ existing.first_occurred_at = existing.first_occurred_at < event.first_occurred_at
98
+ ? existing.first_occurred_at
99
+ : event.first_occurred_at;
100
+ existing.last_occurred_at = existing.last_occurred_at > event.last_occurred_at
101
+ ? existing.last_occurred_at
102
+ : event.last_occurred_at;
103
+ }
104
+ else {
105
+ this.buffer.set(dedupKey, event);
106
+ }
107
+ }
108
+ }
109
+ finally {
110
+ clearTimeout(timeout);
111
+ this.flushing = false;
112
+ if (this.buffer.size > 0 && !this.disposed) {
113
+ this.scheduleFlush();
114
+ }
115
+ }
116
+ }
117
+ /** Dispose — flush remaining events and stop timers. */
118
+ dispose() {
119
+ this.disposed = true;
120
+ this.clearFlushTimer();
121
+ this.flush();
122
+ }
123
+ scheduleFlush() {
124
+ if (this.flushTimer)
125
+ return;
126
+ this.flushTimer = setTimeout(() => {
127
+ this.flushTimer = null;
128
+ this.flush();
129
+ }, 5000);
130
+ }
131
+ flushNow() {
132
+ this.clearFlushTimer();
133
+ this.flush();
134
+ }
135
+ clearFlushTimer() {
136
+ if (this.flushTimer) {
137
+ clearTimeout(this.flushTimer);
138
+ this.flushTimer = null;
139
+ }
140
+ }
141
+ }
@@ -4,6 +4,8 @@ export interface VerifyAIConfig {
4
4
  baseUrl?: string;
5
5
  timeout?: number;
6
6
  offlineMode?: boolean;
7
+ /** Enable SDK telemetry reporting for client-side errors. Default: true. */
8
+ telemetry?: boolean;
7
9
  }
8
10
  export interface VerificationRequest {
9
11
  image: string;
@@ -11,6 +13,12 @@ export interface VerificationRequest {
11
13
  metadata?: Record<string, unknown>;
12
14
  provider?: 'openai' | 'anthropic' | 'gemini';
13
15
  }
16
+ export interface MultipartVerificationRequest {
17
+ imageUri: string;
18
+ policy: string;
19
+ metadata?: Record<string, unknown>;
20
+ provider?: 'openai' | 'anthropic' | 'gemini';
21
+ }
14
22
  export interface VerificationResult {
15
23
  id: string;
16
24
  created_at: string;
@@ -37,6 +45,10 @@ export interface VerificationListParams {
37
45
  start_date?: string;
38
46
  end_date?: string;
39
47
  }
48
+ export interface VerifyOptions {
49
+ /** Idempotency key to prevent duplicate verifications on retry. */
50
+ idempotencyKey?: string;
51
+ }
40
52
  export interface QueueItem {
41
53
  id: string;
42
54
  request: VerificationRequest;
@@ -49,6 +61,11 @@ export interface VerifyAIError {
49
61
  current_usage?: number;
50
62
  limit?: number;
51
63
  upgrade_url?: string;
64
+ request_id?: string;
65
+ code?: string;
66
+ path?: string;
67
+ url?: string;
68
+ method?: string;
52
69
  }
53
70
  export type ScannerStatus = 'idle' | 'capturing' | 'processing' | 'success' | 'error';
54
71
  export interface ScannerOverlayConfig {
@@ -77,6 +94,7 @@ export interface ScannerOverlayConfig {
77
94
  exhaustedMessage?: string;
78
95
  maxAttempts?: number;
79
96
  autoApproveOnExhaust?: boolean;
97
+ showTechnicalErrorDetails?: boolean;
80
98
  }
81
99
  export interface PolicyConfigResponse {
82
100
  maxAttempts: number;
@@ -0,0 +1 @@
1
+ export declare const SDK_VERSION = "1.1.1";
package/lib/version.js ADDED
@@ -0,0 +1 @@
1
+ export const SDK_VERSION = '1.1.1';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@switchlabs/verify-ai-react-native",
3
- "version": "1.1.0",
3
+ "version": "1.1.2",
4
4
  "description": "React Native SDK for Verify AI - photo verification with AI vision processing",
5
5
  "main": "./lib/index.js",
6
6
  "types": "./lib/index.d.ts",
@@ -23,26 +23,47 @@
23
23
  ],
24
24
  "author": "Switch Labs",
25
25
  "license": "MIT",
26
+ "dependencies": {
27
+ "@noble/hashes": "^1.8.0",
28
+ "buffer": "^6.0.3",
29
+ "jpeg-js": "^0.4.4"
30
+ },
26
31
  "peerDependencies": {
27
32
  "react": ">=18.0.0",
28
33
  "react-native": ">=0.72.0",
29
34
  "expo-camera": ">=15.0.0",
30
- "@react-native-async-storage/async-storage": ">=1.19.0"
35
+ "expo-image-manipulator": ">=12.0.0",
36
+ "@react-native-async-storage/async-storage": ">=1.19.0",
37
+ "react-native-fast-tflite": ">=1.0.0",
38
+ "expo-file-system": ">=17.0.0"
31
39
  },
32
40
  "peerDependenciesMeta": {
33
41
  "expo-camera": {
34
42
  "optional": true
35
43
  },
44
+ "expo-image-manipulator": {
45
+ "optional": true
46
+ },
36
47
  "@react-native-async-storage/async-storage": {
37
48
  "optional": true
49
+ },
50
+ "react-native-fast-tflite": {
51
+ "optional": true
52
+ },
53
+ "expo-file-system": {
54
+ "optional": true
38
55
  }
39
56
  },
40
57
  "devDependencies": {
58
+ "@types/jpeg-js": "^0.3.7",
41
59
  "@react-native-async-storage/async-storage": "^2.1.0",
42
60
  "@types/react": "^18.2.0",
43
61
  "expo-camera": "^16.0.0",
62
+ "expo-file-system": "^18.0.12",
63
+ "expo-image-manipulator": "^13.0.0",
44
64
  "react": "^18.2.0",
45
65
  "react-native": "^0.76.0",
66
+ "react-native-fast-tflite": "^1.6.1",
46
67
  "typescript": "^5.5.0"
47
68
  }
48
69
  }
@@ -1,20 +1,30 @@
1
1
  import type {
2
2
  VerifyAIConfig,
3
3
  VerificationRequest,
4
+ MultipartVerificationRequest,
4
5
  VerificationResult,
5
6
  VerificationListResponse,
6
7
  VerificationListParams,
7
8
  VerifyAIError,
9
+ VerifyOptions,
8
10
  PolicyConfigResponse,
9
11
  } from '../types';
12
+ import { TelemetryReporter } from '../telemetry/TelemetryReporter';
10
13
 
11
14
  const DEFAULT_BASE_URL = 'https://verify.switchlabs.dev/api/v1';
12
15
  const DEFAULT_TIMEOUT = 30000;
13
16
 
17
+ interface RequestContext {
18
+ path: string;
19
+ url: string;
20
+ method: string;
21
+ }
22
+
14
23
  export class VerifyAIClient {
15
24
  private apiKey: string;
16
25
  private baseUrl: string;
17
26
  private timeout: number;
27
+ readonly telemetry: TelemetryReporter | null;
18
28
 
19
29
  constructor(config: VerifyAIConfig) {
20
30
  if (!config.apiKey) {
@@ -23,17 +33,71 @@ export class VerifyAIClient {
23
33
  this.apiKey = config.apiKey;
24
34
  this.baseUrl = (config.baseUrl || DEFAULT_BASE_URL).replace(/\/$/, '');
25
35
  this.timeout = config.timeout || DEFAULT_TIMEOUT;
36
+ this.telemetry = config.telemetry !== false
37
+ ? new TelemetryReporter(this.apiKey, this.baseUrl)
38
+ : null;
26
39
  }
27
40
 
28
- private async request<T>(
41
+ private parseResponseBody(rawBody: string): unknown {
42
+ if (!rawBody) {
43
+ return null;
44
+ }
45
+
46
+ try {
47
+ return JSON.parse(rawBody);
48
+ } catch {
49
+ return { error: rawBody };
50
+ }
51
+ }
52
+
53
+ private buildRequestError(
54
+ message: string,
55
+ status: number,
56
+ context: RequestContext,
57
+ body: Partial<VerifyAIError> = {}
58
+ ): VerifyAIRequestError {
59
+ const error: VerifyAIError = {
60
+ error: message,
61
+ status,
62
+ code: status === 408 ? 'timeout' : status === 0 ? 'network_error' : 'request_error',
63
+ path: context.path,
64
+ url: context.url,
65
+ method: context.method,
66
+ ...body,
67
+ };
68
+
69
+ return new VerifyAIRequestError(message, status, error);
70
+ }
71
+
72
+ private normalizeRequestError(error: unknown, context: RequestContext): VerifyAIRequestError {
73
+ if (error instanceof VerifyAIRequestError) {
74
+ return error;
75
+ }
76
+
77
+ if (error instanceof Error && error.name === 'AbortError') {
78
+ return this.buildRequestError('Verification request timed out', 408, context, { code: 'timeout' });
79
+ }
80
+
81
+ if (error instanceof TypeError) {
82
+ return this.buildRequestError('Network request failed', 0, context, { code: 'network_error' });
83
+ }
84
+
85
+ const message = error instanceof Error ? error.message : 'VerifyAI request failed';
86
+ return this.buildRequestError(message, 0, context);
87
+ }
88
+
89
+ private async executeRequest<T>(
29
90
  path: string,
30
91
  options: RequestInit = {}
31
92
  ): Promise<T> {
32
93
  const controller = new AbortController();
33
94
  const timer = setTimeout(() => controller.abort(), this.timeout);
95
+ const method = (options.method || 'GET').toUpperCase();
96
+ const url = `${this.baseUrl}${path}`;
97
+ const context: RequestContext = { path, url, method };
34
98
 
35
99
  try {
36
- const response = await fetch(`${this.baseUrl}${path}`, {
100
+ const response = await fetch(url, {
37
101
  ...options,
38
102
  signal: controller.signal,
39
103
  headers: {
@@ -43,43 +107,76 @@ export class VerifyAIClient {
43
107
  });
44
108
 
45
109
  const rawBody = await response.text();
46
- let body: unknown = null;
47
-
48
- if (rawBody) {
49
- try {
50
- body = JSON.parse(rawBody);
51
- } catch {
52
- body = { error: rawBody };
53
- }
54
- }
110
+ const body = this.parseResponseBody(rawBody);
55
111
 
56
112
  if (!response.ok) {
57
113
  const errorBody = (body && typeof body === 'object' ? body : null) as VerifyAIError | null;
58
- const error: VerifyAIError = {
59
- error: errorBody?.error || `Request failed with status ${response.status}`,
60
- status: response.status,
61
- current_usage: errorBody?.current_usage,
62
- limit: errorBody?.limit,
63
- upgrade_url: errorBody?.upgrade_url,
64
- };
65
-
66
- throw new VerifyAIRequestError(
67
- error.error,
114
+ throw this.buildRequestError(
115
+ errorBody?.error || `Request failed with status ${response.status}`,
68
116
  response.status,
69
- error
117
+ context,
118
+ {
119
+ current_usage: errorBody?.current_usage,
120
+ limit: errorBody?.limit,
121
+ upgrade_url: errorBody?.upgrade_url,
122
+ request_id: errorBody?.request_id || response.headers.get('X-Request-Id') || undefined,
123
+ code: errorBody?.code || 'http_error',
124
+ }
70
125
  );
71
126
  }
72
127
 
73
128
  if (!body || typeof body !== 'object') {
74
- throw new Error('VerifyAI: Invalid response payload');
129
+ throw this.buildRequestError(
130
+ 'VerifyAI: Invalid response payload',
131
+ 0,
132
+ context,
133
+ {
134
+ code: 'invalid_response',
135
+ request_id: response.headers.get('X-Request-Id') || undefined,
136
+ }
137
+ );
75
138
  }
76
139
 
77
140
  return body as T;
141
+ } catch (error) {
142
+ const normalized = this.normalizeRequestError(error, context);
143
+ // Track network/timeout/server errors
144
+ if (this.telemetry) {
145
+ const errorCode = normalized.code || (normalized.status >= 500 ? 'server_error' : undefined);
146
+ const eventType =
147
+ normalized.status === 408 || normalized.code === 'timeout'
148
+ ? 'request_timeout'
149
+ : normalized.status === 0 || normalized.code === 'network_error'
150
+ ? 'network_error'
151
+ : normalized.status === 401 || normalized.status === 403
152
+ ? 'auth_error'
153
+ : normalized.status === 429
154
+ ? 'rate_limited'
155
+ : normalized.status >= 400 && normalized.status < 500
156
+ ? 'request_error'
157
+ : 'server_error';
158
+ this.telemetry.track(
159
+ eventType,
160
+ {
161
+ component: 'client',
162
+ error: normalized,
163
+ errorCode,
164
+ },
165
+ );
166
+ }
167
+ throw normalized;
78
168
  } finally {
79
169
  clearTimeout(timer);
80
170
  }
81
171
  }
82
172
 
173
+ private async request<T>(
174
+ path: string,
175
+ options: RequestInit = {}
176
+ ): Promise<T> {
177
+ return this.executeRequest<T>(path, options);
178
+ }
179
+
83
180
  /**
84
181
  * Submit a photo for AI verification.
85
182
  *
@@ -102,14 +199,60 @@ export class VerifyAIClient {
102
199
  * }
103
200
  * ```
104
201
  */
105
- async verify(request: VerificationRequest): Promise<VerificationResult> {
202
+ async verify(request: VerificationRequest, options?: VerifyOptions): Promise<VerificationResult> {
203
+ const headers: Record<string, string> = { 'Content-Type': 'application/json' };
204
+ if (options?.idempotencyKey) {
205
+ headers['Idempotency-Key'] = options.idempotencyKey;
206
+ }
106
207
  return this.request<VerificationResult>('/verify', {
107
208
  method: 'POST',
108
- headers: { 'Content-Type': 'application/json' },
209
+ headers,
109
210
  body: JSON.stringify(request),
110
211
  });
111
212
  }
112
213
 
214
+ /**
215
+ * Submit a photo for AI verification using multipart/form-data.
216
+ * Streams the image directly from disk — avoids base64 encoding overhead.
217
+ *
218
+ * **Breaking change:** `onCapture` prop on `VerifyAIScanner` now receives
219
+ * an image URI instead of a base64 string.
220
+ *
221
+ * @param request - Multipart request with file URI and policy
222
+ * @param options - Optional verify options (e.g. idempotency key)
223
+ * @returns The verification result with compliance status and feedback
224
+ */
225
+ async verifyMultipart(request: MultipartVerificationRequest, options?: VerifyOptions): Promise<VerificationResult> {
226
+ const formData = new FormData();
227
+ // React Native FormData accepts { uri, type, name } objects for file fields
228
+ formData.append('image', {
229
+ uri: request.imageUri,
230
+ type: 'image/jpeg',
231
+ name: 'photo.jpg',
232
+ } as unknown as Blob);
233
+ formData.append('policy', request.policy);
234
+ if (request.metadata) {
235
+ formData.append('metadata', JSON.stringify(request.metadata));
236
+ }
237
+ if (request.provider) {
238
+ formData.append('provider', request.provider);
239
+ }
240
+
241
+ const headers: Record<string, string> = {
242
+ 'X-API-Key': this.apiKey,
243
+ // Do NOT set Content-Type — fetch auto-sets multipart boundary
244
+ };
245
+ if (options?.idempotencyKey) {
246
+ headers['Idempotency-Key'] = options.idempotencyKey;
247
+ }
248
+
249
+ return this.executeRequest<VerificationResult>('/verify', {
250
+ method: 'POST',
251
+ headers,
252
+ body: formData,
253
+ });
254
+ }
255
+
113
256
  /**
114
257
  * List past verifications with optional filters.
115
258
  *
@@ -211,4 +354,12 @@ export class VerifyAIRequestError extends Error {
211
354
  get upgradeUrl(): string | undefined {
212
355
  return this.body.upgrade_url;
213
356
  }
357
+
358
+ get requestId(): string | undefined {
359
+ return this.body.request_id;
360
+ }
361
+
362
+ get code(): string | undefined {
363
+ return this.body.code;
364
+ }
214
365
  }