verant_id_cloud_scan 1.4.5-beta.9 → 1.4.6-beta.0

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.
@@ -24,4 +24,12 @@ jobs:
24
24
  run: npm install -g npm@latest
25
25
 
26
26
  - name: Publish to npm
27
- run: npm publish
27
+ run: |
28
+ VERSION="$(node -p "require('./package.json').version")"
29
+ if [[ "$VERSION" == *-* ]]; then
30
+ echo "Publishing prerelease $VERSION under the 'beta' dist-tag"
31
+ npm publish --tag beta
32
+ else
33
+ echo "Publishing release $VERSION under 'latest'"
34
+ npm publish
35
+ fi
package/Api.js CHANGED
@@ -264,9 +264,60 @@ export const checkFaceComparisonFeatureLicense = async (clientId) => {
264
264
  }
265
265
  }
266
266
 
267
- export const checkLicense = async (clientId, macAddress) => {
267
+ export const checkDigitalIDFeatureLicense = async (clientId) => {
268
+ const url = getLicenseServerAddress() + "/mobile_id/check_license";
269
+ const data = { client_id: clientId };
270
+
271
+ try {
272
+ const response = await fetch(url, {
273
+ method: "POST",
274
+ headers: {
275
+ "Content-Type": "application/json",
276
+ },
277
+ body: JSON.stringify(data),
278
+ });
279
+
280
+ // Extract the JSON data from the response
281
+ const responseData = await response.json();
282
+
283
+ if (response.ok && responseData.licensed === true) {
284
+ return true; // Successful response
285
+ } else {
286
+ console.log(
287
+ "checkDigitalIDFeatureLicense returned false or non 200:",
288
+ responseData
289
+ );
290
+ return false; // Unsuccessful response
291
+ }
292
+ } catch (error) {
293
+ console.error(
294
+ "There was a problem with the checkDigitalIDFeatureLicense request:",
295
+ error
296
+ );
297
+ return false; // Error occurred
298
+ }
299
+ };
300
+
301
+ // Pass exactly one of macAddress / serialNumber. The license server rejects
302
+ // requests that supply both or neither; we also fail fast here so callers
303
+ // don't have to wait for a 400 to learn they passed a bad identifier.
304
+ export const checkLicense = async (clientId, identifier = {}) => {
305
+ if (identifier === null || typeof identifier !== "object") {
306
+ throw new TypeError(
307
+ `checkLicense: second argument must be an object like { macAddress } or { serialNumber }, got ${typeof identifier}`,
308
+ );
309
+ }
310
+ const { macAddress, serialNumber } = identifier;
311
+ if (Boolean(macAddress) === Boolean(serialNumber)) {
312
+ throw new Error(
313
+ "checkLicense: pass exactly one of macAddress / serialNumber",
314
+ );
315
+ }
316
+
268
317
  const url = getLicenseServerAddress() + "/check_license";
269
- const data = { client_id: clientId, mac_address: macAddress };
318
+ const data = { client_id: clientId };
319
+ if (macAddress) data.mac_address = macAddress;
320
+ if (serialNumber) data.serial_number = serialNumber;
270
321
 
271
322
  try {
272
323
  const response = await fetch(url, {
@@ -346,48 +397,148 @@ export const getBarcodeLicenseKey = async () => {
346
397
  }
347
398
  };
348
399
 
349
- // Helper function for making POST requests to the scanner API
350
- const postToScannerApi = async (
400
+ /**
401
+ * Build the /securelink URL for the given scanner address.
402
+ *
403
+ * Mirrors the SDK's historical behaviour: if the caller didn't include a
404
+ * scheme, default to http://. The scheme test is case-insensitive per
405
+ * RFC 3986 (VNT-1522) — `HTTP://`, `HTTPS://`, and mixed-case forms are
406
+ * recognised so the SDK doesn't double-prefix them with another `http://`.
407
+ */
408
+ export const buildSecurelinkUrl = (scannerAddress) => {
409
+ const hasScheme = /^https?:\/\//i.test(scannerAddress);
410
+ const withScheme = hasScheme ? scannerAddress : `http://${scannerAddress}`;
411
+ return `${withScheme.replace(/\/+$/, "")}/securelink`;
412
+ };
413
+
414
+ /**
415
+ * Lower-level POST that returns a structured result instead of throwing.
416
+ * Used by `checkScannerPresentDetailed` (VNT-1522) so the caller can
417
+ * distinguish network failures, HTTP errors, parse failures, and scanner
418
+ * application-layer errors. Existing callers go through `postToScannerApi`
419
+ * which preserves the throw-on-error contract.
420
+ *
421
+ * Supports an external `AbortSignal` so host apps can cancel an in-flight
422
+ * probe on retry / unmount without waiting for the internal timeout.
423
+ *
424
+ * @param {string} scannerAddress
425
+ * @param {object} commandObj
426
+ * @param {number} [timeout=10000] timeout in ms before the internal AbortController aborts
427
+ * @param {AbortSignal} [externalSignal] optional external signal; when aborted, the request is cancelled
428
+ * @returns {Promise<{
429
+ * threw: boolean,
430
+ * error?: Error,
431
+ * ok?: boolean,
432
+ * status?: number,
433
+ * statusText?: string,
434
+ * body?: unknown,
435
+ * attemptedUrl: string,
436
+ * }>}
437
+ */
438
+ const postToScannerApiDetailed = async (
351
439
  scannerAddress,
352
440
  commandObj,
353
- timeout = 10000
441
+ timeout = 10000,
442
+ externalSignal,
354
443
  ) => {
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`;
444
+ const attemptedUrl = buildSecurelinkUrl(scannerAddress);
365
445
 
366
446
  const controller = new AbortController();
367
- const signal = controller.signal;
368
-
369
- const fetchTimeout = setTimeout(() => {
370
- controller.abort();
371
- }, timeout);
447
+ const fetchTimeout = setTimeout(() => controller.abort(), timeout);
448
+
449
+ // Forward an external abort signal to our controller so the host app's
450
+ // cancellation (retry, unmount) propagates to the in-flight fetch.
451
+ let externalAbortHandler;
452
+ if (externalSignal) {
453
+ if (externalSignal.aborted) {
454
+ controller.abort();
455
+ } else {
456
+ externalAbortHandler = () => controller.abort();
457
+ externalSignal.addEventListener("abort", externalAbortHandler, { once: true });
458
+ }
459
+ }
372
460
 
373
461
  try {
374
- const response = await fetch(apiAddress, {
462
+ const response = await fetch(attemptedUrl, {
375
463
  method: "POST",
376
464
  headers: { "Content-Type": "application/json" },
377
465
  body: JSON.stringify(commandObj),
378
- signal: signal,
466
+ signal: controller.signal,
379
467
  });
380
468
 
381
- clearTimeout(fetchTimeout);
469
+ // Read the body once as text, then try to JSON-parse — keeps a malformed
470
+ // body from making us discard otherwise-useful status info.
471
+ let body = null;
472
+ try {
473
+ const text = await response.text();
474
+ if (text) {
475
+ try {
476
+ body = JSON.parse(text);
477
+ } catch {
478
+ body = text; // not JSON, but still meaningful for diagnostics
479
+ }
480
+ }
481
+ } catch {
482
+ // body read failed — leave body=null, caller can decide
483
+ }
382
484
 
383
- if (!response.ok) throw new Error(response.statusText);
384
- return response.json();
485
+ return {
486
+ threw: false,
487
+ ok: response.ok,
488
+ status: response.status,
489
+ statusText: response.statusText,
490
+ body,
491
+ attemptedUrl,
492
+ };
385
493
  } catch (error) {
386
- if (error.name === "AbortError") {
494
+ return { threw: true, error, attemptedUrl };
495
+ } finally {
496
+ clearTimeout(fetchTimeout);
497
+ if (externalSignal && externalAbortHandler) {
498
+ externalSignal.removeEventListener("abort", externalAbortHandler);
499
+ }
500
+ }
501
+ };
502
+
503
+ // Helper function for making POST requests to the scanner API. Thin wrapper
504
+ // around `postToScannerApiDetailed` that preserves the historical contract
505
+ // (throws on timeout / network / non-2xx; returns parsed JSON on success).
506
+ const postToScannerApi = async (
507
+ scannerAddress,
508
+ commandObj,
509
+ timeout = 10000
510
+ ) => {
511
+ const result = await postToScannerApiDetailed(
512
+ scannerAddress,
513
+ commandObj,
514
+ timeout,
515
+ );
516
+
517
+ if (result.threw) {
518
+ if (result.error?.name === "AbortError") {
387
519
  throw new Error("Request timed out");
388
520
  }
389
- throw error;
521
+ throw result.error;
522
+ }
523
+
524
+ if (!result.ok) {
525
+ throw new Error(result.statusText || `HTTP ${result.status}`);
526
+ }
527
+
528
+ // Match the original `response.json()` contract: throw if the 2xx body
529
+ // wasn't a parseable JSON object. The detailed wrapper deliberately
530
+ // preserves non-JSON bodies as strings (or `null` for empty) so error
531
+ // surfaces like `checkScannerPresentDetailed` can use them — but every
532
+ // existing caller of `postToScannerApi` (`getNetworkInfo`,
533
+ // `establishConnection`, `getParams`, `resetParams`, `setParameters`,
534
+ // `scanDocument`, …) reads object properties off the return value, so
535
+ // returning a string or null here would silently produce `undefined`
536
+ // downstream instead of failing loudly.
537
+ if (result.body === null || typeof result.body !== "object") {
538
+ throw new SyntaxError("Scanner response was not a JSON object");
390
539
  }
540
+
541
+ return result.body;
391
542
  };
392
543
 
393
544
  export const getNetworkInfo = (scannerAddress, commandObj) =>
@@ -414,42 +565,174 @@ export const stopScanning = (scannerAddress, commandObj) =>
414
565
  export const closeConnection = (scannerAddress, commandObj) =>
415
566
  postToScannerApi(scannerAddress, commandObj);
416
567
 
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",
568
+ /**
569
+ * Classify a thrown fetch error into one of the documented codes (VNT-1522).
570
+ *
571
+ * `externallyAborted` distinguishes a host-app cancellation (retry, unmount)
572
+ * from an internal timeout — both surface as `AbortError` from fetch, but
573
+ * callers branching on `code` for UI copy (e.g. "scanner timed out") should
574
+ * not see the timeout label when the user themselves cancelled.
575
+ *
576
+ * @param {unknown} error
577
+ * @param {string} attemptedUrl
578
+ * @param {boolean} [externallyAborted=false]
579
+ * @returns {{ code: string, name: string, message: string, attemptedUrl: string }}
580
+ */
581
+ const classifyFetchError = (error, attemptedUrl, externallyAborted = false) => {
582
+ if (error?.name === "AbortError") {
583
+ return {
584
+ code: externallyAborted ? "ABORTED" : "TIMEOUT",
585
+ name: "AbortError",
586
+ message: externallyAborted ? "Request aborted" : "Request timed out",
587
+ attemptedUrl,
588
+ };
589
+ }
590
+ if (error instanceof TypeError) {
591
+ // Browser fetch surfaces network failures (DNS, connection refused,
592
+ // mixed-content blocks, CORS preflight rejection) as `TypeError`.
593
+ return {
594
+ code: "NETWORK_ERROR",
595
+ name: "TypeError",
596
+ message: error.message || "Network error",
597
+ attemptedUrl,
429
598
  };
599
+ }
600
+ return {
601
+ code: "NETWORK_ERROR",
602
+ name: error?.name || "Unknown",
603
+ message: error?.message || String(error),
604
+ attemptedUrl,
605
+ };
606
+ };
607
+
608
+ /**
609
+ * Structured version of `checkScannerPresent` (VNT-1522).
610
+ *
611
+ * Returns `{ ok: true }` on success or `{ ok: false, error }` with a tagged
612
+ * `error.code` so host apps can distinguish:
613
+ * - `TIMEOUT` — internal 5s timeout fired
614
+ * - `ABORTED` — host app cancelled via the external `AbortSignal`
615
+ * - `NETWORK_ERROR` — fetch threw (DNS, refused, mixed content, CORS, etc.)
616
+ * - `HTTP_ERROR` — server replied non-2xx
617
+ * - `INVALID_RESPONSE` — 2xx but the body wasn't JSON
618
+ * - `SCANNER_ERROR` — 2xx JSON but no `SessionId` (the scanner returned
619
+ * an application-layer error in the body, e.g. an HTTPS-only scanner
620
+ * rejecting an HTTP probe with `{HttpRC:"403", ExtInformation:"..."}`)
621
+ *
622
+ * The error object also carries `attemptedUrl`, `status` (when a response
623
+ * was received), and the raw `scannerResponse` body for SCANNER_ERROR so
624
+ * callers can surface vendor-specific fields like `ExtInformation`.
625
+ *
626
+ * @param {string} scannerAddress
627
+ * @param {{ signal?: AbortSignal }} [options]
628
+ */
629
+ export const checkScannerPresentDetailed = async (
630
+ scannerAddress,
631
+ options = {},
632
+ ) => {
633
+ const { signal } = options;
634
+ const attemptedUrl = buildSecurelinkUrl(scannerAddress);
635
+
636
+ const connectCommand = {
637
+ Command: "Connect",
638
+ MessageId: new Date().toISOString(),
639
+ Exclusive: "false", // non-exclusive — we're only probing
640
+ IdleTimeout: "5",
641
+ };
430
642
 
431
- const connectResponse = await postToScannerApi(scannerAddress, connectCommand, 5000);
643
+ const probe = await postToScannerApiDetailed(
644
+ scannerAddress,
645
+ connectCommand,
646
+ 5000,
647
+ signal,
648
+ );
432
649
 
433
- // If connection failed or no session ID, scanner not present
434
- if (!connectResponse || !connectResponse.SessionId) {
435
- return false;
436
- }
650
+ if (probe.threw) {
651
+ return {
652
+ ok: false,
653
+ error: classifyFetchError(
654
+ probe.error,
655
+ attemptedUrl,
656
+ Boolean(signal?.aborted),
657
+ ),
658
+ };
659
+ }
437
660
 
438
- // Got a session ID, now disconnect
439
- const disconnectCommand = {
440
- Command: "Disconnect",
441
- MessageId: new Date().toISOString(),
442
- SessionId: connectResponse.SessionId,
661
+ if (!probe.ok) {
662
+ return {
663
+ ok: false,
664
+ error: {
665
+ code: "HTTP_ERROR",
666
+ name: "HTTPError",
667
+ message: probe.statusText || `HTTP ${probe.status}`,
668
+ status: probe.status,
669
+ attemptedUrl,
670
+ },
443
671
  };
672
+ }
444
673
 
445
- await postToScannerApi(scannerAddress, disconnectCommand, 5000);
674
+ if (!probe.body || typeof probe.body !== "object") {
675
+ return {
676
+ ok: false,
677
+ error: {
678
+ code: "INVALID_RESPONSE",
679
+ name: "InvalidResponse",
680
+ message: "Scanner returned a non-JSON or empty body",
681
+ status: probe.status,
682
+ attemptedUrl,
683
+ },
684
+ };
685
+ }
446
686
 
447
- // Successfully connected and disconnected
448
- return true;
449
- } catch (error) {
450
- // Any error means scanner is not reachable
451
- return false;
687
+ if (!probe.body.SessionId) {
688
+ // App-layer scanner error — common when an HTTPS-only scanner is hit
689
+ // over HTTP. The body usually contains vendor-specific fields like
690
+ // `ExtInformation` ("Scanner not configured for HTTP") that we forward
691
+ // so callers can present an actionable message.
692
+ return {
693
+ ok: false,
694
+ error: {
695
+ code: "SCANNER_ERROR",
696
+ name: "ScannerError",
697
+ message:
698
+ probe.body.ExtInformation ||
699
+ probe.body.ErrorInformation ||
700
+ "Scanner returned no SessionId",
701
+ status:
702
+ typeof probe.body.HttpRC === "string"
703
+ ? parseInt(probe.body.HttpRC, 10) || probe.status
704
+ : probe.status,
705
+ attemptedUrl,
706
+ scannerResponse: probe.body,
707
+ },
708
+ };
452
709
  }
710
+
711
+ // Got a SessionId — clean up by disconnecting. We don't surface a
712
+ // disconnect failure: from the host app's perspective the scanner IS
713
+ // present and the leaked session will time out server-side per the
714
+ // scanner's IdleTimeout.
715
+ const disconnectCommand = {
716
+ Command: "Disconnect",
717
+ MessageId: new Date().toISOString(),
718
+ SessionId: probe.body.SessionId,
719
+ };
720
+ await postToScannerApiDetailed(
721
+ scannerAddress,
722
+ disconnectCommand,
723
+ 5000,
724
+ signal,
725
+ );
726
+
727
+ return { ok: true };
728
+ };
729
+
730
+ // Boolean form of scanner presence check. Thin wrapper around
731
+ // `checkScannerPresentDetailed` (VNT-1522) — preserved for existing
732
+ // callers; new code should prefer the detailed version.
733
+ export const checkScannerPresent = async (scannerAddress) => {
734
+ const result = await checkScannerPresentDetailed(scannerAddress);
735
+ return result.ok;
453
736
  };
454
737
 
455
738
  /**
@@ -460,11 +743,16 @@ export const checkScannerPresent = async (scannerAddress) => {
460
743
  *
461
744
  * @param {string} userId - User ID for the verification session
462
745
  * @param {string} clientId - Client ID for licensing and tracking
746
+ * @param {string} [origin] - Holder page origin; binds the server's
747
+ * SessionTranscript to the wallet's view (HPKE info). Required on non-default hosts.
463
748
  * @returns {Promise<Object>} Session data with session_id, session_url, and websocket_url
464
749
  */
465
- export const createVerificationSession = async (userId, clientId) => {
750
+ export const createVerificationSession = async (userId, clientId, origin) => {
466
751
  const url = getMobileVerificationServerAddress() + "/sessions";
467
752
  const data = { user_id: userId, client_id: clientId };
753
+ if (origin) {
754
+ data.origin = origin;
755
+ }
468
756
 
469
757
  try {
470
758
  const response = await fetch(url, {
@@ -479,24 +767,31 @@ export const createVerificationSession = async (userId, clientId) => {
479
767
  const responseData = await response.json();
480
768
  return responseData;
481
769
  } else {
482
- // Try to parse error response
770
+ // Read the body once as text — fetch consumes the stream on first read,
771
+ // so a `response.json()` then `response.text()` fallback would throw
772
+ // `TypeError: body stream already read` and mask the original error.
773
+ const errorText = await response.text();
774
+ let errorData = null;
483
775
  try {
484
- const errorData = await response.json();
776
+ errorData = JSON.parse(errorText);
777
+ } catch {
778
+ // Non-JSON body (e.g. plain text 502 from API Gateway).
779
+ }
780
+
781
+ if (errorData) {
485
782
  console.error("Verification session creation failed:", errorData);
486
783
  return {
487
784
  status: 'error',
488
785
  message: errorData.message || 'Failed to create verification session',
489
- error: errorData
490
- };
491
- } catch (parseError) {
492
- const errorText = await response.text();
493
- console.error("Verification session creation failed:", errorText);
494
- return {
495
- status: 'error',
496
- message: 'Failed to create verification session',
497
- raw_error: errorText
786
+ error: errorData,
498
787
  };
499
788
  }
789
+ console.error("Verification session creation failed:", errorText);
790
+ return {
791
+ status: 'error',
792
+ message: 'Failed to create verification session',
793
+ raw_error: errorText,
794
+ };
500
795
  }
501
796
  } catch (error) {
502
797
  console.error("There was a problem with the verification session request:", error);