@kinocode/dsh-mobile-bridge 0.0.0-stage → 0.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +47 -2
- package/cordis.patch.yml +16 -0
- package/lib/auth.mjs +180 -0
- package/lib/client.js +219 -0
- package/lib/control.mjs +226 -0
- package/lib/desktop-api.mjs +102 -0
- package/lib/gateway-client.mjs +236 -0
- package/lib/gateway.mjs +319 -0
- package/lib/index.mjs +246 -0
- package/lib/preview.mjs +149 -0
- package/lib/push.mjs +173 -0
- package/lib/relay-tunnel.mjs +256 -0
- package/lib/sessions.mjs +131 -0
- package/lib/state.mjs +187 -0
- package/lib/tls-store.mjs +107 -0
- package/lib/upstream.mjs +61 -0
- package/lib/vendor/qrcode.mjs +2291 -0
- package/lib/web-sessions.mjs +113 -0
- package/lib/x509.mjs +228 -0
- package/package.json +63 -4
package/lib/control.mjs
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
// Control API served to registered phones, directly on the gateway or through the relay tunnel
|
|
2
|
+
// (docs/PROTOCOL.md v2 §2-§3).
|
|
3
|
+
|
|
4
|
+
import { AuthError, parseDevicePublicKey, verifySignedRequest } from './auth.mjs'
|
|
5
|
+
import { describe, validateAnswer } from './preview.mjs'
|
|
6
|
+
import { summaryTitle } from './sessions.mjs'
|
|
7
|
+
import { MAX_WATCHES_PER_DEVICE } from './state.mjs'
|
|
8
|
+
|
|
9
|
+
const MAX_BODY_BYTES = 64 * 1024
|
|
10
|
+
const PATH = /^\/v1\/[a-z0-9/-]{1,64}$/
|
|
11
|
+
const FCM_TOKEN = /^[A-Za-z0-9_:\-.]{20,4096}$/
|
|
12
|
+
const SESSION_ID = /^[A-Za-z0-9_:.-]{1,200}$/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* @typedef {object} ControlDeps
|
|
16
|
+
* @property {import('./state.mjs').StateStore} state
|
|
17
|
+
* @property {import('./auth.mjs').NonceCache} nonces
|
|
18
|
+
* @property {import('./auth.mjs').RegistrationCodes} codes
|
|
19
|
+
* @property {import('./gateway-client.mjs').GatewayClient} gateway
|
|
20
|
+
* @property {import('./sessions.mjs').SessionTracker} sessions
|
|
21
|
+
* @property {import('./web-sessions.mjs').WebSessions} webSessions
|
|
22
|
+
* @property {() => string[]} origins - gateway origins
|
|
23
|
+
* @property {string} relayUrl
|
|
24
|
+
* @property {() => Promise<Array<{sessionId: string, running: boolean, updatedAt: number, cwd?: string, parentSessionId?: string, origin?: string, projections?: {values?: Record<string, any>}}>>} listSessions
|
|
25
|
+
* @property {(eventId: string, deviceId: string) => void} [onAnswered]
|
|
26
|
+
* @property {string} hostLabel
|
|
27
|
+
* @property {string} version
|
|
28
|
+
* @property {number} previewMaxChars
|
|
29
|
+
* @property {() => number} [now]
|
|
30
|
+
* @property {(...args: unknown[]) => void} log
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* @param {ControlDeps} deps
|
|
35
|
+
* @returns {{handle: (request: {method: string, path: string, headers: Record<string, string>, body: string, direct?: boolean}) => Promise<{status: number, body: unknown}>, pair: (body: string) => Promise<{status: number, body: unknown}>}}
|
|
36
|
+
*/
|
|
37
|
+
export function createControlHandler(deps) {
|
|
38
|
+
const now = deps.now ?? Date.now
|
|
39
|
+
|
|
40
|
+
async function handle(request) {
|
|
41
|
+
try {
|
|
42
|
+
const method = String(request.method).toUpperCase()
|
|
43
|
+
const path = String(request.path)
|
|
44
|
+
if (!PATH.test(path)) throw new AuthError(404, 'not_found')
|
|
45
|
+
if (Buffer.byteLength(request.body ?? '', 'utf8') > MAX_BODY_BYTES) throw new AuthError(413, 'payload_too_large')
|
|
46
|
+
const headers = lowerCaseHeaders(request.headers)
|
|
47
|
+
|
|
48
|
+
const deviceId = verifySignedRequest({
|
|
49
|
+
method,
|
|
50
|
+
path,
|
|
51
|
+
headers,
|
|
52
|
+
body: request.body ?? '',
|
|
53
|
+
hostId: deps.state.hostId,
|
|
54
|
+
lookup: id => deps.state.device(id),
|
|
55
|
+
nonces: deps.nonces,
|
|
56
|
+
now,
|
|
57
|
+
})
|
|
58
|
+
deps.state.updateDevice(deviceId, (record) => { record.lastSeenAt = now() })
|
|
59
|
+
|
|
60
|
+
if (method === 'POST' && path === '/v1/session') {
|
|
61
|
+
// WebView sessions are only handed out on the gateway itself, never through the relay.
|
|
62
|
+
if (request.direct !== true) throw new AuthError(404, 'not_found')
|
|
63
|
+
return ok(deps.webSessions.create(deviceId))
|
|
64
|
+
}
|
|
65
|
+
if (method === 'GET' && path === '/v1/status') return ok(status(deviceId))
|
|
66
|
+
if (method === 'GET' && path === '/v1/pending') return ok({ items: deps.gateway.list().map(p => describe(p, deps.sessions, deps.previewMaxChars)) })
|
|
67
|
+
if (method === 'POST' && path === '/v1/answer') return await answer(deviceId, parseJson(request.body))
|
|
68
|
+
if (method === 'POST' && path === '/v1/watch') return watch(deviceId, parseJson(request.body))
|
|
69
|
+
if (method === 'POST' && path === '/v1/settings') return settings(deviceId, parseJson(request.body))
|
|
70
|
+
if (method === 'GET' && path === '/v1/sessions') return ok({ items: await sessionList() })
|
|
71
|
+
if (method === 'POST' && path === '/v1/unregister') {
|
|
72
|
+
deps.state.removeDevice(deviceId)
|
|
73
|
+
deps.webSessions.revokeDevice(deviceId)
|
|
74
|
+
return ok({ ok: true })
|
|
75
|
+
}
|
|
76
|
+
throw new AuthError(404, 'not_found')
|
|
77
|
+
} catch (error) {
|
|
78
|
+
if (error instanceof AuthError) return { status: error.status, body: { error: error.code } }
|
|
79
|
+
if (error?.code === 'invalid_value') return { status: 400, body: { error: 'invalid_value', message: error.message } }
|
|
80
|
+
deps.log('control request failed:', error?.message ?? String(error))
|
|
81
|
+
return { status: 500, body: { error: 'internal_error' } }
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Unsigned pairing on the gateway: a one-time code from the desktop QR plus the device key.
|
|
87
|
+
* @param {string} text
|
|
88
|
+
*/
|
|
89
|
+
async function pair(text) {
|
|
90
|
+
try {
|
|
91
|
+
const body = parseJson(text)
|
|
92
|
+
deps.codes.consume(body.code)
|
|
93
|
+
parseDevicePublicKey(body.publicKey)
|
|
94
|
+
const fcmToken = body.fcmToken === undefined || body.fcmToken === null ? null : String(body.fcmToken)
|
|
95
|
+
if (fcmToken !== null && !FCM_TOKEN.test(fcmToken)) throw new AuthError(400, 'invalid_request')
|
|
96
|
+
const deviceId = deps.state.addDevice({
|
|
97
|
+
publicKey: body.publicKey,
|
|
98
|
+
label: typeof body.label === 'string' ? body.label.slice(0, 80) : '',
|
|
99
|
+
fcmToken,
|
|
100
|
+
previewMode: body.previewMode === 'full' ? 'full' : 'redacted',
|
|
101
|
+
})
|
|
102
|
+
deps.log('device paired', JSON.stringify({ deviceId }))
|
|
103
|
+
return ok({ deviceId, hostId: deps.state.hostId, hostLabel: deps.hostLabel, relayUrl: deps.relayUrl, origins: deps.origins() })
|
|
104
|
+
} catch (error) {
|
|
105
|
+
if (error instanceof AuthError) return { status: error.status, body: { error: error.code } }
|
|
106
|
+
deps.log('pairing failed:', error?.message ?? String(error))
|
|
107
|
+
return { status: 500, body: { error: 'internal_error' } }
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function status(deviceId) {
|
|
112
|
+
const device = deps.state.device(deviceId)
|
|
113
|
+
return {
|
|
114
|
+
hostId: deps.state.hostId,
|
|
115
|
+
hostLabel: deps.hostLabel,
|
|
116
|
+
deviceId,
|
|
117
|
+
previewMode: device.previewMode,
|
|
118
|
+
pushEnabled: device.pushEnabled,
|
|
119
|
+
watchAll: device.watchAll,
|
|
120
|
+
pluginVersion: deps.version,
|
|
121
|
+
gatewayReady: deps.gateway.ready,
|
|
122
|
+
relayUrl: deps.relayUrl,
|
|
123
|
+
origins: deps.origins(),
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function answer(deviceId, body) {
|
|
128
|
+
if (typeof body.eventId !== 'string' || body.eventId.length > 100) throw new AuthError(400, 'invalid_request')
|
|
129
|
+
const pending = deps.gateway.get(body.eventId)
|
|
130
|
+
if (pending !== undefined) {
|
|
131
|
+
const value = validateAnswer(describe(pending, deps.sessions, deps.previewMaxChars), body.value)
|
|
132
|
+
const result = await deps.gateway.answer(body.eventId, value, `device:${deviceId}`)
|
|
133
|
+
return answerResponse(result, deviceId, body.eventId)
|
|
134
|
+
}
|
|
135
|
+
// Not pending: either already settled (409) or unknown/expired (404).
|
|
136
|
+
const result = await deps.gateway.answer(body.eventId, body.value, `device:${deviceId}`)
|
|
137
|
+
return answerResponse(result, deviceId, body.eventId)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function answerResponse(result, deviceId, eventId) {
|
|
141
|
+
switch (result.status) {
|
|
142
|
+
case 200:
|
|
143
|
+
deps.onAnswered?.(eventId, deviceId)
|
|
144
|
+
return ok({ ok: true, eventId })
|
|
145
|
+
case 409:
|
|
146
|
+
return { status: 409, body: { error: 'already_resolved', by: result.by === `device:${deviceId}` ? 'this_device' : 'other' } }
|
|
147
|
+
case 404:
|
|
148
|
+
return { status: 404, body: { error: 'unknown_event' } }
|
|
149
|
+
default:
|
|
150
|
+
return { status: 503, body: { error: 'gateway_unavailable' } }
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function watch(deviceId, body) {
|
|
155
|
+
const ids = Array.isArray(body.sessionIds) ? body.sessionIds : []
|
|
156
|
+
if (ids.length > MAX_WATCHES_PER_DEVICE || !ids.every(id => typeof id === 'string' && SESSION_ID.test(id))) {
|
|
157
|
+
throw new AuthError(400, 'invalid_request')
|
|
158
|
+
}
|
|
159
|
+
const ttlSec = body.ttlSec === undefined ? 86_400 : Number(body.ttlSec)
|
|
160
|
+
if (!Number.isInteger(ttlSec) || ttlSec < 60 || ttlSec > 604_800) throw new AuthError(400, 'invalid_request')
|
|
161
|
+
if (body.watchAll !== undefined && typeof body.watchAll !== 'boolean') throw new AuthError(400, 'invalid_request')
|
|
162
|
+
const current = now()
|
|
163
|
+
deps.state.updateDevice(deviceId, (record) => {
|
|
164
|
+
for (const [id, expiry] of Object.entries(record.watches)) if (expiry <= current) delete record.watches[id]
|
|
165
|
+
for (const id of ids) record.watches[id] = current + ttlSec * 1000
|
|
166
|
+
const entries = Object.entries(record.watches).sort((a, b) => b[1] - a[1])
|
|
167
|
+
record.watches = Object.fromEntries(entries.slice(0, MAX_WATCHES_PER_DEVICE))
|
|
168
|
+
if (body.watchAll !== undefined) record.watchAll = body.watchAll
|
|
169
|
+
})
|
|
170
|
+
return ok({ ok: true })
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function settings(deviceId, body) {
|
|
174
|
+
if (body.fcmToken !== undefined && body.fcmToken !== null && (typeof body.fcmToken !== 'string' || !FCM_TOKEN.test(body.fcmToken))) {
|
|
175
|
+
throw new AuthError(400, 'invalid_request')
|
|
176
|
+
}
|
|
177
|
+
if (body.previewMode !== undefined && body.previewMode !== 'full' && body.previewMode !== 'redacted') throw new AuthError(400, 'invalid_request')
|
|
178
|
+
if (body.pushEnabled !== undefined && typeof body.pushEnabled !== 'boolean') throw new AuthError(400, 'invalid_request')
|
|
179
|
+
deps.state.updateDevice(deviceId, (record) => {
|
|
180
|
+
if (body.fcmToken !== undefined) record.fcmToken = body.fcmToken
|
|
181
|
+
if (body.previewMode !== undefined) record.previewMode = body.previewMode
|
|
182
|
+
if (body.pushEnabled !== undefined) record.pushEnabled = body.pushEnabled
|
|
183
|
+
}, true)
|
|
184
|
+
return ok({ ok: true })
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async function sessionList() {
|
|
188
|
+
let summaries = []
|
|
189
|
+
try {
|
|
190
|
+
summaries = await deps.listSessions()
|
|
191
|
+
} catch (error) {
|
|
192
|
+
deps.log('session list unavailable:', error?.message ?? String(error))
|
|
193
|
+
}
|
|
194
|
+
deps.sessions.learnTitles(summaries)
|
|
195
|
+
return summaries
|
|
196
|
+
.filter(s => s.parentSessionId === undefined && s.origin !== 'subagent')
|
|
197
|
+
.sort((a, b) => b.updatedAt - a.updatedAt)
|
|
198
|
+
.slice(0, 100)
|
|
199
|
+
.map(s => ({ sessionId: s.sessionId, title: deps.sessions.title(s.sessionId) || summaryTitle(s), running: s.running, updatedAt: s.updatedAt, cwd: s.cwd ?? '' }))
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return { handle, pair }
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function ok(body) {
|
|
206
|
+
return { status: 200, body }
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function parseJson(text) {
|
|
210
|
+
if (text === undefined || text === '') return {}
|
|
211
|
+
try {
|
|
212
|
+
const value = JSON.parse(text)
|
|
213
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) throw new Error('not an object')
|
|
214
|
+
return value
|
|
215
|
+
} catch {
|
|
216
|
+
throw new AuthError(400, 'invalid_request')
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function lowerCaseHeaders(headers) {
|
|
221
|
+
/** @type {Record<string, string>} */
|
|
222
|
+
const out = {}
|
|
223
|
+
if (headers === null || typeof headers !== 'object') return out
|
|
224
|
+
for (const [key, value] of Object.entries(headers)) if (typeof value === 'string') out[key.toLowerCase()] = value
|
|
225
|
+
return out
|
|
226
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// Owner-only DSH `/api` routes used by the plugin's sidebar panel on the desktop
|
|
2
|
+
// (docs/PROTOCOL.md v2 §2.1). Registered through ctx.connection.fetch, which admits only the owner.
|
|
3
|
+
|
|
4
|
+
import qrcode from './vendor/qrcode.mjs'
|
|
5
|
+
|
|
6
|
+
const PREFIX = '/api/dsh-mobile-bridge'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* @param {object} deps
|
|
10
|
+
* @param {import('./state.mjs').StateStore} deps.state
|
|
11
|
+
* @param {import('./auth.mjs').RegistrationCodes} deps.codes
|
|
12
|
+
* @param {import('./gateway.mjs').Gateway} deps.gateway
|
|
13
|
+
* @param {import('./tls-store.mjs').TlsStore} deps.tls
|
|
14
|
+
* @param {import('./web-sessions.mjs').WebSessions} deps.webSessions
|
|
15
|
+
* @param {() => {configured: boolean, connected: boolean}} deps.relay
|
|
16
|
+
* @param {string} deps.hostLabel
|
|
17
|
+
* @param {string} deps.version
|
|
18
|
+
* @param {(...a: unknown[]) => void} deps.log
|
|
19
|
+
* @returns {Array<{path: string, methods: string[], requestBody: 'buffered', fetch: (req: Request) => Promise<Response>}>}
|
|
20
|
+
*/
|
|
21
|
+
export function desktopRoutes(deps) {
|
|
22
|
+
const json = (status, body) => new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' } })
|
|
23
|
+
|
|
24
|
+
return [
|
|
25
|
+
{
|
|
26
|
+
path: `${PREFIX}/pairing`,
|
|
27
|
+
methods: ['POST'],
|
|
28
|
+
requestBody: 'buffered',
|
|
29
|
+
async fetch() {
|
|
30
|
+
const origins = deps.gateway.origins()
|
|
31
|
+
if (origins.length === 0) return json(503, { error: 'gateway_not_listening' })
|
|
32
|
+
const { code, expiresAt } = deps.codes.issue('desktop')
|
|
33
|
+
const params = new URLSearchParams({ v: '2', h: deps.state.hostId, fp: deps.tls.ca.fingerprint, c: code, n: deps.hostLabel })
|
|
34
|
+
for (const origin of origins) params.append('o', origin)
|
|
35
|
+
const qr = `dshmb://pair?${params.toString()}`
|
|
36
|
+
const symbol = qrcode(0, 'M')
|
|
37
|
+
symbol.addData(qr)
|
|
38
|
+
symbol.make()
|
|
39
|
+
return json(200, {
|
|
40
|
+
qr,
|
|
41
|
+
qrSvg: symbol.createSvgTag({ cellSize: 4, margin: 4, scalable: true }),
|
|
42
|
+
code,
|
|
43
|
+
expiresAt,
|
|
44
|
+
origins,
|
|
45
|
+
caFingerprint: deps.tls.ca.fingerprint,
|
|
46
|
+
hostId: deps.state.hostId,
|
|
47
|
+
hostLabel: deps.hostLabel,
|
|
48
|
+
})
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
path: `${PREFIX}/status`,
|
|
53
|
+
methods: ['GET'],
|
|
54
|
+
requestBody: 'buffered',
|
|
55
|
+
async fetch() {
|
|
56
|
+
return json(200, {
|
|
57
|
+
hostId: deps.state.hostId,
|
|
58
|
+
hostLabel: deps.hostLabel,
|
|
59
|
+
version: deps.version,
|
|
60
|
+
gateway: { running: deps.gateway.running, port: deps.gateway.deps.port, origins: deps.gateway.origins(), caFingerprint: deps.tls.ca?.fingerprint ?? '' },
|
|
61
|
+
relay: deps.relay(),
|
|
62
|
+
})
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
path: `${PREFIX}/devices`,
|
|
67
|
+
methods: ['GET'],
|
|
68
|
+
requestBody: 'buffered',
|
|
69
|
+
async fetch() {
|
|
70
|
+
return json(200, {
|
|
71
|
+
devices: deps.state.devices().map(([deviceId, d]) => ({
|
|
72
|
+
deviceId,
|
|
73
|
+
label: d.label,
|
|
74
|
+
createdAt: d.createdAt,
|
|
75
|
+
lastSeenAt: d.lastSeenAt,
|
|
76
|
+
pushEnabled: d.pushEnabled,
|
|
77
|
+
hasPush: Boolean(d.fcmToken),
|
|
78
|
+
})),
|
|
79
|
+
})
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
path: `${PREFIX}/devices/revoke`,
|
|
84
|
+
methods: ['POST'],
|
|
85
|
+
requestBody: 'buffered',
|
|
86
|
+
async fetch(req) {
|
|
87
|
+
let body
|
|
88
|
+
try {
|
|
89
|
+
body = await req.json()
|
|
90
|
+
} catch {
|
|
91
|
+
return json(400, { error: 'invalid_request' })
|
|
92
|
+
}
|
|
93
|
+
const deviceId = typeof body?.deviceId === 'string' ? body.deviceId : ''
|
|
94
|
+
if (!/^[a-f0-9]{32}$/.test(deviceId)) return json(400, { error: 'invalid_request' })
|
|
95
|
+
if (!deps.state.removeDevice(deviceId)) return json(404, { error: 'unknown_device' })
|
|
96
|
+
deps.webSessions.revokeDevice(deviceId)
|
|
97
|
+
deps.log('device removed from desktop', JSON.stringify({ deviceId }))
|
|
98
|
+
return json(200, { ok: true })
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
]
|
|
102
|
+
}
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
// Joins the DSH Gateway forwarded-event stream (`$events`) as an in-process owner client,
|
|
2
|
+
// exactly like one more GUI window (ADR-0001 §3.1).
|
|
3
|
+
//
|
|
4
|
+
// - It never registers an `approval/request` / `user-questions/request` listener, so it can
|
|
5
|
+
// neither steal requests nor skip the GUI.
|
|
6
|
+
// - It answers with `$events/result`; the Gateway keeps the first answer from any client and
|
|
7
|
+
// sends `cancel` to the others, which closes the desktop card.
|
|
8
|
+
// - It never answers `{kind:'next'}`: if every client did, the waterfall would move past the GUI.
|
|
9
|
+
|
|
10
|
+
import { randomUUID } from 'node:crypto'
|
|
11
|
+
|
|
12
|
+
const HANDLED_EVENTS = new Set(['approval/request', 'user-questions/request'])
|
|
13
|
+
const SETTLED_TTL_MS = 30 * 60 * 1000
|
|
14
|
+
/** Time to let an already-queued `cancel` frame arrive after our result was accepted. */
|
|
15
|
+
const RACE_SETTLE_MS = 150
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* @typedef {object} PendingEvent
|
|
19
|
+
* @property {string} eventId
|
|
20
|
+
* @property {'approval'|'question'} kind
|
|
21
|
+
* @property {string} sessionId
|
|
22
|
+
* @property {Record<string, unknown>} request - JSON request fields from the Gateway
|
|
23
|
+
* @property {number} receivedAt
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* @typedef {object} GatewayClientHooks
|
|
28
|
+
* @property {(event: PendingEvent) => void} [onPending]
|
|
29
|
+
* @property {(eventId: string, info: {by: string, pending: PendingEvent|undefined}) => void} [onResolved]
|
|
30
|
+
* @property {(ready: boolean) => void} [onReadyChange]
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
export class GatewayClient {
|
|
34
|
+
/**
|
|
35
|
+
* @param {object} deps
|
|
36
|
+
* @param {() => {createPeer: Function, createSharedFetchHandler: Function}} deps.connection
|
|
37
|
+
* @param {() => {wireStream: {open: Function}}} deps.gateway
|
|
38
|
+
* @param {GatewayClientHooks} deps.hooks
|
|
39
|
+
* @param {(...args: unknown[]) => void} deps.log
|
|
40
|
+
* @param {() => number} [deps.now]
|
|
41
|
+
*/
|
|
42
|
+
constructor({ connection, gateway, hooks, log, now = Date.now }) {
|
|
43
|
+
this.connection = connection
|
|
44
|
+
this.gateway = gateway
|
|
45
|
+
this.hooks = hooks
|
|
46
|
+
this.log = log
|
|
47
|
+
this.now = now
|
|
48
|
+
/** @type {Map<string, PendingEvent>} */
|
|
49
|
+
this.pending = new Map()
|
|
50
|
+
/** @type {Map<string, {by: string, at: number}>} */
|
|
51
|
+
this.settled = new Map()
|
|
52
|
+
/** @type {Map<string, {lost: boolean}>} */
|
|
53
|
+
this.submitting = new Map()
|
|
54
|
+
this.clientId = undefined
|
|
55
|
+
this.peer = undefined
|
|
56
|
+
this.fetchHandler = undefined
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
get ready() { return this.clientId !== undefined }
|
|
60
|
+
|
|
61
|
+
/** @returns {PendingEvent[]} */
|
|
62
|
+
list() { return [...this.pending.values()].sort((a, b) => a.receivedAt - b.receivedAt) }
|
|
63
|
+
|
|
64
|
+
/** @param {string} eventId */
|
|
65
|
+
get(eventId) { return this.pending.get(eventId) }
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Keep one `$events` generation open until `signal` aborts; reconnect with backoff.
|
|
69
|
+
* @param {AbortSignal} signal
|
|
70
|
+
*/
|
|
71
|
+
async run(signal) {
|
|
72
|
+
let delay = 500
|
|
73
|
+
const gateway = this.gateway()
|
|
74
|
+
const connection = this.connection()
|
|
75
|
+
if (typeof gateway?.wireStream?.open !== 'function' || typeof connection?.createPeer !== 'function'
|
|
76
|
+
|| typeof connection?.createSharedFetchHandler !== 'function') {
|
|
77
|
+
// Pre-stable Gateway internals moved: fail loudly once instead of retrying forever.
|
|
78
|
+
this.log('this DSH version does not expose the Gateway event stream this plugin needs; approvals from the phone are disabled')
|
|
79
|
+
return
|
|
80
|
+
}
|
|
81
|
+
while (!signal.aborted) {
|
|
82
|
+
const generation = new AbortController()
|
|
83
|
+
const stop = () => generation.abort()
|
|
84
|
+
signal.addEventListener('abort', stop, { once: true })
|
|
85
|
+
try {
|
|
86
|
+
const connection = this.connection()
|
|
87
|
+
this.peer = connection.createPeer({ principal: { kind: 'owner' }, interactions: 'answer' })
|
|
88
|
+
this.fetchHandler = connection.createSharedFetchHandler('/api')
|
|
89
|
+
const uplink = (async function* () {})()
|
|
90
|
+
const stream = await this.gateway().wireStream.open('$events', { args: {} }, uplink, this.peer, generation.signal)
|
|
91
|
+
for await (const frame of stream) {
|
|
92
|
+
this.onFrame(frame)
|
|
93
|
+
delay = 500
|
|
94
|
+
}
|
|
95
|
+
if (!signal.aborted) this.log('event stream ended; reconnecting')
|
|
96
|
+
} catch (error) {
|
|
97
|
+
if (signal.aborted) break
|
|
98
|
+
this.log('event stream unavailable; retrying:', error?.message ?? String(error))
|
|
99
|
+
} finally {
|
|
100
|
+
signal.removeEventListener('abort', stop)
|
|
101
|
+
generation.abort()
|
|
102
|
+
this.resetGeneration()
|
|
103
|
+
}
|
|
104
|
+
if (signal.aborted) break
|
|
105
|
+
await sleep(delay, signal)
|
|
106
|
+
delay = Math.min(delay * 2, 15_000)
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
resetGeneration() {
|
|
111
|
+
const wasReady = this.ready
|
|
112
|
+
this.clientId = undefined
|
|
113
|
+
// Pending events are redelivered with the same eventId by the next generation.
|
|
114
|
+
this.pending.clear()
|
|
115
|
+
if (wasReady) this.hooks.onReadyChange?.(false)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** @param {any} frame */
|
|
119
|
+
onFrame(frame) {
|
|
120
|
+
if (frame === null || typeof frame !== 'object') return
|
|
121
|
+
switch (frame.type) {
|
|
122
|
+
case 'ready':
|
|
123
|
+
this.clientId = frame.clientId
|
|
124
|
+
this.hooks.onReadyChange?.(true)
|
|
125
|
+
return
|
|
126
|
+
case 'waterfall': {
|
|
127
|
+
if (!HANDLED_EVENTS.has(frame.event) || typeof frame.eventId !== 'string' || typeof frame.agentId !== 'string') return
|
|
128
|
+
if (this.pending.has(frame.eventId) || this.settled.has(frame.eventId)) return
|
|
129
|
+
/** @type {PendingEvent} */
|
|
130
|
+
const pending = {
|
|
131
|
+
eventId: frame.eventId,
|
|
132
|
+
kind: frame.event === 'approval/request' ? 'approval' : 'question',
|
|
133
|
+
sessionId: frame.agentId,
|
|
134
|
+
request: frame.request !== null && typeof frame.request === 'object' ? frame.request : {},
|
|
135
|
+
receivedAt: this.now(),
|
|
136
|
+
}
|
|
137
|
+
this.pending.set(pending.eventId, pending)
|
|
138
|
+
this.hooks.onPending?.(pending)
|
|
139
|
+
return
|
|
140
|
+
}
|
|
141
|
+
case 'cancel': {
|
|
142
|
+
const eventId = frame.eventId
|
|
143
|
+
const inFlight = this.submitting.get(eventId)
|
|
144
|
+
if (inFlight !== undefined) inFlight.lost = true
|
|
145
|
+
const pending = this.pending.get(eventId)
|
|
146
|
+
this.pending.delete(eventId)
|
|
147
|
+
if (pending === undefined && inFlight === undefined) return
|
|
148
|
+
if (!this.settled.has(eventId) || inFlight !== undefined) this.remember(eventId, 'other')
|
|
149
|
+
this.hooks.onResolved?.(eventId, { by: 'other', pending })
|
|
150
|
+
return
|
|
151
|
+
}
|
|
152
|
+
default:
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Answer one pending event. First answer wins; everything after is a conflict.
|
|
158
|
+
* @param {string} eventId
|
|
159
|
+
* @param {unknown} value - already validated for the event kind
|
|
160
|
+
* @param {string} by - e.g. `device:<id>`
|
|
161
|
+
* @returns {Promise<{status: 200} | {status: 409, by: string} | {status: 404} | {status: 503}>}
|
|
162
|
+
*/
|
|
163
|
+
async answer(eventId, value, by) {
|
|
164
|
+
const settled = this.settled.get(eventId)
|
|
165
|
+
if (settled !== undefined) return { status: 409, by: settled.by }
|
|
166
|
+
const pending = this.pending.get(eventId)
|
|
167
|
+
if (pending === undefined) return { status: 404 }
|
|
168
|
+
if (!this.ready || this.peer === undefined || this.fetchHandler === undefined) return { status: 503 }
|
|
169
|
+
// Claim synchronously so a concurrent second answer in this process gets 409.
|
|
170
|
+
this.pending.delete(eventId)
|
|
171
|
+
this.remember(eventId, by)
|
|
172
|
+
const race = { lost: false }
|
|
173
|
+
this.submitting.set(eventId, race)
|
|
174
|
+
try {
|
|
175
|
+
const response = await this.fetchHandler.fetch(new Request('http://in-process/api/$events/result', {
|
|
176
|
+
method: 'POST',
|
|
177
|
+
headers: { 'content-type': 'application/json' },
|
|
178
|
+
body: JSON.stringify({
|
|
179
|
+
type: 'client-request',
|
|
180
|
+
rpcId: randomUUID(),
|
|
181
|
+
method: '$events/result',
|
|
182
|
+
payload: { args: { clientId: this.clientId, eventId, outcome: { kind: 'result', value } } },
|
|
183
|
+
}),
|
|
184
|
+
}), this.peer)
|
|
185
|
+
const text = await response.text()
|
|
186
|
+
let ok = response.status === 200
|
|
187
|
+
try {
|
|
188
|
+
ok = ok && JSON.parse(text)?.result?.ok === true
|
|
189
|
+
} catch {
|
|
190
|
+
ok = false
|
|
191
|
+
}
|
|
192
|
+
if (!ok) {
|
|
193
|
+
// The Gateway refused the result (stale generation, malformed); give the event back.
|
|
194
|
+
this.settled.delete(eventId)
|
|
195
|
+
if (!race.lost) this.pending.set(eventId, pending)
|
|
196
|
+
this.log('gateway rejected answer', JSON.stringify({ eventId, status: response.status }))
|
|
197
|
+
return race.lost ? { status: 409, by: 'other' } : { status: 503 }
|
|
198
|
+
}
|
|
199
|
+
// A result for an event another client already settled is a silent no-op in the
|
|
200
|
+
// Gateway; the `cancel` frame for it is already queued, so give it a moment to land.
|
|
201
|
+
await sleep(RACE_SETTLE_MS)
|
|
202
|
+
if (race.lost) {
|
|
203
|
+
this.remember(eventId, 'other')
|
|
204
|
+
return { status: 409, by: 'other' }
|
|
205
|
+
}
|
|
206
|
+
this.hooks.onResolved?.(eventId, { by, pending })
|
|
207
|
+
return { status: 200 }
|
|
208
|
+
} finally {
|
|
209
|
+
this.submitting.delete(eventId)
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* @param {string} eventId
|
|
215
|
+
* @param {string} by
|
|
216
|
+
*/
|
|
217
|
+
remember(eventId, by) {
|
|
218
|
+
const now = this.now()
|
|
219
|
+
this.settled.set(eventId, { by, at: now })
|
|
220
|
+
if (this.settled.size > 512) {
|
|
221
|
+
for (const [id, entry] of this.settled) if (now - entry.at > SETTLED_TTL_MS) this.settled.delete(id)
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function sleep(ms, signal) {
|
|
227
|
+
return new Promise((resolve) => {
|
|
228
|
+
const timer = setTimeout(done, ms)
|
|
229
|
+
function done() {
|
|
230
|
+
signal?.removeEventListener('abort', done)
|
|
231
|
+
clearTimeout(timer)
|
|
232
|
+
resolve()
|
|
233
|
+
}
|
|
234
|
+
signal?.addEventListener('abort', done, { once: true })
|
|
235
|
+
})
|
|
236
|
+
}
|