dsh-computers 0.0.0-stage → 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/lib/routes.js ADDED
@@ -0,0 +1,697 @@
1
+ /**
2
+ * Every HTTP route dsh-computers serves (PROTOCOL §3 and §4).
3
+ *
4
+ * Two surfaces share `/api/dsh-computers`:
5
+ * - machine routes (`/agent/*`) authenticate with a bearer token and never
6
+ * pass the browser gate;
7
+ * - browser routes (state, keys, key-status, delete, rename) ask the host's
8
+ * request gate first, then answer JSON;
9
+ * - public routes (join.sh, join.ps1, agent.mjs, health) are unauthenticated
10
+ * but key- or path-scoped.
11
+ *
12
+ * Node builtins only.
13
+ */
14
+
15
+ import {
16
+ HttpError, joinPs1, joinSh, jsonError, methodNotAllowed, readJsonBody,
17
+ refuseBrowserRequest, resolveBaseUrl, sendJson, sendText, serveAgentScript,
18
+ } from './http.js'
19
+ import {
20
+ bearerToken, isExpired, mintSecret, newComputerId, normalizeDisplayName,
21
+ optionalCount, optionalString, platformLabel, sha256Hex, tokenMatches,
22
+ } from './keys.js'
23
+ import { normalizeSessionId } from './selection.js'
24
+
25
+ /** Base path shared by every route (PROTOCOL §3). */
26
+ export const API_BASE = '/api/dsh-computers'
27
+
28
+ /** Enrollment bodies are small (PROTOCOL §3.1: max 16 KiB). */
29
+ const ENROLL_MAX_BYTES = 16 * 1024
30
+
31
+ /** Browser control bodies are tiny. */
32
+ const CONTROL_MAX_BYTES = 16 * 1024
33
+
34
+ /** Result posts carry two ≤1 MiB streams plus JSON escaping. */
35
+ const RESULT_MAX_BYTES = 8 * 1024 * 1024
36
+
37
+ /** Platform names an agent may report → the label the settings page shows. */
38
+ const PLATFORM_LABELS = new Map([
39
+ ['macos', 'macOS'],
40
+ ['darwin', 'macOS'],
41
+ ['osx', 'macOS'],
42
+ ['linux', 'Linux'],
43
+ ['windows', 'Windows'],
44
+ ['win32', 'Windows'],
45
+ ])
46
+
47
+ /** Enforce one method; answers 405 and returns false otherwise. */
48
+ function requireMethod(req, res, ...methods) {
49
+ const method = typeof req.method === 'string' ? req.method.toUpperCase() : ''
50
+ if (method === 'HEAD' && methods.includes('GET')) return true
51
+ if (methods.includes(method)) return true
52
+ methodNotAllowed(res, methods.join(' / '))
53
+ return false
54
+ }
55
+
56
+ /** Parse an object body or throw the protocol's 400. */
57
+ async function readObjectBody(req, maxBytes) {
58
+ const body = await readJsonBody(req, maxBytes)
59
+ if (body === null || typeof body !== 'object' || Array.isArray(body)) {
60
+ throw new HttpError(400, 'bad-request', 'request body must be a JSON object')
61
+ }
62
+ return body
63
+ }
64
+
65
+ /**
66
+ * Build the exact-route registrar used by both route surfaces. Handlers own
67
+ * the whole response lifecycle, which is what lets the SSE route stay open.
68
+ */
69
+ function createRouteRegistrar(ctx, onWarn) {
70
+ return (suffix, handler) => {
71
+ const path = `${API_BASE}${suffix}`
72
+ ctx.effect(() => ctx.webServer.register({
73
+ kind: 'exact',
74
+ path,
75
+ handler: async (req, res) => {
76
+ try {
77
+ await handler(req, res)
78
+ } catch (error) {
79
+ if (res.headersSent) {
80
+ try {
81
+ res.end()
82
+ } catch {
83
+ // Client already gone.
84
+ }
85
+ return
86
+ }
87
+ if (error instanceof HttpError) {
88
+ jsonError(res, error.status, error.code, error.message)
89
+ return
90
+ }
91
+ onWarn(`dsh-computers: ${req.method} ${path} failed: ${error && error.stack ? error.stack : error}`)
92
+ jsonError(res, 500, 'internal', 'the dsh-computers host failed to handle this request')
93
+ }
94
+ },
95
+ }), `dsh-computers: ${path}`)
96
+ }
97
+ }
98
+
99
+ /**
100
+ * Register every route on the host web server.
101
+ * @param {object} options - route wiring.
102
+ * @param {object} options.ctx - Cordis context (webServer + optional connection).
103
+ * @param {object} options.config - resolved plugin config.
104
+ * @param {object} options.store - registry store.
105
+ * @param {object} options.hub - SSE/exec hub.
106
+ * @param {string} options.version - plugin version reported by /health.
107
+ * @param {URL} options.agentUrl - machine agent bundle location.
108
+ * @param {(message: string) => void} [options.onWarn] - warning sink.
109
+ */
110
+ export function createRoutes({ ctx, config, store, hub, selections, version, agentUrl, role = 'both', onWarn = () => {} }) {
111
+ /** Current clock as ISO text. */
112
+ const nowIso = () => new Date().toISOString()
113
+ /** Parsed request URL (pathname + query). */
114
+ const urlOf = (req) => new URL(typeof req.url === 'string' && req.url !== '' ? req.url : '/', 'http://dsh-computers.invalid')
115
+ /** Exact-route registrar shared with the runtime surface. */
116
+ const route = createRouteRegistrar(ctx, onWarn)
117
+
118
+ /** Ask the host's browser gate; true when the request was already refused. */
119
+ const refuseBrowser = (req, res) => refuseBrowserRequest(ctx, req, res)
120
+
121
+ /** Resolve the bearer token to a registry computer, or undefined. */
122
+ const authenticate = (req) => {
123
+ const token = bearerToken(req)
124
+ if (token === null) return undefined
125
+ for (const computer of store.read().computers) {
126
+ if (tokenMatches(token, computer.tokenHash)) return computer
127
+ }
128
+ return undefined
129
+ }
130
+
131
+ /** Refuse a machine route without valid credentials. */
132
+ const requireComputer = (req, res) => {
133
+ const computer = authenticate(req)
134
+ if (computer === undefined) {
135
+ jsonError(res, 401, 'invalid-token', 'authorization: Bearer <agent token> is required')
136
+ return undefined
137
+ }
138
+ return computer
139
+ }
140
+
141
+ /** The pending key the browser should show, if any. */
142
+ const currentPendingKey = (doc, nowMs) => {
143
+ for (let index = doc.pendingKeys.length - 1; index >= 0; index -= 1) {
144
+ const entry = doc.pendingKeys[index]
145
+ if (typeof entry.usedAt === 'string') continue
146
+ if (isExpired(entry.expiresAt, nowMs)) continue
147
+ return entry
148
+ }
149
+ return null
150
+ }
151
+
152
+ /** Protocol §4.1 projection of one registry record. */
153
+ const publicComputer = (computer) => {
154
+ const online = hub.isOnline(computer)
155
+ return {
156
+ id: computer.id,
157
+ name: computer.name,
158
+ hostname: computer.hostname,
159
+ os: computer.os,
160
+ platformLabel: computer.platformLabel,
161
+ online,
162
+ lastSeenAt: computer.lastSeenAt ?? null,
163
+ createdAt: computer.createdAt ?? null,
164
+ agentVersion: computer.agentVersion ?? null,
165
+ cpuModel: computer.cpuModel ?? null,
166
+ memoryBytes: computer.memoryBytes ?? null,
167
+ // Display sugar for the "1/1 Bot" badge (PROTOCOL §4.1).
168
+ bots: { used: online ? 1 : 0, total: 1 },
169
+ }
170
+ }
171
+
172
+ /** Key-validated installer scripts share one refusal rule. */
173
+ const installerKey = (req, res, requiredSearchParam) => {
174
+ const keyValue = urlOf(req).searchParams.get(requiredSearchParam)
175
+ if (keyValue === null || keyValue === '') {
176
+ sendText(res, 410, 'missing enrollment key — open the Computers settings page and copy a fresh command\n')
177
+ return null
178
+ }
179
+ const entry = store.read().pendingKeys.find((candidate) => candidate.key === keyValue)
180
+ if (entry === undefined) {
181
+ sendText(res, 410, 'unknown enrollment key — create a new one on the Computers settings page\n')
182
+ return null
183
+ }
184
+ if (typeof entry.usedAt === 'string') {
185
+ sendText(res, 410, 'this enrollment key was already used — create a new one on the Computers settings page\n')
186
+ return null
187
+ }
188
+ if (isExpired(entry.expiresAt, Date.now())) {
189
+ sendText(res, 410, 'this enrollment key expired — create a new one on the Computers settings page\n')
190
+ return null
191
+ }
192
+ return entry
193
+ }
194
+
195
+ // ---------------------------------------------------------------------------
196
+ // Machine routes (bearer token, no browser gate)
197
+ // ---------------------------------------------------------------------------
198
+
199
+ route('/agent/enroll', async (req, res) => {
200
+ if (!requireMethod(req, res, 'POST')) return
201
+ const body = await readObjectBody(req, ENROLL_MAX_BYTES)
202
+ const keyValue = typeof body.key === 'string' ? body.key.trim() : ''
203
+ if (!/^dshc_[A-Za-z0-9_-]{20,}$/.test(keyValue)) {
204
+ throw new HttpError(400, 'bad-request', 'key must be a dshc_ enrollment key')
205
+ }
206
+ const nowMs = Date.now()
207
+ const doc = store.read()
208
+ const pendingKey = doc.pendingKeys.find((entry) => entry.key === keyValue)
209
+ // Order matters: an unknown key is 401, a consumed one 409, a stale one 410.
210
+ if (pendingKey === undefined) {
211
+ throw new HttpError(401, 'invalid-key', 'unknown enrollment key; create a new one on the Computers settings page')
212
+ }
213
+ if (typeof pendingKey.usedAt === 'string') {
214
+ throw new HttpError(409, 'key-used', 'this enrollment key was already used; create a new one')
215
+ }
216
+ if (isExpired(pendingKey.expiresAt, nowMs)) {
217
+ throw new HttpError(410, 'key-expired', 'this enrollment key expired; create a new one')
218
+ }
219
+
220
+ const machine = body.machine !== null && typeof body.machine === 'object' && !Array.isArray(body.machine)
221
+ ? body.machine
222
+ : null
223
+ const hostname = optionalString(machine?.hostname, 255)
224
+ if (hostname === null) throw new HttpError(400, 'bad-request', 'machine.hostname is required')
225
+ const osName = optionalString(machine?.os, 64) ?? 'unknown'
226
+ const displayName = normalizeDisplayName(pendingKey.label)
227
+ ?? normalizeDisplayName(optionalString(machine?.name, 255))
228
+ ?? hostname
229
+ const requestedPlatform = optionalString(machine?.platform, 64)
230
+ const label = requestedPlatform !== null
231
+ ? PLATFORM_LABELS.get(requestedPlatform.toLowerCase()) ?? platformLabel(osName)
232
+ : platformLabel(osName)
233
+
234
+ const fields = {
235
+ name: displayName,
236
+ hostname,
237
+ os: osName,
238
+ arch: optionalString(machine?.arch, 64) ?? 'unknown',
239
+ platformLabel: label,
240
+ cpuModel: optionalString(machine?.cpuModel, 256) ?? null,
241
+ memoryBytes: optionalCount(machine?.memoryBytes) ?? null,
242
+ nodeVersion: optionalString(machine?.nodeVersion, 64) ?? null,
243
+ agentVersion: optionalString(body.agentVersion, 64) ?? optionalString(machine?.agentVersion, 64) ?? 'unknown',
244
+ }
245
+ const token = mintSecret()
246
+ const tokenHash = sha256Hex(token)
247
+ // Re-enroll in place only when the key asked for it (PROTOCOL §3.1).
248
+ const replaceTarget = pendingKey.replace === true
249
+ ? doc.computers.find((computer) => computer.hostname === hostname
250
+ || computer.name.toLowerCase() === displayName.toLowerCase())
251
+ : undefined
252
+
253
+ const enrolled = store.mutate((document) => {
254
+ const keyEntry = document.pendingKeys.find((entry) => entry.key === keyValue)
255
+ if (keyEntry === undefined) {
256
+ throw new HttpError(401, 'invalid-key', 'unknown enrollment key; create a new one on the Computers settings page')
257
+ }
258
+ let computer
259
+ if (replaceTarget !== undefined) {
260
+ computer = document.computers.find((entry) => entry.id === replaceTarget.id)
261
+ }
262
+ if (computer !== undefined) {
263
+ // Same id, same name unless the agent reported a new one: the old
264
+ // token hash is replaced so the previous install stops working.
265
+ Object.assign(computer, fields, { tokenHash, lastSeenAt: nowIso() })
266
+ } else {
267
+ computer = {
268
+ id: newComputerId(),
269
+ ...fields,
270
+ tokenHash,
271
+ createdAt: nowIso(),
272
+ lastSeenAt: nowIso(),
273
+ }
274
+ document.computers.push(computer)
275
+ }
276
+ keyEntry.usedAt = nowIso()
277
+ keyEntry.computerId = computer.id
278
+ return { computerId: computer.id, name: computer.name, replaced: replaceTarget !== undefined }
279
+ })
280
+
281
+ if (enrolled.replaced) {
282
+ // The old agent's token no longer authenticates; drop its session.
283
+ hub.closeStream(enrolled.computerId)
284
+ }
285
+ sendJson(res, 200, {
286
+ computerId: enrolled.computerId,
287
+ token,
288
+ name: enrolled.name,
289
+ serverUrl: resolveBaseUrl({ ctx, config, req }),
290
+ })
291
+ })
292
+
293
+ route('/agent/events', async (req, res) => {
294
+ // Authenticate before anything else on machine routes (PROTOCOL §3).
295
+ const computer = requireComputer(req, res)
296
+ if (computer === undefined) return
297
+ if (!requireMethod(req, res, 'GET')) return
298
+ store.touch(computer.id)
299
+ hub.openStream(computer.id, computer.name, req, res)
300
+ })
301
+
302
+ route('/agent/results', async (req, res) => {
303
+ // Authenticate before anything else on machine routes (PROTOCOL §3).
304
+ const computer = requireComputer(req, res)
305
+ if (computer === undefined) return
306
+ if (!requireMethod(req, res, 'POST')) return
307
+ const body = await readObjectBody(req, RESULT_MAX_BYTES)
308
+ store.touch(computer.id)
309
+ if (!hub.settle(computer.id, body)) {
310
+ // The server already settled/timed out this request (PROTOCOL §3.3);
311
+ // the agent ignores this answer.
312
+ jsonError(res, 404, 'unknown-request', 'this execution is no longer pending (already settled or timed out)')
313
+ return
314
+ }
315
+ sendJson(res, 200, { ok: true })
316
+ })
317
+
318
+ route('/agent/heartbeat', async (req, res) => {
319
+ // Authenticate before anything else on machine routes (PROTOCOL §3).
320
+ const computer = requireComputer(req, res)
321
+ if (computer === undefined) return
322
+ if (!requireMethod(req, res, 'POST')) return
323
+ let body = {}
324
+ try {
325
+ body = await readObjectBody(req, 64 * 1024)
326
+ } catch (error) {
327
+ // An empty heartbeat body is legal; anything else is still a 400.
328
+ if (!(error instanceof HttpError) || error.code !== 'bad-request' || !/empty/.test(error.message)) throw error
329
+ }
330
+ const meta = body.meta !== null && typeof body.meta === 'object' && !Array.isArray(body.meta) ? body.meta : null
331
+ const name = store.mutate((document) => {
332
+ const record = document.computers.find((entry) => entry.id === computer.id)
333
+ if (record === undefined) return computer.name
334
+ record.lastSeenAt = nowIso()
335
+ if (meta !== null) {
336
+ const cpuModel = optionalString(meta.cpuModel, 256)
337
+ const agentVersion = optionalString(meta.agentVersion, 64)
338
+ const memoryBytes = optionalCount(meta.memoryBytes)
339
+ if (cpuModel !== null) record.cpuModel = cpuModel
340
+ if (agentVersion !== null) record.agentVersion = agentVersion
341
+ if (memoryBytes !== null) record.memoryBytes = memoryBytes
342
+ }
343
+ return record.name
344
+ })
345
+ sendJson(res, 200, { ok: true, name })
346
+ })
347
+
348
+ // ---------------------------------------------------------------------------
349
+ // Browser routes (host request gate first)
350
+ // ---------------------------------------------------------------------------
351
+
352
+ route('/state', async (req, res) => {
353
+ // The host's request gate runs first on every browser route (PROTOCOL §4).
354
+ if (refuseBrowser(req, res)) return
355
+ if (!requireMethod(req, res, 'GET')) return
356
+ const doc = store.read()
357
+ const pendingKey = currentPendingKey(doc, Date.now())
358
+ sendJson(res, 200, {
359
+ version: 2,
360
+ role,
361
+ serverUrl: resolveBaseUrl({ ctx, config, req }),
362
+ heartbeatMs: config.heartbeatMs,
363
+ computers: doc.computers.map(publicComputer),
364
+ pendingKey: pendingKey === null
365
+ ? null
366
+ : { key: pendingKey.key, expiresAt: pendingKey.expiresAt ?? null, os: pendingKey.os ?? 'macos' },
367
+ })
368
+ })
369
+
370
+ route('/keys', async (req, res) => {
371
+ // The host's request gate runs first on every browser route (PROTOCOL §4).
372
+ if (refuseBrowser(req, res)) return
373
+ if (!requireMethod(req, res, 'POST')) return
374
+ const body = await readObjectBody(req, CONTROL_MAX_BYTES)
375
+ const osName = typeof body.os === 'string' ? body.os.trim().toLowerCase() : 'macos'
376
+ if (!['macos', 'windows', 'linux'].includes(osName)) {
377
+ throw new HttpError(400, 'bad-request', 'os must be one of macos, windows, linux')
378
+ }
379
+ let label = null
380
+ if (body.label !== undefined && body.label !== null) {
381
+ label = normalizeDisplayName(body.label)
382
+ if (label === null) throw new HttpError(400, 'bad-request', 'label must be 1-64 visible characters')
383
+ }
384
+ const replace = body.replace === true
385
+ const createdAt = nowIso()
386
+ const expiresAt = new Date(Date.now() + config.keyTtlMinutes * 60_000).toISOString()
387
+ const keyValue = mintSecret()
388
+
389
+ store.mutate((document) => {
390
+ // Only one pending key at a time: creating a new one invalidates the
391
+ // earlier ones (PROTOCOL §4.2).
392
+ for (const entry of document.pendingKeys) {
393
+ if (typeof entry.usedAt !== 'string') entry.usedAt = createdAt
394
+ }
395
+ document.pendingKeys.push({
396
+ key: keyValue,
397
+ createdAt,
398
+ expiresAt,
399
+ os: osName,
400
+ label,
401
+ usedAt: null,
402
+ computerId: null,
403
+ replace,
404
+ })
405
+ })
406
+
407
+ const serverUrl = resolveBaseUrl({ ctx, config, req })
408
+ const installUrl = `${serverUrl}${API_BASE}/join.sh?key=${encodeURIComponent(keyValue)}`
409
+ const windowsUrl = `${serverUrl}${API_BASE}/join.ps1?key=${encodeURIComponent(keyValue)}`
410
+ sendJson(res, 200, {
411
+ key: keyValue,
412
+ os: osName,
413
+ expiresAt,
414
+ serverUrl,
415
+ command: `curl -fsSL '${installUrl}' | sh`,
416
+ windowsCommand: `irm '${windowsUrl}' | iex`,
417
+ installUrl,
418
+ })
419
+ })
420
+
421
+ route('/key-status', async (req, res) => {
422
+ // The host's request gate runs first on every browser route (PROTOCOL §4).
423
+ if (refuseBrowser(req, res)) return
424
+ if (!requireMethod(req, res, 'GET')) return
425
+ const keyValue = urlOf(req).searchParams.get('key')
426
+ const doc = store.read()
427
+ const entry = keyValue === null
428
+ ? undefined
429
+ : doc.pendingKeys.find((candidate) => candidate.key === keyValue)
430
+ let status = 'unknown'
431
+ let computer = null
432
+ if (entry !== undefined) {
433
+ const linked = typeof entry.computerId === 'string'
434
+ ? doc.computers.find((candidate) => candidate.id === entry.computerId)
435
+ : undefined
436
+ if (linked !== undefined) {
437
+ status = 'connected'
438
+ computer = { id: linked.id, name: linked.name, online: hub.isOnline(linked) }
439
+ } else if (typeof entry.usedAt === 'string') {
440
+ status = 'used'
441
+ } else if (isExpired(entry.expiresAt, Date.now())) {
442
+ status = 'expired'
443
+ } else {
444
+ status = 'waiting'
445
+ }
446
+ }
447
+ sendJson(res, 200, { status, computer })
448
+ })
449
+
450
+ route('/delete', async (req, res) => {
451
+ // The host's request gate runs first on every browser route (PROTOCOL §4).
452
+ if (refuseBrowser(req, res)) return
453
+ if (!requireMethod(req, res, 'POST')) return
454
+ const body = await readObjectBody(req, CONTROL_MAX_BYTES)
455
+ const id = typeof body.id === 'string' ? body.id.trim() : ''
456
+ if (id === '') throw new HttpError(400, 'bad-request', 'id is required')
457
+ const found = store.read().computers.some((computer) => computer.id === id)
458
+ if (!found) throw new HttpError(404, 'not-found', `no computer with id ${JSON.stringify(id)}`)
459
+ hub.closeStream(id)
460
+ store.mutate((document) => {
461
+ document.computers = document.computers.filter((computer) => computer.id !== id)
462
+ })
463
+ sendJson(res, 200, { ok: true })
464
+ })
465
+
466
+ route('/rename', async (req, res) => {
467
+ // The host's request gate runs first on every browser route (PROTOCOL §4).
468
+ if (refuseBrowser(req, res)) return
469
+ if (!requireMethod(req, res, 'POST')) return
470
+ const body = await readObjectBody(req, CONTROL_MAX_BYTES)
471
+ const id = typeof body.id === 'string' ? body.id.trim() : ''
472
+ if (id === '') throw new HttpError(400, 'bad-request', 'id is required')
473
+ const name = normalizeDisplayName(body.name)
474
+ if (name === null) throw new HttpError(400, 'bad-request', 'name must be 1-64 visible characters')
475
+ const found = store.read().computers.some((computer) => computer.id === id)
476
+ if (!found) throw new HttpError(404, 'not-found', `no computer with id ${JSON.stringify(id)}`)
477
+ store.mutate((document) => {
478
+ const record = document.computers.find((computer) => computer.id === id)
479
+ if (record !== undefined) record.name = name
480
+ })
481
+ // Optional `config` event (PROTOCOL §3.2): the agent picks up the new name.
482
+ hub.send(id, 'config', { name, execEnabled: config.execEnabled })
483
+ sendJson(res, 200, { ok: true, name })
484
+ })
485
+
486
+ // ---------------------------------------------------------------------------
487
+ // Per-session target selection (task-9)
488
+ // ---------------------------------------------------------------------------
489
+
490
+ /**
491
+ * Protocol shape shared by GET and POST /selection. A stored id whose
492
+ * computer no longer exists is reported as "no selection" so the client can
493
+ * render the unselected state instead of a dangling target.
494
+ */
495
+ const selectionPayload = (sessionId, selectedId) => {
496
+ const computer = selectedId === null
497
+ ? undefined
498
+ : store.read().computers.find((candidate) => candidate.id === selectedId)
499
+ return {
500
+ sessionId,
501
+ computerId: computer === undefined ? null : computer.id,
502
+ computer: computer === undefined
503
+ ? null
504
+ : {
505
+ id: computer.id,
506
+ name: computer.name,
507
+ online: hub.isOnline(computer),
508
+ platformLabel: computer.platformLabel ?? 'Unknown',
509
+ },
510
+ }
511
+ }
512
+
513
+ route('/selection', async (req, res) => {
514
+ // The host's request gate runs first on every browser route (PROTOCOL §4).
515
+ if (refuseBrowser(req, res)) return
516
+ const method = typeof req.method === 'string' ? req.method.toUpperCase() : ''
517
+ if (method === 'GET' || method === 'HEAD') {
518
+ const sessionId = normalizeSessionId(urlOf(req).searchParams.get('sessionId'))
519
+ if (sessionId === null) {
520
+ throw new HttpError(400, 'bad-request', 'sessionId must be a non-empty string (max 256 characters)')
521
+ }
522
+ sendJson(res, 200, selectionPayload(sessionId, selections.get(sessionId)))
523
+ return
524
+ }
525
+ if (method === 'POST') {
526
+ const body = await readObjectBody(req, CONTROL_MAX_BYTES)
527
+ const sessionId = normalizeSessionId(body.sessionId)
528
+ if (sessionId === null) {
529
+ throw new HttpError(400, 'bad-request', 'sessionId must be a non-empty string (max 256 characters)')
530
+ }
531
+ if (!Object.hasOwn(body, 'computerId')) {
532
+ throw new HttpError(400, 'bad-request', 'computerId is required (a computer id or null)')
533
+ }
534
+ if (body.computerId === null) {
535
+ selections.clear(sessionId)
536
+ sendJson(res, 200, selectionPayload(sessionId, null))
537
+ return
538
+ }
539
+ if (typeof body.computerId !== 'string' || body.computerId.trim() === '') {
540
+ throw new HttpError(400, 'bad-request', 'computerId must be a non-empty string or null')
541
+ }
542
+ const computerId = body.computerId.trim()
543
+ const known = store.read().computers.some((candidate) => candidate.id === computerId)
544
+ if (!known) throw new HttpError(404, 'not-found', `no computer with id ${JSON.stringify(computerId)}`)
545
+ selections.set(sessionId, computerId)
546
+ sendJson(res, 200, selectionPayload(sessionId, computerId))
547
+ return
548
+ }
549
+ methodNotAllowed(res, 'GET / POST')
550
+ })
551
+
552
+ // ---------------------------------------------------------------------------
553
+ // Public routes
554
+ // ---------------------------------------------------------------------------
555
+
556
+ route('/join.sh', async (req, res) => {
557
+ if (!requireMethod(req, res, 'GET')) return
558
+ const entry = installerKey(req, res, 'key')
559
+ if (entry === null) return
560
+ const serverUrl = resolveBaseUrl({ ctx, config, req })
561
+ sendText(res, 200, joinSh(serverUrl, entry.key), 'text/x-shellscript; charset=utf-8')
562
+ })
563
+
564
+ route('/join.ps1', async (req, res) => {
565
+ if (!requireMethod(req, res, 'GET')) return
566
+ const entry = installerKey(req, res, 'key')
567
+ if (entry === null) return
568
+ const serverUrl = resolveBaseUrl({ ctx, config, req })
569
+ sendText(res, 200, joinPs1(serverUrl, entry.key), 'text/plain; charset=utf-8')
570
+ })
571
+
572
+ route('/agent.mjs', async (req, res) => {
573
+ if (!requireMethod(req, res, 'GET')) return
574
+ await serveAgentScript(res, agentUrl)
575
+ })
576
+
577
+ route('/health', async (req, res) => {
578
+ if (!requireMethod(req, res, 'GET')) return
579
+ const computers = store.read().computers
580
+ sendJson(res, 200, {
581
+ ok: true,
582
+ version,
583
+ computers: computers.length,
584
+ online: hub.onlineCount(),
585
+ })
586
+ })
587
+ }
588
+
589
+ /**
590
+ * Register the runtime-mode routes (PROTOCOL §10.5) on the host web server.
591
+ *
592
+ * These are browser-gated exactly like §4 and every answer carries the full
593
+ * §10.3 status object (plus `role`, so a pure `role: 'runtime'` deployment —
594
+ * which registers no `/state` route — can still tell the client what to render).
595
+ *
596
+ * @param {object} options - route wiring.
597
+ * @param {object} options.ctx - Cordis context.
598
+ * @param {object} options.config - resolved plugin config.
599
+ * @param {object} options.runtime - runtime manager (lib/runtime.js).
600
+ * @param {string} options.role - 'hub' | 'runtime' | 'both' (echoed in answers).
601
+ * @param {(message: string) => void} [options.onWarn] - warning sink.
602
+ */
603
+ export function createRuntimeRoutes({ ctx, config, runtime, role = 'both', onWarn = () => {} }) {
604
+ const route = createRouteRegistrar(ctx, onWarn)
605
+ const refuseBrowser = (req, res) => refuseBrowserRequest(ctx, req, res)
606
+ /** Every answer is the status object plus the role the client is talking to. */
607
+ const respond = (res, status) => sendJson(res, 200, { ...status, role })
608
+
609
+ route('/runtime', async (req, res) => {
610
+ if (refuseBrowser(req, res)) return
611
+ if (!requireMethod(req, res, 'GET')) return
612
+ respond(res, runtime.status())
613
+ })
614
+
615
+ route('/runtime/connect', async (req, res) => {
616
+ if (refuseBrowser(req, res)) return
617
+ if (!requireMethod(req, res, 'POST')) return
618
+ const body = await readObjectBody(req, CONTROL_MAX_BYTES)
619
+ const serverUrl = typeof body.serverUrl === 'string' ? body.serverUrl.trim() : ''
620
+ if (serverUrl === '') throw new HttpError(400, 'bad-request', 'serverUrl is required')
621
+ // The client may send `key` (spec) or `enrollmentKey` (older drafts).
622
+ const key = typeof body.key === 'string'
623
+ ? body.key.trim()
624
+ : typeof body.enrollmentKey === 'string'
625
+ ? body.enrollmentKey.trim()
626
+ : ''
627
+ if (key === '') throw new HttpError(400, 'bad-request', 'key is required')
628
+ // configure() validates the URL, enrolls against the hub and maps the hub's
629
+ // 401/409/410 onto the protocol's invalid-key/key-used/key-expired codes;
630
+ // a transport failure becomes 502 hub-unreachable.
631
+ respond(res, await runtime.configure({ serverUrl, key }))
632
+ })
633
+
634
+ route('/runtime/enable', async (req, res) => {
635
+ if (refuseBrowser(req, res)) return
636
+ if (!requireMethod(req, res, 'POST')) return
637
+ const body = await readObjectBody(req, CONTROL_MAX_BYTES)
638
+ if (typeof body.enabled !== 'boolean') {
639
+ throw new HttpError(400, 'bad-request', 'enabled must be a boolean')
640
+ }
641
+ respond(res, await runtime.enable(body.enabled))
642
+ })
643
+
644
+ route('/runtime/disconnect', async (req, res) => {
645
+ if (refuseBrowser(req, res)) return
646
+ if (!requireMethod(req, res, 'POST')) return
647
+ // Body is optional on disconnect: nothing to read, nothing to validate.
648
+ // Drain it anyway so keep-alive sockets are not reset between requests.
649
+ try {
650
+ req.resume()
651
+ } catch {
652
+ // Nothing to drain.
653
+ }
654
+ respond(res, await runtime.disconnect())
655
+ })
656
+ }
657
+
658
+ /**
659
+ * Register the minimal read-only routes a `role: 'runtime'` deployment still
660
+ * needs (PROTOCOL §10.1, client contract in §10.6).
661
+ *
662
+ * A runtime is not a controller, so the hub API stays unregistered — no
663
+ * `/agent/*`, no keys/join scripts, no served agent bundle. But the settings
664
+ * client learns its role from `GET /state`; without it the page can only show
665
+ * a load-error card. `/state` therefore answers with `role` and an empty
666
+ * computer list (a runtime owns no registry), and `/health` stays for probes.
667
+ *
668
+ * @param {object} options - route wiring.
669
+ * @param {object} options.ctx - Cordis context.
670
+ * @param {object} options.config - resolved plugin config.
671
+ * @param {string} options.role - always 'runtime' here; echoed in /state.
672
+ * @param {string} options.version - plugin version reported by /health.
673
+ * @param {(message: string) => void} [options.onWarn] - warning sink.
674
+ */
675
+ export function createRuntimeStatusRoutes({ ctx, config, role = 'runtime', version, onWarn = () => {} }) {
676
+ const route = createRouteRegistrar(ctx, onWarn)
677
+ const refuseBrowser = (req, res) => refuseBrowserRequest(ctx, req, res)
678
+
679
+ route('/state', async (req, res) => {
680
+ if (refuseBrowser(req, res)) return
681
+ if (!requireMethod(req, res, 'GET')) return
682
+ sendJson(res, 200, {
683
+ version: 2,
684
+ role,
685
+ serverUrl: resolveBaseUrl({ ctx, config, req }),
686
+ heartbeatMs: config.heartbeatMs,
687
+ // This deployment only follows a hub; it has no computer registry.
688
+ computers: [],
689
+ pendingKey: null,
690
+ })
691
+ })
692
+
693
+ route('/health', async (req, res) => {
694
+ if (!requireMethod(req, res, 'GET')) return
695
+ sendJson(res, 200, { ok: true, version, computers: 0, online: 0 })
696
+ })
697
+ }