@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/dist/index.js CHANGED
@@ -1,316 +1,1990 @@
1
1
  'use strict';
2
2
 
3
- var axios = require('axios');
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
- function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
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
- var axios__default = /*#__PURE__*/_interopDefault(axios);
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/index.ts
10
- var KYCSdkError = class extends Error {
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
- * Creates a new KYC SDK error
13
- * @param message Human-readable error message
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
- constructor(message, code, statusCode) {
18
- super(message);
19
- this.name = "KYCSdkError";
20
- this.code = code;
21
- this.statusCode = statusCode;
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
- var KYCCore = class _KYCCore {
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
- * Creates a new KYC Core instance
27
- * @param credentials API credentials (apiKey and baseUrl)
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
- constructor(credentials) {
30
- this.eventCallbacks = [];
31
- this.credentials = credentials;
32
- this.client = axios__default.default.create({
33
- baseURL: credentials.baseUrl,
34
- headers: {
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
- this.client.interceptors.request.use((config) => {
40
- config.headers["x-api-key"] = this.credentials.apiKey;
41
- return config;
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
- * Registers a callback for KYC status events
46
- * @param callback Function to call when status changes
47
- * @returns Unsubscribe function to remove the callback
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
- onEvent(callback) {
50
- this.eventCallbacks.push(callback);
51
- return () => {
52
- this.eventCallbacks = this.eventCallbacks.filter((cb) => cb !== callback);
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
- * Emits a status event to all registered callbacks
57
- * @param event The event to emit
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
- emitEvent(event) {
60
- this.eventCallbacks.forEach((callback) => callback(event));
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
- * Creates a verification token for secure API access
64
- * @param externalId External reference ID
65
- * @param expiry Token expiry in hours (default: 3)
66
- * @returns Object containing the token and its expiration time
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
- async createVerificationToken(externalId, expiry = "3") {
69
- try {
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
- * Starts a new verification session
82
- * @param params Parameters including documentType, country, identityId, and externalId
83
- * @returns Verification session with verificationId and status
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 startVerification(params) {
86
- try {
87
- const response = await this.client.post("/verification/start", params);
88
- this.emitEvent({
89
- type: "statusChanged",
90
- status: "PENDING"
91
- });
92
- return response.data;
93
- } catch (error) {
94
- const message = error.response?.data?.message || error.message || "Failed to start verification";
95
- throw new KYCSdkError(message, "VERIFICATION_START_FAILED", error.response?.status);
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
- * Uploads a selfie image for face verification
100
- * @param params Parameters including verificationId, imageData, and optional mimeType
101
- * @returns Upload result with status
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 uploadSelfie(params) {
104
- try {
105
- const formData = new FormData();
106
- formData.append("verificationId", params.verificationId);
107
- const mimeType = params.mimeType || "image/jpeg";
108
- const imageData = params.imageData;
109
- if (imageData.startsWith("file://")) {
110
- formData.append("file", {
111
- uri: imageData,
112
- type: mimeType,
113
- name: "selfie.jpg"
114
- });
115
- } else if (imageData.startsWith("data:")) {
116
- formData.append("file", {
117
- uri: imageData,
118
- type: mimeType,
119
- name: "selfie.jpg"
120
- });
121
- } else {
122
- formData.append("file", {
123
- uri: `data:${mimeType};base64,${imageData}`,
124
- type: mimeType,
125
- name: "selfie.jpg"
126
- });
127
- }
128
- const response = await this.client.post("/verification/upload/selfie", formData, {
129
- headers: { "Content-Type": "multipart/form-data" }
130
- });
131
- this.emitEvent({
132
- type: "statusChanged",
133
- status: "PROCESSING"
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 a document image (front or back)
144
- * @param params Parameters including verificationId, type, imageData, and optional mimeType
145
- * @returns Upload result with status
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(params) {
148
- try {
149
- const formData = new FormData();
150
- formData.append("verificationId", params.verificationId);
151
- formData.append("type", params.type);
152
- const mimeType = params.mimeType || "image/jpeg";
153
- const imageData = params.imageData;
154
- if (imageData.startsWith("file://")) {
155
- formData.append("file", {
156
- uri: imageData,
157
- type: mimeType,
158
- name: `${params.type}.jpg`
159
- });
160
- } else if (imageData.startsWith("data:")) {
161
- formData.append("file", {
162
- uri: imageData,
163
- type: mimeType,
164
- name: `${params.type}.jpg`
165
- });
166
- } else {
167
- formData.append("file", {
168
- uri: `data:${mimeType};base64,${imageData}`,
169
- type: mimeType,
170
- name: `${params.type}.jpg`
171
- });
172
- }
173
- const response = await this.client.post("/verification/upload/document", formData, {
174
- headers: { "Content-Type": "multipart/form-data" }
175
- });
176
- this.emitEvent({
177
- type: "statusChanged",
178
- status: "PROCESSING"
179
- });
180
- return response.data;
181
- } catch (error) {
182
- console.log("Upload document error:", error.response?.data || error.message);
183
- const message = error.response?.data?.message || error.message || "Failed to upload document";
184
- throw new KYCSdkError(message, "DOCUMENT_UPLOAD_FAILED", error.response?.status);
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
- * Gets the current status of a verification session
189
- * @param verificationId The verification ID to check
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 getStatus(verificationId) {
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
- const response = await this.client.get(`/verification/status/${verificationId}`);
195
- this.emitEvent({
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 faceMatchVerification(params) {
1650
+ async read() {
1651
+ if (!this.storage) return null;
206
1652
  try {
207
- const formData = new FormData();
208
- const mimeType = params.mimeType || "image/jpeg";
209
- const imageData = params.file;
210
- if (imageData.startsWith("file://")) {
211
- formData.append("file", {
212
- uri: imageData,
213
- type: mimeType,
214
- name: `selfie.jpg`
215
- });
216
- } else if (imageData.startsWith("data:")) {
217
- formData.append("file", {
218
- uri: imageData,
219
- type: mimeType,
220
- name: `selfie.jpg`
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
- formData.append("file", {
224
- uri: `data:${mimeType};base64,${imageData}`,
225
- type: mimeType,
226
- name: `selfie.jpg`
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 response = await this.client.post(
230
- `identities/${params.externalId}/face-match`,
231
- formData,
1763
+ const session = await this.client.verifications.start(
232
1764
  {
233
- headers: { "Content-Type": "multipart/form-data" }
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 response.data;
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
- * Polls for verification status until completion or timeout
272
- * @param verificationId The verification ID to check
273
- * @param interval Polling interval in milliseconds (default: 3000)
274
- * @param timeout Maximum time to wait in milliseconds (default: 120000)
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 pollStatus(verificationId, interval = 3e3, timeout = 12e4) {
278
- const startTime = Date.now();
279
- return new Promise((resolve, reject) => {
280
- const poll = async () => {
281
- try {
282
- const status = await this.getStatus(verificationId);
283
- if (status.status === "APPROVED" || status.status === "REJECTED") {
284
- resolve(status);
285
- return;
286
- }
287
- if (Date.now() - startTime > timeout) {
288
- reject(new KYCSdkError("Polling timeout", "POLLING_TIMEOUT"));
289
- return;
290
- }
291
- setTimeout(poll, interval);
292
- } catch (error) {
293
- reject(error);
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
- * Static factory method to create a KYC client instance
301
- * @param credentials API credentials (apiKey and baseUrl)
302
- * @returns Configured KYCCore instance
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
- static createClient(credentials) {
305
- return new _KYCCore(credentials);
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.KYCCore = KYCCore;
313
- exports.KYCSdkError = KYCSdkError;
314
- exports.createKYCClient = createKYCClient;
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