verant_id_cloud_scan 1.4.5-beta.10 → 1.4.5-beta.11

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
@@ -264,9 +264,26 @@ export const checkFaceComparisonFeatureLicense = async (clientId) => {
264
264
  }
265
265
  }
266
266
 
267
- export const checkLicense = async (clientId, macAddress) => {
267
+ // Pass exactly one of macAddress / serialNumber. The license server rejects
268
+ // requests that supply both or neither; we also fail fast here so callers
269
+ // don't have to wait for a 400 to learn they passed a bad identifier.
270
+ export const checkLicense = async (clientId, identifier = {}) => {
271
+ if (identifier === null || typeof identifier !== "object") {
272
+ throw new TypeError(
273
+ `checkLicense: second argument must be an object like { macAddress } or { serialNumber }, got ${typeof identifier}`,
274
+ );
275
+ }
276
+ const { macAddress, serialNumber } = identifier;
277
+ if (Boolean(macAddress) === Boolean(serialNumber)) {
278
+ throw new Error(
279
+ "checkLicense: pass exactly one of macAddress / serialNumber",
280
+ );
281
+ }
282
+
268
283
  const url = getLicenseServerAddress() + "/check_license";
269
- const data = { client_id: clientId, mac_address: macAddress };
284
+ const data = { client_id: clientId };
285
+ if (macAddress) data.mac_address = macAddress;
286
+ if (serialNumber) data.serial_number = serialNumber;
270
287
 
271
288
  try {
272
289
  const response = await fetch(url, {
@@ -346,48 +363,148 @@ export const getBarcodeLicenseKey = async () => {
346
363
  }
347
364
  };
348
365
 
349
- // Helper function for making POST requests to the scanner API
350
- const postToScannerApi = async (
366
+ /**
367
+ * Build the /securelink URL for the given scanner address.
368
+ *
369
+ * Mirrors the SDK's historical behaviour: if the caller didn't include a
370
+ * scheme, default to http://. The scheme test is case-insensitive per
371
+ * RFC 3986 (VNT-1522) — `HTTP://`, `HTTPS://`, and mixed-case forms are
372
+ * recognised so the SDK doesn't double-prefix them with another `http://`.
373
+ */
374
+ export const buildSecurelinkUrl = (scannerAddress) => {
375
+ const hasScheme = /^https?:\/\//i.test(scannerAddress);
376
+ const withScheme = hasScheme ? scannerAddress : `http://${scannerAddress}`;
377
+ return `${withScheme.replace(/\/+$/, "")}/securelink`;
378
+ };
379
+
380
+ /**
381
+ * Lower-level POST that returns a structured result instead of throwing.
382
+ * Used by `checkScannerPresentDetailed` (VNT-1522) so the caller can
383
+ * distinguish network failures, HTTP errors, parse failures, and scanner
384
+ * application-layer errors. Existing callers go through `postToScannerApi`
385
+ * which preserves the throw-on-error contract.
386
+ *
387
+ * Supports an external `AbortSignal` so host apps can cancel an in-flight
388
+ * probe on retry / unmount without waiting for the internal timeout.
389
+ *
390
+ * @param {string} scannerAddress
391
+ * @param {object} commandObj
392
+ * @param {number} [timeout=10000] timeout in ms before the internal AbortController aborts
393
+ * @param {AbortSignal} [externalSignal] optional external signal; when aborted, the request is cancelled
394
+ * @returns {Promise<{
395
+ * threw: boolean,
396
+ * error?: Error,
397
+ * ok?: boolean,
398
+ * status?: number,
399
+ * statusText?: string,
400
+ * body?: unknown,
401
+ * attemptedUrl: string,
402
+ * }>}
403
+ */
404
+ const postToScannerApiDetailed = async (
351
405
  scannerAddress,
352
406
  commandObj,
353
- timeout = 10000
407
+ timeout = 10000,
408
+ externalSignal,
354
409
  ) => {
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`;
410
+ const attemptedUrl = buildSecurelinkUrl(scannerAddress);
365
411
 
366
412
  const controller = new AbortController();
367
- const signal = controller.signal;
368
-
369
- const fetchTimeout = setTimeout(() => {
370
- controller.abort();
371
- }, timeout);
413
+ const fetchTimeout = setTimeout(() => controller.abort(), timeout);
414
+
415
+ // Forward an external abort signal to our controller so the host app's
416
+ // cancellation (retry, unmount) propagates to the in-flight fetch.
417
+ let externalAbortHandler;
418
+ if (externalSignal) {
419
+ if (externalSignal.aborted) {
420
+ controller.abort();
421
+ } else {
422
+ externalAbortHandler = () => controller.abort();
423
+ externalSignal.addEventListener("abort", externalAbortHandler, { once: true });
424
+ }
425
+ }
372
426
 
373
427
  try {
374
- const response = await fetch(apiAddress, {
428
+ const response = await fetch(attemptedUrl, {
375
429
  method: "POST",
376
430
  headers: { "Content-Type": "application/json" },
377
431
  body: JSON.stringify(commandObj),
378
- signal: signal,
432
+ signal: controller.signal,
379
433
  });
380
434
 
381
- clearTimeout(fetchTimeout);
435
+ // Read the body once as text, then try to JSON-parse — keeps a malformed
436
+ // body from making us discard otherwise-useful status info.
437
+ let body = null;
438
+ try {
439
+ const text = await response.text();
440
+ if (text) {
441
+ try {
442
+ body = JSON.parse(text);
443
+ } catch {
444
+ body = text; // not JSON, but still meaningful for diagnostics
445
+ }
446
+ }
447
+ } catch {
448
+ // body read failed — leave body=null, caller can decide
449
+ }
382
450
 
383
- if (!response.ok) throw new Error(response.statusText);
384
- return response.json();
451
+ return {
452
+ threw: false,
453
+ ok: response.ok,
454
+ status: response.status,
455
+ statusText: response.statusText,
456
+ body,
457
+ attemptedUrl,
458
+ };
385
459
  } catch (error) {
386
- if (error.name === "AbortError") {
460
+ return { threw: true, error, attemptedUrl };
461
+ } finally {
462
+ clearTimeout(fetchTimeout);
463
+ if (externalSignal && externalAbortHandler) {
464
+ externalSignal.removeEventListener("abort", externalAbortHandler);
465
+ }
466
+ }
467
+ };
468
+
469
+ // Helper function for making POST requests to the scanner API. Thin wrapper
470
+ // around `postToScannerApiDetailed` that preserves the historical contract
471
+ // (throws on timeout / network / non-2xx; returns parsed JSON on success).
472
+ const postToScannerApi = async (
473
+ scannerAddress,
474
+ commandObj,
475
+ timeout = 10000
476
+ ) => {
477
+ const result = await postToScannerApiDetailed(
478
+ scannerAddress,
479
+ commandObj,
480
+ timeout,
481
+ );
482
+
483
+ if (result.threw) {
484
+ if (result.error?.name === "AbortError") {
387
485
  throw new Error("Request timed out");
388
486
  }
389
- throw error;
487
+ throw result.error;
488
+ }
489
+
490
+ if (!result.ok) {
491
+ throw new Error(result.statusText || `HTTP ${result.status}`);
492
+ }
493
+
494
+ // Match the original `response.json()` contract: throw if the 2xx body
495
+ // wasn't a parseable JSON object. The detailed wrapper deliberately
496
+ // preserves non-JSON bodies as strings (or `null` for empty) so error
497
+ // surfaces like `checkScannerPresentDetailed` can use them — but every
498
+ // existing caller of `postToScannerApi` (`getNetworkInfo`,
499
+ // `establishConnection`, `getParams`, `resetParams`, `setParameters`,
500
+ // `scanDocument`, …) reads object properties off the return value, so
501
+ // returning a string or null here would silently produce `undefined`
502
+ // downstream instead of failing loudly.
503
+ if (result.body === null || typeof result.body !== "object") {
504
+ throw new SyntaxError("Scanner response was not a JSON object");
390
505
  }
506
+
507
+ return result.body;
391
508
  };
392
509
 
393
510
  export const getNetworkInfo = (scannerAddress, commandObj) =>
@@ -414,42 +531,174 @@ export const stopScanning = (scannerAddress, commandObj) =>
414
531
  export const closeConnection = (scannerAddress, commandObj) =>
415
532
  postToScannerApi(scannerAddress, commandObj);
416
533
 
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",
534
+ /**
535
+ * Classify a thrown fetch error into one of the documented codes (VNT-1522).
536
+ *
537
+ * `externallyAborted` distinguishes a host-app cancellation (retry, unmount)
538
+ * from an internal timeout — both surface as `AbortError` from fetch, but
539
+ * callers branching on `code` for UI copy (e.g. "scanner timed out") should
540
+ * not see the timeout label when the user themselves cancelled.
541
+ *
542
+ * @param {unknown} error
543
+ * @param {string} attemptedUrl
544
+ * @param {boolean} [externallyAborted=false]
545
+ * @returns {{ code: string, name: string, message: string, attemptedUrl: string }}
546
+ */
547
+ const classifyFetchError = (error, attemptedUrl, externallyAborted = false) => {
548
+ if (error?.name === "AbortError") {
549
+ return {
550
+ code: externallyAborted ? "ABORTED" : "TIMEOUT",
551
+ name: "AbortError",
552
+ message: externallyAborted ? "Request aborted" : "Request timed out",
553
+ attemptedUrl,
554
+ };
555
+ }
556
+ if (error instanceof TypeError) {
557
+ // Browser fetch surfaces network failures (DNS, connection refused,
558
+ // mixed-content blocks, CORS preflight rejection) as `TypeError`.
559
+ return {
560
+ code: "NETWORK_ERROR",
561
+ name: "TypeError",
562
+ message: error.message || "Network error",
563
+ attemptedUrl,
429
564
  };
565
+ }
566
+ return {
567
+ code: "NETWORK_ERROR",
568
+ name: error?.name || "Unknown",
569
+ message: error?.message || String(error),
570
+ attemptedUrl,
571
+ };
572
+ };
573
+
574
+ /**
575
+ * Structured version of `checkScannerPresent` (VNT-1522).
576
+ *
577
+ * Returns `{ ok: true }` on success or `{ ok: false, error }` with a tagged
578
+ * `error.code` so host apps can distinguish:
579
+ * - `TIMEOUT` — internal 5s timeout fired
580
+ * - `ABORTED` — host app cancelled via the external `AbortSignal`
581
+ * - `NETWORK_ERROR` — fetch threw (DNS, refused, mixed content, CORS, etc.)
582
+ * - `HTTP_ERROR` — server replied non-2xx
583
+ * - `INVALID_RESPONSE` — 2xx but the body wasn't JSON
584
+ * - `SCANNER_ERROR` — 2xx JSON but no `SessionId` (the scanner returned
585
+ * an application-layer error in the body, e.g. an HTTPS-only scanner
586
+ * rejecting an HTTP probe with `{HttpRC:"403", ExtInformation:"..."}`)
587
+ *
588
+ * The error object also carries `attemptedUrl`, `status` (when a response
589
+ * was received), and the raw `scannerResponse` body for SCANNER_ERROR so
590
+ * callers can surface vendor-specific fields like `ExtInformation`.
591
+ *
592
+ * @param {string} scannerAddress
593
+ * @param {{ signal?: AbortSignal }} [options]
594
+ */
595
+ export const checkScannerPresentDetailed = async (
596
+ scannerAddress,
597
+ options = {},
598
+ ) => {
599
+ const { signal } = options;
600
+ const attemptedUrl = buildSecurelinkUrl(scannerAddress);
601
+
602
+ const connectCommand = {
603
+ Command: "Connect",
604
+ MessageId: new Date().toISOString(),
605
+ Exclusive: "false", // non-exclusive — we're only probing
606
+ IdleTimeout: "5",
607
+ };
430
608
 
431
- const connectResponse = await postToScannerApi(scannerAddress, connectCommand, 5000);
609
+ const probe = await postToScannerApiDetailed(
610
+ scannerAddress,
611
+ connectCommand,
612
+ 5000,
613
+ signal,
614
+ );
432
615
 
433
- // If connection failed or no session ID, scanner not present
434
- if (!connectResponse || !connectResponse.SessionId) {
435
- return false;
436
- }
616
+ if (probe.threw) {
617
+ return {
618
+ ok: false,
619
+ error: classifyFetchError(
620
+ probe.error,
621
+ attemptedUrl,
622
+ Boolean(signal?.aborted),
623
+ ),
624
+ };
625
+ }
437
626
 
438
- // Got a session ID, now disconnect
439
- const disconnectCommand = {
440
- Command: "Disconnect",
441
- MessageId: new Date().toISOString(),
442
- SessionId: connectResponse.SessionId,
627
+ if (!probe.ok) {
628
+ return {
629
+ ok: false,
630
+ error: {
631
+ code: "HTTP_ERROR",
632
+ name: "HTTPError",
633
+ message: probe.statusText || `HTTP ${probe.status}`,
634
+ status: probe.status,
635
+ attemptedUrl,
636
+ },
443
637
  };
638
+ }
444
639
 
445
- await postToScannerApi(scannerAddress, disconnectCommand, 5000);
640
+ if (!probe.body || typeof probe.body !== "object") {
641
+ return {
642
+ ok: false,
643
+ error: {
644
+ code: "INVALID_RESPONSE",
645
+ name: "InvalidResponse",
646
+ message: "Scanner returned a non-JSON or empty body",
647
+ status: probe.status,
648
+ attemptedUrl,
649
+ },
650
+ };
651
+ }
446
652
 
447
- // Successfully connected and disconnected
448
- return true;
449
- } catch (error) {
450
- // Any error means scanner is not reachable
451
- return false;
653
+ if (!probe.body.SessionId) {
654
+ // App-layer scanner error — common when an HTTPS-only scanner is hit
655
+ // over HTTP. The body usually contains vendor-specific fields like
656
+ // `ExtInformation` ("Scanner not configured for HTTP") that we forward
657
+ // so callers can present an actionable message.
658
+ return {
659
+ ok: false,
660
+ error: {
661
+ code: "SCANNER_ERROR",
662
+ name: "ScannerError",
663
+ message:
664
+ probe.body.ExtInformation ||
665
+ probe.body.ErrorInformation ||
666
+ "Scanner returned no SessionId",
667
+ status:
668
+ typeof probe.body.HttpRC === "string"
669
+ ? parseInt(probe.body.HttpRC, 10) || probe.status
670
+ : probe.status,
671
+ attemptedUrl,
672
+ scannerResponse: probe.body,
673
+ },
674
+ };
452
675
  }
676
+
677
+ // Got a SessionId — clean up by disconnecting. We don't surface a
678
+ // disconnect failure: from the host app's perspective the scanner IS
679
+ // present and the leaked session will time out server-side per the
680
+ // scanner's IdleTimeout.
681
+ const disconnectCommand = {
682
+ Command: "Disconnect",
683
+ MessageId: new Date().toISOString(),
684
+ SessionId: probe.body.SessionId,
685
+ };
686
+ await postToScannerApiDetailed(
687
+ scannerAddress,
688
+ disconnectCommand,
689
+ 5000,
690
+ signal,
691
+ );
692
+
693
+ return { ok: true };
694
+ };
695
+
696
+ // Boolean form of scanner presence check. Thin wrapper around
697
+ // `checkScannerPresentDetailed` (VNT-1522) — preserved for existing
698
+ // callers; new code should prefer the detailed version.
699
+ export const checkScannerPresent = async (scannerAddress) => {
700
+ const result = await checkScannerPresentDetailed(scannerAddress);
701
+ return result.ok;
453
702
  };
454
703
 
455
704
  /**
package/Api.test.js ADDED
@@ -0,0 +1,338 @@
1
+ /**
2
+ * Tests for the structured scanner-presence API (VNT-1522).
3
+ *
4
+ * Uses node:test (built-in, no extra deps) — run with `npm test`.
5
+ * `globalThis.fetch` is stubbed per test so we can simulate timeouts,
6
+ * network failures, HTTP errors, and scanner-layer body errors without
7
+ * needing a real local scanner.
8
+ */
9
+
10
+ import { describe, it, beforeEach, afterEach } from "node:test";
11
+ import assert from "node:assert/strict";
12
+
13
+ import {
14
+ checkScannerPresentDetailed,
15
+ buildSecurelinkUrl,
16
+ getNetworkInfo,
17
+ } from "./Api.js";
18
+
19
+ let originalFetch;
20
+
21
+ beforeEach(() => {
22
+ originalFetch = globalThis.fetch;
23
+ });
24
+
25
+ afterEach(() => {
26
+ globalThis.fetch = originalFetch;
27
+ });
28
+
29
+ /** Build a Response-like object the production code will treat as a real Response. */
30
+ function jsonResponse(body, init = {}) {
31
+ return new Response(JSON.stringify(body), {
32
+ status: 200,
33
+ headers: { "Content-Type": "application/json" },
34
+ ...init,
35
+ });
36
+ }
37
+
38
+ describe("buildSecurelinkUrl — case-insensitive scheme detection (VNT-1522)", () => {
39
+ it("prepends http:// when no scheme is given", () => {
40
+ assert.equal(
41
+ buildSecurelinkUrl("192.168.1.50"),
42
+ "http://192.168.1.50/securelink",
43
+ );
44
+ });
45
+
46
+ it("preserves a lowercase https:// scheme", () => {
47
+ assert.equal(
48
+ buildSecurelinkUrl("https://192.168.1.50"),
49
+ "https://192.168.1.50/securelink",
50
+ );
51
+ });
52
+
53
+ it("preserves a lowercase http:// scheme", () => {
54
+ assert.equal(
55
+ buildSecurelinkUrl("http://192.168.1.50"),
56
+ "http://192.168.1.50/securelink",
57
+ );
58
+ });
59
+
60
+ it("preserves an UPPERCASE HTTPS:// scheme and does not double-prefix", () => {
61
+ // Previously this regressed to `http://HTTPS://...` because the SDK's
62
+ // `startsWith("https://")` check was case-sensitive.
63
+ assert.equal(
64
+ buildSecurelinkUrl("HTTPS://192.168.1.50"),
65
+ "HTTPS://192.168.1.50/securelink",
66
+ );
67
+ });
68
+
69
+ it("preserves a mixed-case Https:// scheme", () => {
70
+ assert.equal(
71
+ buildSecurelinkUrl("Https://192.168.1.50"),
72
+ "Https://192.168.1.50/securelink",
73
+ );
74
+ });
75
+
76
+ it("strips trailing slashes so /securelink is appended cleanly", () => {
77
+ assert.equal(
78
+ buildSecurelinkUrl("http://192.168.1.50/"),
79
+ "http://192.168.1.50/securelink",
80
+ );
81
+ assert.equal(
82
+ buildSecurelinkUrl("http://192.168.1.50///"),
83
+ "http://192.168.1.50/securelink",
84
+ );
85
+ });
86
+ });
87
+
88
+ describe("checkScannerPresentDetailed — happy path", () => {
89
+ it("returns ok=true when Connect responds with a SessionId", async () => {
90
+ let connectCalled = false;
91
+ let disconnectCalled = false;
92
+
93
+ globalThis.fetch = async (_url, init) => {
94
+ const body = JSON.parse(init.body);
95
+ if (body.Command === "Connect") {
96
+ connectCalled = true;
97
+ return jsonResponse({ SessionId: "abc-123" });
98
+ }
99
+ if (body.Command === "Disconnect") {
100
+ disconnectCalled = true;
101
+ return jsonResponse({});
102
+ }
103
+ throw new Error(`Unexpected command: ${body.Command}`);
104
+ };
105
+
106
+ const result = await checkScannerPresentDetailed("192.168.1.50");
107
+ assert.deepEqual(result, { ok: true });
108
+ assert.equal(connectCalled, true, "Connect should have been called");
109
+ assert.equal(disconnectCalled, true, "Disconnect should have been called");
110
+ });
111
+
112
+ it("still returns ok=true when Disconnect fails (scanner is present regardless)", async () => {
113
+ let attempt = 0;
114
+ globalThis.fetch = async (_url, init) => {
115
+ attempt += 1;
116
+ const body = JSON.parse(init.body);
117
+ if (body.Command === "Connect") {
118
+ return jsonResponse({ SessionId: "abc-123" });
119
+ }
120
+ // Disconnect call fails — should NOT degrade the ok=true return,
121
+ // since the host app's question ("is the scanner present?") has
122
+ // already been answered yes.
123
+ throw new TypeError("network error on disconnect");
124
+ };
125
+
126
+ const result = await checkScannerPresentDetailed("192.168.1.50");
127
+ assert.equal(result.ok, true);
128
+ assert.equal(attempt, 2, "both Connect and Disconnect should be attempted");
129
+ });
130
+ });
131
+
132
+ describe("checkScannerPresentDetailed — error codes", () => {
133
+ it("returns SCANNER_ERROR when 200 OK has no SessionId in body", async () => {
134
+ // Becca's case: HTTPS-only scanner being hit over HTTP returns 200 OK
135
+ // with a body like `{"HttpRC":"403", "ExtInformation":"Scanner not
136
+ // configured for HTTP"}`. The SDK's old boolean form collapsed this
137
+ // to `false`; the detailed form surfaces the vendor message.
138
+ globalThis.fetch = async () =>
139
+ jsonResponse({
140
+ HttpRC: "403",
141
+ Result: "12",
142
+ ErrorInformation: "Device specific error",
143
+ ExtInformation: "Scanner not configured for HTTP",
144
+ });
145
+
146
+ const result = await checkScannerPresentDetailed("192.168.1.50");
147
+ assert.equal(result.ok, false);
148
+ assert.equal(result.error.code, "SCANNER_ERROR");
149
+ assert.equal(result.error.message, "Scanner not configured for HTTP");
150
+ assert.equal(result.error.status, 403);
151
+ assert.equal(result.error.attemptedUrl, "http://192.168.1.50/securelink");
152
+ assert.equal(
153
+ result.error.scannerResponse.ExtInformation,
154
+ "Scanner not configured for HTTP",
155
+ );
156
+ });
157
+
158
+ it("returns HTTP_ERROR when the server replies with a non-2xx status", async () => {
159
+ globalThis.fetch = async () =>
160
+ new Response("Forbidden", { status: 403, statusText: "Forbidden" });
161
+
162
+ const result = await checkScannerPresentDetailed("192.168.1.50");
163
+ assert.equal(result.ok, false);
164
+ assert.equal(result.error.code, "HTTP_ERROR");
165
+ assert.equal(result.error.status, 403);
166
+ assert.equal(result.error.message, "Forbidden");
167
+ assert.equal(result.error.attemptedUrl, "http://192.168.1.50/securelink");
168
+ });
169
+
170
+ it("returns NETWORK_ERROR when fetch throws a TypeError (DNS, CORS, mixed content, etc.)", async () => {
171
+ globalThis.fetch = async () => {
172
+ throw new TypeError("Failed to fetch");
173
+ };
174
+
175
+ const result = await checkScannerPresentDetailed("192.168.1.50");
176
+ assert.equal(result.ok, false);
177
+ assert.equal(result.error.code, "NETWORK_ERROR");
178
+ assert.equal(result.error.name, "TypeError");
179
+ assert.match(result.error.message, /Failed to fetch/);
180
+ assert.equal(result.error.attemptedUrl, "http://192.168.1.50/securelink");
181
+ });
182
+
183
+ it("returns ABORTED when an already-aborted external signal is passed", async () => {
184
+ // Real browser fetch rejects synchronously when given an already-
185
+ // aborted signal — mirror that contract in the mock so the test
186
+ // doesn't deadlock if the listener is attached post-abort.
187
+ globalThis.fetch = async (_url, init) => {
188
+ if (init.signal?.aborted) {
189
+ const err = new Error("aborted");
190
+ err.name = "AbortError";
191
+ throw err;
192
+ }
193
+ return new Promise((_resolve, reject) => {
194
+ init.signal?.addEventListener("abort", () => {
195
+ const err = new Error("aborted");
196
+ err.name = "AbortError";
197
+ reject(err);
198
+ });
199
+ });
200
+ };
201
+
202
+ const controller = new AbortController();
203
+ controller.abort();
204
+ const result = await checkScannerPresentDetailed("192.168.1.50", {
205
+ signal: controller.signal,
206
+ });
207
+
208
+ assert.equal(result.ok, false);
209
+ assert.equal(result.error.code, "ABORTED");
210
+ assert.equal(result.error.name, "AbortError");
211
+ });
212
+
213
+ it("returns TIMEOUT when fetch aborts and no external signal was supplied", async () => {
214
+ // Simulates the internal 5s AbortController firing — fetch throws
215
+ // AbortError, no external signal was passed in, so classification
216
+ // falls through to TIMEOUT rather than ABORTED.
217
+ globalThis.fetch = async () => {
218
+ const err = new Error("aborted");
219
+ err.name = "AbortError";
220
+ throw err;
221
+ };
222
+
223
+ const result = await checkScannerPresentDetailed("192.168.1.50");
224
+
225
+ assert.equal(result.ok, false);
226
+ assert.equal(result.error.code, "TIMEOUT");
227
+ assert.equal(result.error.name, "AbortError");
228
+ });
229
+
230
+ it("returns INVALID_RESPONSE when the body isn't JSON", async () => {
231
+ globalThis.fetch = async () =>
232
+ new Response("<html>not json</html>", {
233
+ status: 200,
234
+ headers: { "Content-Type": "text/html" },
235
+ });
236
+
237
+ const result = await checkScannerPresentDetailed("192.168.1.50");
238
+ assert.equal(result.ok, false);
239
+ // The body parses as a non-object string, so the `typeof !== "object"`
240
+ // branch fires before the SessionId check.
241
+ assert.equal(result.error.code, "INVALID_RESPONSE");
242
+ assert.equal(result.error.status, 200);
243
+ assert.equal(result.error.attemptedUrl, "http://192.168.1.50/securelink");
244
+ });
245
+ });
246
+
247
+ describe("checkScannerPresentDetailed — AbortSignal support", () => {
248
+ it("propagates an external abort to the in-flight fetch", async () => {
249
+ let fetchSignal = null;
250
+ globalThis.fetch = async (_url, init) => {
251
+ fetchSignal = init.signal;
252
+ if (init.signal?.aborted) {
253
+ const err = new Error("aborted");
254
+ err.name = "AbortError";
255
+ throw err;
256
+ }
257
+ return new Promise((_resolve, reject) => {
258
+ init.signal.addEventListener("abort", () => {
259
+ const err = new Error("aborted");
260
+ err.name = "AbortError";
261
+ reject(err);
262
+ });
263
+ });
264
+ };
265
+
266
+ const controller = new AbortController();
267
+ const resultPromise = checkScannerPresentDetailed("192.168.1.50", {
268
+ signal: controller.signal,
269
+ });
270
+
271
+ // Give the fetch microtask a beat to start, then abort externally.
272
+ await Promise.resolve();
273
+ controller.abort();
274
+
275
+ const result = await resultPromise;
276
+ assert.equal(result.ok, false);
277
+ assert.equal(result.error.code, "ABORTED");
278
+ assert.equal(
279
+ fetchSignal.aborted,
280
+ true,
281
+ "the fetch's signal should have been aborted",
282
+ );
283
+ });
284
+ });
285
+
286
+ describe("postToScannerApi (throw-on-error wrapper) — JSON contract (VNT-1522)", () => {
287
+ // Tested via `getNetworkInfo`, the simplest of the thin wrappers around
288
+ // `postToScannerApi`. Behaviour must match here for every wrapper —
289
+ // they're identical one-liners.
290
+
291
+ it("returns the parsed object on a 2xx JSON response", async () => {
292
+ globalThis.fetch = async () => jsonResponse({ NetworkInfo: { ip: "x" } });
293
+ const result = await getNetworkInfo("192.168.1.50", { Command: "GetNetworkInfo" });
294
+ assert.deepEqual(result, { NetworkInfo: { ip: "x" } });
295
+ });
296
+
297
+ it("throws on a 2xx response with a non-JSON body (preserves the response.json() contract)", async () => {
298
+ // Pre-VNT-1522 behaviour: the wrapper called `response.json()` which
299
+ // threw `SyntaxError` on invalid JSON. The detailed-form refactor
300
+ // accepted non-JSON bodies (kept them as strings for diagnostics);
301
+ // the wrapper has to re-throw so existing callers — which all read
302
+ // object properties off the return value — fail loudly instead of
303
+ // silently receiving a string.
304
+ globalThis.fetch = async () =>
305
+ new Response("not json", {
306
+ status: 200,
307
+ headers: { "Content-Type": "text/plain" },
308
+ });
309
+
310
+ await assert.rejects(
311
+ getNetworkInfo("192.168.1.50", { Command: "GetNetworkInfo" }),
312
+ /not a JSON object/,
313
+ );
314
+ });
315
+
316
+ it("throws on a 2xx response with an empty body", async () => {
317
+ globalThis.fetch = async () =>
318
+ new Response("", {
319
+ status: 200,
320
+ headers: { "Content-Type": "application/json" },
321
+ });
322
+
323
+ await assert.rejects(
324
+ getNetworkInfo("192.168.1.50", { Command: "GetNetworkInfo" }),
325
+ /not a JSON object/,
326
+ );
327
+ });
328
+
329
+ it("throws on non-2xx HTTP responses (unchanged)", async () => {
330
+ globalThis.fetch = async () =>
331
+ new Response("Forbidden", { status: 403, statusText: "Forbidden" });
332
+
333
+ await assert.rejects(
334
+ getNetworkInfo("192.168.1.50", { Command: "GetNetworkInfo" }),
335
+ /Forbidden/,
336
+ );
337
+ });
338
+ });
package/CHANGELOG.md CHANGED
@@ -5,6 +5,46 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [1.4.5-beta.11] - 2026-05-21
9
+
10
+ ### Added
11
+ - **`scannerPresentDetailed(scannerAddress, options?)`** (VNT-1522). Returns
12
+ a structured `{ ok, error? }` result so host apps can distinguish
13
+ timeouts, host-app cancellations, network errors, HTTP-level errors,
14
+ application-layer scanner errors (e.g. HTTPS-only scanner refusing HTTP
15
+ with `HttpRC:403` / `ExtInformation` in the body), and missing-
16
+ `SessionId` responses. The `error` object carries `code` (`TIMEOUT` |
17
+ `ABORTED` | `NETWORK_ERROR` | `HTTP_ERROR` | `INVALID_RESPONSE` |
18
+ `SCANNER_ERROR`), `name`, `message`, `status`, `attemptedUrl`, and the
19
+ raw `scannerResponse` body for the `SCANNER_ERROR` case so callers can
20
+ surface vendor-specific text. `ABORTED` is distinct from `TIMEOUT` so
21
+ UI copy that branches on "scanner timed out" doesn't mislabel a user-
22
+ cancelled probe.
23
+ - Optional `signal: AbortSignal` parameter on `scannerPresentDetailed` so
24
+ host apps can cancel an in-flight probe on retry / unmount instead of
25
+ waiting for the internal 5s timeout.
26
+ - `Api.test.js` — first test suite in this repo, using Node's built-in
27
+ `node:test` runner (zero new dependencies). Covers timeout, HTTP 4xx,
28
+ network error, scanner-error-in-body, invalid response, AbortSignal
29
+ propagation, happy path, and case-insensitive scheme detection.
30
+
31
+ ### Fixed
32
+ - Case-insensitive scheme detection in `postToScannerApi` URL builder
33
+ (VNT-1522). Previously `HTTPS://192.168.1.50` was treated as having no
34
+ scheme and double-prefixed to `http://HTTPS://...`; now matches `/^https?:\/\//i`
35
+ per RFC 3986.
36
+
37
+ ### Changed
38
+ - `scannerPresent` (boolean) now delegates to `scannerPresentDetailed`.
39
+ One small behaviour difference: a failed Disconnect after a successful
40
+ Connect no longer flips the return to `false`. From the host app's
41
+ perspective the scanner IS present at that point, and the leaked
42
+ session times out server-side per the scanner's `IdleTimeout`. The
43
+ pre-refactor behaviour was a side-effect of the older try/catch wrap;
44
+ treating the disconnect as best-effort is the intended contract.
45
+ - `package.json` `test` script now runs Node's built-in `node --test`
46
+ runner instead of stubbing an error.
47
+
8
48
  ## [1.4.5-beta.10] - 2026-05-11
9
49
 
10
50
  ### Fixed
package/CloudScan.js CHANGED
@@ -13,6 +13,7 @@ import {
13
13
  getBarcodeLicenseKey,
14
14
  scannerChain,
15
15
  scannerPresent,
16
+ scannerPresentDetailed,
16
17
  } from "./ScannerApi.js";
17
18
  import { VerificationModal } from "./VerificationModal.js";
18
19
  import { checkAutoDmvEnabled, setEnvironment, createVerificationSession } from "./Api.js";
@@ -42,7 +43,7 @@ export const checkFaceCompareFeature = async (clientId) => {
42
43
  return await checkFaceComparisonFeatureLicense(clientId);
43
44
  }
44
45
 
45
- export { scannerPresent, setEnvironment };
46
+ export { scannerPresent, scannerPresentDetailed, setEnvironment };
46
47
 
47
48
  export async function readImageBarcode(imageFile, clientId = null, scannerType = 'Upload') {
48
49
  let returnObj = {
@@ -57,37 +57,49 @@ export async function localScannerChain(ambirApiAddress, clientId) {
57
57
  }
58
58
 
59
59
  // //2-3 Get serial number and Check the license
60
- // try {
61
- // //2 check scanner serial
62
- // const scannerSerialNumberResult = await LocalAPI.getScannerSerialNumber();
63
- // if (!scannerSerialNumberResult) {
64
- // clearImageAndCloseScannerConnection(); // Even if scanning has errors, always attempt to close the connection
65
- // return {
66
- // success: false,
67
- // errorMessages,
68
- // };
69
- // }
70
-
71
- // //3 check license
72
- // const isValid = await checkLicense(clientId, scannerSerialNumberResult);
73
-
74
- // if (isValid) {
75
- // // continue
76
- // } else {
77
- // buildError("Error: License error!");
78
- // return {
79
- // success: false,
80
- // errorMessages,
81
- // };
82
- // }
83
- // } catch (error) {
84
- // buildError(" Error: " + error.message);
85
- // console.error("An error occurred:", error.message);
86
- // return {
87
- // success: false,
88
- // errorMessages,
89
- // };
90
- // }
60
+ try {
61
+ //2 check scanner serial
62
+ const scannerSerialNumberResult = await getLocalScannerSerialNumber();
63
+ // Intentionally NOT logging the serial — it's a stable device
64
+ // identifier and shouldn't appear in customer-facing browser logs.
65
+ // Use the network tab if you need to inspect it during dev.
66
+ if (!scannerSerialNumberResult) {
67
+ console.log("No serial retrieved, closing connection");
68
+ await clearImageAndCloseScannerConnection(); // Even if scanning has errors, always attempt to close the connection
69
+ return {
70
+ success: false,
71
+ errorMessages,
72
+ };
73
+ }
74
+
75
+ const isValid = await checkLicense(clientId, { serialNumber: scannerSerialNumberResult });
76
+
77
+ if (isValid) {
78
+ // continue
79
+ } else {
80
+ // `checkLicense` collapses both "denied" and "request failed"
81
+ // (network blip, 5xx, etc.) into a bare `false`, so this branch
82
+ // is genuinely ambiguous. Copy reflects that until the SDK
83
+ // surfaces a structured result (VNT-1522 follow-up).
84
+ buildError(
85
+ "License check failed or this scanner is not licensed. " +
86
+ "Try again, or contact Verant ID if this persists."
87
+ );
88
+ await clearImageAndCloseScannerConnection();
89
+ return {
90
+ success: false,
91
+ errorMessages,
92
+ };
93
+ }
94
+ } catch (error) {
95
+ buildError("Serial/license check error: " + (error?.message ?? error));
96
+ console.error("Serial/license check error:", error);
97
+ await clearImageAndCloseScannerConnection();
98
+ return {
99
+ success: false,
100
+ errorMessages,
101
+ };
102
+ }
91
103
  }
92
104
 
93
105
  console.log("about to check scanner availability");
@@ -344,7 +356,10 @@ async function getLocalScannerSerialNumber() {
344
356
  return false;
345
357
  }
346
358
 
347
- return responseData;
359
+ // Ambir's /getserialnumber response shape isn't pinned down yet — handle both
360
+ // a bare string and a structured payload so we don't send a Hash on the wire.
361
+ if (typeof responseData === "string") return responseData;
362
+ return responseData.SerialNumber || responseData.serialNumber || false;
348
363
  }
349
364
 
350
365
  // step 3 - check if scanner is licensed with verant
package/ScannerApi.js CHANGED
@@ -1,6 +1,14 @@
1
1
  import * as API from "./Api.js";
2
2
  import { getProfile } from "./ScannerProfiles.js";
3
3
 
4
+ // Maps a scanner-profile field name to the corresponding license-server
5
+ // request key. Static — defined at module scope so it's not re-allocated
6
+ // on every scannerChain() call.
7
+ const SCANNER_FIELD_TO_LICENSE_KEY = {
8
+ ScannerMAC: "macAddress",
9
+ ScannerSerial: "serialNumber",
10
+ };
11
+
4
12
  let errorMessages = [];
5
13
  let selectedScannerAddress = "";
6
14
 
@@ -46,6 +54,19 @@ export async function scannerPresent(scannerAddress) {
46
54
  return await API.checkScannerPresent(scannerAddress);
47
55
  }
48
56
 
57
+ /**
58
+ * Detailed scanner-presence check (VNT-1522). Returns a structured result
59
+ * so callers can distinguish timeouts, network errors, HTTP errors,
60
+ * application-layer scanner errors (e.g. HTTPS-only scanner refusing HTTP),
61
+ * and missing-SessionId responses without inspecting the SDK's internals.
62
+ *
63
+ * Supports an optional `AbortSignal` so callers can cancel in-flight
64
+ * probes on retry / unmount.
65
+ */
66
+ export async function scannerPresentDetailed(scannerAddress, options) {
67
+ return await API.checkScannerPresentDetailed(scannerAddress, options);
68
+ }
69
+
49
70
  //This is the top level function that calls the other functions in order
50
71
  export async function scannerChain(
51
72
  scannerAddress,
@@ -111,10 +132,12 @@ export async function scannerChain(
111
132
  const identifierField = profile.licenseIdentifierField;
112
133
  const fallbackField = profile.licenseIdentifierFallback;
113
134
  let scannerIdentifier = existingParams[identifierField];
135
+ let usedField = identifierField;
114
136
 
115
137
  // Fall back to alternate field if primary is missing
116
138
  if (!scannerIdentifier && fallbackField) {
117
139
  scannerIdentifier = existingParams[fallbackField];
140
+ usedField = fallbackField;
118
141
  }
119
142
 
120
143
  if (!scannerIdentifier) {
@@ -128,9 +151,19 @@ export async function scannerChain(
128
151
  };
129
152
  }
130
153
 
154
+ const licenseKey = SCANNER_FIELD_TO_LICENSE_KEY[usedField];
155
+ if (!licenseKey) {
156
+ buildError(`Error: Unknown scanner identifier field "${usedField}" — cannot check license`);
157
+ await closeScannerConnection(currentSessionId);
158
+ return {
159
+ success: false,
160
+ errorMessages,
161
+ };
162
+ }
163
+
131
164
  // Check the license
132
165
  try {
133
- const isValid = await API.checkLicense(clientId, scannerIdentifier);
166
+ const isValid = await API.checkLicense(clientId, { [licenseKey]: scannerIdentifier });
134
167
 
135
168
  if (isValid) {
136
169
  // continue
package/index.d.ts CHANGED
@@ -143,10 +143,91 @@ declare module "verant_id_cloud_scan" {
143
143
  disconnect(): void;
144
144
  getState(): ConnectionStateValue;
145
145
  }
146
+
147
+ // Scanner presence — VNT-1522
148
+ //
149
+ // `scannerPresent` returns the simple boolean form (legacy); new code
150
+ // should prefer `scannerPresentDetailed` for richer error info.
151
+
152
+ export type ScannerPresentErrorCode =
153
+ | "TIMEOUT"
154
+ | "ABORTED"
155
+ | "NETWORK_ERROR"
156
+ | "HTTP_ERROR"
157
+ | "INVALID_RESPONSE"
158
+ | "SCANNER_ERROR";
159
+
160
+ export interface ScannerPresentError {
161
+ /** SDK-defined tag for branching UX without parsing `message`. */
162
+ code: ScannerPresentErrorCode;
163
+ /** Underlying error class name (e.g. "TypeError", "AbortError", "HTTPError"). */
164
+ name: string;
165
+ /** Human-readable detail; for `SCANNER_ERROR` this includes vendor-supplied text when present. */
166
+ message: string;
167
+ /** HTTP status when a response was received; for `SCANNER_ERROR` may be the scanner-layer `HttpRC` if it was a parseable number. */
168
+ status?: number;
169
+ /** The /securelink URL the SDK probed. */
170
+ attemptedUrl: string;
171
+ /** For `SCANNER_ERROR`: raw scanner response body (vendor-specific fields like `ExtInformation`, `ErrorInformation`). */
172
+ scannerResponse?: unknown;
173
+ }
174
+
175
+ export type ScannerPresentResult =
176
+ | { ok: true }
177
+ | { ok: false; error: ScannerPresentError };
178
+
179
+ export interface ScannerPresentOptions {
180
+ /** External signal to cancel the probe (retry, unmount). Forwarded into the underlying fetch. */
181
+ signal?: AbortSignal;
182
+ }
183
+
184
+ /**
185
+ * Detailed scanner-presence check. Returns a structured result so callers
186
+ * can distinguish timeouts, network errors, HTTP errors, application-layer
187
+ * scanner errors (HTTPS-only scanner refusing HTTP, etc.), and missing-
188
+ * SessionId responses without inspecting the SDK's internals.
189
+ */
190
+ export function scannerPresentDetailed(
191
+ scannerAddress: string,
192
+ options?: ScannerPresentOptions,
193
+ ): Promise<ScannerPresentResult>;
146
194
  }
147
195
 
148
196
  export function scannerPresent(scannerAddress: string): Promise<boolean>;
149
197
 
198
+ // Same signature as the in-module declaration above; mirrored here so it
199
+ // resolves under both lookup paths (matches `scannerPresent`'s existing
200
+ // outside-the-module placement).
201
+ export type ScannerPresentErrorCode =
202
+ | "TIMEOUT"
203
+ | "ABORTED"
204
+ | "NETWORK_ERROR"
205
+ | "HTTP_ERROR"
206
+ | "INVALID_RESPONSE"
207
+ | "SCANNER_ERROR";
208
+
209
+ export interface ScannerPresentError {
210
+ code: ScannerPresentErrorCode;
211
+ name: string;
212
+ message: string;
213
+ status?: number;
214
+ attemptedUrl: string;
215
+ scannerResponse?: unknown;
216
+ }
217
+
218
+ export type ScannerPresentResult =
219
+ | { ok: true }
220
+ | { ok: false; error: ScannerPresentError };
221
+
222
+ export interface ScannerPresentOptions {
223
+ signal?: AbortSignal;
224
+ }
225
+
226
+ export function scannerPresentDetailed(
227
+ scannerAddress: string,
228
+ options?: ScannerPresentOptions,
229
+ ): Promise<ScannerPresentResult>;
230
+
150
231
  /**
151
232
  * Explicitly set the API environment.
152
233
  * By default, production is used for all hosts (including localhost).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "verant_id_cloud_scan",
3
- "version": "1.4.5-beta.10",
3
+ "version": "1.4.5-beta.11",
4
4
  "description": "Verant ID Cloud Scan NPM Library",
5
5
  "main": "CloudScan.js",
6
6
  "types": "index.d.ts",
@@ -8,7 +8,7 @@
8
8
  "ws": false
9
9
  },
10
10
  "scripts": {
11
- "test": "echo \"Error: no test specified\" && exit 1"
11
+ "test": "node --test"
12
12
  },
13
13
  "author": "Verant ID Inc",
14
14
  "license": "ISC",