@myazahq/kyc-sdk-react-native 2.0.0

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 (87) hide show
  1. package/KycSdkReactNative.podspec +32 -0
  2. package/LICENSE +21 -0
  3. package/README.md +164 -0
  4. package/android/CMakeLists.txt +29 -0
  5. package/android/build.gradle +110 -0
  6. package/android/src/main/AndroidManifest.xml +12 -0
  7. package/android/src/main/cpp/cpp-adapter.cpp +12 -0
  8. package/android/src/main/java/co/myazahq/kyc/rn/MyazaFaceDetectorPackage.kt +36 -0
  9. package/android/src/main/java/co/myazahq/kyc/rn/MyazaStatusBarModule.kt +64 -0
  10. package/android/src/main/java/com/margelo/nitro/myazakyc/HybridMyazaFaceDetector.kt +134 -0
  11. package/app.plugin.js +55 -0
  12. package/expo-module.config.json +6 -0
  13. package/ios/HybridMyazaFaceDetector.swift +233 -0
  14. package/package.json +84 -0
  15. package/react-native.config.js +23 -0
  16. package/src/MyazaKYC.tsx +235 -0
  17. package/src/__tests__/cardCrop.test.ts +39 -0
  18. package/src/__tests__/deviceMetadata.test.ts +34 -0
  19. package/src/__tests__/errors.test.ts +34 -0
  20. package/src/__tests__/flow.test.ts +61 -0
  21. package/src/__tests__/gestureDetector.test.ts +37 -0
  22. package/src/__tests__/liveness.test.ts +112 -0
  23. package/src/__tests__/resolveUrl.test.ts +64 -0
  24. package/src/__tests__/validators.test.ts +38 -0
  25. package/src/assets/liveness/Blink.gif +0 -0
  26. package/src/assets/liveness/Nod.gif +0 -0
  27. package/src/assets/liveness/Smile.gif +0 -0
  28. package/src/assets/liveness/Turn.gif +0 -0
  29. package/src/components/CameraPermissionView.tsx +116 -0
  30. package/src/components/CameraViewfinder.tsx +156 -0
  31. package/src/components/CountryFlag.tsx +52 -0
  32. package/src/components/DocumentCropper.tsx +325 -0
  33. package/src/components/GlassIconButton.tsx +92 -0
  34. package/src/components/Icon.tsx +125 -0
  35. package/src/components/KycFlow.tsx +205 -0
  36. package/src/components/KycSheet.tsx +224 -0
  37. package/src/components/MyazaAlert.tsx +57 -0
  38. package/src/components/MyazaButton.tsx +101 -0
  39. package/src/components/MyazaCard.tsx +48 -0
  40. package/src/components/MyazaInput.tsx +112 -0
  41. package/src/components/MyazaPulseLoader.tsx +71 -0
  42. package/src/components/StatusBarController.tsx +42 -0
  43. package/src/components/StepHeader.tsx +56 -0
  44. package/src/components/StepIndicator.tsx +74 -0
  45. package/src/components/Typography.tsx +69 -0
  46. package/src/components/fonts.ts +49 -0
  47. package/src/components/glass/GlassGroup.tsx +34 -0
  48. package/src/components/glass/GlassSurface.tsx +64 -0
  49. package/src/components/runtime.tsx +93 -0
  50. package/src/components/toast.tsx +154 -0
  51. package/src/components/useBranding.ts +27 -0
  52. package/src/components/useVideoRecorder.ts +122 -0
  53. package/src/config/captureSettings.ts +67 -0
  54. package/src/config/idTypes.ts +79 -0
  55. package/src/config/theme.ts +186 -0
  56. package/src/index.ts +54 -0
  57. package/src/liveness/challengeManager.ts +130 -0
  58. package/src/liveness/faceDetector.ts +79 -0
  59. package/src/liveness/gestureDetector.ts +80 -0
  60. package/src/liveness/speech.ts +66 -0
  61. package/src/liveness/types.ts +99 -0
  62. package/src/liveness/useLiveness.ts +484 -0
  63. package/src/liveness/visionCameraFaceDetector.ts +118 -0
  64. package/src/screens/ConsentStep.tsx +164 -0
  65. package/src/screens/DocumentCaptureStep.tsx +500 -0
  66. package/src/screens/IdInputStep.tsx +79 -0
  67. package/src/screens/IdTypeStep.tsx +142 -0
  68. package/src/screens/LivenessAvatar.tsx +69 -0
  69. package/src/screens/LivenessStep.tsx +615 -0
  70. package/src/screens/SubmittedStep.tsx +177 -0
  71. package/src/services/api.ts +291 -0
  72. package/src/services/cardCrop.ts +52 -0
  73. package/src/services/deviceMetadata.ts +185 -0
  74. package/src/services/errors.ts +92 -0
  75. package/src/services/mediaCompress.ts +129 -0
  76. package/src/services/resolveUrl.ts +98 -0
  77. package/src/services/retry.ts +70 -0
  78. package/src/services/validators.ts +103 -0
  79. package/src/specs/MyazaFaceDetector.nitro.ts +44 -0
  80. package/src/store/kycStore.ts +288 -0
  81. package/src/store/serverConfig.ts +78 -0
  82. package/src/types/config.ts +239 -0
  83. package/src/types/country-flag-icons.d.ts +6 -0
  84. package/src/types/verification.ts +79 -0
  85. package/src/utils/platform.ts +23 -0
  86. package/src/utils/tokens.ts +10 -0
  87. package/src/utils/uuid.ts +31 -0
