verant_id_cloud_scan 1.4.5 → 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.
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 ADDED
@@ -0,0 +1,254 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [1.4.6-beta.0] - 2026-07-01
9
+
10
+ > First beta of the `1.4.6` line, cut from the reset (SF-free) `staging`
11
+ > (`1.4.5` is already published as `latest`, so further `1.4.5-beta`s would sort
12
+ > below it). Does **not** include the VNT-1464 / AAMVA 3.0 work
13
+ > (`extractPortraitFromLicense`, `person_digital_image`) — that rejoins on a
14
+ > later beta once AAMVA 3.0 ships.
15
+
16
+ ### Added
17
+ - **`checkDigitalIDFeature(clientId)`** (VNT-1643). Returns `true` only when the
18
+ client has a `mobile_id` feature license that exists and is unexpired, by
19
+ POSTing to the license server's `/mobile_id/check_license` endpoint. Mirrors
20
+ `checkDmvFeature` / `checkFaceCompareFeature` through `Api.js` →
21
+ `ScannerApi.js` → `CloudScan.js`, and is typed in `index.d.ts`. Lets host apps
22
+ gate the "Read Digital ID" capture method on MDL licensing.
23
+
24
+ ## [1.4.5-beta.11] - 2026-05-21
25
+
26
+ ### Added
27
+ - **`scannerPresentDetailed(scannerAddress, options?)`** (VNT-1522). Returns
28
+ a structured `{ ok, error? }` result so host apps can distinguish
29
+ timeouts, host-app cancellations, network errors, HTTP-level errors,
30
+ application-layer scanner errors (e.g. HTTPS-only scanner refusing HTTP
31
+ with `HttpRC:403` / `ExtInformation` in the body), and missing-
32
+ `SessionId` responses. The `error` object carries `code` (`TIMEOUT` |
33
+ `ABORTED` | `NETWORK_ERROR` | `HTTP_ERROR` | `INVALID_RESPONSE` |
34
+ `SCANNER_ERROR`), `name`, `message`, `status`, `attemptedUrl`, and the
35
+ raw `scannerResponse` body for the `SCANNER_ERROR` case so callers can
36
+ surface vendor-specific text. `ABORTED` is distinct from `TIMEOUT` so
37
+ UI copy that branches on "scanner timed out" doesn't mislabel a user-
38
+ cancelled probe.
39
+ - Optional `signal: AbortSignal` parameter on `scannerPresentDetailed` so
40
+ host apps can cancel an in-flight probe on retry / unmount instead of
41
+ waiting for the internal 5s timeout.
42
+ - `Api.test.js` — first test suite in this repo, using Node's built-in
43
+ `node:test` runner (zero new dependencies). Covers timeout, HTTP 4xx,
44
+ network error, scanner-error-in-body, invalid response, AbortSignal
45
+ propagation, happy path, and case-insensitive scheme detection.
46
+
47
+ ### Fixed
48
+ - Case-insensitive scheme detection in `postToScannerApi` URL builder
49
+ (VNT-1522). Previously `HTTPS://192.168.1.50` was treated as having no
50
+ scheme and double-prefixed to `http://HTTPS://...`; now matches `/^https?:\/\//i`
51
+ per RFC 3986.
52
+
53
+ ### Changed
54
+ - `scannerPresent` (boolean) now delegates to `scannerPresentDetailed`.
55
+ One small behaviour difference: a failed Disconnect after a successful
56
+ Connect no longer flips the return to `false`. From the host app's
57
+ perspective the scanner IS present at that point, and the leaked
58
+ session times out server-side per the scanner's `IdleTimeout`. The
59
+ pre-refactor behaviour was a side-effect of the older try/catch wrap;
60
+ treating the disconnect as best-effort is the intended contract.
61
+ - `package.json` `test` script now runs Node's built-in `node --test`
62
+ runner instead of stubbing an error.
63
+
64
+ ## [1.4.5-beta.10] - 2026-05-11
65
+
66
+ ### Fixed
67
+ - iOS HPKE decryption failure on non-default hosts (e.g. ngrok tunnels,
68
+ custom staging domains). `startMobileVerification()` now derives the
69
+ holder page origin from `mobileVerifyBaseUrl` (falling back to the
70
+ CSR page's `window.location.origin` for same-origin dev setups) and
71
+ forwards it to `POST /sessions`. The backend uses this origin to
72
+ build its SessionTranscript, which must match the origin the mobile
73
+ wallet binds to. Without it, the server fell back to a per-environment
74
+ default that disagreed with the wallet's view, causing
75
+ `"Decryption failed: Could not decrypt credential response"` (400).
76
+ - `WebSocketClient.connect()` no longer hangs forever when the underlying
77
+ socket emits `onerror` or `onclose` before `onopen`. The returned Promise
78
+ now rejects with a tagged error in those paths so awaiters can handle
79
+ connection failures instead of waiting indefinitely.
80
+ - `startMobileVerification()` QR code URL construction now uses `new URL()`
81
+ to safely combine `mobileVerifyBaseUrl` with the session id. Previously,
82
+ a base URL containing a query string or hash (e.g. `https://host/path?x=1`)
83
+ produced an invalid result like `https://host/path?x=1/{session_id}`.
84
+ - `createVerificationSession()` no longer throws "body stream already
85
+ read" when an error response is not valid JSON. The response body is
86
+ now read once as text, then parsed in a try/catch instead of using
87
+ `response.json()` followed by a `response.text()` fallback.
88
+ - `startMobileVerification()` no longer emits duplicate `onError`
89
+ callbacks when a WebSocket failure surfaces via both
90
+ `WebSocketClient.connect()` and the outer catch. Errors already
91
+ reported by a lower layer carry an `error.reported` flag, and the
92
+ outer catch skips re-emitting in that case.
93
+ - `WebSocketClient.connect()` now invokes `callbacks.onError` from the
94
+ close-before-open path (previously only `onerror` did). Direct
95
+ `connectToVerificationSession` callers receive the failure event
96
+ even when no preceding `onerror` fires (e.g., server closes the
97
+ socket immediately). The synthesized error carries `reported: true`
98
+ so `startMobileVerification`'s outer catch still avoids double-emit.
99
+ - `WebSocketClient._handleMessage()` no longer logs the raw WebSocket
100
+ frame to the console. Verification payloads carry PII (mDL fields,
101
+ portrait base64), so the raw `console.log` of `data` was dropped.
102
+ Per-route type logs below remain for debug visibility.
103
+ - `WebSocketClient.connect()` now releases its socket reference when
104
+ `onerror` fires before `onopen`, so callers can immediately retry
105
+ `connect()` without hitting "WebSocket connection already exists"
106
+ in implementations where `onclose` doesn't fire after `onerror`.
107
+ - `WebSocketClient` now transitions to the `ERROR` state (not
108
+ `CLOSED`) when the socket closes before opening, so
109
+ `status`/`onStatusChange` reflect a connection failure rather than
110
+ a normal disconnect. Post-open closes still transition to `CLOSED`.
111
+ Now driven by an explicit `hasOpened` flag (set in `onopen`) — the
112
+ previous version used the Promise's `settled` flag, which is also
113
+ set by pre-open `onerror` and would incorrectly downgrade the
114
+ ERROR state to CLOSED when the follow-up onclose fired.
115
+ - `WebSocketClient.connect()` synchronous-throw rejections now carry
116
+ a stable `.code` (`NO_WEBSOCKET_IMPL` when the Node fallback fails,
117
+ `CONNECTION_FAILED` when the `WebSocket` constructor throws). The
118
+ thrown/rejected error now matches the structured `onError` payload,
119
+ so awaiters of the Promise can branch on the failure reason without
120
+ relying on the `onError` callback.
121
+ - `WebSocketClient.disconnect()` called while the socket is still
122
+ CONNECTING no longer reports a spurious connection error. The
123
+ `onclose` triggered by the user-initiated close used to flip
124
+ `status` from `closed` back to `error` and emit `onError`; it now
125
+ detects an instance-level `_closeRequested` flag set by
126
+ `disconnect()` and skips both. The pending `connect()` Promise
127
+ rejects with `code: 'CLIENT_DISCONNECTED'` so awaiters resolve.
128
+ - `WebSocketClient._handleMessage()` now decodes binary WebSocket
129
+ frames (Node `Buffer` from the `ws` package, browser `ArrayBuffer`
130
+ / `Uint8Array` when `binaryType === 'arraybuffer'`) via `TextDecoder`
131
+ before `JSON.parse`. Previously, anything that wasn't a string
132
+ passed through as-is, so `routeKey` was always undefined and every
133
+ frame was silently dropped as "Unknown message route."
134
+ - Removed informational `console.log` calls from the mobile
135
+ verification code path (`WebSocketClient` + `startMobileVerification`).
136
+ Several of them embedded the `session_id` or QR target URL, which
137
+ is effectively a session access token and shouldn't be sent to
138
+ application logs / monitoring. Diagnostic `console.warn` and
139
+ `console.error` calls are preserved.
140
+
141
+ ### Changed
142
+ - `startMobileVerification()` error callbacks now emit stage-specific
143
+ codes — `SESSION_CREATION_FAILED`, `QR_GENERATION_FAILED`, or
144
+ `WEBSOCKET_CONNECTION_FAILED` — so callers can distinguish which step
145
+ failed. Upstream `error.code` values are preserved when present.
146
+
147
+ ### Internal
148
+ - The internal `createVerificationSession()` API helper gained an
149
+ optional third `origin` parameter; `startMobileVerification()` now
150
+ populates it from `mobileVerifyBaseUrl` (or `window.location.origin`
151
+ for same-origin dev). Host apps don't need to change anything — keep
152
+ calling `startMobileVerification()` as before.
153
+ - `package.json` now declares a `"browser": { "ws": false }` field so
154
+ browser bundlers (Vite / Webpack / Rollup) stub out the Node-only
155
+ `ws` optional dependency instead of trying to bundle it. Has no
156
+ effect in Node — the dynamic `import('ws')` fallback still works.
157
+
158
+ ## [1.4.5-beta.9] - 2026-04-21
159
+
160
+ ### Added
161
+ - Mobile ID verification support (VNT-1409, VNT-1419, VNT-1429, VNT-1455)
162
+ - Added `startMobileVerification()` function to initiate mobile verification sessions
163
+ - Added `createVerificationSession()` API function to create verification sessions with user ID and client ID
164
+ - Added WebSocket client (`WebSocketClient.js`) for real-time verification updates from AWS API Gateway
165
+ - Added mobile verification server address configuration with environment detection
166
+ - QR code generation for mobile verification sessions with customizable base URLs
167
+ - Real-time verification callbacks: `onVerificationStarted`, `onResult`, `onStatusChange`, `onError`
168
+ - Session management with session ID, session URL, WebSocket URL, and QR code SVG
169
+ - Connection state management (CONNECTING, OPEN, CLOSED, ERROR) for WebSocket connections
170
+
171
+ ### Changed
172
+ - Updated environment detection to support mobile verification endpoints
173
+ - Mobile verification uses `staging.mdl.verantid.bluerocket.us` for staging
174
+ - Mobile verification uses `mdl.verantid.bluerocket.us` for production
175
+ - Updated scanner type detection to use `scannerProfile` from scanner response in auto-DMV verification
176
+ - Enhanced TypeScript definitions (`index.d.ts`) to include mobile verification types and callbacks
177
+
178
+ ## [1.4.5-beta.8] - 2026-04-15
179
+
180
+ ### Changed
181
+ - Moved UV/IR image type definitions to scanner profile configuration
182
+ - Added `frontUvImageType`, `backUvImageType`, `frontIrImageType`, and `backIrImageType` to IDXpressPlus profile
183
+ - Updated image extraction logic to use profile-defined UV/IR types instead of hardcoded strings
184
+ - Updated TypeScript definitions (`index.d.ts`) to include UV/IR base64 fields in `scanId` return type
185
+ - Removed UV/IR fields from local scanner functions (`localScanId`, `localContinueScanId`) as local scanners do not support UV/IR imaging
186
+
187
+ ### Fixed
188
+ - Eliminated redundant variable declarations in `ScannerApi.js` by declaring UV/IR variables at function scope
189
+
190
+ ## [1.4.5-beta.7] - 2026-04-15
191
+
192
+ ### Changed
193
+ - Internal refactoring and code review fixes
194
+
195
+ ## [1.4.5-beta.6] - 2026-04-15
196
+
197
+ ### Added
198
+ - UV/IR image support for IDXpressPlus scanner profile
199
+ - Added `licenseFrontUvBase64`, `licenseBackUvBase64`, `licenseFrontIrBase64`, and `licenseBackIrBase64` fields to scan results
200
+ - Scanner now captures and returns ultraviolet (UV) and infrared (IR) images alongside standard color images
201
+ - Image types supported: fclr (front color), rclr (rear color), fclruv (front UV), rclruv (rear UV), fgsir (front IR), rgsir (rear IR)
202
+
203
+ ### Fixed
204
+ - Image extraction logic now captures all image types from scanner response instead of only front/back color images
205
+
206
+ ### Changed
207
+ - Updated IDXpressPlus scanner profile to include UV/IR parameters in default settings and restore configuration
208
+ - Added `clrUvIrResolution`, `fclrUvEnabled`, `rclrUvEnabled`, `fgsIrEnabled`, and `rgsIrEnabled` to `restoreParamKeys`
209
+ - Updated `ScannerApi.js` to extract UV/IR images from GetDocument response
210
+ - Updated `CloudScan.js` `scanId` function to return UV/IR image data (`licenseFrontUvBase64`, `licenseBackUvBase64`, `licenseFrontIrBase64`, `licenseBackIrBase64`)
211
+ - Local scanner functions (`localScanId`, `localContinueScanId`) do not include UV/IR fields as local scanners (VerantId6S) do not support UV/IR imaging
212
+
213
+ ## [1.4.5-beta.5] - 2026-04-14
214
+
215
+ ### Changed
216
+ - Applied dynamic environment URLs to face comparison feature
217
+
218
+ ## [1.4.5-beta.4] - 2026-04-14
219
+
220
+ ### Added
221
+ - Error validation for invalid environment type
222
+
223
+ ### Changed
224
+ - Updated default server URLs to production endpoints
225
+
226
+ ## [1.4.5-beta.3] - 2026-04-13
227
+
228
+ ### Fixed
229
+ - Various bug fixes and code quality improvements
230
+
231
+ ## [1.4.5-beta.2] - 2026-04-12
232
+
233
+ ### Added
234
+ - Dynamic environment detection for server URLs
235
+ - Support for multiple deployment environments
236
+
237
+ ### Changed
238
+ - Server address handling now supports trailing slashes
239
+ - Code improvements based on Copilot suggestions
240
+
241
+ ## [1.4.5-beta.1] - 2026-04-11
242
+
243
+ ### Added
244
+ - IDXpress scanner profiles support
245
+ - New scanner profile configuration options
246
+
247
+ ---
248
+
249
+ ## Version Number Guide
250
+
251
+ - **Major** (1.x.x): Breaking changes
252
+ - **Minor** (x.4.x): New features, backwards compatible
253
+ - **Patch** (x.x.5): Bug fixes, backwards compatible
254
+ - **Beta** (x.x.x-beta.x): Pre-release versions for testing