dsh-computers 0.0.0-stage → 1.3.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/http.js ADDED
@@ -0,0 +1,761 @@
1
+ /**
2
+ * Small HTTP helpers shared by every dsh-computers host route: JSON/text
3
+ * writers, a bounded JSON body reader, the browser request gate, the origin
4
+ * resolver and the two installer script generators.
5
+ *
6
+ * Node builtins only — this module has to load in any DSH profile.
7
+ *
8
+ * The gate is a compact restatement of `dshmarket/src/http.ts` (sendJson,
9
+ * refuseUnadmitted, sameOrigin, loopbackAuthority, trustedAuthority). The
10
+ * reason it exists is the same: `webServer.register` routes are exact
11
+ * registrations on the bare web server, so they never pass DSH's own `/api`
12
+ * fence and each plugin has to ask the host's fence itself.
13
+ */
14
+
15
+ import { readFile } from 'node:fs/promises'
16
+ import { request as httpRequest } from 'node:http'
17
+ import { request as httpsRequest } from 'node:https'
18
+
19
+ /**
20
+ * One request failure that maps directly onto an HTTP response.
21
+ * Route handlers throw these; the route wrapper turns them into JSON.
22
+ */
23
+ export class HttpError extends Error {
24
+ /**
25
+ * @param {number} status - HTTP status code.
26
+ * @param {string} code - stable machine-readable error code.
27
+ * @param {string} message - human-readable detail.
28
+ */
29
+ constructor(status, code, message) {
30
+ super(message)
31
+ this.name = 'HttpError'
32
+ this.status = status
33
+ this.code = code
34
+ }
35
+ }
36
+
37
+ /** Write one JSON payload. `no-store` because every answer here is live state. */
38
+ export function sendJson(res, status, payload) {
39
+ if (res.headersSent) {
40
+ res.end()
41
+ return
42
+ }
43
+ const body = JSON.stringify(payload)
44
+ res.writeHead(status, {
45
+ 'content-type': 'application/json; charset=utf-8',
46
+ 'cache-control': 'no-store',
47
+ 'content-length': Buffer.byteLength(body),
48
+ })
49
+ res.end(body)
50
+ }
51
+
52
+ /** Write one plain-text payload (installer scripts, exit refusals). */
53
+ export function sendText(res, status, text, contentType = 'text/plain; charset=utf-8') {
54
+ if (res.headersSent) {
55
+ res.end()
56
+ return
57
+ }
58
+ const body = String(text)
59
+ res.writeHead(status, {
60
+ 'content-type': contentType,
61
+ 'cache-control': 'no-store',
62
+ 'content-length': Buffer.byteLength(body),
63
+ })
64
+ res.end(body)
65
+ }
66
+
67
+ /** One protocol-shaped `{ error, message }` failure. */
68
+ export function jsonError(res, status, code, message) {
69
+ sendJson(res, status, { error: code, message })
70
+ }
71
+
72
+ /** 405 with the single method the route supports. */
73
+ export function methodNotAllowed(res, allow) {
74
+ if (!res.headersSent) res.setHeader('allow', allow)
75
+ sendJson(res, 405, { error: 'method-not-allowed', message: `use ${allow}` })
76
+ }
77
+
78
+ /**
79
+ * Read a JSON request body, refusing anything above `maxBytes`.
80
+ * Reads the stream to its end (or drains it on refusal) so the client sees a
81
+ * response instead of a cut socket.
82
+ * @param {import('node:http').IncomingMessage} req - request to read.
83
+ * @param {number} maxBytes - inclusive size cap.
84
+ * @returns {Promise<unknown>} the parsed JSON value.
85
+ */
86
+ export async function readJsonBody(req, maxBytes) {
87
+ const chunks = []
88
+ let size = 0
89
+ let overLimit = false
90
+ await new Promise((resolve, reject) => {
91
+ req.on('data', (chunk) => {
92
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
93
+ size += buffer.length
94
+ if (size > maxBytes) {
95
+ // Keep consuming (and forgetting) the rest so the client receives a
96
+ // readable 413 instead of a reset socket, without buffering it.
97
+ overLimit = true
98
+ chunks.length = 0
99
+ return
100
+ }
101
+ chunks.push(buffer)
102
+ })
103
+ req.on('end', resolve)
104
+ req.on('error', reject)
105
+ req.on('aborted', () => reject(new HttpError(400, 'bad-request', 'request aborted')))
106
+ })
107
+ if (overLimit) throw new HttpError(413, 'payload-too-large', `request body exceeds ${maxBytes} bytes`)
108
+ if (size === 0) throw new HttpError(400, 'bad-request', 'request body is empty')
109
+ try {
110
+ return JSON.parse(Buffer.concat(chunks).toString('utf8'))
111
+ } catch {
112
+ throw new HttpError(400, 'bad-request', 'request body is not valid JSON')
113
+ }
114
+ }
115
+
116
+ /** Parse a bare `host[:port]` authority, or null when the value is not one. */
117
+ function parseAuthority(value) {
118
+ try {
119
+ const url = new URL(`http://${value}`)
120
+ const canonical = url.port === '' ? url.hostname : `${url.hostname}:${url.port}`
121
+ return canonical === value.toLowerCase() ? url : null
122
+ } catch {
123
+ return null
124
+ }
125
+ }
126
+
127
+ /**
128
+ * Whether a request authority is one of the deployment's declared ones.
129
+ * Port-less entries match the hostname on any port; entries with a port match
130
+ * that exact authority. Both sides compare through WHATWG normalization.
131
+ * @param {string} host - the request's `Host` header.
132
+ * @param {readonly string[]} trustedHosts - authorities the host declared.
133
+ * @returns {boolean} whether the authority is declared.
134
+ */
135
+ export function trustedAuthority(host, trustedHosts) {
136
+ const hostUrl = parseAuthority(host)
137
+ if (hostUrl === null) return false
138
+ return trustedHosts.some((entry) => {
139
+ if (typeof entry !== 'string') return false
140
+ const entryUrl = parseAuthority(entry)
141
+ if (entryUrl === null) return false
142
+ return entryUrl.port === '' ? entryUrl.hostname === hostUrl.hostname : entryUrl.host === hostUrl.host
143
+ })
144
+ }
145
+
146
+ /**
147
+ * Whether a `Host` header names a loopback authority. DNS rebinding is stopped
148
+ * here: Host is the one header an attacker's page cannot forge.
149
+ * @param {string | undefined} host - the request's `Host` header.
150
+ * @returns {boolean} whether it names loopback.
151
+ */
152
+ export function loopbackAuthority(host) {
153
+ if (typeof host !== 'string') return false
154
+ const lower = host.toLowerCase()
155
+ // IPv6 literals keep their brackets; `[::1]:3080` → `[::1]`.
156
+ const name = lower.startsWith('[') ? lower.slice(0, lower.indexOf(']') + 1) : lower.split(':')[0]
157
+ return name === '127.0.0.1' || name === 'localhost' || name === '[::1]'
158
+ }
159
+
160
+ /**
161
+ * Same-origin check used only when the host publishes no request gate.
162
+ *
163
+ * Copied in spirit from `dshmarket/src/http.ts`:
164
+ * - an absent `Host` is allowed (a stripping proxy — the Desktop build's —
165
+ * removes it, and a page can never omit it);
166
+ * - a present `Host` must name loopback or a declared authority;
167
+ * - `sec-fetch-site: cross-site` is refused;
168
+ * - an absent `Origin` is allowed (non-browser clients can forge any Origin,
169
+ * and the Desktop proxy strips it);
170
+ * - a present Origin must share the request's Host.
171
+ * @param {import('node:http').IncomingMessage} req - request to judge.
172
+ * @param {readonly string[]} trustedHosts - host-declared authorities.
173
+ * @returns {boolean} whether the request may reach a browser route.
174
+ */
175
+ export function sameOrigin(req, trustedHosts = []) {
176
+ const host = req.headers.host
177
+ if (host !== undefined && !loopbackAuthority(host) && !trustedAuthority(host, trustedHosts)) return false
178
+ if (req.headers['sec-fetch-site'] === 'cross-site') return false
179
+ const origin = req.headers.origin
180
+ if (origin === undefined) return true
181
+ try {
182
+ return new URL(origin).host === host
183
+ } catch {
184
+ // `Origin: null` and an empty Origin are present-but-unparseable: refused.
185
+ return false
186
+ }
187
+ }
188
+
189
+ /**
190
+ * The composition's `connection` service, if this host has one.
191
+ * Resolved per request: the host provides it only after its own async init.
192
+ * @param {object} ctx - Cordis context.
193
+ * @returns {object | undefined} the connection service.
194
+ */
195
+ export function connectionOf(ctx) {
196
+ try {
197
+ const service = ctx.get('connection')
198
+ if (service !== undefined && service !== null) return service
199
+ } catch {
200
+ // fall through to the reflective read
201
+ }
202
+ try {
203
+ const service = Reflect.get(ctx, 'connection')
204
+ return service ?? undefined
205
+ } catch {
206
+ return undefined
207
+ }
208
+ }
209
+
210
+ /**
211
+ * POST one JSON body to a URL we dial ourselves (the hub's enroll route) and
212
+ * parse its JSON answer. Used only for outbound calls the plugin initiates.
213
+ *
214
+ * Any transport failure — DNS, refused connection, TLS, timeout, abort, an
215
+ * over-cap response — throws `HttpError(502, 'hub-unreachable', ...)`; an HTTP
216
+ * answer (whatever its status) resolves so the caller can map the hub's own
217
+ * error codes.
218
+ *
219
+ * @param {string} urlString - absolute http(s) URL.
220
+ * @param {unknown} body - JSON-serializable request body.
221
+ * @param {object} [options] - call options.
222
+ * @param {number} [options.timeoutMs] - whole-exchange timeout (default 10 s).
223
+ * @param {AbortSignal} [options.signal] - caller cancellation.
224
+ * @param {number} [options.maxBytes] - response body cap (default 64 KiB).
225
+ * @returns {Promise<{ status: number, headers: Record<string, string | string[] | undefined>, text: string, data: unknown }>} the response.
226
+ */
227
+ export async function postJson(urlString, body, { timeoutMs = 10_000, signal, maxBytes = 64 * 1024 } = {}) {
228
+ let url
229
+ try {
230
+ url = new URL(String(urlString))
231
+ } catch {
232
+ throw new HttpError(400, 'bad-request', `not a valid URL: ${JSON.stringify(urlString)}`)
233
+ }
234
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') {
235
+ throw new HttpError(400, 'bad-request', 'serverUrl must use http or https')
236
+ }
237
+ let payload
238
+ try {
239
+ payload = Buffer.from(JSON.stringify(body), 'utf8')
240
+ } catch {
241
+ throw new HttpError(400, 'bad-request', 'request body is not JSON-serializable')
242
+ }
243
+ if (signal?.aborted === true) throw new HttpError(502, 'hub-unreachable', 'the request was aborted')
244
+
245
+ return await new Promise((resolve, reject) => {
246
+ let settled = false
247
+ let timer = null
248
+ const finish = (error, value) => {
249
+ if (settled) return
250
+ settled = true
251
+ if (timer !== null) clearTimeout(timer)
252
+ if (signal !== undefined) signal.removeEventListener('abort', onAbort)
253
+ if (error !== undefined) reject(error)
254
+ else resolve(value)
255
+ }
256
+ const onAbort = () => {
257
+ request.destroy(new Error('aborted'))
258
+ }
259
+
260
+ const transport = url.protocol === 'https:' ? httpsRequest : httpRequest
261
+ const request = transport(url, {
262
+ method: 'POST',
263
+ headers: {
264
+ 'content-type': 'application/json; charset=utf-8',
265
+ 'content-length': payload.length,
266
+ accept: 'application/json',
267
+ },
268
+ }, (response) => {
269
+ const chunks = []
270
+ let size = 0
271
+ let overLimit = false
272
+ response.on('data', (chunk) => {
273
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
274
+ size += buffer.length
275
+ if (size > maxBytes) {
276
+ overLimit = true
277
+ chunks.length = 0
278
+ response.resume()
279
+ return
280
+ }
281
+ chunks.push(buffer)
282
+ })
283
+ response.on('end', () => {
284
+ if (overLimit) {
285
+ finish(new HttpError(502, 'hub-unreachable', `hub response exceeded ${maxBytes} bytes`))
286
+ return
287
+ }
288
+ const text = Buffer.concat(chunks).toString('utf8')
289
+ let data
290
+ try {
291
+ data = text === '' ? undefined : JSON.parse(text)
292
+ } catch {
293
+ data = undefined
294
+ }
295
+ finish(undefined, { status: response.statusCode ?? 0, headers: response.headers, text, data })
296
+ })
297
+ response.on('error', (error) => finish(new HttpError(502, 'hub-unreachable', `hub response failed: ${error.message}`)))
298
+ })
299
+
300
+ request.on('error', (error) => {
301
+ // `request.destroy(new Error('aborted'))` also lands here; report the
302
+ // caller's cancellation rather than a raw socket error.
303
+ const message = signal?.aborted === true
304
+ ? 'the request was aborted'
305
+ : `cannot reach the hub: ${error.message}`
306
+ finish(new HttpError(502, 'hub-unreachable', message))
307
+ })
308
+ timer = setTimeout(() => {
309
+ request.destroy(new Error('timeout'))
310
+ finish(new HttpError(502, 'hub-unreachable', `the hub did not answer within ${timeoutMs}ms`))
311
+ }, timeoutMs)
312
+ if (typeof timer.unref === 'function') timer.unref()
313
+ if (signal !== undefined) signal.addEventListener('abort', onAbort, { once: true })
314
+ request.end(payload)
315
+ })
316
+ }
317
+
318
+ /**
319
+ * Answer the request with the host's own rejection, if it has one; when the
320
+ * connection service is absent, apply the fallback same-origin rules.
321
+ * @param {object} ctx - Cordis context.
322
+ * @param {import('node:http').IncomingMessage} req - request to judge.
323
+ * @param {import('node:http').ServerResponse} res - response to answer on.
324
+ * @returns {boolean} true when the request was refused and answered here.
325
+ */
326
+ export function refuseBrowserRequest(ctx, req, res) {
327
+ const connection = connectionOf(ctx)
328
+ const gate = connection?.requestRejection
329
+ if (typeof gate === 'function') {
330
+ let status
331
+ try {
332
+ status = gate.call(connection, req)
333
+ } catch {
334
+ // A gate that throws has not admitted anything: fail closed.
335
+ status = 403
336
+ }
337
+ if (typeof status === 'number') {
338
+ // Drain an unread request body so the refusal is not a socket reset.
339
+ try {
340
+ req.resume()
341
+ } catch {
342
+ // Nothing to drain.
343
+ }
344
+ jsonError(res, status, 'unadmitted', status === 401
345
+ ? 'DSH login required: reopen the URL printed by dsh web at startup.'
346
+ : 'This request came from an untrusted origin and was refused.')
347
+ return true
348
+ }
349
+ return false
350
+ }
351
+ const trustedHosts = Array.isArray(connection?.trustedHosts)
352
+ ? connection.trustedHosts.filter((entry) => typeof entry === 'string')
353
+ : []
354
+ if (!sameOrigin(req, trustedHosts)) {
355
+ try {
356
+ req.resume()
357
+ } catch {
358
+ // Nothing to drain.
359
+ }
360
+ jsonError(res, 403, 'unadmitted', 'This request came from an untrusted origin and was refused.')
361
+ return true
362
+ }
363
+ return false
364
+ }
365
+
366
+ /** Authority length cap: anything longer is not a host[:port], it is an attack. */
367
+ const MAX_AUTHORITY_CHARS = 255
368
+
369
+ /** One header as its first comma-separated value (Node joins duplicate headers). */
370
+ function firstHeaderValue(raw) {
371
+ const value = Array.isArray(raw) ? raw[0] : raw
372
+ if (typeof value !== 'string') return undefined
373
+ const first = value.split(',')[0]?.trim()
374
+ return first === undefined || first === '' ? undefined : first
375
+ }
376
+
377
+ /** One header as a single trimmed string (no comma splitting). */
378
+ function headerString(raw) {
379
+ const value = Array.isArray(raw) ? raw[0] : raw
380
+ return typeof value === 'string' && value.trim() !== '' ? value.trim() : undefined
381
+ }
382
+
383
+ /**
384
+ * Normalize `proto` + `authority` into a bare origin, or null when the pair is
385
+ * not a clean one.
386
+ *
387
+ * Everything here is defensive: a proxy header arrives from outside and must
388
+ * never smuggle a scheme, path, credentials, control characters or a numeric
389
+ * shorthand past the WHATWG parser into a one-liner the user will paste into a
390
+ * shell. A rejected value returns null and the caller falls through.
391
+ * @param {unknown} proto - 'http' | 'https' (any case, trailing colon tolerated).
392
+ * @param {unknown} authority - bare `host[:port]` (IPv6 literals included).
393
+ * @returns {string | null} canonical `scheme://authority`, or null.
394
+ */
395
+ function cleanOrigin(proto, authority) {
396
+ const scheme = typeof proto === 'string' ? proto.trim().toLowerCase().replace(/:$/, '') : ''
397
+ if (scheme !== 'http' && scheme !== 'https') return null
398
+ if (typeof authority !== 'string') return null
399
+ const host = authority.trim()
400
+ if (host === '' || host.length > MAX_AUTHORITY_CHARS) return null
401
+ // Reject whitespace and control characters, and the punctuation that would
402
+ // turn an authority into something else, BEFORE URL() can normalize it away.
403
+ if (/[\s\u0000-\u001f\u007f]/.test(host)) return null
404
+ if (host.includes('/') || host.includes('@') || host.includes('?') || host.includes('#')) return null
405
+ let url
406
+ try {
407
+ url = new URL(`${scheme}://${host}`)
408
+ } catch {
409
+ return null
410
+ }
411
+ // A clean authority parses to exactly `/`, with no credentials and no query.
412
+ if (url.username !== '' || url.password !== '') return null
413
+ if (url.pathname !== '/' || url.search !== '' || url.hash !== '') return null
414
+ if (url.host === '') return null
415
+ // The authority must survive parsing unchanged apart from letter case and a
416
+ // redundant default port: `evil.com/x` or `a@b` must never be rewritten into
417
+ // a host, and `0` must not become `0.0.0.0`.
418
+ const canonicalHost = url.host.toLowerCase()
419
+ const given = host.toLowerCase()
420
+ if (given !== canonicalHost && given !== `${canonicalHost}:80` && given !== `${canonicalHost}:443`) return null
421
+ // `url.host` canonicalizes case, keeps IPv6 brackets and drops a default port.
422
+ return `${scheme}://${url.host}`
423
+ }
424
+
425
+ /** Split on `separator` while ignoring separators inside double-quoted strings. */
426
+ function splitUnquoted(value, separator) {
427
+ const parts = []
428
+ let start = 0
429
+ let quoted = false
430
+ let escaped = false
431
+ for (let index = 0; index < value.length; index += 1) {
432
+ const char = value[index]
433
+ if (escaped) {
434
+ escaped = false
435
+ continue
436
+ }
437
+ if (quoted && char === '\\') {
438
+ escaped = true
439
+ continue
440
+ }
441
+ if (char === '"') {
442
+ quoted = !quoted
443
+ continue
444
+ }
445
+ if (!quoted && char === separator) {
446
+ parts.push(value.slice(start, index))
447
+ start = index + 1
448
+ }
449
+ }
450
+ parts.push(value.slice(start))
451
+ return parts
452
+ }
453
+
454
+ /**
455
+ * `proto`/`host` of the FIRST RFC 7239 `Forwarded` element (PROTOCOL §4.
456
+ * "parse the FIRST element only"): later hops may describe a different proxy.
457
+ * @param {unknown} raw - the `forwarded` header value.
458
+ * @returns {{ proto?: string, host?: string }} lowercased keywords, raw values.
459
+ */
460
+ function parseFirstForwarded(raw) {
461
+ const value = Array.isArray(raw) ? raw[0] : raw
462
+ if (typeof value !== 'string' || value.trim() === '' || value.length > 2048) return {}
463
+ const [first = ''] = splitUnquoted(value, ',')
464
+ const out = {}
465
+ for (const segment of splitUnquoted(first, ';')) {
466
+ const equals = segment.indexOf('=')
467
+ if (equals === -1) continue
468
+ const name = segment.slice(0, equals).trim().toLowerCase()
469
+ let pair = segment.slice(equals + 1).trim()
470
+ if (pair.startsWith('"')) {
471
+ if (pair.length < 2 || !pair.endsWith('"')) continue
472
+ pair = pair.slice(1, -1).replace(/\\(.)/g, '$1')
473
+ }
474
+ if (name === 'proto' || name === 'host') out[name] = pair
475
+ }
476
+ return out
477
+ }
478
+
479
+ /**
480
+ * Protocol of the request's own `Origin` header when it names the SAME
481
+ * authority as the request's Host. A browser sends `Origin` on every POST, so
482
+ * an HTTPS reverse proxy that forgets `X-Forwarded-Proto` still gets `https://`
483
+ * as long as the browser's Origin agrees with the Host it forwarded.
484
+ * @param {unknown} rawOrigin - the `origin` header value.
485
+ * @param {string} ownHost - the request's `Host` header value.
486
+ * @returns {'http' | 'https' | undefined} the agreeing protocol, if any.
487
+ */
488
+ function originProtocolFor(rawOrigin, ownHost) {
489
+ const value = headerString(rawOrigin)
490
+ if (value === undefined) return undefined
491
+ let url
492
+ try {
493
+ url = new URL(value)
494
+ } catch {
495
+ // `Origin: null` (sandboxed iframe, data: document) is not a vouch.
496
+ return undefined
497
+ }
498
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') return undefined
499
+ const cleaned = cleanOrigin(url.protocol, ownHost)
500
+ if (cleaned === null) return undefined
501
+ try {
502
+ const own = new URL(cleaned)
503
+ return own.host.toLowerCase() === url.host.toLowerCase() ? url.protocol.slice(0, -1) : undefined
504
+ } catch {
505
+ return undefined
506
+ }
507
+ }
508
+
509
+ /**
510
+ * The externally reachable base URL of this deployment: what the generated
511
+ * one-liner, the settings page and a joining machine should use.
512
+ *
513
+ * Auto-detection instead of a mandatory `publicUrl`: a deployment behind a
514
+ * reverse proxy is exactly the case where the browser's own origin is
515
+ * the address another machine can actually reach, so honoring the proxy's
516
+ * forwarded headers makes the command correct with zero configuration. These
517
+ * headers are trusted here only for *display/URL generation* — this is not an
518
+ * authorization decision (the browser routes run the host's request gate
519
+ * separately). An operator who does not trust their proxy sets `publicUrl`,
520
+ * which always wins.
521
+ *
522
+ * Precedence:
523
+ * 1. `config.publicUrl` — explicit operator override, used verbatim minus a
524
+ * trailing slash. It MAY carry a path prefix (sub-path deployment,
525
+ * e.g. `https://host/dsh`); auto-detected values are origin-only.
526
+ * 2. `x-forwarded-proto` + `x-forwarded-host` (first comma entry of each), or
527
+ * the first RFC 7239 `Forwarded` element (`proto=`/`host=`). The protocol
528
+ * may also come from `x-forwarded-ssl: on` (old nginx), but a forwarded
529
+ * host is only ever paired with a forwarded protocol, never the local one.
530
+ * 3. the request's own `Host` plus a protocol hint: forwarded proto /
531
+ * `x-forwarded-ssl`, else the `origin` header's protocol when its host
532
+ * equals the resolved authority, else `ctx.webServer.protocol`.
533
+ * 4. `ctx.webServer.getOrigin()` (throws before the listener binds).
534
+ * 5. `http://127.0.0.1:<ctx.webServer.port>` when known, else ''.
535
+ *
536
+ * Pure: no I/O, no caching; never throws.
537
+ * @param {object} options - resolution inputs.
538
+ * @param {object} [options.ctx] - Cordis context (webServer).
539
+ * @param {{ publicUrl?: string }} [options.config] - resolved plugin config.
540
+ * @param {import('node:http').IncomingMessage} [options.req] - the request being served.
541
+ * @returns {string} a base URL with no trailing slash, or '' when unknown.
542
+ */
543
+ export function resolveBaseUrl({ ctx, config, req } = {}) {
544
+ // 1. Explicit operator override always wins. A path prefix is preserved
545
+ // verbatim (minus a trailing slash) so sub-path deployments work; callers
546
+ // concatenate `${base}${API_BASE}/...`, which never double-slashes.
547
+ const configured = typeof config?.publicUrl === 'string' ? config.publicUrl.trim() : ''
548
+ if (configured !== '') {
549
+ let url = null
550
+ try {
551
+ url = new URL(configured)
552
+ } catch {
553
+ url = null
554
+ }
555
+ if (url !== null && (url.protocol === 'http:' || url.protocol === 'https:')) {
556
+ // Keep the operator's path prefix (sub-path deployment) and canonicalize
557
+ // everything else: a base URL must not carry credentials, a query or a
558
+ // fragment, because they would be spliced into a shell one-liner.
559
+ return `${url.origin}${url.pathname.replace(/\/+$/, '')}`
560
+ }
561
+ // A malformed publicUrl is the operator's bug; fall through to detection.
562
+ }
563
+
564
+ const headers = req?.headers ?? {}
565
+
566
+ /** Forwarded protocol hint: `x-forwarded-proto` first, else `x-forwarded-ssl: on`. */
567
+ const forwardedProtoRaw = firstHeaderValue(headers['x-forwarded-proto'])
568
+ const forwardedProto = forwardedProtoRaw !== undefined && /^https?:?$/i.test(forwardedProtoRaw)
569
+ ? forwardedProtoRaw.replace(/:$/, '').toLowerCase()
570
+ : undefined
571
+ const forwardedSsl = headerString(headers['x-forwarded-ssl'])
572
+ const sslHint = forwardedSsl !== undefined && forwardedSsl.toLowerCase() === 'on' ? 'https' : undefined
573
+ const protoHint = forwardedProto ?? sslHint
574
+ const forwardedHost = firstHeaderValue(headers['x-forwarded-host'])
575
+
576
+ // 2. A forwarded host is only paired with a forwarded protocol.
577
+ if (forwardedHost !== undefined && protoHint !== undefined) {
578
+ const pair = cleanOrigin(protoHint, forwardedHost)
579
+ if (pair !== null) return pair
580
+ }
581
+
582
+ // 2b. RFC 7239, FIRST element only (later hops may describe another proxy).
583
+ const rfc7239 = parseFirstForwarded(headers.forwarded)
584
+ if (rfc7239.proto !== undefined && rfc7239.host !== undefined) {
585
+ const element = cleanOrigin(rfc7239.proto, rfc7239.host)
586
+ if (element !== null) return element
587
+ }
588
+
589
+ // 3. The request's own Host plus a protocol hint. The hint order is what
590
+ // rescues an HTTPS proxy with no X-Forwarded-Proto: the browser's Origin
591
+ // still says https for the same authority.
592
+ const ownHost = headerString(headers.host)
593
+ if (ownHost !== undefined) {
594
+ const listenerProto = typeof ctx?.webServer?.protocol === 'string'
595
+ ? ctx.webServer.protocol.replace(/:$/, '').toLowerCase()
596
+ : ''
597
+ const hint = protoHint
598
+ ?? originProtocolFor(headers.origin, ownHost)
599
+ ?? (listenerProto === 'http' || listenerProto === 'https' ? listenerProto : undefined)
600
+ const own = cleanOrigin(hint, ownHost)
601
+ if (own !== null) return own
602
+ }
603
+
604
+ // 4. The live bound origin.
605
+ try {
606
+ const origin = ctx?.webServer?.getOrigin?.()
607
+ if (typeof origin === 'string' && origin.trim() !== '') {
608
+ const url = new URL(origin.trim())
609
+ if (url.protocol === 'http:' || url.protocol === 'https:') return url.origin
610
+ }
611
+ } catch {
612
+ // The origin is unreadable before the listener binds.
613
+ }
614
+
615
+ // 5. Last resort: the bound port on loopback, else unknown.
616
+ const port = ctx?.webServer?.port
617
+ if (Number.isSafeInteger(port) && port > 0 && port <= 65535) return `http://127.0.0.1:${port}`
618
+ return ''
619
+ }
620
+
621
+ /** POSIX single-quote escaping for a value embedded in a shell script. */
622
+ function shellQuote(value) {
623
+ return `'${String(value).replace(/'/g, `'\\''`)}'`
624
+ }
625
+
626
+ /** PowerShell single-quote escaping. */
627
+ function psQuote(value) {
628
+ return `'${String(value).replace(/'/g, "''")}'`
629
+ }
630
+
631
+ /**
632
+ * The macOS/Linux one-liner installer behind `GET /join.sh` (PROTOCOL §4.6).
633
+ * Generated from the same server URL and key the settings page shows, so the
634
+ * script can never drift from the routes.
635
+ * @param {string} serverUrl - host origin.
636
+ * @param {string} key - enrollment key.
637
+ * @returns {string} a POSIX shell script.
638
+ */
639
+ export function joinSh(serverUrl, key) {
640
+ const server = shellQuote(serverUrl)
641
+ const token = shellQuote(key)
642
+ // Kept as a line array instead of one template literal so every `$` below
643
+ // stays a shell variable and only `server`/`token` interpolate at build time.
644
+ return [
645
+ '#!/bin/sh',
646
+ '# dsh-computers: connect this machine to a DSH Web deployment.',
647
+ 'set -eu',
648
+ '',
649
+ `SERVER=${server}`,
650
+ `KEY=${token}`,
651
+ 'DIR="$HOME/.dsh-computers"',
652
+ 'AGENT="$DIR/agent.mjs"',
653
+ '',
654
+ 'say() { printf \'%s\\n\' "$*"; }',
655
+ 'die() { printf \'error: %s\\n\' "$*" >&2; exit 1; }',
656
+ '',
657
+ 'command -v curl >/dev/null 2>&1 || die "curl is required to download the agent."',
658
+ 'command -v node >/dev/null 2>&1 || die "Node.js 22 or newer is required: https://nodejs.org"',
659
+ 'NODE_VERSION="$(node -v 2>/dev/null || true)"',
660
+ 'NODE_MAJOR="$(printf \'%s\' "$NODE_VERSION" | sed \'s/^v//\' | cut -d. -f1)"',
661
+ 'case "$NODE_MAJOR" in',
662
+ ' \'\'|*[!0-9]*) die "cannot determine the Node.js version (node -v printed \'$NODE_VERSION\')." ;;',
663
+ 'esac',
664
+ 'if [ "$NODE_MAJOR" -lt 22 ]; then',
665
+ ' die "Node.js 22 or newer is required, found $NODE_VERSION."',
666
+ 'fi',
667
+ '',
668
+ 'mkdir -p "$DIR"',
669
+ 'say "→ downloading the dsh-computers agent"',
670
+ 'curl -fsSL "$SERVER/api/dsh-computers/agent.mjs" -o "$AGENT" || die "could not download $SERVER/api/dsh-computers/agent.mjs"',
671
+ 'chmod 700 "$AGENT"',
672
+ '',
673
+ 'say "→ enrolling this computer with $SERVER"',
674
+ 'node "$AGENT" enroll --server "$SERVER" --key "$KEY"',
675
+ '',
676
+ 'STARTED=0',
677
+ 'case "$(uname -s 2>/dev/null || echo unknown)" in',
678
+ ' Darwin|Linux)',
679
+ ' if node "$AGENT" service install >/dev/null 2>&1 && node "$AGENT" service start >/dev/null 2>&1; then',
680
+ ' STARTED=1',
681
+ ' fi',
682
+ ' ;;',
683
+ 'esac',
684
+ '',
685
+ 'if [ "$STARTED" -eq 1 ]; then',
686
+ ' say "✓ dsh-computers is connected and will reconnect automatically after a reboot."',
687
+ 'else',
688
+ ' say "ℹ automatic startup is not available on this machine."',
689
+ ' say " keep this computer connected with:"',
690
+ ' say " node \\"$AGENT\\" run"',
691
+ 'fi',
692
+ '',
693
+ ].join('\n')
694
+ }
695
+
696
+ /**
697
+ * The Windows installer behind `GET /join.ps1` (PROTOCOL §4.6, best effort).
698
+ * @param {string} serverUrl - host origin.
699
+ * @param {string} key - enrollment key.
700
+ * @returns {string} a PowerShell script.
701
+ */
702
+ export function joinPs1(serverUrl, key) {
703
+ const server = psQuote(serverUrl)
704
+ const token = psQuote(key)
705
+ return [
706
+ '# dsh-computers: connect this machine to a DSH Web deployment.',
707
+ "$ErrorActionPreference = 'Stop'",
708
+ `$Server = ${server}`,
709
+ `$Key = ${token}`,
710
+ "$Dir = Join-Path $HOME '.dsh-computers'",
711
+ "$Agent = Join-Path $Dir 'agent.mjs'",
712
+ '',
713
+ '$node = Get-Command node -ErrorAction SilentlyContinue',
714
+ "if ($null -eq $node) { throw 'Node.js 22 or newer is required: https://nodejs.org' }",
715
+ "$version = (& node -v).TrimStart('v')",
716
+ 'if ([int]($version.Split(\'.\')[0]) -lt 22) { throw "Node.js 22 or newer is required, found v$version" }',
717
+ '',
718
+ 'New-Item -ItemType Directory -Force -Path $Dir | Out-Null',
719
+ 'Write-Host "-> downloading the dsh-computers agent"',
720
+ "Invoke-WebRequest -UseBasicParsing -Uri \"$Server/api/dsh-computers/agent.mjs\" -OutFile $Agent",
721
+ '',
722
+ 'Write-Host "-> enrolling this computer with $Server"',
723
+ '& node $Agent enroll --server $Server --key $Key',
724
+ 'if ($LASTEXITCODE -ne 0) { throw "enroll failed with exit code $LASTEXITCODE" }',
725
+ '',
726
+ 'Write-Host "OK dsh-computers is connected."',
727
+ 'Write-Host " Keep this computer connected with: node `"$Agent`" run"',
728
+ '',
729
+ ].join('\n')
730
+ }
731
+
732
+ /**
733
+ * Serve the machine agent bundle as bytes. Read per request (never imported):
734
+ * the file belongs to the agent surface and may be replaced between requests.
735
+ * @param {import('node:http').ServerResponse} res - response to write.
736
+ * @param {URL} agentUrl - `new URL('./agent/dsh-computers-agent.mjs', import.meta.url)`.
737
+ * @returns {Promise<void>} resolves once the response is written.
738
+ */
739
+ export async function serveAgentScript(res, agentUrl) {
740
+ let bytes
741
+ try {
742
+ bytes = await readFile(agentUrl)
743
+ } catch (error) {
744
+ const missing = error && (error.code === 'ENOENT' || error.code === 'ENOTDIR')
745
+ jsonError(res, missing ? 404 : 500, missing ? 'agent-unavailable' : 'internal',
746
+ missing
747
+ ? `the machine agent bundle is missing from this deployment (${agentUrl.pathname})`
748
+ : 'the machine agent bundle could not be read')
749
+ return
750
+ }
751
+ if (res.headersSent) {
752
+ res.end()
753
+ return
754
+ }
755
+ res.writeHead(200, {
756
+ 'content-type': 'text/javascript; charset=utf-8',
757
+ 'cache-control': 'no-store',
758
+ 'content-length': bytes.length,
759
+ })
760
+ res.end(bytes)
761
+ }