@savepoint/bridge 1.0.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/LICENSE +21 -0
- package/README.md +212 -0
- package/USAGE.md +366 -0
- package/capabilities/printers.json +56 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +73 -0
- package/dist/daemon.d.ts +10 -0
- package/dist/daemon.js +141 -0
- package/dist/discovery.d.ts +8 -0
- package/dist/discovery.js +54 -0
- package/dist/logger.d.ts +9 -0
- package/dist/logger.js +59 -0
- package/dist/logs.d.ts +1 -0
- package/dist/logs.js +30 -0
- package/dist/poller.d.ts +19 -0
- package/dist/poller.js +70 -0
- package/dist/printer.d.ts +18 -0
- package/dist/printer.js +82 -0
- package/dist/registry.d.ts +17 -0
- package/dist/registry.js +98 -0
- package/dist/service.d.ts +2 -0
- package/dist/service.js +114 -0
- package/dist/setup.d.ts +1 -0
- package/dist/setup.js +131 -0
- package/dist/status.d.ts +1 -0
- package/dist/status.js +115 -0
- package/dist/token.d.ts +3 -0
- package/dist/token.js +75 -0
- package/dist/types.d.ts +47 -0
- package/dist/types.js +1 -0
- package/package.json +74 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) SavePoint
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
# SavePoint Bridge
|
|
2
|
+
|
|
3
|
+
Connect your local printers and hardware to SavePoint.
|
|
4
|
+
|
|
5
|
+
SavePoint Bridge is a small background service that runs on your local network. It receives print jobs from SavePoint (labels, receipts) and sends them directly to your physical printers — no cloud-to-printer connection required.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Requirements
|
|
10
|
+
|
|
11
|
+
- **Node.js 18+** — [nodejs.org](https://nodejs.org)
|
|
12
|
+
- **macOS, Linux, or Windows**
|
|
13
|
+
- A network printer on port 9100, or a printer exposed through CUPS
|
|
14
|
+
- A SavePoint account with the Bridge feature enabled
|
|
15
|
+
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
## Install
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
npm install -g @savepoint/bridge
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Or run without installing:
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
npx @savepoint/bridge setup
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
---
|
|
31
|
+
|
|
32
|
+
## Setup
|
|
33
|
+
|
|
34
|
+
The setup wizard walks you through everything in about 5 minutes.
|
|
35
|
+
|
|
36
|
+
**Step 1 — Get your agent token**
|
|
37
|
+
|
|
38
|
+
1. In SavePoint, go to **Settings > Bridge > Add Bridge**
|
|
39
|
+
2. Copy the token shown
|
|
40
|
+
|
|
41
|
+
**Step 2 — Run setup**
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
savepoint-bridge setup
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
The wizard will:
|
|
48
|
+
- Verify your token
|
|
49
|
+
- Scan your network and local CUPS queues for printers
|
|
50
|
+
- Install the bridge as a background service (starts automatically on login/boot)
|
|
51
|
+
|
|
52
|
+
**That's it.** Print a test label from **Settings > Printers** to confirm everything works.
|
|
53
|
+
|
|
54
|
+
---
|
|
55
|
+
|
|
56
|
+
## Commands
|
|
57
|
+
|
|
58
|
+
```
|
|
59
|
+
savepoint-bridge setup Guided setup wizard (start here)
|
|
60
|
+
savepoint-bridge start Start the daemon manually (without installing as a service)
|
|
61
|
+
savepoint-bridge status Show running state, connected printers, and recent jobs
|
|
62
|
+
savepoint-bridge logs View recent activity
|
|
63
|
+
savepoint-bridge discover Re-scan for printers on your network and local CUPS queues
|
|
64
|
+
savepoint-bridge uninstall Remove the background service and stored token
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
---
|
|
68
|
+
|
|
69
|
+
## Background Service
|
|
70
|
+
|
|
71
|
+
The setup wizard installs the bridge as an OS-level service so it starts automatically.
|
|
72
|
+
|
|
73
|
+
| OS | Service type |
|
|
74
|
+
|----|-------------|
|
|
75
|
+
| macOS | LaunchAgent (`~/Library/LaunchAgents/com.savepoint.bridge.plist`) |
|
|
76
|
+
| Linux | systemd user unit (`~/.config/systemd/user/savepoint-bridge.service`) |
|
|
77
|
+
| Windows | Scheduled Task at logon ("SavePoint Bridge") |
|
|
78
|
+
|
|
79
|
+
To stop and remove the service:
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
savepoint-bridge uninstall
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
---
|
|
86
|
+
|
|
87
|
+
## Automation / Silent Setup
|
|
88
|
+
|
|
89
|
+
For IT teams deploying to multiple machines:
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
savepoint-bridge setup \
|
|
93
|
+
--token=<your-agent-token> \
|
|
94
|
+
--non-interactive \
|
|
95
|
+
--no-service
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
| Flag | Description |
|
|
99
|
+
|------|-------------|
|
|
100
|
+
| `--token=<token>` | Skip the token prompt |
|
|
101
|
+
| `--non-interactive` | Suppress all prompts, use defaults |
|
|
102
|
+
| `--no-service` | Do not install as an OS service |
|
|
103
|
+
| `--log-level=debug` | Verbose output |
|
|
104
|
+
|
|
105
|
+
---
|
|
106
|
+
|
|
107
|
+
## What It Stores on Your Computer
|
|
108
|
+
|
|
109
|
+
| Data | Location |
|
|
110
|
+
|------|---------|
|
|
111
|
+
| Agent token | OS keychain (macOS Keychain, Windows Credential Manager, or gnome-keyring) |
|
|
112
|
+
| Token fallback | `~/.savepoint-bridge/.token` (AES-256 encrypted file with machine-local derived key) |
|
|
113
|
+
| Printer registry | `~/.savepoint-bridge/registry.db` |
|
|
114
|
+
| Log files | `~/.savepoint-bridge/logs/` (7-day rolling) |
|
|
115
|
+
|
|
116
|
+
The agent token and print job content are never written to log files.
|
|
117
|
+
|
|
118
|
+
---
|
|
119
|
+
|
|
120
|
+
## Security
|
|
121
|
+
|
|
122
|
+
- **Outbound only.** The bridge polls `api.savepointhq.com` — no inbound ports are opened, no firewall changes required.
|
|
123
|
+
- **Tenant-scoped.** The agent token only has access to print jobs and printers for your store. It cannot read orders, customers, or any non-print data.
|
|
124
|
+
- **Revocable.** Go to **Settings > Bridge > Revoke** at any time. The bridge stops immediately on its next poll.
|
|
125
|
+
- **TLS always on.** Certificate validation is always enforced. If your network uses an SSL-intercepting proxy, set `NODE_EXTRA_CA_CERTS=/path/to/proxy-ca.crt` before running the bridge.
|
|
126
|
+
|
|
127
|
+
---
|
|
128
|
+
|
|
129
|
+
## Supported Printers
|
|
130
|
+
|
|
131
|
+
### Pre-configured models
|
|
132
|
+
|
|
133
|
+
| Printer | Format | Connection |
|
|
134
|
+
|---------|--------|-----------|
|
|
135
|
+
| Zebra ZT230 | ZPL | Network / CUPS |
|
|
136
|
+
| Zebra ZT410 | ZPL | Network / CUPS |
|
|
137
|
+
| Zebra GK420d | ZPL | Network / CUPS |
|
|
138
|
+
| Epson TM-T88 | ESC/P | Network / CUPS |
|
|
139
|
+
| Epson TM-T20 | ESC/P | Network / CUPS |
|
|
140
|
+
| Brother QL-820NWB | RAW | Network / CUPS |
|
|
141
|
+
|
|
142
|
+
Any printer not in this list can be added manually in **Settings > Printers** with a custom host/port.
|
|
143
|
+
|
|
144
|
+
### Connection types
|
|
145
|
+
|
|
146
|
+
- **Network** — TCP socket to port 9100 (raw ZPL / ESC/P / JetDirect). Most common.
|
|
147
|
+
- **System (CUPS)** — Uses the OS print queue. macOS and Linux.
|
|
148
|
+
|
|
149
|
+
USB transport is not public in `1.0.0`; use network or CUPS-backed printers for the first public release.
|
|
150
|
+
|
|
151
|
+
---
|
|
152
|
+
|
|
153
|
+
## Troubleshooting
|
|
154
|
+
|
|
155
|
+
**Bridge doesn't connect after setup**
|
|
156
|
+
|
|
157
|
+
- Check that the machine running the bridge can reach `api.savepointhq.com` on port 443
|
|
158
|
+
- Run `savepoint-bridge logs` to see error details
|
|
159
|
+
- Run `savepoint-bridge status` to confirm the service is running
|
|
160
|
+
|
|
161
|
+
**Printer not found during setup**
|
|
162
|
+
|
|
163
|
+
- Make sure the printer is powered on and connected to the same network
|
|
164
|
+
- For network printers: verify the printer's IP is reachable (`ping <printer-ip>`)
|
|
165
|
+
- For CUPS printers: confirm the queue exists locally with `lpstat -p`
|
|
166
|
+
- You can always add a printer manually in **Settings > Printers** after setup
|
|
167
|
+
|
|
168
|
+
**"Token verification failed"**
|
|
169
|
+
|
|
170
|
+
- The token is single-use display — copy it fresh from **Settings > Bridge > Add Bridge**
|
|
171
|
+
- Check that the token is complete (64 hex characters)
|
|
172
|
+
|
|
173
|
+
**SSL / certificate error**
|
|
174
|
+
|
|
175
|
+
Your network may use an SSL-intercepting proxy. Run:
|
|
176
|
+
|
|
177
|
+
```bash
|
|
178
|
+
NODE_EXTRA_CA_CERTS=/path/to/your-proxy-ca.crt savepoint-bridge setup
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
Or add the certificate to your system's trust store and restart the bridge.
|
|
182
|
+
|
|
183
|
+
**Linux: bridge doesn't start at boot on a headless machine**
|
|
184
|
+
|
|
185
|
+
The setup wizard runs `loginctl enable-linger $USER` automatically. If you skipped setup or it failed, run:
|
|
186
|
+
|
|
187
|
+
```bash
|
|
188
|
+
loginctl enable-linger $(whoami)
|
|
189
|
+
systemctl --user enable --now savepoint-bridge
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
---
|
|
193
|
+
|
|
194
|
+
## Logs
|
|
195
|
+
|
|
196
|
+
Logs are structured JSON written to `~/.savepoint-bridge/logs/YYYY-MM-DD.log`. The `logs` command renders them in a human-readable format:
|
|
197
|
+
|
|
198
|
+
```bash
|
|
199
|
+
savepoint-bridge logs
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
Logs rotate automatically — only the last 7 days are kept.
|
|
203
|
+
|
|
204
|
+
---
|
|
205
|
+
|
|
206
|
+
## Uninstall
|
|
207
|
+
|
|
208
|
+
```bash
|
|
209
|
+
savepoint-bridge uninstall
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
This removes the OS service and the stored token. It does not remove the `@savepoint/bridge` npm package itself — run `npm uninstall -g @savepoint/bridge` to remove that too.
|
package/USAGE.md
ADDED
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
# SavePoint Bridge — Developer Reference
|
|
2
|
+
|
|
3
|
+
Internal reference for the SavePoint engineering team. For retailer/IT setup, see [README.md](./README.md).
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## Architecture Overview
|
|
8
|
+
|
|
9
|
+
The bridge is a Node.js daemon in `apps/bridge` (`@savepoint/bridge`). It polls the SavePoint control-plane for queued print jobs, validates them against a local printer capability registry, and dispatches raw bytes to physical printers over TCP or CUPS.
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
apps/bridge/
|
|
13
|
+
src/
|
|
14
|
+
cli.ts Entry point — parse args, route to setup or daemon
|
|
15
|
+
daemon.ts Main poll loop — adaptive interval, job dispatch, signal handling
|
|
16
|
+
poller.ts HTTP client — poll/claim jobs, report results, heartbeat
|
|
17
|
+
printer.ts Transport layer — TCP socket, CUPS pipe
|
|
18
|
+
registry.ts SQLite printer registry — seed, upsert, capability lookup
|
|
19
|
+
discovery.ts Network (port 9100 sweep) + CUPS printer discovery
|
|
20
|
+
token.ts OS keychain storage + AES-256-GCM encrypted file fallback
|
|
21
|
+
setup.ts Interactive setup wizard (@clack/prompts)
|
|
22
|
+
service.ts OS service install/uninstall (launchd/systemd/schtasks)
|
|
23
|
+
logger.ts Pretty terminal output + rolling JSON log files
|
|
24
|
+
capabilities/
|
|
25
|
+
printers.json Curated printer model → format → VID/PID map (ships with binary)
|
|
26
|
+
src/__tests__/
|
|
27
|
+
token.test.ts
|
|
28
|
+
registry.test.ts
|
|
29
|
+
poller.test.ts
|
|
30
|
+
printer.test.ts
|
|
31
|
+
daemon.test.ts
|
|
32
|
+
|
|
33
|
+
types.ts Local runtime-safe bridge contract types
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
---
|
|
37
|
+
|
|
38
|
+
## Job Dispatch Flow
|
|
39
|
+
|
|
40
|
+
```
|
|
41
|
+
Control plane Bridge daemon
|
|
42
|
+
───────────── ─────────────
|
|
43
|
+
POST /api/labels/print-jobs poll GET /api/labels/print-jobs
|
|
44
|
+
status = 'queued' ──────────────► every 5s (active) / 30s (idle)
|
|
45
|
+
|
|
46
|
+
◄────────────── POST /api/labels/print-jobs/:id/claim
|
|
47
|
+
→ 409 already claimed → skip
|
|
48
|
+
→ 200 claimed → proceed
|
|
49
|
+
|
|
50
|
+
registry.validateFormat(printer_id, job.metadata.format)
|
|
51
|
+
→ FORMAT_MISMATCH → report failed, stop
|
|
52
|
+
|
|
53
|
+
fetch(content_url) ← R2 pre-signed URL
|
|
54
|
+
→ retry ×2 with exponential backoff
|
|
55
|
+
|
|
56
|
+
getTransport(printer).send(bytes)
|
|
57
|
+
→ TCP socket / CUPS lpr
|
|
58
|
+
|
|
59
|
+
◄────────────── POST /api/labels/print-jobs/:id/result
|
|
60
|
+
{ status: 'completed' | 'failed', result: {...} }
|
|
61
|
+
|
|
62
|
+
◄────────────── POST /api/agents/heartbeat (every 60s)
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
**Adaptive polling:** `buildIntervalStrategy` in `daemon.ts` — 5s when jobs were found on the last poll, backs off to 30s after 3 consecutive empty polls. Resets to 5s on the next non-empty poll.
|
|
66
|
+
|
|
67
|
+
**Claim safety:** The control-plane uses a conditional UPDATE (`WHERE status = 'queued' AND claimed_by IS NULL`). If two bridge instances race, the loser gets `409` and skips the job. No distributed lock needed.
|
|
68
|
+
|
|
69
|
+
---
|
|
70
|
+
|
|
71
|
+
## API Contract
|
|
72
|
+
|
|
73
|
+
All requests include:
|
|
74
|
+
|
|
75
|
+
```
|
|
76
|
+
Authorization: Bearer <agent_token>
|
|
77
|
+
X-Agent-Version: <semver>
|
|
78
|
+
Content-Type: application/json
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
### Endpoints the bridge calls
|
|
82
|
+
|
|
83
|
+
| Method | Path | Description |
|
|
84
|
+
|--------|------|-------------|
|
|
85
|
+
| `GET` | `/api/labels/print-jobs` | Poll for work |
|
|
86
|
+
| `POST` | `/api/labels/print-jobs/:id/claim` | Claim a job |
|
|
87
|
+
| `POST` | `/api/labels/print-jobs/:id/result` | Report result |
|
|
88
|
+
| `POST` | `/api/agents/heartbeat` | Update `last_seen_at` and sync discovered printers |
|
|
89
|
+
| `GET` | `/api/agents/printers` | Refresh printer registry |
|
|
90
|
+
|
|
91
|
+
### Claim a job
|
|
92
|
+
|
|
93
|
+
```
|
|
94
|
+
POST /api/labels/print-jobs/:id/claim
|
|
95
|
+
|
|
96
|
+
200 { job: PrintJob } — claimed, proceed
|
|
97
|
+
409 — already claimed, skip
|
|
98
|
+
404 — job gone, skip
|
|
99
|
+
401 — token revoked, stop polling, prompt re-setup
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
### Report result
|
|
103
|
+
|
|
104
|
+
```
|
|
105
|
+
POST /api/labels/print-jobs/:id/result
|
|
106
|
+
Body: { status: 'completed' | 'failed', result: JobResult }
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
### Heartbeat
|
|
110
|
+
|
|
111
|
+
```
|
|
112
|
+
POST /api/agents/heartbeat
|
|
113
|
+
Body: { version?, printers?[] }
|
|
114
|
+
→ 200 { ok: true }
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
Sent every 60s. Full discovery sync runs on startup and every 5 minutes. Non-fatal if it fails — daemon continues.
|
|
118
|
+
|
|
119
|
+
---
|
|
120
|
+
|
|
121
|
+
## Shared Types (`src/types.ts`)
|
|
122
|
+
|
|
123
|
+
```typescript
|
|
124
|
+
type JobType = 'label' | 'receipt' | 'document' | 'test'
|
|
125
|
+
type JobStatus = 'queued' | 'processing' | 'completed' | 'failed' | 'cancelled'
|
|
126
|
+
|
|
127
|
+
interface PrintJob {
|
|
128
|
+
id: string
|
|
129
|
+
tenant_id: string
|
|
130
|
+
printer_id: string | null
|
|
131
|
+
job_type: JobType
|
|
132
|
+
content_url: string | null // R2 pre-signed URL, expires after 1 hour
|
|
133
|
+
metadata: { format?: 'zpl' | 'esc-p' | 'pdf' | 'raw'; copies?: number; [k: string]: unknown }
|
|
134
|
+
status: JobStatus
|
|
135
|
+
claimed_by: string | null // agents.id UUID
|
|
136
|
+
claimed_at: string | null
|
|
137
|
+
created_at: string
|
|
138
|
+
updated_at: string
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
interface JobResult {
|
|
142
|
+
code?: string
|
|
143
|
+
message?: string
|
|
144
|
+
attempts?: number
|
|
145
|
+
http_status?: number
|
|
146
|
+
[key: string]: unknown
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
type ConnectionType = 'network' | 'usb' | 'system'
|
|
150
|
+
|
|
151
|
+
interface Printer {
|
|
152
|
+
id: string; name: string; model: string | null
|
|
153
|
+
host: string | null; port: number | null
|
|
154
|
+
connectionType: ConnectionType; protocol: string | null; isDefault: boolean
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
interface PrinterCapability {
|
|
158
|
+
model: string; manufacturer: string
|
|
159
|
+
expected_format: 'zpl' | 'esc-p' | 'pdf' | 'raw'
|
|
160
|
+
default_port: number; connection_types: ConnectionType[]
|
|
161
|
+
vid?: number; pid?: number // USB Vendor/Product ID
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
---
|
|
167
|
+
|
|
168
|
+
## Token Storage
|
|
169
|
+
|
|
170
|
+
**Primary:** OS keychain via `@postman/node-keytar` (actively maintained fork of archived `keytar`).
|
|
171
|
+
|
|
172
|
+
**Fallback** (headless Linux without gnome-keyring):
|
|
173
|
+
- AES-256-GCM encrypted file at `~/.savepoint-bridge/.token`
|
|
174
|
+
- Key derived from `SHA-256(HOME_DIR + ":" + platform)` — machine-stable, not portable
|
|
175
|
+
- Setup wizard displays a warning when fallback is used
|
|
176
|
+
|
|
177
|
+
`@postman/node-keytar` is used over the original `keytar` (archived 2023, build failures on Node 20+ Linux).
|
|
178
|
+
|
|
179
|
+
---
|
|
180
|
+
|
|
181
|
+
## Printer Capability Registry
|
|
182
|
+
|
|
183
|
+
Local SQLite database (`~/.savepoint-bridge/registry.db`) seeded from two sources:
|
|
184
|
+
|
|
185
|
+
1. **`capabilities/printers.json`** — curated `model → { manufacturer, expected_format, default_port, vid, pid }` map, shipped with the binary
|
|
186
|
+
2. **Control-plane** — `GET /api/agents/printers` on startup and every 5 minutes
|
|
187
|
+
|
|
188
|
+
`Registry.validateFormat(printerId, jobFormat)` returns `null` (pass) or a `RegistryError` with `code: FORMAT_MISMATCH | PRINTER_NOT_FOUND`. Format mismatches are caught before any bytes are sent.
|
|
189
|
+
|
|
190
|
+
`better-sqlite3` is synchronous by design — the registry lookup is in the hot path of every job, and the sync API simplifies the dispatch code without performance concerns at this scale.
|
|
191
|
+
|
|
192
|
+
---
|
|
193
|
+
|
|
194
|
+
## Error Codes
|
|
195
|
+
|
|
196
|
+
| Code | Cause | Outcome |
|
|
197
|
+
|------|-------|---------|
|
|
198
|
+
| `FORMAT_MISMATCH` | Job format ≠ printer expected format | Job failed, no bytes sent |
|
|
199
|
+
| `PRINTER_NOT_FOUND` | `printer_id` not in local registry | Job failed |
|
|
200
|
+
| `NO_CONTENT_URL` | Job has no `content_url` | Job failed |
|
|
201
|
+
| `DOWNLOAD_FAILED` | R2 fetch non-200 or network error | Retry ×2, then job failed |
|
|
202
|
+
| `CONNECTION_FAILED` | TCP timeout/refused | Retry ×3 with backoff, then job failed |
|
|
203
|
+
| `DISPATCH_ERROR` | Any unhandled transport error | Job failed |
|
|
204
|
+
| `UNAUTHORIZED` (401) | Token revoked | Stop polling, log notice, exit |
|
|
205
|
+
|
|
206
|
+
**Content URL expiry:** R2 pre-signed URLs expire after 1 hour. Jobs queued but not processed within that window will fail at download with `DOWNLOAD_FAILED`. The platform should set pre-signed URL expiry to cover expected queue depth.
|
|
207
|
+
|
|
208
|
+
---
|
|
209
|
+
|
|
210
|
+
## OS Service Installation
|
|
211
|
+
|
|
212
|
+
Runs as a **user-level service** on all platforms (not root/SYSTEM) to get correct `$HOME` paths and user-level printer access.
|
|
213
|
+
|
|
214
|
+
| OS | Mechanism | Path |
|
|
215
|
+
|----|-----------|------|
|
|
216
|
+
| macOS | launchd LaunchAgent | `~/Library/LaunchAgents/com.savepoint.bridge.plist` |
|
|
217
|
+
| Linux | systemd user unit + `loginctl enable-linger` | `~/.config/systemd/user/savepoint-bridge.service` |
|
|
218
|
+
| Windows | Scheduled Task at logon | Task Scheduler: "SavePoint Bridge" |
|
|
219
|
+
|
|
220
|
+
`loginctl enable-linger $USER` is set on Linux so the user unit survives logout — required for headless back-of-house machines.
|
|
221
|
+
|
|
222
|
+
All shell commands in `service.ts` and `discovery.ts` use `execFile()` with array arguments to prevent shell injection. No user-controlled data is ever interpolated into command strings.
|
|
223
|
+
|
|
224
|
+
---
|
|
225
|
+
|
|
226
|
+
## Local Storage Paths
|
|
227
|
+
|
|
228
|
+
| Data | macOS / Linux | Windows |
|
|
229
|
+
|------|--------------|---------|
|
|
230
|
+
| Token (keychain) | OS keychain | Windows Credential Manager |
|
|
231
|
+
| Token (fallback) | `~/.savepoint-bridge/.token` | `%APPDATA%\savepoint-bridge\.token` |
|
|
232
|
+
| Printer registry | `~/.savepoint-bridge/registry.db` | `%APPDATA%\savepoint-bridge\registry.db` |
|
|
233
|
+
| Logs | `~/.savepoint-bridge/logs/YYYY-MM-DD.log` | `%APPDATA%\savepoint-bridge\logs\` |
|
|
234
|
+
|
|
235
|
+
---
|
|
236
|
+
|
|
237
|
+
## Development
|
|
238
|
+
|
|
239
|
+
### Setup
|
|
240
|
+
|
|
241
|
+
```bash
|
|
242
|
+
# From repo root
|
|
243
|
+
pnpm install
|
|
244
|
+
|
|
245
|
+
# Run in dev mode (tsx, no build needed)
|
|
246
|
+
pnpm --filter @savepoint/bridge dev -- --help
|
|
247
|
+
pnpm --filter @savepoint/bridge dev -- setup --non-interactive --no-service --token=<64-char-token>
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
### Test
|
|
251
|
+
|
|
252
|
+
```bash
|
|
253
|
+
# From apps/bridge (recommended — avoids cross-workspace runner conflicts)
|
|
254
|
+
cd apps/bridge
|
|
255
|
+
pnpm test # All tests
|
|
256
|
+
pnpm test token # Single suite
|
|
257
|
+
pnpm test:watch # Watch mode
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
Tests are TDD-structured: each module has a corresponding `src/__tests__/*.test.ts` with mocked I/O. The daemon loop (`daemon.ts`) exports `buildIntervalStrategy` as a pure function specifically to allow unit testing the adaptive interval logic in isolation from the live poll loop.
|
|
261
|
+
|
|
262
|
+
### Build
|
|
263
|
+
|
|
264
|
+
```bash
|
|
265
|
+
cd apps/bridge
|
|
266
|
+
pnpm build # Compiles to dist/
|
|
267
|
+
pnpm type-check # tsc --noEmit only
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
### Test the built binary
|
|
271
|
+
|
|
272
|
+
```bash
|
|
273
|
+
node apps/bridge/dist/cli.js --help
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
`dist/` is gitignored — do not commit build artifacts.
|
|
277
|
+
|
|
278
|
+
---
|
|
279
|
+
|
|
280
|
+
## Key Dependencies
|
|
281
|
+
|
|
282
|
+
| Package | Purpose | Notes |
|
|
283
|
+
|---------|---------|-------|
|
|
284
|
+
| `better-sqlite3@^12` | Printer capability registry | Synchronous API intentional; v9 does not support Node 25 |
|
|
285
|
+
| `@postman/node-keytar` | OS keychain access | Active fork of archived `keytar`; identical API |
|
|
286
|
+
| `@clack/prompts` | Interactive wizard UI | Polished terminal prompts |
|
|
287
|
+
| `picocolors` | Terminal colour | Zero-dependency |
|
|
288
|
+
| `tsx` | Dev runtime (no build step) | devDependency only |
|
|
289
|
+
|
|
290
|
+
USB transport (`createUsbTransport` in `printer.ts`) is a deferred post-1.0 task — it throws immediately and is not part of the first public release.
|
|
291
|
+
|
|
292
|
+
---
|
|
293
|
+
|
|
294
|
+
## v2 Extension Points
|
|
295
|
+
|
|
296
|
+
The registry/transport split is designed for transcoding without a rewrite:
|
|
297
|
+
|
|
298
|
+
1. Add a `transcoder` field to `capabilities/printers.json` per model family
|
|
299
|
+
2. `registry.ts` selects the transcoder module for the resolved model
|
|
300
|
+
3. Transcoder modules are dynamically imported in `daemon.ts` before `transport.send()`
|
|
301
|
+
4. Transport layer is unchanged
|
|
302
|
+
|
|
303
|
+
Purely additive — job schema and API contract do not change.
|
|
304
|
+
|
|
305
|
+
---
|
|
306
|
+
|
|
307
|
+
## Database Schema (relevant tables)
|
|
308
|
+
|
|
309
|
+
Defined in `neon/migrations/070_print_jobs.sql` and `071_agents.sql`.
|
|
310
|
+
|
|
311
|
+
```sql
|
|
312
|
+
-- print_jobs
|
|
313
|
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid()
|
|
314
|
+
tenant_id UUID NOT NULL REFERENCES tenants(id)
|
|
315
|
+
printer_id UUID REFERENCES printers(id) ON DELETE SET NULL
|
|
316
|
+
job_type TEXT CHECK (job_type IN ('label','receipt','document','test'))
|
|
317
|
+
content_url TEXT -- R2 pre-signed URL, expires 1 hour
|
|
318
|
+
metadata JSONB DEFAULT '{}'
|
|
319
|
+
status TEXT DEFAULT 'queued'
|
|
320
|
+
result JSONB
|
|
321
|
+
claimed_by UUID REFERENCES agents(id) ON DELETE SET NULL
|
|
322
|
+
claimed_at TIMESTAMPTZ
|
|
323
|
+
created_at / updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
324
|
+
|
|
325
|
+
-- agents (bridge instance registry)
|
|
326
|
+
id UUID PRIMARY KEY
|
|
327
|
+
tenant_id UUID NOT NULL REFERENCES tenants(id)
|
|
328
|
+
external_agent_id TEXT -- machine identifier sent at registration (not the claim value)
|
|
329
|
+
name TEXT NOT NULL
|
|
330
|
+
version TEXT
|
|
331
|
+
last_seen_at TIMESTAMPTZ
|
|
332
|
+
created_at / updated_at / deleted_at TIMESTAMPTZ
|
|
333
|
+
|
|
334
|
+
-- agent_tokens (hashed token store)
|
|
335
|
+
id UUID PRIMARY KEY
|
|
336
|
+
agent_id UUID NOT NULL REFERENCES agents(id)
|
|
337
|
+
tenant_id UUID NOT NULL
|
|
338
|
+
token_hash TEXT NOT NULL UNIQUE -- SHA-256(raw_token) — raw token never stored
|
|
339
|
+
token_prefix TEXT NOT NULL -- first 8 chars, safe for display
|
|
340
|
+
is_active BOOLEAN DEFAULT true
|
|
341
|
+
created_at / rotated_at TIMESTAMPTZ
|
|
342
|
+
```
|
|
343
|
+
|
|
344
|
+
**Claim atomicity** is enforced at the database level:
|
|
345
|
+
|
|
346
|
+
```sql
|
|
347
|
+
UPDATE print_jobs
|
|
348
|
+
SET status = 'processing', claimed_by = $agents_id, claimed_at = NOW()
|
|
349
|
+
WHERE id = $id AND status = 'queued' AND claimed_by IS NULL
|
|
350
|
+
RETURNING *
|
|
351
|
+
-- zero rows → route returns 409
|
|
352
|
+
```
|
|
353
|
+
|
|
354
|
+
---
|
|
355
|
+
|
|
356
|
+
## Open / Deferred
|
|
357
|
+
|
|
358
|
+
| Item | Status |
|
|
359
|
+
|------|--------|
|
|
360
|
+
| USB transport (Windows Zadig driver setup) | v1.1 |
|
|
361
|
+
| `savepoint-bridge status` implementation | shipped |
|
|
362
|
+
| `savepoint-bridge logs` tail implementation | shipped |
|
|
363
|
+
| Self-update apply | v1.1 (currently: startup notice only) |
|
|
364
|
+
| Format transcoding (ZPL ↔ ESC/P) | v2 |
|
|
365
|
+
| Token expiry reminders (90-day rotation) | v1.1 |
|
|
366
|
+
| Printer-aware job claiming (skip offline printers) | Deferred |
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
[
|
|
2
|
+
{
|
|
3
|
+
"model": "Zebra ZT230",
|
|
4
|
+
"manufacturer": "Zebra",
|
|
5
|
+
"expected_format": "zpl",
|
|
6
|
+
"default_port": 9100,
|
|
7
|
+
"connection_types": ["network", "usb"],
|
|
8
|
+
"vid": 2655,
|
|
9
|
+
"pid": 516
|
|
10
|
+
},
|
|
11
|
+
{
|
|
12
|
+
"model": "Zebra ZT410",
|
|
13
|
+
"manufacturer": "Zebra",
|
|
14
|
+
"expected_format": "zpl",
|
|
15
|
+
"default_port": 9100,
|
|
16
|
+
"connection_types": ["network", "usb"],
|
|
17
|
+
"vid": 2655,
|
|
18
|
+
"pid": 519
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
"model": "Zebra GK420d",
|
|
22
|
+
"manufacturer": "Zebra",
|
|
23
|
+
"expected_format": "zpl",
|
|
24
|
+
"default_port": 9100,
|
|
25
|
+
"connection_types": ["network", "usb"],
|
|
26
|
+
"vid": 2655,
|
|
27
|
+
"pid": 515
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
"model": "Epson TM-T88",
|
|
31
|
+
"manufacturer": "Epson",
|
|
32
|
+
"expected_format": "esc-p",
|
|
33
|
+
"default_port": 9100,
|
|
34
|
+
"connection_types": ["network", "usb"],
|
|
35
|
+
"vid": 1208,
|
|
36
|
+
"pid": 514
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
"model": "Epson TM-T20",
|
|
40
|
+
"manufacturer": "Epson",
|
|
41
|
+
"expected_format": "esc-p",
|
|
42
|
+
"default_port": 9100,
|
|
43
|
+
"connection_types": ["network", "usb"],
|
|
44
|
+
"vid": 1208,
|
|
45
|
+
"pid": 1024
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
"model": "Brother QL-820NWB",
|
|
49
|
+
"manufacturer": "Brother",
|
|
50
|
+
"expected_format": "raw",
|
|
51
|
+
"default_port": 9100,
|
|
52
|
+
"connection_types": ["network", "usb"],
|
|
53
|
+
"vid": 1273,
|
|
54
|
+
"pid": 8201
|
|
55
|
+
}
|
|
56
|
+
]
|
package/dist/cli.d.ts
ADDED