vesant-sdk 1.7.0 → 1.7.1-dev.0520b46

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.
Files changed (75) hide show
  1. package/dist/{client-B0qhE2kr.d.mts → client-5q4whhGu.d.mts} +2 -2
  2. package/dist/{client-DF7hlMEz.d.ts → client-BaUekQT8.d.ts} +96 -52
  3. package/dist/{client-DtH2RLuy.d.ts → client-DUHC-68_.d.ts} +2 -2
  4. package/dist/{client-DrjgZoH_.d.mts → client-XDEhh9VN.d.mts} +96 -52
  5. package/dist/{client-BolQlL5e.d.mts → client-q9fN8Hhf.d.mts} +103 -1
  6. package/dist/{client-BolQlL5e.d.ts → client-q9fN8Hhf.d.ts} +103 -1
  7. package/dist/compliance/index.d.mts +3 -3
  8. package/dist/compliance/index.d.ts +3 -3
  9. package/dist/compliance/index.js +218 -90
  10. package/dist/compliance/index.js.map +1 -1
  11. package/dist/compliance/index.mjs +218 -90
  12. package/dist/compliance/index.mjs.map +1 -1
  13. package/dist/decisions/index.d.mts +1 -1
  14. package/dist/decisions/index.d.ts +1 -1
  15. package/dist/decisions/index.js +69 -8
  16. package/dist/decisions/index.js.map +1 -1
  17. package/dist/decisions/index.mjs +69 -8
  18. package/dist/decisions/index.mjs.map +1 -1
  19. package/dist/geolocation/index.d.mts +68 -7
  20. package/dist/geolocation/index.d.ts +68 -7
  21. package/dist/geolocation/index.js +223 -90
  22. package/dist/geolocation/index.js.map +1 -1
  23. package/dist/geolocation/index.mjs +220 -91
  24. package/dist/geolocation/index.mjs.map +1 -1
  25. package/dist/index-C3zUKydT.d.mts +264 -0
  26. package/dist/index-DSDgS09k.d.ts +264 -0
  27. package/dist/index.d.mts +27 -10
  28. package/dist/index.d.ts +27 -10
  29. package/dist/index.js +418 -126
  30. package/dist/index.js.map +1 -1
  31. package/dist/index.mjs +409 -127
  32. package/dist/index.mjs.map +1 -1
  33. package/dist/kyc/core.d.mts +2 -2
  34. package/dist/kyc/core.d.ts +2 -2
  35. package/dist/kyc/core.js +102 -21
  36. package/dist/kyc/core.js.map +1 -1
  37. package/dist/kyc/core.mjs +102 -22
  38. package/dist/kyc/core.mjs.map +1 -1
  39. package/dist/kyc/index.d.mts +154 -30
  40. package/dist/kyc/index.d.ts +154 -30
  41. package/dist/kyc/index.js +102 -21
  42. package/dist/kyc/index.js.map +1 -1
  43. package/dist/kyc/index.mjs +102 -22
  44. package/dist/kyc/index.mjs.map +1 -1
  45. package/dist/react.d.mts +14 -3
  46. package/dist/react.d.ts +14 -3
  47. package/dist/react.js +317 -68
  48. package/dist/react.js.map +1 -1
  49. package/dist/react.mjs +317 -68
  50. package/dist/react.mjs.map +1 -1
  51. package/dist/risk-profile/index.d.mts +1 -1
  52. package/dist/risk-profile/index.d.ts +1 -1
  53. package/dist/risk-profile/index.js +69 -8
  54. package/dist/risk-profile/index.js.map +1 -1
  55. package/dist/risk-profile/index.mjs +69 -8
  56. package/dist/risk-profile/index.mjs.map +1 -1
  57. package/dist/scores/index.d.mts +1 -1
  58. package/dist/scores/index.d.ts +1 -1
  59. package/dist/scores/index.js +69 -8
  60. package/dist/scores/index.js.map +1 -1
  61. package/dist/scores/index.mjs +69 -8
  62. package/dist/scores/index.mjs.map +1 -1
  63. package/dist/tax/index.d.mts +2 -2
  64. package/dist/tax/index.d.ts +2 -2
  65. package/dist/tax/index.js +74 -10
  66. package/dist/tax/index.js.map +1 -1
  67. package/dist/tax/index.mjs +74 -10
  68. package/dist/tax/index.mjs.map +1 -1
  69. package/dist/webhooks/index.d.mts +2 -189
  70. package/dist/webhooks/index.d.ts +2 -189
  71. package/dist/webhooks/index.js +85 -21
  72. package/dist/webhooks/index.js.map +1 -1
  73. package/dist/webhooks/index.mjs +85 -22
  74. package/dist/webhooks/index.mjs.map +1 -1
  75. package/package.json +4 -1
package/dist/react.mjs CHANGED
@@ -4,7 +4,7 @@ import { createRoot } from 'react-dom/client';
4
4
  // src/geolocation/hooks.ts
5
5
 
6
6
  // src/core/version.ts
7
- var SDK_VERSION = "1.7.0";
7
+ var SDK_VERSION = "1.7.1";
8
8
 
9
9
  // src/core/errors.ts
