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.
@@ -4,6 +4,11 @@ interface RetryOptions {
4
4
  attempts?: number;
5
5
  /** Base backoff in ms (doubled each retry). Default 500. */
6
6
  baseDelayMs?: number;
7
+ /**
8
+ * How long to keep waiting while the server says the same request is still
9
+ * in progress (409 idempotency_request_in_progress). Default 120 000 ms.
10
+ */
11
+ inProgressWaitMs?: number;
7
12
  }
8
13
 
9
14
  type IdType = 'national_id' | 'passport' | 'drivers_licence';
@@ -74,10 +79,35 @@ interface LivenessCredentials {
74
79
  expiration: string;
75
80
  region: string;
76
81
  }
82
+ /**
83
+ * Whether to ask for the back of a national ID / driver's licence. Each
84
+ * company decides on the AttestID dashboard (Settings):
85
+ * - 'off' — don't ask; a back sent anyway is ignored by the API.
86
+ * - 'optional' — ask, and let the user skip it.
87
+ * - 'required' — the back must be sent and its MRZ must be readable,
88
+ * otherwise POST /kyc/start fails with
89
+ * document_back_required / document_back_unreadable.
90
+ * Never applies to passports.
91
+ */
92
+ type DocumentBackPolicy = 'off' | 'optional' | 'required';
93
+ /** What the document capture should ask for, as set by the company. */
94
+ interface CaptureSettings {
95
+ document_back: DocumentBackPolicy;
96
+ }
77
97
  interface StartKYCOptions {
78
98
  userId: string;
79
99
  idType: IdType;
80
100
  idPhoto: File | Blob;
101
+ /**
102
+ * Optional photo of the BACK of a national ID / driver's licence. Many
103
+ * cards (ICAO 9303 TD1) print their machine-readable zone (MRZ) on the back;
104
+ * when this photo carries one, the API cross-checks its date of birth and
105
+ * expiry against the front and rejects a mismatch (document_sides_mismatch).
106
+ * A back with no MRZ is ignored, never a failure. Not accepted with
107
+ * idType 'passport'. Use checkBackPhotoQuality() to tell the user whether
108
+ * an MRZ was found before sending it.
109
+ */
110
+ idPhotoBack?: File | Blob;
81
111
  /**
82
112
  * Stated purpose for biometric processing. Defaults to 'identity_verification'.
83
113
  * One of: 'identity_verification', 'kyc_onboarding', 'kyc_refresh',
@@ -260,6 +290,12 @@ declare class AttestID {
260
290
  * TypeScript's type check are still rejected here at runtime.
261
291
  */
262
292
  startVerification(options: StartKYCOptions): Promise<StartKYCResponse>;
293
+ /**
294
+ * The company's capture settings, set on the AttestID dashboard. Read
295
+ * `document_back` before the document step to know whether to ask for the
296
+ * back of a national ID / driver's licence (see DocumentBackPolicy).
297
+ */
298
+ getCaptureSettings(userId: string): Promise<CaptureSettings>;
263
299
  /**
264
300
  * Fetch temporary AWS credentials and the Cognito identity pool ID from the
265
301
  * backend. Call this alongside startVerification() for the same userId to
@@ -502,6 +538,19 @@ interface PhotoQualityResult {
502
538
  ok: boolean;
503
539
  issues: string[];
504
540
  }
541
+ interface BackPhotoQualityResult extends PhotoQualityResult {
542
+ /** True when an MRZ (the lines of letters, numbers and <<<) was found. */
543
+ hasMrz: boolean;
544
+ }
545
+ /**
546
+ * Checks a photo of the BACK of an ID card / driver's licence (the optional
547
+ * StartKYCOptions.idPhotoBack). The back is only useful to the API when it
548
+ * carries an ICAO 9303 MRZ, so `ok` means "an MRZ was found and is legible
549
+ * enough to send". When `hasMrz` is false the caller should offer a retake
550
+ * or let the user skip the back: many cards have no MRZ at all, and the
551
+ * verification never needs one.
552
+ */
553
+ declare function checkBackPhotoQuality(photo: Blob): Promise<BackPhotoQualityResult>;
505
554
  /**
506
555
  * Analyses a photo for blur, exposure, and (where the browser supports it)
507
556
  * face presence before it is submitted to the AttestID API. Returns a list
@@ -582,4 +631,4 @@ declare function currentUrlWithoutHandoffParams(): string;
582
631
  /** Strips our own handoff params from the visible address bar, without a page reload. Call once, after reading them. */
583
632
  declare function clearHandoffParamsFromAddressBar(): void;
584
633
 
