verant_id_cloud_scan 1.4.5-beta.10 → 1.4.5-beta.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/Api.js CHANGED
@@ -1,4 +1,4 @@
1
- import { compressAndCompareImages } from "./FaceComparison.js";
1
+ import { compressAndCompareImages } from "./FaceApi.js";
2
2
  import { formatDateToISO, DATE_FIELDS } from "./DateUtils.js";
3
3
  import { FIELD_MAPPING, FIELD_DEFAULTS, getFieldValue } from "./FieldMapping.js";
4
4
  import { sanitizeFieldForDmv } from "./FormatUtils.js";
@@ -62,47 +62,61 @@ export function setEnvironment(environment) {
62
62
  }
63
63
 
64
64
  /**
65
- * Get license server address based on current environment
65
+ * Per-service host by environment.
66
+ */
67
+ const HOSTS = {
68
+ staging: {
69
+ license: "https://lic-staging.verantid.com/api/v1",
70
+ dmv: "https://dmv-check-server-staging-fcaab48bec21.herokuapp.com",
71
+ face: "https://staging.face.verantid.bluerocket.us",
72
+ mobile: "https://staging.mdl.verantid.bluerocket.us",
73
+ },
74
+ production: {
75
+ license: "https://lic.verantid.com/api/v1",
76
+ dmv: "https://dmv.verantid.com",
77
+ face: "https://faceid.verantid.com",
78
+ mobile: "https://mdl.verantid.bluerocket.us",
79
+ },
80
+ };
81
+
82
+ /**
83
+ * Resolve a service's host for the current environment.
84
+ * @param {'license' | 'dmv' | 'face' | 'mobile'} service
66
85
  * @returns {string}
67
86
  */
87
+ function host(service) {
88
+ return HOSTS[getEnvironment()][service];
89
+ }
90
+
68
91
  function getLicenseServerAddress() {
69
- const env = getEnvironment();
70
- return env === 'staging'
71
- ? "https://lic-staging.verantid.com/api/v1"
72
- : "https://lic.verantid.com/api/v1";
92
+ return host("license");
73
93
  }
74
94
 
75
95
  /**
76
- * Get DMV server address based on current environment
77
- * @returns {string}
96
+ * Still on /api/v1 (SOAP transport), which does NOT carry
97
+ * the personDigitalImage match. The field already forwards in the driver object,
98
+ * but the "License Portrait" match indicator will only round-trip once this path
99
+ * is switched to /api/v2/dmv_check (REST 3.0). That endpoint is request/response
100
+ * compatible — change the path below — but it must NOT be flipped until VNT-1463
101
+ * is deployed to these hosts, or all DMV checks will 404.
78
102
  */
79
103
  function getDmvServerAddress() {
80
- const env = getEnvironment();
81
- return env === 'staging'
82
- ? "https://dmv-check-server-staging-fcaab48bec21.herokuapp.com/api/v1/dmv_check"
83
- : "https://dmv.verantid.com/api/v1/dmv_check";
104
+ return `${host("dmv")}/api/v1/dmv_check`;
84
105
  }
85
106
 
86
- /**
87
- * Get Face Comparison server address based on current environment
88
- * @returns {string}
89
- */
90
107
  export function getFaceComparisonServerAddress() {
91
- const env = getEnvironment();
92
- return env === 'staging'
93
- ? "https://staging.face.verantid.bluerocket.us/compare_id_and_face"
94
- : "https://faceid.verantid.com/compare_id_and_face";
108
+ return `${host("face")}/compare_id_and_face`;
95
109
  }
96
110
 
97
111
  /**
98
- * Get Mobile Verification server address based on current environment
99
- * @returns {string}
112
+ * License Portrait extraction
100
113
  */
114
+ export function getExtractPortraitServerAddress() {
115
+ return `${host("face")}/extract_portrait`;
116
+ }
117
+
101
118
  function getMobileVerificationServerAddress() {
102
- const env = getEnvironment();
103
- return env === 'staging'
104
- ? "https://staging.mdl.verantid.bluerocket.us"
105
- : "https://mdl.verantid.bluerocket.us";
119
+ return host("mobile");
106
120
  }
107
121
 
