Skip to main content

Browser scanner support for portal inventory workflows

Scope

This report contextualizes the Linear request titled “Serial/USB scanner support in browser for inventory workflows” against the current retail portal and evaluates keyboard-wedge, Web Serial, and WebHID approaches.

The issue identifier, comments, priority, and any acceptance criteria beyond the title and description supplied for this investigation were not available in this session: no Linear connector was mounted, and the Linear web session required authentication. The recommendations below therefore implement the supplied scope without inferring additional Linear requirements.

Decision

The feature is feasible, but it should not treat every USB scanner as one browser device class.

  1. Ship keyboard-wedge capture as the cross-browser baseline. Configure compatible scanners as USB HID keyboards with an Enter suffix. The portal receives ordinary keyboard input, needs no device permission, and continues to work in Chrome, Edge, Firefox, and Safari.
  2. Add Web Serial as an optional Chromium desktop enhancement for explicitly supported scanners configured in USB CDC or virtual-COM mode. It requires HTTPS, a user-initiated device chooser, matching serial settings, stream framing, and explicit disconnect/error handling.
  3. Do not build a generic WebHID adapter. Standard keyboard-mode scanners already arrive through KeyboardEvent; WebHID is intended for HID functionality not exposed through high-level browser input, and raw reports require device-specific parsing. Add a WebHID adapter only for a validated scanner model whose required interface is exposed and cannot operate as a wedge or serial device.
  4. A scan must stage workflow state, not perform an inventory mutation. The user must review the selected product and quantity and press the existing action button. This avoids duplicate receives from composite devices, retries, focus mistakes, and disconnect/reconnect races.
  5. Keep the scanner seam local to the active workflow. A reusable ScannerCapture module should normalize keyboard and serial input into one onScan callback. It must not be a global route-agnostic scanner manager because a scan can mean product lookup, serialized-item input, transfer-line selection, or purchase-order receipt depending on the visible workflow.

No backend change is required for a first release that only resolves a scanned barcode and fills the existing Receive Inventory form. A high-throughput count-session feature does require a verified idempotent backend contract before it can safely replay or batch adjustments.

Implementation status

Implemented on 2026-08-05 in the retail portal:

  • ScannerCapture.tsx provides focused manual/keyboard-wedge entry, Web Serial feature detection and chooser-driven connection, editable serial settings, lifecycle status, cleanup, and cross-transport duplicate suppression.
  • scan-framer.ts incrementally decodes arbitrary chunks, accepts CR, LF, CRLF, and Tab delimiters, bounds frames, discards overflow until the next delimiter, and expires stale partial input.
  • serial-scanner.ts owns port selection, opening, stream reads, fatal-error reporting, reader-lock release, and port closure behind injected browser capability interfaces.
  • InventoryPage.tsx mounts capture only in Receive Inventory, resolves exact selected-store barcodes through api.products.byBarcode, ignores stale lookup results after store changes or dialog close, and stages the product without calling api.inventory.receive.
  • Contract tests cover wedge input, Web Serial lifecycle and settings, framing boundaries, cross-transport deduplication, exact lookup, 404 recovery, stale lookup protection, and the no-auto-mutation invariant.

Physical scanner validation and a supported-device matrix remain release-readiness work. The generic chooser and editable settings are intentional until manufacturer/model, VID/PID, USB mode, framing, and fleet policy are verified. WebHID and adjacent inventory workflows remain out of scope.

Current repository context

Retail inventory page

apps/websites/portals/retail/src/pages/InventoryPage.tsx owns the selected-store inventory workflow.

Observed behavior:

  • The route is store-scoped and guarded by store.inventory.read; mutations are shown when the user has store.inventory.write.
  • loadAllInventoryItems pages through api.inventory.list in batches of 100. The InventoryItem contract in apps/websites/portals/retail/src/lib/api/catalog.ts contains productId and productName, but not barcode.
  • The Receive Inventory dialog already exposes a product search field with the placeholder “Search by product name or barcode...”. handleReceiveProductSearch calls api.products.list after two characters, while receiveProductMatches filters the locally loaded products by name, display value, or barcode.
  • Selecting a product only updates receiveForm.productId. handleReceive posts { productId, quantity, notes } through api.inventory.receive only after the user presses Receive.
  • The page already protects store changes and stale asynchronous responses with scopeKey, activeScopeRef, and request sequence refs. Scanner-triggered lookup must follow the same pattern.
  • filterInventoryItems in apps/websites/portals/retail/src/pages/inventory-helpers.ts searches inventory rows by product name or product ID only because inventory rows do not carry barcodes.

