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