@tapicker/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.
Files changed (50) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +46 -0
  3. package/dist/bridge/bridge-service.d.ts +46 -0
  4. package/dist/bridge/bridge-service.js +245 -0
  5. package/dist/bridge/control.d.ts +7 -0
  6. package/dist/bridge/control.js +211 -0
  7. package/dist/bridge/daemon.d.ts +16 -0
  8. package/dist/bridge/daemon.js +97 -0
  9. package/dist/bridge/extension-session.d.ts +43 -0
  10. package/dist/bridge/extension-session.js +204 -0
  11. package/dist/bridge/pairing-state.d.ts +22 -0
  12. package/dist/bridge/pairing-state.js +76 -0
  13. package/dist/cli.d.ts +3 -0
  14. package/dist/cli.js +835 -0
  15. package/dist/commands.d.ts +33 -0
  16. package/dist/commands.js +123 -0
  17. package/dist/constants.d.ts +10 -0
  18. package/dist/constants.js +10 -0
  19. package/dist/errors.d.ts +27 -0
  20. package/dist/errors.js +52 -0
  21. package/dist/index.d.ts +10 -0
  22. package/dist/index.js +8 -0
  23. package/dist/mcp.d.ts +13 -0
  24. package/dist/mcp.js +300 -0
  25. package/dist/protocol/schema.d.ts +149 -0
  26. package/dist/protocol/schema.js +155 -0
  27. package/dist/server.d.ts +63 -0
  28. package/dist/server.js +486 -0
  29. package/dist/workflow-watch.d.ts +31 -0
  30. package/dist/workflow-watch.js +304 -0
  31. package/docs/cli.md +112 -0
  32. package/docs/http-api.md +149 -0
  33. package/docs/mcp.md +90 -0
  34. package/docs/operations.md +85 -0
  35. package/docs/workflow-watch.md +86 -0
  36. package/package.json +55 -0
  37. package/src/bridge/bridge-service.ts +291 -0
  38. package/src/bridge/control.ts +281 -0
  39. package/src/bridge/daemon.ts +102 -0
  40. package/src/bridge/extension-session.ts +286 -0
  41. package/src/bridge/pairing-state.ts +92 -0
  42. package/src/cli.ts +1019 -0
  43. package/src/commands.ts +158 -0
  44. package/src/constants.ts +10 -0
  45. package/src/errors.ts +72 -0
  46. package/src/index.ts +35 -0
  47. package/src/mcp.ts +386 -0
  48. package/src/protocol/schema.ts +202 -0
  49. package/src/server.ts +623 -0
  50. package/src/workflow-watch.ts +364 -0