10
10
  var VesantError = class _VesantError extends Error {
@@ -87,27 +87,63 @@ function getBrowserInfo() {
87
87
  return { browser, browser_version: browserVersion, os, os_version: osVersion };
88
88
  }
89
89
 
90
- // src/geolocation/ciphertext.ts
91
- var CIPHER_TEXT_EXPIRY_MINUTES = 5;
92
- async function computeHMAC(key, message) {
93
- if (typeof globalThis.crypto !== "undefined" && globalThis.crypto.subtle) {
94
- const encoder = new TextEncoder();
95
- const keyData = encoder.encode(key);
96
- const msgData = encoder.encode(message);
97
- const cryptoKey = await globalThis.crypto.subtle.importKey(
98
- "raw",
99
- keyData,
100
- { name: "HMAC", hash: "SHA-256" },
101
- false,
102
- ["sign"]
103
- );
104
- const signature = await globalThis.crypto.subtle.sign("HMAC", cryptoKey, msgData);
105
- const bytes = new Uint8Array(signature);
106
- return Array.from(bytes).map((b) => b.toString(16).padStart(2, "0")).join("");
90
+ // src/geolocation/integrity.ts
91
+ var isNil = (v) => v === null || v === void 0;
92
+ function assessGpsIntegrity(input, opts = {}) {
93
+ const flags = [];
94
+ const now = opts.now ?? (() => Date.now());
95
+ const maxAgeMs = opts.maxAgeMs ?? 6e4;
96
+ const minAccuracy = opts.minAccuracyMeters ?? 1;
97
+ const { accuracy, altitude, altitudeAccuracy, heading, speed, timestamp } = input;
98
+ if (isNil(accuracy)) {
99
+ flags.push("accuracy_missing");
100
+ } else if (accuracy === 0) {
101
+ flags.push("accuracy_zero");
102
+ } else if (accuracy < minAccuracy) {
103
+ flags.push("accuracy_implausibly_small");
104
+ }
105
+ const hasMotion = !isNil(heading) || !isNil(speed);
106
+ if (hasMotion && (isNil(accuracy) || accuracy === 0)) {
107
+ flags.push("motion_without_accuracy");
108
+ }
109
+ if (!isNil(altitude) && isNil(altitudeAccuracy)) {
110
+ flags.push("altitude_without_accuracy");
111
+ }
112
+ if (timestamp !== void 0 && now() - timestamp > maxAgeMs) {
113
+ flags.push("stale_timestamp");
107
114
  }
108
- const { createHmac } = await import('crypto');
109
- return createHmac("sha256", key).update(message).digest("hex");
115
+ if (opts.permissionState === "denied" || opts.permissionState === "prompt") {
116
+ flags.push(`permission_${opts.permissionState}`);
117
+ }
118
+ return { suspicious: flags.length > 0, flags };
119
+ }
120
+ function gpsIntegrityInputFromPosition(position) {
121
+ const c = position.coords;
122
+ return {
123
+ latitude: c.latitude,
124
+ longitude: c.longitude,
125
+ accuracy: c.accuracy,
126
+ altitude: c.altitude,
127
+ altitudeAccuracy: c.altitudeAccuracy,
128
+ heading: c.heading,
129
+ speed: c.speed,
130
+ timestamp: position.timestamp
131
+ };
110
132
  }
133
+ async function probeGeolocationPermission() {
134
+ try {
135
+ if (typeof navigator === "undefined" || !navigator.permissions?.query) {
136
+ return "unavailable";
137
+ }
138
+ const status = await navigator.permissions.query({ name: "geolocation" });
139
+ return status.state;
140
+ } catch {
141
+ return "unavailable";
142
+ }
143
+ }
144
+
145
+ // src/geolocation/ciphertext.ts
146
+ var CIPHER_TEXT_EXPIRY_MINUTES = 5;
111
147
  function getWebGLInfo() {
112
148
  if (typeof document === "undefined") return null;
113
149
  try {
@@ -139,6 +175,7 @@ async function requestGPSLocation(timeout = 1e4, highAccuracy = true) {
139
175
  if (typeof navigator === "undefined" || !navigator.geolocation) {
140
176
  return null;
141
177
  }
178
+ const permissionState = await probeGeolocationPermission();
142
179
  return new Promise((resolve) => {
143
180
  navigator.geolocation.getCurrentPosition(
144
181
  (position) => {
@@ -155,6 +192,7 @@ async function requestGPSLocation(timeout = 1e4, highAccuracy = true) {
155
192
  resolve(null);
156
193
  return;
157
194
  }
195
+ const { flags } = assessGpsIntegrity(gpsIntegrityInputFromPosition(position), { permissionState });
158
196
  resolve({
159
197
  latitude,
160
198
  longitude,
@@ -163,7 +201,8 @@ async function requestGPSLocation(timeout = 1e4, highAccuracy = true) {
163
201
  altitude_accuracy: position.coords.altitudeAccuracy ?? void 0,
164
202
  heading: position.coords.heading ?? void 0,
165
203
  speed: position.coords.speed ?? void 0,
166
- timestamp: position.timestamp
204
+ timestamp: position.timestamp,
205
+ integrity_flags: flags.length ? flags : void 0
167
206
  });
168
207
  },
169
208
  () => {
@@ -230,6 +269,9 @@ async function generateCipherText(options, config) {
230
269
  );
231
270
  if (location) {
232
271
  locationData = location;
272
+ if (location.integrity_flags?.length) {
273
+ warnings.push(`GPS integrity flags (advisory): ${location.integrity_flags.join(", ")}`);
274
+ }
233
275
  } else if (gpsRequiredByConfig) {
234
276
  throw new VesantError(
235
277
  `GPS location is required for ${options.reason} by tenant configuration, but GPS was not available or permission was denied`,
@@ -259,15 +301,7 @@ async function generateCipherText(options, config) {
259
301
  };
260
302
  const encoded = encodePayload(payload);
261
303
  const timestamp = now.getTime().toString(36);
262
- let cipherText;
263
- const hmacKey = options.signingKey || options.apiKey;
264
- if (hmacKey) {
265
- const message = `02.${timestamp}.${encoded}`;
266
- const hmac = await computeHMAC(hmacKey, message);
267
- cipherText = `${message}.${hmac}`;
268
- } else {
269
- cipherText = `01.${timestamp}.${encoded}`;
270
- }
304
+ const cipherText = `01.${timestamp}.${encoded}`;
271
305
  return {
272
306
  cipherText,
273
307
  locationCaptured: !!locationData,
@@ -332,8 +366,6 @@ function useGeolocation(client, options = {}) {
332
366
  if (optionsRef.current.autoVerify) {
333
367
  const detectAndVerify = async () => {
334
368
  try {
335
- const ipResponse = await fetch("https://api.ipify.org?format=json");
336
- const { ip } = await ipResponse.json();
337
369
  let deviceFingerprint;
338
370
  if (optionsRef.current.includeDeviceFingerprint && typeof window !== "undefined") {
339
371
  const deviceId = generateDeviceId();
@@ -347,7 +379,6 @@ function useGeolocation(client, options = {}) {
347
379
  };
348
380
  }
349
381
  await verifyIP({
350
- ip_address: ip,
351
382
  user_id: optionsRef.current.userId || "",
352
383
  event_type: optionsRef.current.eventType || "page_view",
353
384
  device_fingerprint: deviceFingerprint
@@ -392,14 +423,11 @@ function useCipherText(client) {
392
423
  const [configLoading, setConfigLoading] = useState(!!client);
393
424
  const [configError, setConfigError] = useState(null);
394
425
  const configRef = useRef(void 0);
395
- const configPromiseRef = useRef(null);
396
426
  useEffect(() => {
397
427
  if (!client) return;
398
428
  let cancelled = false;
399
429
  setConfigLoading(true);
400
- const promise = client.getGPSConfig();
401
- configPromiseRef.current = promise;
402
- promise.then((cfg) => {
430
+ client.getGPSConfig().then((cfg) => {
403
431
  if (!cancelled) {
404
432
  configRef.current = cfg;
405
433
  setConfigLoading(false);
@@ -414,13 +442,21 @@ function useCipherText(client) {
414
442
  cancelled = true;
415
443
  };
416
444
  }, [client]);
445
+ const refreshConfig = useCallback(async () => {
446
+ if (!client) return;
447
+ try {
448
+ configRef.current = await client.refreshGPSConfig();
449
+ setConfigError(null);
450
+ } catch (error) {
451
+ setConfigError(error instanceof Error ? error : new Error("Failed to refresh GPS config"));
452
+ }
453
+ }, [client]);
417
454
  const generate = useCallback(
418
455
  async (options) => {
419
456
  try {
420
- if (configPromiseRef.current) {
457
+ if (client) {
421
458
  try {
422
- const cfg = await configPromiseRef.current;
423
- configRef.current = cfg;
459
+ configRef.current = await client.getGPSConfig();
424
460
  } catch {
425
461
  }
426
462
  }
@@ -430,9 +466,9 @@ function useCipherText(client) {
430
466
  return null;
431
467
  }
432
468
  },
433
- []
469
+ [client]
434
470
  );
435
- return { generate, configLoading, configError };
471
+ return { generate, refreshConfig, configLoading, configError };
436
472
  }
437
473
  function useLocationRequests(client, options = {}) {
438
474
  const [requests, setRequests] = useState([]);
@@ -661,6 +697,12 @@ function useLocationCapture(client, options) {
661
697
  if (accuracy !== null && accuracy !== void 0 && accuracy > 0) {
662
698
  capture.accuracy = accuracy;
663
699
  }
700
+ const { flags } = assessGpsIntegrity(gpsIntegrityInputFromPosition(position), {
701
+ permissionState: await probeGeolocationPermission()
702
+ });
703
+ if (flags.length) {
704
+ capture.integrity_flags = flags;
705
+ }
664
706
  }
665
707
  }
666
708
  if (gpsError) {
@@ -887,19 +929,19 @@ var Close = "data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0id
887
929
  var FACE_MESSAGES = {
888
930
  idle: "Initializing camera...",
889
931
  loading: "Loading face detection...",
890
- scanning: "Position your face in the circle",
932
+ scanning: "Position your face in the oval",
891
933
  too_far: "Face too far. Please move closer to the camera",
892
934
  too_close: "Too close. Please move back a little",
893
- off_center: "Center your face in the circle",
935
+ off_center: "Center your face in the oval",
894
936
  ok: "Looking great! Tap the button to capture"
895
937
  };
896
938
  var FACE_COLORS = {
897
939
  idle: "#9ca3af",
898
- loading: "#3b82f6",
899
- scanning: "#3b82f6",
940
+ loading: "#ef4444",
941
+ scanning: "#ef4444",
900
942
  too_far: "#ef4444",
901
- too_close: "#f59e0b",
902
- off_center: "#f59e0b",
943
+ too_close: "#ef4444",
944
+ off_center: "#ef4444",
903
945
  ok: "#10b981"
904
946
  };
905
947
  var MEDIAPIPE_BASE = "https://cdn.jsdelivr.net/npm/@mediapipe/face_detection";
@@ -964,11 +1006,77 @@ function loadMediaPipeFaceDetection(assetBasePath) {
964
1006
  return loader;
965
1007
  }
966
1008
 
1009
+ // src/kyc/hostedJourney.ts
1010
+ var REUSE_KYC_SESSION_EXPIRY_MS = 10 * 60 * 1e3;
1011
+ var JOURNEY_FRAME_HEIGHT = "clamp(500px, 64vh, 680px)";
1012
+ var JOURNEY_CONFIRM_TIMEOUT_MS = 45e3;
1013
+ function journeyOutcomeFromMessage(data) {
1014
+ const status = data?.verification_status;
1015
+ if (typeof status !== "string") return null;
1016
+ switch (status) {
1017
+ case "verification.accepted":
1018
+ return "accepted";
1019
+ case "verification.declined":
1020
+ return "declined";
1021
+ case "verification.cancelled":
1022
+ case "request.timeout":
1023
+ case "request.invalid":
1024
+ case "request.unauthorized":
1025
+ case "request.deleted":
1026
+ return "cancelled";
1027
+ default:
1028
+ return null;
1029
+ }
1030
+ }
1031
+ function journeyPollIntervalMs(confirming) {
1032
+ return confirming ? 1e3 : 2500;
1033
+ }
1034
+ function pendingJourneyResult(reference) {
1035
+ return {
1036
+ event: "reuse_kyc",
1037
+ reference,
1038
+ resource_id: "",
1039
+ status: "pending",
1040
+ data: {
1041
+ retries_remaining: 0,
1042
+ retry_limit_exceeded: false,
1043
+ trigger_alert: false,
1044
+ enforce_logout: false,
1045
+ freeze_account: false,
1046
+ block: false,
1047
+ freeze_duration_minutes: 0
1048
+ }
1049
+ };
1050
+ }
1051
+ function tryStartOperation(guard) {
1052
+ if (guard.current) return false;
1053
+ guard.current = true;
1054
+ return true;
1055
+ }
1056
+ function releaseOperation(guard) {
1057
+ guard.current = false;
1058
+ }
1059
+ function retryTarget(hasHostedJourney) {
1060
+ return hasHostedJourney ? "journey" : "capture";
1061
+ }
1062
+ var CAPTURE_MODAL_MAX_WIDTH = 448;
1063
+ function modalMaxWidth(hasHostedJourney) {
1064
+ return hasHostedJourney ? CAPTURE_MODAL_MAX_WIDTH : CAPTURE_MODAL_MAX_WIDTH;
1065
+ }
1066
+ var JOURNEY_CONTENT_PADDING = 12;
1067
+ function resultForModalClose(stageKind, result, unconfirmed) {
1068
+ const terminal = stageKind === "accepted" || stageKind === "declined" || stageKind === "max_attempts";
1069
+ if (terminal && result !== void 0) return result;
1070
+ const awaitingConfirmation = stageKind === "confirming" || stageKind === "unconfirmed";
1071
+ if (awaitingConfirmation && unconfirmed !== void 0) return unconfirmed;
1072
+ return null;
1073
+ }
1074
+
967
1075
  // src/kyc/FaceCaptureModal.tsx
968
1076
  var MOBILE_UA = /Mobi|Android|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i;
969
- var SESSION_EXPIRY_MS = 15 * 60 * 1e3;
1077
+ var ACCEPTED_AUTO_CLOSE_MS = 1200;
970
1078
  var LIVENESS_MESSAGES = [
971
- "Position your face inside the circle",
1079
+ "Position your face inside the oval",
972
1080
  "Make sure your face is well lit",
973
1081
  "Remove glasses or hats if possible",
974
1082
  "Look straight at the camera, then tap Capture"
@@ -977,6 +1085,12 @@ function headerSubtitle(stageKind) {
977
1085
  switch (stageKind) {
978
1086
  case "qr":
979
1087
  return "Use your phone to continue";
1088
+ case "journey":
1089
+ return "Follow the steps to verify your identity";
1090
+ case "confirming":
1091
+ return "Confirming your result";
1092
+ case "unconfirmed":
1093
+ return "Still confirming";
980
1094
  case "accepted":
981
1095
  return "All set";
982
1096
  case "declined":
@@ -1002,7 +1116,9 @@ function FaceCaptureModal({
1002
1116
  }) {
1003
1117
  const isMobile = typeof navigator !== "undefined" && MOBILE_UA.test(navigator.userAgent);
1004
1118
  const initialChoice = defaultDevice ?? (isMobile ? "this" : "ask");
1119
+ const journeyMode = Boolean(session.verification_url);
1005
1120
  const initialStage = (() => {
1121
+ if (journeyMode) return { kind: "journey", url: session.verification_url ?? "" };
1006
1122
  if (initialChoice === "this") return { kind: "capture" };
1007
1123
  if (initialChoice === "mobile") return { kind: "qr", mobileConnected: false };
1008
1124
  return { kind: "choose" };
@@ -1020,19 +1136,20 @@ function FaceCaptureModal({
1020
1136
  const qrPayload = session.link || `vesant://reuse-kyc?token=${encodeURIComponent(session.token)}`;
1021
1137
  const cancelledRef = useRef(false);
1022
1138
  const submittingRef = useRef(false);
1139
+ const journeyRetryingRef = useRef(false);
1023
1140
  useEffect(() => {
1024
1141
  const timer = setTimeout(() => {
1025
1142
  setStage(
1026
- (prev) => prev.kind === "choose" || prev.kind === "qr" || prev.kind === "capture" ? { kind: "error", message: "Session expired" } : prev
1143
+ (prev) => prev.kind === "choose" || prev.kind === "qr" || prev.kind === "capture" || prev.kind === "journey" ? { kind: "error", message: "Session expired" } : prev
1027
1144
  );
1028
- }, SESSION_EXPIRY_MS);
1145
+ }, REUSE_KYC_SESSION_EXPIRY_MS);
1029
1146
  return () => clearTimeout(timer);
1030
1147
  }, [session.token]);
1031
1148
  useEffect(() => {
1032
1149
  if (stage.kind !== "qr") return;
1033
1150
  let stopped = false;
1034
1151
  const intervalMs = 2e3;
1035
- const deadline = Date.now() + SESSION_EXPIRY_MS;
1152
+ const deadline = Date.now() + REUSE_KYC_SESSION_EXPIRY_MS;
1036
1153
  let mobileSeen = stage.mobileConnected;
1037
1154
  const tick = async () => {
1038
1155
  if (stopped || cancelledRef.current) return;
@@ -1073,6 +1190,82 @@ function FaceCaptureModal({
1073
1190
  stopped = true;
1074
1191
  };
1075
1192
  }, [stage.kind, stage.kind === "qr" ? stage.mobileConnected : false]);
1193
+ const journeyUrl = stage.kind === "journey" || stage.kind === "confirming" ? stage.url : "";
1194
+ useEffect(() => {
1195
+ if (stage.kind !== "journey" && stage.kind !== "confirming" || !journeyUrl) return;
1196
+ const intervalMs = journeyPollIntervalMs(stage.kind === "confirming");
1197
+ let stopped = false;
1198
+ const checkOnce = async () => {
1199
+ try {
1200
+ const result = await client.getEventBasedFaceVerificationSessionStatus(session.token);
1201
+ if (applyResult(result)) {
1202
+ stopped = true;
1203
+ return true;
1204
+ }
1205
+ } catch {
1206
+ }
1207
+ return false;
1208
+ };
1209
+ const tick = async () => {
1210
+ if (stopped || cancelledRef.current) return;
1211
+ await checkOnce();
1212
+ if (!stopped && !cancelledRef.current) {
1213
+ setTimeout(tick, intervalMs);
1214
+ }
1215
+ };
1216
+ void tick();
1217
+ let frameOrigin = "";
1218
+ try {
1219
+ frameOrigin = new URL(journeyUrl).origin;
1220
+ } catch {
1221
+ frameOrigin = "";
1222
+ }
1223
+ const onMessage = (e) => {
1224
+ if (!frameOrigin || e.origin !== frameOrigin) return;
1225
+ const outcome = journeyOutcomeFromMessage(e.data);
1226
+ if (!outcome) return;
1227
+ void checkOnce();
1228
+ setStage((prev) => {
1229
+ if (prev.kind !== "journey") return prev;
1230
+ if (outcome === "cancelled") {
1231
+ return { kind: "error", message: "The verification was closed before it finished. Please try again." };
1232
+ }
1233
+ return { kind: "confirming", outcome, url: prev.url };
1234
+ });
1235
+ };
1236
+ window.addEventListener("message", onMessage);
1237
+ return () => {
1238
+ stopped = true;
1239
+ window.removeEventListener("message", onMessage);
1240
+ };
1241
+ }, [stage.kind, journeyUrl]);
1242
+ useEffect(() => {
1243
+ if (stage.kind !== "confirming") return;
1244
+ const outcome = stage.outcome;
1245
+ const timer = setTimeout(() => {
1246
+ setStage((prev) => prev.kind === "confirming" ? { kind: "unconfirmed", outcome } : prev);
1247
+ }, JOURNEY_CONFIRM_TIMEOUT_MS);
1248
+ return () => clearTimeout(timer);
1249
+ }, [stage.kind, stage.kind === "confirming" ? stage.outcome : ""]);
1250
+ const handleJourneyRetry = async () => {
1251
+ if (!tryStartOperation(journeyRetryingRef)) return;
1252
+ setStage({ kind: "journey", url: "" });
1253
+ try {
1254
+ const refreshed = await client.retryEventBasedFaceVerificationJourney(session.token);
1255
+ if (refreshed.verification_url) {
1256
+ setStage({ kind: "journey", url: refreshed.verification_url });
1257
+ return;
1258
+ }
1259
+ setStage({ kind: "error", message: "Unable to start a new attempt. Please try again." });
1260
+ } catch (err) {
1261
+ setStage({
1262
+ kind: "error",
1263
+ message: err instanceof Error ? err.message : "Unable to start a new attempt. Please try again."
1264
+ });
1265
+ } finally {
1266
+ releaseOperation(journeyRetryingRef);
1267
+ }
1268
+ };
1076
1269
  const applyResult = (result) => {
1077
1270
  if (result.status === "accepted") {
1078
1271
  setStage({ kind: "accepted", result });
@@ -1200,12 +1393,23 @@ function FaceCaptureModal({
1200
1393
  }, 3e3);
1201
1394
  return () => clearInterval(interval);
1202
1395
  }, [captureMode]);
1203
- const close = (result) => {
1396
+ const close = useCallback((result) => {
1204
1397
  cancelledRef.current = true;
1205
1398
  if (result === null && onCancel) {
1206
1399
  onCancel();
1207
1400
  }
1208
1401
  onComplete(result);
1402
+ }, [onCancel, onComplete]);
1403
+ useEffect(() => {
1404
+ if (stage.kind !== "accepted") return;
1405
+ const result = stage.result;
1406
+ const timer = setTimeout(() => close(result), ACCEPTED_AUTO_CLOSE_MS);
1407
+ return () => clearTimeout(timer);
1408
+ }, [close, stage]);
1409
+ const handleHeaderClose = () => {
1410
+ const result = "result" in stage ? stage.result : void 0;
1411
+ const unconfirmed = stage.kind === "confirming" || stage.kind === "unconfirmed" ? pendingJourneyResult(session.reference) : void 0;
1412
+ close(resultForModalClose(stage.kind, result, unconfirmed));
1209
1413
  };
1210
1414
  const renderChoose = () => /* @__PURE__ */ React.createElement("div", { style: contentStyle }, /* @__PURE__ */ React.createElement("div", { style: visualGuideContainerStyle }, /* @__PURE__ */ React.createElement("div", { style: visualGuideStyle }, /* @__PURE__ */ React.createElement("div", { style: circleStyle }, /* @__PURE__ */ React.createElement("img", { src: Camera, alt: "", width: 48, height: 48 })))), /* @__PURE__ */ React.createElement("p", { style: subtitleStyle }, "How would you like to verify?"), /* @__PURE__ */ React.createElement("div", { style: buttonsContainerStyle }, /* @__PURE__ */ React.createElement(
1211
1415
  "button",
@@ -1264,12 +1468,37 @@ function FaceCaptureModal({
1264
1468
  };
1265
1469
  const renderSubmitting = () => /* @__PURE__ */ React.createElement("div", { style: { ...contentStyle, alignItems: "center", textAlign: "center" } }, /* @__PURE__ */ React.createElement("div", { style: { ...spinnerStyle, margin: "24px auto", width: 32, height: 32 } }), /* @__PURE__ */ React.createElement("p", { style: subtitleStyle }, "Verifying your photo\u2026"), /* @__PURE__ */ React.createElement("p", { style: { ...attemptsTextStyle, marginTop: 8 } }, "This usually takes a few seconds."));
1266
1470
  const renderAccepted = (result) => /* @__PURE__ */ React.createElement("div", { style: { ...contentStyle, alignItems: "center", textAlign: "center" } }, /* @__PURE__ */ React.createElement("div", { style: successCircleStyle }, /* @__PURE__ */ React.createElement("img", { src: Done, alt: "", width: 48, height: 48 })), /* @__PURE__ */ React.createElement("h3", { style: titleStyle }, "Verified"), /* @__PURE__ */ React.createElement("p", { style: subtitleStyle }, "Your identity has been confirmed. You can continue."), /* @__PURE__ */ React.createElement("div", { style: buttonsContainerStyle }, /* @__PURE__ */ React.createElement("button", { style: primaryButtonStyle, onClick: () => close(result) }, "Continue")));
1267
- const renderDeclined = (result) => /* @__PURE__ */ React.createElement("div", { style: contentStyle }, /* @__PURE__ */ React.createElement("div", { style: alertBoxStyle }, /* @__PURE__ */ React.createElement("p", { style: alertTextStyle }, result.declined_reason ?? "We couldn't match your selfie. Please take a new one.")), /* @__PURE__ */ React.createElement("div", { style: buttonsContainerStyle }, /* @__PURE__ */ React.createElement("button", { style: primaryButtonStyle, onClick: handleRetakeAfterDecline }, /* @__PURE__ */ React.createElement("img", { src: Camera, alt: "", width: 20, height: 20 }), /* @__PURE__ */ React.createElement("span", null, "Retake")), /* @__PURE__ */ React.createElement("button", { style: cancelButtonStyle, onClick: () => close(result) }, "Cancel")), /* @__PURE__ */ React.createElement("p", { style: attemptsTextStyle }, "Attempt ", Math.min(attempts + 1, maxAttempts), " of ", maxAttempts));
1471
+ const renderDeclined = (result) => /* @__PURE__ */ React.createElement("div", { style: contentStyle }, /* @__PURE__ */ React.createElement("div", { style: alertBoxStyle }, /* @__PURE__ */ React.createElement("p", { style: alertTextStyle }, result.declined_reason ?? "We couldn't match your selfie. Please take a new one.")), /* @__PURE__ */ React.createElement("div", { style: buttonsContainerStyle }, journeyMode ? /* @__PURE__ */ React.createElement("button", { style: primaryButtonStyle, onClick: () => void handleJourneyRetry() }, /* @__PURE__ */ React.createElement("span", null, "Try Again")) : /* @__PURE__ */ React.createElement("button", { style: primaryButtonStyle, onClick: handleRetakeAfterDecline }, /* @__PURE__ */ React.createElement("img", { src: Camera, alt: "", width: 20, height: 20 }), /* @__PURE__ */ React.createElement("span", null, "Retake")), /* @__PURE__ */ React.createElement("button", { style: cancelButtonStyle, onClick: () => close(result) }, "Cancel")), /* @__PURE__ */ React.createElement("p", { style: attemptsTextStyle }, "Attempt ", Math.min(attempts + 1, maxAttempts), " of ", maxAttempts));
1268
1472
  const renderMaxAttempts = (result) => /* @__PURE__ */ React.createElement("div", { style: { ...contentStyle, alignItems: "center", textAlign: "center" } }, /* @__PURE__ */ React.createElement("div", { style: errorCircleStyle }, "!"), /* @__PURE__ */ React.createElement("h3", { style: titleStyle }, "Maximum Attempts Reached"), /* @__PURE__ */ React.createElement("p", { style: subtitleStyle }, result.declined_reason ?? "We couldn't verify your identity after several attempts."), result.data?.freeze_account && /* @__PURE__ */ React.createElement("p", { style: alertTextStyle }, "For your security, your account has been temporarily restricted", result.data.freeze_duration_minutes ? ` for ${result.data.freeze_duration_minutes} minutes` : "", ". Please contact support if you need immediate help."), result.data?.enforce_logout && /* @__PURE__ */ React.createElement("p", { style: alertTextStyle }, "You will be signed out of your session."), result.data?.block && /* @__PURE__ */ React.createElement("p", { style: alertTextStyle }, "This action has been blocked for security reasons."), /* @__PURE__ */ React.createElement("div", { style: buttonsContainerStyle }, /* @__PURE__ */ React.createElement("button", { style: primaryButtonStyle, onClick: () => close(result) }, "Close")));
1269
1473
  const renderError = (message) => {
1270
1474
  const sessionExpired = /session\s+expired/i.test(message);
1271
- return /* @__PURE__ */ React.createElement("div", { style: { ...contentStyle, alignItems: "center", textAlign: "center" } }, /* @__PURE__ */ React.createElement("div", { style: errorCircleStyle }, "!"), /* @__PURE__ */ React.createElement("h3", { style: titleStyle }, sessionExpired ? "Session expired" : "Something Went Wrong"), /* @__PURE__ */ React.createElement("p", { style: alertTextStyle }, sessionExpired ? "Your verification session is no longer valid. Please start a new verification from the beginning." : message), /* @__PURE__ */ React.createElement("div", { style: buttonsContainerStyle }, !sessionExpired && /* @__PURE__ */ React.createElement("button", { style: primaryButtonStyle, onClick: () => setStage({ kind: "capture" }) }, "Try Again"), /* @__PURE__ */ React.createElement("button", { style: cancelButtonStyle, onClick: () => close(null) }, sessionExpired ? "Close" : "Cancel")));
1475
+ return /* @__PURE__ */ React.createElement("div", { style: { ...contentStyle, alignItems: "center", textAlign: "center" } }, /* @__PURE__ */ React.createElement("div", { style: errorCircleStyle }, "!"), /* @__PURE__ */ React.createElement("h3", { style: titleStyle }, sessionExpired ? "Session expired" : "Something Went Wrong"), /* @__PURE__ */ React.createElement("p", { style: alertTextStyle }, sessionExpired ? "Your verification session is no longer valid. Please start a new verification from the beginning." : message), /* @__PURE__ */ React.createElement("div", { style: buttonsContainerStyle }, !sessionExpired && /* @__PURE__ */ React.createElement(
1476
+ "button",
1477
+ {
1478
+ style: primaryButtonStyle,
1479
+ onClick: () => retryTarget(journeyMode) === "journey" ? void handleJourneyRetry() : setStage({ kind: "capture" })
1480
+ },
1481
+ "Try Again"
1482
+ ), /* @__PURE__ */ React.createElement("button", { style: cancelButtonStyle, onClick: () => close(null) }, sessionExpired ? "Close" : "Cancel")));
1272
1483
  };
1484
+ const renderConfirming = () => /* @__PURE__ */ React.createElement("div", { style: { ...contentStyle, alignItems: "center", textAlign: "center" } }, /* @__PURE__ */ React.createElement("div", { style: { ...spinnerStyle, margin: "24px auto", width: 32, height: 32 } }), /* @__PURE__ */ React.createElement("h3", { style: titleStyle }, "Confirming your result\u2026"), /* @__PURE__ */ React.createElement("p", { style: { ...attemptsTextStyle, marginTop: 8 } }, "This usually takes a few seconds."));
1485
+ const renderUnconfirmed = (outcome) => /* @__PURE__ */ React.createElement("div", { style: { ...contentStyle, alignItems: "center", textAlign: "center" } }, /* @__PURE__ */ React.createElement("div", { style: errorCircleStyle }, "!"), /* @__PURE__ */ React.createElement("h3", { style: titleStyle }, "Still confirming"), /* @__PURE__ */ React.createElement("p", { style: alertTextStyle }, outcome === "accepted" ? "Your verification was submitted, but we haven't received the final result yet. You can close this window and we'll keep checking in the background." : "Your last attempt wasn't accepted, but we haven't received the details yet. You can try again or close this window."), /* @__PURE__ */ React.createElement("div", { style: buttonsContainerStyle }, outcome === "declined" && /* @__PURE__ */ React.createElement("button", { style: primaryButtonStyle, onClick: () => void handleJourneyRetry() }, "Try Again"), /* @__PURE__ */ React.createElement(
1486
+ "button",
1487
+ {
1488
+ style: outcome === "accepted" ? primaryButtonStyle : cancelButtonStyle,
1489
+ onClick: () => close(pendingJourneyResult(session.reference))
1490
+ },
1491
+ "Close"
1492
+ )));
1493
+ const renderJourney = (url) => /* @__PURE__ */ React.createElement("div", { style: { ...contentStyle, padding: JOURNEY_CONTENT_PADDING } }, url ? /* @__PURE__ */ React.createElement(
1494
+ "iframe",
1495
+ {
1496
+ src: url,
1497
+ title: "Identity verification",
1498
+ allow: "camera; microphone",
1499
+ style: journeyFrameStyle
1500
+ }
1501
+ ) : /* @__PURE__ */ React.createElement("div", { style: { padding: 24, textAlign: "center" } }, /* @__PURE__ */ React.createElement("div", { style: { ...spinnerStyle, margin: "24px auto", width: 32, height: 32 } }), /* @__PURE__ */ React.createElement("p", { style: subtitleStyle }, "Preparing your next attempt\u2026")), /* @__PURE__ */ React.createElement("p", { style: { ...attemptsTextStyle, textAlign: "center" } }, "Attempt ", Math.min(attempts + 1, maxAttempts), " of ", maxAttempts));
1273
1502
  const body = (() => {
1274
1503
  switch (stage.kind) {
1275
1504
  case "choose":
@@ -1278,6 +1507,12 @@ function FaceCaptureModal({
1278
1507
  return renderQRStage(stage.mobileConnected);
1279
1508
  case "capture":
1280
1509
  return renderCapture(stage.declinedReason);
1510
+ case "journey":
1511
+ return renderJourney(stage.url);
1512
+ case "confirming":
1513
+ return renderConfirming();
1514
+ case "unconfirmed":
1515
+ return renderUnconfirmed(stage.outcome);
1281
1516
  case "submitting":
1282
1517
  return renderSubmitting();
1283
1518
  case "accepted":
@@ -1290,10 +1525,10 @@ function FaceCaptureModal({
1290
1525
  return renderError(stage.message);
1291
1526
  }
1292
1527
  })();
1293
- return /* @__PURE__ */ React.createElement(React.Fragment, null, /* @__PURE__ */ React.createElement("style", null, keyframes), /* @__PURE__ */ React.createElement("div", { style: overlayStyle }, /* @__PURE__ */ React.createElement("div", { style: modalStyle }, /* @__PURE__ */ React.createElement("div", { style: headerStyle }, /* @__PURE__ */ React.createElement(
1528
+ return /* @__PURE__ */ React.createElement(React.Fragment, null, /* @__PURE__ */ React.createElement("style", null, keyframes), /* @__PURE__ */ React.createElement("div", { style: overlayStyle }, /* @__PURE__ */ React.createElement("div", { style: { ...modalStyle, maxWidth: modalMaxWidth(journeyMode) } }, /* @__PURE__ */ React.createElement("div", { style: headerStyle }, /* @__PURE__ */ React.createElement(
1294
1529
  "button",
1295
1530
  {
1296
- onClick: () => close(null),
1531
+ onClick: handleHeaderClose,
1297
1532
  style: closeButtonStyle,
1298
1533
  "aria-label": "Close"
1299
1534
  },
@@ -1323,7 +1558,8 @@ var modalStyle = {
1323
1558
  borderRadius: 16,
1324
1559
  boxShadow: "0 25px 50px -12px rgba(0,0,0,.25)",
1325
1560
  width: "100%",
1326
- maxWidth: 448,
1561
+ maxHeight: "calc(100vh - 32px)",
1562
+ overflowY: "auto",
1327
1563
  animation: "zoomIn .2s ease-out"
1328
1564
  };
1329
1565
  var headerStyle = {
@@ -1349,6 +1585,13 @@ var contentStyle = {
1349
1585
  flexDirection: "column",
1350
1586
  gap: 16
1351
1587
  };
1588
+ var journeyFrameStyle = {
1589
+ width: "100%",
1590
+ height: JOURNEY_FRAME_HEIGHT,
1591
+ border: 0,
1592
+ borderRadius: 12,
1593
+ background: "#f9fafb"
1594
+ };
1352
1595
  var visualGuideContainerStyle = { display: "flex", justifyContent: "center" };
1353
1596
  var visualGuideStyle = { position: "relative" };
1354
1597
  var circleStyle = {
@@ -1615,9 +1858,10 @@ function LiveCamera({ onCapture, onCancel, message, assetBasePath }) {
1615
1858
  if (error) {
1616
1859
  return /* @__PURE__ */ React.createElement("div", { style: { ...contentStyle, alignItems: "center", textAlign: "center", padding: 0 } }, /* @__PURE__ */ React.createElement("div", { style: errorCircleStyle }, "!"), /* @__PURE__ */ React.createElement("h3", { style: titleStyle }, "Camera unavailable"), /* @__PURE__ */ React.createElement("p", { style: alertTextStyle }, error), /* @__PURE__ */ React.createElement("div", { style: buttonsContainerStyle }, /* @__PURE__ */ React.createElement("button", { style: secondaryButtonStyle, onClick: onCancel }, "Back")));
1617
1860
  }
1618
- const detectionActive = ready && !faceDetectionFailed;
1619
- const liveMessage = detectionActive ? FACE_MESSAGES[faceStatus] : message;
1620
- const liveFaceColor = detectionActive ? FACE_COLORS[faceStatus] : void 0;
1861
+ const detectionAvailable = !faceDetectionFailed;
1862
+ const detectionActive = ready && detectionAvailable;
1863
+ const liveMessage = detectionAvailable ? FACE_MESSAGES[faceStatus] : message;
1864
+ const liveFaceColor = detectionAvailable ? FACE_COLORS[faceStatus] : void 0;
1621
1865
  const captureDisabled = isCaptureDisabled(ready, detectionActive, faceStatus);
1622
1866
  const ringPulse = detectionActive && (faceStatus === "too_far" || faceStatus === "too_close") ? "pulse 1.5s ease-in-out infinite" : void 0;
1623
1867
  const dynamicCircleStyle = {
@@ -1733,20 +1977,22 @@ var faceOverlayContainerStyle = {
1733
1977
  };
1734
1978
  var faceRingWrapStyle = {
1735
1979
  position: "relative",
1736
- width: "72%",
1737
- aspectRatio: "1 / 1"
1980
+ height: "64%",
1981
+ aspectRatio: "3 / 4"
1738
1982
  };
1739
1983
  var faceDashedRingStyle = {
1740
1984
  position: "absolute",
1741
1985
  inset: -6,
1742
- borderRadius: "50%",
1743
- border: "6px dashed rgba(255, 255, 255, 0.4)",
1744
- animation: "spin 10s linear infinite"
1986
+ borderRadius: 9999,
1987
+ border: "6px dashed rgba(255, 255, 255, 0.4)"
1745
1988
  };
1746
1989
  var faceCircleStyle = {
1747
1990
  position: "absolute",
1748
1991
  inset: 0,
1749
- borderRadius: "50%",
1992
+ // Tailwind's `rounded-full` resolves to a very large fixed radius. On a
1993
+ // 3:4 box that creates the normal KYC capsule/head guide with straighter
1994
+ // sides; 50% would instead produce a mathematically perfect ellipse.
1995
+ borderRadius: 9999,
1750
1996
  border: "4px solid #00bc7d",
1751
1997
  boxShadow: "0 0 0 1000px rgba(0, 0, 0, 0.5)"
1752
1998
  };
@@ -2089,6 +2335,7 @@ function isValidFileSize(file, maxSizeBytes = 10 * 1024 * 1024) {
2089
2335
  }
2090
2336
  function formatKycStatus(status) {
2091
2337
  const statusMap = {
2338
+ awaiting_kyc: "Awaiting KYC",
2092
2339
  pending: "Pending Review",
2093
2340
  accepted: "Approved",
2094
2341
  declined: "Rejected",
@@ -2099,6 +2346,8 @@ function formatKycStatus(status) {
2099
2346
  }
2100
2347
  function getStatusColor(status) {
2101
2348
  const colorMap = {
2349
+ awaiting_kyc: "#94a3b8",
2350
+ // slate — not started
2102
2351
  pending: "#f59e0b",
2103
2352
  // amber
2104
2353
  accepted: "#10b981",