@kyciris/core 0.1.2 → 1.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/README.md +273 -160
- package/dist/index.d.mts +1395 -153
- package/dist/index.d.ts +1395 -153
- package/dist/index.js +1930 -256
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1891 -252
- package/dist/index.mjs.map +1 -1
- package/package.json +39 -12
package/dist/index.js
CHANGED
|
@@ -1,316 +1,1990 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
// src/errors.ts
|
|
4
|
+
var GATEWAY_ERROR_CODES = [
|
|
5
|
+
// OCR processing
|
|
6
|
+
"INVALID_OCR_DATA",
|
|
7
|
+
"OCR_EXTRACTION_ERROR",
|
|
8
|
+
// Document validation
|
|
9
|
+
"DOCUMENT_EXPIRED",
|
|
10
|
+
"INVALID_DOCUMENT_QUALITY",
|
|
11
|
+
// Face matching
|
|
12
|
+
"FACE_MISMATCH",
|
|
13
|
+
"LOW_FACE_MATCH_SCORE",
|
|
14
|
+
"NO_FACE",
|
|
15
|
+
// Manual review
|
|
16
|
+
"MANUAL_REJECTION",
|
|
17
|
+
// Worker terminal failures
|
|
18
|
+
"BAD_PAYLOAD",
|
|
19
|
+
"UNSUPPORTED_DOCUMENT",
|
|
20
|
+
"BAD_IMAGE",
|
|
21
|
+
// Account status
|
|
22
|
+
"ACCOUNT_SUSPENDED",
|
|
23
|
+
// Generic HTTP labels
|
|
24
|
+
"VALIDATION_ERROR",
|
|
25
|
+
"UNAUTHORIZED",
|
|
26
|
+
"FORBIDDEN",
|
|
27
|
+
"NOT_FOUND",
|
|
28
|
+
"RESOURCE_ALREADY_EXISTS",
|
|
29
|
+
"RATE_LIMIT_EXCEEDED",
|
|
30
|
+
"INTERNAL_SERVER_ERROR"
|
|
31
|
+
];
|
|
32
|
+
var SDK_ERROR_CODES = [
|
|
33
|
+
/** The client was constructed with an unusable configuration. */
|
|
34
|
+
"CONFIG_ERROR",
|
|
35
|
+
/** A project API key was about to be sent from a browser or React Native app. */
|
|
36
|
+
"CREDENTIAL_MISUSE",
|
|
37
|
+
/** The endpoint needs a credential the client was not given. */
|
|
38
|
+
"MISSING_CREDENTIAL",
|
|
39
|
+
/** The request never reached the gateway (DNS, TLS, offline, CORS). */
|
|
40
|
+
"NETWORK_ERROR",
|
|
41
|
+
/** The request exceeded `timeoutMs`. */
|
|
42
|
+
"TIMEOUT",
|
|
43
|
+
/** An AbortSignal supplied by the caller fired. */
|
|
44
|
+
"ABORTED",
|
|
45
|
+
/** A 2xx response whose body was not the documented envelope. */
|
|
46
|
+
"INVALID_RESPONSE",
|
|
47
|
+
/** A file argument could not be turned into an upload on this platform. */
|
|
48
|
+
"INVALID_FILE",
|
|
49
|
+
/** Polling ran past its own timeout without reaching a final state. */
|
|
50
|
+
"POLL_TIMEOUT",
|
|
51
|
+
/** No verification session was found in storage to resume from. */
|
|
52
|
+
"SESSION_NOT_FOUND"
|
|
53
|
+
];
|
|
54
|
+
var KycirisError = class _KycirisError extends Error {
|
|
55
|
+
constructor(message, init) {
|
|
56
|
+
super(message);
|
|
57
|
+
this.name = "KycirisError";
|
|
58
|
+
if (init.cause !== void 0) {
|
|
59
|
+
this.cause = init.cause;
|
|
60
|
+
}
|
|
61
|
+
this.code = init.code;
|
|
62
|
+
this.status = init.status;
|
|
63
|
+
this.requestId = init.requestId;
|
|
64
|
+
this.details = init.details;
|
|
65
|
+
this.retryable = init.retryable ?? false;
|
|
66
|
+
Object.setPrototypeOf(this, _KycirisError.prototype);
|
|
67
|
+
}
|
|
68
|
+
/** True when this is a KycirisError, across bundle/realm boundaries. */
|
|
69
|
+
static isKycirisError(value) {
|
|
70
|
+
return value instanceof _KycirisError || typeof value === "object" && value !== null && value.name === "KycirisError" && typeof value.code === "string";
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
function isKycirisError(value) {
|
|
74
|
+
return KycirisError.isKycirisError(value);
|
|
75
|
+
}
|
|
76
|
+
function isRetryableStatus(status) {
|
|
77
|
+
return status === 408 || status === 429 || status >= 500 && status <= 599;
|
|
78
|
+
}
|
|
4
79
|
|
|
5
|
-
|
|
80
|
+
// src/files.ts
|
|
81
|
+
var MAX_IMAGE_FILE_SIZE = 8 * 1024 * 1024;
|
|
82
|
+
var NORMALIZED = /* @__PURE__ */ Symbol.for("kyciris.normalizedFile");
|
|
83
|
+
function mark(file) {
|
|
84
|
+
Object.defineProperty(file, NORMALIZED, { value: true, enumerable: false });
|
|
85
|
+
return file;
|
|
86
|
+
}
|
|
87
|
+
function isNormalizedFile(value) {
|
|
88
|
+
return typeof value === "object" && value !== null && value[NORMALIZED] === true;
|
|
89
|
+
}
|
|
90
|
+
function isReactNative() {
|
|
91
|
+
return typeof navigator !== "undefined" && navigator.product === "ReactNative";
|
|
92
|
+
}
|
|
93
|
+
var JPEG_MAGIC = [255, 216, 255];
|
|
94
|
+
var PNG_MAGIC = [137, 80, 78, 71, 13, 10, 26, 10];
|
|
95
|
+
function startsWith(bytes, magic) {
|
|
96
|
+
if (bytes.length < magic.length) return false;
|
|
97
|
+
return magic.every((byte, index) => bytes[index] === byte);
|
|
98
|
+
}
|
|
99
|
+
function detectImageMimeType(bytes) {
|
|
100
|
+
if (bytes.length < 8) return null;
|
|
101
|
+
if (startsWith(bytes, JPEG_MAGIC)) return "image/jpeg";
|
|
102
|
+
if (startsWith(bytes, PNG_MAGIC)) return "image/png";
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
var BASE64_ALPHABET = /^[A-Za-z0-9+/]+={0,2}$/;
|
|
106
|
+
function decodeBase64(input) {
|
|
107
|
+
const normalized = input.replace(/\s/g, "");
|
|
108
|
+
const maybeBuffer = globalThis.Buffer;
|
|
109
|
+
if (maybeBuffer) {
|
|
110
|
+
const buffer = maybeBuffer.from(normalized, "base64");
|
|
111
|
+
return new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength);
|
|
112
|
+
}
|
|
113
|
+
if (typeof atob === "function") {
|
|
114
|
+
const binary = atob(normalized);
|
|
115
|
+
const bytes = new Uint8Array(binary.length);
|
|
116
|
+
for (let i = 0; i < binary.length; i += 1) {
|
|
117
|
+
bytes[i] = binary.charCodeAt(i);
|
|
118
|
+
}
|
|
119
|
+
return bytes;
|
|
120
|
+
}
|
|
121
|
+
throw new KycirisError(
|
|
122
|
+
"This runtime has neither Buffer nor atob, so base64 image data cannot be decoded. Pass a Blob or raw bytes instead.",
|
|
123
|
+
{ code: "INVALID_FILE" }
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
function toBytes(data) {
|
|
127
|
+
return data instanceof ArrayBuffer ? new Uint8Array(data) : new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
|
|
128
|
+
}
|
|
129
|
+
function isPlatformUri(value) {
|
|
130
|
+
return /^(file|content|ph|assets-library):/i.test(value);
|
|
131
|
+
}
|
|
132
|
+
var DATA_URI_RE = /^data:([^;,]*)(;[^,]*)?,(.*)$/s;
|
|
133
|
+
function parseDataUri(value) {
|
|
134
|
+
const match = DATA_URI_RE.exec(value);
|
|
135
|
+
if (!match) {
|
|
136
|
+
throw new KycirisError("Malformed data: URI.", { code: "INVALID_FILE" });
|
|
137
|
+
}
|
|
138
|
+
const [, declaredType, parameters, payload] = match;
|
|
139
|
+
const isBase64 = (parameters ?? "").toLowerCase().includes("base64");
|
|
140
|
+
let bytes;
|
|
141
|
+
if (isBase64) {
|
|
142
|
+
bytes = decodeBase64(payload);
|
|
143
|
+
} else {
|
|
144
|
+
try {
|
|
145
|
+
bytes = new TextEncoder().encode(decodeURIComponent(payload));
|
|
146
|
+
} catch (cause) {
|
|
147
|
+
throw new KycirisError(
|
|
148
|
+
"Malformed percent-encoding in a data: URI. Image data should be base64-encoded (data:image/jpeg;base64,...) \u2014 percent-encoding is UTF-8 and cannot carry arbitrary bytes.",
|
|
149
|
+
{ code: "INVALID_FILE", cause }
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return { mimeType: declaredType || "application/octet-stream", bytes };
|
|
154
|
+
}
|
|
155
|
+
function assertBlobSize(blob, filename) {
|
|
156
|
+
if (blob.size === 0) {
|
|
157
|
+
throw new KycirisError(`${filename} is empty.`, { code: "INVALID_FILE" });
|
|
158
|
+
}
|
|
159
|
+
if (blob.size > MAX_IMAGE_FILE_SIZE) {
|
|
160
|
+
throw new KycirisError(
|
|
161
|
+
`${filename} is ${(blob.size / 1024 / 1024).toFixed(1)}MB; the gateway accepts at most ${MAX_IMAGE_FILE_SIZE / 1024 / 1024}MB. Re-encode at a lower quality before uploading.`,
|
|
162
|
+
{ code: "INVALID_FILE" }
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
function assertUsableImage(bytes, filename) {
|
|
167
|
+
if (bytes.length === 0) {
|
|
168
|
+
throw new KycirisError(`${filename} is empty.`, { code: "INVALID_FILE" });
|
|
169
|
+
}
|
|
170
|
+
if (bytes.length > MAX_IMAGE_FILE_SIZE) {
|
|
171
|
+
throw new KycirisError(
|
|
172
|
+
`${filename} is ${(bytes.length / 1024 / 1024).toFixed(1)}MB; the gateway accepts at most ${MAX_IMAGE_FILE_SIZE / 1024 / 1024}MB. Re-encode at a lower quality before uploading.`,
|
|
173
|
+
{ code: "INVALID_FILE" }
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
const mimeType = detectImageMimeType(bytes);
|
|
177
|
+
if (!mimeType) {
|
|
178
|
+
throw new KycirisError(
|
|
179
|
+
`${filename} is not a JPEG or PNG image. The gateway decides the type from the file's own bytes, not its name or Content-Type.`,
|
|
180
|
+
{ code: "INVALID_FILE" }
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
return mimeType;
|
|
184
|
+
}
|
|
185
|
+
function bytesToValue(bytes, mimeType, filename) {
|
|
186
|
+
if (isReactNative()) {
|
|
187
|
+
const base64 = encodeBase64(bytes);
|
|
188
|
+
return mark({
|
|
189
|
+
value: { uri: `data:${mimeType};base64,${base64}`, name: filename, type: mimeType },
|
|
190
|
+
filename,
|
|
191
|
+
mimeType
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
if (typeof Blob === "undefined") {
|
|
195
|
+
throw new KycirisError(
|
|
196
|
+
"This runtime has no Blob, so image bytes cannot be turned into an upload. Node 18+ is required.",
|
|
197
|
+
{ code: "INVALID_FILE" }
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
const copy = new Uint8Array(bytes.byteLength);
|
|
201
|
+
copy.set(bytes);
|
|
202
|
+
return mark({ value: new Blob([copy], { type: mimeType }), filename, mimeType });
|
|
203
|
+
}
|
|
204
|
+
function encodeBase64(bytes) {
|
|
205
|
+
const maybeBuffer = globalThis.Buffer;
|
|
206
|
+
if (maybeBuffer) {
|
|
207
|
+
return maybeBuffer.from(bytes).toString("base64");
|
|
208
|
+
}
|
|
209
|
+
if (typeof btoa === "function") {
|
|
210
|
+
let binary = "";
|
|
211
|
+
const CHUNK = 32768;
|
|
212
|
+
for (let i = 0; i < bytes.length; i += CHUNK) {
|
|
213
|
+
binary += String.fromCharCode(...bytes.subarray(i, i + CHUNK));
|
|
214
|
+
}
|
|
215
|
+
return btoa(binary);
|
|
216
|
+
}
|
|
217
|
+
throw new KycirisError(
|
|
218
|
+
"This runtime has neither Buffer nor btoa, so image bytes cannot be base64-encoded.",
|
|
219
|
+
{ code: "INVALID_FILE" }
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
function normalizeFile(input, defaultName) {
|
|
223
|
+
if (input == null) {
|
|
224
|
+
throw new KycirisError(`No image was given for ${defaultName}.`, {
|
|
225
|
+
code: "INVALID_FILE"
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
if (isNormalizedFile(input)) return input;
|
|
229
|
+
if (typeof Blob !== "undefined" && input instanceof Blob) {
|
|
230
|
+
assertBlobSize(input, defaultName);
|
|
231
|
+
const filename = input.name || defaultName;
|
|
232
|
+
return mark({ value: input, filename, mimeType: input.type || "image/jpeg" });
|
|
233
|
+
}
|
|
234
|
+
if (typeof input === "string") {
|
|
235
|
+
if (input.startsWith("data:")) {
|
|
236
|
+
const { bytes: bytes2 } = parseDataUri(input);
|
|
237
|
+
const mimeType2 = assertUsableImage(bytes2, defaultName);
|
|
238
|
+
return bytesToValue(bytes2, mimeType2, defaultName);
|
|
239
|
+
}
|
|
240
|
+
if (isPlatformUri(input)) {
|
|
241
|
+
if (!isReactNative()) {
|
|
242
|
+
throw new KycirisError(
|
|
243
|
+
`${defaultName} was given as "${input.slice(0, 12)}\u2026", a URI only React Native can read. Read the file yourself and pass a Blob or its bytes.`,
|
|
244
|
+
{ code: "INVALID_FILE" }
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
return mark({
|
|
248
|
+
value: { uri: input, name: defaultName, type: "image/jpeg" },
|
|
249
|
+
filename: defaultName,
|
|
250
|
+
mimeType: "image/jpeg"
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
const trimmed = input.replace(/\s/g, "");
|
|
254
|
+
if (!trimmed || !BASE64_ALPHABET.test(trimmed)) {
|
|
255
|
+
throw new KycirisError(
|
|
256
|
+
`${defaultName} was given as a string that is neither a data: URI, a file URI, nor base64.`,
|
|
257
|
+
{ code: "INVALID_FILE" }
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
const bytes = decodeBase64(trimmed);
|
|
261
|
+
const mimeType = assertUsableImage(bytes, defaultName);
|
|
262
|
+
return bytesToValue(bytes, mimeType, defaultName);
|
|
263
|
+
}
|
|
264
|
+
if ("uri" in input && typeof input.uri === "string") {
|
|
265
|
+
if (input.uri.startsWith("data:")) {
|
|
266
|
+
const { bytes } = parseDataUri(input.uri);
|
|
267
|
+
const mimeType2 = assertUsableImage(bytes, input.name ?? defaultName);
|
|
268
|
+
return bytesToValue(bytes, mimeType2, input.name ?? defaultName);
|
|
269
|
+
}
|
|
270
|
+
if (!isReactNative()) {
|
|
271
|
+
throw new KycirisError(
|
|
272
|
+
`${defaultName} was given as a { uri } object, which only React Native can read. Read the file yourself and pass a Blob or its bytes.`,
|
|
273
|
+
{ code: "INVALID_FILE" }
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
const filename = input.name ?? defaultName;
|
|
277
|
+
const mimeType = input.type ?? "image/jpeg";
|
|
278
|
+
return mark({ value: { uri: input.uri, name: filename, type: mimeType }, filename, mimeType });
|
|
279
|
+
}
|
|
280
|
+
if ("data" in input && input.data) {
|
|
281
|
+
const bytes = toBytes(input.data);
|
|
282
|
+
const filename = input.name ?? defaultName;
|
|
283
|
+
const mimeType = assertUsableImage(bytes, filename);
|
|
284
|
+
return bytesToValue(bytes, mimeType, filename);
|
|
285
|
+
}
|
|
286
|
+
throw new KycirisError(
|
|
287
|
+
`${defaultName} is not a Blob, a { uri } object, a { data } object, or a base64/data: string.`,
|
|
288
|
+
{ code: "INVALID_FILE" }
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
async function readBlobHead(blob) {
|
|
292
|
+
const head = blob.slice(0, 8);
|
|
293
|
+
if (typeof head.arrayBuffer === "function") {
|
|
294
|
+
try {
|
|
295
|
+
return new Uint8Array(await head.arrayBuffer());
|
|
296
|
+
} catch {
|
|
297
|
+
return null;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
if (typeof FileReader === "undefined") return null;
|
|
301
|
+
return new Promise((resolve) => {
|
|
302
|
+
const reader = new FileReader();
|
|
303
|
+
reader.onload = () => resolve(reader.result instanceof ArrayBuffer ? new Uint8Array(reader.result) : null);
|
|
304
|
+
reader.onerror = () => resolve(null);
|
|
305
|
+
reader.onabort = () => resolve(null);
|
|
306
|
+
try {
|
|
307
|
+
reader.readAsArrayBuffer(head);
|
|
308
|
+
} catch {
|
|
309
|
+
resolve(null);
|
|
310
|
+
}
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
async function prepareFile(input, defaultName) {
|
|
314
|
+
if (isNormalizedFile(input)) return input;
|
|
315
|
+
if (typeof Blob !== "undefined" && input instanceof Blob) {
|
|
316
|
+
assertBlobSize(input, defaultName);
|
|
317
|
+
const filename = input.name || defaultName;
|
|
318
|
+
const head = await readBlobHead(input);
|
|
319
|
+
if (!head) {
|
|
320
|
+
return mark({ value: input, filename, mimeType: input.type || "image/jpeg" });
|
|
321
|
+
}
|
|
322
|
+
const mimeType = detectImageMimeType(head);
|
|
323
|
+
if (!mimeType) {
|
|
324
|
+
throw new KycirisError(
|
|
325
|
+
`${filename} is not a JPEG or PNG image. The gateway decides the type from the file's own bytes, not its name or Content-Type.`,
|
|
326
|
+
{ code: "INVALID_FILE" }
|
|
327
|
+
);
|
|
328
|
+
}
|
|
329
|
+
return mark({ value: input, filename, mimeType });
|
|
330
|
+
}
|
|
331
|
+
return normalizeFile(input, defaultName);
|
|
332
|
+
}
|
|
6
333
|
|
|
7
|
-
|
|
334
|
+
// src/http/envelope.ts
|
|
335
|
+
function isRecord(value) {
|
|
336
|
+
return typeof value === "object" && value !== null;
|
|
337
|
+
}
|
|
338
|
+
function readMeta(body) {
|
|
339
|
+
if (!isRecord(body) || !isRecord(body.meta)) return void 0;
|
|
340
|
+
const { requestId, timestamp, version } = body.meta;
|
|
341
|
+
if (typeof requestId !== "string") return void 0;
|
|
342
|
+
return {
|
|
343
|
+
requestId,
|
|
344
|
+
timestamp: typeof timestamp === "string" ? timestamp : "",
|
|
345
|
+
version: typeof version === "string" ? version : ""
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
function readRequestId(body) {
|
|
349
|
+
return readMeta(body)?.requestId;
|
|
350
|
+
}
|
|
351
|
+
function unwrapEnvelope(body, requestPath) {
|
|
352
|
+
const meta = readMeta(body);
|
|
353
|
+
if (!isRecord(body) || !("data" in body) || !meta) {
|
|
354
|
+
throw new KycirisError(
|
|
355
|
+
`The response to ${requestPath} was not a KYCiris API envelope. Check that baseUrl points at the gateway and that nothing is rewriting responses in between.`,
|
|
356
|
+
{ code: "INVALID_RESPONSE" }
|
|
357
|
+
);
|
|
358
|
+
}
|
|
359
|
+
const result = { data: body.data, meta };
|
|
360
|
+
if (isRecord(body.pagination)) {
|
|
361
|
+
const { page, limit, total } = body.pagination;
|
|
362
|
+
if (typeof page === "number" && typeof limit === "number" && typeof total === "number") {
|
|
363
|
+
result.pagination = { page, limit, total };
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
return result;
|
|
367
|
+
}
|
|
368
|
+
function toPage(result) {
|
|
369
|
+
const items = Array.isArray(result.data) ? result.data : [];
|
|
370
|
+
return {
|
|
371
|
+
items,
|
|
372
|
+
pagination: result.pagination ?? {
|
|
373
|
+
page: 1,
|
|
374
|
+
limit: items.length,
|
|
375
|
+
total: items.length
|
|
376
|
+
}
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
function readErrorEnvelope(body) {
|
|
380
|
+
if (!isRecord(body)) {
|
|
381
|
+
return typeof body === "string" && body.trim() ? { message: body.trim() } : {};
|
|
382
|
+
}
|
|
383
|
+
const error = isRecord(body.error) ? body.error : void 0;
|
|
384
|
+
if (error) {
|
|
385
|
+
return {
|
|
386
|
+
code: typeof error.code === "string" ? error.code : void 0,
|
|
387
|
+
message: typeof error.message === "string" ? error.message : void 0,
|
|
388
|
+
details: error.details
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
const message = body.message;
|
|
392
|
+
return {
|
|
393
|
+
message: Array.isArray(message) ? message.join(", ") : typeof message === "string" ? message : void 0
|
|
394
|
+
};
|
|
395
|
+
}
|
|
8
396
|
|
|
9
|
-
// src/
|
|
10
|
-
var
|
|
397
|
+
// src/http/client.ts
|
|
398
|
+
var DEFAULT_TIMEOUT_MS = 3e4;
|
|
399
|
+
var DEFAULT_MAX_RETRIES = 2;
|
|
400
|
+
var BASE_BACKOFF_MS = 300;
|
|
401
|
+
var MAX_BACKOFF_MS = 5e3;
|
|
402
|
+
var LOOPBACK_HOSTS = /* @__PURE__ */ new Set(["localhost", "127.0.0.1", "::1", "[::1]", "0.0.0.0"]);
|
|
403
|
+
function isUntrustedClient() {
|
|
404
|
+
if (typeof navigator !== "undefined" && navigator.product === "ReactNative") {
|
|
405
|
+
return true;
|
|
406
|
+
}
|
|
407
|
+
if (typeof window !== "undefined" && typeof window.document !== "undefined") {
|
|
408
|
+
return true;
|
|
409
|
+
}
|
|
410
|
+
const scope = globalThis;
|
|
411
|
+
if (typeof scope.importScripts === "function") return true;
|
|
412
|
+
return /WorkerGlobalScope$/.test(scope.constructor?.name ?? "");
|
|
413
|
+
}
|
|
414
|
+
function assertUsableBaseUrl(baseUrl, allowInsecure) {
|
|
415
|
+
let url;
|
|
416
|
+
try {
|
|
417
|
+
url = new URL(baseUrl);
|
|
418
|
+
} catch {
|
|
419
|
+
throw new KycirisError(
|
|
420
|
+
`baseUrl "${baseUrl}" is not a valid URL. Expected something like https://api.kyciris.com.`,
|
|
421
|
+
{ code: "CONFIG_ERROR" }
|
|
422
|
+
);
|
|
423
|
+
}
|
|
424
|
+
if (url.protocol === "https:") return url;
|
|
425
|
+
if (url.protocol !== "http:") {
|
|
426
|
+
throw new KycirisError(`baseUrl must be http:// or https://, not ${url.protocol}`, {
|
|
427
|
+
code: "CONFIG_ERROR"
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
if (LOOPBACK_HOSTS.has(url.hostname) || allowInsecure) return url;
|
|
431
|
+
throw new KycirisError(
|
|
432
|
+
`baseUrl "${baseUrl}" is plaintext http to a remote host. API keys, verification tokens and identity-document data would travel unencrypted. Use https, or set allowInsecureBaseUrl if this is a trusted private network.`,
|
|
433
|
+
{ code: "CONFIG_ERROR" }
|
|
434
|
+
);
|
|
435
|
+
}
|
|
436
|
+
function parseRetryAfter(header) {
|
|
437
|
+
if (!header) return void 0;
|
|
438
|
+
const seconds = Number(header);
|
|
439
|
+
if (Number.isFinite(seconds) && seconds >= 0) return seconds * 1e3;
|
|
440
|
+
const date = Date.parse(header);
|
|
441
|
+
if (!Number.isNaN(date)) return Math.max(0, date - Date.now());
|
|
442
|
+
return void 0;
|
|
443
|
+
}
|
|
444
|
+
function backoffDelay(attempt) {
|
|
445
|
+
const ceiling = Math.min(MAX_BACKOFF_MS, BASE_BACKOFF_MS * 2 ** attempt);
|
|
446
|
+
return Math.random() * ceiling;
|
|
447
|
+
}
|
|
448
|
+
function sleep(ms, signal) {
|
|
449
|
+
return new Promise((resolve, reject) => {
|
|
450
|
+
if (signal?.aborted) {
|
|
451
|
+
reject(new KycirisError("The request was aborted.", { code: "ABORTED" }));
|
|
452
|
+
return;
|
|
453
|
+
}
|
|
454
|
+
const timer = setTimeout(() => {
|
|
455
|
+
signal?.removeEventListener("abort", onAbort);
|
|
456
|
+
resolve();
|
|
457
|
+
}, ms);
|
|
458
|
+
function onAbort() {
|
|
459
|
+
clearTimeout(timer);
|
|
460
|
+
reject(new KycirisError("The request was aborted.", { code: "ABORTED" }));
|
|
461
|
+
}
|
|
462
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
463
|
+
});
|
|
464
|
+
}
|
|
465
|
+
var HttpClient = class {
|
|
466
|
+
constructor(config) {
|
|
467
|
+
this.baseUrl = assertUsableBaseUrl(
|
|
468
|
+
config.baseUrl,
|
|
469
|
+
config.allowInsecureBaseUrl ?? false
|
|
470
|
+
);
|
|
471
|
+
if (!config.apiKey && !config.verificationToken) {
|
|
472
|
+
throw new KycirisError(
|
|
473
|
+
"Provide either apiKey (server-side) or verificationToken (client-side) when creating the client.",
|
|
474
|
+
{ code: "CONFIG_ERROR" }
|
|
475
|
+
);
|
|
476
|
+
}
|
|
477
|
+
if (config.apiKey && isUntrustedClient() && !config.allowApiKeyInUntrustedClient) {
|
|
478
|
+
throw new KycirisError(
|
|
479
|
+
"A project API key must not be used from a browser or React Native app: it is readable by anyone who has the bundle and grants access to every identity in the project. Mint a verification token on your server (POST /verification/token) and pass it as verificationToken instead.",
|
|
480
|
+
{ code: "CREDENTIAL_MISUSE" }
|
|
481
|
+
);
|
|
482
|
+
}
|
|
483
|
+
this.apiKey = config.apiKey;
|
|
484
|
+
this.tokenSource = config.verificationToken;
|
|
485
|
+
this.apiVersion = (config.apiVersion ?? "v1").replace(/^\/+|\/+$/g, "");
|
|
486
|
+
this.timeoutMs = config.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
487
|
+
this.maxRetries = Math.max(0, config.maxRetries ?? DEFAULT_MAX_RETRIES);
|
|
488
|
+
this.extraHeaders = { ...config.headers };
|
|
489
|
+
const fetchImpl = config.fetch ?? globalThis.fetch;
|
|
490
|
+
if (!fetchImpl) {
|
|
491
|
+
throw new KycirisError(
|
|
492
|
+
"No global fetch was found. Use Node 18+, or pass a fetch implementation as `fetch`.",
|
|
493
|
+
{ code: "CONFIG_ERROR" }
|
|
494
|
+
);
|
|
495
|
+
}
|
|
496
|
+
this.fetchImpl = fetchImpl;
|
|
497
|
+
}
|
|
498
|
+
/** True when the client holds a project API key. */
|
|
499
|
+
hasApiKey() {
|
|
500
|
+
return Boolean(this.apiKey);
|
|
501
|
+
}
|
|
502
|
+
/** True when the client holds (or can fetch) a verification token. */
|
|
503
|
+
hasVerificationToken() {
|
|
504
|
+
return Boolean(this.tokenSource);
|
|
505
|
+
}
|
|
506
|
+
buildUrl(path, query) {
|
|
507
|
+
const basePath = this.baseUrl.pathname.replace(/\/+$/, "");
|
|
508
|
+
const url = new URL(this.baseUrl.toString());
|
|
509
|
+
url.pathname = `${basePath}/${this.apiVersion}${path.startsWith("/") ? path : `/${path}`}`;
|
|
510
|
+
if (query) {
|
|
511
|
+
for (const [key, value] of Object.entries(query)) {
|
|
512
|
+
if (value === void 0 || value === null) continue;
|
|
513
|
+
if (Array.isArray(value)) {
|
|
514
|
+
for (const entry of value) {
|
|
515
|
+
if (entry !== void 0 && entry !== null) {
|
|
516
|
+
url.searchParams.append(key, String(entry));
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
} else {
|
|
520
|
+
url.searchParams.append(key, String(value));
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
return url.toString();
|
|
525
|
+
}
|
|
526
|
+
async authHeaders(mode) {
|
|
527
|
+
if (mode === "none") return {};
|
|
528
|
+
if (mode === "api-key") {
|
|
529
|
+
if (!this.apiKey) {
|
|
530
|
+
throw new KycirisError(
|
|
531
|
+
"This endpoint requires the project API key, which is a server-side credential. A verification token is scoped to one end user and is refused here.",
|
|
532
|
+
{ code: "MISSING_CREDENTIAL", status: 401 }
|
|
533
|
+
);
|
|
534
|
+
}
|
|
535
|
+
return { "X-API-Key": this.apiKey };
|
|
536
|
+
}
|
|
537
|
+
const token = await this.resolveToken();
|
|
538
|
+
if (mode === "token") {
|
|
539
|
+
if (!token) {
|
|
540
|
+
throw new KycirisError(
|
|
541
|
+
"This endpoint requires a verification token. Mint one with client.verifications.createToken(externalId) using the project API key.",
|
|
542
|
+
{ code: "MISSING_CREDENTIAL", status: 401 }
|
|
543
|
+
);
|
|
544
|
+
}
|
|
545
|
+
return { "x-verification-token": token };
|
|
546
|
+
}
|
|
547
|
+
if (token) return { "x-verification-token": token };
|
|
548
|
+
if (this.apiKey) return { "X-API-Key": this.apiKey };
|
|
549
|
+
throw new KycirisError("No credential is configured for this request.", {
|
|
550
|
+
code: "MISSING_CREDENTIAL",
|
|
551
|
+
status: 401
|
|
552
|
+
});
|
|
553
|
+
}
|
|
554
|
+
async resolveToken() {
|
|
555
|
+
if (!this.tokenSource) return void 0;
|
|
556
|
+
if (typeof this.tokenSource === "string") return this.tokenSource;
|
|
557
|
+
const token = await this.tokenSource();
|
|
558
|
+
if (typeof token !== "string" || !token) {
|
|
559
|
+
throw new KycirisError(
|
|
560
|
+
"The verificationToken provider returned no token.",
|
|
561
|
+
{ code: "MISSING_CREDENTIAL", status: 401 }
|
|
562
|
+
);
|
|
563
|
+
}
|
|
564
|
+
return token;
|
|
565
|
+
}
|
|
566
|
+
async buildForm(form, fileFields) {
|
|
567
|
+
if (typeof FormData === "undefined") {
|
|
568
|
+
throw new KycirisError(
|
|
569
|
+
"This runtime has no FormData, so files cannot be uploaded. Node 18+ is required.",
|
|
570
|
+
{ code: "CONFIG_ERROR" }
|
|
571
|
+
);
|
|
572
|
+
}
|
|
573
|
+
const formData = new FormData();
|
|
574
|
+
for (const [field, value] of Object.entries(form)) {
|
|
575
|
+
if (value === void 0) continue;
|
|
576
|
+
if (fileFields.includes(field)) {
|
|
577
|
+
const file = await prepareFile(value, `${field}.jpg`);
|
|
578
|
+
formData.append(field, file.value, file.filename);
|
|
579
|
+
} else {
|
|
580
|
+
formData.append(field, String(value));
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
return formData;
|
|
584
|
+
}
|
|
585
|
+
/** Runs a request, retrying where the method and failure allow it. */
|
|
586
|
+
async request(options) {
|
|
587
|
+
const method = options.method ?? "GET";
|
|
588
|
+
const url = this.buildUrl(options.path, options.query);
|
|
589
|
+
const idempotent = options.retry ?? method === "GET";
|
|
590
|
+
const formData = options.form ? await this.buildForm(options.form, options.fileFields ?? []) : void 0;
|
|
591
|
+
let attempt = 0;
|
|
592
|
+
for (; ; ) {
|
|
593
|
+
try {
|
|
594
|
+
return await this.attempt(options, method, url, formData);
|
|
595
|
+
} catch (error) {
|
|
596
|
+
const failure = error;
|
|
597
|
+
const canRetry = attempt < this.maxRetries && KycirisError.isKycirisError(failure) && failure.retryable && // A rate-limited request never ran, so repeating it is safe whatever
|
|
598
|
+
// the method. Everything else is only repeated when the caller says
|
|
599
|
+
// the call can be made twice.
|
|
600
|
+
(idempotent || failure.status === 429);
|
|
601
|
+
if (!canRetry) throw error;
|
|
602
|
+
const delay2 = failure.status === 429 && failure.details ? failure.details.retryAfterMs ?? backoffDelay(attempt) : backoffDelay(attempt);
|
|
603
|
+
await sleep(delay2, options.signal);
|
|
604
|
+
attempt += 1;
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
async attempt(options, method, url, formData) {
|
|
609
|
+
if (options.signal?.aborted) {
|
|
610
|
+
throw new KycirisError("The request was aborted.", { code: "ABORTED" });
|
|
611
|
+
}
|
|
612
|
+
const controller = new AbortController();
|
|
613
|
+
const timeoutMs = options.timeoutMs ?? this.timeoutMs;
|
|
614
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
615
|
+
const onCallerAbort = () => controller.abort();
|
|
616
|
+
options.signal?.addEventListener("abort", onCallerAbort, { once: true });
|
|
617
|
+
try {
|
|
618
|
+
const headers = {
|
|
619
|
+
Accept: "application/json",
|
|
620
|
+
...this.extraHeaders,
|
|
621
|
+
...await this.authHeaders(options.auth ?? "any")
|
|
622
|
+
};
|
|
623
|
+
let body;
|
|
624
|
+
if (formData) {
|
|
625
|
+
body = formData;
|
|
626
|
+
} else if (options.body !== void 0) {
|
|
627
|
+
headers["Content-Type"] = "application/json";
|
|
628
|
+
body = JSON.stringify(options.body);
|
|
629
|
+
}
|
|
630
|
+
let response;
|
|
631
|
+
try {
|
|
632
|
+
response = await this.fetchImpl(url, {
|
|
633
|
+
method,
|
|
634
|
+
headers,
|
|
635
|
+
body,
|
|
636
|
+
signal: controller.signal
|
|
637
|
+
});
|
|
638
|
+
} catch (cause) {
|
|
639
|
+
if (options.signal?.aborted) {
|
|
640
|
+
throw new KycirisError("The request was aborted.", {
|
|
641
|
+
code: "ABORTED",
|
|
642
|
+
cause
|
|
643
|
+
});
|
|
644
|
+
}
|
|
645
|
+
if (controller.signal.aborted) {
|
|
646
|
+
throw new KycirisError(
|
|
647
|
+
`The request to ${options.path} timed out after ${timeoutMs}ms.`,
|
|
648
|
+
{ code: "TIMEOUT", retryable: true, cause }
|
|
649
|
+
);
|
|
650
|
+
}
|
|
651
|
+
throw new KycirisError(
|
|
652
|
+
`The request to ${options.path} could not reach the gateway. Check connectivity, the baseUrl, and (in a browser) that this origin is in the gateway's ALLOWED_ORIGINS.`,
|
|
653
|
+
{ code: "NETWORK_ERROR", retryable: true, cause }
|
|
654
|
+
);
|
|
655
|
+
}
|
|
656
|
+
const payload = await readBody(response);
|
|
657
|
+
if (!response.ok) {
|
|
658
|
+
throw toApiError(response, payload, options.path);
|
|
659
|
+
}
|
|
660
|
+
if (response.status === 204 || payload === void 0) {
|
|
661
|
+
return {
|
|
662
|
+
data: void 0,
|
|
663
|
+
meta: {
|
|
664
|
+
requestId: response.headers.get("x-request-id") ?? "",
|
|
665
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
666
|
+
version: ""
|
|
667
|
+
}
|
|
668
|
+
};
|
|
669
|
+
}
|
|
670
|
+
return unwrapEnvelope(payload, options.path);
|
|
671
|
+
} finally {
|
|
672
|
+
clearTimeout(timer);
|
|
673
|
+
options.signal?.removeEventListener("abort", onCallerAbort);
|
|
674
|
+
}
|
|
675
|
+
}
|
|
11
676
|
/**
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* @param code Error code for handling
|
|
15
|
-
* @param statusCode Optional HTTP status code
|
|
677
|
+
* Streams a response body instead of parsing it — the media download
|
|
678
|
+
* endpoint returns an image, not an envelope.
|
|
16
679
|
*/
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
this.
|
|
680
|
+
async requestRaw(options) {
|
|
681
|
+
if (options.signal?.aborted) {
|
|
682
|
+
throw new KycirisError("The request was aborted.", { code: "ABORTED" });
|
|
683
|
+
}
|
|
684
|
+
const url = this.buildUrl(options.path, options.query);
|
|
685
|
+
const controller = new AbortController();
|
|
686
|
+
const timeoutMs = options.timeoutMs ?? this.timeoutMs;
|
|
687
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
688
|
+
const onCallerAbort = () => controller.abort();
|
|
689
|
+
options.signal?.addEventListener("abort", onCallerAbort, { once: true });
|
|
690
|
+
try {
|
|
691
|
+
const headers = {
|
|
692
|
+
...this.extraHeaders,
|
|
693
|
+
...await this.authHeaders(options.auth ?? "any")
|
|
694
|
+
};
|
|
695
|
+
let response;
|
|
696
|
+
try {
|
|
697
|
+
response = await this.fetchImpl(url, {
|
|
698
|
+
method: options.method ?? "GET",
|
|
699
|
+
headers,
|
|
700
|
+
signal: controller.signal
|
|
701
|
+
});
|
|
702
|
+
} catch (cause) {
|
|
703
|
+
if (options.signal?.aborted) {
|
|
704
|
+
throw new KycirisError("The request was aborted.", { code: "ABORTED", cause });
|
|
705
|
+
}
|
|
706
|
+
if (controller.signal.aborted) {
|
|
707
|
+
throw new KycirisError(
|
|
708
|
+
`The request to ${options.path} timed out after ${timeoutMs}ms.`,
|
|
709
|
+
{ code: "TIMEOUT", retryable: true, cause }
|
|
710
|
+
);
|
|
711
|
+
}
|
|
712
|
+
throw new KycirisError(
|
|
713
|
+
`The request to ${options.path} could not reach the gateway.`,
|
|
714
|
+
{ code: "NETWORK_ERROR", retryable: true, cause }
|
|
715
|
+
);
|
|
716
|
+
}
|
|
717
|
+
if (!response.ok) {
|
|
718
|
+
throw toApiError(response, await readBody(response), options.path);
|
|
719
|
+
}
|
|
720
|
+
return response;
|
|
721
|
+
} finally {
|
|
722
|
+
clearTimeout(timer);
|
|
723
|
+
options.signal?.removeEventListener("abort", onCallerAbort);
|
|
724
|
+
}
|
|
22
725
|
}
|
|
23
726
|
};
|
|
24
|
-
|
|
727
|
+
async function readBody(response) {
|
|
728
|
+
const contentType = response.headers.get("content-type") ?? "";
|
|
729
|
+
try {
|
|
730
|
+
if (contentType.includes("application/json")) {
|
|
731
|
+
return await response.json();
|
|
732
|
+
}
|
|
733
|
+
const text = await response.text();
|
|
734
|
+
if (!text) return void 0;
|
|
735
|
+
try {
|
|
736
|
+
return JSON.parse(text);
|
|
737
|
+
} catch {
|
|
738
|
+
return text;
|
|
739
|
+
}
|
|
740
|
+
} catch {
|
|
741
|
+
return void 0;
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
function toApiError(response, payload, path) {
|
|
745
|
+
const { code, message, details } = readErrorEnvelope(payload);
|
|
746
|
+
const retryAfterMs = parseRetryAfter(response.headers.get("retry-after"));
|
|
747
|
+
const fallback = response.status === 401 ? "The credential was rejected. Check the API key, or mint a fresh verification token \u2014 they expire." : response.status === 403 ? "The gateway refused this request." : `The gateway returned ${response.status} for ${path}.`;
|
|
748
|
+
return new KycirisError(message || fallback, {
|
|
749
|
+
code: code ?? statusFallbackCode(response.status),
|
|
750
|
+
status: response.status,
|
|
751
|
+
requestId: readRequestId(payload) ?? response.headers.get("x-request-id") ?? void 0,
|
|
752
|
+
details: retryAfterMs !== void 0 ? { ...typeof details === "object" && details ? details : { details }, retryAfterMs } : details,
|
|
753
|
+
retryable: isRetryableStatus(response.status)
|
|
754
|
+
});
|
|
755
|
+
}
|
|
756
|
+
function statusFallbackCode(status) {
|
|
757
|
+
switch (status) {
|
|
758
|
+
case 400:
|
|
759
|
+
case 422:
|
|
760
|
+
return "VALIDATION_ERROR";
|
|
761
|
+
case 401:
|
|
762
|
+
return "UNAUTHORIZED";
|
|
763
|
+
case 403:
|
|
764
|
+
return "FORBIDDEN";
|
|
765
|
+
case 404:
|
|
766
|
+
return "NOT_FOUND";
|
|
767
|
+
case 409:
|
|
768
|
+
return "RESOURCE_ALREADY_EXISTS";
|
|
769
|
+
case 429:
|
|
770
|
+
return "RATE_LIMIT_EXCEEDED";
|
|
771
|
+
default:
|
|
772
|
+
return "INTERNAL_SERVER_ERROR";
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
// src/resources/analytics.ts
|
|
777
|
+
var AnalyticsResource = class {
|
|
778
|
+
constructor(http) {
|
|
779
|
+
this.http = http;
|
|
780
|
+
}
|
|
781
|
+
async get(options = {}) {
|
|
782
|
+
const result = await this.http.request({
|
|
783
|
+
path: "/analytics",
|
|
784
|
+
auth: "api-key",
|
|
785
|
+
signal: options.signal
|
|
786
|
+
});
|
|
787
|
+
return result.data;
|
|
788
|
+
}
|
|
789
|
+
};
|
|
790
|
+
|
|
791
|
+
// src/poll.ts
|
|
792
|
+
var DEFAULT_INTERVAL_MS = 3e3;
|
|
793
|
+
var DEFAULT_TIMEOUT_MS2 = 12e4;
|
|
794
|
+
function abortError() {
|
|
795
|
+
return new KycirisError("Polling was aborted.", { code: "ABORTED" });
|
|
796
|
+
}
|
|
797
|
+
async function pollUntil(fetchOnce, isDone, options = {}, description = "the operation") {
|
|
798
|
+
const intervalMs = Math.max(0, options.intervalMs ?? DEFAULT_INTERVAL_MS);
|
|
799
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS2;
|
|
800
|
+
const deadline = Date.now() + timeoutMs;
|
|
801
|
+
for (; ; ) {
|
|
802
|
+
if (options.signal?.aborted) throw abortError();
|
|
803
|
+
const value = await fetchOnce(options.signal);
|
|
804
|
+
options.onUpdate?.(value);
|
|
805
|
+
if (isDone(value)) return value;
|
|
806
|
+
const remaining = deadline - Date.now();
|
|
807
|
+
if (remaining <= 0) {
|
|
808
|
+
throw new KycirisError(
|
|
809
|
+
`Timed out after ${timeoutMs}ms waiting for ${description} to finish. It may still complete \u2014 read the status again later, or subscribe to the webhook instead of polling.`,
|
|
810
|
+
{ code: "POLL_TIMEOUT", details: { lastValue: value } }
|
|
811
|
+
);
|
|
812
|
+
}
|
|
813
|
+
await delay(Math.min(intervalMs, remaining), options.signal);
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
function delay(ms, signal) {
|
|
817
|
+
return new Promise((resolve, reject) => {
|
|
818
|
+
if (signal?.aborted) {
|
|
819
|
+
reject(abortError());
|
|
820
|
+
return;
|
|
821
|
+
}
|
|
822
|
+
const timer = setTimeout(() => {
|
|
823
|
+
signal?.removeEventListener("abort", onAbort);
|
|
824
|
+
resolve();
|
|
825
|
+
}, ms);
|
|
826
|
+
function onAbort() {
|
|
827
|
+
clearTimeout(timer);
|
|
828
|
+
reject(abortError());
|
|
829
|
+
}
|
|
830
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
831
|
+
});
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
// src/resources/identities.ts
|
|
835
|
+
var IdentitiesResource = class {
|
|
836
|
+
constructor(http) {
|
|
837
|
+
this.http = http;
|
|
838
|
+
}
|
|
839
|
+
/** Registers an identity independently of any verification. */
|
|
840
|
+
async create(fields, options = {}) {
|
|
841
|
+
const result = await this.http.request({
|
|
842
|
+
method: "POST",
|
|
843
|
+
path: "/identities",
|
|
844
|
+
body: fields,
|
|
845
|
+
auth: "api-key",
|
|
846
|
+
signal: options.signal
|
|
847
|
+
});
|
|
848
|
+
return result.data;
|
|
849
|
+
}
|
|
850
|
+
/** A page of identity summaries. Read one identity for the full record. */
|
|
851
|
+
async list(params = {}, options = {}) {
|
|
852
|
+
const result = await this.http.request({
|
|
853
|
+
path: "/identities",
|
|
854
|
+
query: { ...params },
|
|
855
|
+
auth: "api-key",
|
|
856
|
+
signal: options.signal
|
|
857
|
+
});
|
|
858
|
+
return toPage(result);
|
|
859
|
+
}
|
|
860
|
+
/** The full record, including its verification history. */
|
|
861
|
+
async get(id, options = {}) {
|
|
862
|
+
const result = await this.http.request({
|
|
863
|
+
path: `/identities/${encodeURIComponent(id)}`,
|
|
864
|
+
auth: "api-key",
|
|
865
|
+
signal: options.signal
|
|
866
|
+
});
|
|
867
|
+
return result.data;
|
|
868
|
+
}
|
|
869
|
+
/** This identity's verifications, without embeddings or storage paths. */
|
|
870
|
+
async verifications(id, options = {}) {
|
|
871
|
+
const result = await this.http.request({
|
|
872
|
+
path: `/identities/${encodeURIComponent(id)}/verifications`,
|
|
873
|
+
auth: "api-key",
|
|
874
|
+
signal: options.signal
|
|
875
|
+
});
|
|
876
|
+
return result.data ?? [];
|
|
877
|
+
}
|
|
878
|
+
async update(id, fields, options = {}) {
|
|
879
|
+
const result = await this.http.request({
|
|
880
|
+
method: "PUT",
|
|
881
|
+
path: `/identities/${encodeURIComponent(id)}`,
|
|
882
|
+
body: fields,
|
|
883
|
+
auth: "api-key",
|
|
884
|
+
signal: options.signal
|
|
885
|
+
});
|
|
886
|
+
return result.data;
|
|
887
|
+
}
|
|
888
|
+
/** Deletes the identity, its verifications, and the stored images. */
|
|
889
|
+
async delete(id, options = {}) {
|
|
890
|
+
await this.http.request({
|
|
891
|
+
method: "DELETE",
|
|
892
|
+
path: `/identities/${encodeURIComponent(id)}`,
|
|
893
|
+
auth: "api-key",
|
|
894
|
+
signal: options.signal
|
|
895
|
+
});
|
|
896
|
+
}
|
|
25
897
|
/**
|
|
26
|
-
*
|
|
27
|
-
*
|
|
898
|
+
* Groups identities that share a value, to find duplicate registrations.
|
|
899
|
+
*
|
|
900
|
+
* Passing one field returns a single grouping; passing several returns one
|
|
901
|
+
* per field, which is why the return type is a union.
|
|
28
902
|
*/
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
"Content-Type": "application/json",
|
|
36
|
-
Accept: "application/json"
|
|
37
|
-
}
|
|
903
|
+
async findDuplicates(params = {}, options = {}) {
|
|
904
|
+
const result = await this.http.request({
|
|
905
|
+
path: "/identities/duplicates",
|
|
906
|
+
query: { field: params.field, id: params.id },
|
|
907
|
+
auth: "api-key",
|
|
908
|
+
signal: options.signal
|
|
38
909
|
});
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
910
|
+
return result.data;
|
|
911
|
+
}
|
|
912
|
+
/**
|
|
913
|
+
* Folds duplicates into a primary identity, moving their verifications
|
|
914
|
+
* across and deleting them. Requires the admin role on a user token; an API
|
|
915
|
+
* key is a project credential and carries no role.
|
|
916
|
+
*/
|
|
917
|
+
async merge(id, duplicateIds, options = {}) {
|
|
918
|
+
const result = await this.http.request({
|
|
919
|
+
method: "POST",
|
|
920
|
+
path: "/identities/merge",
|
|
921
|
+
body: { id, duplicateIds },
|
|
922
|
+
auth: "api-key",
|
|
923
|
+
signal: options.signal
|
|
42
924
|
});
|
|
925
|
+
return result.data;
|
|
43
926
|
}
|
|
44
927
|
/**
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
928
|
+
* Compares a fresh photo against the face already on file for an identity —
|
|
929
|
+
* a re-authentication check, separate from the verification flow.
|
|
930
|
+
*
|
|
931
|
+
* Asynchronous: the returned check starts PENDING. Poll it with
|
|
932
|
+
* `faceCheckStatus`, or use `waitForFaceCheck`.
|
|
48
933
|
*/
|
|
49
|
-
|
|
50
|
-
this.
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
934
|
+
async faceMatch(id, file, options = {}) {
|
|
935
|
+
const result = await this.http.request({
|
|
936
|
+
method: "POST",
|
|
937
|
+
path: `/identities/${encodeURIComponent(id)}/face-match`,
|
|
938
|
+
form: { file },
|
|
939
|
+
fileFields: ["file"],
|
|
940
|
+
auth: "api-key",
|
|
941
|
+
signal: options.signal,
|
|
942
|
+
timeoutMs: options.timeoutMs
|
|
943
|
+
});
|
|
944
|
+
return result.data;
|
|
945
|
+
}
|
|
946
|
+
/** The result of a face-match check started with `faceMatch`. */
|
|
947
|
+
async faceCheckStatus(id, faceCheckId, options = {}) {
|
|
948
|
+
const result = await this.http.request({
|
|
949
|
+
path: `/identities/${encodeURIComponent(id)}/face-match/status/${encodeURIComponent(faceCheckId)}`,
|
|
950
|
+
auth: "api-key",
|
|
951
|
+
signal: options.signal
|
|
952
|
+
});
|
|
953
|
+
return result.data;
|
|
954
|
+
}
|
|
955
|
+
/** Polls a face-match check until it is APPROVED or REJECTED. */
|
|
956
|
+
async waitForFaceCheck(id, faceCheckId, options = {}) {
|
|
957
|
+
return pollUntil(
|
|
958
|
+
(signal) => this.faceCheckStatus(id, faceCheckId, { signal }),
|
|
959
|
+
(check) => check.status === "APPROVED" || check.status === "REJECTED",
|
|
960
|
+
options,
|
|
961
|
+
`face check ${faceCheckId}`
|
|
962
|
+
);
|
|
963
|
+
}
|
|
964
|
+
};
|
|
965
|
+
|
|
966
|
+
// src/resources/levels.ts
|
|
967
|
+
var LevelsResource = class {
|
|
968
|
+
constructor(http) {
|
|
969
|
+
this.http = http;
|
|
970
|
+
}
|
|
971
|
+
async create(params, options = {}) {
|
|
972
|
+
const result = await this.http.request({
|
|
973
|
+
method: "POST",
|
|
974
|
+
path: "/verification-levels",
|
|
975
|
+
body: params,
|
|
976
|
+
auth: "api-key",
|
|
977
|
+
signal: options.signal
|
|
978
|
+
});
|
|
979
|
+
return result.data;
|
|
980
|
+
}
|
|
981
|
+
async list(options = {}) {
|
|
982
|
+
const result = await this.http.request({
|
|
983
|
+
path: "/verification-levels",
|
|
984
|
+
auth: "api-key",
|
|
985
|
+
signal: options.signal
|
|
986
|
+
});
|
|
987
|
+
return result.data ?? [];
|
|
988
|
+
}
|
|
989
|
+
async get(id, options = {}) {
|
|
990
|
+
const result = await this.http.request({
|
|
991
|
+
path: `/verification-levels/${encodeURIComponent(id)}`,
|
|
992
|
+
auth: "api-key",
|
|
993
|
+
signal: options.signal
|
|
994
|
+
});
|
|
995
|
+
return result.data;
|
|
996
|
+
}
|
|
997
|
+
async update(id, params, options = {}) {
|
|
998
|
+
const result = await this.http.request({
|
|
999
|
+
method: "PUT",
|
|
1000
|
+
path: `/verification-levels/${encodeURIComponent(id)}`,
|
|
1001
|
+
body: params,
|
|
1002
|
+
auth: "api-key",
|
|
1003
|
+
signal: options.signal
|
|
1004
|
+
});
|
|
1005
|
+
return result.data;
|
|
1006
|
+
}
|
|
1007
|
+
async delete(id, options = {}) {
|
|
1008
|
+
await this.http.request({
|
|
1009
|
+
method: "DELETE",
|
|
1010
|
+
path: `/verification-levels/${encodeURIComponent(id)}`,
|
|
1011
|
+
auth: "api-key",
|
|
1012
|
+
signal: options.signal
|
|
1013
|
+
});
|
|
1014
|
+
}
|
|
1015
|
+
};
|
|
1016
|
+
|
|
1017
|
+
// src/resources/media.ts
|
|
1018
|
+
var MediaResourceApi = class {
|
|
1019
|
+
constructor(http) {
|
|
1020
|
+
this.http = http;
|
|
54
1021
|
}
|
|
55
1022
|
/**
|
|
56
|
-
*
|
|
57
|
-
*
|
|
1023
|
+
* Issues a single-use, 60-second token for one file of one verification.
|
|
1024
|
+
*
|
|
1025
|
+
* Useful on its own when the bytes should be fetched somewhere else — hand
|
|
1026
|
+
* the token to a browser and let it call the download URL directly, without
|
|
1027
|
+
* the API key ever leaving your server.
|
|
58
1028
|
*/
|
|
59
|
-
|
|
60
|
-
this.
|
|
1029
|
+
async createDownloadToken(verificationId, resource, options = {}) {
|
|
1030
|
+
const result = await this.http.request({
|
|
1031
|
+
method: "POST",
|
|
1032
|
+
path: `/media/token/${encodeURIComponent(verificationId)}`,
|
|
1033
|
+
body: { resource },
|
|
1034
|
+
auth: "api-key",
|
|
1035
|
+
signal: options.signal
|
|
1036
|
+
});
|
|
1037
|
+
return result.data;
|
|
61
1038
|
}
|
|
62
1039
|
/**
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
1040
|
+
* The URL that exchanges a download token for the file.
|
|
1041
|
+
*
|
|
1042
|
+
* Treat it as a credential: the token is the whole authorization, so the URL
|
|
1043
|
+
* grants the image to whoever holds it, once.
|
|
67
1044
|
*/
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
const response = await this.client.post("/verification/token", {
|
|
71
|
-
externalId,
|
|
72
|
-
expiry
|
|
73
|
-
});
|
|
74
|
-
return response.data;
|
|
75
|
-
} catch (error) {
|
|
76
|
-
const message = error.response?.data?.message || error.message || "Failed to create verification token";
|
|
77
|
-
throw new KYCSdkError(message, "TOKEN_CREATE_FAILED", error.response?.status);
|
|
78
|
-
}
|
|
1045
|
+
downloadUrl(token) {
|
|
1046
|
+
return this.http.buildUrl("/media/download", { token });
|
|
79
1047
|
}
|
|
80
1048
|
/**
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
1049
|
+
* Fetches a file, taking care of both steps.
|
|
1050
|
+
*
|
|
1051
|
+
* Returns the raw Response so a caller can stream it (to disk, or straight
|
|
1052
|
+
* into an HTTP response) instead of holding an identity document in memory.
|
|
84
1053
|
*/
|
|
85
|
-
async
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
1054
|
+
async download(verificationId, resource, options = {}) {
|
|
1055
|
+
const { token } = await this.createDownloadToken(verificationId, resource, options);
|
|
1056
|
+
return this.http.requestRaw({
|
|
1057
|
+
path: "/media/download",
|
|
1058
|
+
query: { token },
|
|
1059
|
+
// The single-use token *is* the credential here; sending the API key as
|
|
1060
|
+
// well would put it on a URL-addressed request for no benefit.
|
|
1061
|
+
auth: "none",
|
|
1062
|
+
signal: options.signal
|
|
1063
|
+
});
|
|
1064
|
+
}
|
|
1065
|
+
/** Fetches a file and buffers it. Convenient; holds the image in memory. */
|
|
1066
|
+
async downloadBytes(verificationId, resource, options = {}) {
|
|
1067
|
+
const response = await this.download(verificationId, resource, options);
|
|
1068
|
+
return new Uint8Array(await response.arrayBuffer());
|
|
1069
|
+
}
|
|
1070
|
+
};
|
|
1071
|
+
|
|
1072
|
+
// src/types.ts
|
|
1073
|
+
var DOCUMENT_CATALOG = [
|
|
1074
|
+
{ country: "AO", documentType: "ID_CARD" },
|
|
1075
|
+
{ country: "AO", documentType: "DRIVING_LICENSE" },
|
|
1076
|
+
{ country: "MZ", documentType: "ID_CARD" }
|
|
1077
|
+
];
|
|
1078
|
+
function isSupportedDocumentPair(country, documentType) {
|
|
1079
|
+
return DOCUMENT_CATALOG.some(
|
|
1080
|
+
(entry) => entry.country === country && entry.documentType === documentType
|
|
1081
|
+
);
|
|
1082
|
+
}
|
|
1083
|
+
function supportedCountries() {
|
|
1084
|
+
return Array.from(new Set(DOCUMENT_CATALOG.map((entry) => entry.country)));
|
|
1085
|
+
}
|
|
1086
|
+
function supportedDocumentTypes(country) {
|
|
1087
|
+
return DOCUMENT_CATALOG.filter((entry) => entry.country === country).map(
|
|
1088
|
+
(entry) => entry.documentType
|
|
1089
|
+
);
|
|
1090
|
+
}
|
|
1091
|
+
var VERIFICATION_STATUSES = [
|
|
1092
|
+
"PENDING",
|
|
1093
|
+
"PROCESSING",
|
|
1094
|
+
"REVIEW",
|
|
1095
|
+
"APPROVED",
|
|
1096
|
+
"REJECTED"
|
|
1097
|
+
];
|
|
1098
|
+
var VERIFICATION_OUTCOMES = [
|
|
1099
|
+
/** Passed. */
|
|
1100
|
+
"APPROVED",
|
|
1101
|
+
/** A genuine business decision. Retrying cannot change it. */
|
|
1102
|
+
"REJECTED",
|
|
1103
|
+
/** The pipeline broke. Not the user's fault; retry once fixed. */
|
|
1104
|
+
"FAILED_TECHNICAL",
|
|
1105
|
+
/** Could be either — a human should look at the images. */
|
|
1106
|
+
"NEEDS_REVIEW"
|
|
1107
|
+
];
|
|
1108
|
+
var TERMINAL_VERIFICATION_STATUSES = ["APPROVED", "REJECTED"];
|
|
1109
|
+
function isTerminalStatus(status) {
|
|
1110
|
+
return TERMINAL_VERIFICATION_STATUSES.includes(status);
|
|
1111
|
+
}
|
|
1112
|
+
var PIPELINE_EVENT_KINDS = [
|
|
1113
|
+
/** The verification was created — the start of every timeline. */
|
|
1114
|
+
"CREATED",
|
|
1115
|
+
/** The status moved; `fromStatus`/`toStatus` say where. */
|
|
1116
|
+
"STATUS_CHANGED",
|
|
1117
|
+
/**
|
|
1118
|
+
* The pipeline was re-run from the uploaded files, clearing what had been
|
|
1119
|
+
* extracted. Not a STATUS_CHANGED: a re-run of an already-PROCESSING
|
|
1120
|
+
* verification would otherwise read "PROCESSING -> PROCESSING", which says
|
|
1121
|
+
* nothing while hiding the destructive part.
|
|
1122
|
+
*/
|
|
1123
|
+
"REPROCESSED",
|
|
1124
|
+
/** A queue message for it exhausted every retry and was stored. */
|
|
1125
|
+
"JOB_PARKED",
|
|
1126
|
+
/** A parked message was handed back to its handler. */
|
|
1127
|
+
"JOB_RESENT",
|
|
1128
|
+
/** A parked message was abandoned. Reversible — a resend may follow. */
|
|
1129
|
+
"JOB_DISCARDED"
|
|
1130
|
+
];
|
|
1131
|
+
var DUPLICATE_FIELDS = [
|
|
1132
|
+
"fullName",
|
|
1133
|
+
"idNumber",
|
|
1134
|
+
"mrz",
|
|
1135
|
+
"gender",
|
|
1136
|
+
"country",
|
|
1137
|
+
"birthDate"
|
|
1138
|
+
];
|
|
1139
|
+
var WEBHOOK_EVENT_TYPES = [
|
|
1140
|
+
"VERIFICATION_STARTED",
|
|
1141
|
+
"VERIFICATION_COMPLETED",
|
|
1142
|
+
"VERIFICATION_APPROVED",
|
|
1143
|
+
"VERIFICATION_REJECTED",
|
|
1144
|
+
"VERIFICATION_REVIEW_REQUIRED",
|
|
1145
|
+
"OCR_COMPLETED",
|
|
1146
|
+
"OCR_FAILED"
|
|
1147
|
+
];
|
|
1148
|
+
var ALL_WEBHOOK_EVENTS = "*";
|
|
1149
|
+
|
|
1150
|
+
// src/resources/ocr.ts
|
|
1151
|
+
var OcrResource = class {
|
|
1152
|
+
constructor(http) {
|
|
1153
|
+
this.http = http;
|
|
1154
|
+
}
|
|
1155
|
+
/**
|
|
1156
|
+
* Queues a document for extraction.
|
|
1157
|
+
*
|
|
1158
|
+
* Returns as soon as the job is accepted; the fields arrive later, via
|
|
1159
|
+
* `getStatus` or `waitForResult`.
|
|
1160
|
+
*/
|
|
1161
|
+
async run(file, params, options = {}) {
|
|
1162
|
+
if (!isSupportedDocumentPair(params.country, params.documentType)) {
|
|
1163
|
+
const forCountry = supportedDocumentTypes(params.country);
|
|
1164
|
+
throw new KycirisError(
|
|
1165
|
+
forCountry.length ? `${params.country} supports ${forCountry.join(", ")}, not ${params.documentType}.` : `No documents are supported for country "${params.country}".`,
|
|
1166
|
+
{ code: "VALIDATION_ERROR" }
|
|
1167
|
+
);
|
|
96
1168
|
}
|
|
1169
|
+
const result = await this.http.request({
|
|
1170
|
+
method: "POST",
|
|
1171
|
+
path: "/ocr",
|
|
1172
|
+
// The gateway names this field `document`, not `file`.
|
|
1173
|
+
form: {
|
|
1174
|
+
document: file,
|
|
1175
|
+
documentType: params.documentType,
|
|
1176
|
+
country: params.country,
|
|
1177
|
+
externalId: params.externalId
|
|
1178
|
+
},
|
|
1179
|
+
fileFields: ["document"],
|
|
1180
|
+
signal: options.signal,
|
|
1181
|
+
timeoutMs: options.timeoutMs
|
|
1182
|
+
});
|
|
1183
|
+
return result.data;
|
|
1184
|
+
}
|
|
1185
|
+
/** Status of a queued job, with the extracted fields once COMPLETED. */
|
|
1186
|
+
async getStatus(ocrId, options = {}) {
|
|
1187
|
+
const result = await this.http.request({
|
|
1188
|
+
path: `/ocr/${encodeURIComponent(ocrId)}/status`,
|
|
1189
|
+
signal: options.signal
|
|
1190
|
+
});
|
|
1191
|
+
return result.data;
|
|
1192
|
+
}
|
|
1193
|
+
/** Polls until the job is COMPLETED or FAILED. */
|
|
1194
|
+
async waitForResult(ocrId, options = {}) {
|
|
1195
|
+
return pollUntil(
|
|
1196
|
+
(signal) => this.getStatus(ocrId, { signal }),
|
|
1197
|
+
(process) => process.status === "COMPLETED" || process.status === "FAILED",
|
|
1198
|
+
options,
|
|
1199
|
+
`OCR job ${ocrId}`
|
|
1200
|
+
);
|
|
97
1201
|
}
|
|
1202
|
+
};
|
|
1203
|
+
|
|
1204
|
+
// src/resources/verifications.ts
|
|
1205
|
+
var DOCUMENT_STEPS = ["document_front", "document_back"];
|
|
1206
|
+
var STEP_MEDIA = {
|
|
1207
|
+
document_front: "document-front",
|
|
1208
|
+
document_back: "document-back",
|
|
1209
|
+
selfie: "selfie"
|
|
1210
|
+
};
|
|
1211
|
+
var VerificationsResource = class {
|
|
1212
|
+
constructor(http, session) {
|
|
1213
|
+
this.http = http;
|
|
1214
|
+
this.session = session;
|
|
1215
|
+
}
|
|
1216
|
+
// -------------------------------------------------------------------------
|
|
1217
|
+
// Credentials
|
|
1218
|
+
// -------------------------------------------------------------------------
|
|
98
1219
|
/**
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
*
|
|
1220
|
+
* Mints a verification token for one end user.
|
|
1221
|
+
*
|
|
1222
|
+
* Call this on **your server**, with the project API key, and hand the token
|
|
1223
|
+
* to the client app. The token authorizes exactly the flow below (start,
|
|
1224
|
+
* upload, status) for the given `externalId` and nothing else, which is what
|
|
1225
|
+
* keeps the project-wide API key out of a bundle an end user can read.
|
|
1226
|
+
*
|
|
1227
|
+
* The gateway owns the lifetime (VERIFICATION_TOKEN_TTL, 8h by default) — a
|
|
1228
|
+
* client cannot ask for a longer one.
|
|
1229
|
+
*
|
|
1230
|
+
* @param externalId Your own reference for the end user
|
|
102
1231
|
*/
|
|
103
|
-
async
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
1232
|
+
async createToken(externalId, options = {}) {
|
|
1233
|
+
const result = await this.http.request({
|
|
1234
|
+
method: "POST",
|
|
1235
|
+
path: "/verification/token",
|
|
1236
|
+
body: { externalId },
|
|
1237
|
+
auth: "api-key",
|
|
1238
|
+
signal: options.signal
|
|
1239
|
+
});
|
|
1240
|
+
return result.data;
|
|
1241
|
+
}
|
|
1242
|
+
// -------------------------------------------------------------------------
|
|
1243
|
+
// The end-user flow
|
|
1244
|
+
// -------------------------------------------------------------------------
|
|
1245
|
+
/**
|
|
1246
|
+
* Starts a verification and, when a storage adapter is configured, records
|
|
1247
|
+
* it so an interrupted flow can be resumed.
|
|
1248
|
+
*
|
|
1249
|
+
* The (country, documentType) pair and the level name are checked here before
|
|
1250
|
+
* the request goes out: the gateway rejects an unsupported pair with a 422
|
|
1251
|
+
* that names neither what is supported nor why, and a missing level with a
|
|
1252
|
+
* validation error that reads like a typo in your code rather than a policy
|
|
1253
|
+
* you have not created yet.
|
|
1254
|
+
*/
|
|
1255
|
+
async start(params, options = {}) {
|
|
1256
|
+
if (typeof params.levelName !== "string" || !params.levelName.trim()) {
|
|
1257
|
+
throw new KycirisError(
|
|
1258
|
+
"levelName is required: a verification runs under a verification level, which says which documents are accepted and whether a selfie is needed. Create one from your backend with client.levels.create().",
|
|
1259
|
+
{ code: "VALIDATION_ERROR", details: { levelName: params.levelName ?? null } }
|
|
1260
|
+
);
|
|
1261
|
+
}
|
|
1262
|
+
if (!isSupportedDocumentPair(params.country, params.documentType)) {
|
|
1263
|
+
const forCountry = supportedDocumentTypes(params.country);
|
|
1264
|
+
throw new KycirisError(
|
|
1265
|
+
forCountry.length ? `${params.country} supports ${forCountry.join(", ")}, not ${params.documentType}.` : `No documents are supported for country "${params.country}".`,
|
|
1266
|
+
{ code: "VALIDATION_ERROR", details: { country: params.country, documentType: params.documentType } }
|
|
1267
|
+
);
|
|
1268
|
+
}
|
|
1269
|
+
const result = await this.http.request({
|
|
1270
|
+
method: "POST",
|
|
1271
|
+
path: "/verification/start",
|
|
1272
|
+
body: params,
|
|
1273
|
+
signal: options.signal
|
|
1274
|
+
});
|
|
1275
|
+
const started = result.data;
|
|
1276
|
+
if (started?.verificationId) {
|
|
1277
|
+
await this.session.save({
|
|
1278
|
+
verificationId: started.verificationId,
|
|
1279
|
+
identityId: started.identityId,
|
|
1280
|
+
externalId: params.externalId,
|
|
1281
|
+
documentType: params.documentType,
|
|
1282
|
+
country: params.country,
|
|
1283
|
+
levelName: params.levelName,
|
|
1284
|
+
startedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
134
1285
|
});
|
|
135
|
-
return response.data;
|
|
136
|
-
} catch (error) {
|
|
137
|
-
console.log("Upload selfie error:", error.response?.data || error.message);
|
|
138
|
-
const message = error.response?.data?.message || error.message || "Failed to upload selfie";
|
|
139
|
-
throw new KYCSdkError(message, "SELFIE_UPLOAD_FAILED", error.response?.status);
|
|
140
1286
|
}
|
|
1287
|
+
return started;
|
|
1288
|
+
}
|
|
1289
|
+
/** Uploads the end user's selfie. Triggers face matching once OCR has run. */
|
|
1290
|
+
async uploadSelfie(verificationId, file, options = {}) {
|
|
1291
|
+
const result = await this.http.request({
|
|
1292
|
+
method: "POST",
|
|
1293
|
+
path: "/verification/upload/selfie",
|
|
1294
|
+
form: { verificationId, file },
|
|
1295
|
+
fileFields: ["file"],
|
|
1296
|
+
signal: options.signal,
|
|
1297
|
+
timeoutMs: options.timeoutMs
|
|
1298
|
+
});
|
|
1299
|
+
return result.data;
|
|
141
1300
|
}
|
|
142
1301
|
/**
|
|
143
|
-
* Uploads
|
|
144
|
-
*
|
|
145
|
-
*
|
|
1302
|
+
* Uploads one side of the identity document.
|
|
1303
|
+
*
|
|
1304
|
+
* OCR is queued once both sides are present, so a flow that collects only
|
|
1305
|
+
* the front never starts processing.
|
|
146
1306
|
*/
|
|
147
|
-
async uploadDocument(
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
1307
|
+
async uploadDocument(verificationId, side, file, options = {}) {
|
|
1308
|
+
const result = await this.http.request({
|
|
1309
|
+
method: "POST",
|
|
1310
|
+
path: "/verification/upload/document",
|
|
1311
|
+
form: { verificationId, type: side, file },
|
|
1312
|
+
fileFields: ["file"],
|
|
1313
|
+
signal: options.signal,
|
|
1314
|
+
timeoutMs: options.timeoutMs
|
|
1315
|
+
});
|
|
1316
|
+
return result.data;
|
|
1317
|
+
}
|
|
1318
|
+
/** Current status, face-match score and extracted document data. */
|
|
1319
|
+
async getStatus(verificationId, options = {}) {
|
|
1320
|
+
const result = await this.http.request({
|
|
1321
|
+
path: `/verification/status/${encodeURIComponent(verificationId)}`,
|
|
1322
|
+
signal: options.signal
|
|
1323
|
+
});
|
|
1324
|
+
return result.data;
|
|
1325
|
+
}
|
|
1326
|
+
/**
|
|
1327
|
+
* Which files exist for a verification.
|
|
1328
|
+
*
|
|
1329
|
+
* This is the credential-safe way to tell what an end user has already
|
|
1330
|
+
* uploaded: it works with a verification token, whereas the identity record
|
|
1331
|
+
* that used to serve the same purpose is API-key only.
|
|
1332
|
+
*/
|
|
1333
|
+
async listMedia(verificationId, options = {}) {
|
|
1334
|
+
const result = await this.http.request({
|
|
1335
|
+
path: `/verification/${encodeURIComponent(verificationId)}/media`,
|
|
1336
|
+
signal: options.signal
|
|
1337
|
+
});
|
|
1338
|
+
return result.data?.available ?? [];
|
|
1339
|
+
}
|
|
1340
|
+
/**
|
|
1341
|
+
* Re-runs OCR and face matching from the files already uploaded.
|
|
1342
|
+
*
|
|
1343
|
+
* The remedy for a verification that failed technically (outcome
|
|
1344
|
+
* FAILED_TECHNICAL) or stalled. Clears the previous extraction and decision,
|
|
1345
|
+
* so it is not a read-only operation.
|
|
1346
|
+
*
|
|
1347
|
+
* Only the document front is required, which is why this also works for a
|
|
1348
|
+
* verification that never got as far as the selfie.
|
|
1349
|
+
*/
|
|
1350
|
+
async reanalyze(verificationId, options = {}) {
|
|
1351
|
+
const result = await this.http.request({
|
|
1352
|
+
method: "POST",
|
|
1353
|
+
path: `/verification/reanalyze/${encodeURIComponent(verificationId)}`,
|
|
1354
|
+
signal: options.signal
|
|
1355
|
+
});
|
|
1356
|
+
return result.data;
|
|
1357
|
+
}
|
|
1358
|
+
// -------------------------------------------------------------------------
|
|
1359
|
+
// Resumable flow helpers
|
|
1360
|
+
// -------------------------------------------------------------------------
|
|
1361
|
+
/** The stored session, or null when there is none. */
|
|
1362
|
+
async getSession() {
|
|
1363
|
+
return this.session.read();
|
|
1364
|
+
}
|
|
1365
|
+
/**
|
|
1366
|
+
* Forgets the stored session. Call it once a verification is finished, so
|
|
1367
|
+
* the next one starts clean.
|
|
1368
|
+
*/
|
|
1369
|
+
async clearSession() {
|
|
1370
|
+
return this.session.clear();
|
|
1371
|
+
}
|
|
1372
|
+
/**
|
|
1373
|
+
* What the end user has uploaded and what is still missing.
|
|
1374
|
+
*
|
|
1375
|
+
* Derived from the files that actually exist server-side rather than from
|
|
1376
|
+
* anything the client remembers, so it is correct after a reinstall, on a
|
|
1377
|
+
* second device, or when a previous attempt failed mid-upload.
|
|
1378
|
+
*/
|
|
1379
|
+
async getProgress(verificationId, options = {}) {
|
|
1380
|
+
const id = await this.resolveVerificationId(verificationId);
|
|
1381
|
+
const [status, available] = await Promise.all([
|
|
1382
|
+
this.getStatus(id, { signal: options.signal }),
|
|
1383
|
+
this.listMedia(id, { signal: options.signal })
|
|
1384
|
+
]);
|
|
1385
|
+
const present = new Set(available);
|
|
1386
|
+
const required = [
|
|
1387
|
+
...DOCUMENT_STEPS,
|
|
1388
|
+
...options.selfieRequired === false ? [] : ["selfie"]
|
|
1389
|
+
];
|
|
1390
|
+
const uploaded = {
|
|
1391
|
+
document_front: present.has("document-front"),
|
|
1392
|
+
document_back: present.has("document-back"),
|
|
1393
|
+
selfie: present.has("selfie")
|
|
1394
|
+
};
|
|
1395
|
+
const missingSteps = required.filter((step) => !present.has(STEP_MEDIA[step]));
|
|
1396
|
+
return {
|
|
1397
|
+
verificationId: id,
|
|
1398
|
+
status: status.status,
|
|
1399
|
+
outcome: status.outcome,
|
|
1400
|
+
uploaded,
|
|
1401
|
+
missingSteps,
|
|
1402
|
+
isComplete: missingSteps.length === 0
|
|
1403
|
+
};
|
|
1404
|
+
}
|
|
1405
|
+
/**
|
|
1406
|
+
* Uploads one step, skipping it when the file is already there.
|
|
1407
|
+
*
|
|
1408
|
+
* The call a resumable UI should make: it never re-sends a step the end user
|
|
1409
|
+
* already completed, which matters most on a retry after a dropped
|
|
1410
|
+
* connection, where the upload may well have succeeded.
|
|
1411
|
+
*/
|
|
1412
|
+
async uploadStep(params) {
|
|
1413
|
+
const verificationId = await this.resolveVerificationId(params.verificationId);
|
|
1414
|
+
const prepared = await prepareFile(params.file, `${params.step}.jpg`);
|
|
1415
|
+
const progress = await this.getProgress(verificationId, {
|
|
1416
|
+
selfieRequired: params.selfieRequired,
|
|
1417
|
+
signal: params.signal
|
|
1418
|
+
});
|
|
1419
|
+
if (progress.uploaded[params.step]) {
|
|
1420
|
+
return {
|
|
1421
|
+
message: `${params.step} was already uploaded`,
|
|
1422
|
+
status: progress.status,
|
|
1423
|
+
skipped: true
|
|
1424
|
+
};
|
|
185
1425
|
}
|
|
1426
|
+
const uploadOptions = {
|
|
1427
|
+
signal: params.signal,
|
|
1428
|
+
timeoutMs: params.timeoutMs
|
|
1429
|
+
};
|
|
1430
|
+
const result = params.step === "selfie" ? await this.uploadSelfie(verificationId, prepared, uploadOptions) : await this.uploadDocument(
|
|
1431
|
+
verificationId,
|
|
1432
|
+
params.step === "document_front" ? "front" : "back",
|
|
1433
|
+
prepared,
|
|
1434
|
+
uploadOptions
|
|
1435
|
+
);
|
|
1436
|
+
return { ...result, skipped: false };
|
|
1437
|
+
}
|
|
1438
|
+
/**
|
|
1439
|
+
* Polls the status until the verification is decided.
|
|
1440
|
+
*
|
|
1441
|
+
* Stops at APPROVED, REJECTED and — unless `stopOnReview` is false — REVIEW.
|
|
1442
|
+
* For anything longer than a user is willing to watch a spinner, use the
|
|
1443
|
+
* webhooks: polling burns a request every few seconds and still gives up
|
|
1444
|
+
* after `timeoutMs`.
|
|
1445
|
+
*/
|
|
1446
|
+
async waitForResult(verificationId, options = {}) {
|
|
1447
|
+
const id = await this.resolveVerificationId(verificationId);
|
|
1448
|
+
const stopOnReview = options.stopOnReview ?? true;
|
|
1449
|
+
return pollUntil(
|
|
1450
|
+
(signal) => this.getStatus(id, { signal }),
|
|
1451
|
+
(status) => isTerminalStatus(status.status) || stopOnReview && status.status === "REVIEW",
|
|
1452
|
+
options,
|
|
1453
|
+
`verification ${id}`
|
|
1454
|
+
);
|
|
1455
|
+
}
|
|
1456
|
+
async resolveVerificationId(explicit) {
|
|
1457
|
+
if (explicit) return explicit;
|
|
1458
|
+
const stored = await this.session.read();
|
|
1459
|
+
if (stored?.verificationId) return stored.verificationId;
|
|
1460
|
+
throw new KycirisError(
|
|
1461
|
+
this.session.enabled ? "No verificationId was given and there is no stored session to resume. Start a verification first." : "No verificationId was given. Pass one, or configure `storage` so the SDK can remember the verification it started.",
|
|
1462
|
+
{ code: "SESSION_NOT_FOUND" }
|
|
1463
|
+
);
|
|
1464
|
+
}
|
|
1465
|
+
// -------------------------------------------------------------------------
|
|
1466
|
+
// Review and operations (project API key)
|
|
1467
|
+
// -------------------------------------------------------------------------
|
|
1468
|
+
/**
|
|
1469
|
+
* A page of the project's verifications, newest first.
|
|
1470
|
+
*
|
|
1471
|
+
* Carries no OCR data or storage paths by design — a 20-row page is not the
|
|
1472
|
+
* place for identity-document PII. Read one verification for those.
|
|
1473
|
+
*/
|
|
1474
|
+
async list(params = {}, options = {}) {
|
|
1475
|
+
const result = await this.http.request({
|
|
1476
|
+
path: "/verification",
|
|
1477
|
+
query: { ...params },
|
|
1478
|
+
auth: "api-key",
|
|
1479
|
+
signal: options.signal
|
|
1480
|
+
});
|
|
1481
|
+
return toPage(result);
|
|
1482
|
+
}
|
|
1483
|
+
/** Counts per status and outcome, plus what needs an operator's attention. */
|
|
1484
|
+
async summary(options = {}) {
|
|
1485
|
+
const result = await this.http.request({
|
|
1486
|
+
path: "/verification/summary",
|
|
1487
|
+
auth: "api-key",
|
|
1488
|
+
signal: options.signal
|
|
1489
|
+
});
|
|
1490
|
+
return result.data;
|
|
1491
|
+
}
|
|
1492
|
+
/**
|
|
1493
|
+
* A verification's recorded history, oldest first.
|
|
1494
|
+
*
|
|
1495
|
+
* The verification row keeps only the last decision, so this is the only
|
|
1496
|
+
* place the sequence survives.
|
|
1497
|
+
*/
|
|
1498
|
+
async events(verificationId, options = {}) {
|
|
1499
|
+
const result = await this.http.request({
|
|
1500
|
+
path: `/verification/events/${encodeURIComponent(verificationId)}`,
|
|
1501
|
+
auth: "api-key",
|
|
1502
|
+
signal: options.signal
|
|
1503
|
+
});
|
|
1504
|
+
return result.data ?? [];
|
|
1505
|
+
}
|
|
1506
|
+
/** Moves a PENDING verification to REVIEW so a person can look at it. */
|
|
1507
|
+
async sendToReview(verificationId, options = {}) {
|
|
1508
|
+
const result = await this.http.request({
|
|
1509
|
+
method: "PATCH",
|
|
1510
|
+
path: `/verification/review/${encodeURIComponent(verificationId)}`,
|
|
1511
|
+
auth: "api-key",
|
|
1512
|
+
signal: options.signal
|
|
1513
|
+
});
|
|
1514
|
+
return result.data;
|
|
1515
|
+
}
|
|
1516
|
+
/**
|
|
1517
|
+
* Records a reviewer's decision and fires the matching webhook.
|
|
1518
|
+
*
|
|
1519
|
+
* Repeating the decision a verification already has is refused with 409 —
|
|
1520
|
+
* that is the gateway making a second webhook impossible, not a transient
|
|
1521
|
+
* failure. Changing a decision (rejected in error, then approved) is allowed.
|
|
1522
|
+
*/
|
|
1523
|
+
async decide(verificationId, decision, options = {}) {
|
|
1524
|
+
const result = await this.http.request({
|
|
1525
|
+
method: "PATCH",
|
|
1526
|
+
path: `/verification/decision/${encodeURIComponent(verificationId)}`,
|
|
1527
|
+
body: decision,
|
|
1528
|
+
auth: "api-key",
|
|
1529
|
+
signal: options.signal
|
|
1530
|
+
});
|
|
1531
|
+
return result.data;
|
|
1532
|
+
}
|
|
1533
|
+
};
|
|
1534
|
+
|
|
1535
|
+
// src/resources/webhooks.ts
|
|
1536
|
+
var WebhooksResource = class {
|
|
1537
|
+
constructor(http) {
|
|
1538
|
+
this.http = http;
|
|
1539
|
+
}
|
|
1540
|
+
async create(params, options = {}) {
|
|
1541
|
+
const result = await this.http.request({
|
|
1542
|
+
method: "POST",
|
|
1543
|
+
path: "/webhooks",
|
|
1544
|
+
body: params,
|
|
1545
|
+
auth: "api-key",
|
|
1546
|
+
signal: options.signal
|
|
1547
|
+
});
|
|
1548
|
+
return result.data;
|
|
1549
|
+
}
|
|
1550
|
+
async list(options = {}) {
|
|
1551
|
+
const result = await this.http.request({
|
|
1552
|
+
path: "/webhooks",
|
|
1553
|
+
auth: "api-key",
|
|
1554
|
+
signal: options.signal
|
|
1555
|
+
});
|
|
1556
|
+
return result.data ?? [];
|
|
1557
|
+
}
|
|
1558
|
+
async get(id, options = {}) {
|
|
1559
|
+
const result = await this.http.request({
|
|
1560
|
+
path: `/webhooks/${encodeURIComponent(id)}`,
|
|
1561
|
+
auth: "api-key",
|
|
1562
|
+
signal: options.signal
|
|
1563
|
+
});
|
|
1564
|
+
return result.data;
|
|
1565
|
+
}
|
|
1566
|
+
async update(id, params, options = {}) {
|
|
1567
|
+
const result = await this.http.request({
|
|
1568
|
+
method: "PUT",
|
|
1569
|
+
path: `/webhooks/${encodeURIComponent(id)}`,
|
|
1570
|
+
body: params,
|
|
1571
|
+
auth: "api-key",
|
|
1572
|
+
signal: options.signal
|
|
1573
|
+
});
|
|
1574
|
+
return result.data;
|
|
1575
|
+
}
|
|
1576
|
+
async delete(id, options = {}) {
|
|
1577
|
+
await this.http.request({
|
|
1578
|
+
method: "DELETE",
|
|
1579
|
+
path: `/webhooks/${encodeURIComponent(id)}`,
|
|
1580
|
+
auth: "api-key",
|
|
1581
|
+
signal: options.signal
|
|
1582
|
+
});
|
|
186
1583
|
}
|
|
187
1584
|
/**
|
|
188
|
-
*
|
|
189
|
-
*
|
|
190
|
-
* @returns Current verification status including OCR data and face match score
|
|
1585
|
+
* Delivery attempts, newest first — what was sent, what came back, how many
|
|
1586
|
+
* retries it took. The place to look when an endpoint stops receiving events.
|
|
191
1587
|
*/
|
|
192
|
-
async
|
|
1588
|
+
async logs(params = {}, options = {}) {
|
|
1589
|
+
const result = await this.http.request({
|
|
1590
|
+
path: "/webhook-log",
|
|
1591
|
+
query: { ...params },
|
|
1592
|
+
auth: "api-key",
|
|
1593
|
+
signal: options.signal
|
|
1594
|
+
});
|
|
1595
|
+
return toPage(result);
|
|
1596
|
+
}
|
|
1597
|
+
/** One delivery attempt. */
|
|
1598
|
+
async log(id, options = {}) {
|
|
1599
|
+
const result = await this.http.request({
|
|
1600
|
+
path: `/webhook-log/${encodeURIComponent(id)}`,
|
|
1601
|
+
auth: "api-key",
|
|
1602
|
+
signal: options.signal
|
|
1603
|
+
});
|
|
1604
|
+
return result.data;
|
|
1605
|
+
}
|
|
1606
|
+
/**
|
|
1607
|
+
* Sends a recorded delivery again, to the endpoint it originally went to.
|
|
1608
|
+
*
|
|
1609
|
+
* Synchronous: the result says whether it arrived this time. A failure comes
|
|
1610
|
+
* back as `{ success: false, error }` rather than throwing, because the
|
|
1611
|
+
* gateway has already written a FAILED row either way and the caller usually
|
|
1612
|
+
* wants to show the reason beside the delivery it belongs to. A missing
|
|
1613
|
+
* delivery (404) or a deleted endpoint (409) still throw -- those are not
|
|
1614
|
+
* outcomes of the send.
|
|
1615
|
+
*
|
|
1616
|
+
* Not retried: a resend that timed out may well have arrived.
|
|
1617
|
+
*/
|
|
1618
|
+
async resendLog(id, options = {}) {
|
|
1619
|
+
const result = await this.http.request({
|
|
1620
|
+
method: "POST",
|
|
1621
|
+
path: `/webhook-log/${encodeURIComponent(id)}/resend`,
|
|
1622
|
+
auth: "api-key",
|
|
1623
|
+
retry: false,
|
|
1624
|
+
signal: options.signal
|
|
1625
|
+
});
|
|
1626
|
+
return result.data;
|
|
1627
|
+
}
|
|
1628
|
+
};
|
|
1629
|
+
|
|
1630
|
+
// src/storage.ts
|
|
1631
|
+
var SESSION_STORAGE_KEY = "kyciris:session:v1";
|
|
1632
|
+
function isStoredSession(value) {
|
|
1633
|
+
return typeof value === "object" && value !== null && typeof value.verificationId === "string";
|
|
1634
|
+
}
|
|
1635
|
+
var SessionStore = class {
|
|
1636
|
+
constructor(storage) {
|
|
1637
|
+
this.storage = storage;
|
|
1638
|
+
}
|
|
1639
|
+
/** True when a storage adapter was configured. */
|
|
1640
|
+
get enabled() {
|
|
1641
|
+
return Boolean(this.storage);
|
|
1642
|
+
}
|
|
1643
|
+
async save(session) {
|
|
1644
|
+
if (!this.storage) return;
|
|
193
1645
|
try {
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
type: "statusChanged",
|
|
197
|
-
status: response.data.status
|
|
198
|
-
});
|
|
199
|
-
return response.data;
|
|
200
|
-
} catch (error) {
|
|
201
|
-
const message = error.response?.data?.message || error.message || "Failed to get verification status";
|
|
202
|
-
throw new KYCSdkError(message, "STATUS_CHECK_FAILED", error.response?.status);
|
|
1646
|
+
await this.storage.setItem(SESSION_STORAGE_KEY, JSON.stringify(session));
|
|
1647
|
+
} catch {
|
|
203
1648
|
}
|
|
204
1649
|
}
|
|
205
|
-
async
|
|
1650
|
+
async read() {
|
|
1651
|
+
if (!this.storage) return null;
|
|
206
1652
|
try {
|
|
207
|
-
const
|
|
208
|
-
|
|
209
|
-
const
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
1653
|
+
const raw = await this.storage.getItem(SESSION_STORAGE_KEY);
|
|
1654
|
+
if (!raw) return null;
|
|
1655
|
+
const parsed = JSON.parse(raw);
|
|
1656
|
+
return isStoredSession(parsed) ? parsed : null;
|
|
1657
|
+
} catch {
|
|
1658
|
+
return null;
|
|
1659
|
+
}
|
|
1660
|
+
}
|
|
1661
|
+
async clear() {
|
|
1662
|
+
if (!this.storage) return;
|
|
1663
|
+
try {
|
|
1664
|
+
await this.storage.removeItem(SESSION_STORAGE_KEY);
|
|
1665
|
+
} catch {
|
|
1666
|
+
}
|
|
1667
|
+
}
|
|
1668
|
+
};
|
|
1669
|
+
function createMemoryStorage() {
|
|
1670
|
+
const map = /* @__PURE__ */ new Map();
|
|
1671
|
+
return {
|
|
1672
|
+
getItem: (key) => map.get(key) ?? null,
|
|
1673
|
+
setItem: (key, value) => {
|
|
1674
|
+
map.set(key, value);
|
|
1675
|
+
},
|
|
1676
|
+
removeItem: (key) => {
|
|
1677
|
+
map.delete(key);
|
|
1678
|
+
}
|
|
1679
|
+
};
|
|
1680
|
+
}
|
|
1681
|
+
|
|
1682
|
+
// src/client.ts
|
|
1683
|
+
var KycirisClient = class {
|
|
1684
|
+
constructor(config) {
|
|
1685
|
+
this.http = new HttpClient(config);
|
|
1686
|
+
const session = new SessionStore(config.storage);
|
|
1687
|
+
this.verifications = new VerificationsResource(this.http, session);
|
|
1688
|
+
this.identities = new IdentitiesResource(this.http);
|
|
1689
|
+
this.levels = new LevelsResource(this.http);
|
|
1690
|
+
this.media = new MediaResourceApi(this.http);
|
|
1691
|
+
this.ocr = new OcrResource(this.http);
|
|
1692
|
+
this.webhooks = new WebhooksResource(this.http);
|
|
1693
|
+
this.analytics = new AnalyticsResource(this.http);
|
|
1694
|
+
}
|
|
1695
|
+
/**
|
|
1696
|
+
* Whether this client holds the project API key, and so can reach the
|
|
1697
|
+
* server-side endpoints.
|
|
1698
|
+
*
|
|
1699
|
+
* Worth checking in code that runs in both places, rather than letting a
|
|
1700
|
+
* MISSING_CREDENTIAL error be the way you find out.
|
|
1701
|
+
*/
|
|
1702
|
+
get isServerSide() {
|
|
1703
|
+
return this.http.hasApiKey();
|
|
1704
|
+
}
|
|
1705
|
+
};
|
|
1706
|
+
function createKycirisClient(config) {
|
|
1707
|
+
return new KycirisClient(config);
|
|
1708
|
+
}
|
|
1709
|
+
|
|
1710
|
+
// src/flow.ts
|
|
1711
|
+
var EMPTY_UPLOADED = {
|
|
1712
|
+
document_front: false,
|
|
1713
|
+
document_back: false,
|
|
1714
|
+
selfie: false
|
|
1715
|
+
};
|
|
1716
|
+
var VerificationFlow = class {
|
|
1717
|
+
constructor(client, options) {
|
|
1718
|
+
this.client = client;
|
|
1719
|
+
this.options = options;
|
|
1720
|
+
this.state = {
|
|
1721
|
+
phase: "idle",
|
|
1722
|
+
verificationId: null,
|
|
1723
|
+
currentStep: null,
|
|
1724
|
+
missingSteps: [],
|
|
1725
|
+
uploaded: { ...EMPTY_UPLOADED },
|
|
1726
|
+
status: null,
|
|
1727
|
+
outcome: null,
|
|
1728
|
+
rejectionReason: null,
|
|
1729
|
+
error: null,
|
|
1730
|
+
busy: false
|
|
1731
|
+
};
|
|
1732
|
+
this.listeners = /* @__PURE__ */ new Set();
|
|
1733
|
+
this.aborter = null;
|
|
1734
|
+
if (options.onStateChange) this.listeners.add(options.onStateChange);
|
|
1735
|
+
}
|
|
1736
|
+
getState() {
|
|
1737
|
+
return this.state;
|
|
1738
|
+
}
|
|
1739
|
+
/** Observes state changes. Returns an unsubscribe function. */
|
|
1740
|
+
subscribe(listener) {
|
|
1741
|
+
this.listeners.add(listener);
|
|
1742
|
+
return () => {
|
|
1743
|
+
this.listeners.delete(listener);
|
|
1744
|
+
};
|
|
1745
|
+
}
|
|
1746
|
+
/**
|
|
1747
|
+
* Starts a verification, or picks up the one already in storage.
|
|
1748
|
+
*
|
|
1749
|
+
* Resuming is the default because it is almost always what the user wants:
|
|
1750
|
+
* an app killed between the document and the selfie should carry on, not
|
|
1751
|
+
* ask for the document again. Pass `{ fresh: true }` to start over.
|
|
1752
|
+
*/
|
|
1753
|
+
async start(options = {}) {
|
|
1754
|
+
return this.guard("starting", async (signal) => {
|
|
1755
|
+
if (options.fresh) {
|
|
1756
|
+
await this.client.verifications.clearSession();
|
|
222
1757
|
} else {
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
});
|
|
1758
|
+
const existing = await this.client.verifications.getSession();
|
|
1759
|
+
if (existing?.verificationId) {
|
|
1760
|
+
return this.loadProgress(existing.verificationId, signal);
|
|
1761
|
+
}
|
|
228
1762
|
}
|
|
229
|
-
const
|
|
230
|
-
`identities/${params.externalId}/face-match`,
|
|
231
|
-
formData,
|
|
1763
|
+
const session = await this.client.verifications.start(
|
|
232
1764
|
{
|
|
233
|
-
|
|
234
|
-
|
|
1765
|
+
country: this.options.country,
|
|
1766
|
+
documentType: this.options.documentType,
|
|
1767
|
+
levelName: this.options.levelName,
|
|
1768
|
+
externalId: this.options.externalId
|
|
1769
|
+
},
|
|
1770
|
+
{ signal }
|
|
235
1771
|
);
|
|
236
|
-
return
|
|
237
|
-
} catch (error) {
|
|
238
|
-
console.log("Upload face match error:", error.response?.data || error.message);
|
|
239
|
-
const message = error.response?.data?.message || error.message || "Failed to upload face match";
|
|
240
|
-
throw new KYCSdkError(message, "FACEMATCH_UPLOAD_FAILED", error.response?.status);
|
|
241
|
-
}
|
|
242
|
-
}
|
|
243
|
-
async faceMatchStatus({
|
|
244
|
-
externalId,
|
|
245
|
-
faceCheckId,
|
|
246
|
-
interval = 3e3,
|
|
247
|
-
timeout = 12e4
|
|
248
|
-
}) {
|
|
249
|
-
const startTime = Date.now();
|
|
250
|
-
return new Promise((resolve, reject) => {
|
|
251
|
-
const poll = async () => {
|
|
252
|
-
try {
|
|
253
|
-
const status = (await this.client.get(`identities/${externalId}/face-match/status/${faceCheckId}`)).data;
|
|
254
|
-
if (status.status === "APPROVED" || status.status === "REJECTED") {
|
|
255
|
-
resolve(status);
|
|
256
|
-
return;
|
|
257
|
-
}
|
|
258
|
-
if (Date.now() - startTime > timeout) {
|
|
259
|
-
reject(new KYCSdkError("Polling timeout", "POLLING_TIMEOUT"));
|
|
260
|
-
return;
|
|
261
|
-
}
|
|
262
|
-
setTimeout(poll, interval);
|
|
263
|
-
} catch (error) {
|
|
264
|
-
reject(error);
|
|
265
|
-
}
|
|
266
|
-
};
|
|
267
|
-
poll();
|
|
1772
|
+
return this.loadProgress(session.verificationId, signal);
|
|
268
1773
|
});
|
|
269
1774
|
}
|
|
270
1775
|
/**
|
|
271
|
-
*
|
|
272
|
-
*
|
|
273
|
-
*
|
|
274
|
-
*
|
|
275
|
-
* @returns Final verification status when approved or rejected
|
|
1776
|
+
* Re-reads what the gateway holds.
|
|
1777
|
+
*
|
|
1778
|
+
* Worth calling when a component remounts, or after an upload whose response
|
|
1779
|
+
* never arrived: the file may well have landed, and this is what notices.
|
|
276
1780
|
*/
|
|
277
|
-
async
|
|
278
|
-
const
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
1781
|
+
async refresh() {
|
|
1782
|
+
const verificationId = this.state.verificationId;
|
|
1783
|
+
if (!verificationId) return this.start();
|
|
1784
|
+
return this.guard(
|
|
1785
|
+
this.state.phase,
|
|
1786
|
+
(signal) => this.loadProgress(verificationId, signal)
|
|
1787
|
+
);
|
|
1788
|
+
}
|
|
1789
|
+
/**
|
|
1790
|
+
* Uploads an image for the current step and advances.
|
|
1791
|
+
*
|
|
1792
|
+
* A step that turns out to be already uploaded is skipped rather than sent
|
|
1793
|
+
* again, so retrying after a dropped connection cannot duplicate work.
|
|
1794
|
+
*/
|
|
1795
|
+
async submit(file) {
|
|
1796
|
+
const { verificationId, currentStep } = this.state;
|
|
1797
|
+
if (!verificationId) {
|
|
1798
|
+
return this.fail(
|
|
1799
|
+
new KycirisError("The flow has not been started yet.", {
|
|
1800
|
+
code: "SESSION_NOT_FOUND"
|
|
1801
|
+
})
|
|
1802
|
+
);
|
|
1803
|
+
}
|
|
1804
|
+
if (!currentStep) {
|
|
1805
|
+
return this.fail(
|
|
1806
|
+
new KycirisError("There is no step waiting for an image.", {
|
|
1807
|
+
code: "VALIDATION_ERROR"
|
|
1808
|
+
})
|
|
1809
|
+
);
|
|
1810
|
+
}
|
|
1811
|
+
return this.guard("uploading", async (signal) => {
|
|
1812
|
+
await this.client.verifications.uploadStep({
|
|
1813
|
+
step: currentStep,
|
|
1814
|
+
file,
|
|
1815
|
+
verificationId,
|
|
1816
|
+
selfieRequired: this.options.selfieRequired,
|
|
1817
|
+
signal
|
|
1818
|
+
});
|
|
1819
|
+
return this.loadProgress(verificationId, signal);
|
|
1820
|
+
});
|
|
1821
|
+
}
|
|
1822
|
+
/**
|
|
1823
|
+
* Waits for the pipeline's decision.
|
|
1824
|
+
*
|
|
1825
|
+
* Called automatically once the last step is uploaded, unless
|
|
1826
|
+
* `waitForDecision` is false. Stops at REVIEW, which is a person's queue and
|
|
1827
|
+
* not something worth holding a spinner for.
|
|
1828
|
+
*/
|
|
1829
|
+
async waitForDecision() {
|
|
1830
|
+
const verificationId = this.state.verificationId;
|
|
1831
|
+
if (!verificationId) {
|
|
1832
|
+
return this.fail(
|
|
1833
|
+
new KycirisError("The flow has not been started yet.", {
|
|
1834
|
+
code: "SESSION_NOT_FOUND"
|
|
1835
|
+
})
|
|
1836
|
+
);
|
|
1837
|
+
}
|
|
1838
|
+
return this.guard("processing", async (signal) => {
|
|
1839
|
+
const status = await this.client.verifications.waitForResult(verificationId, {
|
|
1840
|
+
signal,
|
|
1841
|
+
timeoutMs: this.options.decisionTimeoutMs
|
|
1842
|
+
});
|
|
1843
|
+
return {
|
|
1844
|
+
phase: "finished",
|
|
1845
|
+
status: status.status,
|
|
1846
|
+
outcome: status.outcome,
|
|
1847
|
+
rejectionReason: status.rejectionReason
|
|
295
1848
|
};
|
|
296
|
-
poll();
|
|
297
1849
|
});
|
|
298
1850
|
}
|
|
299
1851
|
/**
|
|
300
|
-
*
|
|
301
|
-
*
|
|
302
|
-
*
|
|
1852
|
+
* Cancels whatever is in flight.
|
|
1853
|
+
*
|
|
1854
|
+
* A component's unmount should call this, so a poll does not keep running
|
|
1855
|
+
* against a screen that is gone.
|
|
303
1856
|
*/
|
|
304
|
-
|
|
305
|
-
|
|
1857
|
+
cancel() {
|
|
1858
|
+
this.aborter?.abort();
|
|
1859
|
+
this.aborter = null;
|
|
1860
|
+
if (this.state.busy) this.patch({ busy: false });
|
|
1861
|
+
}
|
|
1862
|
+
/** Cancels, forgets the stored session, and returns to `idle`. */
|
|
1863
|
+
async reset() {
|
|
1864
|
+
this.cancel();
|
|
1865
|
+
await this.client.verifications.clearSession();
|
|
1866
|
+
this.patch({
|
|
1867
|
+
phase: "idle",
|
|
1868
|
+
verificationId: null,
|
|
1869
|
+
currentStep: null,
|
|
1870
|
+
missingSteps: [],
|
|
1871
|
+
uploaded: { ...EMPTY_UPLOADED },
|
|
1872
|
+
status: null,
|
|
1873
|
+
outcome: null,
|
|
1874
|
+
rejectionReason: null,
|
|
1875
|
+
error: null,
|
|
1876
|
+
busy: false
|
|
1877
|
+
});
|
|
1878
|
+
return this.state;
|
|
1879
|
+
}
|
|
1880
|
+
// -------------------------------------------------------------------------
|
|
1881
|
+
/**
|
|
1882
|
+
* Reads progress and derives the next phase from it.
|
|
1883
|
+
*
|
|
1884
|
+
* Returns a patch rather than applying one, so `guard` stays the single
|
|
1885
|
+
* place that publishes a state change.
|
|
1886
|
+
*/
|
|
1887
|
+
async loadProgress(verificationId, signal) {
|
|
1888
|
+
const progress = await this.client.verifications.getProgress(verificationId, {
|
|
1889
|
+
selfieRequired: this.options.selfieRequired,
|
|
1890
|
+
signal
|
|
1891
|
+
});
|
|
1892
|
+
return {
|
|
1893
|
+
verificationId,
|
|
1894
|
+
currentStep: progress.missingSteps[0] ?? null,
|
|
1895
|
+
missingSteps: progress.missingSteps,
|
|
1896
|
+
uploaded: progress.uploaded,
|
|
1897
|
+
status: progress.status,
|
|
1898
|
+
outcome: progress.outcome,
|
|
1899
|
+
phase: progress.isComplete ? "processing" : "collecting"
|
|
1900
|
+
};
|
|
1901
|
+
}
|
|
1902
|
+
/**
|
|
1903
|
+
* Runs one transition: marks the flow busy, applies the resulting patch, and
|
|
1904
|
+
* turns any failure into `state.error` instead of a rejected promise.
|
|
1905
|
+
*
|
|
1906
|
+
* The awkward part it exists to contain is that reaching `processing` should
|
|
1907
|
+
* flow straight on into polling — but only when the caller asked for it, and
|
|
1908
|
+
* only once.
|
|
1909
|
+
*/
|
|
1910
|
+
async guard(phase, run) {
|
|
1911
|
+
this.aborter?.abort();
|
|
1912
|
+
const aborter = new AbortController();
|
|
1913
|
+
this.aborter = aborter;
|
|
1914
|
+
this.patch({ phase, busy: true, error: null });
|
|
1915
|
+
try {
|
|
1916
|
+
const patch = await run(aborter.signal);
|
|
1917
|
+
this.patch({ ...patch, busy: false });
|
|
1918
|
+
const shouldWait = this.state.phase === "processing" && (this.options.waitForDecision ?? true) && // Not a nested call: waitForDecision itself lands here with phase
|
|
1919
|
+
// 'processing' already set, and would otherwise recurse forever.
|
|
1920
|
+
phase !== "processing";
|
|
1921
|
+
if (shouldWait) return this.waitForDecision();
|
|
1922
|
+
return this.state;
|
|
1923
|
+
} catch (error) {
|
|
1924
|
+
if (isKycirisError(error) && error.code === "ABORTED") {
|
|
1925
|
+
this.patch({ busy: false });
|
|
1926
|
+
return this.state;
|
|
1927
|
+
}
|
|
1928
|
+
return this.fail(error);
|
|
1929
|
+
} finally {
|
|
1930
|
+
if (this.aborter === aborter) this.aborter = null;
|
|
1931
|
+
}
|
|
1932
|
+
}
|
|
1933
|
+
fail(error) {
|
|
1934
|
+
const failure = isKycirisError(error) ? error : new KycirisError(
|
|
1935
|
+
error instanceof Error ? error.message : "The verification failed.",
|
|
1936
|
+
{ code: "INTERNAL_SERVER_ERROR", cause: error }
|
|
1937
|
+
);
|
|
1938
|
+
this.patch({ phase: "failed", busy: false, error: failure });
|
|
1939
|
+
return this.state;
|
|
1940
|
+
}
|
|
1941
|
+
patch(patch) {
|
|
1942
|
+
this.state = { ...this.state, ...patch };
|
|
1943
|
+
for (const listener of this.listeners) listener(this.state);
|
|
306
1944
|
}
|
|
307
1945
|
};
|
|
308
|
-
function createKYCClient(credentials) {
|
|
309
|
-
return KYCCore.createClient(credentials);
|
|
310
|
-
}
|
|
311
1946
|
|
|
312
|
-
exports.
|
|
313
|
-
exports.
|
|
314
|
-
exports.
|
|
1947
|
+
exports.ALL_WEBHOOK_EVENTS = ALL_WEBHOOK_EVENTS;
|
|
1948
|
+
exports.AnalyticsResource = AnalyticsResource;
|
|
1949
|
+
exports.DOCUMENT_CATALOG = DOCUMENT_CATALOG;
|
|
1950
|
+
exports.DUPLICATE_FIELDS = DUPLICATE_FIELDS;
|
|
1951
|
+
exports.GATEWAY_ERROR_CODES = GATEWAY_ERROR_CODES;
|
|
1952
|
+
exports.HttpClient = HttpClient;
|
|
1953
|
+
exports.IdentitiesResource = IdentitiesResource;
|
|
1954
|
+
exports.KycirisClient = KycirisClient;
|
|
1955
|
+
exports.KycirisError = KycirisError;
|
|
1956
|
+
exports.LevelsResource = LevelsResource;
|
|
1957
|
+
exports.MAX_IMAGE_FILE_SIZE = MAX_IMAGE_FILE_SIZE;
|
|
1958
|
+
exports.MediaResourceApi = MediaResourceApi;
|
|
1959
|
+
exports.OcrResource = OcrResource;
|
|
1960
|
+
exports.PIPELINE_EVENT_KINDS = PIPELINE_EVENT_KINDS;
|
|
1961
|
+
exports.SDK_ERROR_CODES = SDK_ERROR_CODES;
|
|
1962
|
+
exports.SESSION_STORAGE_KEY = SESSION_STORAGE_KEY;
|
|
1963
|
+
exports.SessionStore = SessionStore;
|
|
1964
|
+
exports.TERMINAL_VERIFICATION_STATUSES = TERMINAL_VERIFICATION_STATUSES;
|
|
1965
|
+
exports.VERIFICATION_OUTCOMES = VERIFICATION_OUTCOMES;
|
|
1966
|
+
exports.VERIFICATION_STATUSES = VERIFICATION_STATUSES;
|
|
1967
|
+
exports.VerificationFlow = VerificationFlow;
|
|
1968
|
+
exports.VerificationsResource = VerificationsResource;
|
|
1969
|
+
exports.WEBHOOK_EVENT_TYPES = WEBHOOK_EVENT_TYPES;
|
|
1970
|
+
exports.WebhooksResource = WebhooksResource;
|
|
1971
|
+
exports.createKycirisClient = createKycirisClient;
|
|
1972
|
+
exports.createMemoryStorage = createMemoryStorage;
|
|
1973
|
+
exports.decodeBase64 = decodeBase64;
|
|
1974
|
+
exports.detectImageMimeType = detectImageMimeType;
|
|
1975
|
+
exports.encodeBase64 = encodeBase64;
|
|
1976
|
+
exports.isKycirisError = isKycirisError;
|
|
1977
|
+
exports.isNormalizedFile = isNormalizedFile;
|
|
1978
|
+
exports.isReactNative = isReactNative;
|
|
1979
|
+
exports.isRetryableStatus = isRetryableStatus;
|
|
1980
|
+
exports.isSupportedDocumentPair = isSupportedDocumentPair;
|
|
1981
|
+
exports.isTerminalStatus = isTerminalStatus;
|
|
1982
|
+
exports.isUntrustedClient = isUntrustedClient;
|
|
1983
|
+
exports.normalizeFile = normalizeFile;
|
|
1984
|
+
exports.pollUntil = pollUntil;
|
|
1985
|
+
exports.prepareFile = prepareFile;
|
|
1986
|
+
exports.supportedCountries = supportedCountries;
|
|
1987
|
+
exports.supportedDocumentTypes = supportedDocumentTypes;
|
|
1988
|
+
exports.toPage = toPage;
|
|
315
1989
|
//# sourceMappingURL=index.js.map
|
|
316
1990
|
//# sourceMappingURL=index.js.map
|