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/LICENSE +21 -0
- package/PROTOCOL.md +559 -0
- package/README.md +147 -2
- package/agent/README.md +276 -0
- package/agent/dsh-computers-agent.mjs +1993 -0
- package/client.js +3219 -0
- package/cordis.patch.yml +23 -0
- package/docs/deploy.md +103 -0
- package/icon.svg +8 -0
- package/index.js +211 -0
- package/lib/http.js +761 -0
- package/lib/hub.js +381 -0
- package/lib/keys.js +134 -0
- package/lib/routes.js +697 -0
- package/lib/runtime.js +469 -0
- package/lib/selection.js +205 -0
- package/lib/state.js +181 -0
- package/lib/tools.js +394 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +56 -4
package/lib/hub.js
ADDED
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The live side of dsh-computers: one SSE stream per computer and the exec
|
|
3
|
+
* dispatcher that rides it (PROTOCOL §3.2, §3.3, §5.2).
|
|
4
|
+
*
|
|
5
|
+
* Invariants this file keeps:
|
|
6
|
+
* - at most one stream per computer; opening a new one ends the old one and
|
|
7
|
+
* fails the old agent's in-flight executions;
|
|
8
|
+
* - `online` is the live stream and nothing else; `lastSeenAt` is display data
|
|
9
|
+
* refreshed by stream open / heartbeat / result POST;
|
|
10
|
+
* - every exec has a unique `req_…` id, every promise settles exactly once —
|
|
11
|
+
* a result POST, the timeout, the abort signal, the stream closing or the
|
|
12
|
+
* plugin disposing;
|
|
13
|
+
* - a late result for an already settled id is not accepted (the route answers
|
|
14
|
+
* 404).
|
|
15
|
+
*
|
|
16
|
+
* Node builtins only.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { newRequestId } from './keys.js'
|
|
20
|
+
|
|
21
|
+
/** Failure carrying a machine-readable code the tool layer maps to a message. */
|
|
22
|
+
export class HubError extends Error {
|
|
23
|
+
/**
|
|
24
|
+
* @param {string} code - `offline` | `timeout` | `aborted` | `disposed`.
|
|
25
|
+
* @param {string} message - model-facing detail.
|
|
26
|
+
*/
|
|
27
|
+
constructor(code, message) {
|
|
28
|
+
super(message)
|
|
29
|
+
this.name = 'HubError'
|
|
30
|
+
this.code = code
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** SSE response headers required by the protocol. */
|
|
35
|
+
const SSE_HEADERS = {
|
|
36
|
+
'content-type': 'text/event-stream; charset=utf-8',
|
|
37
|
+
'cache-control': 'no-store',
|
|
38
|
+
connection: 'keep-alive',
|
|
39
|
+
'x-accel-buffering': 'no',
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Trim UTF-8 text to a byte budget without cutting a character in half.
|
|
44
|
+
* @param {string} text - input text.
|
|
45
|
+
* @param {number} maxBytes - inclusive byte budget.
|
|
46
|
+
* @returns {{ text: string, truncated: boolean }} trimmed text and whether it was cut.
|
|
47
|
+
*/
|
|
48
|
+
function capText(text, maxBytes) {
|
|
49
|
+
if (typeof text !== 'string') return { text: '', truncated: false }
|
|
50
|
+
const bytes = Buffer.byteLength(text, 'utf8')
|
|
51
|
+
if (bytes <= maxBytes) return { text, truncated: false }
|
|
52
|
+
let out = Buffer.from(text, 'utf8').subarray(0, maxBytes).toString('utf8')
|
|
53
|
+
// A multi-byte character split at the boundary decodes to U+FFFD; drop only
|
|
54
|
+
// the trailing replacement characters this slice may have produced.
|
|
55
|
+
while (out.endsWith('\uFFFD')) out = out.slice(0, -1)
|
|
56
|
+
return { text: `${out}\n[output truncated]`, truncated: true }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Create the hub for one plugin mount.
|
|
61
|
+
* @param {object} options - hub options.
|
|
62
|
+
* @param {object} options.store - registry store (presence timestamps).
|
|
63
|
+
* @param {number} options.heartbeatMs - SSE ping cadence reported to the agent.
|
|
64
|
+
* @param {number} options.maxOutputBytes - per-stream result cap.
|
|
65
|
+
* @param {(message: string) => void} [options.onWarn] - warning sink.
|
|
66
|
+
* @returns {object} the hub.
|
|
67
|
+
*/
|
|
68
|
+
export function createHub({ store, heartbeatMs, maxOutputBytes, onWarn = () => {} }) {
|
|
69
|
+
/** @type {Map<string, { res: import('node:http').ServerResponse, timer: ReturnType<typeof setInterval>, openedAt: number }>} */
|
|
70
|
+
const streams = new Map()
|
|
71
|
+
/** @type {Map<string, object>} */
|
|
72
|
+
const pending = new Map()
|
|
73
|
+
let disposed = false
|
|
74
|
+
|
|
75
|
+
/** Write one SSE frame; false when the stream is gone. */
|
|
76
|
+
const writeFrame = (stream, event, data) => {
|
|
77
|
+
const res = stream.res
|
|
78
|
+
if (res.writableEnded || res.destroyed) return false
|
|
79
|
+
try {
|
|
80
|
+
res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`)
|
|
81
|
+
return true
|
|
82
|
+
} catch (error) {
|
|
83
|
+
onWarn(`dsh-computers: SSE write failed: ${error && error.message ? error.message : error}`)
|
|
84
|
+
return false
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Detach one pending execution's timer and abort listener (idempotent). */
|
|
89
|
+
const detach = (entry) => {
|
|
90
|
+
if (entry.timer !== null) {
|
|
91
|
+
clearTimeout(entry.timer)
|
|
92
|
+
entry.timer = null
|
|
93
|
+
}
|
|
94
|
+
if (entry.signal !== undefined && entry.onAbort !== null) {
|
|
95
|
+
entry.signal.removeEventListener('abort', entry.onAbort)
|
|
96
|
+
entry.onAbort = null
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Reject one entry exactly once. */
|
|
101
|
+
const failEntry = (entry, error) => {
|
|
102
|
+
if (entry.settled) return
|
|
103
|
+
entry.settled = true
|
|
104
|
+
detach(entry)
|
|
105
|
+
pending.delete(entry.requestId)
|
|
106
|
+
entry.reject(error)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Reject every execution belonging to one computer (stream gone/offline). */
|
|
110
|
+
const failComputerExecs = (computerId, error) => {
|
|
111
|
+
for (const entry of [...pending.values()]) {
|
|
112
|
+
if (entry.computerId === computerId) failEntry(entry, error)
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Stop a stream without letting its close event touch a replacement. */
|
|
117
|
+
const retire = (computerId, stream, { end }) => {
|
|
118
|
+
if (streams.get(computerId) === stream) streams.delete(computerId)
|
|
119
|
+
if (stream.timer !== null) {
|
|
120
|
+
clearInterval(stream.timer)
|
|
121
|
+
stream.timer = null
|
|
122
|
+
}
|
|
123
|
+
if (end) {
|
|
124
|
+
try {
|
|
125
|
+
stream.res.end()
|
|
126
|
+
} catch {
|
|
127
|
+
// Already closed by the peer.
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const hub = {
|
|
133
|
+
/** Whether a computer currently holds an open SSE stream. */
|
|
134
|
+
isLive: (computerId) => streams.has(computerId),
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Presence is the live SSE stream and nothing else.
|
|
138
|
+
*
|
|
139
|
+
* `lastSeenAt` is still refreshed by stream open / heartbeat / result POST
|
|
140
|
+
* and is shown in the UI, but a heartbeat without a stream must NOT read
|
|
141
|
+
* as online: the settings page polls every 5 s, so a machine that died
|
|
142
|
+
* while its heartbeat timestamp is still warm would show "在线" for up to
|
|
143
|
+
* 2x heartbeatMs. (PROTOCOL.md §2 still describes that older fallback; the
|
|
144
|
+
* live behaviour is stream-only and the protocol is being updated.)
|
|
145
|
+
* @param {object} computer - registry record.
|
|
146
|
+
* @returns {boolean} whether the computer holds an open stream.
|
|
147
|
+
*/
|
|
148
|
+
isOnline(computer) {
|
|
149
|
+
if (computer === null || typeof computer !== 'object') return false
|
|
150
|
+
return streams.has(computer.id)
|
|
151
|
+
},
|
|
152
|
+
|
|
153
|
+
/** How many computers are online right now. */
|
|
154
|
+
onlineCount: () => store.read().computers.filter((computer) => hub.isOnline(computer)).length,
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Accept a new SSE stream for a computer, replacing any previous one.
|
|
158
|
+
* The caller owns authentication and `lastSeenAt`; the hub owns framing.
|
|
159
|
+
* @param {string} computerId - authenticated computer.
|
|
160
|
+
* @param {string} name - display name for the `hello` event.
|
|
161
|
+
* @param {import('node:http').IncomingMessage} req - request (for close hooks).
|
|
162
|
+
* @param {import('node:http').ServerResponse} res - response to hold open.
|
|
163
|
+
* @returns {{ replaced: boolean, stream: object }} the opened stream.
|
|
164
|
+
*/
|
|
165
|
+
openStream(computerId, name, req, res) {
|
|
166
|
+
if (disposed) throw new HubError('disposed', 'the dsh-computers host plugin is shutting down')
|
|
167
|
+
const previous = streams.get(computerId)
|
|
168
|
+
let replaced = false
|
|
169
|
+
if (previous !== undefined) {
|
|
170
|
+
replaced = true
|
|
171
|
+
retire(computerId, previous, { end: true })
|
|
172
|
+
failComputerExecs(computerId, new HubError('offline',
|
|
173
|
+
'the computer reconnected and replaced its previous session'))
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
res.writeHead(200, SSE_HEADERS)
|
|
177
|
+
// Flush headers immediately: an agent must see the stream accepted
|
|
178
|
+
// before it decides the connection failed.
|
|
179
|
+
if (typeof res.flushHeaders === 'function') res.flushHeaders()
|
|
180
|
+
res.write('retry: 2000\n\n')
|
|
181
|
+
if (typeof res.socket?.setNoDelay === 'function') {
|
|
182
|
+
try {
|
|
183
|
+
res.socket.setNoDelay(true)
|
|
184
|
+
} catch {
|
|
185
|
+
// Socket already gone; the close handler will clean up.
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const stream = { computerId, res, timer: null, openedAt: Date.now() }
|
|
190
|
+
streams.set(computerId, stream)
|
|
191
|
+
writeFrame(stream, 'hello', { computerId, name, heartbeatMs })
|
|
192
|
+
|
|
193
|
+
const onClose = () => {
|
|
194
|
+
// Only the CURRENT stream owns the computer's liveness.
|
|
195
|
+
if (streams.get(computerId) !== stream) return
|
|
196
|
+
retire(computerId, stream, { end: false })
|
|
197
|
+
failComputerExecs(computerId, new HubError('offline', 'the connection to the computer was lost'))
|
|
198
|
+
}
|
|
199
|
+
res.on('close', onClose)
|
|
200
|
+
res.on('error', onClose)
|
|
201
|
+
req.on('close', () => {
|
|
202
|
+
// The agent may close the request side without the response side
|
|
203
|
+
// reporting it promptly on every platform.
|
|
204
|
+
if (res.writableEnded || res.destroyed) onClose()
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
stream.timer = setInterval(() => {
|
|
208
|
+
if (!writeFrame(stream, 'ping', { t: Date.now() })) onClose()
|
|
209
|
+
}, heartbeatMs)
|
|
210
|
+
// Pings must not keep a shutting-down process alive.
|
|
211
|
+
if (typeof stream.timer.unref === 'function') stream.timer.unref()
|
|
212
|
+
|
|
213
|
+
return { replaced, stream }
|
|
214
|
+
},
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Push one event to a computer's live stream.
|
|
218
|
+
* @param {string} computerId - target computer.
|
|
219
|
+
* @param {string} event - SSE event name.
|
|
220
|
+
* @param {unknown} data - JSON payload.
|
|
221
|
+
* @returns {boolean} false when the computer has no live stream.
|
|
222
|
+
*/
|
|
223
|
+
send(computerId, event, data) {
|
|
224
|
+
const stream = streams.get(computerId)
|
|
225
|
+
if (stream === undefined) return false
|
|
226
|
+
return writeFrame(stream, event, data)
|
|
227
|
+
},
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* End a computer's live stream and fail its in-flight executions.
|
|
231
|
+
* @param {string} computerId - target computer.
|
|
232
|
+
* @returns {boolean} whether a stream was open.
|
|
233
|
+
*/
|
|
234
|
+
closeStream(computerId) {
|
|
235
|
+
const stream = streams.get(computerId)
|
|
236
|
+
if (stream === undefined) {
|
|
237
|
+
// Still fail executions: the HTTP side may already be gone.
|
|
238
|
+
failComputerExecs(computerId, new HubError('offline', 'the computer was disconnected'))
|
|
239
|
+
return false
|
|
240
|
+
}
|
|
241
|
+
retire(computerId, stream, { end: true })
|
|
242
|
+
failComputerExecs(computerId, new HubError('offline', 'the computer was disconnected'))
|
|
243
|
+
return true
|
|
244
|
+
},
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Dispatch one command through the computer's live stream.
|
|
248
|
+
* @param {object} request - exec request.
|
|
249
|
+
* @param {string} request.computerId - target computer.
|
|
250
|
+
* @param {string} request.name - display name used in error messages.
|
|
251
|
+
* @param {string} request.command - shell command.
|
|
252
|
+
* @param {string | null} [request.cwd] - working directory or null.
|
|
253
|
+
* @param {number} request.timeoutMs - settlement deadline.
|
|
254
|
+
* @param {string} [request.shell] - shell hint for the agent.
|
|
255
|
+
* @param {AbortSignal | undefined} [request.signal] - caller cancellation.
|
|
256
|
+
* @returns {Promise<object>} normalized result when the agent answers.
|
|
257
|
+
*/
|
|
258
|
+
async dispatch({ computerId, name, command, cwd = null, timeoutMs, shell = 'sh', signal }) {
|
|
259
|
+
const label = typeof name === 'string' && name !== '' ? name : computerId
|
|
260
|
+
if (disposed) throw new HubError('disposed', 'the dsh-computers host plugin is shutting down')
|
|
261
|
+
if (!streams.has(computerId)) {
|
|
262
|
+
throw new HubError('offline', `computer '${label}' is offline; run computer_list to see what is connected`)
|
|
263
|
+
}
|
|
264
|
+
if (typeof command !== 'string' || command.trim() === '') {
|
|
265
|
+
throw new HubError('invalid', 'command must be a non-empty string')
|
|
266
|
+
}
|
|
267
|
+
const stream = streams.get(computerId)
|
|
268
|
+
if (stream === undefined) {
|
|
269
|
+
throw new HubError('offline', `computer '${label}' is offline; run computer_list to see what is connected`)
|
|
270
|
+
}
|
|
271
|
+
const requestId = newRequestId()
|
|
272
|
+
return await new Promise((resolve, reject) => {
|
|
273
|
+
const entry = {
|
|
274
|
+
requestId,
|
|
275
|
+
computerId,
|
|
276
|
+
command,
|
|
277
|
+
startedAt: Date.now(),
|
|
278
|
+
signal,
|
|
279
|
+
onAbort: null,
|
|
280
|
+
timer: null,
|
|
281
|
+
settled: false,
|
|
282
|
+
resolve,
|
|
283
|
+
reject,
|
|
284
|
+
}
|
|
285
|
+
pending.set(requestId, entry)
|
|
286
|
+
|
|
287
|
+
// NOT unref'd: an in-flight execution must settle even if it is the
|
|
288
|
+
// only work left in the process.
|
|
289
|
+
entry.timer = setTimeout(() => {
|
|
290
|
+
// Tell the agent to kill the process group before settling, so a
|
|
291
|
+
// timed-out command does not keep eating the user's computer.
|
|
292
|
+
hub.send(computerId, 'cancel', { requestId })
|
|
293
|
+
failEntry(entry, new HubError('timeout',
|
|
294
|
+
`computer '${label}' did not answer within ${timeoutMs}ms (the command was cancelled)`))
|
|
295
|
+
}, timeoutMs)
|
|
296
|
+
|
|
297
|
+
if (signal !== undefined) {
|
|
298
|
+
const abort = () => {
|
|
299
|
+
hub.send(computerId, 'cancel', { requestId })
|
|
300
|
+
failEntry(entry, new HubError('aborted', `computer_exec was aborted while running on '${label}'`))
|
|
301
|
+
}
|
|
302
|
+
if (signal.aborted) {
|
|
303
|
+
abort()
|
|
304
|
+
return
|
|
305
|
+
}
|
|
306
|
+
entry.onAbort = abort
|
|
307
|
+
signal.addEventListener('abort', abort, { once: true })
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const written = writeFrame(stream, 'exec', {
|
|
311
|
+
requestId,
|
|
312
|
+
command,
|
|
313
|
+
cwd: cwd ?? null,
|
|
314
|
+
timeoutMs,
|
|
315
|
+
shell,
|
|
316
|
+
})
|
|
317
|
+
if (!written) {
|
|
318
|
+
failEntry(entry, new HubError('offline', `computer '${label}' is offline; run computer_list to see what is connected`))
|
|
319
|
+
}
|
|
320
|
+
})
|
|
321
|
+
},
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Settle one pending execution from the agent's result POST.
|
|
325
|
+
* @param {string} computerId - authenticated computer.
|
|
326
|
+
* @param {unknown} payload - result body from the agent.
|
|
327
|
+
* @returns {boolean} false when the id is unknown/settled or owned elsewhere.
|
|
328
|
+
*/
|
|
329
|
+
settle(computerId, payload) {
|
|
330
|
+
const requestId = payload !== null && typeof payload === 'object' ? payload.requestId : undefined
|
|
331
|
+
if (typeof requestId !== 'string') return false
|
|
332
|
+
const entry = pending.get(requestId)
|
|
333
|
+
if (entry === undefined || entry.computerId !== computerId) return false
|
|
334
|
+
entry.settled = true
|
|
335
|
+
detach(entry)
|
|
336
|
+
pending.delete(requestId)
|
|
337
|
+
|
|
338
|
+
const stdout = capText(typeof payload.stdout === 'string' ? payload.stdout : '', maxOutputBytes)
|
|
339
|
+
const stderr = capText(typeof payload.stderr === 'string' ? payload.stderr : '', maxOutputBytes)
|
|
340
|
+
const exitCode = typeof payload.exitCode === 'number' && Number.isFinite(payload.exitCode)
|
|
341
|
+
? Math.trunc(payload.exitCode)
|
|
342
|
+
: null
|
|
343
|
+
const duration = typeof payload.durationMs === 'number' && Number.isFinite(payload.durationMs) && payload.durationMs >= 0
|
|
344
|
+
? Math.trunc(payload.durationMs)
|
|
345
|
+
: Date.now() - entry.startedAt
|
|
346
|
+
entry.resolve({
|
|
347
|
+
requestId,
|
|
348
|
+
ok: payload.ok === true,
|
|
349
|
+
exitCode,
|
|
350
|
+
signal: typeof payload.signal === 'string' ? payload.signal : null,
|
|
351
|
+
stdout: stdout.text,
|
|
352
|
+
stderr: stderr.text,
|
|
353
|
+
durationMs: duration,
|
|
354
|
+
truncated: payload.truncated === true || stdout.truncated || stderr.truncated,
|
|
355
|
+
timedOut: payload.timedOut === true,
|
|
356
|
+
error: typeof payload.error === 'string' ? payload.error : null,
|
|
357
|
+
})
|
|
358
|
+
return true
|
|
359
|
+
},
|
|
360
|
+
|
|
361
|
+
/** Pending execution count (diagnostics + smoke assertions). */
|
|
362
|
+
pendingCount: (computerId) => [...pending.values()].filter((entry) => computerId === undefined || entry.computerId === computerId).length,
|
|
363
|
+
|
|
364
|
+
/** Fail one computer's executions (delete/disable paths). */
|
|
365
|
+
failComputerExecs,
|
|
366
|
+
|
|
367
|
+
/** End every stream and reject every pending execution (plugin disposal). */
|
|
368
|
+
dispose() {
|
|
369
|
+
disposed = true
|
|
370
|
+
for (const computerId of [...streams.keys()]) {
|
|
371
|
+
const stream = streams.get(computerId)
|
|
372
|
+
if (stream !== undefined) retire(computerId, stream, { end: true })
|
|
373
|
+
}
|
|
374
|
+
for (const entry of [...pending.values()]) {
|
|
375
|
+
failEntry(entry, new HubError('disposed', 'the dsh-computers host plugin shut down'))
|
|
376
|
+
}
|
|
377
|
+
},
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
return hub
|
|
381
|
+
}
|
package/lib/keys.js
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Secrets and small value helpers for dsh-computers (PROTOCOL §2, §3.1).
|
|
3
|
+
*
|
|
4
|
+
* Enrollment keys and agent tokens share one shape — `dshc_` plus 32 random
|
|
5
|
+
* bytes as base64url — but have different lifetimes: the key is single use
|
|
6
|
+
* with a TTL, the token is long lived and the host stores only its sha256.
|
|
7
|
+
*
|
|
8
|
+
* Node builtins only.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto'
|
|
12
|
+
|
|
13
|
+
/** Every secret this plugin mints carries this prefix. */
|
|
14
|
+
export const SECRET_PREFIX = 'dshc_'
|
|
15
|
+
|
|
16
|
+
/** Token bytes per secret (base64url-encodes to 43 characters). */
|
|
17
|
+
const SECRET_BYTES = 32
|
|
18
|
+
|
|
19
|
+
/** Mint one `dshc_<43 base64url>` secret. */
|
|
20
|
+
export function mintSecret() {
|
|
21
|
+
return `${SECRET_PREFIX}${randomBytes(SECRET_BYTES).toString('base64url')}`
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Mint one computer id: `cmp_` plus 12 lowercase hex characters. */
|
|
25
|
+
export function newComputerId() {
|
|
26
|
+
return `cmp_${randomBytes(6).toString('hex')}`
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Mint one exec request id: `req_` plus 24 hex characters. */
|
|
30
|
+
export function newRequestId() {
|
|
31
|
+
return `req_${randomBytes(12).toString('hex')}`
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* sha256 of a secret as lowercase hex — the only form the host persists.
|
|
36
|
+
* @param {string} value - secret or arbitrary string.
|
|
37
|
+
* @returns {string} 64 hex characters.
|
|
38
|
+
*/
|
|
39
|
+
export function sha256Hex(value) {
|
|
40
|
+
return createHash('sha256').update(String(value), 'utf8').digest('hex')
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Compare a presented token against a stored sha256 hash in constant time.
|
|
45
|
+
* Digests always have the same length, so `timingSafeEqual` cannot throw on
|
|
46
|
+
* the comparison itself; a malformed stored hash simply fails closed.
|
|
47
|
+
* @param {string} presented - bearer token from the request.
|
|
48
|
+
* @param {string} expectedHash - stored `tokenHash`.
|
|
49
|
+
* @returns {boolean} whether the token matches.
|
|
50
|
+
*/
|
|
51
|
+
export function tokenMatches(presented, expectedHash) {
|
|
52
|
+
if (typeof presented !== 'string' || typeof expectedHash !== 'string') return false
|
|
53
|
+
let expected
|
|
54
|
+
try {
|
|
55
|
+
expected = Buffer.from(expectedHash, 'hex')
|
|
56
|
+
} catch {
|
|
57
|
+
return false
|
|
58
|
+
}
|
|
59
|
+
if (expected.length !== 32) return false
|
|
60
|
+
const actual = createHash('sha256').update(presented, 'utf8').digest()
|
|
61
|
+
return timingSafeEqual(actual, expected)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Extract a bearer token from an `authorization` header.
|
|
66
|
+
* @param {import('node:http').IncomingMessage} req - incoming request.
|
|
67
|
+
* @returns {string | null} the token, or null when the header is absent/malformed.
|
|
68
|
+
*/
|
|
69
|
+
export function bearerToken(req) {
|
|
70
|
+
const header = req?.headers?.authorization
|
|
71
|
+
if (typeof header !== 'string') return null
|
|
72
|
+
const match = /^bearer[ \t]+(\S+)[ \t]*$/i.exec(header.trim())
|
|
73
|
+
return match === null ? null : match[1]
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Whether an ISO timestamp is at or before `nowMs`.
|
|
78
|
+
* @param {string} iso - timestamp to test.
|
|
79
|
+
* @param {number} nowMs - current epoch milliseconds.
|
|
80
|
+
* @returns {boolean} true when the timestamp is missing/unparseable or in the past.
|
|
81
|
+
*/
|
|
82
|
+
export function isExpired(iso, nowMs) {
|
|
83
|
+
if (typeof iso !== 'string') return true
|
|
84
|
+
const at = Date.parse(iso)
|
|
85
|
+
return !Number.isFinite(at) || at <= nowMs
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Normalize a display name to 1–64 visible characters (trimmed, control
|
|
90
|
+
* characters removed). Returns null when the result is unacceptable, so the
|
|
91
|
+
* caller can answer 400 instead of storing garbage.
|
|
92
|
+
* @param {unknown} raw - candidate from a request body.
|
|
93
|
+
* @returns {string | null} the normalized name.
|
|
94
|
+
*/
|
|
95
|
+
export function normalizeDisplayName(raw) {
|
|
96
|
+
if (typeof raw !== 'string') return null
|
|
97
|
+
const cleaned = raw.replace(/[\u0000-\u001f\u007f]/g, ' ').replace(/\s+/g, ' ').trim()
|
|
98
|
+
if (cleaned === '') return null
|
|
99
|
+
if ([...cleaned].length > 64) return null
|
|
100
|
+
return cleaned
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Human platform label for a Node `os.platform()` value. */
|
|
104
|
+
export function platformLabel(osName) {
|
|
105
|
+
switch (String(osName)) {
|
|
106
|
+
case 'darwin': return 'macOS'
|
|
107
|
+
case 'linux': return 'Linux'
|
|
108
|
+
case 'win32': return 'Windows'
|
|
109
|
+
default: return 'Unknown'
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Coerce an optional string field from a request body.
|
|
115
|
+
* @param {unknown} value - candidate.
|
|
116
|
+
* @param {number} maxLength - inclusive character cap.
|
|
117
|
+
* @returns {string | null} the value or null.
|
|
118
|
+
*/
|
|
119
|
+
export function optionalString(value, maxLength = 256) {
|
|
120
|
+
if (typeof value !== 'string') return null
|
|
121
|
+
const trimmed = value.trim()
|
|
122
|
+
if (trimmed === '' || trimmed.length > maxLength) return null
|
|
123
|
+
return trimmed
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Coerce an optional non-negative integer field from a request body.
|
|
128
|
+
* @param {unknown} value - candidate.
|
|
129
|
+
* @returns {number | null} the value or null.
|
|
130
|
+
*/
|
|
131
|
+
export function optionalCount(value) {
|
|
132
|
+
if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0) return null
|
|
133
|
+
return value
|
|
134
|
+
}
|