dsh-plugin-mobile-link 0.2.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/lib/index.js ADDED
@@ -0,0 +1,128 @@
1
+ /**
2
+ * dsh-plugin-mobile-link — host half.
3
+ *
4
+ * Scope (deliberately tiny so a DSH upgrade can never make it fatal):
5
+ * 1. Run the DLP agent: dial the relay over WSS, serve the iOS client's
6
+ * session/event traffic from the local DSH instance. On a computer that has
7
+ * never been registered, redeem the invite code from the environment (or
8
+ * the config) first, so a fresh install becomes usable without the relay
9
+ * operator provisioning anything by hand.
10
+ * 2. Three routes under `/mobile-link` behind DSH's own connection fence:
11
+ * GET /status — is the link up, which devices are attached, and is
12
+ * this computer registered yet
13
+ * POST /pair-code — mint a pairing code + QR payload through the relay
14
+ * GET /qr — the same code rendered as a scannable QR SVG
15
+ *
16
+ * Deliberate design choices:
17
+ * * The plugin declares **no required services** (`inject` is empty). The DLP
18
+ * agent must run even if a future DSH renames or drops `webServer` /
19
+ * `connection`, so the routes are attached through `ctx.inject([...])`
20
+ * instead of a top-level requirement. Only `ctx.logger` / `ctx.effect` /
21
+ * `ctx.get` (all optional-chained) are used.
22
+ * * Nothing outside `lib/` is imported, and no DSH business API is touched:
23
+ * the tunnel is protocol-transparent by construction (spec §1).
24
+ *
25
+ * Disable by removing the row from the profile's cordis.patch.yml or by setting
26
+ * `enabled: false` in the config block.
27
+ */
28
+
29
+ import { MobileLinkAgent } from './link.js'
30
+ import { registerMobileLinkRoutes } from './routes.js'
31
+
32
+ /** Cordis function-plugin name (the loader row id is separate). */
33
+ export const name = 'mobile-link'
34
+
35
+ const DEFAULTS = {
36
+ enabled: true,
37
+ // 仓库里只留占位符:真值走 config.relayUrl,或 DSH_RELAY_URL / DSH_MOBILE_LINK_RELAY
38
+ // 环境变量(本机的 .env.local 里写着,见 README「配置」一节)。
39
+ relayUrl: process.env.DSH_RELAY_URL
40
+ || process.env.DSH_MOBILE_LINK_RELAY
41
+ || 'wss://relay.example.com/dsh-link',
42
+ agentId: '',
43
+ agentSecret: '',
44
+ stateFile: '',
45
+ dshUrl: '',
46
+ endpointFile: '',
47
+ inviteCode: '',
48
+ heartbeatMs: 20000,
49
+ pongTimeoutMs: 60000,
50
+ maxBackoffMs: 30000,
51
+ pairTtlMs: 10 * 60 * 1000,
52
+ }
53
+
54
+ /** @param {unknown} value */
55
+ function asRecord(value) {
56
+ return value !== null && typeof value === 'object' ? value : {}
57
+ }
58
+
59
+ /**
60
+ * @param {import('@deepseek-ai/cordis').Context} ctx
61
+ * @param {Record<string, unknown>} rawConfig
62
+ */
63
+ export function apply(ctx, rawConfig) {
64
+ const config = { ...DEFAULTS, ...asRecord(rawConfig) }
65
+ const logger = ctx.logger ?? console
66
+
67
+ const agent = new MobileLinkAgent({
68
+ enabled: config.enabled !== false,
69
+ relayUrl: typeof config.relayUrl === 'string' ? config.relayUrl : DEFAULTS.relayUrl,
70
+ agentId: typeof config.agentId === 'string' ? config.agentId : '',
71
+ agentSecret: typeof config.agentSecret === 'string' ? config.agentSecret : '',
72
+ stateFile: typeof config.stateFile === 'string' && config.stateFile ? config.stateFile : undefined,
73
+ // An invite code may be pinned in the plugin config for a packaged install;
74
+ // otherwise the agent reads DSH_MOBILE_LINK_INVITE (see enroll.js).
75
+ inviteCode: typeof config.inviteCode === 'string' && config.inviteCode ? config.inviteCode : undefined,
76
+ dshUrl: typeof config.dshUrl === 'string' && config.dshUrl ? config.dshUrl : undefined,
77
+ endpointFile: typeof config.endpointFile === 'string' && config.endpointFile ? config.endpointFile : undefined,
78
+ heartbeatMs: Number(config.heartbeatMs) || DEFAULTS.heartbeatMs,
79
+ pongTimeoutMs: Number(config.pongTimeoutMs) || DEFAULTS.pongTimeoutMs,
80
+ maxBackoffMs: Number(config.maxBackoffMs) || DEFAULTS.maxBackoffMs,
81
+ logger,
82
+ })
83
+
84
+ ctx.effect(() => {
85
+ let disposed = false
86
+ agent.emit('status')
87
+ agent.start().catch((error) => {
88
+ if (!disposed) logger.warn?.(`mobile-link: start failed: ${error}`)
89
+ })
90
+ return () => {
91
+ disposed = true
92
+ void agent.stop().catch(() => {})
93
+ }
94
+ }, 'mobile-link: DLP agent')
95
+
96
+ // Routes ride on DSH's web server when that service exists. `ctx.inject`
97
+ // scopes them without making the service a hard requirement for the agent.
98
+ try {
99
+ ctx.inject(['webServer'], (scope) => {
100
+ const disposers = registerMobileLinkRoutes(scope, { agent, logger, config })
101
+ scope.effect?.(() => () => {
102
+ for (const dispose of disposers) {
103
+ try {
104
+ dispose()
105
+ } catch {
106
+ /* route table already torn down */
107
+ }
108
+ }
109
+ }, 'mobile-link: routes')
110
+ logger.info?.('mobile-link: routes ready under /mobile-link')
111
+ })
112
+ } catch (error) {
113
+ logger.warn?.(`mobile-link: routes unavailable (the link itself keeps running): ${error}`)
114
+ }
115
+
116
+ logger.info?.(`mobile-link: ready (relay ${config.relayUrl})`)
117
+ }
118
+
119
+ export { MobileLinkAgent } from './link.js'
120
+ export { DshClient, discoverEndpoint, DshUnavailable } from './dsh-client.js'
121
+ export {
122
+ MAX_FRAME_BYTES, StreamTable, WaterfallDedupe, agentEndpoint, decodeFrame, encodeFrame,
123
+ joinRelayPath, nextBackoff, normalizeRelayUrl, pairCodeEndpoint, qrPayload,
124
+ } from './dlp.js'
125
+ export { resolveIdentity, defaultStatePath, defaultEndpointFile, MissingIdentity } from './state.js'
126
+ export { enrollAgent, enrollPlan, enrollCommand, describeEnrollFailure } from './enroll.js'
127
+ export { INVITE_ENV, DEFAULT_AGENT_NAME } from './state.js'
128
+ export { qrMatrix, qrSvg, versionFor } from './qr.js'
package/lib/link.js ADDED
@@ -0,0 +1,439 @@
1
+ /**
2
+ * The DLP agent transport: identity, the relay socket, heartbeats, reconnect.
3
+ * Device-level routing lives in `router.js`; this file owns every socket and
4
+ * timer. Spec: docs/RELAY-PROTOCOL.md §4.
5
+ */
6
+
7
+ import { EventEmitter } from 'node:events'
8
+
9
+ import { DshClient } from './dsh-client.js'
10
+ import { agentEndpoint, decodeFrame, encodeFrame, nextBackoff, normalizeRelayUrl, pongFor } from './dlp.js'
11
+ import { enrollAgent, enrollCommand, inviteFrom } from './enroll.js'
12
+ import { mintPairCode } from './pairing.js'
13
+ import { EVENTS_ENDPOINT, EVENTS_RESULT, DeviceRouter, messageOf } from './router.js'
14
+ import { SERVER_VERSION, SERVER_CAPABILITIES } from './hello.js'
15
+ import { MissingIdentity, resolveIdentity } from './state.js'
16
+ import { connect as wsConnect } from './ws.js'
17
+
18
+ const PROTOCOL_VERSION = 1
19
+
20
+ function sleep(ms, signal) {
21
+ return new Promise((resolve) => {
22
+ const timer = setTimeout(resolve, ms)
23
+ timer.unref?.()
24
+ signal?.addEventListener?.('abort', () => {
25
+ clearTimeout(timer)
26
+ resolve()
27
+ }, { once: true })
28
+ })
29
+ }
30
+
31
+ export class MobileLinkAgent extends EventEmitter {
32
+ constructor({
33
+ relayUrl, agentId, agentSecret, agentName, stateFile, dshUrl, endpointFile,
34
+ logger = console, heartbeatMs = 20000, pongTimeoutMs = 60000, maxBackoffMs = 30000,
35
+ random = Math.random, now = Date.now, dshClient, connectImpl = wsConnect, enabled = true,
36
+ inviteCode, fetchImpl = fetch, env = process.env,
37
+ } = {}) {
38
+ super()
39
+ this.config = { relayUrl, agentId, agentSecret, agentName, stateFile, dshUrl, endpointFile, inviteCode }
40
+ this.logger = logger
41
+ this.heartbeatMs = heartbeatMs
42
+ this.pongTimeoutMs = pongTimeoutMs
43
+ this.maxBackoffMs = maxBackoffMs
44
+ this.random = random
45
+ this.now = now
46
+ this.connectImpl = connectImpl
47
+ this.fetchImpl = fetchImpl
48
+ this.env = env
49
+ this.enabled = enabled
50
+ //: Set when this computer has no identity yet: a setup problem for a person
51
+ //: to fix, not a transport problem to retry.
52
+ this.needsEnroll = false
53
+ this.dsh = dshClient ?? new DshClient({ endpointFile, explicitUrl: dshUrl, logger })
54
+ this.router = new DeviceRouter({
55
+ dsh: this.dsh,
56
+ logger,
57
+ now,
58
+ protocolVersion: PROTOCOL_VERSION,
59
+ send: (deviceId, frame) => this.sendToDevice(deviceId, frame),
60
+ onChange: () => this.emit('status'),
61
+ })
62
+
63
+ this.identity = undefined
64
+ this.relay = undefined
65
+ this.socket = undefined
66
+ this.state = 'idle'
67
+ this.lastError = undefined
68
+ this.dshError = undefined
69
+ this.startedAt = undefined
70
+ this.connectedAt = undefined
71
+ this.reconnectAttempts = 0
72
+
73
+ this._stopping = false
74
+ this._sessionClosed = undefined
75
+ this._heartbeat = undefined
76
+ this._inbox = Promise.resolve()
77
+ this._lastPong = 0
78
+ this._loop = undefined
79
+ this._abort = new AbortController()
80
+
81
+ // The mux is shared by every device, so its events are wired once here.
82
+ this.dsh.on?.('stream-item', (muxId, value) => this.onStreamItem(muxId, value))
83
+ this.dsh.on?.('stream-error', (muxId, error) => this.onStreamError(muxId, error))
84
+ this.dsh.on?.('stream-end', (muxId) => this.onStreamEnd(muxId))
85
+ this.dsh.on?.('mux-down', (reason) => this.onMuxDown(reason))
86
+ }
87
+
88
+ // Routing state, exposed for status and for tests.
89
+ get devices() {
90
+ return this.router.devices
91
+ }
92
+
93
+ get streams() {
94
+ return this.router.streams
95
+ }
96
+
97
+ get dedupe() {
98
+ return this.router.dedupe
99
+ }
100
+
101
+ /** The most recent failure from either half of the link. */
102
+ get failure() {
103
+ return this.lastError ?? this.router.lastError
104
+ }
105
+
106
+ // ── lifecycle ───────────────────────────────────────────────────────────
107
+
108
+ async start() {
109
+ if (this.enabled === false) {
110
+ this.state = 'disabled'
111
+ this.emit('status')
112
+ return this
113
+ }
114
+ if (this._loop) return this
115
+ this._stopping = false
116
+ this._abort = new AbortController()
117
+ this.startedAt = this.now()
118
+ this.state = 'connecting'
119
+ this._loop = this.#run().catch((error) => {
120
+ this.lastError = messageOf(error)
121
+ this.state = 'failed'
122
+ this.emit('status')
123
+ })
124
+ return this
125
+ }
126
+
127
+ async stop() {
128
+ this._stopping = true
129
+ this._abort.abort()
130
+ this.#teardownSocket('agent stopped')
131
+ try {
132
+ await this._loop
133
+ } catch {
134
+ /* already reported */
135
+ }
136
+ this._loop = undefined
137
+ this.dsh.close()
138
+ this.state = 'stopped'
139
+ this.emit('status')
140
+ }
141
+
142
+ async #run() {
143
+ while (!this._stopping) {
144
+ // First run on a computer that has never been registered: redeem the
145
+ // invite code once, then carry on into the normal connect below. A
146
+ // failure here is a setup error, so it is reported as such and not
147
+ // retried in a loop.
148
+ if (this.needsEnroll || this.#enrollPending()) {
149
+ try {
150
+ await this.enrollNow()
151
+ } catch (error) {
152
+ this.needsEnroll = true
153
+ this.state = 'unregistered'
154
+ this.lastError = messageOf(error)
155
+ this.logger.error?.(this.lastError)
156
+ this.emit('status')
157
+ return
158
+ }
159
+ }
160
+ this.state = this.reconnectAttempts === 0 ? 'connecting' : 'reconnecting'
161
+ this.emit('status')
162
+ try {
163
+ await this.#session()
164
+ this.reconnectAttempts = 0
165
+ } catch (error) {
166
+ // A missing identity is a setup state, not a transport failure:
167
+ // retrying it would only produce the same error every few seconds.
168
+ if (error instanceof MissingIdentity) {
169
+ this.needsEnroll = true
170
+ this.state = 'unregistered'
171
+ this.lastError = messageOf(error)
172
+ this.logger.error?.(`${this.lastError}\n ${this.enrollHint()}`)
173
+ this.emit('status')
174
+ return
175
+ }
176
+ this.lastError = messageOf(error)
177
+ this.logger.warn?.(`mobile-link: ${this.lastError}`)
178
+ }
179
+ if (this._stopping) break
180
+ this.state = 'backoff'
181
+ this.emit('status')
182
+ const delay = nextBackoff(this.reconnectAttempts, { maxMs: this.maxBackoffMs, random: this.random })
183
+ this.reconnectAttempts += 1
184
+ await sleep(delay, this._abort.signal)
185
+ }
186
+ this.state = 'stopped'
187
+ this.emit('status')
188
+ }
189
+
190
+ /** An invite code was supplied and there is no identity file yet. */
191
+ #enrollPending() {
192
+ if (!inviteFrom({ inviteCode: this.config.inviteCode, env: this.env })) return false
193
+ return !this.identity
194
+ }
195
+
196
+ /**
197
+ * Redeem this computer's invite code.
198
+ *
199
+ * Exposed because the install path calls it directly (before the link ever
200
+ * starts) and because `status()` reports whether it is still outstanding.
201
+ */
202
+ async enrollNow() {
203
+ const relayUrl = this.config.relayUrl
204
+ const settled = await enrollAgent({
205
+ relayUrl,
206
+ inviteCode: inviteFrom({ inviteCode: this.config.inviteCode, env: this.env }),
207
+ name: this.config.agentName,
208
+ stateFile: this.config.stateFile,
209
+ fetchImpl: this.fetchImpl,
210
+ logger: this.logger,
211
+ })
212
+ this.needsEnroll = false
213
+ this.lastError = undefined
214
+ this.emit('status')
215
+ return settled
216
+ }
217
+
218
+ /** What a person has to run to register this computer. */
219
+ enrollHint() {
220
+ return enrollCommand(this.config.relayUrl, this.config.agentName)
221
+ }
222
+
223
+ /** One relay connection: resolves when the socket closes. */
224
+ async #session() {
225
+ const identity = await resolveIdentity(this.config)
226
+ this.identity = identity
227
+ this.needsEnroll = false
228
+ this.relay = normalizeRelayUrl(identity.relayUrl)
229
+ const url = `${agentEndpoint(identity.relayUrl)}?agentId=${encodeURIComponent(identity.agentId)}`
230
+ const socket = await this.connectImpl(url, {
231
+ headers: { authorization: `Bearer ${identity.agentSecret}` },
232
+ timeoutMs: 20000,
233
+ })
234
+ this.socket = socket
235
+ this.state = 'connected'
236
+ this.connectedAt = this.now()
237
+ this.lastError = undefined
238
+ this.router.lastError = undefined
239
+ this._lastPong = this.now()
240
+ this.logger.info?.(`mobile-link: connected to ${this.relay.wsBase} as ${identity.agentId}`)
241
+ this.emit('status')
242
+
243
+ socket.onMessage((text) => {
244
+ this._inbox = this._inbox
245
+ .then(() => this.handleRelayFrame(decodeFrame(text)))
246
+ .catch((error) => this.logger.debug?.(`mobile-link: frame handler error: ${messageOf(error)}`))
247
+ return this._inbox
248
+ })
249
+ socket.onError((error) => this.logger.debug?.(`mobile-link: relay socket error: ${messageOf(error)}`))
250
+ socket.onClose((code, reason) => this.#teardownSocket(`relay closed the socket (${code ?? '?'} ${reason ?? ''})`))
251
+
252
+ this.#startHeartbeat()
253
+ // Verify discovery + auth as soon as the relay link is up, so /mobile-link/
254
+ // status can report the real DSH port (and the failure, if any) before the
255
+ // first device ever asks for anything.
256
+ try {
257
+ await this.dsh.refresh()
258
+ this.dshError = undefined
259
+ } catch (error) {
260
+ this.dshError = messageOf(error)
261
+ this.lastError = this.dshError
262
+ this.logger.warn?.(`mobile-link: local DSH is not reachable yet: ${this.dshError}`)
263
+ }
264
+ this.emit('status')
265
+ this.sendHostStatus()
266
+
267
+ await new Promise((resolve) => {
268
+ this._sessionClosed = resolve
269
+ })
270
+ }
271
+
272
+ #startHeartbeat() {
273
+ this.#stopHeartbeat()
274
+ this._heartbeat = setInterval(() => {
275
+ if (this.now() - this._lastPong > this.pongTimeoutMs) {
276
+ this.lastError = `relay stopped answering pings (>${this.pongTimeoutMs}ms)`
277
+ this.logger.warn?.(`mobile-link: ${this.lastError}`)
278
+ this.#teardownSocket(this.lastError)
279
+ return
280
+ }
281
+ this.#rawSend({ t: 'ping', ts: this.now() })
282
+ }, this.heartbeatMs)
283
+ this._heartbeat.unref?.()
284
+ }
285
+
286
+ #stopHeartbeat() {
287
+ if (this._heartbeat) clearInterval(this._heartbeat)
288
+ this._heartbeat = undefined
289
+ }
290
+
291
+ #teardownSocket(reason) {
292
+ this.#stopHeartbeat()
293
+ const socket = this.socket
294
+ this.socket = undefined
295
+ if (socket) {
296
+ try {
297
+ socket.close(1000, 'reconnecting')
298
+ } catch {
299
+ /* already gone */
300
+ }
301
+ }
302
+ // The relay re-attaches devices on the next connection; drop their streams
303
+ // now so nothing leaks while we are away.
304
+ for (const muxId of this.router.teardownAll()) this.dsh.cancelStream(muxId)
305
+ this.dsh.close()
306
+ if (this.state !== 'stopped' && this.state !== 'disabled') this.state = 'disconnected'
307
+ this.lastError = this.lastError ?? reason
308
+ const resolve = this._sessionClosed
309
+ this._sessionClosed = undefined
310
+ this.emit('status')
311
+ resolve?.()
312
+ }
313
+
314
+ // ── relay I/O ───────────────────────────────────────────────────────────
315
+
316
+ #rawSend(frame) {
317
+ const socket = this.socket
318
+ if (!socket || socket.closed) return false
319
+ try {
320
+ socket.send(encodeFrame(frame))
321
+ return true
322
+ } catch (error) {
323
+ this.logger.debug?.(`mobile-link: send failed: ${messageOf(error)}`)
324
+ return false
325
+ }
326
+ }
327
+
328
+ sendToDevice(deviceId, frame) {
329
+ return this.#rawSend({ ...frame, deviceId })
330
+ }
331
+
332
+ sendHostStatus() {
333
+ return this.#rawSend({
334
+ t: 'hostStatus',
335
+ info: {
336
+ online: true,
337
+ agentId: this.identity?.agentId,
338
+ name: this.identity?.agentName || undefined,
339
+ dshPort: this.dsh?.endpoint?.port,
340
+ protocol: PROTOCOL_VERSION,
341
+ },
342
+ })
343
+ }
344
+
345
+ /** Route one frame from the relay. */
346
+ async handleRelayFrame(frame) {
347
+ if (!frame || typeof frame !== 'object') return
348
+ if (frame.t === 'pong') {
349
+ this._lastPong = this.now()
350
+ return
351
+ }
352
+ if (frame.t === 'ping') {
353
+ this.#rawSend(pongFor(frame))
354
+ return
355
+ }
356
+ if (frame.t === 'error') {
357
+ this.lastError = `${frame.code}: ${frame.message}`
358
+ this.emit('status')
359
+ if (frame.fatal) this.socket?.close(4002, 'relay protocol error')
360
+ return
361
+ }
362
+ await this.router.handleRelayFrame(frame)
363
+ }
364
+
365
+ // ── mux-event delegates ─────────────────────────────────────────────────
366
+
367
+ onStreamItem(muxId, value) {
368
+ this.router.onStreamItem(muxId, value)
369
+ }
370
+
371
+ onStreamError(muxId, error) {
372
+ this.router.onStreamError(muxId, error)
373
+ }
374
+
375
+ onStreamEnd(muxId) {
376
+ this.router.onStreamEnd(muxId)
377
+ }
378
+
379
+ onMuxDown(reason) {
380
+ this.router.onMuxDown(reason)
381
+ }
382
+
383
+ // ── relay-side helpers ──────────────────────────────────────────────────
384
+
385
+ /** Ask the relay to mint a pairing code for this agent (relay NOTES.md §2). */
386
+ async mintPairCode({ ttlMs = 10 * 60 * 1000, fetchImpl = fetch } = {}) {
387
+ const identity = this.identity ?? await resolveIdentity(this.config)
388
+ return mintPairCode({ identity, ttlMs, fetchImpl })
389
+ }
390
+
391
+ /** Snapshot for `GET /mobile-link/status`. */
392
+ status() {
393
+ return {
394
+ ok: true,
395
+ enabled: this.enabled !== false,
396
+ protocolVersion: PROTOCOL_VERSION,
397
+ // Same facts as `_link/hello`, which is the channel the app actually
398
+ // uses: this route only exists on the direct path.
399
+ serverVersion: SERVER_VERSION,
400
+ capabilities: SERVER_CAPABILITIES,
401
+ enroll: {
402
+ // A phone (or the user's browser) can read this to find out that the
403
+ // computer half is installed but not yet registered, and what to run.
404
+ registered: !this.needsEnroll,
405
+ needsEnroll: this.needsEnroll,
406
+ stateFile: this.identity?.stateFile ?? this.config.stateFile ?? null,
407
+ relayUrl: this.config.relayUrl ?? null,
408
+ command: this.needsEnroll ? this.enrollHint() : null,
409
+ hint: this.needsEnroll
410
+ ? '这台电脑还没有登记到中转。请带上邀请码运行一次登记命令,然后重启 DSH。'
411
+ : null,
412
+ },
413
+ state: this.state,
414
+ connected: this.state === 'connected',
415
+ relayUrl: this.identity?.relayUrl ?? this.config.relayUrl ?? null,
416
+ agentId: this.identity?.agentId ?? this.config.agentId ?? null,
417
+ agentName: this.identity?.agentName ?? null,
418
+ stateFile: this.identity?.stateFile ?? this.config.stateFile ?? null,
419
+ dsh: {
420
+ endpoint: this.dsh?.endpoint?.base ?? null,
421
+ source: this.dsh?.endpoint?.source ?? null,
422
+ port: this.dsh?.endpoint?.port ?? null,
423
+ authenticated: Boolean(this.dsh?.cookie),
424
+ muxUp: Boolean(this.dsh?.muxReady),
425
+ error: this.dshError ?? null,
426
+ },
427
+ devices: this.router.snapshot(),
428
+ deviceCount: this.devices.size,
429
+ openStreams: this.streams.size,
430
+ pendingWaterfalls: this.dedupe.size,
431
+ lastError: this.failure ?? null,
432
+ startedAt: this.startedAt ?? null,
433
+ connectedAt: this.connectedAt ?? null,
434
+ reconnectAttempts: this.reconnectAttempts,
435
+ }
436
+ }
437
+ }
438
+
439
+ export { EVENTS_ENDPOINT, EVENTS_RESULT }
package/lib/pairing.js ADDED
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Pairing helper: ask the relay to mint a one-time code and build the QR
3
+ * payload the desktop shows. Spec §2.1 plus relay/NOTES.md §2 (the endpoint the
4
+ * spec's table is missing).
5
+ */
6
+
7
+ import { pairCodeEndpoint, qrPayload } from './dlp.js'
8
+
9
+ /**
10
+ * @param {object} options
11
+ * @param {{agentId: string, agentSecret: string, relayUrl: string}} options.identity
12
+ * @param {number} [options.ttlMs] requested lifetime (the relay caps it at 1 hour)
13
+ * @param {typeof fetch} [options.fetchImpl] injectable for tests
14
+ */
15
+ export async function mintPairCode({ identity, ttlMs = 10 * 60 * 1000, fetchImpl = fetch }) {
16
+ const response = await fetchImpl(pairCodeEndpoint(identity.relayUrl), {
17
+ method: 'POST',
18
+ headers: {
19
+ 'content-type': 'application/json',
20
+ authorization: `Bearer ${identity.agentSecret}`,
21
+ },
22
+ body: JSON.stringify({ agentId: identity.agentId, ttlMs }),
23
+ })
24
+ let body
25
+ try {
26
+ body = await response.json()
27
+ } catch {
28
+ throw new Error(`relay returned HTTP ${response.status} for /pair/code`)
29
+ }
30
+ if (!response.ok || body?.ok !== true) {
31
+ throw new Error(body?.error?.message ?? `relay rejected the pairing request (HTTP ${response.status})`)
32
+ }
33
+ return {
34
+ code: body.code,
35
+ expiresAt: body.expiresAt,
36
+ ttlMs: body.ttlMs ?? ttlMs,
37
+ relayUrl: identity.relayUrl,
38
+ agentId: identity.agentId,
39
+ qrPayload: qrPayload({ relay: identity.relayUrl, code: body.code }),
40
+ }
41
+ }