585
- export { AttestID, type AttestIDConfig, type AttestIDConfigBrowser, type AttestIDConfigServer, AttestIDError, type AttestationClaims, AuthError, CAPTURE_ISSUE_PROMPTS, CameraError, type CaptureAssurance, type CaptureConditions, type CaptureIssue, type ChecksInfo, DOC_ASPECT, type DocumentInfo, FLASH_CSS, HANDOFF_PARAM_KEYS, type HandoffParams, type IdType, KYCError, type KYCResult, LIVENESS_PROMPTS, type LivenessAction, type LivenessChallenge, type LivenessCredentials, type LivenessProvider, type LivenessRecording, type LivenessSegment, type LivenessVideoResult, MAX_GOOD_BRIGHTNESS, MIN_GOOD_BRIGHTNESS, MIN_GOOD_FPS, type PhotoQualityResult, type QrErrorCorrection, RECORDING_TAIL_MS, RETRYABLE_LIVENESS_REASONS, type RecordLivenessOptions, type SdkSessionResponse, SignatureError, type StartKYCOptions, type StartKYCResponse, type SubmitLivenessVideoOptions, ValidationError, type VerifyAttestationOptions, type VerifyWebhookOptions, type WaitForLivenessOptions, buildHandoffUrl, canRetryLiveness, canonicalJson, checkCaptureConditions, checkPhotoQuality, classifyConditions, clearHandoffParamsFromAddressBar, createSdkSession, currentUrlWithoutHandoffParams, faceMatchOutcome, generateHandoffToken, getQrMatrix, isMobileDevice, livenessCaptureSupported, meanLuma, openCameraSession, openFrontCamera, pickRecorderMimeType, promptFor, rawSessionId, readHandoffParams, recordLiveness, scriptDurationMs, segmentAt, verifyAttestation, verifyWebhook };
634
+ export { AttestID, type AttestIDConfig, type AttestIDConfigBrowser, type AttestIDConfigServer, AttestIDError, type AttestationClaims, AuthError, type BackPhotoQualityResult, CAPTURE_ISSUE_PROMPTS, CameraError, type CaptureAssurance, type CaptureConditions, type CaptureIssue, type CaptureSettings, type ChecksInfo, DOC_ASPECT, type DocumentBackPolicy, type DocumentInfo, FLASH_CSS, HANDOFF_PARAM_KEYS, type HandoffParams, type IdType, KYCError, type KYCResult, LIVENESS_PROMPTS, type LivenessAction, type LivenessChallenge, type LivenessCredentials, type LivenessProvider, type LivenessRecording, type LivenessSegment, type LivenessVideoResult, MAX_GOOD_BRIGHTNESS, MIN_GOOD_BRIGHTNESS, MIN_GOOD_FPS, type PhotoQualityResult, type QrErrorCorrection, RECORDING_TAIL_MS, RETRYABLE_LIVENESS_REASONS, type RecordLivenessOptions, type SdkSessionResponse, SignatureError, type StartKYCOptions, type StartKYCResponse, type SubmitLivenessVideoOptions, ValidationError, type VerifyAttestationOptions, type VerifyWebhookOptions, type WaitForLivenessOptions, buildHandoffUrl, canRetryLiveness, canonicalJson, checkBackPhotoQuality, checkCaptureConditions, checkPhotoQuality, classifyConditions, clearHandoffParamsFromAddressBar, createSdkSession, currentUrlWithoutHandoffParams, faceMatchOutcome, generateHandoffToken, getQrMatrix, isMobileDevice, livenessCaptureSupported, meanLuma, openCameraSession, openFrontCamera, pickRecorderMimeType, promptFor, rawSessionId, readHandoffParams, recordLiveness, scriptDurationMs, segmentAt, verifyAttestation, verifyWebhook };
@@ -4,6 +4,11 @@ interface RetryOptions {
4
4
  attempts?: number;
5
5
  /** Base backoff in ms (doubled each retry). Default 500. */
6
6
  baseDelayMs?: number;
7
+ /**
8
+ * How long to keep waiting while the server says the same request is still
9
+ * in progress (409 idempotency_request_in_progress). Default 120 000 ms.
10
+ */
11
+ inProgressWaitMs?: number;
7
12
  }
8
13
 
9
14
  type IdType = 'national_id' | 'passport' | 'drivers_licence';
@@ -74,10 +79,35 @@ interface LivenessCredentials {
74
79
  expiration: string;
75
80
  region: string;
76
81
  }