The implemented scanner integration lives in the Receive Inventory dialog. It resolves a scan to a product, selects that product, and leaves quantity and final submission unchanged.

Existing exact-barcode lookup

productsApi.byBarcode in apps/websites/portals/retail/src/lib/api/catalog.ts already calls:

GET /stores/{storeId}/products/barcode/{barcode}

ProductController.getByBarcode requires store.products.read. ProductRepository.findByBarcode canonicalizes through ProductBarcodeIdentity before querying. apps/microservices/merchant-api/src/main/java/com/myriad/merchant_api/product/ProductBarcodeIdentity.kt trims display whitespace, removes whitespace and hyphens for identity comparison, and uppercases with Locale.ROOT.

This endpoint is a better scanner lookup than paginated product search:

  • it expresses exact barcode intent;
  • it preserves the stored display barcode and leading zeros;
  • it uses the backend’s canonical identity rules rather than duplicating them in TypeScript;
  • it avoids ambiguity from substring search.

The client should trim only framing whitespace and pass the remaining scan as a string. It must never parse a barcode as a number.

Authorization is now explicit at the portal boundary: Receive Inventory and barcode lookup are exposed only when the caller has both store.inventory.write and store.products.read. Inventory-only callers can still load the inventory page without a product-list request. This preserves the product endpoint's existing authorization instead of silently broadening it; any future requirement for inventory writers without product-read access needs an inventory-owned lookup contract.

Backend inventory mutation semantics

inventoryApi.receive posts to /stores/{storeId}/inventory/receive. ReceiveInventoryRequest requires a product UUID and integer quantity of at least one. InventoryService.receive performs a delta increment and writes a ledger row.

inventoryApi.adjust posts a quantity delta and reason to /stores/{storeId}/inventory/adjust. AdjustInventoryRequest can persist a sourceWorkflow provenance tag.

Neither InventoryController.receive nor InventoryController.adjust currently accepts or enforces an idempotency key. That matters for scan-driven automation: retrying the same receive or adjustment can apply the delta twice. The mobile code under apps/mobile/peak-mobile models count sessions and sends X-Idempotency-Key, but the current merchant inventory controller does not consume that header. The browser feature must not treat the mobile client’s metadata as proof of backend replay safety.

Consequences:

  • product selection by scan is safe with existing endpoints;
  • automatic receive-on-scan is not safe;
  • offline queues, retryable count sessions, and bulk scan reconciliation need a separate backend design with durable operation identity and request-fingerprint conflict handling.

Adjacent portal workflows

The same capture module can later support these workflows without moving domain decisions into the scanner module:

  • InventoryToolsPage.tsx — stock-transfer creation has product and quantity rows; a scan can resolve a product and add or increment a staged row. Transfer receiving already stages quantities before the user presses Receive.
  • InventoryToolsPage.tsx — serialized inventory has distinct product and serial-number fields. Each field needs an explicit scan target; a global scan listener cannot safely decide whether the value is a product barcode or an item serial.
  • PurchaseOrdersPage.tsx — purchase-order receiving stages per-line quantities. A scan can resolve a product, match an order line, and increment a staged receipt count, but final receipt remains explicit.
  • The main inventory list can later use an exact barcode lookup followed by api.inventory.get(productId) to open detail. The current inventory list itself cannot filter directly by barcode because InventoryItem omits it.

Existing scanner precedent in Android

The browser implementation cannot reuse the Kotlin transport code, but the existing Android scanner module records relevant production behavior:

  • ScannerManager.kt merges keyboard-wedge and TTY serial sources.
  • TtyBarcodeFramer.kt preserves partial frames across reads and treats CR, LF, Tab, and configured control terminators as frame boundaries.
  • BarcodeDeduper.kt suppresses near-simultaneous duplicate payloads from composite HID-plus-serial scanners while allowing an intentional later re-scan.
  • TtySerialScanner.kt treats EOF, unplug, framing errors, and unreadable ports as connection lifecycle events rather than complete barcodes.

The browser module should preserve those behavioral lessons: chunk-aware framing, bounded state, composite-device duplicate handling, hot-unplug cleanup, and no assumption that detecting a port proves it is the intended scanner.

Hosting and browser policy

The retail portal is deployed over HTTPS and apps/websites/portals/retail/public/_headers sets a Permissions-Policy that denies camera, microphone, and geolocation but does not deny serial or hid. Web Serial and WebHID are secure-context, permission-controlled features; the current top-level same-origin deployment does not introduce an iframe delegation requirement.