@@ -0,0 +1,177 @@
1
+ import React, { useEffect, useRef, useState } from 'react';
2
+ import { View } from 'react-native';
3
+
4
+ import { radius, spacing } from '../config/theme';
5
+ import { mapToKycError, safeReportError } from '../services/errors';
6
+ import type { KYCError, KYCSubmission } from '../types/verification';
7
+ import { useKyc, useKycConfig, useKycStore, useTheme } from '../components/runtime';
8
+ import { MyazaText } from '../components/Typography';
9
+ import { MyazaButton } from '../components/MyazaButton';
10
+ import { MyazaAlert } from '../components/MyazaAlert';
11
+ import { MyazaPulseLoader } from '../components/MyazaPulseLoader';
12
+ import { Icon } from '../components/Icon';
13
+ import { fillTokens } from '../utils/tokens';
14
+
15
+ // Terminal screen — 1:1 with the Flutter SDK's SubmittedScreen. Calls
16
+ // submitAsync on mount; renders submitting / success / error views with the same
17
+ // badges + copy. The header title is empty for this step.
18
+
19
+ type Phase = 'submitting' | 'success' | 'error';
20
+
21
+ const DEFAULT_SUCCESS_TITLE = 'Verification Submitted!';
22
+ const DEFAULT_SUCCESS_DESCRIPTION =
23
+ "Your identity verification has been submitted for review. You'll be notified of the result.";
24
+
25
+ const ERROR_TITLES: Record<string, string> = {
26
+ insufficient_credits: 'Credits Exhausted',
27
+ invalid_api_key: 'Authentication Failed',
28
+ feature_disabled: 'Verification Unavailable',
29
+ upload_failed: 'Upload Failed',
30
+ network_error: 'Connection Failed',
31
+ };
32
+
33
+ export function SubmittedStep({ onClose }: { onClose: () => void }): React.ReactElement {
34
+ const { colors } = useTheme();
35
+ const config = useKycConfig();
36
+ const store = useKycStore();
37
+ const existingResult = useKyc((s) => s.submissionResult);
38
+
39
+ const [phase, setPhase] = useState<Phase>('submitting');
40
+ const [error, setError] = useState<KYCError | null>(null);
41
+ const [retry, setRetry] = useState<{ attempt: number; total: number } | null>(null);
42
+ const reportedRef = useRef(false);
43
+ const kickedRef = useRef(false);
44
+
45
+ const submit = React.useCallback(async () => {
46
+ setPhase('submitting');
47
+ setError(null);
48
+ setRetry(null);
49
+ try {
50
+ const result = await store.getState().submitAsync((attempt, total) => setRetry({ attempt, total }));
51
+ const submission: KYCSubmission = {
52
+ verificationId: result.verificationId,
53
+ status: 'pending',
54
+ metadata: config.metadata ?? {},
55
+ submittedAt: new Date().toISOString(),
56
+ };
57
+ config.onSubmit?.(submission);
58
+ setPhase('success');
59
+ } catch (err) {
60
+ const kycError = mapToKycError(err, 'verify');
61
+ setError(kycError);
62
+ if (!reportedRef.current) {
63
+ reportedRef.current = true;
64
+ safeReportError(config.onError, kycError);
65
+ }
66
+ setPhase('error');
67
+ }
68
+ // eslint-disable-next-line react-hooks/exhaustive-deps
69
+ }, [store, config]);
70
+
71
+ useEffect(() => {
72
+ if (existingResult) {
73
+ setPhase('success');
74
+ return;
75
+ }
76
+ if (!kickedRef.current) {
77
+ kickedRef.current = true;
78
+ void submit();
79
+ }
80
+ // eslint-disable-next-line react-hooks/exhaustive-deps
81
+ }, []);
82
+
83
+ if (phase === 'submitting') {
84
+ const retrying = retry != null;
85
+ return (
86
+ <View style={{ flex: 1, alignItems: 'center', justifyContent: 'center', paddingVertical: spacing.xl }}>
87
+ <MyazaPulseLoader />
88
+ <View style={{ height: spacing.lg }} />
89
+ <MyazaText variant="heading3" style={{ textAlign: 'center' }}>
90
+ {retrying ? 'Reconnecting…' : 'Submitting your verification…'}
91
+ </MyazaText>
92
+ <View style={{ height: spacing.sm }} />
93
+ <MyazaText variant="bodyMedium" style={{ textAlign: 'center' }}>
94
+ {retrying ? `Connection issue — retrying (${retry!.attempt}/${retry!.total})…` : 'Please wait a moment.'}
95
+ </MyazaText>
96
+ </View>
97
+ );
98
+ }
99
+
100
+ if (phase === 'error' && error) {
101
+ const title = ERROR_TITLES[error.code] ?? 'Submission Failed';
102
+ const canRetry = error.code === 'network_error';
103
+ return (
104
+ <View style={{ flex: 1, justifyContent: 'space-between' }}>
105
+ <View style={{ alignItems: 'center' }}>
106
+ <View style={{ height: spacing.xl }} />
107
+ <Badge bg={colors.errorBg} border={`${colors.error}4D`}>
108
+ <Icon name="alert" size={44} color={colors.error} />
109
+ </Badge>
110
+ <View style={{ height: spacing.lg }} />
111
+ <MyazaText variant="heading1" style={{ textAlign: 'center' }}>
112
+ {title}
113
+ </MyazaText>
114
+ <View style={{ height: spacing.md }} />
115
+ <View style={{ width: '100%', paddingHorizontal: spacing.md }}>
116
+ <MyazaAlert variant="error" title="What happened" message={error.message} />
117
+ </View>
118
+ </View>
119
+ <View style={{ flexDirection: 'row', gap: spacing.md }}>
120
+ {canRetry ? (
121
+ <View style={{ flex: 1 }}>
122
+ <MyazaButton label="Try Again" variant="outline" leadingIcon="refresh" onPress={() => void submit()} />
123
+ </View>
124
+ ) : null}
125
+ <View style={{ flex: 1 }}>
126
+ <MyazaButton label="Close" onPress={onClose} />
127
+ </View>
128
+ </View>
129
+ </View>
130
+ );
131
+ }
132
+
133
+ // success
134
+ const title = config.success?.title ? fillTokens(config.success.title, config.userData) : DEFAULT_SUCCESS_TITLE;
135
+ const description = config.success?.description
136
+ ? fillTokens(config.success.description, config.userData)
137
+ : DEFAULT_SUCCESS_DESCRIPTION;
138
+
139
+ return (
140
+ <View style={{ flex: 1, justifyContent: 'space-between' }}>
141
+ <View style={{ alignItems: 'center' }}>
142
+ <View style={{ height: spacing.xl }} />
143
+ <Badge bg={colors.successBg} border={`${colors.success}4D`}>
144
+ <Icon name="check" size={44} color={colors.success} />
145
+ </Badge>
146
+ <View style={{ height: spacing.lg }} />
147
+ <MyazaText variant="heading1" style={{ textAlign: 'center' }}>
148
+ {title}
149
+ </MyazaText>
150
+ <View style={{ height: spacing.sm }} />
151
+ <MyazaText variant="bodyMedium" style={{ textAlign: 'center' }}>
152
+ {description}
153
+ </MyazaText>
154
+ </View>
155
+ <MyazaButton label="Done" onPress={onClose} />
156
+ </View>
157
+ );
158
+ }
159
+
160
+ function Badge({ bg, border, children }: { bg: string; border: string; children: React.ReactNode }): React.ReactElement {
161
+ return (
162
+ <View
163
+ style={{
164
+ width: 88,
165
+ height: 88,
166
+ borderRadius: radius.full,
167
+ backgroundColor: bg,
168
+ borderWidth: 2,
169
+ borderColor: border,
170
+ alignItems: 'center',
171
+ justifyContent: 'center',
172
+ }}
173
+ >
174
+ {children}
175
+ </View>
176
+ );
177
+ }
@@ -0,0 +1,291 @@
1
+ import { SDK_VERSION } from './deviceMetadata';
2
+
3
+ // ---------------------------------------------------------------------------
4
+ // HTTP client — talks to the only endpoints the SDK calls (upload / verify /
5
+ // status / config / health). Mirrors the web SDK's `services/api.ts`. Multipart
6
+ // uploads append a real `Blob` (read from the local file URI via `uriToBlob`),
7
+ // NOT the classic RN `{ uri, name, type }` part — Expo SDK 54+ installs a
8
+ // WinterCG `fetch` whose serializer rejects the `{ uri }` form.
9
+ // ---------------------------------------------------------------------------
10
+
11
+ export class KYCApiError extends Error {
12
+ statusCode: number;
13
+ code?: string;
14
+ body?: Record<string, unknown>;
15
+
16
+ constructor(message: string, statusCode: number, code?: string, body?: Record<string, unknown>) {
17
+ super(message);
18
+ this.name = 'KYCApiError';
19
+ this.statusCode = statusCode;
20
+ this.code = code;
21
+ this.body = body;
22
+ Object.setPrototypeOf(this, KYCApiError.prototype);
23
+ }
24
+ }
25
+
26
+ function baseHeaders(apiKey: string): Record<string, string> {
27
+ return {
28
+ Authorization: `Bearer ${apiKey}`,
29
+ 'X-SDK-Version': SDK_VERSION,
30
+ };
31
+ }
32
+
33
+ /**
34
+ * Read a local file URI (`file://…`, `content://…`, `ph://…`) into a Blob.
35
+ *
36
+ * Uses `XMLHttpRequest` (not the global `fetch`) on purpose: React Native's XHR
37
+ * has built-in blob support for local URIs and is NOT shadowed by the WinterCG
38
+ * `fetch` Expo installs — so this works regardless of which `fetch` is global.
39
+ * The resulting Blob is what the multipart upload appends (Expo-fetch's
40
+ * serializer accepts a Blob; the classic RN `{ uri }` part it rejects).
41
+ */
42
+ function uriToBlob(uri: string, mimeType: string): Promise<Blob> {
43
+ return new Promise<Blob>((resolve, reject) => {
44
+ const xhr = new XMLHttpRequest();
45
+ xhr.open('GET', uri, true);
46
+ xhr.responseType = 'blob';
47
+ xhr.onload = () => {
48
+ const blob = xhr.response as Blob | null;
49
+ if (blob) {
50
+ // Ensure the part carries the right content-type even if the platform
51
+ // didn't infer one from the URI.
52
+ resolve(blob.type ? blob : blob.slice(0, blob.size, mimeType));
53
+ } else {
54
+ reject(new Error(`Could not read file at ${uri}`));
55
+ }
56
+ };
57
+ xhr.onerror = () => reject(new Error(`Failed to read file at ${uri}`));
58
+ xhr.send();
59
+ });
60
+ }
61
+
62
+ async function handleResponse<T>(res: Response): Promise<T> {
63
+ // Some endpoints return an empty body — read as text first so JSON.parse
64
+ // isn't called on "" (which throws).
65
+ const text = await res.text();
66
+ let data: unknown = null;
67
+ if (text) {
68
+ try {
69
+ data = JSON.parse(text);
70
+ } catch {
71
+ data = { message: text };
72
+ }
73
+ }
74
+ if (!res.ok) {
75
+ const errObj = typeof data === 'object' && data !== null ? (data as Record<string, unknown>) : {};
76
+ const message = String(errObj.message ?? errObj.error ?? `Request failed with status ${res.status}`);
77
+ const code = typeof errObj.error === 'string' ? errObj.error : undefined;
78
+ throw new KYCApiError(message, res.status, code, errObj);
79
+ }
80
+ return data as T;
81
+ }
82
+
83
+ // ---------------------------------------------------------------------------
84
+ // Types
85
+ // ---------------------------------------------------------------------------
86
+
87
+ /** Media kinds the SDK can upload — document/selfie photos plus best-effort videos. */
88
+ export type MediaUploadType =
89
+ | 'document_front'
90
+ | 'document_back'
91
+ | 'selfie'
92
+ | 'document_front_video'
93
+ | 'document_back_video'
94
+ | 'liveness_video';
95
+
96
+ /**
97
+ * A file to upload, in React Native's multipart shape. `uri` points at a
98
+ * local file (camera capture / gallery pick); `name`/`type` populate the
99
+ * multipart part's filename + Content-Type.
100
+ */
101
+ export interface UploadFile {
102
+ uri: string;
103
+ name?: string;
104
+ /** Raw mime type (codec params are stripped before sending). */
105
+ type?: string;
106
+ }
107
+
108
+ export interface UploadResponse {
109
+ mediaId: string;
110
+ }
111
+
112
+ export interface VerifyRequest {
113
+ country: string;
114
+ idType: string;
115
+ idNumber?: string;
116
+ userData?: {
117
+ firstName?: string;
118
+ lastName?: string;
119
+ dateOfBirth?: string;
120
+ };
121
+ mediaIds: {
122
+ documentFront?: string;
123
+ documentBack?: string;
124
+ selfie?: string;
125
+ documentFrontVideo?: string;
126
+ documentBackVideo?: string;
127
+ livenessVideo?: string;
128
+ };
129
+ metadata: {
130
+ requestId: string;
131
+ device?: Record<string, unknown>;
132
+ [key: string]: unknown;
133
+ };
134
+ }
135
+
136
+ export interface VerifyResponse {
137
+ verificationId: string;
138
+ status: 'pending';
139
+ }
140
+
141
+ /**
142
+ * Minimal, publishable-safe status from `GET /api/kyc/status/:id` — no PII,
143
+ * scores, or result data; only the lifecycle state and an org-safe reason.
144
+ */
145
+ export interface VerificationStatusResponse {
146
+ verificationId: string;
147
+ status: 'pending' | 'verified' | 'failed' | 'not_found' | 'error';
148
+ reason?: string | null;
149
+ reasonCode?: string | null;
150
+ createdAt: string;
151
+ completedAt?: string;
152
+ }
153
+
154
+ export interface SdkConfigIdType {
155
+ country: string;
156
+ idType: string;
157
+ features: {
158
+ documentVerification: boolean;
159
+ livenessCheck: boolean;
160
+ govDbCheck: boolean;
161
+ };
162
+ }
163
+
164
+ /** Org branding configured server-side, returned with the SDK config. */
165
+ export interface SdkConfigBranding {
166
+ logo?: string;
167
+ companyName?: string;
168
+ primaryColor?: string;
169
+ }
170
+
171
+ export interface SdkConfigResponse {
172
+ environment: 'DEVELOPMENT' | 'SANDBOX' | 'PRODUCTION';
173
+ idTypes: SdkConfigIdType[];
174
+ branding?: SdkConfigBranding;
175
+ }
176
+
177
+ export interface HealthResponse {
178
+ status: string;
179
+ }
180
+
181
+ // The mime types the server accepts (must mirror the server's upload allowlist).
182
+ const IMAGE_MIME_TYPES = ['image/jpeg', 'image/png', 'image/webp'] as const;
183
+ const VIDEO_MIME_TYPES = ['video/webm', 'video/mp4'] as const;
184
+
185
+ const MIME_EXTENSIONS: Record<string, string> = {
186
+ 'image/jpeg': 'jpg',
187
+ 'image/png': 'png',
188
+ 'image/webp': 'webp',
189
+ 'video/webm': 'webm',
190
+ 'video/mp4': 'mp4',
191
+ };
192
+
193
+ /**
194
+ * Drop codec params from a file's type (e.g. `video/mp4;codecs=...` →
195
+ * `video/mp4`) and fall back to a sane default per media kind when the value
196
+ * isn't one the server recognizes. RN cameras record mp4, so videos default
197
+ * to `video/mp4` (the web SDK defaults to `video/webm`).
198
+ */
199
+ function normalizeMimeType(rawType: string | undefined, type: MediaUploadType): string {
200
+ const base = (rawType?.split(';')[0] ?? '').trim().toLowerCase();
201
+ const isVideo = type.endsWith('_video');
202
+ const allowed: readonly string[] = isVideo ? VIDEO_MIME_TYPES : IMAGE_MIME_TYPES;
203
+ if (allowed.includes(base)) return base;
204
+ return isVideo ? 'video/mp4' : 'image/jpeg';
205
+ }
206
+
207
+ // ---------------------------------------------------------------------------
208
+ // Factory
209
+ // ---------------------------------------------------------------------------
210
+
211
+ export function createKYCApi(baseUrl: string, apiKey: string) {
212
+ const base = `${baseUrl}/api/kyc`;
213
+ const headers = baseHeaders(apiKey);
214
+
215
+ // JSON request to our own server (verify, status, config, health).
216
+ async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
217
+ const res = await fetch(`${base}${path}`, {
218
+ ...init,
219
+ headers: {
220
+ ...headers,
221
+ ...(init.body ? { 'Content-Type': 'application/json' } : {}),
222
+ ...init.headers,
223
+ },
224
+ });
225
+ return handleResponse<T>(res);
226
+ }
227
+
228
+ return {
229
+ /**
230
+ * Single multipart upload: the local file is POSTed to our server, which
231
+ * stores it and returns the `mediaId` referenced later by /verify.
232
+ */
233
+ async upload(file: UploadFile, type: MediaUploadType, maxBytes?: number): Promise<string> {
234
+ const mimeType = normalizeMimeType(file.type, type);
235
+ const name = file.name ?? `${type}.${MIME_EXTENSIONS[mimeType]}`;
236
+ const form = new FormData();
237
+
238
+ // Read the local file (`file://…`) into a Blob and append THAT, not a
239
+ // `{ uri, name, type }` object. Expo SDK 54+ installs a WinterCG `fetch`
240
+ // whose multipart serializer (expo/src/winter/fetch/convertFormData) only
241
+ // accepts string | Blob | { bytes() } and throws "Unsupported FormDataPart
242
+ // implementation" on the classic RN `{ uri }` part. A Blob satisfies both
243
+ // Expo-fetch and React Native's own networking. The Blob carries its own
244
+ // content-type; pass the filename as the 3rd append arg.
245
+ const blob = await uriToBlob(file.uri, mimeType);
246
+ // Hard size ceiling (used for best-effort videos): reject locally rather than
247
+ // wasting an upload the server will refuse. A 4xx KYCApiError is terminal, so
248
+ // withRetry won't retry it and the caller drops the media.
249
+ if (maxBytes != null && blob.size > maxBytes) {
250
+ throw new KYCApiError(
251
+ `File too large (${(blob.size / 1024 / 1024).toFixed(1)}MB > ${(maxBytes / 1024 / 1024).toFixed(0)}MB)`,
252
+ 413,
253
+ 'file_too_large',
254
+ );
255
+ }
256
+ form.append('file', blob, name);
257
+ form.append('type', type);
258
+
259
+ // Don't set Content-Type — the fetch impl adds the multipart boundary.
260
+ const res = await fetch(`${base}/upload`, {
261
+ method: 'POST',
262
+ headers,
263
+ body: form,
264
+ });
265
+ const { mediaId } = await handleResponse<UploadResponse>(res);
266
+ return mediaId;
267
+ },
268
+
269
+ async verify(body: VerifyRequest): Promise<VerifyResponse> {
270
+ return request<VerifyResponse>('/verify', {
271
+ method: 'POST',
272
+ body: JSON.stringify(body),
273
+ });
274
+ },
275
+
276
+ async status(verificationId: string): Promise<VerificationStatusResponse> {
277
+ return request<VerificationStatusResponse>(`/status/${verificationId}`);
278
+ },
279
+
280
+ async config(): Promise<SdkConfigResponse> {
281
+ return request<SdkConfigResponse>('/config');
282
+ },
283
+
284
+ async health(): Promise<HealthResponse> {
285
+ // Public endpoint — no auth needed, but the shared headers are harmless.
286
+ return request<HealthResponse>('/health');
287
+ },
288
+ };
289
+ }
290
+
291
+ export type KYCApi = ReturnType<typeof createKYCApi>;
@@ -0,0 +1,52 @@
1
+ // Pure card-crop geometry — no native imports, so it's unit-testable in the
2
+ // plain-node jest env. The RN mirror of the math in the Flutter SDK's
3
+ // `_cropCardWorker`: the live-camera preview is shown BoxFit.cover in a fixed 3:4
4
+ // box with the ID-card guide centred at 88% width and `aspect` height; this maps
5
+ // that guide rectangle to pixel coordinates of the captured photo.
6
+
7
+ /** A crop rectangle in source-image pixels. */
8
+ export interface CropRect {
9
+ originX: number;
10
+ originY: number;
11
+ width: number;
12
+ height: number;
13
+ }
14
+
15
+ const VIEW_AR = 3 / 4; // viewfinder box width ÷ height
16
+ const GUIDE_WIDTH_FRACTION = 0.88; // card width as a fraction of the box (overlay GW/VB_W)
17
+
18
+ /**
19
+ * Given the photo's pixel size and the guide `aspect` (width ÷ height), return
20
+ * the card-guide crop rect in photo pixels:
21
+ * 1. the cover-fit 3:4 box shows a centred 3:4 slice of the photo,
22
+ * 2. the guide is centred in that slice (88% wide, `aspect` ratio).
23
+ */
24
+ export function cardCropRect(imageW: number, imageH: number, aspect: number): CropRect {
25
+ const photoAR = imageW / imageH;
26
+ let visW: number;
27
+ let visH: number;
28
+ let offX: number;
29
+ let offY: number;
30
+ if (photoAR > VIEW_AR) {
31
+ visH = imageH;
32
+ visW = imageH * VIEW_AR;
33
+ offX = (imageW - visW) / 2;
34
+ offY = 0;
35
+ } else {
36
+ visW = imageW;
37
+ visH = imageW / VIEW_AR;
38
+ offX = 0;
39
+ offY = (imageH - visH) / 2;
40
+ }
41
+ const leftFraction = (1 - GUIDE_WIDTH_FRACTION) / 2;
42
+ // card height ÷ box height = (cardWidthFrac · boxWidth / aspect) / boxHeight,
43
+ // and boxWidth ÷ boxHeight = VIEW_AR.
44
+ const heightFraction = (GUIDE_WIDTH_FRACTION * VIEW_AR) / aspect;
45
+ const topFraction = (1 - heightFraction) / 2;
46
+ return {
47
+ originX: offX + leftFraction * visW,
48
+ originY: offY + topFraction * visH,
49
+ width: GUIDE_WIDTH_FRACTION * visW,
50
+ height: heightFraction * visH,
51
+ };
52
+ }