13 — Bridge & Devices
CEO Summary
The Bridge is the on-site integration agent: it connects our cloud to the tenant’s LAN so we can drive printers, kitchen displays, optional crypto nodes, device awareness, and signage refresh signals—without opening inbound firewall holes. The bridge always dials out to our server over WebSocket. Business value: Hybrid retail and hospitality: cloud POS plus reliable local peripherals and in-venue screens. Key metrics: WebSocket connected, heartbeat age, print/KDS success. If this breaks: Cloud features that depend on LAN routing fail or degrade; many POS flows still work in-browser, but local print/KDS and bridge-mediated actions stop until the tunnel returns. Signage playback still works from the cloud player URL without the bridge; LAN-side auto-refresh may pause until reconnect.
Bridge in the customer’s stack
The bridge sits at the edge of the tenant network (typically a small Windows PC or VM on the same subnet as printers and KDS). Browsers and tablets talk to our cloud over HTTPS as usual. When the cloud needs something on the LAN, it sends a message down the existing WebSocket to the bridge; the bridge executes locally (spool print jobs, fire KDS tickets, optional RPC to a local crypto node, etc.).
- No inbound NAT: Sites do not expose bridge ports to the internet; connectivity is outbound-only from bridge → cloud.
- Routing “everything”: Practically, the bridge routes LAN-side capabilities we have implemented (print, KDS, device notifications, crypto RPC, wallet listing)—not arbitrary TCP proxying. New capabilities extend the same pattern: new
actionhandlers on the bridge and callers on the server. - Identity: Connection is keyed by
bridgeId+tenantSlug; the server resolves the tenant and storesorgIdfor isolation.bridgeTokenis supported in the URL for future hardening (validation is still TODO in code).
WAN failover, offline, and “Starlink if AT&T dies”
WAN redundancy (primary fiber/cable vs Starlink, dual ISP, SD-WAN) is normally handled by the customer’s router, firewall, or SD-WAN appliance—not by the bridge application. When the public path to our API changes or comes back, the bridge’s existing reconnect and retry logic (see C# ServerConnectionService) should re-establish the WebSocket automatically once DNS and routing stabilize.
Offline operations: True offline POS (queue sales locally, sync later) is implemented in the browser SPA via IndexedDB (pawspos_offline) and posApi / idempotent replay — see 14 — Frontend and 06 — POS & Invoicing. The Node.js bridge (bridge/) attaches the same style of mutation envelope headers on mutating axios calls toward the cloud API (Idempotency-Key, X-PPO-*) so bridge-originated writes dedupe consistently with web POS. On the C# bridge side, OfflineQueueService periodically attempts to drain a local SQLite queue when cloud sync is authenticated; that path remains largely scaffolding (verify before relying on it).
Failsafe operations: If the bridge process stops, LAN routing from the cloud stops until it restarts; mitigations are monitoring, auto-restart (Windows Service / PM2-style supervisor), and redundant bridge host if the site requires it. Peripheral-level failsafe (e.g. backup printer) remains local policy.
Cloud server: WebSocket and manager
Mount: WebSocketServer in backend/server.js, path /api/bridges/connect. Logic: backend/services/bridgeConnectionManager.js.
Query parameters: bridgeId, tenantSlug (required); bridgeToken (optional, not yet enforced server-side).
On connect, the server validates the tenant exists and is active, then sends { type: "welcome", bridgeId, message }.
| Method / area | Role |
|---|---|
registerWebSocketServer(wss) | Attaches connection lifecycle, ping interval, message parsing |
handleMessage(bridgeId, message) | Handles bridge → cloud: register, heartbeat, ping/pong, response |
sendRequest(tenantSlug, orgId, action, data, timeout) | Sends { type: "request", requestId, action, data }; waits for matching response |
getBridgeStatus / getBridgeByTenant / getBridgeById | Status and connection lookup with org isolation |
getAllBridges | List connected bridges (admin/ops) |
trackDevice / getActiveDevices | In-memory map of recent device notifications per bridge (cleared on disconnect); action exit or register_closed removes the device key; exposed in getBridgeStatus as activeDevices |
REST API (tenant-authenticated)
Mounted under the API prefix (e.g. /api). All routes use requireSessionAuth and tenantResolver unless noted.
| Method | Path | Purpose |
|---|---|---|
| GET | /bridge/status | WebSocket bridge status if connected (includes activeDevices from notify-device tracking); else optional HTTP fallback to tenant_configs.bridge_url (/health, /info) |
| POST | /bridge/config | Save bridge_url (legacy HTTP path) |
| POST | /bridge/test | Test reachability of bridge URL (/health) |
| POST | /bridge/token | Generate and store bridge_token in tenant_configs |
| GET | /bridge/token | Return whether a token exists (not the secret value) |
| GET | /bridge/config-file | Download suggested appsettings.json for the C# bridge |
| POST | /bridge/notify-device | Push device enter/exit to bridge via WebSocket; updates server-side active-device map |
| POST | /bridge/notify-signage | Push signageUpdate with action: manualRefresh; optional body displaySlug, reason. Session + tenant. |
WebSocket protocol (summary)
Bridge → cloud (inbound to server)
type | Meaning |
|---|---|
register | Updates metadata: bridgeName, localIP, platform, version, device/printer counts |
heartbeat | Periodic status; refreshes lastHeartbeat and counts |
ping | Application-level; server replies { type: "pong" } |
pong | Ack to server-initiated keepalive |
response | Reply to a prior request (includes requestId, success, data or error) |
Cloud → bridge
Requests are JSON messages: { "type": "request", "requestId", "action", "data" }. The C# client may also accept a top-level deviceNotification message type for the same payload shape.
action | Typical data | Bridge behavior (C#) |
|---|---|---|
getStatus | — | Returns bridge id/name/local IP, device list, printer list |
print | printerId, jobType, data (base64) | Queues job via PrinterRoutingService |
fireTicket | orderId, invoiceId, station, items[] | KDSService.FireTicketAsync |
cryptoRpc | symbol, method, parameters | CryptoNodeService.ExecuteRpcAsync |
getWallets | — | Lists wallets via WalletStorageService |
deviceNotification | deviceKey, deviceType, mode, action (enter/exit/…), optional staff, shiftId | Updates bridge-side ServerTrackedDevice map |
Sequence: print / KDS from cloud
C# bridge (bridge-win-csharp/PAWSPOS.Bridge)
Services (inventory)
| Service | Role |
|---|---|
ServerConnectionService | Outbound WebSocket client, heartbeats, ping timer, dispatches request actions |
ConfigService | Reads appsettings.json (URLs, tenant, bridge id, token) |
DatabaseService | Local SQLite (devices, printers, offline queue, etc.) |
DeviceDiscoveryService | LAN discovery cadence |
PrinterRoutingService | Print queue to physical printers |
KDSService | Kitchen ticket lifecycle |
CloudSyncService | Authentication / sync toward cloud |
OfflineQueueService | Timer-driven queue sync attempt (implementation incomplete) |
CryptoNodeService / WalletStorageService | Optional local chain RPC and wallet metadata |
WebSocketService | Local HTTP upgrade /ws for LAN clients (signage relay, scripts); relays cloud-originated JSON such as signageUpdate to connected browsers |
ProxyService | Composition shell (currently minimal) |
HTTP controllers under the bridge host (e.g. BridgeController, HomeController) support legacy health/info endpoints used when bridge_url HTTP fallback is enabled.
Tenant configuration
Table tenant_configs (per-tenant DB): bridge_url, bridge_token. Routes ensure the table exists. Generated appsettings uses API_BASE_URL or defaults to staging API.
Source files (this repo)
backend/services/bridgeConnectionManager.js— WebSocket registry,sendRequest, status, device trackingbackend/routes/bridge.routes.js— REST endpoints under/bridge/*backend/server.js—WebSocketServeron path/api/bridges/connectbridge-win-csharp/PAWSPOS.Bridge/Services/ServerConnectionService.cs— outbound client, message switch, request actions
Signage: cloud → bridge (high level)
Detail: 26 — Signage & Displays.
Device routing & discovery (flows)
Goal: Staff see registers, kiosks, and KDS-style endpoints in Admin → Devices (/admin/devices/manager). Rows live in the tenant Devices table. There is no magic browser LAN scan of every device on Wi‑Fi — visibility comes from heartbeats to the cloud API (while a user session exists), manual CRUD, and bridge-side plumbing (local DB, WebSocket notify-device, signage WS clients).
1) Where device truth shows up
2) Browser heartbeat sequence (auto-register)
While POS is open and the user is logged in, the SPA posts JSON on an interval. deviceKey is stable in localStorage. Local IP is best-effort (WebRTC and optional manual deviceLocalIP) — not guaranteed across browsers or locked-down networks.
3) Cloud ↔ bridge: live register awareness (not the same as SQL rows)
POST /api/bridge/notify-device pushes a deviceNotification request down the bridge WebSocket when the cloud wants the bridge to know a register entered or exited POS. The bridge connection manager keeps a short-lived in-memory map (trackDevice), surfaced in GET /api/bridge/status as activeDevices. That list is for ops and bridge diagnostics; it does not replace the Devices table unless the heartbeat path also wrote the row.
4) On-site bridge: LAN heartbeat endpoint (Node / C#)
The Node bridge exposes POST /local/devices/heartbeat for LAN agents. It forwards the body toward the cloud as POST …/devices/heartbeat with a Bearer token after bridge authentication. The public router for /api/devices applies cookie session auth to those routes — so server-to-server bridge forwarding may receive 401 until a bridge-scoped ingest path exists (for example a dedicated POST /api/bridges/… handler or optional Bearer validation on heartbeat). Today, the path into Devices that matches the dashboard session model is the logged-in browser heartbeat.
5) Signage and other LAN clients
Signage relay players use the bridge’s local /ws relay. They are not inserted into Devices by default — they consume playlists. If you need them listed as devices, use the same heartbeat pattern as other LAN clients once bridge-to-cloud device ingest is aligned with auth, or register them manually in Admin → Devices.
Device registry, metadata, and POS vertical modules
Where registers are defined: Tenant staff configure devices in the dashboard at /admin/devices/manager (frontend/src/pages/admin/sections/devices/DeviceManager.jsx) — create/edit rows in the tenant Devices table (type, location, name, device_key, JSON metadata). This is not the Business Hub (/business-hub) settings search; forecourt/fuel and similar toggles live on the device record.
POS modules (verticals): Optional Modern POS panels are gated by metadata.posModules (alias pos_modules). The server normalizes module IDs in backend/utils/posModules.js. Today only fuel is recognized: { "posModules": ["fuel"] } on the POS device row. Admin: Forecourt / fuel module checkbox; table shows Fuel and optional pump count under POS extras.
Forecourt site config (fuelSite): Stored as metadata.fuelSite (legacy fuel_site read on parse; canonical key on save). Sanitized in backend/utils/fuelSite.js; admin device POST/PUT runs normalizeDeviceMetadataFuelSite. Fields: authorizationMode (pos | attendant | fms_monitor); pumps[] (id, label, hoses 1-12); grades[] (code, label, optional tankId, productSku); integration (type: none | site_bridge | third_party_fms, vendor, notes); posPanel (maxPumpsVisible, showAllStop, showTankAlarmsShortcut). Admin form: FuelSiteFormSection.jsx inside DeviceManager.jsx.
Runtime: GET /api/pos/settings (06 — POS & Invoicing) returns device.posModules and device.fuelSite via parseFuelSiteFromDeviceRow. ModernPOS.jsx passes both to PosModuleHost / FuelModulePanel. Cart line metadata for fuel: see ch. 06.
Related platform routes
Devices: POST /api/devices/heartbeat, /api/admin device CRUD (devices router), and device catalog flows (see route appendix)—orthogonal to the bridge WebSocket but part of “who is on the floor.” See Device routing & discovery above for flowcharts (browser heartbeat vs bridge notify vs signage). Notify bridge: POST /api/bridge/notify-device ties POS/session events to the bridge’s view of active registers. Signage: /api/signage/* and POST /api/bridge/notify-signage.
Bridge implementations
| Folder | Notes |
|---|---|
bridge-win-csharp/ | Primary documented bridge (.NET 8) |
bridge/, bridge-win/ | Node variants; legacy / alternate deployments |