@@ -0,0 +1,85 @@
1
+ # Pairing, Deployment, and Security
2
+
3
+ ## Start the Bridge and connect an Extension
4
+
5
+ By default, the Bridge binds to `127.0.0.1` and tries ports `9520` through `9530`. Run it in the foreground:
6
+
7
+ ```bash
8
+ tapicker bridge start
9
+ ```
10
+
11
+ Or run it as a daemon:
12
+
13
+ ```bash
14
+ tapicker bridge start --daemon
15
+ tapicker bridge code
16
+ tapicker bridge extensions
17
+ tapicker bridge stop
18
+ ```
19
+
20
+ Startup output includes the selected HTTP, WebSocket, and MCP addresses, plus a four-digit pairing code. In the Extension, open **Settings > General**, enable the Bridge connection, and set its endpoint to the WebSocket address, for example `ws://127.0.0.1:9520/ws`. Enter the displayed code to pair it for the first time.
21
+
22
+ Paired Extension credentials are stored locally. After a Bridge restart, the Extension reconnects with its saved credentials and does not need another pairing code. The Extension's `/ws` channel is not the HTTP API for agents; see [HTTP API](http-api.md) for commands and [Workflow status events](workflow-watch.md) for live status updates.
23
+
24
+ ## Pairing codes and rate limits
25
+
26
+ - The current four-digit code is held in memory and regenerated each time the Bridge starts.
27
+ - After a new Extension pairs successfully, the code is invalidated and replaced. Previously paired Extensions reconnect with their own saved credentials and do not consume a new code.
28
+ - Each remote IP can make up to five pairing attempts per 60 seconds. Five incorrect attempts with the current code pause new pairing.
29
+ - To resume pairing after it is paused, run `tapicker bridge code --refresh` on the Bridge host. This generates a new code without disconnecting paired Extensions.
30
+ - `/v1/health`, `/v1/discovery`, `/v1/capabilities`, and `/v1/extensions` never return pairing codes or pairing credentials.
31
+
32
+ ## Data directory
33
+
34
+ The default directory is `~/.tapicker`. The CLI creates and uses these files:
35
+
36
+ | File | Purpose |
37
+ | --------------------- | ------------------------------------------------------------------------- |
38
+ | `paired-clients.json` | Credentials for paired Extensions; restrict access to this file. |
39
+ | `bridge.pid` | Process information for a daemon Bridge. |
40
+ | `bridge.log` | Daemon output log. |
41
+ | `bridge.sock` | Local management channel on macOS/Linux; Windows uses a local named pipe. |
42
+
43
+ Set a different directory with `--data-dir <path>` or `TAPICKER_DATA_DIR`. Use the same custom directory for `bridge start` and later management commands such as `stop`, `code`, and `extensions`. Removing `paired-clients.json` removes saved credentials, so affected Extensions must pair again.
44
+
45
+ ## Configuration
46
+
47
+ | Setting | Purpose |
48
+ | ----------------------- | ---------------------------------------------------------------------------------------------- |
49
+ | `TAPICKER_DATA_DIR` | Bridge data directory; equivalent to `--data-dir`. |
50
+ | `TAPICKER_BRIDGE_TOKEN` | HTTP Bearer token used by the Bridge and, by default, by `tapicker mcp` when connecting to it. |
51
+ | `TAPICKER_BRIDGE_URL` | HTTP(S) URL used by the `tapicker mcp` stdio adapter; defaults to `http://127.0.0.1:9520`. |
52
+
53
+ Command-line `--token` takes precedence over the corresponding environment variable. Avoid putting tokens in shared shell history or public configuration files.
54
+
55
+ The HTTP API and `/mcp` allow any web-page Origin by default (`--cors-origin *`). Restrict browser-based callers with a comma-separated allowlist, for example `--cors-origin https://agent.example.com,https://app.example.com`. CORS is not authentication: in the default loopback configuration, no token is required, so web pages may call the local HTTP API. Use an Origin allowlist when that is not desired. Remote deployments must use TLS and a Bearer token.
56
+
57
+ ## Remote deployment
58
+
59
+ Use TLS for remote Extension and agent connections. When binding to a non-loopback address, the CLI requires a Bearer token, TLS private key, and certificate:
60
+
61
+ ```bash
62
+ tapicker bridge start \
63
+ --host 0.0.0.0 \
64
+ --port 9520 \
65
+ --token 'replace-with-a-long-random-token' \
66
+ --tls-key ./server.key \
67
+ --tls-cert ./server.crt
68
+ ```
69
+
70
+ Use these addresses from remote clients:
71
+
72
+ - Extension: `wss://<host>:9520/ws`
73
+ - MCP: `https://<host>:9520/mcp`
74
+ - HTTP: `https://<host>:9520/...`
75
+ - Workflow status events: `wss://<host>:9520/v1/commands/workflow.watch`
76
+
77
+ The Bearer token protects HTTP commands, Workflow status queries, MCP, the `workflow.watch` WebSocket handshake, and `/v1/extensions`. `/v1/health`, `/v1/discovery`, and `/v1/capabilities` are public. The Extension's `/ws` uses pairing codes and saved credentials instead of the Bearer token. The Bridge does not provide accounts or centralized client management.
78
+
79
+ ## Troubleshooting
80
+
81
+ - **A Workflow command requires `clientId`:** Call MCP `extensions_list` or HTTP `GET /v1/extensions`, choose an Extension with `ready: true`, and pass its `clientId`.
82
+ - **No Extension is available:** Check the endpoint and connection indicator under **Settings > General**. Pair once; subsequent connections should use saved credentials.
83
+ - **Pairing is paused:** Run `tapicker bridge code --refresh` on the Bridge host and use the new code.
84
+ - **A management command cannot find the Bridge:** Make sure it uses the same `--data-dir` or `TAPICKER_DATA_DIR` as the Bridge process.
85
+ - **You need to check available commands and parameters:** Query `/v1/capabilities`; see [HTTP API](http-api.md) for request formats.
@@ -0,0 +1,86 @@
1
+ # Workflow Status Events over WebSocket
2
+
3
+ A Workflow is a long-running task. External clients can use `workflow.watch` to open a WebSocket subscription for one Workflow and receive status changes from the Bridge, instead of repeatedly polling the status endpoint.
4
+
5
+ ## Connect and subscribe
6
+
7
+ The default local URL is `ws://127.0.0.1:9520/v1/commands/workflow.watch`. Use `wss://` when TLS is enabled. If the Bridge has a Bearer token, include `Authorization: Bearer <token>` in the WebSocket handshake.
8
+
9
+ After connecting, send a Bridge envelope:
10
+
11
+ ```json
12
+ {
13
+ "id": "request-1",
14
+ "time": 1720000000000,
15
+ "type": "workflow.watch",
16
+ "data": {
17
+ "clientId": "c0ffee00-0000-4000-8000-000000000001",
18
+ "input": { "workflowId": "workflow_xxx" }
19
+ }
20
+ }
21
+ ```
22
+
23
+ You may omit `clientId` only when exactly one ready Extension is connected. If multiple Extensions are ready, specify the target. The Bridge responds with `workflow.watch:res`; on success, the response includes the selected `clientId` and `workflowId`. Each WebSocket watches one Workflow. Closing the connection cancels the subscription, not the Workflow.
24
+
25
+ After the subscription is accepted, the Bridge sends `workflow.status` events:
26
+
27
+ ```json
28
+ {
29
+ "clientId": "c0ffee00-0000-4000-8000-000000000001",
30
+ "workflowId": "workflow_xxx",
31
+ "state": "running",
32
+ "updatedAt": "2026-09-30T10:20:30.000Z"
33
+ }
34
+ ```
35
+
36
+ Possible states are `running`, `paused`, `stopping`, `completed`, `stopped`, and `failed`. The Bridge does not buffer or replay events missed while the client is disconnected.
37
+
38
+ ## Watch a Workflow as it starts
39
+
40
+ To avoid missing early status changes, first connect and wait for a successful `workflow.watch:res`, then call `POST /v1/commands/workflow.start`. The watch response identifies the selected `clientId`; pass that same ID to the start command so both requests target the same Extension.
41
+
42
+ After reconnecting, restore the subscription and make one `POST /v1/commands/workflow.status` request to obtain the current or most recently persisted state. This is a one-time snapshot check, not a polling loop. Terminal states are `completed`, `stopped`, and `failed`.
43
+
44
+ The CLI provides `tapicker workflow start <workflowId> --watch` and `tapicker workflow watch <workflowId>`. MCP provides the `workflow_watch` tool. See the [CLI reference](cli.md), [HTTP API](http-api.md), and [MCP guide](mcp.md).
45
+
46
+ ## Node.js example
47
+
48
+ This example uses the `ws` package. If the Bridge requires a token, the example adds it to the handshake headers:
49
+
50
+ ```js
51
+ import { randomUUID } from 'node:crypto'
52
+ import WebSocket from 'ws'
53
+
54
+ const url = 'ws://127.0.0.1:9520/v1/commands/workflow.watch'
55
+ const token = process.env.TAPICKER_BRIDGE_TOKEN
56
+ const options = token ? { headers: { Authorization: `Bearer ${token}` } } : undefined
57
+ const watch = new WebSocket(url, options)
58
+ const requestId = randomUUID()
59
+
60
+ watch.on('open', () => {
61
+ watch.send(
62
+ JSON.stringify({
63
+ id: requestId,
64
+ time: Date.now(),
65
+ type: 'workflow.watch',
66
+ data: {
67
+ clientId: 'c0ffee00-0000-4000-8000-000000000001',
68
+ input: { workflowId: 'workflow_xxx' },
69
+ },
70
+ }),
71
+ )
72
+ })
73
+
74
+ watch.on('message', (raw) => {
75
+ const message = JSON.parse(raw.toString())
76
+ if (message.type === 'workflow.watch:res' && message.id === requestId) {
77
+ if (!message.data.ok) throw new Error(message.data.error.message)
78
+ console.log('Watching:', message.data.data)
79
+ }
80
+ if (message.type === 'workflow.status') console.log(message.data)
81
+ })
82
+ ```
83
+
84
+ ## The Extension WebSocket is different
85
+
86
+ `/v1/commands/workflow.watch` is for external clients and only streams Workflow status. The Extension uses `/ws` on the same port to pair with the Bridge, reconnect, and exchange execution commands. `/ws` is an internal Bridge connection, not an agent command API.
package/package.json ADDED
@@ -0,0 +1,55 @@
1
+ {
2
+ "name": "@tapicker/bridge",
3
+ "version": "1.0.0",
4
+ "description": "Tapicker local and remote command bridge",
5
+ "homepage": "https://www.tapicker.com/",
6
+ "license": "MIT",
7
+ "type": "module",
8
+ "main": "dist/index.js",
9
+ "types": "dist/index.d.ts",
10
+ "exports": {
11
+ ".": {
12
+ "types": "./dist/index.d.ts",
13
+ "import": "./dist/index.js"
14
+ }
15
+ },
16
+ "bin": {
17
+ "tapicker": "dist/cli.js"
18
+ },
19
+ "files": [
20
+ "dist/**/*.js",
21
+ "dist/**/*.d.ts",
22
+ "src",
23
+ "README.md",
24
+ "docs"
25
+ ],
26
+ "scripts": {
27
+ "build": "tsc -p tsconfig.json",
28
+ "prepack": "npm run build && node -e \"require('node:fs').chmodSync('dist/cli.js', 0o755)\"",
29
+ "start": "node dist/cli.js bridge start",
30
+ "test": "vitest run",
31
+ "typecheck": "tsc --noEmit -p tsconfig.json",
32
+ "format": "prettier --write .",
33
+ "format:check": "prettier --check ."
34
+ },
35
+ "engines": {
36
+ "node": ">=22"
37
+ },
38
+ "publishConfig": {
39
+ "access": "public"
40
+ },
41
+ "dependencies": {
42
+ "@modelcontextprotocol/node": "^2.1.0",
43
+ "@modelcontextprotocol/server": "^2.2.0",
44
+ "cac": "^6.7.14",
45
+ "ws": "^8.18.0",
46
+ "zod": "^4.0.0"
47
+ },
48
+ "devDependencies": {
49
+ "@types/node": "^22.0.0",
50
+ "@types/ws": "^8.5.13",
51
+ "prettier": "^3.6.0",
52
+ "typescript": "^5.9.0",
53
+ "vitest": "^4.0.0"
54
+ }
55
+ }
@@ -0,0 +1,291 @@
1
+ import { randomInt, randomUUID, timingSafeEqual } from 'node:crypto'
2
+ import { EventEmitter } from 'node:events'
3
+ import type { WebSocket } from 'ws'
4
+ import { BridgeError } from '../errors.js'
5
+ import { WorkflowStatusDataSchema } from '../protocol/schema.js'
6
+ import type { ExtensionPairInput, WorkflowStatusData } from '../protocol/schema.js'
7
+ import { VERSION, PROTOCOL_VERSION } from '../constants.js'
8
+ import { ExtensionSession } from './extension-session.js'
9
+ import type { ExtensionSessionStatus } from './extension-session.js'
10
+ import type { PairedExtensionIdentity, PairingStatePersistence } from './pairing-state.js'
11
+
12
+ const PAIRING_WINDOW_MS = 60_000
13
+ const MAX_PAIRING_ATTEMPTS_PER_IP = 5
14
+ const MAX_PAIRING_FAILURES_PER_CODE = 5
15
+
16
+ export type BridgeServiceOptions = {
17
+ pairingCode: string
18
+ capabilities: string[]
19
+ pairingState?: PairingStatePersistence
20
+ }
21
+
22
+ export type BridgeWorkflowStatusEvent = WorkflowStatusData & { clientId: string }
23
+
24
+ type PairingAttemptWindow = {
25
+ startedAt: number
26
+ attempts: number
27
+ }
28
+
29
+ export class BridgeService extends EventEmitter {
30
+ private readonly sessions = new Set<ExtensionSession>()
31
+ private readonly sessionsByClientId = new Map<string, ExtensionSession>()
32
+ private readonly pairedCredentials = new Map<string, string>()
33
+ private readonly pairingAttempts = new Map<string, PairingAttemptWindow>()
34
+ private pairingCode: string
35
+ private pairingFailures = 0
36
+ private pairingLocked = false
37
+ private pairingInProgress = false
38
+ private initialization: Promise<void> | undefined
39
+
40
+ constructor(private readonly options: BridgeServiceOptions) {
41
+ super()
42
+ this.pairingCode = options.pairingCode
43
+ }
44
+
45
+ initialize() {
46
+ if (!this.initialization) {
47
+ this.initialization = this.restorePairingState()
48
+ }
49
+ return this.initialization
50
+ }
51
+
52
+ attachExtension(socket: WebSocket, remoteAddress = 'unknown') {
53
+ const session = new ExtensionSession(socket, {
54
+ bridgeVersion: VERSION,
55
+ protocolVersion: PROTOCOL_VERSION,
56
+ capabilities: this.options.capabilities,
57
+ remoteAddress,
58
+ verifyCredential: (clientId, credential) => this.verifyCredential(clientId, credential),
59
+ pair: (input, address) => this.pair(input, address),
60
+ })
61
+
62
+ this.sessions.add(session)
63
+ session.on('ready', (hello) => {
64
+ const previousSession = this.sessionsByClientId.get(hello.clientId)
65
+ this.sessionsByClientId.set(hello.clientId, session)
66
+ if (previousSession && previousSession !== session) {
67
+ previousSession.close(1000, 'Replaced by a newer connection for this Extension')
68
+ }
69
+ this.emit('extension.ready', hello)
70
+ })
71
+ session.on('message', (message) => {
72
+ const status = session.getStatus()
73
+ if (status.ready && status.clientId && message.type === 'workflow.status') {
74
+ const parsed = WorkflowStatusDataSchema.safeParse(message.data)
75
+ if (parsed.success) {
76
+ this.emit('workflow.status', {
77
+ clientId: status.clientId,
78
+ ...parsed.data,
79
+ } satisfies BridgeWorkflowStatusEvent)
80
+ }
81
+ }
82
+ this.emit('extension.message', message)
83
+ })
84
+ session.on('error', (error) => this.emit('extension.error', error))
85
+ session.on('close', (details) => {
86
+ this.sessions.delete(session)
87
+ const clientId = session.getStatus().clientId
88
+ if (clientId && this.sessionsByClientId.get(clientId) === session) {
89
+ this.sessionsByClientId.delete(clientId)
90
+ }
91
+ this.emit('extension.close', details)
92
+ })
93
+
94
+ session.start()
95
+ return session
96
+ }
97
+
98
+ async sendCommand<T>(type: string, data: unknown, clientId?: string) {
99
+ const session = this.selectCommandSession(type, clientId)
100
+ return session.request<T>(type, data)
101
+ }
102
+
103
+ async sendCommandWithClientId<T>(type: string, data: unknown, clientId?: string) {
104
+ const session = this.selectCommandSession(type, clientId)
105
+ const selectedClientId = session.getStatus().clientId
106
+ if (!selectedClientId) {
107
+ throw new BridgeError('ExtensionUnavailable', 'The selected Tapicker Extension has no client ID', { status: 503 })
108
+ }
109
+
110
+ return {
111
+ clientId: selectedClientId,
112
+ result: await session.request<T>(type, data),
113
+ }
114
+ }
115
+
116
+ assertExtensionCapability(type: string, clientId: string) {
117
+ this.selectCommandSession(type, clientId)
118
+ }
119
+
120
+ resolveCommandClientId(type: string, clientId?: string) {
121
+ const selectedClientId = this.selectCommandSession(type, clientId).getStatus().clientId
122
+ if (!selectedClientId) {
123
+ throw new BridgeError('ExtensionUnavailable', 'The selected Tapicker Extension has no client ID', { status: 503 })
124
+ }
125
+ return selectedClientId
126
+ }
127
+
128
+ private selectCommandSession(type: string, clientId?: string) {
129
+ const readySessions = [...this.sessionsByClientId.entries()].filter(([, session]) => {
130
+ const status = session.getStatus()
131
+ return status.connected && status.ready
132
+ })
133
+ let session: ExtensionSession | undefined
134
+
135
+ if (clientId) {
136
+ session = this.sessionsByClientId.get(clientId)
137
+ const status = session?.getStatus()
138
+ if (!status?.connected || !status.ready) {
139
+ throw new BridgeError('ExtensionNotConnected', `Tapicker Extension ${clientId} is not connected`, {
140
+ status: 409,
141
+ details: { clientId },
142
+ })
143
+ }
144
+ } else if (readySessions.length === 0) {
145
+ throw new BridgeError('ExtensionUnavailable', 'No paired Tapicker Extension is connected', { status: 503 })
146
+ } else if (readySessions.length > 1) {
147
+ throw new BridgeError('ClientIdRequired', 'Specify clientId when multiple Extensions are connected', {
148
+ status: 409,
149
+ details: {
150
+ extensions: readySessions.map(([clientId, readySession]) => ({
151
+ clientId,
152
+ clientName: readySession.getStatus().clientName,
153
+ })),
154
+ },
155
+ })
156
+ } else {
157
+ session = readySessions[0]?.[1]
158
+ }
159
+
160
+ if (!session) {
161
+ throw new BridgeError('ExtensionUnavailable', 'No paired Tapicker Extension is connected', { status: 503 })
162
+ }
163
+
164
+ const status = session.getStatus()
165
+ if (!status.capabilities.includes(type)) {
166
+ throw new BridgeError('ExtensionCapabilityUnavailable', `Tapicker Extension does not support ${type}`, {
167
+ status: 409,
168
+ details: { clientId: status.clientId, clientName: status.clientName, command: type },
169
+ })
170
+ }
171
+
172
+ return session
173
+ }
174
+
175
+ getExtensionStatuses(): ExtensionSessionStatus[] {
176
+ return [...this.sessions]
177
+ .map((session) => session.getStatus())
178
+ .filter((status) => status.connected)
179
+ .sort((left, right) => (left.clientName || '').localeCompare(right.clientName || ''))
180
+ }
181
+
182
+ getPairingCode() {
183
+ return this.pairingLocked ? undefined : this.pairingCode
184
+ }
185
+
186
+ refreshPairingCode() {
187
+ if (this.pairingInProgress) {
188
+ throw new BridgeError('PairingUnavailable', 'An Extension is currently being paired; try again shortly', {
189
+ status: 409,
190
+ })
191
+ }
192
+ this.rotatePairingCode()
193
+ return this.pairingCode
194
+ }
195
+
196
+ close() {
197
+ for (const session of this.sessions) {
198
+ session.close(1000, 'Bridge shutting down')
199
+ }
200
+ this.sessions.clear()
201
+ this.sessionsByClientId.clear()
202
+ }
203
+
204
+ private verifyCredential(clientId: string, credential: string) {
205
+ const savedCredential = this.pairedCredentials.get(clientId)
206
+ if (!savedCredential) return false
207
+ return constantTimeEqual(credential, savedCredential)
208
+ }
209
+
210
+ private async pair(input: ExtensionPairInput, remoteAddress: string) {
211
+ if (this.pairingLocked) {
212
+ throw new BridgeError(
213
+ 'PairingRateLimited',
214
+ 'Pairing is locked after too many incorrect codes; run tapicker bridge code --refresh locally',
215
+ { status: 429, details: { refreshCommand: 'tapicker bridge code --refresh' } },
216
+ )
217
+ }
218
+ if (this.pairingInProgress) {
219
+ throw new BridgeError('PairingUnavailable', 'Another Extension is currently being paired', { status: 409 })
220
+ }
221
+
222
+ const now = Date.now()
223
+ for (const [address, attemptWindow] of this.pairingAttempts) {
224
+ if (now - attemptWindow.startedAt >= PAIRING_WINDOW_MS) {
225
+ this.pairingAttempts.delete(address)
226
+ }
227
+ }
228
+
229
+ const current = this.pairingAttempts.get(remoteAddress)
230
+ const window = !current || now - current.startedAt >= PAIRING_WINDOW_MS ? { startedAt: now, attempts: 0 } : current
231
+ if (window.attempts >= MAX_PAIRING_ATTEMPTS_PER_IP) {
232
+ throw new BridgeError('PairingRateLimited', 'Too many pairing attempts; try again in one minute', {
233
+ status: 429,
234
+ })
235
+ }
236
+
237
+ window.attempts += 1
238
+ this.pairingAttempts.set(remoteAddress, window)
239
+ if (!constantTimeEqual(input.pairingCode, this.pairingCode)) {
240
+ this.pairingFailures += 1
241
+ if (this.pairingFailures >= MAX_PAIRING_FAILURES_PER_CODE) {
242
+ this.pairingLocked = true
243
+ throw new BridgeError(
244
+ 'PairingRateLimited',
245
+ 'Pairing is locked after too many incorrect codes; run tapicker bridge code --refresh locally',
246
+ { status: 429, details: { refreshCommand: 'tapicker bridge code --refresh' } },
247
+ )
248
+ }
249
+ throw new BridgeError('InvalidPairingCode', 'The pairing code is incorrect', { status: 401 })
250
+ }
251
+
252
+ this.pairingInProgress = true
253
+ try {
254
+ const credential = randomUUID()
255
+ const identities: PairedExtensionIdentity[] = [...this.pairedCredentials.entries()]
256
+ .filter(([clientId]) => clientId !== input.clientId)
257
+ .map(([clientId, savedCredential]) => ({ clientId, credential: savedCredential }))
258
+ identities.push({ clientId: input.clientId, credential })
259
+ await this.options.pairingState?.save(identities)
260
+ this.pairedCredentials.set(input.clientId, credential)
261
+ this.rotatePairingCode()
262
+ return credential
263
+ } finally {
264
+ this.pairingInProgress = false
265
+ }
266
+ }
267
+
268
+ private rotatePairingCode() {
269
+ const previousCode = this.pairingCode
270
+ do {
271
+ this.pairingCode = String(randomInt(0, 10_000)).padStart(4, '0')
272
+ } while (this.pairingCode === previousCode)
273
+ this.pairingFailures = 0
274
+ this.pairingLocked = false
275
+ this.pairingAttempts.clear()
276
+ }
277
+
278
+ private async restorePairingState() {
279
+ const pairedIdentities = (await this.options.pairingState?.load()) ?? []
280
+ for (const identity of pairedIdentities) {
281
+ this.pairedCredentials.set(identity.clientId, identity.credential)
282
+ }
283
+ }
284
+ }
285
+
286
+ function constantTimeEqual(left: string, right: string) {
287
+ const leftBuffer = Buffer.from(left)
288
+ const rightBuffer = Buffer.from(right)
289
+ if (leftBuffer.length !== rightBuffer.length) return false
290
+ return timingSafeEqual(leftBuffer, rightBuffer)
291
+ }