@tapicker/bridge 1.0.0 → 1.1.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/README.md +15 -15
- package/dist/bridge/bridge-service.d.ts +1 -1
- package/dist/bridge/bridge-service.js +7 -7
- package/dist/bridge/control.js +1 -1
- package/dist/bridge/export-store.d.ts +35 -0
- package/dist/bridge/export-store.js +195 -0
- package/dist/bridge/extension-session.d.ts +9 -2
- package/dist/bridge/extension-session.js +56 -8
- package/dist/cli.js +563 -148
- package/dist/commands.d.ts +8 -0
- package/dist/commands.js +276 -40
- package/dist/constants.d.ts +2 -2
- package/dist/constants.js +4 -2
- package/dist/http-client.d.ts +24 -0
- package/dist/http-client.js +101 -0
- package/dist/index.d.ts +4 -4
- package/dist/index.js +2 -2
- package/dist/mcp.d.ts +4 -3
- package/dist/mcp.js +221 -134
- package/dist/protocol/schema.d.ts +503 -34
- package/dist/protocol/schema.js +396 -15
- package/dist/server.d.ts +7 -4
- package/dist/server.js +70 -26
- package/dist/update-notifier-worker.d.ts +2 -0
- package/dist/update-notifier-worker.js +3 -0
- package/dist/update-notifier.d.ts +9 -0
- package/dist/update-notifier.js +178 -0
- package/dist/workflow-watch.d.ts +8 -7
- package/dist/workflow-watch.js +39 -29
- package/package.json +16 -6
- package/docs/cli.md +0 -112
- package/docs/http-api.md +0 -149
- package/docs/mcp.md +0 -90
- package/docs/operations.md +0 -85
- package/docs/workflow-watch.md +0 -86
- package/src/bridge/bridge-service.ts +0 -291
- package/src/bridge/control.ts +0 -281
- package/src/bridge/daemon.ts +0 -102
- package/src/bridge/extension-session.ts +0 -286
- package/src/bridge/pairing-state.ts +0 -92
- package/src/cli.ts +0 -1019
- package/src/commands.ts +0 -158
- package/src/constants.ts +0 -10
- package/src/errors.ts +0 -72
- package/src/index.ts +0 -35
- package/src/mcp.ts +0 -386
- package/src/protocol/schema.ts +0 -202
- package/src/server.ts +0 -623
- package/src/workflow-watch.ts +0 -364
package/docs/operations.md
DELETED
|
@@ -1,85 +0,0 @@
|
|
|
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.
|
package/docs/workflow-watch.md
DELETED
|
@@ -1,86 +0,0 @@
|
|
|
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.
|
|
@@ -1,291 +0,0 @@
|
|
|
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
|
-
}
|