kreltix-react-native-sdk 0.1.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/package.json +22 -0
- package/src/KreltixSDK.ts +114 -0
- package/src/api/client.ts +136 -0
- package/src/api/types.ts +196 -0
- package/src/camera/CameraRecorder.ts +46 -0
- package/src/errors/KreltixError.ts +60 -0
- package/src/hooks/useLiveness.ts +248 -0
- package/src/index.ts +50 -0
- package/src/session/SessionManager.ts +37 -0
- package/src/ui/LivenessView.tsx +313 -0
- package/src/utils/retry.ts +28 -0
- package/src/utils/video.ts +31 -0
- package/tsconfig.json +14 -0
package/package.json
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "kreltix-react-native-sdk",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Kreltix Liveness & Face-Match SDK for React Native (Expo)",
|
|
5
|
+
"main": "src/index.ts",
|
|
6
|
+
"types": "src/index.ts",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"typecheck": "tsc --noEmit"
|
|
9
|
+
},
|
|
10
|
+
"peerDependencies": {
|
|
11
|
+
"expo-camera": ">=16.0.0",
|
|
12
|
+
"react": ">=18.0.0",
|
|
13
|
+
"react-native": ">=0.74.0"
|
|
14
|
+
},
|
|
15
|
+
"devDependencies": {
|
|
16
|
+
"@types/react": "^18.0.0",
|
|
17
|
+
"@types/react-native": "^0.74.0",
|
|
18
|
+
"typescript": "^5.3.0"
|
|
19
|
+
},
|
|
20
|
+
"keywords": ["kreltix", "liveness", "face", "verification", "kyc", "react-native", "expo"],
|
|
21
|
+
"license": "MIT"
|
|
22
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { ApiClient } from './api/client';
|
|
2
|
+
import { KreltixError } from './errors/KreltixError';
|
|
3
|
+
import type {
|
|
4
|
+
SDKConfig,
|
|
5
|
+
SDKOptions,
|
|
6
|
+
FaceMatchOptions,
|
|
7
|
+
FaceMatchResult,
|
|
8
|
+
CombinedFaceMatchOptions,
|
|
9
|
+
CombinedFaceMatchResult,
|
|
10
|
+
} from './api/types';
|
|
11
|
+
|
|
12
|
+
const DEFAULT_BASE_URL = 'https://api.kreltix.com';
|
|
13
|
+
const DEFAULT_TIMEOUT = 10_000;
|
|
14
|
+
const DEFAULT_MAX_RETRIES = 2;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* KreltixSDK — programmatic access to the Kreltix API without camera UI.
|
|
18
|
+
*
|
|
19
|
+
* For camera-based liveness verification, use <KreltixLivenessView> or useLiveness().
|
|
20
|
+
*
|
|
21
|
+
* @example
|
|
22
|
+
* const sdk = KreltixSDK.initialize('pk_live_...');
|
|
23
|
+
* const result = await sdk.faceMatch({ idCardImage: '...', selfieImage: '...' });
|
|
24
|
+
*/
|
|
25
|
+
export class KreltixSDK {
|
|
26
|
+
private apiClient: ApiClient;
|
|
27
|
+
|
|
28
|
+
private constructor(private readonly config: SDKConfig) {
|
|
29
|
+
this.apiClient = new ApiClient(config);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
static initialize(publicKey: string, options?: SDKOptions): KreltixSDK {
|
|
33
|
+
if (!publicKey.startsWith('pk_live_')) {
|
|
34
|
+
throw new KreltixError(
|
|
35
|
+
"Invalid key. Use a public key (pk_live_...) for the client-side SDK.",
|
|
36
|
+
'INVALID_KEY',
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
return new KreltixSDK({
|
|
40
|
+
publicKey,
|
|
41
|
+
baseUrl: options?.baseUrl ?? DEFAULT_BASE_URL,
|
|
42
|
+
timeout: options?.timeout ?? DEFAULT_TIMEOUT,
|
|
43
|
+
maxRetries: options?.maxRetries ?? DEFAULT_MAX_RETRIES,
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Compare two face images. No camera session required.
|
|
49
|
+
* Useful for one-shot face verification when you already have both images.
|
|
50
|
+
*/
|
|
51
|
+
async faceMatch(options: FaceMatchOptions): Promise<FaceMatchResult> {
|
|
52
|
+
const raw = await this.apiClient.verifyFace(
|
|
53
|
+
options.idCardImage,
|
|
54
|
+
options.selfieImage,
|
|
55
|
+
options.metadata,
|
|
56
|
+
);
|
|
57
|
+
return {
|
|
58
|
+
requestId: raw.request_id,
|
|
59
|
+
match: raw.match,
|
|
60
|
+
distance: raw.distance,
|
|
61
|
+
verified: raw.verified,
|
|
62
|
+
status: raw.status,
|
|
63
|
+
error: raw.error,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Run liveness + face match in a single API call without a camera session.
|
|
69
|
+
* For standard tier: provide `image` (selfie base64 or URL).
|
|
70
|
+
* For premium tier: provide `video` or `videoUrl` + `challenge`.
|
|
71
|
+
*/
|
|
72
|
+
async combinedFaceMatch(options: CombinedFaceMatchOptions): Promise<CombinedFaceMatchResult> {
|
|
73
|
+
const { idCardImage, tier = 'standard', metadata, image, video, videoUrl, challenge } = options;
|
|
74
|
+
|
|
75
|
+
const body: Record<string, unknown> = {
|
|
76
|
+
tier,
|
|
77
|
+
id_card_image: idCardImage,
|
|
78
|
+
...(metadata ? { metadata } : {}),
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
if (tier === 'standard') {
|
|
82
|
+
if (!image) throw new KreltixError('Standard tier requires an `image`.', 'VALIDATION_ERROR');
|
|
83
|
+
body.image = image;
|
|
84
|
+
} else {
|
|
85
|
+
if (!video && !videoUrl) {
|
|
86
|
+
throw new KreltixError('Premium tier requires `video` or `videoUrl`.', 'VALIDATION_ERROR');
|
|
87
|
+
}
|
|
88
|
+
if (!challenge) {
|
|
89
|
+
throw new KreltixError('Premium tier requires a `challenge`.', 'VALIDATION_ERROR');
|
|
90
|
+
}
|
|
91
|
+
if (video) body.video = video;
|
|
92
|
+
if (videoUrl) body.video_url = videoUrl;
|
|
93
|
+
body.challenge = challenge;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const raw = await this.apiClient.combinedCheck(body);
|
|
97
|
+
return {
|
|
98
|
+
requestId: raw.request_id,
|
|
99
|
+
tier: raw.tier,
|
|
100
|
+
isLive: raw.is_live,
|
|
101
|
+
livenessConfidence: raw.liveness_confidence,
|
|
102
|
+
challengeCompleted: raw.challenge_completed,
|
|
103
|
+
challengeType: raw.challenge_type,
|
|
104
|
+
faceChecked: raw.face_checked,
|
|
105
|
+
faceMatch: raw.face_match,
|
|
106
|
+
faceDistance: raw.face_distance,
|
|
107
|
+
verified: raw.verified,
|
|
108
|
+
indicators: raw.indicators,
|
|
109
|
+
recommendation: raw.recommendation,
|
|
110
|
+
error: raw.error,
|
|
111
|
+
chargeAmount: raw.charge_amount,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import {
|
|
2
|
+
KreltixAuthError,
|
|
3
|
+
KreltixInsufficientFundsError,
|
|
4
|
+
KreltixNetworkError,
|
|
5
|
+
KreltixServerError,
|
|
6
|
+
KreltixSessionExpiredError,
|
|
7
|
+
KreltixValidationError,
|
|
8
|
+
} from '../errors/KreltixError';
|
|
9
|
+
import { withRetry } from '../utils/retry';
|
|
10
|
+
import type {
|
|
11
|
+
SDKConfig,
|
|
12
|
+
CreateSessionResponse,
|
|
13
|
+
SubmitVerificationResponse,
|
|
14
|
+
VerifyFaceApiResponse,
|
|
15
|
+
CombinedCheckApiResponse,
|
|
16
|
+
} from './types';
|
|
17
|
+
|
|
18
|
+
export class ApiClient {
|
|
19
|
+
private config: SDKConfig;
|
|
20
|
+
|
|
21
|
+
constructor(config: SDKConfig) {
|
|
22
|
+
this.config = config;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
private async request<T>(
|
|
26
|
+
method: string,
|
|
27
|
+
path: string,
|
|
28
|
+
body?: Record<string, unknown>,
|
|
29
|
+
timeoutOverride?: number,
|
|
30
|
+
): Promise<T> {
|
|
31
|
+
const url = `${this.config.baseUrl}${path}`;
|
|
32
|
+
const controller = new AbortController();
|
|
33
|
+
const timer = setTimeout(
|
|
34
|
+
() => controller.abort(),
|
|
35
|
+
timeoutOverride ?? this.config.timeout,
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
try {
|
|
39
|
+
const response = await fetch(url, {
|
|
40
|
+
method,
|
|
41
|
+
headers: {
|
|
42
|
+
'Content-Type': 'application/json',
|
|
43
|
+
'X-API-Key': this.config.publicKey,
|
|
44
|
+
},
|
|
45
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
46
|
+
signal: controller.signal,
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
if (response.ok) {
|
|
50
|
+
return (await response.json()) as T;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const errorBody = await response
|
|
54
|
+
.json()
|
|
55
|
+
.catch(() => ({ detail: response.statusText }));
|
|
56
|
+
const detail = (errorBody as { detail?: string }).detail ?? response.statusText;
|
|
57
|
+
|
|
58
|
+
switch (response.status) {
|
|
59
|
+
case 401:
|
|
60
|
+
case 403:
|
|
61
|
+
throw new KreltixAuthError(detail);
|
|
62
|
+
case 402:
|
|
63
|
+
throw new KreltixInsufficientFundsError(detail);
|
|
64
|
+
case 409:
|
|
65
|
+
throw new KreltixValidationError(detail);
|
|
66
|
+
case 410:
|
|
67
|
+
throw new KreltixSessionExpiredError(detail);
|
|
68
|
+
case 429:
|
|
69
|
+
throw new KreltixServerError('Rate limit exceeded. Try again shortly.');
|
|
70
|
+
default:
|
|
71
|
+
if (response.status >= 500) throw new KreltixServerError(detail);
|
|
72
|
+
throw new KreltixValidationError(detail);
|
|
73
|
+
}
|
|
74
|
+
} catch (err) {
|
|
75
|
+
if (err instanceof TypeError || (err as Error).name === 'AbortError') {
|
|
76
|
+
throw new KreltixNetworkError(
|
|
77
|
+
(err as Error).name === 'AbortError' ? 'Request timed out' : 'Network request failed',
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
throw err;
|
|
81
|
+
} finally {
|
|
82
|
+
clearTimeout(timer);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async createSession(metadata?: Record<string, unknown>): Promise<CreateSessionResponse> {
|
|
87
|
+
return withRetry(
|
|
88
|
+
() => this.request<CreateSessionResponse>('POST', '/api/v1/sessions/', {
|
|
89
|
+
metadata: metadata ?? null,
|
|
90
|
+
}),
|
|
91
|
+
this.config.maxRetries,
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// No retry — sessions are single-use; a timed-out upload that retried would
|
|
96
|
+
// re-submit an already-consumed session token and cause a 409.
|
|
97
|
+
async submitVerification(
|
|
98
|
+
sessionId: string,
|
|
99
|
+
sessionToken: string,
|
|
100
|
+
videos: string[],
|
|
101
|
+
): Promise<SubmitVerificationResponse> {
|
|
102
|
+
return this.request<SubmitVerificationResponse>(
|
|
103
|
+
'POST',
|
|
104
|
+
`/api/v1/sessions/${sessionId}/verify`,
|
|
105
|
+
{ session_token: sessionToken, videos },
|
|
106
|
+
60_000, // 60s — multi-video upload can be slow on mobile
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async verifyFace(
|
|
111
|
+
idCardImage: string,
|
|
112
|
+
selfieImage: string,
|
|
113
|
+
metadata?: Record<string, unknown>,
|
|
114
|
+
): Promise<VerifyFaceApiResponse> {
|
|
115
|
+
return withRetry(
|
|
116
|
+
() => this.request<VerifyFaceApiResponse>('POST', '/api/v1/verification/verify-face', {
|
|
117
|
+
id_card_image: idCardImage,
|
|
118
|
+
selfie_image: selfieImage,
|
|
119
|
+
...(metadata ? { metadata } : {}),
|
|
120
|
+
}),
|
|
121
|
+
this.config.maxRetries,
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async combinedCheck(body: Record<string, unknown>): Promise<CombinedCheckApiResponse> {
|
|
126
|
+
return withRetry(
|
|
127
|
+
() => this.request<CombinedCheckApiResponse>(
|
|
128
|
+
'POST',
|
|
129
|
+
'/api/v1/verification/combined-check',
|
|
130
|
+
body,
|
|
131
|
+
60_000,
|
|
132
|
+
),
|
|
133
|
+
this.config.maxRetries,
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
}
|
package/src/api/types.ts
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
// ── SDK config ──────────────────────────────────────────────────────────────────
|
|
2
|
+
|
|
3
|
+
export interface SDKConfig {
|
|
4
|
+
publicKey: string;
|
|
5
|
+
baseUrl: string;
|
|
6
|
+
timeout: number;
|
|
7
|
+
maxRetries: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface SDKOptions {
|
|
11
|
+
/** Override the default Kreltix API base URL. */
|
|
12
|
+
baseUrl?: string;
|
|
13
|
+
/** Request timeout in ms. Default: 10 000. */
|
|
14
|
+
timeout?: number;
|
|
15
|
+
/** Max retries for retryable errors. Default: 2. */
|
|
16
|
+
maxRetries?: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// ── Raw API response shapes ──────────────────────────────────────────────────────
|
|
20
|
+
|
|
21
|
+
export interface CreateSessionResponse {
|
|
22
|
+
session_id: string;
|
|
23
|
+
session_token: string;
|
|
24
|
+
challenges: string[];
|
|
25
|
+
instructions: string[];
|
|
26
|
+
expires_at: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface ChallengeStepResult {
|
|
30
|
+
challenge: string;
|
|
31
|
+
completed: boolean;
|
|
32
|
+
indicators: Record<string, unknown> | null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface SubmitVerificationResponse {
|
|
36
|
+
session_id: string;
|
|
37
|
+
is_live: boolean;
|
|
38
|
+
confidence: number;
|
|
39
|
+
challenges_completed: ChallengeStepResult[];
|
|
40
|
+
all_challenges_passed: boolean;
|
|
41
|
+
indicators: Record<string, unknown> | null;
|
|
42
|
+
recommendation: string | null;
|
|
43
|
+
error: string | null;
|
|
44
|
+
charge_amount: number | null;
|
|
45
|
+
captured_frame: string | null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface VerifyFaceApiResponse {
|
|
49
|
+
request_id: string | null;
|
|
50
|
+
match: boolean;
|
|
51
|
+
distance: number;
|
|
52
|
+
verified: boolean;
|
|
53
|
+
status: string;
|
|
54
|
+
error: string | null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface CombinedCheckApiResponse {
|
|
58
|
+
request_id: string | null;
|
|
59
|
+
tier: string;
|
|
60
|
+
is_live: boolean;
|
|
61
|
+
liveness_confidence: number;
|
|
62
|
+
challenge_completed: boolean | null;
|
|
63
|
+
challenge_type: string | null;
|
|
64
|
+
face_checked: boolean;
|
|
65
|
+
face_match: boolean | null;
|
|
66
|
+
face_distance: number | null;
|
|
67
|
+
verified: boolean;
|
|
68
|
+
indicators: Record<string, unknown> | null;
|
|
69
|
+
recommendation: string | null;
|
|
70
|
+
error: string | null;
|
|
71
|
+
charge_amount: number | null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ── Public result types ──────────────────────────────────────────────────────────
|
|
75
|
+
|
|
76
|
+
export interface LivenessResult {
|
|
77
|
+
sessionId: string;
|
|
78
|
+
isLive: boolean;
|
|
79
|
+
confidence: number;
|
|
80
|
+
challengeResults: ChallengeStepResult[];
|
|
81
|
+
allChallengesPassed: boolean;
|
|
82
|
+
recommendation: string | null;
|
|
83
|
+
error: string | null;
|
|
84
|
+
/** Base64 JPEG of the sharpest frame captured during liveness. Null if not available. */
|
|
85
|
+
capturedFrame: string | null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export interface CombinedCheckResult {
|
|
89
|
+
sessionId: string;
|
|
90
|
+
isLive: boolean;
|
|
91
|
+
livenessConfidence: number;
|
|
92
|
+
allChallengesPassed: boolean;
|
|
93
|
+
challengeResults: ChallengeStepResult[];
|
|
94
|
+
recommendation: string | null;
|
|
95
|
+
faceChecked: boolean;
|
|
96
|
+
faceMatch: boolean | null;
|
|
97
|
+
faceDistance: number | null;
|
|
98
|
+
/** True only when liveness passed AND face matches the id card. */
|
|
99
|
+
verified: boolean;
|
|
100
|
+
capturedFrame: string | null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export interface FaceMatchResult {
|
|
104
|
+
requestId: string | null;
|
|
105
|
+
match: boolean;
|
|
106
|
+
distance: number;
|
|
107
|
+
verified: boolean;
|
|
108
|
+
status: string;
|
|
109
|
+
error: string | null;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export interface CombinedFaceMatchResult {
|
|
113
|
+
requestId: string | null;
|
|
114
|
+
tier: string;
|
|
115
|
+
isLive: boolean;
|
|
116
|
+
livenessConfidence: number;
|
|
117
|
+
challengeCompleted: boolean | null;
|
|
118
|
+
challengeType: string | null;
|
|
119
|
+
faceChecked: boolean;
|
|
120
|
+
faceMatch: boolean | null;
|
|
121
|
+
faceDistance: number | null;
|
|
122
|
+
verified: boolean;
|
|
123
|
+
indicators: Record<string, unknown> | null;
|
|
124
|
+
recommendation: string | null;
|
|
125
|
+
error: string | null;
|
|
126
|
+
chargeAmount: number | null;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ── Progress & events ────────────────────────────────────────────────────────────
|
|
130
|
+
|
|
131
|
+
export type ProgressStage =
|
|
132
|
+
| 'permission_request'
|
|
133
|
+
| 'session_created'
|
|
134
|
+
| 'camera_ready'
|
|
135
|
+
| 'recording'
|
|
136
|
+
| 'uploading'
|
|
137
|
+
| 'complete';
|
|
138
|
+
|
|
139
|
+
export interface ProgressEvent {
|
|
140
|
+
stage: ProgressStage;
|
|
141
|
+
message: string;
|
|
142
|
+
/** Index of the challenge currently being recorded (0-based). */
|
|
143
|
+
challengeIndex?: number;
|
|
144
|
+
/** Total number of challenges in this session. */
|
|
145
|
+
totalChallenges?: number;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ── Options types ────────────────────────────────────────────────────────────────
|
|
149
|
+
|
|
150
|
+
export type LivenessTier = 'standard' | 'premium';
|
|
151
|
+
export type ChallengeType = 'blink' | 'turn_left' | 'turn_right' | 'smile';
|
|
152
|
+
|
|
153
|
+
export interface LivenessOptions {
|
|
154
|
+
/** Custom metadata attached to the Kreltix session. */
|
|
155
|
+
metadata?: Record<string, unknown>;
|
|
156
|
+
/** How long (ms) each challenge video is recorded. Default: 4000. */
|
|
157
|
+
recordingDurationMs?: number;
|
|
158
|
+
/** Pause (ms) between showing the challenge instruction and starting recording. Default: 2000. */
|
|
159
|
+
guidePauseMs?: number;
|
|
160
|
+
onSessionCreated?: (session: CreateSessionResponse) => void;
|
|
161
|
+
onChallengeStarted?: (challenge: string, index: number, total: number) => void;
|
|
162
|
+
onProgress?: (event: ProgressEvent) => void;
|
|
163
|
+
onError?: (error: Error) => void;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export interface CombinedCheckOptions extends LivenessOptions {
|
|
167
|
+
/**
|
|
168
|
+
* Reference face to match against (the employee's enrolled photo).
|
|
169
|
+
* Accepts a base64 string (with or without data URI prefix) or a URL.
|
|
170
|
+
*/
|
|
171
|
+
idCardImage: string;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export interface FaceMatchOptions {
|
|
175
|
+
/** Reference face (e.g. employee profile photo). Base64 or URL. */
|
|
176
|
+
idCardImage: string;
|
|
177
|
+
/** Selfie to compare against. Base64 or URL. */
|
|
178
|
+
selfieImage: string;
|
|
179
|
+
metadata?: Record<string, unknown>;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export interface CombinedFaceMatchOptions {
|
|
183
|
+
/** Reference face. Base64 or URL. */
|
|
184
|
+
idCardImage: string;
|
|
185
|
+
/** Liveness tier. Default: "standard". */
|
|
186
|
+
tier?: LivenessTier;
|
|
187
|
+
/** Selfie image for standard tier. */
|
|
188
|
+
image?: string;
|
|
189
|
+
/** Base64-encoded video for premium tier. */
|
|
190
|
+
video?: string;
|
|
191
|
+
/** Publicly accessible video URL for premium tier. */
|
|
192
|
+
videoUrl?: string;
|
|
193
|
+
/** Challenge type — required for premium tier. */
|
|
194
|
+
challenge?: ChallengeType;
|
|
195
|
+
metadata?: Record<string, unknown>;
|
|
196
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { RefObject } from 'react';
|
|
2
|
+
import type { CameraView } from 'expo-camera';
|
|
3
|
+
|
|
4
|
+
import { KreltixCameraError } from '../errors/KreltixError';
|
|
5
|
+
import { videoUriToBase64 } from '../utils/video';
|
|
6
|
+
|
|
7
|
+
export interface RecordingOptions {
|
|
8
|
+
/** Maximum recording duration in seconds. Default: 4. */
|
|
9
|
+
maxDurationSeconds?: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Thin wrapper around expo-camera's CameraView that records one video clip
|
|
14
|
+
* and converts it to a base64 string ready for the Kreltix API.
|
|
15
|
+
*/
|
|
16
|
+
export class CameraRecorder {
|
|
17
|
+
private ref: RefObject<CameraView | null>;
|
|
18
|
+
|
|
19
|
+
constructor(cameraRef: RefObject<CameraView | null>) {
|
|
20
|
+
this.ref = cameraRef;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Record a single video clip and return it as a base64 string.
|
|
25
|
+
* The camera must already be mounted and permissions granted.
|
|
26
|
+
*/
|
|
27
|
+
async record(options: RecordingOptions = {}): Promise<string> {
|
|
28
|
+
const { maxDurationSeconds = 4 } = options;
|
|
29
|
+
const camera = this.ref.current;
|
|
30
|
+
if (!camera) {
|
|
31
|
+
throw new KreltixCameraError('CameraView ref is not attached — make sure the camera is mounted before recording.');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const result = await camera.recordAsync({ maxDuration: maxDurationSeconds });
|
|
35
|
+
if (!result?.uri) {
|
|
36
|
+
throw new KreltixCameraError('Recording produced no video URI.');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return videoUriToBase64(result.uri);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Stop an in-progress recording early. */
|
|
43
|
+
stopRecording(): void {
|
|
44
|
+
this.ref.current?.stopRecording();
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
export class KreltixError extends Error {
|
|
2
|
+
code: string;
|
|
3
|
+
isRetryable: boolean;
|
|
4
|
+
|
|
5
|
+
constructor(message: string, code: string, isRetryable = false) {
|
|
6
|
+
super(message);
|
|
7
|
+
this.name = 'KreltixError';
|
|
8
|
+
this.code = code;
|
|
9
|
+
this.isRetryable = isRetryable;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export class KreltixNetworkError extends KreltixError {
|
|
14
|
+
constructor(message = 'Network request failed') {
|
|
15
|
+
super(message, 'NETWORK_ERROR', true);
|
|
16
|
+
this.name = 'KreltixNetworkError';
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export class KreltixAuthError extends KreltixError {
|
|
21
|
+
constructor(message = 'Invalid or expired API key') {
|
|
22
|
+
super(message, 'AUTH_ERROR', false);
|
|
23
|
+
this.name = 'KreltixAuthError';
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export class KreltixSessionExpiredError extends KreltixError {
|
|
28
|
+
constructor(message = 'Liveness session expired') {
|
|
29
|
+
super(message, 'SESSION_EXPIRED', false);
|
|
30
|
+
this.name = 'KreltixSessionExpiredError';
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export class KreltixCameraError extends KreltixError {
|
|
35
|
+
constructor(message = 'Camera not available or permission denied') {
|
|
36
|
+
super(message, 'CAMERA_ERROR', false);
|
|
37
|
+
this.name = 'KreltixCameraError';
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export class KreltixServerError extends KreltixError {
|
|
42
|
+
constructor(message = 'Server error') {
|
|
43
|
+
super(message, 'SERVER_ERROR', true);
|
|
44
|
+
this.name = 'KreltixServerError';
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export class KreltixValidationError extends KreltixError {
|
|
49
|
+
constructor(message = 'Validation error') {
|
|
50
|
+
super(message, 'VALIDATION_ERROR', false);
|
|
51
|
+
this.name = 'KreltixValidationError';
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export class KreltixInsufficientFundsError extends KreltixError {
|
|
56
|
+
constructor(message = 'Insufficient wallet balance') {
|
|
57
|
+
super(message, 'INSUFFICIENT_FUNDS', false);
|
|
58
|
+
this.name = 'KreltixInsufficientFundsError';
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import { useRef, useState, useCallback, type RefObject } from 'react';
|
|
2
|
+
import type { CameraView } from 'expo-camera';
|
|
3
|
+
import { useCameraPermissions, useMicrophonePermissions } from 'expo-camera';
|
|
4
|
+
import { ApiClient } from '../api/client';
|
|
5
|
+
import { SessionManager } from '../session/SessionManager';
|
|
6
|
+
import { CameraRecorder } from '../camera/CameraRecorder';
|
|
7
|
+
import { KreltixCameraError } from '../errors/KreltixError';
|
|
8
|
+
import type {
|
|
9
|
+
SDKConfig,
|
|
10
|
+
LivenessOptions,
|
|
11
|
+
LivenessResult,
|
|
12
|
+
CombinedCheckOptions,
|
|
13
|
+
CombinedCheckResult,
|
|
14
|
+
CreateSessionResponse,
|
|
15
|
+
ProgressEvent,
|
|
16
|
+
} from '../api/types';
|
|
17
|
+
|
|
18
|
+
export type LivenessPhase =
|
|
19
|
+
| 'idle'
|
|
20
|
+
| 'requesting_permissions'
|
|
21
|
+
| 'creating_session'
|
|
22
|
+
| 'challenge' // guide shown, recording starts automatically after guidePauseMs
|
|
23
|
+
| 'recording' // camera recording in progress
|
|
24
|
+
| 'uploading' // videos submitted to Kreltix
|
|
25
|
+
| 'complete'
|
|
26
|
+
| 'error';
|
|
27
|
+
|
|
28
|
+
export interface UseLivenessState {
|
|
29
|
+
phase: LivenessPhase;
|
|
30
|
+
/** Current challenge index (0-based). */
|
|
31
|
+
challengeIndex: number;
|
|
32
|
+
/** Challenge key for the current step, e.g. "blink". */
|
|
33
|
+
currentChallenge: string;
|
|
34
|
+
/** Human-readable instruction for the current challenge. */
|
|
35
|
+
currentInstruction: string;
|
|
36
|
+
/** Total number of challenges in this session. */
|
|
37
|
+
totalChallenges: number;
|
|
38
|
+
/** Countdown seconds remaining during recording phase. */
|
|
39
|
+
countdown: number;
|
|
40
|
+
/** Set when phase === 'complete'. */
|
|
41
|
+
result: LivenessResult | null;
|
|
42
|
+
combinedResult: CombinedCheckResult | null;
|
|
43
|
+
/** Set when phase === 'error'. */
|
|
44
|
+
error: Error | null;
|
|
45
|
+
/** Ref to attach to the <CameraView> component. */
|
|
46
|
+
cameraRef: RefObject<CameraView | null>;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface UseLivenessActions {
|
|
50
|
+
/** Start the liveness flow. Idempotent if already running. */
|
|
51
|
+
start(options?: LivenessOptions): Promise<void>;
|
|
52
|
+
/** Start liveness + face match combined check. */
|
|
53
|
+
startCombined(options: CombinedCheckOptions): Promise<void>;
|
|
54
|
+
/** Reset to idle state so the flow can be restarted. */
|
|
55
|
+
reset(): void;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const DEFAULT_RECORDING_MS = 4000;
|
|
59
|
+
const DEFAULT_GUIDE_PAUSE_MS = 2000;
|
|
60
|
+
|
|
61
|
+
export function useLiveness(config: SDKConfig): UseLivenessState & UseLivenessActions {
|
|
62
|
+
const cameraRef = useRef<CameraView>(null);
|
|
63
|
+
const [camPerm, requestCamPerm] = useCameraPermissions();
|
|
64
|
+
const [micPerm, requestMicPerm] = useMicrophonePermissions();
|
|
65
|
+
|
|
66
|
+
const [phase, setPhase] = useState<LivenessPhase>('idle');
|
|
67
|
+
const [challengeIndex, setChallengeIndex] = useState(0);
|
|
68
|
+
const [currentChallenge, setCurrentChallenge] = useState('');
|
|
69
|
+
const [currentInstruction, setCurrentInstruction] = useState('');
|
|
70
|
+
const [totalChallenges, setTotalChallenges] = useState(0);
|
|
71
|
+
const [countdown, setCountdown] = useState(0);
|
|
72
|
+
const [result, setResult] = useState<LivenessResult | null>(null);
|
|
73
|
+
const [combinedResult, setCombinedResult] = useState<CombinedCheckResult | null>(null);
|
|
74
|
+
const [error, setError] = useState<Error | null>(null);
|
|
75
|
+
|
|
76
|
+
function reset() {
|
|
77
|
+
setPhase('idle');
|
|
78
|
+
setChallengeIndex(0);
|
|
79
|
+
setCurrentChallenge('');
|
|
80
|
+
setCurrentInstruction('');
|
|
81
|
+
setTotalChallenges(0);
|
|
82
|
+
setCountdown(0);
|
|
83
|
+
setResult(null);
|
|
84
|
+
setCombinedResult(null);
|
|
85
|
+
setError(null);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const runFlow = useCallback(async (options: LivenessOptions, idCardImage?: string) => {
|
|
89
|
+
const {
|
|
90
|
+
metadata,
|
|
91
|
+
recordingDurationMs = DEFAULT_RECORDING_MS,
|
|
92
|
+
guidePauseMs = DEFAULT_GUIDE_PAUSE_MS,
|
|
93
|
+
onSessionCreated,
|
|
94
|
+
onChallengeStarted,
|
|
95
|
+
onProgress,
|
|
96
|
+
onError,
|
|
97
|
+
} = options;
|
|
98
|
+
|
|
99
|
+
const notify = (event: ProgressEvent) => onProgress?.(event);
|
|
100
|
+
const recordingSeconds = Math.round(recordingDurationMs / 1000);
|
|
101
|
+
|
|
102
|
+
try {
|
|
103
|
+
// 1. Permissions
|
|
104
|
+
setPhase('requesting_permissions');
|
|
105
|
+
notify({ stage: 'permission_request', message: 'Requesting camera permissions…' });
|
|
106
|
+
|
|
107
|
+
const cam = camPerm?.granted ? camPerm : await requestCamPerm();
|
|
108
|
+
const mic = micPerm?.granted ? micPerm : await requestMicPerm();
|
|
109
|
+
if (!cam?.granted || !mic?.granted) {
|
|
110
|
+
throw new KreltixCameraError('Camera and microphone permissions are required.');
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// 2. Create session
|
|
114
|
+
setPhase('creating_session');
|
|
115
|
+
notify({ stage: 'session_created', message: 'Creating verification session…' });
|
|
116
|
+
|
|
117
|
+
const apiClient = new ApiClient(config);
|
|
118
|
+
const sessionManager = new SessionManager(apiClient);
|
|
119
|
+
const session: CreateSessionResponse = await sessionManager.createSession(metadata);
|
|
120
|
+
onSessionCreated?.(session);
|
|
121
|
+
|
|
122
|
+
const { challenges, instructions } = session;
|
|
123
|
+
setTotalChallenges(challenges.length);
|
|
124
|
+
notify({
|
|
125
|
+
stage: 'session_created',
|
|
126
|
+
message: `Session ready — ${challenges.length} challenge${challenges.length > 1 ? 's' : ''}`,
|
|
127
|
+
totalChallenges: challenges.length,
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
// 3. Record each challenge
|
|
131
|
+
const recorder = new CameraRecorder(cameraRef);
|
|
132
|
+
const videos: string[] = [];
|
|
133
|
+
|
|
134
|
+
for (let i = 0; i < challenges.length; i++) {
|
|
135
|
+
const challenge = challenges[i];
|
|
136
|
+
const instruction = instructions[i] ?? challenge;
|
|
137
|
+
|
|
138
|
+
// Show challenge guide
|
|
139
|
+
setChallengeIndex(i);
|
|
140
|
+
setCurrentChallenge(challenge);
|
|
141
|
+
setCurrentInstruction(instruction);
|
|
142
|
+
setPhase('challenge');
|
|
143
|
+
onChallengeStarted?.(challenge, i, challenges.length);
|
|
144
|
+
notify({
|
|
145
|
+
stage: 'recording',
|
|
146
|
+
message: `Step ${i + 1}/${challenges.length}: ${challenge}`,
|
|
147
|
+
challengeIndex: i,
|
|
148
|
+
totalChallenges: challenges.length,
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
// Guide pause so the user can read the instruction
|
|
152
|
+
await new Promise((resolve) => setTimeout(resolve, guidePauseMs));
|
|
153
|
+
|
|
154
|
+
// Start recording + countdown
|
|
155
|
+
setPhase('recording');
|
|
156
|
+
setCountdown(recordingSeconds);
|
|
157
|
+
const tick = setInterval(() => {
|
|
158
|
+
setCountdown((prev: number) => Math.max(0, prev - 1));
|
|
159
|
+
}, 1000);
|
|
160
|
+
|
|
161
|
+
try {
|
|
162
|
+
const base64 = await recorder.record({ maxDurationSeconds: recordingSeconds });
|
|
163
|
+
videos.push(base64);
|
|
164
|
+
} finally {
|
|
165
|
+
clearInterval(tick);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Brief inter-challenge pause
|
|
169
|
+
if (i < challenges.length - 1) {
|
|
170
|
+
await new Promise((resolve) => setTimeout(resolve, 600));
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// 4. Upload and verify
|
|
175
|
+
setPhase('uploading');
|
|
176
|
+
notify({ stage: 'uploading', message: 'Uploading videos…' });
|
|
177
|
+
|
|
178
|
+
const raw = await sessionManager.submitVerification(videos);
|
|
179
|
+
|
|
180
|
+
// 5. Map result
|
|
181
|
+
if (idCardImage) {
|
|
182
|
+
// Combined check result
|
|
183
|
+
const cr: CombinedCheckResult = {
|
|
184
|
+
sessionId: raw.session_id,
|
|
185
|
+
isLive: raw.is_live,
|
|
186
|
+
livenessConfidence: raw.confidence,
|
|
187
|
+
allChallengesPassed: raw.all_challenges_passed,
|
|
188
|
+
challengeResults: raw.challenges_completed,
|
|
189
|
+
recommendation: raw.recommendation,
|
|
190
|
+
faceChecked: (raw as unknown as { face_checked?: boolean }).face_checked ?? false,
|
|
191
|
+
faceMatch: (raw as unknown as { face_match?: boolean | null }).face_match ?? null,
|
|
192
|
+
faceDistance: (raw as unknown as { face_distance?: number | null }).face_distance ?? null,
|
|
193
|
+
verified: (raw as unknown as { verified?: boolean }).verified ?? false,
|
|
194
|
+
capturedFrame: raw.captured_frame,
|
|
195
|
+
};
|
|
196
|
+
setCombinedResult(cr);
|
|
197
|
+
} else {
|
|
198
|
+
const lr: LivenessResult = {
|
|
199
|
+
sessionId: raw.session_id,
|
|
200
|
+
isLive: raw.is_live,
|
|
201
|
+
confidence: raw.confidence,
|
|
202
|
+
challengeResults: raw.challenges_completed,
|
|
203
|
+
allChallengesPassed: raw.all_challenges_passed,
|
|
204
|
+
recommendation: raw.recommendation,
|
|
205
|
+
error: raw.error,
|
|
206
|
+
capturedFrame: raw.captured_frame,
|
|
207
|
+
};
|
|
208
|
+
setResult(lr);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
setPhase('complete');
|
|
212
|
+
notify({ stage: 'complete', message: 'Verification complete' });
|
|
213
|
+
} catch (err) {
|
|
214
|
+
const e = err instanceof Error ? err : new Error(String(err));
|
|
215
|
+
setError(e);
|
|
216
|
+
setPhase('error');
|
|
217
|
+
onError?.(e);
|
|
218
|
+
}
|
|
219
|
+
}, [config, camPerm, micPerm, requestCamPerm, requestMicPerm]);
|
|
220
|
+
|
|
221
|
+
const start = useCallback(
|
|
222
|
+
async (options: LivenessOptions = {}) => runFlow(options),
|
|
223
|
+
[runFlow],
|
|
224
|
+
);
|
|
225
|
+
|
|
226
|
+
const startCombined = useCallback(
|
|
227
|
+
async (options: CombinedCheckOptions) => runFlow(options, options.idCardImage),
|
|
228
|
+
[runFlow],
|
|
229
|
+
);
|
|
230
|
+
|
|
231
|
+
return {
|
|
232
|
+
// State
|
|
233
|
+
phase,
|
|
234
|
+
challengeIndex,
|
|
235
|
+
currentChallenge,
|
|
236
|
+
currentInstruction,
|
|
237
|
+
totalChallenges,
|
|
238
|
+
countdown,
|
|
239
|
+
result,
|
|
240
|
+
combinedResult,
|
|
241
|
+
error,
|
|
242
|
+
cameraRef,
|
|
243
|
+
// Actions
|
|
244
|
+
start,
|
|
245
|
+
startCombined,
|
|
246
|
+
reset,
|
|
247
|
+
};
|
|
248
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// Main SDK class (programmatic, no UI)
|
|
2
|
+
export { KreltixSDK } from './KreltixSDK';
|
|
3
|
+
|
|
4
|
+
// React Native component — the primary integration point
|
|
5
|
+
export { KreltixLivenessView } from './ui/LivenessView';
|
|
6
|
+
export type { KreltixLivenessViewProps, LivenessViewProps, CombinedCheckViewProps } from './ui/LivenessView';
|
|
7
|
+
|
|
8
|
+
// React hook — for custom UI with SDK-managed session + recording
|
|
9
|
+
export { useLiveness } from './hooks/useLiveness';
|
|
10
|
+
export type { UseLivenessState, UseLivenessActions, LivenessPhase } from './hooks/useLiveness';
|
|
11
|
+
|
|
12
|
+
// Types
|
|
13
|
+
export type {
|
|
14
|
+
SDKConfig,
|
|
15
|
+
SDKOptions,
|
|
16
|
+
LivenessOptions,
|
|
17
|
+
CombinedCheckOptions,
|
|
18
|
+
FaceMatchOptions,
|
|
19
|
+
CombinedFaceMatchOptions,
|
|
20
|
+
LivenessResult,
|
|
21
|
+
CombinedCheckResult,
|
|
22
|
+
FaceMatchResult,
|
|
23
|
+
CombinedFaceMatchResult,
|
|
24
|
+
ChallengeStepResult,
|
|
25
|
+
CreateSessionResponse,
|
|
26
|
+
SubmitVerificationResponse,
|
|
27
|
+
ProgressEvent,
|
|
28
|
+
ProgressStage,
|
|
29
|
+
LivenessTier,
|
|
30
|
+
ChallengeType,
|
|
31
|
+
} from './api/types';
|
|
32
|
+
|
|
33
|
+
// Errors
|
|
34
|
+
export {
|
|
35
|
+
KreltixError,
|
|
36
|
+
KreltixNetworkError,
|
|
37
|
+
KreltixAuthError,
|
|
38
|
+
KreltixSessionExpiredError,
|
|
39
|
+
KreltixCameraError,
|
|
40
|
+
KreltixServerError,
|
|
41
|
+
KreltixValidationError,
|
|
42
|
+
KreltixInsufficientFundsError,
|
|
43
|
+
} from './errors/KreltixError';
|
|
44
|
+
|
|
45
|
+
// Lower-level building blocks (for custom integrations)
|
|
46
|
+
export { CameraRecorder } from './camera/CameraRecorder';
|
|
47
|
+
export { SessionManager } from './session/SessionManager';
|
|
48
|
+
export { ApiClient } from './api/client';
|
|
49
|
+
export { videoUriToBase64 } from './utils/video';
|
|
50
|
+
export { withRetry } from './utils/retry';
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { ApiClient } from '../api/client';
|
|
2
|
+
import type { CreateSessionResponse, SubmitVerificationResponse } from '../api/types';
|
|
3
|
+
|
|
4
|
+
export class SessionManager {
|
|
5
|
+
private client: ApiClient;
|
|
6
|
+
private currentSession: CreateSessionResponse | null = null;
|
|
7
|
+
|
|
8
|
+
constructor(client: ApiClient) {
|
|
9
|
+
this.client = client;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
async createSession(metadata?: Record<string, unknown>): Promise<CreateSessionResponse> {
|
|
13
|
+
this.currentSession = await this.client.createSession(metadata);
|
|
14
|
+
return this.currentSession;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async submitVerification(videos: string[]): Promise<SubmitVerificationResponse> {
|
|
18
|
+
if (!this.currentSession) {
|
|
19
|
+
throw new Error('No active session. Call createSession() first.');
|
|
20
|
+
}
|
|
21
|
+
const result = await this.client.submitVerification(
|
|
22
|
+
this.currentSession.session_id,
|
|
23
|
+
this.currentSession.session_token,
|
|
24
|
+
videos,
|
|
25
|
+
);
|
|
26
|
+
this.currentSession = null;
|
|
27
|
+
return result;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
getCurrentSession(): CreateSessionResponse | null {
|
|
31
|
+
return this.currentSession;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
clearSession(): void {
|
|
35
|
+
this.currentSession = null;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
import React, { useEffect } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
View,
|
|
4
|
+
Text,
|
|
5
|
+
TouchableOpacity,
|
|
6
|
+
ActivityIndicator,
|
|
7
|
+
StyleSheet,
|
|
8
|
+
ViewStyle,
|
|
9
|
+
} from 'react-native';
|
|
10
|
+
import { CameraView } from 'expo-camera';
|
|
11
|
+
import { useLiveness, type LivenessPhase } from '../hooks/useLiveness';
|
|
12
|
+
import type {
|
|
13
|
+
SDKConfig,
|
|
14
|
+
SDKOptions,
|
|
15
|
+
LivenessOptions,
|
|
16
|
+
LivenessResult,
|
|
17
|
+
CombinedCheckOptions,
|
|
18
|
+
CombinedCheckResult,
|
|
19
|
+
} from '../api/types';
|
|
20
|
+
|
|
21
|
+
// ── Challenge display labels ─────────────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
const CHALLENGE_LABELS: Record<string, string> = {
|
|
24
|
+
blink: 'Blink your eyes once clearly',
|
|
25
|
+
turn_left: 'Slowly turn your head to the left',
|
|
26
|
+
turn_right: 'Slowly turn your head to the right',
|
|
27
|
+
smile: 'Smile visibly',
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const CHALLENGE_EMOJI: Record<string, string> = {
|
|
31
|
+
blink: '👁',
|
|
32
|
+
turn_left: '←',
|
|
33
|
+
turn_right: '→',
|
|
34
|
+
smile: '😊',
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
// ── Props ────────────────────────────────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
interface BaseProps {
|
|
40
|
+
/** Kreltix public key (pk_live_...). */
|
|
41
|
+
publicKey: string;
|
|
42
|
+
/** Override the default API base URL. Useful for self-hosted Kreltix instances. */
|
|
43
|
+
baseUrl?: string;
|
|
44
|
+
/** Called when liveness verification completes successfully. */
|
|
45
|
+
onComplete?: (result: LivenessResult | CombinedCheckResult) => void;
|
|
46
|
+
/** Called when an error occurs. */
|
|
47
|
+
onError?: (error: Error) => void;
|
|
48
|
+
/** Custom metadata to attach to the Kreltix session. */
|
|
49
|
+
metadata?: Record<string, unknown>;
|
|
50
|
+
/** Override recording duration per challenge in ms. Default: 4000. */
|
|
51
|
+
recordingDurationMs?: number;
|
|
52
|
+
/** Override guide pause before recording in ms. Default: 2000. */
|
|
53
|
+
guidePauseMs?: number;
|
|
54
|
+
/** Style for the outer container. */
|
|
55
|
+
style?: ViewStyle;
|
|
56
|
+
/** Accent colour used for progress dots, buttons, and countdown badge. */
|
|
57
|
+
accentColor?: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface LivenessViewProps extends BaseProps {
|
|
61
|
+
mode: 'liveness';
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface CombinedCheckViewProps extends BaseProps {
|
|
65
|
+
mode: 'combined';
|
|
66
|
+
/** Reference face to match against. Base64 or URL. */
|
|
67
|
+
idCardImage: string;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export type KreltixLivenessViewProps = LivenessViewProps | CombinedCheckViewProps;
|
|
71
|
+
|
|
72
|
+
// ── Component ────────────────────────────────────────────────────────────────
|
|
73
|
+
|
|
74
|
+
export function KreltixLivenessView(props: KreltixLivenessViewProps) {
|
|
75
|
+
const {
|
|
76
|
+
publicKey,
|
|
77
|
+
baseUrl = 'https://api.kreltix.com',
|
|
78
|
+
onComplete,
|
|
79
|
+
onError,
|
|
80
|
+
metadata,
|
|
81
|
+
recordingDurationMs,
|
|
82
|
+
guidePauseMs,
|
|
83
|
+
style,
|
|
84
|
+
accentColor = '#6C47FF',
|
|
85
|
+
} = props;
|
|
86
|
+
|
|
87
|
+
const config: SDKConfig = {
|
|
88
|
+
publicKey,
|
|
89
|
+
baseUrl,
|
|
90
|
+
timeout: 10_000,
|
|
91
|
+
maxRetries: 2,
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const options: LivenessOptions = {
|
|
95
|
+
metadata,
|
|
96
|
+
recordingDurationMs,
|
|
97
|
+
guidePauseMs,
|
|
98
|
+
onError,
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const sdk = useLiveness(config);
|
|
102
|
+
|
|
103
|
+
// Auto-start on mount
|
|
104
|
+
useEffect(() => {
|
|
105
|
+
if (props.mode === 'combined') {
|
|
106
|
+
sdk.startCombined({ ...options, idCardImage: props.idCardImage } as CombinedCheckOptions);
|
|
107
|
+
} else {
|
|
108
|
+
sdk.start(options);
|
|
109
|
+
}
|
|
110
|
+
}, []);
|
|
111
|
+
|
|
112
|
+
// Fire onComplete when done
|
|
113
|
+
useEffect(() => {
|
|
114
|
+
if (sdk.phase !== 'complete') return;
|
|
115
|
+
const r = sdk.combinedResult ?? sdk.result;
|
|
116
|
+
if (r) onComplete?.(r);
|
|
117
|
+
}, [sdk.phase]);
|
|
118
|
+
|
|
119
|
+
const accent = accentColor;
|
|
120
|
+
|
|
121
|
+
// ── Camera phases ────────────────────────────────────────────────────────
|
|
122
|
+
|
|
123
|
+
const isCamera = sdk.phase === 'challenge' || sdk.phase === 'recording';
|
|
124
|
+
|
|
125
|
+
if (isCamera) {
|
|
126
|
+
const label =
|
|
127
|
+
CHALLENGE_LABELS[sdk.currentChallenge] ?? sdk.currentInstruction;
|
|
128
|
+
const emoji = CHALLENGE_EMOJI[sdk.currentChallenge] ?? '●';
|
|
129
|
+
const isRecording = sdk.phase === 'recording';
|
|
130
|
+
|
|
131
|
+
return (
|
|
132
|
+
<View style={[s.root, style]}>
|
|
133
|
+
<CameraView
|
|
134
|
+
ref={sdk.cameraRef}
|
|
135
|
+
style={s.camera}
|
|
136
|
+
facing="front"
|
|
137
|
+
mode="video"
|
|
138
|
+
/>
|
|
139
|
+
|
|
140
|
+
{/* Overlay */}
|
|
141
|
+
<View style={StyleSheet.absoluteFill} pointerEvents="none">
|
|
142
|
+
{/* Face oval guide */}
|
|
143
|
+
<View style={s.ovalContainer}>
|
|
144
|
+
<View style={[s.oval, isRecording && { borderColor: accent }]} />
|
|
145
|
+
</View>
|
|
146
|
+
|
|
147
|
+
{/* Progress dots */}
|
|
148
|
+
{sdk.totalChallenges > 1 && (
|
|
149
|
+
<View style={s.dotsRow}>
|
|
150
|
+
{Array.from({ length: sdk.totalChallenges }, (_, i) => (
|
|
151
|
+
<View
|
|
152
|
+
key={i}
|
|
153
|
+
style={[
|
|
154
|
+
s.dot,
|
|
155
|
+
i < sdk.challengeIndex && s.dotDone,
|
|
156
|
+
i === sdk.challengeIndex && { ...s.dotActive, backgroundColor: accent },
|
|
157
|
+
]}
|
|
158
|
+
/>
|
|
159
|
+
))}
|
|
160
|
+
</View>
|
|
161
|
+
)}
|
|
162
|
+
|
|
163
|
+
{/* Instruction card */}
|
|
164
|
+
<View style={s.instructionCard}>
|
|
165
|
+
{isRecording && sdk.countdown > 0 && (
|
|
166
|
+
<View style={[s.countdownBadge, { backgroundColor: accent }]}>
|
|
167
|
+
<Text style={s.countdownNum}>{sdk.countdown}</Text>
|
|
168
|
+
</View>
|
|
169
|
+
)}
|
|
170
|
+
<Text style={s.challengeEmoji}>{emoji}</Text>
|
|
171
|
+
<Text style={s.instLabel}>
|
|
172
|
+
{isRecording ? 'RECORDING — HOLD STILL' : 'GET READY'}
|
|
173
|
+
</Text>
|
|
174
|
+
<Text style={s.instText}>{label}</Text>
|
|
175
|
+
{!isRecording && (
|
|
176
|
+
<Text style={s.instSub}>Recording starts automatically…</Text>
|
|
177
|
+
)}
|
|
178
|
+
</View>
|
|
179
|
+
</View>
|
|
180
|
+
</View>
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// ── Spinner phases ───────────────────────────────────────────────────────
|
|
185
|
+
|
|
186
|
+
if (sdk.phase !== 'complete' && sdk.phase !== 'error') {
|
|
187
|
+
const msg: Record<LivenessPhase, string> = {
|
|
188
|
+
idle: '',
|
|
189
|
+
requesting_permissions: 'Requesting camera access…',
|
|
190
|
+
creating_session: 'Setting up verification…',
|
|
191
|
+
challenge: '',
|
|
192
|
+
recording: '',
|
|
193
|
+
uploading: 'Verifying identity…',
|
|
194
|
+
complete: '',
|
|
195
|
+
error: '',
|
|
196
|
+
};
|
|
197
|
+
return (
|
|
198
|
+
<View style={[s.root, s.center, style]}>
|
|
199
|
+
<ActivityIndicator size="large" color={accent} />
|
|
200
|
+
<Text style={s.statusText}>{msg[sdk.phase]}</Text>
|
|
201
|
+
</View>
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// ── Error ────────────────────────────────────────────────────────────────
|
|
206
|
+
|
|
207
|
+
if (sdk.phase === 'error') {
|
|
208
|
+
return (
|
|
209
|
+
<View style={[s.root, s.center, style]}>
|
|
210
|
+
<Text style={s.errorIcon}>✕</Text>
|
|
211
|
+
<Text style={s.statusText}>{sdk.error?.message ?? 'Verification failed.'}</Text>
|
|
212
|
+
<TouchableOpacity
|
|
213
|
+
style={[s.retryBtn, { borderColor: accent }]}
|
|
214
|
+
onPress={() => {
|
|
215
|
+
sdk.reset();
|
|
216
|
+
if (props.mode === 'combined') {
|
|
217
|
+
sdk.startCombined({ ...options, idCardImage: props.idCardImage } as CombinedCheckOptions);
|
|
218
|
+
} else {
|
|
219
|
+
sdk.start(options);
|
|
220
|
+
}
|
|
221
|
+
}}
|
|
222
|
+
activeOpacity={0.8}
|
|
223
|
+
>
|
|
224
|
+
<Text style={[s.retryTxt, { color: accent }]}>Try Again</Text>
|
|
225
|
+
</TouchableOpacity>
|
|
226
|
+
</View>
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// ── Complete ─────────────────────────────────────────────────────────────
|
|
231
|
+
|
|
232
|
+
const verified =
|
|
233
|
+
sdk.combinedResult?.verified ??
|
|
234
|
+
(sdk.result?.isLive && sdk.result.allChallengesPassed);
|
|
235
|
+
|
|
236
|
+
return (
|
|
237
|
+
<View style={[s.root, s.center, style]}>
|
|
238
|
+
<Text style={[s.doneIcon, { color: verified ? '#22c55e' : '#f59e0b' }]}>
|
|
239
|
+
{verified ? '✓' : '!'}
|
|
240
|
+
</Text>
|
|
241
|
+
<Text style={s.statusText}>
|
|
242
|
+
{verified ? 'Verification complete' : 'Check your result'}
|
|
243
|
+
</Text>
|
|
244
|
+
</View>
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// ── Styles ───────────────────────────────────────────────────────────────────
|
|
249
|
+
|
|
250
|
+
const s = StyleSheet.create({
|
|
251
|
+
root: { overflow: 'hidden', backgroundColor: '#000', borderRadius: 16 },
|
|
252
|
+
camera: { flex: 1, minHeight: 320 },
|
|
253
|
+
center: { alignItems: 'center', justifyContent: 'center', padding: 24, minHeight: 240, gap: 16 },
|
|
254
|
+
|
|
255
|
+
// Face oval
|
|
256
|
+
ovalContainer: {
|
|
257
|
+
flex: 1,
|
|
258
|
+
alignItems: 'center',
|
|
259
|
+
justifyContent: 'center',
|
|
260
|
+
},
|
|
261
|
+
oval: {
|
|
262
|
+
width: 180,
|
|
263
|
+
height: 240,
|
|
264
|
+
borderRadius: 90,
|
|
265
|
+
borderWidth: 2.5,
|
|
266
|
+
borderColor: 'rgba(255,255,255,0.5)',
|
|
267
|
+
},
|
|
268
|
+
|
|
269
|
+
// Progress dots
|
|
270
|
+
dotsRow: {
|
|
271
|
+
flexDirection: 'row',
|
|
272
|
+
gap: 6,
|
|
273
|
+
justifyContent: 'center',
|
|
274
|
+
paddingTop: 16,
|
|
275
|
+
},
|
|
276
|
+
dot: { width: 8, height: 8, borderRadius: 4, backgroundColor: 'rgba(255,255,255,0.25)' },
|
|
277
|
+
dotDone: { backgroundColor: 'rgba(255,255,255,0.6)' },
|
|
278
|
+
dotActive: { backgroundColor: '#fff' },
|
|
279
|
+
|
|
280
|
+
// Instruction card
|
|
281
|
+
instructionCard: {
|
|
282
|
+
backgroundColor: 'rgba(0,0,0,0.7)',
|
|
283
|
+
borderRadius: 12,
|
|
284
|
+
margin: 16,
|
|
285
|
+
padding: 16,
|
|
286
|
+
alignItems: 'center',
|
|
287
|
+
gap: 6,
|
|
288
|
+
},
|
|
289
|
+
countdownBadge: {
|
|
290
|
+
width: 44, height: 44, borderRadius: 22,
|
|
291
|
+
alignItems: 'center', justifyContent: 'center',
|
|
292
|
+
marginBottom: 4,
|
|
293
|
+
},
|
|
294
|
+
countdownNum: { fontSize: 20, fontWeight: '800', color: '#fff' },
|
|
295
|
+
challengeEmoji: { fontSize: 28 },
|
|
296
|
+
instLabel: { fontSize: 11, fontWeight: '700', color: 'rgba(255,255,255,0.55)', letterSpacing: 0.8 },
|
|
297
|
+
instText: { fontSize: 16, fontWeight: '700', color: '#fff', textAlign: 'center' },
|
|
298
|
+
instSub: { fontSize: 12, color: 'rgba(255,255,255,0.45)', marginTop: 2 },
|
|
299
|
+
|
|
300
|
+
// Status
|
|
301
|
+
statusText: { fontSize: 14, color: '#888', textAlign: 'center' },
|
|
302
|
+
|
|
303
|
+
// Error
|
|
304
|
+
errorIcon: { fontSize: 36, color: '#ef4444', fontWeight: '800' },
|
|
305
|
+
retryBtn: {
|
|
306
|
+
borderWidth: 1, borderRadius: 99,
|
|
307
|
+
paddingHorizontal: 28, paddingVertical: 10, marginTop: 4,
|
|
308
|
+
},
|
|
309
|
+
retryTxt: { fontSize: 14, fontWeight: '700' },
|
|
310
|
+
|
|
311
|
+
// Done
|
|
312
|
+
doneIcon: { fontSize: 48, fontWeight: '800' },
|
|
313
|
+
});
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { KreltixError } from '../errors/KreltixError';
|
|
2
|
+
|
|
3
|
+
export async function withRetry<T>(
|
|
4
|
+
fn: () => Promise<T>,
|
|
5
|
+
maxRetries: number,
|
|
6
|
+
baseDelayMs = 1000,
|
|
7
|
+
): Promise<T> {
|
|
8
|
+
let lastError: Error | undefined;
|
|
9
|
+
|
|
10
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
11
|
+
try {
|
|
12
|
+
return await fn();
|
|
13
|
+
} catch (err) {
|
|
14
|
+
lastError = err as Error;
|
|
15
|
+
|
|
16
|
+
if (err instanceof KreltixError && !err.isRetryable) {
|
|
17
|
+
throw err;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
if (attempt < maxRetries) {
|
|
21
|
+
const delay = baseDelayMs * Math.pow(2, attempt);
|
|
22
|
+
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
throw lastError;
|
|
28
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { KreltixCameraError } from '../errors/KreltixError';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Convert a local file URI (returned by expo-camera's recordAsync) to a
|
|
5
|
+
* base64-encoded string. Uses fetch + FileReader — both available in RN ≥ 0.74.
|
|
6
|
+
*/
|
|
7
|
+
export async function videoUriToBase64(uri: string): Promise<string> {
|
|
8
|
+
let response: Response;
|
|
9
|
+
try {
|
|
10
|
+
response = await fetch(uri);
|
|
11
|
+
} catch {
|
|
12
|
+
throw new KreltixCameraError(`Failed to read video file: ${uri}`);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const blob = await response.blob();
|
|
16
|
+
|
|
17
|
+
return new Promise<string>((resolve, reject) => {
|
|
18
|
+
const reader = new FileReader();
|
|
19
|
+
reader.onloadend = () => {
|
|
20
|
+
const result = reader.result as string;
|
|
21
|
+
const base64 = result.split('base64,')[1];
|
|
22
|
+
if (base64) {
|
|
23
|
+
resolve(base64);
|
|
24
|
+
} else {
|
|
25
|
+
reject(new KreltixCameraError('Failed to encode video as base64'));
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
reader.onerror = () => reject(new KreltixCameraError('FileReader error'));
|
|
29
|
+
reader.readAsDataURL(blob);
|
|
30
|
+
});
|
|
31
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2017",
|
|
4
|
+
"module": "Node16",
|
|
5
|
+
"moduleResolution": "node16",
|
|
6
|
+
"jsx": "react-native",
|
|
7
|
+
"strict": true,
|
|
8
|
+
"esModuleInterop": true,
|
|
9
|
+
"skipLibCheck": true,
|
|
10
|
+
"resolveJsonModule": true,
|
|
11
|
+
"noEmit": true
|
|
12
|
+
},
|
|
13
|
+
"include": ["src/**/*"]
|
|
14
|
+
}
|