@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.
- package/KycSdkReactNative.podspec +32 -0
- package/LICENSE +21 -0
- package/README.md +164 -0
- package/android/CMakeLists.txt +29 -0
- package/android/build.gradle +110 -0
- package/android/src/main/AndroidManifest.xml +12 -0
- package/android/src/main/cpp/cpp-adapter.cpp +12 -0
- package/android/src/main/java/co/myazahq/kyc/rn/MyazaFaceDetectorPackage.kt +36 -0
- package/android/src/main/java/co/myazahq/kyc/rn/MyazaStatusBarModule.kt +64 -0
- package/android/src/main/java/com/margelo/nitro/myazakyc/HybridMyazaFaceDetector.kt +134 -0
- package/app.plugin.js +55 -0
- package/expo-module.config.json +6 -0
- package/ios/HybridMyazaFaceDetector.swift +233 -0
- package/package.json +84 -0
- package/react-native.config.js +23 -0
- package/src/MyazaKYC.tsx +235 -0
- package/src/__tests__/cardCrop.test.ts +39 -0
- package/src/__tests__/deviceMetadata.test.ts +34 -0
- package/src/__tests__/errors.test.ts +34 -0
- package/src/__tests__/flow.test.ts +61 -0
- package/src/__tests__/gestureDetector.test.ts +37 -0
- package/src/__tests__/liveness.test.ts +112 -0
- package/src/__tests__/resolveUrl.test.ts +64 -0
- package/src/__tests__/validators.test.ts +38 -0
- package/src/assets/liveness/Blink.gif +0 -0
- package/src/assets/liveness/Nod.gif +0 -0
- package/src/assets/liveness/Smile.gif +0 -0
- package/src/assets/liveness/Turn.gif +0 -0
- package/src/components/CameraPermissionView.tsx +116 -0
- package/src/components/CameraViewfinder.tsx +156 -0
- package/src/components/CountryFlag.tsx +52 -0
- package/src/components/DocumentCropper.tsx +325 -0
- package/src/components/GlassIconButton.tsx +92 -0
- package/src/components/Icon.tsx +125 -0
- package/src/components/KycFlow.tsx +205 -0
- package/src/components/KycSheet.tsx +224 -0
- package/src/components/MyazaAlert.tsx +57 -0
- package/src/components/MyazaButton.tsx +101 -0
- package/src/components/MyazaCard.tsx +48 -0
- package/src/components/MyazaInput.tsx +112 -0
- package/src/components/MyazaPulseLoader.tsx +71 -0
- package/src/components/StatusBarController.tsx +42 -0
- package/src/components/StepHeader.tsx +56 -0
- package/src/components/StepIndicator.tsx +74 -0
- package/src/components/Typography.tsx +69 -0
- package/src/components/fonts.ts +49 -0
- package/src/components/glass/GlassGroup.tsx +34 -0
- package/src/components/glass/GlassSurface.tsx +64 -0
- package/src/components/runtime.tsx +93 -0
- package/src/components/toast.tsx +154 -0
- package/src/components/useBranding.ts +27 -0
- package/src/components/useVideoRecorder.ts +122 -0
- package/src/config/captureSettings.ts +67 -0
- package/src/config/idTypes.ts +79 -0
- package/src/config/theme.ts +186 -0
- package/src/index.ts +54 -0
- package/src/liveness/challengeManager.ts +130 -0
- package/src/liveness/faceDetector.ts +79 -0
- package/src/liveness/gestureDetector.ts +80 -0
- package/src/liveness/speech.ts +66 -0
- package/src/liveness/types.ts +99 -0
- package/src/liveness/useLiveness.ts +484 -0
- package/src/liveness/visionCameraFaceDetector.ts +118 -0
- package/src/screens/ConsentStep.tsx +164 -0
- package/src/screens/DocumentCaptureStep.tsx +500 -0
- package/src/screens/IdInputStep.tsx +79 -0
- package/src/screens/IdTypeStep.tsx +142 -0
- package/src/screens/LivenessAvatar.tsx +69 -0
- package/src/screens/LivenessStep.tsx +615 -0
- package/src/screens/SubmittedStep.tsx +177 -0
- package/src/services/api.ts +291 -0
- package/src/services/cardCrop.ts +52 -0
- package/src/services/deviceMetadata.ts +185 -0
- package/src/services/errors.ts +92 -0
- package/src/services/mediaCompress.ts +129 -0
- package/src/services/resolveUrl.ts +98 -0
- package/src/services/retry.ts +70 -0
- package/src/services/validators.ts +103 -0
- package/src/specs/MyazaFaceDetector.nitro.ts +44 -0
- package/src/store/kycStore.ts +288 -0
- package/src/store/serverConfig.ts +78 -0
- package/src/types/config.ts +239 -0
- package/src/types/country-flag-icons.d.ts +6 -0
- package/src/types/verification.ts +79 -0
- package/src/utils/platform.ts +23 -0
- package/src/utils/tokens.ts +10 -0
- package/src/utils/uuid.ts +31 -0
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
// Device & SDK metadata collected at verify time. Sent as `metadata.device` in
|
|
2
|
+
// the verify payload — the server merges it with server-side facts (real IP,
|
|
3
|
+
// X-SDK-Version header) before persisting. Mirrors the web SDK's
|
|
4
|
+
// `utils/device-metadata.ts` and the Flutter SDK's `device_metadata_service.dart`.
|
|
5
|
+
|
|
6
|
+
import { OS } from '../utils/platform';
|
|
7
|
+
|
|
8
|
+
export const SDK_TYPE = 'react-native' as const;
|
|
9
|
+
|
|
10
|
+
/** Coarse device class, consistent with the Web/Flutter SDKs' `device.type`. */
|
|
11
|
+
export type DeviceType = 'mobile' | 'tablet' | 'desktop' | 'unknown';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Single source of truth for the SDK version — also used by `services/api.ts`
|
|
15
|
+
* for the `X-SDK-Version` header. Keep in sync with `package.json`.
|
|
16
|
+
*/
|
|
17
|
+
export const SDK_VERSION = '2.0.0';
|
|
18
|
+
|
|
19
|
+
export interface ReactNativeDeviceMetadata {
|
|
20
|
+
sdkType: 'react-native';
|
|
21
|
+
sdkVersion: string;
|
|
22
|
+
sdkPlatform: 'ios' | 'android' | 'other';
|
|
23
|
+
capturedAt: string;
|
|
24
|
+
device: {
|
|
25
|
+
/**
|
|
26
|
+
* Coarse device class the dashboard reads first to classify a verification.
|
|
27
|
+
* Matches the Web/Flutter SDKs' `device.type`.
|
|
28
|
+
*/
|
|
29
|
+
type: DeviceType;
|
|
30
|
+
/** Device maker (from expo-device's `brand`); the dashboard reads `vendor`. */
|
|
31
|
+
vendor?: string;
|
|
32
|
+
brand?: string;
|
|
33
|
+
manufacturer?: string;
|
|
34
|
+
model?: string;
|
|
35
|
+
isDevice?: boolean;
|
|
36
|
+
};
|
|
37
|
+
os: {
|
|
38
|
+
name: string;
|
|
39
|
+
version?: string;
|
|
40
|
+
};
|
|
41
|
+
app?: {
|
|
42
|
+
version?: string;
|
|
43
|
+
buildVersion?: string;
|
|
44
|
+
bundleId?: string;
|
|
45
|
+
};
|
|
46
|
+
locale?: string;
|
|
47
|
+
timezone?: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Runs a literal `require(...)` loader, returning `undefined` if it throws.
|
|
52
|
+
* Metro needs the module name to be a STRING LITERAL (no `require(variable)`),
|
|
53
|
+
* so each caller passes its own literal loader. Under the Node test runner the
|
|
54
|
+
* native modules aren't resolvable and the loader throws — caught here.
|
|
55
|
+
*/
|
|
56
|
+
function tryRequire<T>(loader: () => T): T | undefined {
|
|
57
|
+
try {
|
|
58
|
+
return loader();
|
|
59
|
+
} catch {
|
|
60
|
+
return undefined;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function sdkPlatform(): ReactNativeDeviceMetadata['sdkPlatform'] {
|
|
65
|
+
if (OS === 'ios') return 'ios';
|
|
66
|
+
if (OS === 'android') return 'android';
|
|
67
|
+
return 'other';
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Clean OS name. expo-device's `osName` is unreliable on Android — on many devices
|
|
72
|
+
* (Samsung, Xiaomi, …) it returns the build FINGERPRINT, e.g.
|
|
73
|
+
* "samsung/e1q:16/BP2A…/S921U1…:user/release-keys", instead of "Android". So derive
|
|
74
|
+
* the name from the platform (matching the Flutter SDK, which hardcodes "Android");
|
|
75
|
+
* iOS reports "iOS"/"iPadOS" reliably, so keep expo-device's value there.
|
|
76
|
+
*/
|
|
77
|
+
function resolveOsName(device: ExpoDeviceModule | undefined): string {
|
|
78
|
+
if (OS === 'android') return 'Android';
|
|
79
|
+
if (OS === 'ios') return device?.osName ?? 'iOS';
|
|
80
|
+
return device?.osName ?? OS;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function safeTimezone(): string | undefined {
|
|
84
|
+
try {
|
|
85
|
+
return Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
86
|
+
} catch {
|
|
87
|
+
return undefined;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
interface ExpoDeviceModule {
|
|
92
|
+
brand?: string | null;
|
|
93
|
+
manufacturer?: string | null;
|
|
94
|
+
modelName?: string | null;
|
|
95
|
+
isDevice?: boolean;
|
|
96
|
+
osName?: string | null;
|
|
97
|
+
osVersion?: string | null;
|
|
98
|
+
/**
|
|
99
|
+
* expo-device's `DeviceType` enum value (numeric):
|
|
100
|
+
* UNKNOWN=0, PHONE=1, TABLET=2, DESKTOP=3, TV=4. `null` when undeterminable.
|
|
101
|
+
*/
|
|
102
|
+
deviceType?: number | null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Maps expo-device's numeric `DeviceType` to our coarse class, mirroring the
|
|
107
|
+
* Flutter/Web SDKs (PHONE→mobile, TABLET→tablet, DESKTOP→desktop,
|
|
108
|
+
* TV/UNKNOWN→unknown). When expo-device is unavailable or can't determine a type,
|
|
109
|
+
* falls back to the platform: ios/android → 'mobile', otherwise 'unknown'.
|
|
110
|
+
*/
|
|
111
|
+
export function inferDeviceType(device: { deviceType?: number | null } | undefined): DeviceType {
|
|
112
|
+
switch (device?.deviceType) {
|
|
113
|
+
case 1:
|
|
114
|
+
return 'mobile'; // PHONE
|
|
115
|
+
case 2:
|
|
116
|
+
return 'tablet'; // TABLET
|
|
117
|
+
case 3:
|
|
118
|
+
return 'desktop'; // DESKTOP
|
|
119
|
+
case 0: // UNKNOWN
|
|
120
|
+
case 4: // TV
|
|
121
|
+
return 'unknown';
|
|
122
|
+
default:
|
|
123
|
+
// expo-device unavailable / deviceType null — use the platform.
|
|
124
|
+
return OS === 'ios' || OS === 'android' ? 'mobile' : 'unknown';
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
interface ExpoApplicationModule {
|
|
129
|
+
nativeApplicationVersion?: string | null;
|
|
130
|
+
nativeBuildVersion?: string | null;
|
|
131
|
+
applicationId?: string | null;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
interface ExpoLocalizationModule {
|
|
135
|
+
getLocales?: () => Array<{ languageTag?: string }>;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Collects best-effort device metadata. Every field degrades gracefully — if
|
|
140
|
+
* `expo-device` / `expo-application` / `expo-localization` aren't installed (or
|
|
141
|
+
* under a Node test runner), the relevant fields are simply omitted.
|
|
142
|
+
*/
|
|
143
|
+
export function collectDeviceMetadata(): ReactNativeDeviceMetadata {
|
|
144
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
145
|
+
const device = tryRequire<ExpoDeviceModule>(() => require('expo-device'));
|
|
146
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
147
|
+
const application = tryRequire<ExpoApplicationModule>(() => require('expo-application'));
|
|
148
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
149
|
+
const localization = tryRequire<ExpoLocalizationModule>(() => require('expo-localization'));
|
|
150
|
+
|
|
151
|
+
const meta: ReactNativeDeviceMetadata = {
|
|
152
|
+
sdkType: SDK_TYPE,
|
|
153
|
+
sdkVersion: SDK_VERSION,
|
|
154
|
+
sdkPlatform: sdkPlatform(),
|
|
155
|
+
capturedAt: new Date().toISOString(),
|
|
156
|
+
device: {
|
|
157
|
+
type: inferDeviceType(device),
|
|
158
|
+
vendor: device?.brand ?? undefined,
|
|
159
|
+
brand: device?.brand ?? undefined,
|
|
160
|
+
manufacturer: device?.manufacturer ?? undefined,
|
|
161
|
+
model: device?.modelName ?? undefined,
|
|
162
|
+
isDevice: device?.isDevice,
|
|
163
|
+
},
|
|
164
|
+
os: {
|
|
165
|
+
name: resolveOsName(device),
|
|
166
|
+
version: device?.osVersion ?? undefined,
|
|
167
|
+
},
|
|
168
|
+
timezone: safeTimezone(),
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
if (application) {
|
|
172
|
+
meta.app = {
|
|
173
|
+
version: application.nativeApplicationVersion ?? undefined,
|
|
174
|
+
buildVersion: application.nativeBuildVersion ?? undefined,
|
|
175
|
+
bundleId: application.applicationId ?? undefined,
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const locales = localization?.getLocales?.();
|
|
180
|
+
if (locales && locales.length > 0) {
|
|
181
|
+
meta.locale = locales[0]?.languageTag;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return meta;
|
|
185
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// mapToKycError — turn a raw network/API error into a typed KYCError
|
|
3
|
+
//
|
|
4
|
+
// Used at every server call site (upload, verify) so the `onError` callback
|
|
5
|
+
// always receives a documented, typed `code`. Mirrors the web SDK's
|
|
6
|
+
// `lib/errors.ts` and the Flutter SDK's `kyc_error_mapper.dart` so all
|
|
7
|
+
// platforms surface the same codes.
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
|
|
10
|
+
import { KYCApiError } from './api';
|
|
11
|
+
import { KYCError, type KYCErrorCode } from '../types/verification';
|
|
12
|
+
|
|
13
|
+
/** Which operation failed — picks the fallback code for non-HTTP failures. */
|
|
14
|
+
export type ErrorContext = 'upload' | 'verify';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Invokes the consumer's `onError` handler defensively — a handler that throws
|
|
18
|
+
* must never crash the SDK flow. Swallows the throw and warns instead.
|
|
19
|
+
*/
|
|
20
|
+
export function safeReportError(
|
|
21
|
+
onError: ((error: KYCError) => void) | undefined,
|
|
22
|
+
error: KYCError,
|
|
23
|
+
): void {
|
|
24
|
+
if (!onError) return;
|
|
25
|
+
try {
|
|
26
|
+
onError(error);
|
|
27
|
+
} catch (err) {
|
|
28
|
+
if (typeof console !== 'undefined') {
|
|
29
|
+
console.warn('[MyazaKYC] onError handler threw and was ignored:', err);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function toNum(v: unknown): number | undefined {
|
|
35
|
+
if (typeof v === 'number') return v;
|
|
36
|
+
if (typeof v === 'string') {
|
|
37
|
+
const n = parseFloat(v);
|
|
38
|
+
return Number.isNaN(n) ? undefined : n;
|
|
39
|
+
}
|
|
40
|
+
return undefined;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Maps an unknown error thrown by the API client to a typed {@link KYCError}
|
|
45
|
+
* with a user-facing message. `context` selects the fallback code when the
|
|
46
|
+
* failure isn't a specific HTTP status (a bare network failure during an upload
|
|
47
|
+
* becomes `upload_failed`, during verify becomes `network_error`).
|
|
48
|
+
*/
|
|
49
|
+
export function mapToKycError(err: unknown, context: ErrorContext): KYCError {
|
|
50
|
+
if (err instanceof KYCApiError) {
|
|
51
|
+
if (err.statusCode === 401) {
|
|
52
|
+
return new KYCError('invalid_api_key', 'Invalid API key. Please contact support.');
|
|
53
|
+
}
|
|
54
|
+
if (err.statusCode === 402) {
|
|
55
|
+
const body = err.body ?? {};
|
|
56
|
+
const required = toNum(body.required);
|
|
57
|
+
const balance = toNum(body.balance);
|
|
58
|
+
const currency = typeof body.currency === 'string' ? body.currency : undefined;
|
|
59
|
+
const message =
|
|
60
|
+
required !== undefined && balance !== undefined
|
|
61
|
+
? `Insufficient credits. Required: $${required.toFixed(2)}, Available: $${balance.toFixed(2)}`
|
|
62
|
+
: 'Insufficient credits to process this verification.';
|
|
63
|
+
return new KYCError('insufficient_credits', message, { required, balance, currency });
|
|
64
|
+
}
|
|
65
|
+
if (err.statusCode === 403) {
|
|
66
|
+
const feature = typeof err.body?.feature === 'string' ? err.body.feature : null;
|
|
67
|
+
const message =
|
|
68
|
+
err.code === 'id_type_not_allowed'
|
|
69
|
+
? "This ID type isn't enabled for your organization. Contact your administrator to request access."
|
|
70
|
+
: feature === 'document_verification'
|
|
71
|
+
? 'Document verification is currently disabled for your organization.'
|
|
72
|
+
: feature === 'gov_db_check'
|
|
73
|
+
? 'Government database verification is currently disabled for your organization.'
|
|
74
|
+
: err.message || 'This verification feature is currently disabled for your organization.';
|
|
75
|
+
return new KYCError('feature_disabled', message);
|
|
76
|
+
}
|
|
77
|
+
if (err.statusCode >= 500 || err.statusCode === 0) {
|
|
78
|
+
// Transient server error that survived retries.
|
|
79
|
+
const code: KYCErrorCode = context === 'upload' ? 'upload_failed' : 'network_error';
|
|
80
|
+
return new KYCError(code, 'A server error occurred. Please try again in a moment.');
|
|
81
|
+
}
|
|
82
|
+
// Other 4xx — pass the server message through under the context's code.
|
|
83
|
+
const code: KYCErrorCode = context === 'upload' ? 'upload_failed' : 'unknown';
|
|
84
|
+
return new KYCError(code, err.message);
|
|
85
|
+
}
|
|
86
|
+
// fetch() throws a TypeError on network failure (offline / DNS).
|
|
87
|
+
if (err instanceof TypeError) {
|
|
88
|
+
return new KYCError('network_error', 'Network error. Please check your connection and try again.');
|
|
89
|
+
}
|
|
90
|
+
const code: KYCErrorCode = context === 'upload' ? 'upload_failed' : 'unknown';
|
|
91
|
+
return new KYCError(code, err instanceof Error ? err.message : 'Something went wrong. Please try again.');
|
|
92
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { Image } from 'react-native';
|
|
2
|
+
import * as ImageManipulator from 'expo-image-manipulator';
|
|
3
|
+
import { Video } from 'react-native-compressor';
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
DOCUMENT_IMAGE_QUALITY,
|
|
7
|
+
DOCUMENT_MAX_DIMENSION,
|
|
8
|
+
SELFIE_IMAGE_QUALITY,
|
|
9
|
+
VIDEO_COMPRESS_BITRATE,
|
|
10
|
+
VIDEO_COMPRESS_MAX_SIZE,
|
|
11
|
+
} from '../config/captureSettings';
|
|
12
|
+
import { cardCropRect, type CropRect } from './cardCrop';
|
|
13
|
+
|
|
14
|
+
export type { CropRect } from './cardCrop';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Transcodes a recorded clip down to a small evidence video (best-effort).
|
|
18
|
+
* VisionCamera v5 records at the document camera's high-res 4K session format on
|
|
19
|
+
* iOS and ignores the bitrate/resolution hints, so the raw file is far too large;
|
|
20
|
+
* this shrinks it (mirrors the Flutter SDK's video_compress). On failure it returns
|
|
21
|
+
* the original URI — the caller's size guard then drops it if still over the cap.
|
|
22
|
+
*/
|
|
23
|
+
export async function compressVideo(uri: string): Promise<string> {
|
|
24
|
+
try {
|
|
25
|
+
const out = await Video.compress(uri, {
|
|
26
|
+
compressionMethod: 'manual',
|
|
27
|
+
maxSize: VIDEO_COMPRESS_MAX_SIZE,
|
|
28
|
+
bitrate: VIDEO_COMPRESS_BITRATE,
|
|
29
|
+
});
|
|
30
|
+
return out.startsWith('file://') ? out : `file://${out}`;
|
|
31
|
+
} catch {
|
|
32
|
+
return uri;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Post-capture still-image compression — the RN mirror of the Flutter SDK's
|
|
37
|
+
// media_compress_service.dart (and the web SDK's useImageCompress). Effort scales
|
|
38
|
+
// to need:
|
|
39
|
+
// • DOCUMENT — conservative (OCR-critical): JPEG q0.9, only downscaled when the
|
|
40
|
+
// longest edge exceeds DOCUMENT_MAX_DIMENSION, so small text stays legible.
|
|
41
|
+
// • SELFIE — moderate: JPEG q0.8, capped to ~1280 px.
|
|
42
|
+
// Runs natively via expo-image-manipulator (off the JS thread).
|
|
43
|
+
|
|
44
|
+
export function imageSize(uri: string): Promise<{ width: number; height: number }> {
|
|
45
|
+
return new Promise((resolve, reject) =>
|
|
46
|
+
Image.getSize(uri, (width, height) => resolve({ width, height }), reject),
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Crop [uri] to [rect] (source-image pixels) and return a new high-quality JPEG
|
|
52
|
+
* URI — the RN mirror of the Flutter SDK's `cropAndCompress` / `cropCardRegion`.
|
|
53
|
+
* Kept near-lossless here (q0.95); OCR-grade sizing happens in
|
|
54
|
+
* `compressDocumentImage` afterwards, exactly like Flutter.
|
|
55
|
+
*/
|
|
56
|
+
export async function cropImage(uri: string, rect: CropRect): Promise<string> {
|
|
57
|
+
const result = await ImageManipulator.manipulateAsync(
|
|
58
|
+
uri,
|
|
59
|
+
[{ crop: { originX: Math.round(rect.originX), originY: Math.round(rect.originY), width: Math.round(rect.width), height: Math.round(rect.height) } }],
|
|
60
|
+
{ compress: 0.95, format: ImageManipulator.SaveFormat.JPEG },
|
|
61
|
+
);
|
|
62
|
+
return result.uri;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Crop a live-camera capture to the card-guide rectangle painted over the
|
|
67
|
+
* viewfinder — the RN mirror of the Flutter SDK's `cropCardRegion`.
|
|
68
|
+
*
|
|
69
|
+
* The CameraViewfinder shows the preview `BoxFit.cover` inside a fixed 3:4 box,
|
|
70
|
+
* with the guide centred at 88% width and `aspect` height (mirrors the SVG
|
|
71
|
+
* overlay). So the crop is computed purely from the photo's pixel size + those
|
|
72
|
+
* fractions — no absolute viewfinder size needed:
|
|
73
|
+
* 1. the 3:4 box shows a centred 3:4 slice of the photo (cover),
|
|
74
|
+
* 2. the guide is a sub-rect of that slice (88% wide, `aspect` ratio, centred).
|
|
75
|
+
*/
|
|
76
|
+
/**
|
|
77
|
+
* Crop a live-camera capture to the card-guide rectangle painted over the
|
|
78
|
+
* viewfinder — the RN mirror of the Flutter SDK's `cropCardRegion`. The
|
|
79
|
+
* CameraViewfinder shows the preview `BoxFit.cover` in a fixed 3:4 box with the
|
|
80
|
+
* guide centred at 88% width and `aspect` height (mirrors the SVG overlay).
|
|
81
|
+
*/
|
|
82
|
+
export async function cropCardRegion(uri: string, aspect: number): Promise<string> {
|
|
83
|
+
const { width, height } = await imageSize(uri);
|
|
84
|
+
return cropImage(uri, cardCropRect(width, height, aspect));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Compress a document still for OCR. Returns a new file URI. */
|
|
88
|
+
export async function compressDocumentImage(uri: string): Promise<string> {
|
|
89
|
+
let actions: ImageManipulator.Action[] = [];
|
|
90
|
+
try {
|
|
91
|
+
const { width, height } = await imageSize(uri);
|
|
92
|
+
const longest = Math.max(width, height);
|
|
93
|
+
if (longest > DOCUMENT_MAX_DIMENSION) {
|
|
94
|
+
actions = [
|
|
95
|
+
width >= height
|
|
96
|
+
? { resize: { width: DOCUMENT_MAX_DIMENSION } }
|
|
97
|
+
: { resize: { height: DOCUMENT_MAX_DIMENSION } },
|
|
98
|
+
];
|
|
99
|
+
}
|
|
100
|
+
} catch {
|
|
101
|
+
/* size unknown — recompress without resizing */
|
|
102
|
+
}
|
|
103
|
+
const result = await ImageManipulator.manipulateAsync(uri, actions, {
|
|
104
|
+
compress: DOCUMENT_IMAGE_QUALITY,
|
|
105
|
+
format: ImageManipulator.SaveFormat.JPEG,
|
|
106
|
+
});
|
|
107
|
+
return result.uri;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Compress a selfie still (moderate). Returns a new file URI. */
|
|
111
|
+
export async function compressSelfieImage(uri: string): Promise<string> {
|
|
112
|
+
let actions: ImageManipulator.Action[] = [];
|
|
113
|
+
try {
|
|
114
|
+
const { width, height } = await imageSize(uri);
|
|
115
|
+
const longest = Math.max(width, height);
|
|
116
|
+
if (longest > 1280) {
|
|
117
|
+
actions = [
|
|
118
|
+
width >= height ? { resize: { width: 1280 } } : { resize: { height: 1280 } },
|
|
119
|
+
];
|
|
120
|
+
}
|
|
121
|
+
} catch {
|
|
122
|
+
/* size unknown — recompress without resizing */
|
|
123
|
+
}
|
|
124
|
+
const result = await ImageManipulator.manipulateAsync(uri, actions, {
|
|
125
|
+
compress: SELFIE_IMAGE_QUALITY,
|
|
126
|
+
format: ImageManipulator.SaveFormat.JPEG,
|
|
127
|
+
});
|
|
128
|
+
return result.uri;
|
|
129
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// Automatic environment detection from the API key prefix
|
|
3
|
+
//
|
|
4
|
+
// The environment is encoded in the key prefix — the single source of truth
|
|
5
|
+
// (there is no manual environment option). The prefix carries scope
|
|
6
|
+
// (`pk` publishable / `sk` secret) and environment (`dev`/`test`/`live`); we
|
|
7
|
+
// read ONLY the environment portion, so detection works for both key types.
|
|
8
|
+
// Mirrors the web SDK's `resolve-url.ts` and the Flutter SDK's `resolve_url.dart`:
|
|
9
|
+
//
|
|
10
|
+
// pk_dev_… / sk_dev_… → development
|
|
11
|
+
// pk_test_… / sk_test_… → sandbox
|
|
12
|
+
// pk_live_… / sk_live_… → production
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
|
|
15
|
+
import { isAndroid } from '../utils/platform';
|
|
16
|
+
|
|
17
|
+
/** Internal environment the SDK resolves a base URL for. Not a public option. */
|
|
18
|
+
export type SdkEnvironment = 'development' | 'sandbox' | 'production';
|
|
19
|
+
|
|
20
|
+
/** Canonical base URLs for the non-development environments. */
|
|
21
|
+
const BASE_URLS: Record<Exclude<SdkEnvironment, 'development'>, string> = {
|
|
22
|
+
sandbox: 'https://sandbox.identity.myaza.app',
|
|
23
|
+
production: 'https://identity.myaza.app',
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Default base URL used for development keys when no `devUrl` is provided.
|
|
28
|
+
* Android emulators reach the host machine via `10.0.2.2`; everywhere else
|
|
29
|
+
* (iOS simulator, desktop) `localhost` works directly.
|
|
30
|
+
*/
|
|
31
|
+
function defaultDevUrl(): string {
|
|
32
|
+
return isAndroid ? 'http://10.0.2.2:3001' : 'http://localhost:3001';
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Matches the environment slot of a Myaza API key prefix, regardless of the
|
|
36
|
+
// pk_/sk_ scope.
|
|
37
|
+
const KEY_ENV_RE = /^(?:pk|sk)_(dev|test|live)_/;
|
|
38
|
+
|
|
39
|
+
const ENV_BY_PREFIX: Record<'dev' | 'test' | 'live', SdkEnvironment> = {
|
|
40
|
+
dev: 'development',
|
|
41
|
+
test: 'sandbox',
|
|
42
|
+
live: 'production',
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Derives the environment from the API key prefix. Throws a clear error on an
|
|
47
|
+
* unrecognized / malformed key — never silently defaults (defaulting to
|
|
48
|
+
* production would be dangerous).
|
|
49
|
+
*/
|
|
50
|
+
export function detectEnvironment(apiKey: string): SdkEnvironment {
|
|
51
|
+
const match = typeof apiKey === 'string' ? apiKey.match(KEY_ENV_RE) : null;
|
|
52
|
+
if (!match) {
|
|
53
|
+
throw new Error(
|
|
54
|
+
'Invalid Myaza API key: expected a dev, test, or live key prefix ' +
|
|
55
|
+
'(e.g. pk_dev_…, pk_test_…, or pk_live_…).',
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
return ENV_BY_PREFIX[match[1] as 'dev' | 'test' | 'live'];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Resolves the API base URL from the API key. The environment is detected from
|
|
63
|
+
* the key prefix:
|
|
64
|
+
* - development → `devUrl` if provided, otherwise a platform-aware localhost.
|
|
65
|
+
* - sandbox / production → the hardcoded URL (`devUrl` is ignored).
|
|
66
|
+
*
|
|
67
|
+
* Throws on an invalid key (via {@link detectEnvironment}).
|
|
68
|
+
*/
|
|
69
|
+
export function resolveBaseUrl(apiKey: string, devUrl?: string): string {
|
|
70
|
+
const environment = detectEnvironment(apiKey);
|
|
71
|
+
if (environment === 'development') {
|
|
72
|
+
return devUrl ?? defaultDevUrl();
|
|
73
|
+
}
|
|
74
|
+
return BASE_URLS[environment];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Hosts that mean "this machine" but resolve differently per platform — a local
|
|
78
|
+
// dev server reachable as `localhost` on the iOS sim and `10.0.2.2` on the
|
|
79
|
+
// Android emulator.
|
|
80
|
+
const LOCAL_HOST_RE = /^https?:\/\/(localhost|127\.0\.0\.1|10\.0\.2\.2)(:\d+)?/i;
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Normalizes a server-provided absolute asset URL (e.g. the branding logo) for
|
|
84
|
+
* local development. The dev server often returns a hardcoded `localhost` origin,
|
|
85
|
+
* which the Android emulator can't reach. When the SDK is pointed at a local dev
|
|
86
|
+
* server (an `http://` base) and the asset points at a localhost-family host, its
|
|
87
|
+
* origin is rewritten to the SDK's base origin so it loads on every platform.
|
|
88
|
+
*
|
|
89
|
+
* Production / sandbox URLs (`https://`) and assets on any other host (e.g. a
|
|
90
|
+
* public CDN) are returned untouched.
|
|
91
|
+
*/
|
|
92
|
+
export function normalizeDevAssetUrl(url: string | undefined, baseUrl: string): string | undefined {
|
|
93
|
+
if (!url) return url;
|
|
94
|
+
// Only ever rewrite for a local (http) dev base — never production CDNs.
|
|
95
|
+
if (!baseUrl.startsWith('http://')) return url;
|
|
96
|
+
if (!LOCAL_HOST_RE.test(url)) return url;
|
|
97
|
+
return url.replace(LOCAL_HOST_RE, baseUrl.replace(/\/+$/, ''));
|
|
98
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// withRetry — shared retry/backoff for the SDK's network operations
|
|
3
|
+
//
|
|
4
|
+
// Wraps the SDK's network calls (media upload, verify submission) so transient
|
|
5
|
+
// failures — lost connection, timeouts, 5xx — are retried with exponential
|
|
6
|
+
// backoff + jitter before giving up. Terminal failures (4xx auth / credits /
|
|
7
|
+
// forbidden) are NOT retried; they surface immediately.
|
|
8
|
+
//
|
|
9
|
+
// Mirrors the web SDK's `lib/retry.ts` and the Flutter SDK's `retry.dart` so all
|
|
10
|
+
// platforms behave identically. After retries are exhausted the original error
|
|
11
|
+
// is rethrown — the caller maps it to a typed KYCError for `onError`.
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
|
|
14
|
+
import { KYCApiError } from './api';
|
|
15
|
+
|
|
16
|
+
export interface RetryOptions {
|
|
17
|
+
/** Total attempts including the first try. Default 3. */
|
|
18
|
+
retries?: number;
|
|
19
|
+
/** Base delay before the first retry (ms). Default 500. */
|
|
20
|
+
baseDelayMs?: number;
|
|
21
|
+
/** Backoff multiplier between attempts. Default 2. */
|
|
22
|
+
factor?: number;
|
|
23
|
+
/** Max delay cap (ms). Default 4000. */
|
|
24
|
+
maxDelayMs?: number;
|
|
25
|
+
/** Notified before each retry with the upcoming attempt number (2-based) + total. */
|
|
26
|
+
onRetry?: (attempt: number, total: number) => void;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Whether an error is transient (worth retrying). A `KYCApiError` is transient
|
|
31
|
+
* only for 5xx (or 0); 4xx are terminal. A `TypeError` from `fetch()` is a
|
|
32
|
+
* network failure — transient. Anything else is terminal.
|
|
33
|
+
*/
|
|
34
|
+
export function isTransientError(err: unknown): boolean {
|
|
35
|
+
if (err instanceof KYCApiError) {
|
|
36
|
+
return err.statusCode >= 500 || err.statusCode === 0;
|
|
37
|
+
}
|
|
38
|
+
// fetch() rejects with a TypeError on network failure.
|
|
39
|
+
if (err instanceof TypeError) return true;
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Runs `fn`, retrying on transient errors with exponential backoff + jitter.
|
|
47
|
+
* Rethrows the last error once attempts are exhausted (or immediately for a
|
|
48
|
+
* terminal error).
|
|
49
|
+
*/
|
|
50
|
+
export async function withRetry<T>(fn: () => Promise<T>, options: RetryOptions = {}): Promise<T> {
|
|
51
|
+
const { retries = 3, baseDelayMs = 500, factor = 2, maxDelayMs = 4000, onRetry } = options;
|
|
52
|
+
|
|
53
|
+
let lastError: unknown;
|
|
54
|
+
for (let attempt = 1; attempt <= retries; attempt++) {
|
|
55
|
+
try {
|
|
56
|
+
return await fn();
|
|
57
|
+
} catch (err) {
|
|
58
|
+
lastError = err;
|
|
59
|
+
const hasMore = attempt < retries;
|
|
60
|
+
if (!hasMore || !isTransientError(err)) throw err;
|
|
61
|
+
|
|
62
|
+
onRetry?.(attempt + 1, retries);
|
|
63
|
+
const backoff = Math.min(baseDelayMs * factor ** (attempt - 1), maxDelayMs);
|
|
64
|
+
// Full jitter — spread retries so a flaky network doesn't see synchronized bursts.
|
|
65
|
+
const delay = Math.random() * backoff;
|
|
66
|
+
await sleep(delay);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
throw lastError;
|
|
70
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import type { IdType, SupportedCountry } from '../types/config';
|
|
2
|
+
|
|
3
|
+
// Per-ID-type format validation — identical rules to the web SDK's
|
|
4
|
+
// `utils/validators.ts` and the Flutter SDK's `validators.dart`.
|
|
5
|
+
|
|
6
|
+
export interface ValidationResult {
|
|
7
|
+
valid: boolean;
|
|
8
|
+
message?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const DIGITS_ONLY = /^\d+$/;
|
|
12
|
+
|
|
13
|
+
function digitsExact(value: string, count: number, label: string): ValidationResult {
|
|
14
|
+
if (!DIGITS_ONLY.test(value)) {
|
|
15
|
+
return { valid: false, message: `${label} must contain only digits` };
|
|
16
|
+
}
|
|
17
|
+
if (value.length !== count) {
|
|
18
|
+
return { valid: false, message: `${label} must be exactly ${count} digits` };
|
|
19
|
+
}
|
|
20
|
+
return { valid: true };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function matchesPattern(value: string, pattern: RegExp, label: string, hint: string): ValidationResult {
|
|
24
|
+
if (!pattern.test(value)) {
|
|
25
|
+
return { valid: false, message: `Invalid ${label} format (${hint})` };
|
|
26
|
+
}
|
|
27
|
+
return { valid: true };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
// Per-ID-type validators (country-prefixed where a key is shared across countries)
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
const validators: Record<string, (value: string) => ValidationResult> = {
|
|
35
|
+
// Nigeria
|
|
36
|
+
bvn: (v) => digitsExact(v, 11, 'BVN'),
|
|
37
|
+
nin: (v) => digitsExact(v, 11, 'NIN'),
|
|
38
|
+
vnin: (v) => {
|
|
39
|
+
if (v.length !== 16) {
|
|
40
|
+
return { valid: false, message: 'vNIN must be exactly 16 characters' };
|
|
41
|
+
}
|
|
42
|
+
return { valid: true };
|
|
43
|
+
},
|
|
44
|
+
'ng-passport': (v) => matchesPattern(v, /^[A-Z]\d{8}$/, 'Passport', 'e.g. A12345678'),
|
|
45
|
+
'ng-drivers-license': (v) => matchesPattern(v, /^[A-Z]{3}\d{5,12}$/, "Driver's License", 'e.g. ABC12345'),
|
|
46
|
+
pvc: (v) => digitsExact(v, 19, "Voter's Card (PVC)"),
|
|
47
|
+
|
|
48
|
+
// Ghana
|
|
49
|
+
'ghana-card': (v) => matchesPattern(v, /^GHA-\d{9}-\d$/, 'Ghana Card', 'e.g. GHA-123456789-0'),
|
|
50
|
+
'gh-voters': (v) => digitsExact(v, 10, "Voter's Card"),
|
|
51
|
+
ssnit: (v) => digitsExact(v, 13, 'SSNIT'),
|
|
52
|
+
'gh-passport': (v) => matchesPattern(v, /^[A-Z]\d{7}$/, 'Passport', 'e.g. A1234567'),
|
|
53
|
+
|
|
54
|
+
// Kenya
|
|
55
|
+
'ke-national-id': (v) => digitsExact(v, 8, 'National ID'),
|
|
56
|
+
|
|
57
|
+
// South Africa
|
|
58
|
+
'za-national-id': (v) => digitsExact(v, 13, 'National ID'),
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
function resolveKey(country: SupportedCountry, idType: IdType): string {
|
|
62
|
+
// Some ID types share a name across countries (passport, drivers-license,
|
|
63
|
+
// national-id, voters). Prefix those with the country code so they map to the
|
|
64
|
+
// country-specific validator.
|
|
65
|
+
const needsPrefix: Record<string, Set<string>> = {
|
|
66
|
+
passport: new Set(['NG', 'GH']),
|
|
67
|
+
'drivers-license': new Set(['NG']),
|
|
68
|
+
'national-id': new Set(['KE', 'ZA']),
|
|
69
|
+
voters: new Set(['GH']),
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const prefixSet = needsPrefix[idType];
|
|
73
|
+
if (prefixSet?.has(country)) {
|
|
74
|
+
return `${country.toLowerCase()}-${idType}`;
|
|
75
|
+
}
|
|
76
|
+
return idType;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function validateIdNumber(
|
|
80
|
+
country: SupportedCountry,
|
|
81
|
+
idType: IdType,
|
|
82
|
+
value: string,
|
|
83
|
+
): ValidationResult {
|
|
84
|
+
const trimmed = value.trim();
|
|
85
|
+
if (!trimmed) {
|
|
86
|
+
return { valid: false, message: 'ID number is required' };
|
|
87
|
+
}
|
|
88
|
+
const validator = validators[resolveKey(country, idType)];
|
|
89
|
+
if (!validator) {
|
|
90
|
+
// No specific validator — accept non-empty input.
|
|
91
|
+
return { valid: true };
|
|
92
|
+
}
|
|
93
|
+
return validator(trimmed);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Masks an ID number for display — never log the raw value. */
|
|
97
|
+
export function maskIdNumber(idNumber: string): string {
|
|
98
|
+
if (idNumber.length <= 7) return idNumber;
|
|
99
|
+
const first4 = idNumber.slice(0, 4);
|
|
100
|
+
const last3 = idNumber.slice(-3);
|
|
101
|
+
const masked = '*'.repeat(idNumber.length - 7);
|
|
102
|
+
return `${first4}${masked}${last3}`;
|
|
103
|
+
}
|