The website now implements navigator.serial through narrow local capability interfaces in serial-scanner.ts. It intentionally does not implement navigator.hid, SerialPort, or HIDDevice globals.

Transport feasibility

ApproachFeasibilityBrowser reachPermission UXDevice requirementsRecommendation
Keyboard wedgeHighBrowser-independent keyboard inputNone beyond normal focusScanner must expose HID keyboard mode; Enter/CR/LF suffix strongly preferredBaseline and fallback
Web SerialMedium to high for known modelsChromium desktop; feature-detect at runtimeUser gesture and chooser for first grant; previously granted ports available through getPorts()Scanner must expose a serial/CDC/virtual-COM port and portal settings must match baud/data/parity/stop/flow controlOptional enhancement after device validation
WebHIDLow as a generic solutionChromium desktop; feature-detect at runtimeUser gesture and device chooserA usable non-protected HID interface plus model-specific report descriptors and parsingDefer unless a named model requires it

Keyboard-wedge baseline

A keyboard-mode scanner uses the operating system’s HID keyboard driver, so the browser receives the scan as ordinary input. The WebHID specification explicitly describes keyboards as already supported through high-level KeyboardEvent input and says WebHID is not intended for devices adequately handled that way (WebHID specification).

Scanner configuration is operationally important. Zebra’s first-party scanner documentation exposes a USB HID Keyboard device type and an Add Enter Key (Carriage Return/Line Feed) suffix option (USB device type, Enter key suffix). Other vendors use equivalent “keyboard wedge” or “USB keyboard” terminology.

Recommended behavior:

  • require an explicitly focused scan input in the active workflow;
  • submit the buffered value on Enter, and optionally accept Tab only when the device matrix requires it;
  • ignore modifier shortcuts and IME composition;
  • preserve leading zeros and printable alphanumeric content;
  • do not use a document-wide timing heuristic to capture every fast sequence of keystrokes;
  • keep manual typing available in the same input.

The browser cannot inherently distinguish a scanner pretending to be a keyboard from a fast typist. Explicit focus plus a terminator is the reliable and accessible contract.

Web Serial enhancement

The Web Serial specification exposes navigator.serial only in secure contexts. requestPort() is permission-policy controlled, requires transient user activation, and prompts the user to select a port. getPorts() returns ports previously granted to the origin. The interface also exposes connect/disconnect events.

Chrome’s implementation guidance confirms the operational model (Chrome Web Serial documentation):

  • feature-detect with "serial" in navigator;
  • call requestPort() from a click or equivalent user gesture;
  • optionally filter by USB vendor and product IDs;
  • call port.open() with settings from the device documentation;
  • read Uint8Array chunks from port.readable;
  • release the reader lock before closing;
  • treat non-fatal read errors separately from fatal removal, where port.readable becomes null.

A scan is not guaranteed to align with one stream chunk. The adapter needs an incremental framer that:

  • decodes across chunk boundaries;
  • emits zero, one, or multiple frames per read;
  • handles the validated device terminators, normally CR, LF, CRLF, or Tab;
  • discards empty frames;
  • enforces a bounded maximum frame and resets after overflow or timeout;
  • cancels and releases its reader before closing the port;
  • clears partial state on disconnect;
  • never records raw scan data in logs, analytics, or error telemetry.

Serial configuration is not universal. Baud rate, data bits, parity, stop bits, flow control, terminator, and USB VID/PID must come from a supported-device matrix. A broad unfiltered chooser is acceptable for a development spike, not a finished managed-store experience.

Firefox and Safari do not expose these direct device interfaces according to the browser-support tables in Chrome’s Web Serial/WebHID documentation. The portal must therefore retain the keyboard/manual path and present direct serial controls only when feature detection succeeds.

Managed Chrome can centrally deny or allow serial requests. DefaultSerialGuardSetting can block sites from requesting ports or allow them to ask, and Chrome also provides origin/device allow-list policies (Chrome Enterprise serial policy). Permission denial must be a normal UI state, not an application error or a reason to disable wedge input.

Why generic WebHID is not the first implementation

The WebHID specification allows user agents to deny keyboard-like top-level collections and requires explicit chooser-based access because HID devices may expose sensitive or dangerous functionality. Chrome’s guide describes input as device-specific binary reports interpreted through report descriptors (Chrome WebHID documentation).