108
122
  export const dmvCheck = async (clientId, scannerType, licenseData) => {
@@ -264,9 +278,26 @@ export const checkFaceComparisonFeatureLicense = async (clientId) => {
264
278
  }
265
279
  }
266
280
 
267
- export const checkLicense = async (clientId, macAddress) => {
281
+ // Pass exactly one of macAddress / serialNumber. The license server rejects
282
+ // requests that supply both or neither; we also fail fast here so callers
283
+ // don't have to wait for a 400 to learn they passed a bad identifier.
284
+ export const checkLicense = async (clientId, identifier = {}) => {
285
+ if (identifier === null || typeof identifier !== "object") {
286
+ throw new TypeError(
287
+ `checkLicense: second argument must be an object like { macAddress } or { serialNumber }, got ${typeof identifier}`,
288
+ );
289
+ }
290
+ const { macAddress, serialNumber } = identifier;
291
+ if (Boolean(macAddress) === Boolean(serialNumber)) {
292
+ throw new Error(
293
+ "checkLicense: pass exactly one of macAddress / serialNumber",
294
+ );
295
+ }
296
+
268
297
  const url = getLicenseServerAddress() + "/check_license";
269
- const data = { client_id: clientId, mac_address: macAddress };
298
+ const data = { client_id: clientId };
299
+ if (macAddress) data.mac_address = macAddress;
300
+ if (serialNumber) data.serial_number = serialNumber;
270
301
 
271
302
  try {
272
303
  const response = await fetch(url, {
@@ -346,48 +377,148 @@ export const getBarcodeLicenseKey = async () => {
346
377
  }
347
378
  };
348
379
 
349
- // Helper function for making POST requests to the scanner API
350
- const postToScannerApi = async (
380
+ /**
381
+ * Build the /securelink URL for the given scanner address.
382
+ *
383
+ * Mirrors the SDK's historical behaviour: if the caller didn't include a
384
+ * scheme, default to http://. The scheme test is case-insensitive per
385
+ * RFC 3986 (VNT-1522) — `HTTP://`, `HTTPS://`, and mixed-case forms are
386
+ * recognised so the SDK doesn't double-prefix them with another `http://`.
387
+ */
388
+ export const buildSecurelinkUrl = (scannerAddress) => {
389
+ const hasScheme = /^https?:\/\//i.test(scannerAddress);
390
+ const withScheme = hasScheme ? scannerAddress : `http://${scannerAddress}`;
391
+ return `${withScheme.replace(/\/+$/, "")}/securelink`;
392
+ };
393
+
394
+ /**
395
+ * Lower-level POST that returns a structured result instead of throwing.
396
+ * Used by `checkScannerPresentDetailed` (VNT-1522) so the caller can
397
+ * distinguish network failures, HTTP errors, parse failures, and scanner
398
+ * application-layer errors. Existing callers go through `postToScannerApi`
399
+ * which preserves the throw-on-error contract.
400
+ *
401
+ * Supports an external `AbortSignal` so host apps can cancel an in-flight
402
+ * probe on retry / unmount without waiting for the internal timeout.
403
+ *
404
+ * @param {string} scannerAddress
405
+ * @param {object} commandObj
406
+ * @param {number} [timeout=10000] timeout in ms before the internal AbortController aborts
407
+ * @param {AbortSignal} [externalSignal] optional external signal; when aborted, the request is cancelled
408
+ * @returns {Promise<{
409
+ * threw: boolean,
410
+ * error?: Error,
411
+ * ok?: boolean,
412
+ * status?: number,
413
+ * statusText?: string,
414
+ * body?: unknown,
415
+ * attemptedUrl: string,
416
+ * }>}
417
+ */
418
+ const postToScannerApiDetailed = async (
351
419
  scannerAddress,
352
420
  commandObj,
353
- timeout = 10000
421
+ timeout = 10000,
422
+ externalSignal,
354
423
  ) => {
355
- // Ensure the scanner address has a protocol (http:// or https://)
356
- let formattedAddress = scannerAddress;
357
- if (!scannerAddress.startsWith('http://') && !scannerAddress.startsWith('https://')) {
358
- formattedAddress = `http://${scannerAddress}`;
359
- }
360
-
361
- // Remove trailing slash to prevent double slashes in URL
362
- formattedAddress = formattedAddress.replace(/\/+$/, '');
363
-
364
- let apiAddress = `${formattedAddress}/securelink`;
424
+ const attemptedUrl = buildSecurelinkUrl(scannerAddress);
365
425
 
366
426
  const controller = new AbortController();
367
- const signal = controller.signal;
368
-
369
- const fetchTimeout = setTimeout(() => {
370
- controller.abort();
371
- }, timeout);
427
+ const fetchTimeout = setTimeout(() => controller.abort(), timeout);
428
+
429
+ // Forward an external abort signal to our controller so the host app's
430
+ // cancellation (retry, unmount) propagates to the in-flight fetch.
431
+ let externalAbortHandler;
432
+ if (externalSignal) {
433
+ if (externalSignal.aborted) {
434
+ controller.abort();
435
+ } else {
436
+ externalAbortHandler = () => controller.abort();
437
+ externalSignal.addEventListener("abort", externalAbortHandler, { once: true });
438
+ }
439
+ }
372
440
 
373
441
  try {
374
- const response = await fetch(apiAddress, {
442
+ const response = await fetch(attemptedUrl, {
375
443
  method: "POST",
376
444
  headers: { "Content-Type": "application/json" },
377
445
  body: JSON.stringify(commandObj),
378
- signal: signal,
446
+ signal: controller.signal,
379
447
  });
380
448
 
381
- clearTimeout(fetchTimeout);
449
+ // Read the body once as text, then try to JSON-parse — keeps a malformed
450
+ // body from making us discard otherwise-useful status info.
451
+ let body = null;
452
+ try {
453
+ const text = await response.text();
454
+ if (text) {
455
+ try {
456
+ body = JSON.parse(text);
457
+ } catch {
458
+ body = text; // not JSON, but still meaningful for diagnostics
459
+ }
460
+ }
461
+ } catch {
462
+ // body read failed — leave body=null, caller can decide
463
+ }
382
464
 
383
- if (!response.ok) throw new Error(response.statusText);
384
- return response.json();
465
+ return {
466
+ threw: false,
467
+ ok: response.ok,
468
+ status: response.status,
469
+ statusText: response.statusText,
470
+ body,
471
+ attemptedUrl,
472
+ };
385
473
  } catch (error) {
386
- if (error.name === "AbortError") {
474
+ return { threw: true, error, attemptedUrl };
475
+ } finally {
476
+ clearTimeout(fetchTimeout);
477
+ if (externalSignal && externalAbortHandler) {
478
+ externalSignal.removeEventListener("abort", externalAbortHandler);
479
+ }
480
+ }
481
+ };
482
+
483
+ // Helper function for making POST requests to the scanner API. Thin wrapper
484
+ // around `postToScannerApiDetailed` that preserves the historical contract
485
+ // (throws on timeout / network / non-2xx; returns parsed JSON on success).
486
+ const postToScannerApi = async (
487
+ scannerAddress,
488
+ commandObj,
489
+ timeout = 10000
490
+ ) => {
491
+ const result = await postToScannerApiDetailed(
492
+ scannerAddress,
493
+ commandObj,
494
+ timeout,
495
+ );
496
+
497
+ if (result.threw) {
498
+ if (result.error?.name === "AbortError") {
387
499
  throw new Error("Request timed out");
388
500
  }
389
- throw error;
501
+ throw result.error;
502
+ }
503
+
504
+ if (!result.ok) {
505
+ throw new Error(result.statusText || `HTTP ${result.status}`);
506
+ }
507
+
508
+ // Match the original `response.json()` contract: throw if the 2xx body
509
+ // wasn't a parseable JSON object. The detailed wrapper deliberately
510
+ // preserves non-JSON bodies as strings (or `null` for empty) so error
511
+ // surfaces like `checkScannerPresentDetailed` can use them — but every
512
+ // existing caller of `postToScannerApi` (`getNetworkInfo`,
513
+ // `establishConnection`, `getParams`, `resetParams`, `setParameters`,
514
+ // `scanDocument`, …) reads object properties off the return value, so
515
+ // returning a string or null here would silently produce `undefined`
516
+ // downstream instead of failing loudly.
517
+ if (result.body === null || typeof result.body !== "object") {
518
+ throw new SyntaxError("Scanner response was not a JSON object");
390
519
  }
520
+
521
+ return result.body;
391
522
  };
392
523
 
393
524
  export const getNetworkInfo = (scannerAddress, commandObj) =>
@@ -414,42 +545,174 @@ export const stopScanning = (scannerAddress, commandObj) =>
414
545
  export const closeConnection = (scannerAddress, commandObj) =>
415
546
  postToScannerApi(scannerAddress, commandObj);
416
547
 
417
- // Check if scanner is present by attempting to connect and disconnect
418
- export const checkScannerPresent = async (scannerAddress) => {
419
- try {
420
- // Generate a unique message ID for this check
421
- const messageId = new Date().toISOString();
422
-
423
- // Attempt to establish connection
424
- const connectCommand = {
425
- Command: "Connect",
426
- MessageId: messageId,
427
- Exclusive: "false", // Use non-exclusive mode for just checking
428
- IdleTimeout: "5",
548
+ /**
549
+ * Classify a thrown fetch error into one of the documented codes (VNT-1522).
550
+ *
551
+ * `externallyAborted` distinguishes a host-app cancellation (retry, unmount)
552
+ * from an internal timeout — both surface as `AbortError` from fetch, but
553
+ * callers branching on `code` for UI copy (e.g. "scanner timed out") should
554
+ * not see the timeout label when the user themselves cancelled.
555
+ *
556
+ * @param {unknown} error
557
+ * @param {string} attemptedUrl
558
+ * @param {boolean} [externallyAborted=false]
559
+ * @returns {{ code: string, name: string, message: string, attemptedUrl: string }}
560
+ */
561
+ const classifyFetchError = (error, attemptedUrl, externallyAborted = false) => {
562
+ if (error?.name === "AbortError") {
563
+ return {
564
+ code: externallyAborted ? "ABORTED" : "TIMEOUT",
565
+ name: "AbortError",
566
+ message: externallyAborted ? "Request aborted" : "Request timed out",
567
+ attemptedUrl,
429
568
  };
569
+ }
570
+ if (error instanceof TypeError) {
571
+ // Browser fetch surfaces network failures (DNS, connection refused,
572
+ // mixed-content blocks, CORS preflight rejection) as `TypeError`.
573
+ return {
574
+ code: "NETWORK_ERROR",
575
+ name: "TypeError",
576
+ message: error.message || "Network error",
577
+ attemptedUrl,
578
+ };
579
+ }
580
+ return {
581
+ code: "NETWORK_ERROR",
582
+ name: error?.name || "Unknown",
583
+ message: error?.message || String(error),
584
+ attemptedUrl,
585
+ };
586
+ };
430
587
 
431
- const connectResponse = await postToScannerApi(scannerAddress, connectCommand, 5000);
588
+ /**
589
+ * Structured version of `checkScannerPresent` (VNT-1522).
590
+ *
591
+ * Returns `{ ok: true }` on success or `{ ok: false, error }` with a tagged
592
+ * `error.code` so host apps can distinguish:
593
+ * - `TIMEOUT` — internal 5s timeout fired
594
+ * - `ABORTED` — host app cancelled via the external `AbortSignal`
595
+ * - `NETWORK_ERROR` — fetch threw (DNS, refused, mixed content, CORS, etc.)
596
+ * - `HTTP_ERROR` — server replied non-2xx
597
+ * - `INVALID_RESPONSE` — 2xx but the body wasn't JSON
598
+ * - `SCANNER_ERROR` — 2xx JSON but no `SessionId` (the scanner returned
599
+ * an application-layer error in the body, e.g. an HTTPS-only scanner
600
+ * rejecting an HTTP probe with `{HttpRC:"403", ExtInformation:"..."}`)
601
+ *
602
+ * The error object also carries `attemptedUrl`, `status` (when a response
603
+ * was received), and the raw `scannerResponse` body for SCANNER_ERROR so
604
+ * callers can surface vendor-specific fields like `ExtInformation`.
605
+ *
606
+ * @param {string} scannerAddress
607
+ * @param {{ signal?: AbortSignal }} [options]
608
+ */
609
+ export const checkScannerPresentDetailed = async (
610
+ scannerAddress,
611
+ options = {},
612
+ ) => {
613
+ const { signal } = options;
614
+ const attemptedUrl = buildSecurelinkUrl(scannerAddress);
615
+
616
+ const connectCommand = {
617
+ Command: "Connect",
618
+ MessageId: new Date().toISOString(),
619
+ Exclusive: "false", // non-exclusive — we're only probing
620
+ IdleTimeout: "5",
621
+ };
432
622
 
433
- // If connection failed or no session ID, scanner not present
434
- if (!connectResponse || !connectResponse.SessionId) {
435
- return false;
436
- }
623
+ const probe = await postToScannerApiDetailed(
624
+ scannerAddress,
625
+ connectCommand,
626
+ 5000,
627
+ signal,
628
+ );
437
629
 
438
- // Got a session ID, now disconnect
439
- const disconnectCommand = {
440
- Command: "Disconnect",
441
- MessageId: new Date().toISOString(),
442
- SessionId: connectResponse.SessionId,
630
+ if (probe.threw) {
631
+ return {
632
+ ok: false,
633
+ error: classifyFetchError(
634
+ probe.error,
635
+ attemptedUrl,
636
+ Boolean(signal?.aborted),
637
+ ),
443
638
  };
639
+ }
444
640
 
445
- await postToScannerApi(scannerAddress, disconnectCommand, 5000);
641
+ if (!probe.ok) {
642
+ return {
643
+ ok: false,
644
+ error: {
645
+ code: "HTTP_ERROR",
646
+ name: "HTTPError",
647
+ message: probe.statusText || `HTTP ${probe.status}`,
648
+ status: probe.status,
649
+ attemptedUrl,
650
+ },
651
+ };
652
+ }
446
653
 
447
- // Successfully connected and disconnected
448
- return true;
449
- } catch (error) {
450
- // Any error means scanner is not reachable
451
- return false;
654
+ if (!probe.body || typeof probe.body !== "object") {
655
+ return {
656
+ ok: false,
657
+ error: {
658
+ code: "INVALID_RESPONSE",
659
+ name: "InvalidResponse",
660
+ message: "Scanner returned a non-JSON or empty body",
661
+ status: probe.status,
662
+ attemptedUrl,
663
+ },
664
+ };
665
+ }
666
+
667
+ if (!probe.body.SessionId) {
668
+ // App-layer scanner error — common when an HTTPS-only scanner is hit
669
+ // over HTTP. The body usually contains vendor-specific fields like
670
+ // `ExtInformation` ("Scanner not configured for HTTP") that we forward
671
+ // so callers can present an actionable message.
672
+ return {
673
+ ok: false,
674
+ error: {
675
+ code: "SCANNER_ERROR",
676
+ name: "ScannerError",
677
+ message:
678
+ probe.body.ExtInformation ||
679
+ probe.body.ErrorInformation ||
680
+ "Scanner returned no SessionId",
681
+ status:
682
+ typeof probe.body.HttpRC === "string"
683
+ ? parseInt(probe.body.HttpRC, 10) || probe.status
684
+ : probe.status,
685
+ attemptedUrl,
686
+ scannerResponse: probe.body,
687
+ },
688
+ };
452
689
  }
690
+
691
+ // Got a SessionId — clean up by disconnecting. We don't surface a
692
+ // disconnect failure: from the host app's perspective the scanner IS
693
+ // present and the leaked session will time out server-side per the
694
+ // scanner's IdleTimeout.
695
+ const disconnectCommand = {
696
+ Command: "Disconnect",
697
+ MessageId: new Date().toISOString(),
698
+ SessionId: probe.body.SessionId,
699
+ };
700
+ await postToScannerApiDetailed(
701
+ scannerAddress,
702
+ disconnectCommand,
703
+ 5000,
704
+ signal,
705
+ );
706
+
707
+ return { ok: true };
708
+ };
709
+
710
+ // Boolean form of scanner presence check. Thin wrapper around
711
+ // `checkScannerPresentDetailed` (VNT-1522) — preserved for existing
712
+ // callers; new code should prefer the detailed version.
713
+ export const checkScannerPresent = async (scannerAddress) => {
714
+ const result = await checkScannerPresentDetailed(scannerAddress);
715
+ return result.ok;
453
716
  };
454
717
 
455
718
  /**