82
+ /**
83
+ * Whether to ask for the back of a national ID / driver's licence. Each
84
+ * company decides on the AttestID dashboard (Settings):
85
+ * - 'off' — don't ask; a back sent anyway is ignored by the API.
86
+ * - 'optional' — ask, and let the user skip it.
87
+ * - 'required' — the back must be sent and its MRZ must be readable,
88
+ * otherwise POST /kyc/start fails with
89
+ * document_back_required / document_back_unreadable.
90
+ * Never applies to passports.
91
+ */
92
+ type DocumentBackPolicy = 'off' | 'optional' | 'required';
93
+ /** What the document capture should ask for, as set by the company. */
94
+ interface CaptureSettings {
95
+ document_back: DocumentBackPolicy;
96
+ }
77
97
  interface StartKYCOptions {
78
98
  userId: string;
79
99
  idType: IdType;
80
100
  idPhoto: File | Blob;
101
+ /**
102
+ * Optional photo of the BACK of a national ID / driver's licence. Many
103
+ * cards (ICAO 9303 TD1) print their machine-readable zone (MRZ) on the back;
104
+ * when this photo carries one, the API cross-checks its date of birth and
105
+ * expiry against the front and rejects a mismatch (document_sides_mismatch).
106
+ * A back with no MRZ is ignored, never a failure. Not accepted with
107
+ * idType 'passport'. Use checkBackPhotoQuality() to tell the user whether
108
+ * an MRZ was found before sending it.
109
+ */
110
+ idPhotoBack?: File | Blob;
81
111
  /**
82
112
  * Stated purpose for biometric processing. Defaults to 'identity_verification'.
83
113
  * One of: 'identity_verification', 'kyc_onboarding', 'kyc_refresh',
@@ -260,6 +290,12 @@ declare class AttestID {
260
290
  * TypeScript's type check are still rejected here at runtime.
261
291
  */
262
292
  startVerification(options: StartKYCOptions): Promise<StartKYCResponse>;
293
+ /**
294
+ * The company's capture settings, set on the AttestID dashboard. Read
295
+ * `document_back` before the document step to know whether to ask for the
296
+ * back of a national ID / driver's licence (see DocumentBackPolicy).
297
+ */
298
+ getCaptureSettings(userId: string): Promise<CaptureSettings>;
263
299
  /**
264
300
  * Fetch temporary AWS credentials and the Cognito identity pool ID from the
265
301
  * backend. Call this alongside startVerification() for the same userId to
@@ -502,6 +538,19 @@ interface PhotoQualityResult {
502
538
  ok: boolean;
503
539
  issues: string[];
504
540
  }
541
+ interface BackPhotoQualityResult extends PhotoQualityResult {
542
+ /** True when an MRZ (the lines of letters, numbers and <<<) was found. */
543
+ hasMrz: boolean;
544
+ }
545
+ /**
546
+ * Checks a photo of the BACK of an ID card / driver's licence (the optional
547
+ * StartKYCOptions.idPhotoBack). The back is only useful to the API when it
548
+ * carries an ICAO 9303 MRZ, so `ok` means "an MRZ was found and is legible
549
+ * enough to send". When `hasMrz` is false the caller should offer a retake
550
+ * or let the user skip the back: many cards have no MRZ at all, and the
551
+ * verification never needs one.
552
+ */
553
+ declare function checkBackPhotoQuality(photo: Blob): Promise<BackPhotoQualityResult>;
505
554
  /**
506
555
  * Analyses a photo for blur, exposure, and (where the browser supports it)
507
556
  * face presence before it is submitted to the AttestID API. Returns a list
@@ -582,4 +631,4 @@ declare function currentUrlWithoutHandoffParams(): string;
582
631
  /** Strips our own handoff params from the visible address bar, without a page reload. Call once, after reading them. */
583
632
  declare function clearHandoffParamsFromAddressBar(): void;
584
633
 