For scanners this yields two distinct cases:

  • A standard HID keyboard scanner should use normal keyboard events. Asking for it through WebHID adds no portable value and the keyboard collection may be unavailable.
  • A scanner exposing a vendor-specific HID interface may be usable, but implementation then needs exact VID/PID/usage filters, report IDs, report layouts, and framing rules for that model.

WebHID should therefore be a later adapter behind the same capture interface, justified by a validated hardware requirement. It is not a fallback for Web Serial and is not a universal “USB scanner” interface.

The implementation uses a deep ScannerCapture module under the retail portal:

apps/websites/portals/retail/src/components/scanner/ScannerCapture.tsx
apps/websites/portals/retail/src/lib/scanner/serial-scanner.ts
apps/websites/portals/retail/src/lib/scanner/scan-framer.ts

External interface:

type Scan = {
value: string;
source: "keyboard-wedge" | "serial";
};

type ScannerCaptureProps = {
label: string;
disabled?: boolean;
onScan: (scan: Scan) => void | Promise<void>;
};

The exact names can follow implementation conventions, but callers should learn only: what the scan means in this workflow, whether capture is disabled, and how to handle a completed value.

The module implementation owns:

  • the focused manual/wedge input and terminator behavior;
  • Web Serial feature detection, permission chooser, connection status, reconnect/disconnect controls, and advanced serial settings;
  • incremental decoding and framing;
  • buffer bounds and partial-frame timeout;
  • cleanup on unmount, route change, store change, and device disconnect;
  • same-payload duplicate suppression when a composite scanner emits on both wedge and serial paths;
  • accessible status and error copy;
  • privacy: raw payloads never leave the workflow callback for analytics or logs.

Workflow modules own:

  • whether a value is a product barcode, item serial, or another identifier;
  • store- and permission-scoped API lookup;
  • stale-request protection;
  • product/line matching;
  • staged quantity changes;
  • user confirmation and mutation.

Do not add a global scanner context that dispatches based on the current URL. Local mount/unmount provides a safer seam: inactive pages cannot consume scans, dialogs can make the target explicit, and serialized-product versus serial-number capture remains unambiguous.

Duplicate handling

A composite scanner may emit the same physical scan through both keyboard and serial paths. Deduplication must be narrow:

  • suppress the same normalized payload arriving from different transports within a short validated window;
  • do not suppress two deliberate scans from the same transport, because repeated item scans may intentionally increment a staged count;
  • do not slide the suppression window when dropping a duplicate;
  • test both a short UPC and a longer payload.

If reliable cross-transport attribution is unavailable in the wedge path, the safer first Web Serial release is mutually exclusive capture: serial mode does not keep the wedge input focused.

Integration plan

Phase 0 — hardware contract

Before promising direct serial support, record for each supported scanner:

  • manufacturer and model;
  • USB VID/PID;
  • keyboard, serial/CDC, composite, and raw-HID modes;
  • browser-visible device name;
  • baud/data/parity/stop/flow settings;
  • suffix/terminator configuration;
  • target operating systems and managed-browser policy.

The wedge release is not blocked on a serial-mode matrix; direct Web Serial filters and defaults are.

Phase 1 — wedge/manual scan-to-select

  1. Add the capture module with focused input and Enter framing.
  2. Mount it in the Receive Inventory dialog.
  3. On scan, call api.products.byBarcode(orgId, storeId, scan.value).
  4. Apply the existing request-sequence and scopeKey guards.
  5. Merge/select the returned product and keep quantity editable.
  6. On 404, show “No product in this store matches that barcode” without clearing unrelated form state.
  7. Keep the existing Receive button as the only mutation trigger.

This phase works across browsers and does not require browser-device typings or permission UX.

Phase 2 — Web Serial adapter

  1. Add serial DOM typings locally or through the repository’s approved TypeScript dependency path.
  2. Add Connect, Disconnect, status, and optional advanced-settings UI inside ScannerCapture.
  3. Filter the chooser to validated devices when the matrix exists.
  4. Implement and unit-test the bounded framer independently of navigator.serial.
  5. Inject or wrap the browser serial capability so adapter lifecycle tests use a deterministic fake.
  6. Reuse the same onScan path as the wedge input.
  7. Keep keyboard/manual fallback visible on unsupported browsers, denied permission, and disconnect.

Phase 3 — staged adjacent workflows

In order of clarity and value:

  1. Scan a product into a stock-transfer draft and increment a staged line.
  2. Scan a product to match a purchase-order line and increment its staged received quantity.
  3. Add explicit product-scan and serial-scan targets to serialized inventory.
  4. Add inventory-list scan-to-detail.

