attestid_js_sdk 0.1.11 → 0.1.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -107,6 +107,28 @@ States: `idle`, `quality_check`, `uploading`, `retrying`, `liveness`, `completin
107
107
 
108
108
  Supported `idType` values: `national_id`, `passport`, `drivers_licence`.
109
109
 
110
+ ### Back of the card (optional)
111
+
112
+ Many ID cards and driver's licences print their machine-readable zone (MRZ, the lines of `<<<`) on the back. For `national_id` and `drivers_licence` you can also send a photo of the back. The API cross-checks the MRZ's date of birth and expiry against the front, and rejects a mismatch as `document_sides_mismatch`. A back with no MRZ adds nothing, so check it first and only send it when an MRZ was found:
113
+
114
+ ```ts
115
+ import { checkBackPhotoQuality } from 'attestid_js_sdk';
116
+
117
+ const back = await checkBackPhotoQuality(backPhoto);
118
+ startVerification({
119
+ userId, idType: 'national_id', idPhoto, consentGivenAt,
120
+ idPhotoBack: back.hasMrz ? backPhoto : undefined,
121
+ });
122
+ ```
123
+
124
+ Whether to ask for the back is each company's choice, set on the AttestID dashboard under Settings: `off` (the default; a back sent anyway is ignored), `optional` (ask, but the user can skip it) or `required` (the back must be sent with a readable MRZ). Read it with `client.getCaptureSettings(userId)`:
125
+
126
+ ```ts
127
+ const { document_back } = await client.getCaptureSettings(userId); // 'off' | 'optional' | 'required'
128
+ ```
129
+
130
+ `AttestIDVerifier` handles all of this for you. It reads the setting, then after the front it asks for the back when the policy is `optional` (with a skip button) or `required` (no skip).
131
+
110
132
  ## Verify results on your backend
111
133
 
112
134
  Always verify `result.attestation` on your backend before acting on a result.
@@ -40,6 +40,7 @@ __export(core_exports, {
40
40
  buildHandoffUrl: () => buildHandoffUrl,
41
41
  canRetryLiveness: () => canRetryLiveness,
42
42
  canonicalJson: () => canonicalJson,
43
+ checkBackPhotoQuality: () => checkBackPhotoQuality,
43
44
  checkCaptureConditions: () => checkCaptureConditions,
44
45
  checkPhotoQuality: () => checkPhotoQuality,
45
46
  classifyConditions: () => classifyConditions,
@@ -254,6 +255,8 @@ function countTextRows(grey, w, h, x0, aw) {
254
255
  return textRows;
255
256
  }
256
257
  var EDGE_DELTA = 28;
258
+ var EDGE_DELTA_MIN = 12;
259
+ var EDGE_DELTA_P99_FRACTION = 0.8;
257
260
  var NOISY_MEDIAN_STEP = 9;
258
261
  var SKEW_TANS = [0, 0.0175, -0.0175, 0.0349, -0.0349, 0.0524, -0.0524, 0.0699, -0.0699];
259
262
  var STRIPS = 8;
@@ -269,14 +272,15 @@ function idTextNotFoundMessage(idType) {
269
272
  const doc = idType === "drivers_licence" ? "driver's licence" : "ID card";
270
273
  return `We could not detect readable printed details on your ${doc}. Photograph the front of the card (the side with your photo, name and ID number) so it fills the frame, in good light and without glare.`;
271
274
  }
272
- function measureTextBands(grey, w, h, x0 = 0, aw = w) {
275
+ function measureTextBands(grey, w, h, x0 = 0, aw = w, edgeDeltaOverride) {
273
276
  const src = medianStep(grey, w, h, x0, aw) >= NOISY_MEDIAN_STEP ? boxSmooth(grey, w, h, x0, aw) : null;
274
277
  const at = src ? (y, x) => src[y * aw + x] : (y, x) => grey[y * w + x0 + x];
278
+ const edgeDelta = edgeDeltaOverride ?? edgeThreshold(at, aw, h);
275
279
  const ex = [];
276
280
  const ey = [];
277
281
  for (let y = 0; y < h; y++) {
278
282
  for (let x = 0; x < aw - 1; x++) {
279
- if (Math.abs(at(y, x + 1) - at(y, x)) > EDGE_DELTA) {
283
+ if (Math.abs(at(y, x + 1) - at(y, x)) > edgeDelta) {
280
284
  ex.push(x);
281
285
  ey.push(y);
282
286
  }
@@ -379,6 +383,7 @@ function measureTextBands(grey, w, h, x0 = 0, aw = w) {
379
383
  height,
380
384
  strip: s,
381
385
  firstCell: first,
386
+ lastCell: last,
382
387
  spanFrac: span / cps,
383
388
  coverage: active / span,
384
389
  density: inside / (span * CELL * height),
@@ -414,6 +419,25 @@ function medianStep(grey, w, h, x0, aw) {
414
419
  }
415
420
  return 255;
416
421
  }
422
+ function edgeThreshold(at, aw, h) {
423
+ const hist = new Uint32Array(256);
424
+ let n = 0;
425
+ for (let y = 0; y < h; y++) {
426
+ for (let x = 0; x < aw - 1; x++) {
427
+ hist[Math.min(255, Math.round(Math.abs(at(y, x + 1) - at(y, x))))]++;
428
+ n++;
429
+ }
430
+ }
431
+ let acc = 0, p99 = 255;
432
+ for (let v = 0; v < 256; v++) {
433
+ acc += hist[v];
434
+ if (acc >= n * 0.99) {
435
+ p99 = v;
436
+ break;
437
+ }
438
+ }
439
+ return Math.max(EDGE_DELTA_MIN, Math.min(EDGE_DELTA, Math.round(p99 * EDGE_DELTA_P99_FRACTION)));
440
+ }
417
441
  function boxSmooth(grey, w, h, x0, aw) {
418
442
  const out = new Float32Array(aw * h);
419
443
  for (let y = 0; y < h; y++) {
@@ -459,15 +483,17 @@ function periodicity(profile, glyphHeight) {
459
483
  }
460
484
  return best;
461
485
  }
462
- function analyseTextLayout(grey, w, h, x0 = 0, aw = w) {
463
- const { bands, fullWidthLines } = measureTextBands(grey, w, h, x0, aw);
486
+ function analyseTextLayout(grey, w, h, x0 = 0, aw = w, edgeDeltaOverride) {
487
+ const { bands, fullWidthLines } = measureTextBands(grey, w, h, x0, aw, edgeDeltaOverride);
464
488
  const stripCells = Math.ceil(Math.max(1, Math.floor(aw / STRIPS)) / CELL);
465
489
  const clean = bands.filter((b) => b.spanFrac >= 0.2 && b.coverage >= 0.35 && b.bleed <= TEXT_MAX_BLEED && b.spanFrac * stripCells * CELL / b.height >= 3);
466
490
  const textLines = clusterBands(clean).length;
467
491
  let alignedLines = 0;
468
492
  for (const b of clean) {
469
- const n = clean.filter((o) => o.strip === b.strip && Math.abs(o.firstCell - b.firstCell) <= 1).length;
470
- alignedLines = Math.max(alignedLines, n);
493
+ const sameStrip = clean.filter((o) => o.strip === b.strip);
494
+ const left = sameStrip.filter((o) => Math.abs(o.firstCell - b.firstCell) <= 1).length;
495
+ const right = sameStrip.filter((o) => Math.abs(o.lastCell - b.lastCell) <= 1).length;
496
+ alignedLines = Math.max(alignedLines, left, right);
471
497
  }
472
498
  const isMrzLine = (l) => l.bleed <= MRZ_MAX_BLEED && l.periodicity >= MRZ_MIN_PERIODICITY;
473
499
  const stacked = (p, q) => {
@@ -475,19 +501,23 @@ function analyseTextLayout(grey, w, h, x0 = 0, aw = w) {
475
501
  const pitch = q.center - p.center;
476
502
  return pitch >= 1.3 * avgH && pitch <= 2.8 * avgH && Math.max(p.height, q.height) / Math.min(p.height, q.height) <= 1.4;
477
503
  };
478
- let hasMrz = false;
504
+ let hasMrz2 = false;
479
505
  let runStart = 0;
480
506
  for (let i = 1; i <= fullWidthLines.length; i++) {
481
507
  if (i < fullWidthLines.length && stacked(fullWidthLines[i - 1], fullWidthLines[i])) continue;
482
508
  const run = fullWidthLines.slice(runStart, i);
483
509
  if (run.length >= 2 && run.length <= 3) {
484
510
  for (let j = 1; j < run.length; j++) {
485
- if (isMrzLine(run[j - 1]) && isMrzLine(run[j])) hasMrz = true;
511
+ if (isMrzLine(run[j - 1]) && isMrzLine(run[j])) hasMrz2 = true;
486
512
  }
487
513
  }
488
514
  runStart = i;
489
515
  }
490
- return { textLines, alignedLines, hasMrz };
516
+ return { textLines, alignedLines, hasMrz: hasMrz2 };
517
+ }
518
+ var MRZ_RETRY_EDGE_DELTA = 14;
519
+ function hasMrz(grey, w, h, x0 = 0, aw = w) {
520
+ return analyseTextLayout(grey, w, h, x0, aw).hasMrz || analyseTextLayout(grey, w, h, x0, aw, MRZ_RETRY_EDGE_DELTA).hasMrz;
491
521
  }
492
522
  async function detectText(source) {
493
523
  const TextDetectorCtor = globalThis.TextDetector;
@@ -516,14 +546,14 @@ function laplacianVariance(grey, w, h, x0, aw) {
516
546
  function checkPassportDocument(grey, w, h, browserFoundMrz = false) {
517
547
  const ratio = w / h;
518
548
  const regions = ratio > 2 ? [{ x0: 0, aw: Math.floor(w / 2) }, { x0: Math.floor(w / 2), aw: w - Math.floor(w / 2) }] : [{ x0: 0, aw: w }];
519
- let dataPage = regions.find(({ x0, aw }) => analyseTextLayout(grey, w, h, x0, aw).hasMrz);
549
+ let dataPage = regions.find(({ x0, aw }) => hasMrz(grey, w, h, x0, aw));
520
550
  if (!dataPage && browserFoundMrz) {
521
551
  dataPage = regions.reduce((best, r) => countTextRows(grey, w, h, r.x0, r.aw) > countTextRows(grey, w, h, best.x0, best.aw) ? r : best);
522
552
  }
523
553
  if (!dataPage) return [MRZ_NOT_FOUND_MESSAGE];
524
554
  if (laplacianVariance(grey, w, h, dataPage.x0, dataPage.aw) < BLUR_THRESHOLD) {
525
555
  return [
526
- "The data page is too blurry \u2014 hold the camera steady and ensure the MRZ text at the bottom of the data page is in sharp focus."
556
+ "The data page is too blurry. Hold the camera steady and make sure the MRZ text at the bottom of the data page is in sharp focus."
527
557
  ];
528
558
  }
529
559
  return [];
@@ -531,6 +561,7 @@ function checkPassportDocument(grey, w, h, browserFoundMrz = false) {
531
561
  function checkReadableText(grey, w, h, idType, browserTextBlocks = null) {
532
562
  if ((browserTextBlocks ?? 0) >= 3) return [];
533
563
  const layout = analyseTextLayout(grey, w, h);
564
+ if (layout.hasMrz) return [];
534
565
  if (layout.textLines >= MIN_ID_TEXT_LINES && layout.alignedLines >= MIN_ID_ALIGNED_LINES) return [];
535
566
  return [idTextNotFoundMessage(idType)];
536
567
  }
@@ -539,7 +570,7 @@ function checkDocumentIdentity(grey, w, h, idType, precomputedTotalTextRows) {
539
570
  const totalTextRows = precomputedTotalTextRows ?? countTextRows(grey, w, h, 0, w);
540
571
  if (totalTextRows < Math.max(2, h * 0.02)) {
541
572
  return [
542
- "This does not look like an identity document \u2014 no printed text was detected. Please photograph your ID card or passport data page on a plain, well-lit background."
573
+ "This does not look like an identity document: no printed text was detected. Please photograph your ID card or passport data page on a plain, well-lit background."
543
574
  ];
544
575
  }
545
576
  if (idType === "passport") {
@@ -555,14 +586,68 @@ function checkDocumentIdentity(grey, w, h, idType, precomputedTotalTextRows) {
555
586
  }
556
587
  return [];
557
588
  }
589
+ var BACK_MRZ_NOT_FOUND_MESSAGE = "We could not read the machine-readable zone (MRZ) on the back of your card: the 3 lines of letters, numbers and <<< along the bottom. This usually means the photo is blurry or the card is too small in the frame. Fill the frame with the back of the card, hold the phone steady, and wait for the <<< lines to come into focus before capturing. Avoid glare on the card.";
590
+ function checkBackOfCard(grey, w, h, browserFoundMrz = false) {
591
+ let total = 0;
592
+ for (let i = 0; i < grey.length; i++) total += grey[i];
593
+ const brightness = total / grey.length;
594
+ const issues = [];
595
+ if (brightness < DARK_THRESHOLD) issues.push("Image is too dark. Move to a well-lit area.");
596
+ if (brightness > BRIGHT_THRESHOLD)
597
+ issues.push("Image is overexposed. Avoid direct light or glare shining onto the card.");
598
+ const mrzFound = browserFoundMrz || hasMrz(grey, w, h);
599
+ if (mrzFound && laplacianVariance(grey, w, h, 0, w) < BLUR_THRESHOLD) {
600
+ issues.push("The back of the card is too blurry. Hold the camera steady so the lines of <<< are sharp.");
601
+ }
602
+ if (!mrzFound && issues.length === 0) issues.push(BACK_MRZ_NOT_FOUND_MESSAGE);
603
+ return { ok: issues.length === 0, issues, hasMrz: mrzFound };
604
+ }
605
+ async function checkBackPhotoQuality(photo) {
606
+ if (photo.size < MIN_FILE_BYTES) {
607
+ return { ok: false, hasMrz: false, issues: ["File is too small. This does not appear to be a real photo."] };
608
+ }
609
+ if (!await hasSupportedMagicBytes(photo)) {
610
+ return { ok: false, hasMrz: false, issues: ["Unsupported file type. Please upload a JPEG or PNG photo."] };
611
+ }
612
+ return new Promise((resolve) => {
613
+ const img = new Image();
614
+ const url = URL.createObjectURL(photo);
615
+ img.onerror = () => {
616
+ URL.revokeObjectURL(url);
617
+ resolve({ ok: false, hasMrz: false, issues: ["Could not read the image. Please try a different photo."] });
618
+ };
619
+ img.onload = async () => {
620
+ URL.revokeObjectURL(url);
621
+ const scale = Math.min(1, 640 / Math.max(img.width, img.height));
622
+ const w = Math.round(img.width * scale);
623
+ const h = Math.round(img.height * scale);
624
+ const canvas = document.createElement("canvas");
625
+ canvas.width = w;
626
+ canvas.height = h;
627
+ const ctx = canvas.getContext("2d");
628
+ ctx.imageSmoothingEnabled = false;
629
+ ctx.drawImage(img, 0, 0, w, h);
630
+ const textPromise = detectText(canvas);
631
+ const { data } = ctx.getImageData(0, 0, w, h);
632
+ const grey = new Float32Array(w * h);
633
+ for (let i = 0; i < grey.length; i++) {
634
+ grey[i] = 0.299 * data[i * 4] + 0.587 * data[i * 4 + 1] + 0.114 * data[i * 4 + 2];
635
+ }
636
+ const texts = await textPromise;
637
+ const browserFoundMrz = texts?.some((t) => looksLikeMrzLine(t.rawValue ?? "")) ?? false;
638
+ resolve(checkBackOfCard(grey, w, h, browserFoundMrz));
639
+ };
640
+ img.src = url;
641
+ });
642
+ }
558
643
  async function checkPhotoQuality(photo, idType = "national_id") {
559
644
  const issues = [];
560
645
  if (photo.size < MIN_FILE_BYTES) {
561
- issues.push("File is too small \u2014 this does not appear to be a real photo.");
646
+ issues.push("File is too small. This does not appear to be a real photo.");
562
647
  return { ok: false, issues };
563
648
  }
564
649
  if (!await hasSupportedMagicBytes(photo)) {
565
- issues.push("Unsupported file type \u2014 please upload a JPEG or PNG photo.");
650
+ issues.push("Unsupported file type. Please upload a JPEG or PNG photo.");
566
651
  return { ok: false, issues };
567
652
  }
568
653
  return new Promise((resolve) => {
@@ -570,7 +655,7 @@ async function checkPhotoQuality(photo, idType = "national_id") {
570
655
  const url = URL.createObjectURL(photo);
571
656
  img.onerror = () => {
572
657
  URL.revokeObjectURL(url);
573
- resolve({ ok: false, issues: ["Could not read the image \u2014 please try a different photo."] });
658
+ resolve({ ok: false, issues: ["Could not read the image. Please try a different photo."] });
574
659
  };
575
660
  img.onload = async () => {
576
661
  URL.revokeObjectURL(url);
@@ -596,11 +681,11 @@ async function checkPhotoQuality(photo, idType = "national_id") {
596
681
  }
597
682
  const brightness = totalLum / len;
598
683
  if (idType !== "passport" && laplacianVariance(grey, w, h, 0, w) < BLUR_THRESHOLD)
599
- issues.push("Photo is too blurry \u2014 hold the camera steady and ensure the document is in sharp focus.");
684
+ issues.push("Photo is too blurry. Hold the camera steady and make sure the document is in sharp focus.");
600
685
  if (brightness < DARK_THRESHOLD)
601
- issues.push("Image is too dark \u2014 move to a well-lit area.");
686
+ issues.push("Image is too dark. Move to a well-lit area.");
602
687
  if (brightness > BRIGHT_THRESHOLD)
603
- issues.push("Image is overexposed \u2014 avoid direct light or glare shining onto the document.");
688
+ issues.push("Image is overexposed. Avoid direct light or glare shining onto the document.");
604
689
  const totalTextRows = countTextRows(grey, w, h, 0, w);
605
690
  const identityIssues = checkDocumentIdentity(grey, w, h, idType, totalTextRows);
606
691
  issues.push(...identityIssues);
@@ -672,13 +757,22 @@ function sdkHeaders(sdkToken, livenessFlows) {
672
757
  }
673
758
  async function startKYC(apiUrl, sdkToken, options, transport, signCapture2, livenessFlows) {
674
759
  if (!await hasSupportedMagicBytes(options.idPhoto)) {
675
- throw new ValidationError("Unsupported image format \u2014 only JPEG and PNG photos are accepted.");
760
+ throw new ValidationError("Unsupported image format. Only JPEG and PNG photos are accepted.");
761
+ }
762
+ if (options.idPhotoBack) {
763
+ if (options.idType === "passport") {
764
+ throw new ValidationError("idPhotoBack is only accepted for national IDs and driver's licences.");
765
+ }
766
+ if (!await hasSupportedMagicBytes(options.idPhotoBack)) {
767
+ throw new ValidationError("Unsupported image format for the back of the card. Only JPEG and PNG photos are accepted.");
768
+ }
676
769
  }
677
770
  const build = async () => {
678
771
  const form = new FormData();
679
772
  form.append("user_id", options.userId);
680
773
  form.append("id_type", options.idType);
681
774
  form.append("id_photo", options.idPhoto);
775
+ if (options.idPhotoBack) form.append("id_photo_back", options.idPhotoBack);
682
776
  form.append("consent_given_at", options.consentGivenAt.toISOString());
683
777
  form.append("processing_purpose", options.processingPurpose ?? "identity_verification");
684
778
  const signature = signCapture2 ? await signCapture2() : null;
@@ -690,6 +784,13 @@ async function startKYC(apiUrl, sdkToken, options, transport, signCapture2, live
690
784
  const res = await postIdempotent(fetchOf(transport), `${apiUrl}/v1/kyc/start`, build, transport?.retry);
691
785
  return handleResponse(res);
692
786
  }
787
+ async function getCaptureSettings(apiUrl, sdkToken, transport) {
788
+ const res = await fetchOf(transport)(`${apiUrl}/v1/kyc/capture-settings`, {
789
+ headers: { "Authorization": `Bearer ${sdkToken}` }
790
+ });
791
+ if (res.status === 404) return { document_back: "off" };
792
+ return handleResponse(res);
793
+ }
693
794
  async function getLivenessCredentials(apiUrl, sdkToken, transport) {
694
795
  const res = await fetchOf(transport)(`${apiUrl}/v1/kyc/liveness-credentials`, {
695
796
  headers: { "Authorization": `Bearer ${sdkToken}` }
@@ -829,22 +930,26 @@ async function registerDeviceKey(fetchImpl, apiUrl, token, pair) {
829
930
  });
830
931
  return res.device_key_id;
831
932
  }
832
- function captureMessage(imageSha256Hex, userId, nonce, ts) {
933
+ function captureMessage(imageSha256Hex, userId, nonce, ts, backImageSha256Hex) {
934
+ if (backImageSha256Hex !== void 0) {
935
+ return `attestid-capture-v2|${imageSha256Hex}|${backImageSha256Hex}|${userId}|${nonce}|${ts}`;
936
+ }
833
937
  return `attestid-capture-v1|${imageSha256Hex}|${userId}|${nonce}|${ts}`;
834
938
  }
835
- async function signCapture(fetchImpl, apiUrl, token, userId, photo) {
939
+ async function signCapture(fetchImpl, apiUrl, token, userId, photo, backPhoto) {
836
940
  try {
837
941
  if (!deviceBindingSupported()) return null;
838
942
  const pair = await getDeviceKeyPair();
839
943
  const deviceKeyId = await registerDeviceKey(fetchImpl, apiUrl, token, pair);
840
944
  const imageHash = bytesToHex(await sha256(new Uint8Array(await photo.arrayBuffer())));
945
+ const backHash = backPhoto ? bytesToHex(await sha256(new Uint8Array(await backPhoto.arrayBuffer()))) : void 0;
841
946
  const nonce = await challenge(fetchImpl, apiUrl, token);
842
947
  const ts = Math.floor(Date.now() / 1e3);
843
948
  const sig = new Uint8Array(
844
949
  await subtle().sign(
845
950
  { name: "ECDSA", hash: "SHA-256" },
846
951
  pair.privateKey,
847
- buf(utf8(captureMessage(imageHash, userId, nonce, ts)))
952
+ buf(utf8(captureMessage(imageHash, userId, nonce, ts, backHash)))
848
953
  )
849
954
  );
850
955
  return {
@@ -909,12 +1014,12 @@ function assertSecureApiUrl(apiUrl) {
909
1014
  try {
910
1015
  url = new URL(apiUrl);
911
1016
  } catch {
912
- throw new AttestIDError(`apiUrl must be a valid absolute URL \u2014 got "${apiUrl}".`, "INSECURE_CONFIG");
1017
+ throw new AttestIDError(`apiUrl must be a valid absolute URL, got "${apiUrl}".`, "INSECURE_CONFIG");
913
1018
  }
914
1019
  if (url.protocol === "https:") return;
915
1020
  if (url.protocol === "http:" && LOOPBACK_HOSTS.has(url.hostname)) return;
916
1021
  throw new AttestIDError(
917
- `apiUrl must use https:// \u2014 got "${apiUrl}". Sending it over plain http exposes your API key, SDK token, and uploaded ID photos to anyone on the network path. http://localhost (or 127.0.0.1/::1) is allowed for local development against a backend running without TLS.`,
1022
+ `apiUrl must use https://, got "${apiUrl}". Sending it over plain http exposes your API key, SDK token, and uploaded ID photos to anyone on the network path. http://localhost (or 127.0.0.1/::1) is allowed for local development against a backend running without TLS.`,
918
1023
  "INSECURE_CONFIG"
