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/README.md +111 -0
- package/cordis.patch.yml +37 -0
- package/lib/cli.js +191 -0
- package/lib/dlp.js +296 -0
- package/lib/dsh-client.js +280 -0
- package/lib/enroll.js +142 -0
- package/lib/files.js +127 -0
- package/lib/hello.js +65 -0
- package/lib/index.js +128 -0
- package/lib/link.js +439 -0
- package/lib/pairing.js +41 -0
- package/lib/qr.js +576 -0
- package/lib/router.js +379 -0
- package/lib/routes.js +181 -0
- package/lib/state.js +148 -0
- package/lib/ws.js +183 -0
- package/package.json +54 -0
package/lib/router.js
ADDED
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Device-side routing for one agent connection.
|
|
3
|
+
*
|
|
4
|
+
* Owns the device registry, the DLP-id ↔ mux-stream table, the waterfall
|
|
5
|
+
* dedupe set and every per-device `$events` stream. The transport concerns
|
|
6
|
+
* live in `link.js`; everything here is driven by frames and by DSH mux events,
|
|
7
|
+
* so `node --test` can exercise it without a socket.
|
|
8
|
+
*
|
|
9
|
+
* Spec: docs/RELAY-PROTOCOL.md §4.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import {
|
|
13
|
+
StreamTable, WaterfallDedupe, deviceFrameError, deviceIdOf, nonEmptyString, resultFrame,
|
|
14
|
+
} from './dlp.js'
|
|
15
|
+
import { FileInbox, isFileMethod } from './files.js'
|
|
16
|
+
import { isHelloMethod, helloPayload } from './hello.js'
|
|
17
|
+
|
|
18
|
+
export const EVENTS_ENDPOINT = '$events'
|
|
19
|
+
export const EVENTS_RESULT = '$events/result'
|
|
20
|
+
|
|
21
|
+
export function messageOf(error) {
|
|
22
|
+
return error instanceof Error ? error.message : String(error)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function errorObject(code, message, details = {}) {
|
|
26
|
+
return { code, message, details }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export class DeviceRouter {
|
|
30
|
+
/**
|
|
31
|
+
* @param {object} options
|
|
32
|
+
* @param {object} options.dsh local DSH client (rpc / ensureMux / openStream / cancelStream)
|
|
33
|
+
* @param {(deviceId: string, frame: object) => boolean} options.send deliver one frame to one device
|
|
34
|
+
* @param {() => void} [options.onChange] called whenever the status snapshot changes
|
|
35
|
+
*/
|
|
36
|
+
constructor({ dsh, send, logger = console, onChange = () => {}, now = Date.now, protocolVersion = 1 } = {}) {
|
|
37
|
+
this.dsh = dsh
|
|
38
|
+
// Reported to the app so it can tell which wire revision it is talking to.
|
|
39
|
+
this.protocolVersion = protocolVersion
|
|
40
|
+
this.send = send
|
|
41
|
+
this.logger = logger
|
|
42
|
+
this.onChange = onChange
|
|
43
|
+
this.now = now
|
|
44
|
+
this.devices = new Map()
|
|
45
|
+
this.streams = new StreamTable()
|
|
46
|
+
this.dedupe = new WaterfallDedupe({ now })
|
|
47
|
+
this.lastError = undefined
|
|
48
|
+
this.fileInbox = new FileInbox(logger)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
#changed() {
|
|
52
|
+
this.onChange()
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
#fail(message) {
|
|
56
|
+
this.lastError = message
|
|
57
|
+
this.logger.warn?.(`mobile-link: ${message}`)
|
|
58
|
+
this.#changed()
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Route one validated frame that the relay addressed to a device. */
|
|
62
|
+
async handleRelayFrame(frame) {
|
|
63
|
+
if (!frame || typeof frame !== 'object') return
|
|
64
|
+
const deviceId = deviceIdOf(frame)
|
|
65
|
+
if (frame.t === 'deviceAttach') {
|
|
66
|
+
await this.attachDevice(deviceId, frame.device)
|
|
67
|
+
return
|
|
68
|
+
}
|
|
69
|
+
if (frame.t === 'deviceDetach') {
|
|
70
|
+
await this.detachDevice(deviceId, frame.reason)
|
|
71
|
+
return
|
|
72
|
+
}
|
|
73
|
+
if (!deviceId) return
|
|
74
|
+
const problem = deviceFrameError(frame)
|
|
75
|
+
if (problem) {
|
|
76
|
+
this.send(deviceId, { t: 'error', code: 'protocol/bad-frame', message: problem })
|
|
77
|
+
return
|
|
78
|
+
}
|
|
79
|
+
switch (frame.t) {
|
|
80
|
+
case 'req':
|
|
81
|
+
await this.handleRequest(deviceId, frame)
|
|
82
|
+
return
|
|
83
|
+
case 'open':
|
|
84
|
+
await this.handleOpen(deviceId, frame)
|
|
85
|
+
return
|
|
86
|
+
case 'cancel':
|
|
87
|
+
this.handleCancel(deviceId, frame)
|
|
88
|
+
return
|
|
89
|
+
case 'eventResult':
|
|
90
|
+
await this.handleEventResult(deviceId, frame)
|
|
91
|
+
return
|
|
92
|
+
default:
|
|
93
|
+
/* Unknown frame types are ignored by design (spec §3). */
|
|
94
|
+
return
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ── device lifecycle ────────────────────────────────────────────────────
|
|
99
|
+
|
|
100
|
+
async attachDevice(deviceId, info = {}) {
|
|
101
|
+
if (!nonEmptyString(deviceId) || this.devices.has(deviceId)) return
|
|
102
|
+
this.devices.set(deviceId, {
|
|
103
|
+
deviceId,
|
|
104
|
+
name: info?.name,
|
|
105
|
+
model: info?.model,
|
|
106
|
+
connectedAt: this.now(),
|
|
107
|
+
clientId: undefined,
|
|
108
|
+
readyValue: undefined,
|
|
109
|
+
eventsMuxId: undefined,
|
|
110
|
+
dlpIds: new Set(),
|
|
111
|
+
})
|
|
112
|
+
this.logger.info?.(`mobile-link: device ${deviceId} attached (${info?.name ?? 'unknown'})`)
|
|
113
|
+
this.#changed()
|
|
114
|
+
await this.ensureEventsStream(deviceId)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async detachDevice(deviceId, reason) {
|
|
118
|
+
const record = this.devices.get(deviceId)
|
|
119
|
+
if (!record) return
|
|
120
|
+
for (const muxId of this.streams.removeDevice(deviceId)) this.dsh.cancelStream(muxId)
|
|
121
|
+
this.devices.delete(deviceId)
|
|
122
|
+
this.logger.info?.(`mobile-link: device ${deviceId} detached (${reason ?? 'closed'})`)
|
|
123
|
+
this.#changed()
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Each device gets its own `$events` stream, and therefore its own `clientId`. */
|
|
127
|
+
async ensureEventsStream(deviceId) {
|
|
128
|
+
const record = this.devices.get(deviceId)
|
|
129
|
+
if (!record || record.eventsMuxId) return
|
|
130
|
+
const muxId = `ev:${deviceId}`
|
|
131
|
+
record.eventsMuxId = muxId
|
|
132
|
+
this.streams.add({ deviceId, dlpId: '', muxId, endpoint: EVENTS_ENDPOINT })
|
|
133
|
+
try {
|
|
134
|
+
await this.dsh.ensureMux()
|
|
135
|
+
this.dsh.openStream(muxId, EVENTS_ENDPOINT, {})
|
|
136
|
+
this.logger.debug?.(`mobile-link: opened per-device $events stream ${muxId}`)
|
|
137
|
+
} catch (error) {
|
|
138
|
+
record.eventsMuxId = undefined
|
|
139
|
+
record.readyValue = undefined
|
|
140
|
+
this.streams.removeByMux(muxId)
|
|
141
|
+
this.#fail(`$events stream unavailable: ${messageOf(error)}`)
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Drop every device and return the mux streams the caller must cancel. */
|
|
146
|
+
teardownAll() {
|
|
147
|
+
const muxIds = []
|
|
148
|
+
for (const deviceId of [...this.devices.keys()]) {
|
|
149
|
+
muxIds.push(...this.streams.removeDevice(deviceId))
|
|
150
|
+
this.devices.delete(deviceId)
|
|
151
|
+
}
|
|
152
|
+
this.streams = new StreamTable()
|
|
153
|
+
return muxIds
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// ── device requests ─────────────────────────────────────────────────────
|
|
157
|
+
|
|
158
|
+
async handleRequest(deviceId, frame) {
|
|
159
|
+
// Files the phone sends are answered here rather than forwarded: the Host
|
|
160
|
+
// has no upload endpoint, and carrying them on the existing unary frame
|
|
161
|
+
// means neither the relay nor the protocol has to change.
|
|
162
|
+
// What this connector is and can do. Answered here for the same reason as
|
|
163
|
+
// the file calls: the HTTP status route exists only on the direct path.
|
|
164
|
+
if (isHelloMethod(frame.method)) {
|
|
165
|
+
const value = helloPayload({
|
|
166
|
+
protocolVersion: this.protocolVersion,
|
|
167
|
+
agentId: this.identity?.agentId,
|
|
168
|
+
name: this.identity?.agentName,
|
|
169
|
+
})
|
|
170
|
+
this.send(deviceId, resultFrame(frame.id, { ok: true, value }, deviceId))
|
|
171
|
+
return
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (isFileMethod(frame.method)) {
|
|
175
|
+
let result
|
|
176
|
+
try {
|
|
177
|
+
result = { ok: true, value: this.fileInbox.handle(frame.method, frame.args ?? {}) }
|
|
178
|
+
} catch (error) {
|
|
179
|
+
result = { ok: false, error: errorObject('file/rejected', messageOf(error)) }
|
|
180
|
+
}
|
|
181
|
+
this.send(deviceId, resultFrame(frame.id, result, deviceId))
|
|
182
|
+
return
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
let result
|
|
186
|
+
try {
|
|
187
|
+
result = await this.dsh.rpc(frame.method, frame.args ?? {})
|
|
188
|
+
} catch (error) {
|
|
189
|
+
result = { ok: false, error: errorObject('host/unavailable', messageOf(error)) }
|
|
190
|
+
this.lastError = messageOf(error)
|
|
191
|
+
this.#changed()
|
|
192
|
+
}
|
|
193
|
+
this.send(deviceId, resultFrame(frame.id, result, deviceId))
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async handleOpen(deviceId, frame) {
|
|
197
|
+
const record = this.devices.get(deviceId)
|
|
198
|
+
if (!record) {
|
|
199
|
+
this.send(deviceId, { t: 'streamError', id: frame.id, error: errorObject('stream/unavailable', 'device is not attached') })
|
|
200
|
+
return
|
|
201
|
+
}
|
|
202
|
+
if (frame.endpoint === EVENTS_ENDPOINT) {
|
|
203
|
+
if (!record.eventsMuxId) await this.ensureEventsStream(deviceId)
|
|
204
|
+
if (!record.eventsMuxId) {
|
|
205
|
+
this.send(deviceId, {
|
|
206
|
+
t: 'streamError', id: frame.id,
|
|
207
|
+
error: errorObject('stream/unavailable', this.lastError ?? 'DSH is unavailable'),
|
|
208
|
+
})
|
|
209
|
+
return
|
|
210
|
+
}
|
|
211
|
+
// Items from this device's $events stream are already flowing as `event`
|
|
212
|
+
// frames; this binding re-delivers them as `item` frames for the id.
|
|
213
|
+
record.dlpIds.add(frame.id)
|
|
214
|
+
// A device that opens $events after the ready item already went by would
|
|
215
|
+
// otherwise never learn its clientId, so replay it for the new binding.
|
|
216
|
+
if (record.readyValue !== undefined) {
|
|
217
|
+
this.send(deviceId, { t: 'item', id: frame.id, value: record.readyValue })
|
|
218
|
+
}
|
|
219
|
+
return
|
|
220
|
+
}
|
|
221
|
+
const muxId = `s:${deviceId}:${frame.id}`
|
|
222
|
+
if (!this.streams.add({ deviceId, dlpId: frame.id, muxId, endpoint: frame.endpoint })) {
|
|
223
|
+
this.send(deviceId, {
|
|
224
|
+
t: 'streamError', id: frame.id,
|
|
225
|
+
error: errorObject('stream/duplicate', `stream ${frame.id} is already open`),
|
|
226
|
+
})
|
|
227
|
+
return
|
|
228
|
+
}
|
|
229
|
+
try {
|
|
230
|
+
await this.dsh.ensureMux()
|
|
231
|
+
} catch (error) {
|
|
232
|
+
this.streams.removeByMux(muxId)
|
|
233
|
+
this.#fail(messageOf(error))
|
|
234
|
+
this.send(deviceId, {
|
|
235
|
+
t: 'streamError', id: frame.id, error: errorObject('host/unavailable', messageOf(error)),
|
|
236
|
+
})
|
|
237
|
+
return
|
|
238
|
+
}
|
|
239
|
+
this.dsh.openStream(muxId, frame.endpoint, frame.args ?? {})
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
handleCancel(deviceId, frame) {
|
|
243
|
+
const record = this.devices.get(deviceId)
|
|
244
|
+
if (record) record.dlpIds.delete(frame.id)
|
|
245
|
+
const muxId = this.streams.removeByDlp(deviceId, frame.id)
|
|
246
|
+
if (muxId) this.dsh.cancelStream(muxId)
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
async handleEventResult(deviceId, frame) {
|
|
250
|
+
const record = this.devices.get(deviceId)
|
|
251
|
+
if (!record) return
|
|
252
|
+
const payload = frame.result ?? {}
|
|
253
|
+
const eventId = payload.eventId
|
|
254
|
+
if (!nonEmptyString(eventId) || payload.outcome === null || typeof payload.outcome !== 'object') {
|
|
255
|
+
this.send(deviceId, {
|
|
256
|
+
t: 'error', code: 'protocol/bad-frame',
|
|
257
|
+
message: 'eventResult needs result.eventId and result.outcome',
|
|
258
|
+
})
|
|
259
|
+
return
|
|
260
|
+
}
|
|
261
|
+
if (!nonEmptyString(record.clientId)) {
|
|
262
|
+
this.send(deviceId, {
|
|
263
|
+
t: 'error', code: 'events/not-ready',
|
|
264
|
+
message: 'this device has no $events clientId yet',
|
|
265
|
+
})
|
|
266
|
+
return
|
|
267
|
+
}
|
|
268
|
+
// The clientId is always the one from *this device's* $events ready frame
|
|
269
|
+
// (spec §4.1.5); any clientId the device echoed is ignored on purpose.
|
|
270
|
+
if (!this.dedupe.claim(eventId)) return
|
|
271
|
+
let result
|
|
272
|
+
try {
|
|
273
|
+
result = await this.dsh.rpc(EVENTS_RESULT, {
|
|
274
|
+
clientId: record.clientId, eventId, outcome: payload.outcome,
|
|
275
|
+
})
|
|
276
|
+
} catch (error) {
|
|
277
|
+
this.dedupe.release(eventId)
|
|
278
|
+
this.logger.warn?.(`mobile-link: $events/result failed: ${messageOf(error)}`)
|
|
279
|
+
return
|
|
280
|
+
}
|
|
281
|
+
if (result?.ok !== true) {
|
|
282
|
+
this.dedupe.release(eventId)
|
|
283
|
+
this.logger.warn?.(`mobile-link: $events/result rejected: ${result?.error?.code ?? 'unknown'}`)
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// ── DSH mux events ──────────────────────────────────────────────────────
|
|
288
|
+
|
|
289
|
+
onStreamItem(muxId, value) {
|
|
290
|
+
const entry = this.streams.entryOfMux(muxId)
|
|
291
|
+
if (!entry) {
|
|
292
|
+
this.logger.debug?.(`mobile-link: item for unknown mux stream ${muxId}`)
|
|
293
|
+
return
|
|
294
|
+
}
|
|
295
|
+
if (entry.endpoint === EVENTS_ENDPOINT) {
|
|
296
|
+
this.forwardEvent(entry.deviceId, value)
|
|
297
|
+
return
|
|
298
|
+
}
|
|
299
|
+
this.send(entry.deviceId, { t: 'item', id: entry.dlpId, value })
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
forwardEvent(deviceId, value) {
|
|
303
|
+
const record = this.devices.get(deviceId)
|
|
304
|
+
if (!record) return
|
|
305
|
+
if (value && value.type === 'ready' && nonEmptyString(value.clientId)) {
|
|
306
|
+
record.clientId = value.clientId
|
|
307
|
+
record.readyValue = value
|
|
308
|
+
this.#changed()
|
|
309
|
+
}
|
|
310
|
+
for (const dlpId of record.dlpIds) this.send(deviceId, { t: 'item', id: dlpId, value })
|
|
311
|
+
// Spec §4.1.5: every $events item is broadcast as an `event` frame until the
|
|
312
|
+
// device opens the stream explicitly, so nothing is delivered twice.
|
|
313
|
+
if (record.dlpIds.size === 0) this.send(deviceId, { t: 'event', value })
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
onStreamError(muxId, error) {
|
|
317
|
+
const entry = this.streams.entryOfMux(muxId)
|
|
318
|
+
if (!entry) return
|
|
319
|
+
if (entry.endpoint === EVENTS_ENDPOINT) {
|
|
320
|
+
this.logger.warn?.(`mobile-link: $events stream failed: ${error?.code ?? 'unknown'}`)
|
|
321
|
+
this.#forgetEvents(entry.deviceId)
|
|
322
|
+
this.streams.removeByMux(muxId)
|
|
323
|
+
return
|
|
324
|
+
}
|
|
325
|
+
this.streams.removeByMux(muxId)
|
|
326
|
+
this.send(entry.deviceId, {
|
|
327
|
+
t: 'streamError', id: entry.dlpId,
|
|
328
|
+
error: errorObject(error?.code ?? 'gateway/failed', error?.message ?? 'DSH stream failed',
|
|
329
|
+
error?.details ?? {}),
|
|
330
|
+
})
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
onStreamEnd(muxId) {
|
|
334
|
+
const entry = this.streams.removeByMux(muxId)
|
|
335
|
+
if (!entry) return
|
|
336
|
+
if (entry.endpoint === EVENTS_ENDPOINT) {
|
|
337
|
+
this.#forgetEvents(entry.deviceId)
|
|
338
|
+
return
|
|
339
|
+
}
|
|
340
|
+
this.send(entry.deviceId, { t: 'end', id: entry.dlpId })
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
onMuxDown(reason) {
|
|
344
|
+
// Every logical stream is gone; tell their devices so they can reopen.
|
|
345
|
+
this.logger.warn?.(`mobile-link: DSH mux down (${reason})`)
|
|
346
|
+
for (const entry of [...this.streams.byMux.values()]) {
|
|
347
|
+
if (entry.endpoint === EVENTS_ENDPOINT) continue
|
|
348
|
+
this.send(entry.deviceId, {
|
|
349
|
+
t: 'streamError', id: entry.dlpId,
|
|
350
|
+
error: errorObject('host/unavailable', 'the local DSH connection dropped'),
|
|
351
|
+
})
|
|
352
|
+
}
|
|
353
|
+
for (const record of this.devices.values()) this.#forgetEvents(record.deviceId)
|
|
354
|
+
this.streams = new StreamTable()
|
|
355
|
+
// Re-arm each device's $events stream against the fresh mux.
|
|
356
|
+
for (const deviceId of this.devices.keys()) void this.ensureEventsStream(deviceId)
|
|
357
|
+
this.#changed()
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
#forgetEvents(deviceId) {
|
|
361
|
+
const record = this.devices.get(deviceId)
|
|
362
|
+
if (!record) return
|
|
363
|
+
record.eventsMuxId = undefined
|
|
364
|
+
record.clientId = undefined
|
|
365
|
+
record.readyValue = undefined
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/** Device inventory for `GET /mobile-link/status`. */
|
|
369
|
+
snapshot() {
|
|
370
|
+
return [...this.devices.values()].map((record) => ({
|
|
371
|
+
deviceId: record.deviceId,
|
|
372
|
+
name: record.name,
|
|
373
|
+
model: record.model,
|
|
374
|
+
connectedAt: record.connectedAt,
|
|
375
|
+
eventsReady: Boolean(record.clientId),
|
|
376
|
+
openStreams: record.dlpIds.size,
|
|
377
|
+
}))
|
|
378
|
+
}
|
|
379
|
+
}
|
package/lib/routes.js
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP routes under `/mobile-link`, registered on DSH's own web server.
|
|
3
|
+
*
|
|
4
|
+
* GET /mobile-link/status connection + device inventory + setup state
|
|
5
|
+
* POST /mobile-link/pair-code mint a pairing code (and its QR payload)
|
|
6
|
+
* GET /mobile-link/qr mint a pairing code and render it as a QR SVG
|
|
7
|
+
*
|
|
8
|
+
* All sit behind DSH's own request fence, exactly like dsh-plugin-desktop-shell:
|
|
9
|
+
* `ctx.get('connection')?.requestRejection?.(req)` performs the Host/Origin check
|
|
10
|
+
* and browser authentication. `/status` stays readable when the fence service is
|
|
11
|
+
* missing (it is diagnostic); the other two mint a credential, so they fail
|
|
12
|
+
* closed.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { qrSvg } from './qr.js'
|
|
16
|
+
|
|
17
|
+
const MAX_BODY_BYTES = 64 * 1024
|
|
18
|
+
|
|
19
|
+
export function sendJson(res, status, payload) {
|
|
20
|
+
res.statusCode = status
|
|
21
|
+
res.setHeader('content-type', 'application/json; charset=utf-8')
|
|
22
|
+
res.setHeader('cache-control', 'no-store')
|
|
23
|
+
res.end(JSON.stringify(payload))
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function sendSvg(res, status, svg) {
|
|
27
|
+
res.statusCode = status
|
|
28
|
+
res.setHeader('content-type', 'image/svg+xml; charset=utf-8')
|
|
29
|
+
res.setHeader('cache-control', 'no-store')
|
|
30
|
+
res.end(svg)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function readJsonBody(req) {
|
|
34
|
+
const chunks = []
|
|
35
|
+
let size = 0
|
|
36
|
+
for await (const chunk of req) {
|
|
37
|
+
size += chunk.byteLength
|
|
38
|
+
if (size > MAX_BODY_BYTES) {
|
|
39
|
+
req.resume()
|
|
40
|
+
return undefined
|
|
41
|
+
}
|
|
42
|
+
chunks.push(chunk)
|
|
43
|
+
}
|
|
44
|
+
if (size === 0) return {}
|
|
45
|
+
try {
|
|
46
|
+
return JSON.parse(Buffer.concat(chunks, size).toString('utf8'))
|
|
47
|
+
} catch {
|
|
48
|
+
return undefined
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* @returns {boolean} true when the request was rejected and the response ended.
|
|
54
|
+
*/
|
|
55
|
+
export function makeGuard(ctx, logger) {
|
|
56
|
+
const connectionOf = () => (typeof ctx.get === 'function' ? ctx.get('connection') : undefined)
|
|
57
|
+
return function guard(req, res, { requireFence = false } = {}) {
|
|
58
|
+
let rejection
|
|
59
|
+
try {
|
|
60
|
+
rejection = connectionOf()?.requestRejection?.(req)
|
|
61
|
+
} catch (error) {
|
|
62
|
+
logger.warn?.(`mobile-link: request fence failed: ${error}`)
|
|
63
|
+
sendJson(res, 403, { ok: false, message: 'request rejected by the DSH fence' })
|
|
64
|
+
return true
|
|
65
|
+
}
|
|
66
|
+
if (rejection !== undefined && rejection !== null) {
|
|
67
|
+
res.statusCode = Number(rejection) || 403
|
|
68
|
+
res.end()
|
|
69
|
+
return true
|
|
70
|
+
}
|
|
71
|
+
if (requireFence && rejection === undefined) {
|
|
72
|
+
sendJson(res, 403, { ok: false, message: 'the DSH connection service is unavailable; refusing a privileged request' })
|
|
73
|
+
return true
|
|
74
|
+
}
|
|
75
|
+
return false
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function ttlFrom(value, fallbackMs) {
|
|
80
|
+
return Number.isFinite(Number(value)) && Number(value) > 0
|
|
81
|
+
? Math.min(Number(value), 60 * 60 * 1000)
|
|
82
|
+
: fallbackMs
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Register the routes.
|
|
87
|
+
* @returns {Array<() => void>} disposers (already wrapped by the caller in ctx.effect).
|
|
88
|
+
*/
|
|
89
|
+
export function registerMobileLinkRoutes(ctx, { agent, logger, config = {} }) {
|
|
90
|
+
const guard = makeGuard(ctx, logger)
|
|
91
|
+
const STATUS_ROUTE = '/mobile-link/status'
|
|
92
|
+
const PAIR_ROUTE = '/mobile-link/pair-code'
|
|
93
|
+
const QR_ROUTE = '/mobile-link/qr'
|
|
94
|
+
const disposers = []
|
|
95
|
+
|
|
96
|
+
disposers.push(ctx.webServer.register({
|
|
97
|
+
kind: 'exact',
|
|
98
|
+
path: STATUS_ROUTE,
|
|
99
|
+
handler: async (req, res) => {
|
|
100
|
+
if (guard(req, res)) return
|
|
101
|
+
if (req.method !== 'GET') {
|
|
102
|
+
res.statusCode = 405
|
|
103
|
+
res.setHeader('allow', 'GET')
|
|
104
|
+
res.end()
|
|
105
|
+
return
|
|
106
|
+
}
|
|
107
|
+
try {
|
|
108
|
+
sendJson(res, 200, agent.status())
|
|
109
|
+
} catch (error) {
|
|
110
|
+
sendJson(res, 500, { ok: false, message: String(error?.message ?? error) })
|
|
111
|
+
}
|
|
112
|
+
},
|
|
113
|
+
}))
|
|
114
|
+
|
|
115
|
+
disposers.push(ctx.webServer.register({
|
|
116
|
+
kind: 'exact',
|
|
117
|
+
path: PAIR_ROUTE,
|
|
118
|
+
handler: async (req, res) => {
|
|
119
|
+
if (guard(req, res, { requireFence: true })) return
|
|
120
|
+
if (req.method !== 'POST') {
|
|
121
|
+
res.statusCode = 405
|
|
122
|
+
res.setHeader('allow', 'POST')
|
|
123
|
+
res.end()
|
|
124
|
+
return
|
|
125
|
+
}
|
|
126
|
+
const body = await readJsonBody(req)
|
|
127
|
+
if (body === undefined) {
|
|
128
|
+
sendJson(res, 400, { ok: false, message: 'invalid JSON body' })
|
|
129
|
+
return
|
|
130
|
+
}
|
|
131
|
+
const ttlMs = ttlFrom(body.ttlMs, Number(config.pairTtlMs) || 10 * 60 * 1000)
|
|
132
|
+
try {
|
|
133
|
+
const minted = await agent.mintPairCode({ ttlMs })
|
|
134
|
+
logger.info?.(`mobile-link: minted a pairing code for ${minted.agentId}`)
|
|
135
|
+
sendJson(res, 200, { ok: true, ...minted })
|
|
136
|
+
} catch (error) {
|
|
137
|
+
sendJson(res, 502, { ok: false, code: 'relay/unavailable', message: String(error?.message ?? error) })
|
|
138
|
+
}
|
|
139
|
+
},
|
|
140
|
+
}))
|
|
141
|
+
|
|
142
|
+
// The QR a phone camera can actually read. Rendered here rather than in the
|
|
143
|
+
// desktop UI so the pairing screen is one URL: anyone can open
|
|
144
|
+
// `http://127.0.0.1:<port>/mobile-link/qr` (with DSH's own authentication)
|
|
145
|
+
// and see a scannable code, with no client-side work and no extra dependency.
|
|
146
|
+
//
|
|
147
|
+
// `?format=json` returns the payload and the code without the picture, which
|
|
148
|
+
// is what the automated tests use.
|
|
149
|
+
disposers.push(ctx.webServer.register({
|
|
150
|
+
kind: 'exact',
|
|
151
|
+
path: QR_ROUTE,
|
|
152
|
+
handler: async (req, res) => {
|
|
153
|
+
if (guard(req, res, { requireFence: true })) return
|
|
154
|
+
if (req.method !== 'GET') {
|
|
155
|
+
res.statusCode = 405
|
|
156
|
+
res.setHeader('allow', 'GET')
|
|
157
|
+
res.end()
|
|
158
|
+
return
|
|
159
|
+
}
|
|
160
|
+
const url = new URL(req.url ?? '/', 'http://127.0.0.1')
|
|
161
|
+
const ttlMs = ttlFrom(url.searchParams.get('ttlMs'), Number(config.pairTtlMs) || 10 * 60 * 1000)
|
|
162
|
+
let minted
|
|
163
|
+
try {
|
|
164
|
+
minted = await agent.mintPairCode({ ttlMs })
|
|
165
|
+
} catch (error) {
|
|
166
|
+
sendJson(res, 502, { ok: false, code: 'relay/unavailable', message: String(error?.message ?? error) })
|
|
167
|
+
return
|
|
168
|
+
}
|
|
169
|
+
const payload = minted.qrPayload
|
|
170
|
+
const wantsJson = (url.searchParams.get('format') ?? '').toLowerCase() === 'json'
|
|
171
|
+
logger.info?.(`mobile-link: rendered a pairing QR for ${minted.agentId}`)
|
|
172
|
+
if (wantsJson) {
|
|
173
|
+
sendJson(res, 200, { ok: true, ...minted, svg: qrSvg(payload) })
|
|
174
|
+
return
|
|
175
|
+
}
|
|
176
|
+
sendSvg(res, 200, qrSvg(payload))
|
|
177
|
+
},
|
|
178
|
+
}))
|
|
179
|
+
|
|
180
|
+
return disposers
|
|
181
|
+
}
|