Each workflow retains its existing final confirmation. No phase should introduce scan-triggered writes as a side effect of device input.

Separate follow-up — count sessions

A cycle-count or offline queue is a different domain feature, not merely another scanner adapter. Before implementing it for the portal, define:

  • durable count-session identity;
  • per-product operation identity and request fingerprint;
  • idempotent replay response;
  • conflict behavior when the same key is reused with different counts;
  • snapshot/version rules when sales or receipts occur during a count;
  • batch/partial-failure semantics;
  • ledger provenance and operator attribution.

The current delta endpoints and browser API client do not satisfy this contract by themselves.

Acceptance criteria

Baseline

  • A user with store.inventory.write and store.products.read can focus the Receive Inventory scanner input, scan a keyboard-wedge barcode ending in Enter, and see the exact selected-store product selected.
  • Leading zeros and alphanumeric barcodes are preserved as strings.
  • A scan with no match shows a recoverable message and does not mutate inventory.
  • The user must still choose quantity and press Receive.
  • Manual entry continues to work.
  • Firefox and Safari receive the wedge/manual workflow without unusable direct-device controls.

Web Serial enhancement

  • On supported Chromium desktop browsers, Connect opens the browser chooser only from a user action.
  • A selected supported serial scanner opens with validated settings and emits complete CR, LF, CRLF, or configured terminator-delimited scans across arbitrary stream chunks.
  • Permission denial, unsupported API, unplug, fatal read error, reconnect, and route/store change produce explicit states and release stream locks/ports.
  • Composite serial-plus-keyboard output does not stage the same physical scan twice, while two deliberate scans remain two events.
  • No raw barcode or serial payload is sent to analytics or logs.
  • Keyboard/manual capture remains usable whenever direct serial is unavailable.

Verification plan

Automated

Add contract-focused Vitest coverage for:

  • framer partial chunks, multiple frames, CRLF, empty frames, overflow, timeout, and disconnect reset;
  • leading-zero and alphanumeric preservation;
  • serial permission denial, chooser cancellation, read error, cleanup, and reconnect with a fake serial adapter;
  • wedge Enter handling, IME/modifier exclusion, and manual entry;
  • cross-transport duplicate handling without dropping intentional repeated scans;
  • Receive Inventory exact lookup, stale response after store switch, 404, permission behavior, and proof that scan alone never calls api.inventory.receive.

Run through Bazel:

bazel test //apps/websites/portals/retail:test
bazel test //apps/websites/portals/retail:typecheck
bazel test //apps/websites/portals/retail:lint

Browser and hardware smoke

Automated browser tests can cover the wedge path by dispatching real key events into the focused input. Direct Web Serial needs physical-device evidence because a JavaScript fake does not verify operating-system enumeration, drivers, serial settings, permission UI, or hardware framing.

Validate at minimum:

  • current Chrome and Edge on every supported desktop OS;
  • Firefox and Safari wedge fallback;
  • keyboard-only, serial-only, and composite scanner modes;
  • UPC-A/EAN with leading zeros and Code 128 alphanumeric values;
  • Enter, CR, LF, CRLF, and any supported Tab suffix;
  • rapid different items and intentional repeated scans;
  • permission allow, cancel, deny, revoke, and enterprise block;
  • unplug during a partial frame and reconnect;
  • navigation, dialog close, browser tab backgrounding, and selected-store change during lookup;
  • unmatched and unauthorized barcode lookups;
  • review of browser network and analytics traffic confirming raw scan values are absent.

Risks and open decisions

  1. Supported hardware is unspecified. Wedge mode is generic; direct serial settings and safe chooser filters are not.
  2. Product-read authorization must be confirmed. Exact lookup requires store.products.read, while inventory writes use store.inventory.write.
  3. Terminator policy must match real devices. Enter/CR/LF is the preferred fleet configuration; timing-only framing is a last resort.
  4. Composite devices can duplicate scans. Validate source-aware deduplication or make wedge and serial capture mutually exclusive.
  5. High-throughput counts are not an idempotent browser workflow today. Keep them outside the scanner-input release until the backend contract is explicit and tested.
  6. Direct APIs reduce browser reach. Product UX and support documentation must describe Chrome/Edge direct serial as an enhancement, not a requirement.
  7. Scanned data is untrusted and may contain sensitive content. Bound frames, do not execute control sequences, and do not log payloads.

Primary sources