919
1024
  );
920
1025
  }
@@ -931,7 +1036,7 @@ var AttestID = class {
931
1036
  assertSecureApiUrl(config.apiUrl);
932
1037
  if (isServerConfig(config) && !isTrustedServerEnvironment()) {
933
1038
  throw new AttestIDError(
934
- "apiKey/accessKeyId must only be used in a trusted Node.js backend process \u2014 a bundler would ship this long-lived secret to every client, letting anyone extract it. In browser or mobile app code, configure `getSdkToken` instead: a callback that fetches an already-minted sdk_token from your own backend, which holds apiKey server-side and calls POST /kyc/session. See the integration guide for the backend-proxy pattern.",
1039
+ "apiKey/accessKeyId must only be used in a trusted Node.js backend process. A bundler would ship this long-lived secret to every client, letting anyone extract it. In browser or mobile app code, configure `getSdkToken` instead: a callback that fetches an already-minted sdk_token from your own backend, which holds apiKey server-side and calls POST /kyc/session. See the integration guide for the backend-proxy pattern.",
935
1040
  "INSECURE_CONFIG"
936
1041
  );
937
1042
  }
@@ -960,7 +1065,7 @@ var AttestID = class {
960
1065
  const minted = sessionPromise.then((session) => {
961
1066
  if (!session?.sdk_token) {
962
1067
  throw new AuthError(
963
- "getSdkToken() did not return a valid sdk_token \u2014 check that your backend's /api/attestid-session endpoint (or equivalent) returned a successful response."
1068
+ "getSdkToken() did not return a valid sdk_token. Check that your backend's /api/attestid-session endpoint (or equivalent) returned a successful response."
964
1069
  );
965
1070
  }
966
1071
  this.tokenExpiresAt = Date.now() + session.expires_in * 1e3;
@@ -990,16 +1095,25 @@ var AttestID = class {
990
1095
  async startVerification(options) {
991
1096
  if (!options.consentGivenAt) {
992
1097
  throw new AttestIDError(
993
- "consentGivenAt is required \u2014 capture the timestamp of an explicit user consent action before starting verification",
1098
+ "consentGivenAt is required: capture the timestamp of an explicit user consent action before starting verification",
994
1099
  "CONSENT_REQUIRED"
995
1100
  );
996
1101
  }
997
1102
  const token = await this.getSdkToken(options.userId);
998
1103
  const { apiUrl } = this.config;
999
1104
  const fetchImpl = this.transport.fetch ?? ((input, init) => fetch(input, init));
1000
- const signer = this.deviceBinding ? () => signCapture(fetchImpl, apiUrl, token, options.userId, options.idPhoto) : void 0;
1105
+ const signer = this.deviceBinding ? () => signCapture(fetchImpl, apiUrl, token, options.userId, options.idPhoto, options.idPhotoBack) : void 0;
1001
1106
  return startKYC(apiUrl, token, options, this.transport, signer, this.livenessFlows);
1002
1107
  }
1108
+ /**
1109
+ * The company's capture settings, set on the AttestID dashboard. Read
1110
+ * `document_back` before the document step to know whether to ask for the
1111
+ * back of a national ID / driver's licence (see DocumentBackPolicy).
1112
+ */
1113
+ async getCaptureSettings(userId) {
1114
+ const token = await this.getSdkToken(userId);
1115
+ return getCaptureSettings(this.config.apiUrl, token, this.transport);
1116
+ }
1003
1117
  /**
1004
1118
  * Fetch temporary AWS credentials and the Cognito identity pool ID from the
1005
1119
  * backend. Call this alongside startVerification() for the same userId to
@@ -2317,6 +2431,7 @@ function clearHandoffParamsFromAddressBar() {
2317
2431
  buildHandoffUrl,
2318
2432
  canRetryLiveness,
2319
2433
  canonicalJson,
2434
+ checkBackPhotoQuality,
2320
2435
  checkCaptureConditions,
2321
2436
  checkPhotoQuality,
2322
2437
  classifyConditions,