attestid_js_sdk 0.1.11 → 0.1.13

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,
@@ -167,6 +168,8 @@ function randomId() {
167
168
  // src/core/http.ts
168
169
  var RETRY_FRESH_STATUSES = /* @__PURE__ */ new Set([429, 500, 502, 503, 504]);
169
170
  var MAX_RETRY_AFTER_MS = 1e4;
171
+ var DEFAULT_IN_PROGRESS_WAIT_MS = 12e4;
172
+ var IN_PROGRESS_ERROR = "idempotency_request_in_progress";
170
173
  var defaultSleep = (ms) => new Promise((r) => setTimeout(r, ms));
171
174
  var sleep = defaultSleep;
172
175
  function retryAfterMs(res, fallback) {
@@ -174,9 +177,19 @@ function retryAfterMs(res, fallback) {
174
177
  const secs = raw ? Number(raw) : NaN;
175
178
  return Number.isFinite(secs) && secs >= 0 ? Math.min(secs * 1e3, MAX_RETRY_AFTER_MS) : fallback;
176
179
  }
180
+ async function isInProgress(res) {
181
+ try {
182
+ const body = await (res.clone ? res.clone() : res).json();
183
+ return (body?.detail?.error ?? body?.error) === IN_PROGRESS_ERROR;
184
+ } catch {
185
+ return false;
186
+ }
187
+ }
177
188
  async function postIdempotent(fetchImpl, url, build, options = {}) {
178
189
  const attempts = Math.max(1, options.attempts ?? 3);
179
190
  const base = options.baseDelayMs ?? 500;
191
+ const inProgressBudget = options.inProgressWaitMs ?? DEFAULT_IN_PROGRESS_WAIT_MS;
192
+ let inProgressWaited = 0;
180
193
  let key = randomId();
181
194
  let req = await build();
182
195
  for (let attempt = 1; ; attempt++) {
@@ -193,11 +206,17 @@ async function postIdempotent(fetchImpl, url, build, options = {}) {
193
206
  await sleep(base * 2 ** (attempt - 1));
194
207
  continue;
195
208
  }
196
- if (last || res.ok) return res;
209
+ if (res.ok) return res;
197
210
  if (res.status === 409) {
198
- await sleep(retryAfterMs(res, base));
211
+ if (!await isInProgress(res)) return res;
212
+ const delay = retryAfterMs(res, base);
213
+ if (inProgressWaited + delay > inProgressBudget) return res;
214
+ inProgressWaited += delay;
215
+ await sleep(delay);
216
+ attempt--;
199
217
  continue;
200
218
  }
219
+ if (last) return res;
201
220
  if (RETRY_FRESH_STATUSES.has(res.status)) {
202
221
  await sleep(retryAfterMs(res, base * 2 ** (attempt - 1)));
203
222
  key = randomId();
@@ -254,6 +273,8 @@ function countTextRows(grey, w, h, x0, aw) {
254
273
  return textRows;
255
274
  }
256
275
  var EDGE_DELTA = 28;
276
+ var EDGE_DELTA_MIN = 12;
277
+ var EDGE_DELTA_P99_FRACTION = 0.8;
257
278
  var NOISY_MEDIAN_STEP = 9;
258
279
  var SKEW_TANS = [0, 0.0175, -0.0175, 0.0349, -0.0349, 0.0524, -0.0524, 0.0699, -0.0699];
259
280
  var STRIPS = 8;
@@ -269,14 +290,15 @@ function idTextNotFoundMessage(idType) {
269
290
  const doc = idType === "drivers_licence" ? "driver's licence" : "ID card";
270
291
  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
292
  }
272
- function measureTextBands(grey, w, h, x0 = 0, aw = w) {
293
+ function measureTextBands(grey, w, h, x0 = 0, aw = w, edgeDeltaOverride) {
273
294
  const src = medianStep(grey, w, h, x0, aw) >= NOISY_MEDIAN_STEP ? boxSmooth(grey, w, h, x0, aw) : null;
274
295
  const at = src ? (y, x) => src[y * aw + x] : (y, x) => grey[y * w + x0 + x];
296
+ const edgeDelta = edgeDeltaOverride ?? edgeThreshold(at, aw, h);
275
297
  const ex = [];
276
298
  const ey = [];
277
299
  for (let y = 0; y < h; y++) {
278
300
  for (let x = 0; x < aw - 1; x++) {
279
- if (Math.abs(at(y, x + 1) - at(y, x)) > EDGE_DELTA) {
301
+ if (Math.abs(at(y, x + 1) - at(y, x)) > edgeDelta) {
280
302
  ex.push(x);
281
303
  ey.push(y);
282
304
  }
@@ -379,6 +401,7 @@ function measureTextBands(grey, w, h, x0 = 0, aw = w) {
379
401
  height,
380
402
  strip: s,
381
403
  firstCell: first,
404
+ lastCell: last,
382
405
  spanFrac: span / cps,
383
406
  coverage: active / span,
384
407
  density: inside / (span * CELL * height),
@@ -414,6 +437,25 @@ function medianStep(grey, w, h, x0, aw) {
414
437
  }
415
438
  return 255;
416
439
  }
440
+ function edgeThreshold(at, aw, h) {
441
+ const hist = new Uint32Array(256);
442
+ let n = 0;
443
+ for (let y = 0; y < h; y++) {
444
+ for (let x = 0; x < aw - 1; x++) {
445
+ hist[Math.min(255, Math.round(Math.abs(at(y, x + 1) - at(y, x))))]++;
446
+ n++;
447
+ }
448
+ }
449
+ let acc = 0, p99 = 255;
450
+ for (let v = 0; v < 256; v++) {
451
+ acc += hist[v];
452
+ if (acc >= n * 0.99) {
453
+ p99 = v;
454
+ break;
455
+ }
456
+ }
457
+ return Math.max(EDGE_DELTA_MIN, Math.min(EDGE_DELTA, Math.round(p99 * EDGE_DELTA_P99_FRACTION)));
458
+ }
417
459
  function boxSmooth(grey, w, h, x0, aw) {
418
460
  const out = new Float32Array(aw * h);
419
461
  for (let y = 0; y < h; y++) {
@@ -459,15 +501,17 @@ function periodicity(profile, glyphHeight) {
459
501
  }
460
502
  return best;
461
503
  }
462
- function analyseTextLayout(grey, w, h, x0 = 0, aw = w) {
463
- const { bands, fullWidthLines } = measureTextBands(grey, w, h, x0, aw);
504
+ function analyseTextLayout(grey, w, h, x0 = 0, aw = w, edgeDeltaOverride) {
505
+ const { bands, fullWidthLines } = measureTextBands(grey, w, h, x0, aw, edgeDeltaOverride);
464
506
  const stripCells = Math.ceil(Math.max(1, Math.floor(aw / STRIPS)) / CELL);
465
507
  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
508
  const textLines = clusterBands(clean).length;
467
509
  let alignedLines = 0;
468
510
  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);
511
+ const sameStrip = clean.filter((o) => o.strip === b.strip);
512
+ const left = sameStrip.filter((o) => Math.abs(o.firstCell - b.firstCell) <= 1).length;
513
+ const right = sameStrip.filter((o) => Math.abs(o.lastCell - b.lastCell) <= 1).length;
514
+ alignedLines = Math.max(alignedLines, left, right);
471
515
  }
472
516
  const isMrzLine = (l) => l.bleed <= MRZ_MAX_BLEED && l.periodicity >= MRZ_MIN_PERIODICITY;
473
517
  const stacked = (p, q) => {
@@ -475,19 +519,23 @@ function analyseTextLayout(grey, w, h, x0 = 0, aw = w) {
475
519
  const pitch = q.center - p.center;
476
520
  return pitch >= 1.3 * avgH && pitch <= 2.8 * avgH && Math.max(p.height, q.height) / Math.min(p.height, q.height) <= 1.4;
477
521
  };
478
- let hasMrz = false;
522
+ let hasMrz2 = false;
479
523
  let runStart = 0;
480
524
  for (let i = 1; i <= fullWidthLines.length; i++) {
481
525
  if (i < fullWidthLines.length && stacked(fullWidthLines[i - 1], fullWidthLines[i])) continue;
482
526
  const run = fullWidthLines.slice(runStart, i);
483
527
  if (run.length >= 2 && run.length <= 3) {
484
528
  for (let j = 1; j < run.length; j++) {
485
- if (isMrzLine(run[j - 1]) && isMrzLine(run[j])) hasMrz = true;
529
+ if (isMrzLine(run[j - 1]) && isMrzLine(run[j])) hasMrz2 = true;
486
530
  }
487
531
  }
488
532
  runStart = i;
489
533
  }
490
- return { textLines, alignedLines, hasMrz };
534
+ return { textLines, alignedLines, hasMrz: hasMrz2 };
535
+ }
536
+ var MRZ_RETRY_EDGE_DELTA = 14;
537
+ function hasMrz(grey, w, h, x0 = 0, aw = w) {
538
+ return analyseTextLayout(grey, w, h, x0, aw).hasMrz || analyseTextLayout(grey, w, h, x0, aw, MRZ_RETRY_EDGE_DELTA).hasMrz;
491
539
  }
492
540
  async function detectText(source) {
493
541
  const TextDetectorCtor = globalThis.TextDetector;
@@ -516,14 +564,14 @@ function laplacianVariance(grey, w, h, x0, aw) {
516
564
  function checkPassportDocument(grey, w, h, browserFoundMrz = false) {
517
565
  const ratio = w / h;
518
566
  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);
567
+ let dataPage = regions.find(({ x0, aw }) => hasMrz(grey, w, h, x0, aw));
520
568
  if (!dataPage && browserFoundMrz) {
521
569
  dataPage = regions.reduce((best, r) => countTextRows(grey, w, h, r.x0, r.aw) > countTextRows(grey, w, h, best.x0, best.aw) ? r : best);
522
570
  }
523
571
  if (!dataPage) return [MRZ_NOT_FOUND_MESSAGE];
524
572
  if (laplacianVariance(grey, w, h, dataPage.x0, dataPage.aw) < BLUR_THRESHOLD) {
525
573
  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."
574
+ "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
575
  ];
528
576
  }
529
577
  return [];
@@ -531,6 +579,7 @@ function checkPassportDocument(grey, w, h, browserFoundMrz = false) {
531
579
  function checkReadableText(grey, w, h, idType, browserTextBlocks = null) {
532
580
  if ((browserTextBlocks ?? 0) >= 3) return [];
533
581
  const layout = analyseTextLayout(grey, w, h);
582
+ if (layout.hasMrz) return [];
534
583
  if (layout.textLines >= MIN_ID_TEXT_LINES && layout.alignedLines >= MIN_ID_ALIGNED_LINES) return [];
535
584
  return [idTextNotFoundMessage(idType)];
536
585
  }
@@ -539,7 +588,7 @@ function checkDocumentIdentity(grey, w, h, idType, precomputedTotalTextRows) {
539
588
  const totalTextRows = precomputedTotalTextRows ?? countTextRows(grey, w, h, 0, w);
540
589
  if (totalTextRows < Math.max(2, h * 0.02)) {
541
590
  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."
591
+ "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
592
  ];
544
593
  }
545
594
  if (idType === "passport") {
@@ -555,14 +604,68 @@ function checkDocumentIdentity(grey, w, h, idType, precomputedTotalTextRows) {
555
604
  }
556
605
  return [];
557
606
  }
607
+ 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.";
608
+ function checkBackOfCard(grey, w, h, browserFoundMrz = false) {
609
+ let total = 0;
610
+ for (let i = 0; i < grey.length; i++) total += grey[i];
611
+ const brightness = total / grey.length;
612
+ const issues = [];
613
+ if (brightness < DARK_THRESHOLD) issues.push("Image is too dark. Move to a well-lit area.");
614
+ if (brightness > BRIGHT_THRESHOLD)
615
+ issues.push("Image is overexposed. Avoid direct light or glare shining onto the card.");
616
+ const mrzFound = browserFoundMrz || hasMrz(grey, w, h);
617
+ if (mrzFound && laplacianVariance(grey, w, h, 0, w) < BLUR_THRESHOLD) {
618
+ issues.push("The back of the card is too blurry. Hold the camera steady so the lines of <<< are sharp.");
619
+ }
620
+ if (!mrzFound && issues.length === 0) issues.push(BACK_MRZ_NOT_FOUND_MESSAGE);
621
+ return { ok: issues.length === 0, issues, hasMrz: mrzFound };
622
+ }
623
+ async function checkBackPhotoQuality(photo) {
624
+ if (photo.size < MIN_FILE_BYTES) {
625
+ return { ok: false, hasMrz: false, issues: ["File is too small. This does not appear to be a real photo."] };
626
+ }
627
+ if (!await hasSupportedMagicBytes(photo)) {
628
+ return { ok: false, hasMrz: false, issues: ["Unsupported file type. Please upload a JPEG or PNG photo."] };
629
+ }
630
+ return new Promise((resolve) => {
631
+ const img = new Image();
632
+ const url = URL.createObjectURL(photo);
633
+ img.onerror = () => {
634
+ URL.revokeObjectURL(url);
635
+ resolve({ ok: false, hasMrz: false, issues: ["Could not read the image. Please try a different photo."] });
636
+ };
637
+ img.onload = async () => {
638
+ URL.revokeObjectURL(url);
639
+ const scale = Math.min(1, 640 / Math.max(img.width, img.height));
640
+ const w = Math.round(img.width * scale);
641
+ const h = Math.round(img.height * scale);
642
+ const canvas = document.createElement("canvas");
643
+ canvas.width = w;
644
+ canvas.height = h;
645
+ const ctx = canvas.getContext("2d");
646
+ ctx.imageSmoothingEnabled = false;
647
+ ctx.drawImage(img, 0, 0, w, h);
648
+ const textPromise = detectText(canvas);
649
+ const { data } = ctx.getImageData(0, 0, w, h);
650
+ const grey = new Float32Array(w * h);
651
+ for (let i = 0; i < grey.length; i++) {
652
+ grey[i] = 0.299 * data[i * 4] + 0.587 * data[i * 4 + 1] + 0.114 * data[i * 4 + 2];
653
+ }
654
+ const texts = await textPromise;
655
+ const browserFoundMrz = texts?.some((t) => looksLikeMrzLine(t.rawValue ?? "")) ?? false;
656
+ resolve(checkBackOfCard(grey, w, h, browserFoundMrz));
657
+ };
658
+ img.src = url;
659
+ });
660
+ }
558
661
  async function checkPhotoQuality(photo, idType = "national_id") {
559
662
  const issues = [];
560
663
  if (photo.size < MIN_FILE_BYTES) {
561
- issues.push("File is too small \u2014 this does not appear to be a real photo.");
664
+ issues.push("File is too small. This does not appear to be a real photo.");
562
665
  return { ok: false, issues };
563
666
  }
564
667
  if (!await hasSupportedMagicBytes(photo)) {
565
- issues.push("Unsupported file type \u2014 please upload a JPEG or PNG photo.");
668
+ issues.push("Unsupported file type. Please upload a JPEG or PNG photo.");
566
669
  return { ok: false, issues };
567
670
  }
568
671
  return new Promise((resolve) => {
@@ -570,7 +673,7 @@ async function checkPhotoQuality(photo, idType = "national_id") {
570
673
  const url = URL.createObjectURL(photo);
571
674
  img.onerror = () => {
572
675
  URL.revokeObjectURL(url);
573
- resolve({ ok: false, issues: ["Could not read the image \u2014 please try a different photo."] });
676
+ resolve({ ok: false, issues: ["Could not read the image. Please try a different photo."] });
574
677
  };
575
678
  img.onload = async () => {
576
679
  URL.revokeObjectURL(url);
@@ -596,11 +699,11 @@ async function checkPhotoQuality(photo, idType = "national_id") {
596
699
  }
597
700
  const brightness = totalLum / len;
598
701
  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.");
702
+ issues.push("Photo is too blurry. Hold the camera steady and make sure the document is in sharp focus.");
600
703
  if (brightness < DARK_THRESHOLD)
601
- issues.push("Image is too dark \u2014 move to a well-lit area.");
704
+ issues.push("Image is too dark. Move to a well-lit area.");
602
705
  if (brightness > BRIGHT_THRESHOLD)
603
- issues.push("Image is overexposed \u2014 avoid direct light or glare shining onto the document.");
706
+ issues.push("Image is overexposed. Avoid direct light or glare shining onto the document.");
604
707
  const totalTextRows = countTextRows(grey, w, h, 0, w);
605
708
  const identityIssues = checkDocumentIdentity(grey, w, h, idType, totalTextRows);
606
709
  issues.push(...identityIssues);
@@ -632,12 +735,14 @@ var fetchOf = (t) => t?.fetch ?? ((input, init) => fetch(input, init));
632
735
  async function extractErrorMessage(res) {
633
736
  const text = await res.text();
634
737
  try {
635
- const detail = JSON.parse(text).detail;
738
+ const parsed = JSON.parse(text);
739
+ const detail = parsed.detail;
636
740
  if (typeof detail === "string") return detail;
637
741
  if (detail !== null && typeof detail === "object") {
638
742
  const message = detail.message;
639
743
  if (typeof message === "string") return message;
640
744
  }
745
+ if (detail === void 0 && typeof parsed.message === "string") return parsed.message;
641
746
  } catch {
642
747
  }
643
748
  return text;
@@ -672,13 +777,22 @@ function sdkHeaders(sdkToken, livenessFlows) {
672
777
  }
673
778
  async function startKYC(apiUrl, sdkToken, options, transport, signCapture2, livenessFlows) {
674
779
  if (!await hasSupportedMagicBytes(options.idPhoto)) {
675
- throw new ValidationError("Unsupported image format \u2014 only JPEG and PNG photos are accepted.");
780
+ throw new ValidationError("Unsupported image format. Only JPEG and PNG photos are accepted.");
781
+ }
782
+ if (options.idPhotoBack) {
783
+ if (options.idType === "passport") {
784
+ throw new ValidationError("idPhotoBack is only accepted for national IDs and driver's licences.");
785
+ }
786
+ if (!await hasSupportedMagicBytes(options.idPhotoBack)) {
787
+ throw new ValidationError("Unsupported image format for the back of the card. Only JPEG and PNG photos are accepted.");
788
+ }
676
789
  }
677
790
  const build = async () => {
678
791
  const form = new FormData();
679
792
  form.append("user_id", options.userId);
680
793
  form.append("id_type", options.idType);
681
794
  form.append("id_photo", options.idPhoto);
795
+ if (options.idPhotoBack) form.append("id_photo_back", options.idPhotoBack);
682
796
  form.append("consent_given_at", options.consentGivenAt.toISOString());
683
797
  form.append("processing_purpose", options.processingPurpose ?? "identity_verification");
684
798
  const signature = signCapture2 ? await signCapture2() : null;
@@ -690,6 +804,13 @@ async function startKYC(apiUrl, sdkToken, options, transport, signCapture2, live
690
804
  const res = await postIdempotent(fetchOf(transport), `${apiUrl}/v1/kyc/start`, build, transport?.retry);
691
805
  return handleResponse(res);
692
806
  }
807
+ async function getCaptureSettings(apiUrl, sdkToken, transport) {
808
+ const res = await fetchOf(transport)(`${apiUrl}/v1/kyc/capture-settings`, {
809
+ headers: { "Authorization": `Bearer ${sdkToken}` }
810
+ });
811
+ if (res.status === 404) return { document_back: "off" };
812
+ return handleResponse(res);
813
+ }
693
814
  async function getLivenessCredentials(apiUrl, sdkToken, transport) {
694
815
  const res = await fetchOf(transport)(`${apiUrl}/v1/kyc/liveness-credentials`, {
695
816
  headers: { "Authorization": `Bearer ${sdkToken}` }
@@ -829,22 +950,26 @@ async function registerDeviceKey(fetchImpl, apiUrl, token, pair) {
829
950
  });
830
951
  return res.device_key_id;
831
952
  }
832
- function captureMessage(imageSha256Hex, userId, nonce, ts) {
953
+ function captureMessage(imageSha256Hex, userId, nonce, ts, backImageSha256Hex) {
954
+ if (backImageSha256Hex !== void 0) {
955
+ return `attestid-capture-v2|${imageSha256Hex}|${backImageSha256Hex}|${userId}|${nonce}|${ts}`;
956
+ }
833
957
  return `attestid-capture-v1|${imageSha256Hex}|${userId}|${nonce}|${ts}`;
834
958
  }
835
- async function signCapture(fetchImpl, apiUrl, token, userId, photo) {
959
+ async function signCapture(fetchImpl, apiUrl, token, userId, photo, backPhoto) {
836
960
  try {
837
961
  if (!deviceBindingSupported()) return null;
838
962
  const pair = await getDeviceKeyPair();
839
963
  const deviceKeyId = await registerDeviceKey(fetchImpl, apiUrl, token, pair);
840
964
  const imageHash = bytesToHex(await sha256(new Uint8Array(await photo.arrayBuffer())));
965
+ const backHash = backPhoto ? bytesToHex(await sha256(new Uint8Array(await backPhoto.arrayBuffer()))) : void 0;
841
966
  const nonce = await challenge(fetchImpl, apiUrl, token);
842
967
  const ts = Math.floor(Date.now() / 1e3);
843
968
  const sig = new Uint8Array(
844
969
  await subtle().sign(
845
970
  { name: "ECDSA", hash: "SHA-256" },
846
971
  pair.privateKey,
847
- buf(utf8(captureMessage(imageHash, userId, nonce, ts)))
972
+ buf(utf8(captureMessage(imageHash, userId, nonce, ts, backHash)))
848
973
  )
849
974
  );
850
975
  return {
@@ -909,12 +1034,12 @@ function assertSecureApiUrl(apiUrl) {
909
1034
  try {
910
1035
  url = new URL(apiUrl);
911
1036
  } catch {
912
- throw new AttestIDError(`apiUrl must be a valid absolute URL \u2014 got "${apiUrl}".`, "INSECURE_CONFIG");
1037
+ throw new AttestIDError(`apiUrl must be a valid absolute URL, got "${apiUrl}".`, "INSECURE_CONFIG");
913
1038
  }
914
1039
  if (url.protocol === "https:") return;
915
1040
  if (url.protocol === "http:" && LOOPBACK_HOSTS.has(url.hostname)) return;
916
1041
  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.`,
1042
+ `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
1043
  "INSECURE_CONFIG"
919
1044
  );
920
1045
  }
@@ -931,7 +1056,7 @@ var AttestID = class {
931
1056
  assertSecureApiUrl(config.apiUrl);
932
1057
  if (isServerConfig(config) && !isTrustedServerEnvironment()) {
933
1058
  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.",
1059
+ "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
1060
  "INSECURE_CONFIG"
936
1061
  );
937
1062
  }
@@ -960,7 +1085,7 @@ var AttestID = class {
960
1085
  const minted = sessionPromise.then((session) => {
961
1086
  if (!session?.sdk_token) {
962
1087
  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."
1088
+ "getSdkToken() did not return a valid sdk_token. Check that your backend's /api/attestid-session endpoint (or equivalent) returned a successful response."
964
1089
  );
965
1090
  }
966
1091
  this.tokenExpiresAt = Date.now() + session.expires_in * 1e3;
@@ -990,16 +1115,25 @@ var AttestID = class {
990
1115
  async startVerification(options) {
991
1116
  if (!options.consentGivenAt) {
992
1117
  throw new AttestIDError(
993
- "consentGivenAt is required \u2014 capture the timestamp of an explicit user consent action before starting verification",
1118
+ "consentGivenAt is required: capture the timestamp of an explicit user consent action before starting verification",
994
1119
  "CONSENT_REQUIRED"
995
1120
  );
996
1121
  }
997
1122
  const token = await this.getSdkToken(options.userId);
998
1123
  const { apiUrl } = this.config;
999
1124
  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;
1125
+ const signer = this.deviceBinding ? () => signCapture(fetchImpl, apiUrl, token, options.userId, options.idPhoto, options.idPhotoBack) : void 0;
1001
1126
  return startKYC(apiUrl, token, options, this.transport, signer, this.livenessFlows);
1002
1127
  }
1128
+ /**
1129
+ * The company's capture settings, set on the AttestID dashboard. Read
1130
+ * `document_back` before the document step to know whether to ask for the
1131
+ * back of a national ID / driver's licence (see DocumentBackPolicy).
1132
+ */
1133
+ async getCaptureSettings(userId) {
1134
+ const token = await this.getSdkToken(userId);
1135
+ return getCaptureSettings(this.config.apiUrl, token, this.transport);
1136
+ }
1003
1137
  /**
1004
1138
  * Fetch temporary AWS credentials and the Cognito identity pool ID from the
1005
1139
  * backend. Call this alongside startVerification() for the same userId to
@@ -2317,6 +2451,7 @@ function clearHandoffParamsFromAddressBar() {
2317
2451
  buildHandoffUrl,
2318
2452
  canRetryLiveness,
2319
2453
  canonicalJson,
2454
+ checkBackPhotoQuality,
2320
2455
  checkCaptureConditions,
2321
2456
  checkPhotoQuality,
2322
2457
  classifyConditions,