585
- export { AttestID, type AttestIDConfig, type AttestIDConfigBrowser, type AttestIDConfigServer, AttestIDError, type AttestationClaims, AuthError, CAPTURE_ISSUE_PROMPTS, CameraError, type CaptureAssurance, type CaptureConditions, type CaptureIssue, type ChecksInfo, DOC_ASPECT, type DocumentInfo, FLASH_CSS, HANDOFF_PARAM_KEYS, type HandoffParams, type IdType, KYCError, type KYCResult, LIVENESS_PROMPTS, type LivenessAction, type LivenessChallenge, type LivenessCredentials, type LivenessProvider, type LivenessRecording, type LivenessSegment, type LivenessVideoResult, MAX_GOOD_BRIGHTNESS, MIN_GOOD_BRIGHTNESS, MIN_GOOD_FPS, type PhotoQualityResult, type QrErrorCorrection, RECORDING_TAIL_MS, RETRYABLE_LIVENESS_REASONS, type RecordLivenessOptions, type SdkSessionResponse, SignatureError, type StartKYCOptions, type StartKYCResponse, type SubmitLivenessVideoOptions, ValidationError, type VerifyAttestationOptions, type VerifyWebhookOptions, type WaitForLivenessOptions, buildHandoffUrl, canRetryLiveness, canonicalJson, checkCaptureConditions, checkPhotoQuality, classifyConditions, clearHandoffParamsFromAddressBar, createSdkSession, currentUrlWithoutHandoffParams, faceMatchOutcome, generateHandoffToken, getQrMatrix, isMobileDevice, livenessCaptureSupported, meanLuma, openCameraSession, openFrontCamera, pickRecorderMimeType, promptFor, rawSessionId, readHandoffParams, recordLiveness, scriptDurationMs, segmentAt, verifyAttestation, verifyWebhook };
634
+ export { AttestID, type AttestIDConfig, type AttestIDConfigBrowser, type AttestIDConfigServer, AttestIDError, type AttestationClaims, AuthError, type BackPhotoQualityResult, CAPTURE_ISSUE_PROMPTS, CameraError, type CaptureAssurance, type CaptureConditions, type CaptureIssue, type CaptureSettings, type ChecksInfo, DOC_ASPECT, type DocumentBackPolicy, type DocumentInfo, FLASH_CSS, HANDOFF_PARAM_KEYS, type HandoffParams, type IdType, KYCError, type KYCResult, LIVENESS_PROMPTS, type LivenessAction, type LivenessChallenge, type LivenessCredentials, type LivenessProvider, type LivenessRecording, type LivenessSegment, type LivenessVideoResult, MAX_GOOD_BRIGHTNESS, MIN_GOOD_BRIGHTNESS, MIN_GOOD_FPS, type PhotoQualityResult, type QrErrorCorrection, RECORDING_TAIL_MS, RETRYABLE_LIVENESS_REASONS, type RecordLivenessOptions, type SdkSessionResponse, SignatureError, type StartKYCOptions, type StartKYCResponse, type SubmitLivenessVideoOptions, ValidationError, type VerifyAttestationOptions, type VerifyWebhookOptions, type WaitForLivenessOptions, buildHandoffUrl, canRetryLiveness, canonicalJson, checkBackPhotoQuality, checkCaptureConditions, checkPhotoQuality, classifyConditions, clearHandoffParamsFromAddressBar, createSdkSession, currentUrlWithoutHandoffParams, faceMatchOutcome, generateHandoffToken, getQrMatrix, isMobileDevice, livenessCaptureSupported, meanLuma, openCameraSession, openFrontCamera, pickRecorderMimeType, promptFor, rawSessionId, readHandoffParams, recordLiveness, scriptDurationMs, segmentAt, verifyAttestation, verifyWebhook };
@@ -99,6 +99,8 @@ function randomId() {
99
99
  // src/core/http.ts
100
100
  var RETRY_FRESH_STATUSES = /* @__PURE__ */ new Set([429, 500, 502, 503, 504]);
101
101
  var MAX_RETRY_AFTER_MS = 1e4;
102
+ var DEFAULT_IN_PROGRESS_WAIT_MS = 12e4;
103
+ var IN_PROGRESS_ERROR = "idempotency_request_in_progress";
102
104
  var defaultSleep = (ms) => new Promise((r) => setTimeout(r, ms));
103
105
  var sleep = defaultSleep;
104
106
  function retryAfterMs(res, fallback) {
@@ -106,9 +108,19 @@ function retryAfterMs(res, fallback) {
106
108
  const secs = raw ? Number(raw) : NaN;
107
109
  return Number.isFinite(secs) && secs >= 0 ? Math.min(secs * 1e3, MAX_RETRY_AFTER_MS) : fallback;
108
110
  }
111
+ async function isInProgress(res) {
112
+ try {
113
+ const body = await (res.clone ? res.clone() : res).json();
114
+ return (body?.detail?.error ?? body?.error) === IN_PROGRESS_ERROR;
115
+ } catch {
116
+ return false;
117
+ }
118
+ }
109
119
  async function postIdempotent(fetchImpl, url, build, options = {}) {
110
120
  const attempts = Math.max(1, options.attempts ?? 3);
111
121
  const base = options.baseDelayMs ?? 500;
122
+ const inProgressBudget = options.inProgressWaitMs ?? DEFAULT_IN_PROGRESS_WAIT_MS;
123
+ let inProgressWaited = 0;
112
124
  let key = randomId();
113
125
  let req = await build();
114
126
  for (let attempt = 1; ; attempt++) {
@@ -125,11 +137,17 @@ async function postIdempotent(fetchImpl, url, build, options = {}) {
125
137
  await sleep(base * 2 ** (attempt - 1));
126
138
  continue;
127
139
  }
128
- if (last || res.ok) return res;
140
+ if (res.ok) return res;
129
141
  if (res.status === 409) {
130
- await sleep(retryAfterMs(res, base));
142
+ if (!await isInProgress(res)) return res;
143
+ const delay = retryAfterMs(res, base);
144
+ if (inProgressWaited + delay > inProgressBudget) return res;
145
+ inProgressWaited += delay;
146
+ await sleep(delay);
147
+ attempt--;
131
148
  continue;
132
149
  }
150
+ if (last) return res;
133
151
  if (RETRY_FRESH_STATUSES.has(res.status)) {
134
152
  await sleep(retryAfterMs(res, base * 2 ** (attempt - 1)));
135
153
  key = randomId();
@@ -186,6 +204,8 @@ function countTextRows(grey, w, h, x0, aw) {
186
204
  return textRows;
187
205
  }
188
206
  var EDGE_DELTA = 28;
207
+ var EDGE_DELTA_MIN = 12;
208
+ var EDGE_DELTA_P99_FRACTION = 0.8;
189
209
  var NOISY_MEDIAN_STEP = 9;
190
210
  var SKEW_TANS = [0, 0.0175, -0.0175, 0.0349, -0.0349, 0.0524, -0.0524, 0.0699, -0.0699];
191
211
  var STRIPS = 8;
@@ -201,14 +221,15 @@ function idTextNotFoundMessage(idType) {
201
221
  const doc = idType === "drivers_licence" ? "driver's licence" : "ID card";
202
222
  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.`;
203
223
  }
204
- function measureTextBands(grey, w, h, x0 = 0, aw = w) {
224
+ function measureTextBands(grey, w, h, x0 = 0, aw = w, edgeDeltaOverride) {
205
225
  const src = medianStep(grey, w, h, x0, aw) >= NOISY_MEDIAN_STEP ? boxSmooth(grey, w, h, x0, aw) : null;
206
226
  const at = src ? (y, x) => src[y * aw + x] : (y, x) => grey[y * w + x0 + x];
227
+ const edgeDelta = edgeDeltaOverride ?? edgeThreshold(at, aw, h);
207
228
  const ex = [];
208
229
  const ey = [];
209
230
  for (let y = 0; y < h; y++) {
210
231
  for (let x = 0; x < aw - 1; x++) {
211
- if (Math.abs(at(y, x + 1) - at(y, x)) > EDGE_DELTA) {
232
+ if (Math.abs(at(y, x + 1) - at(y, x)) > edgeDelta) {
212
233
  ex.push(x);
213
234
  ey.push(y);
214
235
  }
@@ -311,6 +332,7 @@ function measureTextBands(grey, w, h, x0 = 0, aw = w) {
311
332
  height,
312
333
  strip: s,
313
334
  firstCell: first,
335
+ lastCell: last,
314
336
  spanFrac: span / cps,
315
337
  coverage: active / span,
316
338
  density: inside / (span * CELL * height),
@@ -346,6 +368,25 @@ function medianStep(grey, w, h, x0, aw) {
346
368
  }
347
369
  return 255;
348
370
  }
371
+ function edgeThreshold(at, aw, h) {
372
+ const hist = new Uint32Array(256);
373
+ let n = 0;
374
+ for (let y = 0; y < h; y++) {
375
+ for (let x = 0; x < aw - 1; x++) {
376
+ hist[Math.min(255, Math.round(Math.abs(at(y, x + 1) - at(y, x))))]++;
377
+ n++;
378
+ }
379
+ }
380
+ let acc = 0, p99 = 255;
381
+ for (let v = 0; v < 256; v++) {
382
+ acc += hist[v];
383
+ if (acc >= n * 0.99) {
384
+ p99 = v;
385
+ break;
386
+ }
387
+ }
388
+ return Math.max(EDGE_DELTA_MIN, Math.min(EDGE_DELTA, Math.round(p99 * EDGE_DELTA_P99_FRACTION)));
389
+ }
349
390
  function boxSmooth(grey, w, h, x0, aw) {
350
391
  const out = new Float32Array(aw * h);
351
392
  for (let y = 0; y < h; y++) {
@@ -391,15 +432,17 @@ function periodicity(profile, glyphHeight) {
391
432
  }
392
433
  return best;
393
434
  }
394
- function analyseTextLayout(grey, w, h, x0 = 0, aw = w) {
395
- const { bands, fullWidthLines } = measureTextBands(grey, w, h, x0, aw);
435
+ function analyseTextLayout(grey, w, h, x0 = 0, aw = w, edgeDeltaOverride) {
436
+ const { bands, fullWidthLines } = measureTextBands(grey, w, h, x0, aw, edgeDeltaOverride);
396
437
  const stripCells = Math.ceil(Math.max(1, Math.floor(aw / STRIPS)) / CELL);
397
438
  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);
398
439
  const textLines = clusterBands(clean).length;
399
440
  let alignedLines = 0;
400
441
  for (const b of clean) {
401
- const n = clean.filter((o) => o.strip === b.strip && Math.abs(o.firstCell - b.firstCell) <= 1).length;
402
- alignedLines = Math.max(alignedLines, n);
442
+ const sameStrip = clean.filter((o) => o.strip === b.strip);
443
+ const left = sameStrip.filter((o) => Math.abs(o.firstCell - b.firstCell) <= 1).length;
444
+ const right = sameStrip.filter((o) => Math.abs(o.lastCell - b.lastCell) <= 1).length;
445
+ alignedLines = Math.max(alignedLines, left, right);
403
446
  }
404
447
  const isMrzLine = (l) => l.bleed <= MRZ_MAX_BLEED && l.periodicity >= MRZ_MIN_PERIODICITY;
405
448
  const stacked = (p, q) => {
@@ -407,19 +450,23 @@ function analyseTextLayout(grey, w, h, x0 = 0, aw = w) {
407
450
  const pitch = q.center - p.center;
408
451
  return pitch >= 1.3 * avgH && pitch <= 2.8 * avgH && Math.max(p.height, q.height) / Math.min(p.height, q.height) <= 1.4;
409
452
  };
410
- let hasMrz = false;
453
+ let hasMrz2 = false;
411
454
  let runStart = 0;
412
455
  for (let i = 1; i <= fullWidthLines.length; i++) {
413
456
  if (i < fullWidthLines.length && stacked(fullWidthLines[i - 1], fullWidthLines[i])) continue;
414
457
  const run = fullWidthLines.slice(runStart, i);
415
458
  if (run.length >= 2 && run.length <= 3) {
416
459
  for (let j = 1; j < run.length; j++) {
417
- if (isMrzLine(run[j - 1]) && isMrzLine(run[j])) hasMrz = true;
460
+ if (isMrzLine(run[j - 1]) && isMrzLine(run[j])) hasMrz2 = true;
418
461
  }
419
462
  }
420
463
  runStart = i;
421
464
  }
422
- return { textLines, alignedLines, hasMrz };
465
+ return { textLines, alignedLines, hasMrz: hasMrz2 };
466
+ }
467
+ var MRZ_RETRY_EDGE_DELTA = 14;
468
+ function hasMrz(grey, w, h, x0 = 0, aw = w) {
469
+ return analyseTextLayout(grey, w, h, x0, aw).hasMrz || analyseTextLayout(grey, w, h, x0, aw, MRZ_RETRY_EDGE_DELTA).hasMrz;
423
470
  }
424
471
  async function detectText(source) {
425
472
  const TextDetectorCtor = globalThis.TextDetector;
@@ -448,14 +495,14 @@ function laplacianVariance(grey, w, h, x0, aw) {
448
495
  function checkPassportDocument(grey, w, h, browserFoundMrz = false) {
449
496
  const ratio = w / h;
450
497
  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 }];
451
- let dataPage = regions.find(({ x0, aw }) => analyseTextLayout(grey, w, h, x0, aw).hasMrz);
498
+ let dataPage = regions.find(({ x0, aw }) => hasMrz(grey, w, h, x0, aw));
452
499
  if (!dataPage && browserFoundMrz) {
453
500
  dataPage = regions.reduce((best, r) => countTextRows(grey, w, h, r.x0, r.aw) > countTextRows(grey, w, h, best.x0, best.aw) ? r : best);
454
501
  }
455
502
  if (!dataPage) return [MRZ_NOT_FOUND_MESSAGE];
456
503
  if (laplacianVariance(grey, w, h, dataPage.x0, dataPage.aw) < BLUR_THRESHOLD) {
457
504
  return [
458
- "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."
505
+ "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."
459
506
  ];
460
507
  }
461
508
  return [];
@@ -463,6 +510,7 @@ function checkPassportDocument(grey, w, h, browserFoundMrz = false) {
463
510
  function checkReadableText(grey, w, h, idType, browserTextBlocks = null) {
464
511
  if ((browserTextBlocks ?? 0) >= 3) return [];
465
512
  const layout = analyseTextLayout(grey, w, h);
513
+ if (layout.hasMrz) return [];
466
514
  if (layout.textLines >= MIN_ID_TEXT_LINES && layout.alignedLines >= MIN_ID_ALIGNED_LINES) return [];
467
515
  return [idTextNotFoundMessage(idType)];
468
516
  }
@@ -471,7 +519,7 @@ function checkDocumentIdentity(grey, w, h, idType, precomputedTotalTextRows) {
471
519
  const totalTextRows = precomputedTotalTextRows ?? countTextRows(grey, w, h, 0, w);
472
520
  if (totalTextRows < Math.max(2, h * 0.02)) {
473
521
  return [
474
- "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."
522
+ "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."
475
523
  ];
476
524
  }
477
525
  if (idType === "passport") {
@@ -487,14 +535,68 @@ function checkDocumentIdentity(grey, w, h, idType, precomputedTotalTextRows) {
487
535
  }
488
536
  return [];
489
537
  }
538
+ 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.";
539
+ function checkBackOfCard(grey, w, h, browserFoundMrz = false) {
540
+ let total = 0;
541
+ for (let i = 0; i < grey.length; i++) total += grey[i];
542
+ const brightness = total / grey.length;
543
+ const issues = [];
544
+ if (brightness < DARK_THRESHOLD) issues.push("Image is too dark. Move to a well-lit area.");
545
+ if (brightness > BRIGHT_THRESHOLD)
546
+ issues.push("Image is overexposed. Avoid direct light or glare shining onto the card.");
547
+ const mrzFound = browserFoundMrz || hasMrz(grey, w, h);
548
+ if (mrzFound && laplacianVariance(grey, w, h, 0, w) < BLUR_THRESHOLD) {
549
+ issues.push("The back of the card is too blurry. Hold the camera steady so the lines of <<< are sharp.");
550
+ }
551
+ if (!mrzFound && issues.length === 0) issues.push(BACK_MRZ_NOT_FOUND_MESSAGE);
552
+ return { ok: issues.length === 0, issues, hasMrz: mrzFound };
553
+ }
554
+ async function checkBackPhotoQuality(photo) {
555
+ if (photo.size < MIN_FILE_BYTES) {
556
+ return { ok: false, hasMrz: false, issues: ["File is too small. This does not appear to be a real photo."] };
557
+ }
558
+ if (!await hasSupportedMagicBytes(photo)) {
559
+ return { ok: false, hasMrz: false, issues: ["Unsupported file type. Please upload a JPEG or PNG photo."] };
560
+ }
561
+ return new Promise((resolve) => {
562
+ const img = new Image();
563
+ const url = URL.createObjectURL(photo);
564
+ img.onerror = () => {
565
+ URL.revokeObjectURL(url);
566
+ resolve({ ok: false, hasMrz: false, issues: ["Could not read the image. Please try a different photo."] });
567
+ };
568
+ img.onload = async () => {
569
+ URL.revokeObjectURL(url);
570
+ const scale = Math.min(1, 640 / Math.max(img.width, img.height));
571
+ const w = Math.round(img.width * scale);
572
+ const h = Math.round(img.height * scale);
573
+ const canvas = document.createElement("canvas");
574
+ canvas.width = w;
575
+ canvas.height = h;
576
+ const ctx = canvas.getContext("2d");
577
+ ctx.imageSmoothingEnabled = false;
578
+ ctx.drawImage(img, 0, 0, w, h);
579
+ const textPromise = detectText(canvas);
580
+ const { data } = ctx.getImageData(0, 0, w, h);
581
+ const grey = new Float32Array(w * h);
582
+ for (let i = 0; i < grey.length; i++) {
583
+ grey[i] = 0.299 * data[i * 4] + 0.587 * data[i * 4 + 1] + 0.114 * data[i * 4 + 2];
584
+ }
585
+ const texts = await textPromise;
586
+ const browserFoundMrz = texts?.some((t) => looksLikeMrzLine(t.rawValue ?? "")) ?? false;
587
+ resolve(checkBackOfCard(grey, w, h, browserFoundMrz));
588
+ };
589
+ img.src = url;
590
+ });
591
+ }
490
592
  async function checkPhotoQuality(photo, idType = "national_id") {
491
593
  const issues = [];
492
594
  if (photo.size < MIN_FILE_BYTES) {
493
- issues.push("File is too small \u2014 this does not appear to be a real photo.");
595
+ issues.push("File is too small. This does not appear to be a real photo.");
494
596
  return { ok: false, issues };
495
597
  }
496
598
  if (!await hasSupportedMagicBytes(photo)) {
497
- issues.push("Unsupported file type \u2014 please upload a JPEG or PNG photo.");
599
+ issues.push("Unsupported file type. Please upload a JPEG or PNG photo.");
498
600
  return { ok: false, issues };
499
601
  }
500
602
  return new Promise((resolve) => {
@@ -502,7 +604,7 @@ async function checkPhotoQuality(photo, idType = "national_id") {
502
604
  const url = URL.createObjectURL(photo);
503
605
  img.onerror = () => {
504
606
  URL.revokeObjectURL(url);
505
- resolve({ ok: false, issues: ["Could not read the image \u2014 please try a different photo."] });
607
+ resolve({ ok: false, issues: ["Could not read the image. Please try a different photo."] });
506
608
  };
507
609
  img.onload = async () => {
508
610
  URL.revokeObjectURL(url);
@@ -528,11 +630,11 @@ async function checkPhotoQuality(photo, idType = "national_id") {
528
630
  }
529
631
  const brightness = totalLum / len;
530
632
  if (idType !== "passport" && laplacianVariance(grey, w, h, 0, w) < BLUR_THRESHOLD)
531
- issues.push("Photo is too blurry \u2014 hold the camera steady and ensure the document is in sharp focus.");
633
+ issues.push("Photo is too blurry. Hold the camera steady and make sure the document is in sharp focus.");
532
634
  if (brightness < DARK_THRESHOLD)
533
- issues.push("Image is too dark \u2014 move to a well-lit area.");
635
+ issues.push("Image is too dark. Move to a well-lit area.");
534
636
  if (brightness > BRIGHT_THRESHOLD)
535
- issues.push("Image is overexposed \u2014 avoid direct light or glare shining onto the document.");
637
+ issues.push("Image is overexposed. Avoid direct light or glare shining onto the document.");
536
638
  const totalTextRows = countTextRows(grey, w, h, 0, w);
537
639
  const identityIssues = checkDocumentIdentity(grey, w, h, idType, totalTextRows);
538
640
  issues.push(...identityIssues);
@@ -564,12 +666,14 @@ var fetchOf = (t) => t?.fetch ?? ((input, init) => fetch(input, init));
564
666
  async function extractErrorMessage(res) {
565
667
  const text = await res.text();
566
668
  try {
567
- const detail = JSON.parse(text).detail;
669
+ const parsed = JSON.parse(text);
670
+ const detail = parsed.detail;
568
671
  if (typeof detail === "string") return detail;
569
672
  if (detail !== null && typeof detail === "object") {
570
673
  const message = detail.message;
571
674
  if (typeof message === "string") return message;
572
675
  }
676
+ if (detail === void 0 && typeof parsed.message === "string") return parsed.message;
573
677
  } catch {
574
678
  }
575
679
  return text;
@@ -604,13 +708,22 @@ function sdkHeaders(sdkToken, livenessFlows) {
604
708
  }
605
709
  async function startKYC(apiUrl, sdkToken, options, transport, signCapture2, livenessFlows) {
606
710
  if (!await hasSupportedMagicBytes(options.idPhoto)) {
607
- throw new ValidationError("Unsupported image format \u2014 only JPEG and PNG photos are accepted.");
711
+ throw new ValidationError("Unsupported image format. Only JPEG and PNG photos are accepted.");
712
+ }
713
+ if (options.idPhotoBack) {
714
+ if (options.idType === "passport") {
715
+ throw new ValidationError("idPhotoBack is only accepted for national IDs and driver's licences.");
716
+ }
717
+ if (!await hasSupportedMagicBytes(options.idPhotoBack)) {
718
+ throw new ValidationError("Unsupported image format for the back of the card. Only JPEG and PNG photos are accepted.");
719
+ }
608
720
  }
609
721
  const build = async () => {
610
722
  const form = new FormData();
611
723
  form.append("user_id", options.userId);
612
724
  form.append("id_type", options.idType);
613
725
  form.append("id_photo", options.idPhoto);
726
+ if (options.idPhotoBack) form.append("id_photo_back", options.idPhotoBack);
614
727
  form.append("consent_given_at", options.consentGivenAt.toISOString());
615
728
  form.append("processing_purpose", options.processingPurpose ?? "identity_verification");
616
729
  const signature = signCapture2 ? await signCapture2() : null;
@@ -622,6 +735,13 @@ async function startKYC(apiUrl, sdkToken, options, transport, signCapture2, live
622
735
  const res = await postIdempotent(fetchOf(transport), `${apiUrl}/v1/kyc/start`, build, transport?.retry);
623
736
  return handleResponse(res);
624
737
  }
738
+ async function getCaptureSettings(apiUrl, sdkToken, transport) {
739
+ const res = await fetchOf(transport)(`${apiUrl}/v1/kyc/capture-settings`, {
740
+ headers: { "Authorization": `Bearer ${sdkToken}` }
741
+ });
742
+ if (res.status === 404) return { document_back: "off" };
743
+ return handleResponse(res);
744
+ }
625
745
  async function getLivenessCredentials(apiUrl, sdkToken, transport) {
626
746
  const res = await fetchOf(transport)(`${apiUrl}/v1/kyc/liveness-credentials`, {
627
747
  headers: { "Authorization": `Bearer ${sdkToken}` }
@@ -761,22 +881,26 @@ async function registerDeviceKey(fetchImpl, apiUrl, token, pair) {
761
881
  });
762
882
  return res.device_key_id;
763
883
  }
764
- function captureMessage(imageSha256Hex, userId, nonce, ts) {
884
+ function captureMessage(imageSha256Hex, userId, nonce, ts, backImageSha256Hex) {
885
+ if (backImageSha256Hex !== void 0) {
886
+ return `attestid-capture-v2|${imageSha256Hex}|${backImageSha256Hex}|${userId}|${nonce}|${ts}`;
887
+ }
765
888
  return `attestid-capture-v1|${imageSha256Hex}|${userId}|${nonce}|${ts}`;
766
889
  }
767
- async function signCapture(fetchImpl, apiUrl, token, userId, photo) {
890
+ async function signCapture(fetchImpl, apiUrl, token, userId, photo, backPhoto) {
768
891
  try {
769
892
  if (!deviceBindingSupported()) return null;
770
893
  const pair = await getDeviceKeyPair();
771
894
  const deviceKeyId = await registerDeviceKey(fetchImpl, apiUrl, token, pair);
772
895
  const imageHash = bytesToHex(await sha256(new Uint8Array(await photo.arrayBuffer())));
896
+ const backHash = backPhoto ? bytesToHex(await sha256(new Uint8Array(await backPhoto.arrayBuffer()))) : void 0;
773
897
  const nonce = await challenge(fetchImpl, apiUrl, token);
774
898
  const ts = Math.floor(Date.now() / 1e3);
775
899
  const sig = new Uint8Array(
776
900
  await subtle().sign(
777
901
  { name: "ECDSA", hash: "SHA-256" },
778
902
  pair.privateKey,
779
- buf(utf8(captureMessage(imageHash, userId, nonce, ts)))
903
+ buf(utf8(captureMessage(imageHash, userId, nonce, ts, backHash)))
780
904
  )
781
905
  );
782
906
  return {
@@ -841,12 +965,12 @@ function assertSecureApiUrl(apiUrl) {
841
965
  try {
842
966
  url = new URL(apiUrl);
843
967
  } catch {
844
- throw new AttestIDError(`apiUrl must be a valid absolute URL \u2014 got "${apiUrl}".`, "INSECURE_CONFIG");
968
+ throw new AttestIDError(`apiUrl must be a valid absolute URL, got "${apiUrl}".`, "INSECURE_CONFIG");
845
969
  }
846
970
  if (url.protocol === "https:") return;
847
971
  if (url.protocol === "http:" && LOOPBACK_HOSTS.has(url.hostname)) return;
848
972
  throw new AttestIDError(
849
- `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.`,
973
+ `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.`,
850
974
  "INSECURE_CONFIG"
851
975
  );
852
976
  }
@@ -863,7 +987,7 @@ var AttestID = class {
863
987
  assertSecureApiUrl(config.apiUrl);
864
988
  if (isServerConfig(config) && !isTrustedServerEnvironment()) {
865
989
  throw new AttestIDError(
866
- "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.",
990
+ "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.",
867
991
  "INSECURE_CONFIG"
868
992
  );
869
993
  }
@@ -892,7 +1016,7 @@ var AttestID = class {
892
1016
  const minted = sessionPromise.then((session) => {
893
1017
  if (!session?.sdk_token) {
894
1018
  throw new AuthError(
895
- "getSdkToken() did not return a valid sdk_token \u2014 check that your backend's /api/attestid-session endpoint (or equivalent) returned a successful response."
1019
+ "getSdkToken() did not return a valid sdk_token. Check that your backend's /api/attestid-session endpoint (or equivalent) returned a successful response."
896
1020
  );
897
1021
  }
898
1022
  this.tokenExpiresAt = Date.now() + session.expires_in * 1e3;
@@ -922,16 +1046,25 @@ var AttestID = class {
922
1046
  async startVerification(options) {
923
1047
  if (!options.consentGivenAt) {
924
1048
  throw new AttestIDError(
925
- "consentGivenAt is required \u2014 capture the timestamp of an explicit user consent action before starting verification",
1049
+ "consentGivenAt is required: capture the timestamp of an explicit user consent action before starting verification",
926
1050
  "CONSENT_REQUIRED"
927
1051
  );
928
1052
  }
929
1053
  const token = await this.getSdkToken(options.userId);
930
1054
  const { apiUrl } = this.config;
931
1055
  const fetchImpl = this.transport.fetch ?? ((input, init) => fetch(input, init));
932
- const signer = this.deviceBinding ? () => signCapture(fetchImpl, apiUrl, token, options.userId, options.idPhoto) : void 0;
1056
+ const signer = this.deviceBinding ? () => signCapture(fetchImpl, apiUrl, token, options.userId, options.idPhoto, options.idPhotoBack) : void 0;
933
1057
  return startKYC(apiUrl, token, options, this.transport, signer, this.livenessFlows);
934
1058
  }
1059
+ /**
1060
+ * The company's capture settings, set on the AttestID dashboard. Read
1061
+ * `document_back` before the document step to know whether to ask for the
1062
+ * back of a national ID / driver's licence (see DocumentBackPolicy).
1063
+ */
1064
+ async getCaptureSettings(userId) {
1065
+ const token = await this.getSdkToken(userId);
1066
+ return getCaptureSettings(this.config.apiUrl, token, this.transport);
1067
+ }
935
1068
  /**
936
1069
  * Fetch temporary AWS credentials and the Cognito identity pool ID from the
937
1070
  * backend. Call this alongside startVerification() for the same userId to
@@ -2248,6 +2381,7 @@ export {
2248
2381
  buildHandoffUrl,
2249
2382
  canRetryLiveness,
2250
2383
  canonicalJson,
2384
+ checkBackPhotoQuality,
2251
2385
  checkCaptureConditions,
2252
2386
  checkPhotoQuality,
2253
2387
  classifyConditions,