dsh-server-connector 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/routes.js CHANGED
@@ -1,1272 +1,1527 @@
1
- // dsh-server-connector — HTTP routes for the Client half (design doc §8.5).
2
- //
3
- // Eleven endpoints read and write the same units the tools use:
4
- //
5
- // GET /server-connector/servers list (no secrets)
6
- // POST /server-connector/servers create
7
- // PUT /server-connector/servers/:id update (off / 停用 go to the pending queue)
8
- // DELETE /server-connector/servers/:id delete *request* → pending queue
9
- // POST /server-connector/test connection test (outside a turn, no policy gate)
10
- // POST /server-connector/connect/:id open the pooled connection
11
- // POST /server-connector/disconnect/:id close it
12
- // GET /server-connector/audit?limit=20 audit tail
13
- // GET /server-connector/pending two-step queue
14
- // POST /server-connector/pending/:id/confirm
15
- // POST /server-connector/pending/:id/cancel
16
- //
17
- // Two rules shape this module:
18
- //
19
- // 1. **The panel has no turn**, so `ctx.approval` is unavailable (§11). Every
20
- // destructive operation therefore goes through the pending queue (§8.3):
21
- // nothing here deletes a server, sets `hostKeyMode: off` or disables a rule
22
- // directly. The handlers *request*; only `pending.confirm` applies.
23
- // 2. **The response never carries a secret** (§6.3). A stored password is a
24
- // credential reference the panel cannot read; the panel only learns whether
25
- // one is configured. A password arrives write-only on create/update and goes
26
- // straight to `ctx.credentials`.
27
- //
28
- // The HTTP layer is split from the handlers so the contract is testable without
29
- // a socket: {@link createApi} is a pure dispatcher over `{method, path, query,
30
- // body}` and {@link createHandler} is the thin node:http adapter.
31
-
32
- import { stat } from 'node:fs/promises'
33
-
34
- import { AuditError } from './audit.js'
35
- import { PendingError } from './pending.js'
36
- import { PoolError } from './pool.js'
37
- import { RULES, RULES_BY_ID, effectiveDecision } from './policy.js'
38
- import { DECISION_VALUES, RegistryError } from './registry.js'
39
- import { CONNECT_PROBE, parseProbe } from './tools.js'
40
-
41
- /** Mount path of every route below (doc §8.5). */
42
- export const API_PREFIX = '/server-connector'
43
-
44
- /** Largest request body the panel may send; the forms are small JSON objects. */
45
- export const MAX_BODY_BYTES = 64 * 1024
46
-
47
- /** Audit tail ceiling; the panel asks for 20, the API never reads more than this. */
48
- export const MAX_AUDIT_LIMIT = 200
49
-
50
- /**
51
- * The eleven endpoints of doc §8.5, in table order.
52
- *
53
- * `path` is relative to {@link API_PREFIX}; `:name` segments are parameters.
54
- * Tests and the handler's 405 response read this table, so it is the single
55
- * declaration of the surface.
56
- * @type {ReadonlyArray<{method: string, path: string, op: string}>}
57
- */
58
- export const API_ROUTES = Object.freeze([
59
- Object.freeze({ method: 'GET', path: '/servers', op: 'listServers' }),
60
- Object.freeze({ method: 'POST', path: '/servers', op: 'createServer' }),
61
- Object.freeze({ method: 'PUT', path: '/servers/:id', op: 'updateServer' }),
62
- Object.freeze({ method: 'DELETE', path: '/servers/:id', op: 'requestDelete' }),
63
- Object.freeze({ method: 'POST', path: '/test', op: 'testConnection' }),
64
- Object.freeze({ method: 'POST', path: '/connect/:id', op: 'connectServer' }),
65
- Object.freeze({ method: 'POST', path: '/disconnect/:id', op: 'disconnectServer' }),
66
- Object.freeze({ method: 'GET', path: '/audit', op: 'listAudit' }),
67
- Object.freeze({ method: 'GET', path: '/pending', op: 'listPending' }),
68
- Object.freeze({ method: 'POST', path: '/pending/:id/confirm', op: 'confirmPending' }),
69
- Object.freeze({ method: 'POST', path: '/pending/:id/cancel', op: 'cancelPending' }),
70
- ])
71
-
72
- /**
73
- * A handler failure carrying the status the browser should show.
74
- *
75
- * Codes are stable strings the panel may branch on; `message` is written for a
76
- * person and is safe to display (it never contains a secret).
77
- */
78
- export class HttpError extends Error {
79
- /**
80
- * @param {number} status - HTTP status code.
81
- * @param {string} code - stable machine-readable code, e.g. `INVALID_SERVER`.
82
- * @param {string} message - displayable message.
83
- * @param {Record<string, unknown>} [details] - extra fields for the panel.
84
- */
85
- constructor(status, code, message, details = {}) {
86
- super(message)
87
- this.name = 'HttpError'
88
- /** @type {number} */
89
- this.status = status
90
- /** @type {string} */
91
- this.code = code
92
- /** @type {Record<string, unknown>} */
93
- this.details = details
94
- }
95
- }
96
-
97
- /** @param {unknown} value @returns {value is Record<string, unknown>} */
98
- function isPlainObject(value) {
99
- return typeof value === 'object' && value !== null && !Array.isArray(value)
100
- }
101
-
102
- /**
103
- * @param {unknown} value
104
- * @param {string} field
105
- * @returns {string}
106
- */
107
- function requiredString(value, field) {
108
- if (typeof value !== 'string' || value.trim() === '') {
109
- throw new HttpError(400, 'INVALID_SERVER', `${field} must be a non-empty string.`)
110
- }
111
- return value.trim()
112
- }
113
-
114
- /**
115
- * @param {unknown} value
116
- * @param {string} field
117
- * @returns {string} an absolute POSIX path, as doc §8.4 requires.
118
- */
119
- function absolutePath(value, field) {
120
- if (typeof value !== 'string' || value.trim() === '') {
121
- throw new HttpError(400, 'INVALID_SERVER', `${field} must be a non-empty absolute path.`)
122
- }
123
- const path = value.trim()
124
- if (!path.startsWith('/')) {
125
- throw new HttpError(400, 'INVALID_SERVER', `${field} must be an absolute path.`)
126
- }
127
- return path
128
- }
129
-
130
- /**
131
- * A path on *this* machine, which is not necessarily a POSIX one.
132
- *
133
- * `auth.privateKeyPath` points at a local file the Host reads itself, so on
134
- * Windows it is an ordinary drive-letter (`C:\Users\me\.ssh\id_ed25519`) or
135
- * UNC (`\\server\share\key`) path. Requiring a leading `/` here rejected every
136
- * Windows path the panel could offer, which left key auth unusable on Windows
137
- * even though the Host-side file check accepted the very same path.
138
- *
139
- * `defaultWorkDir` deliberately keeps using {@link absolutePath}: it is a path
140
- * on the *remote* host, which is POSIX by contract.
141
- * @param {unknown} value
142
- * @param {string} field
143
- * @returns {string}
144
- */
145
- function localAbsolutePath(value, field) {
146
- if (typeof value !== 'string' || value.trim() === '') {
147
- throw new HttpError(400, 'INVALID_SERVER', `${field} must be a non-empty absolute path.`)
148
- }
149
- const path = value.trim()
150
- if (path.startsWith('/')) return path
151
- if (/^[A-Za-z]:[\\/]/.test(path)) return path
152
- if (path.startsWith('\\\\')) return path
153
- throw new HttpError(400, 'INVALID_SERVER', `${field} must be an absolute path.`)
154
- }
155
-
156
- /** The three auth kinds of doc §3.1. @type {ReadonlyArray<string>} */
157
- const AUTH_KINDS = Object.freeze(['key', 'password', 'agent'])
158
-
159
- /** The three host-key modes of doc §6.4. @type {ReadonlyArray<string>} */
160
- const HOST_KEY_MODES = Object.freeze(['accept-new', 'verify', 'off'])
161
-
162
- /**
163
- * Validate the public `auth` object of a create/update body (§3.1, §8.4).
164
- *
165
- * A password never appears here: it arrives in the separate write-only
166
- * `password` field and is stored through `ctx.credentials` (doc §6.3).
167
- * @param {unknown} value
168
- * @returns {{kind:'key', privateKeyPath:string}|{kind:'password'}|{kind:'agent'}}
169
- */
170
- function normalizeAuthBody(value) {
171
- if (value === undefined) return { kind: 'agent' }
172
- if (!isPlainObject(value)) throw new HttpError(400, 'INVALID_SERVER', 'auth must be an object.')
173
- const kind = value.kind
174
- if (kind === 'key') {
175
- for (const key of Object.keys(value)) {
176
- if (key !== 'kind' && key !== 'privateKeyPath') {
177
- throw new HttpError(400, 'INVALID_SERVER', `Unknown auth field "${key}".`)
178
- }
179
- }
180
- return { kind: 'key', privateKeyPath: localAbsolutePath(value.privateKeyPath, 'auth.privateKeyPath') }
181
- }
182
- if (kind === 'password') {
183
- for (const key of Object.keys(value)) {
184
- if (key !== 'kind') throw new HttpError(400, 'INVALID_SERVER', `Unknown auth field "${key}".`)
185
- }
186
- return { kind: 'password' }
187
- }
188
- if (kind === 'agent') {
189
- for (const key of Object.keys(value)) {
190
- if (key !== 'kind') throw new HttpError(400, 'INVALID_SERVER', `Unknown auth field "${key}".`)
191
- }
192
- return { kind: 'agent' }
193
- }
194
- throw new HttpError(400, 'INVALID_SERVER', `auth.kind must be one of ${AUTH_KINDS.join(', ')}.`)
195
- }
196
-
197
- /**
198
- * The server fields both mutations share, spelled once.
199
- *
200
- * The write-only credential is deliberately not in this list: it is a request
201
- * field, not a record field (`servers.json` never holds it, §6.3), so each
202
- * operation adds it where it applies.
203
- */
204
- const SHARED_BODY_FIELDS = Object.freeze(['name', 'host', 'port', 'username', 'auth', 'hostKeyMode', 'defaultWorkDir', 'policy'])
205
-
206
- /** Fields a create body may carry; everything else is rejected by name. */
207
- const CREATE_BODY_FIELDS = new Set([...SHARED_BODY_FIELDS, 'password', 'disableRules'])
208
-
209
- /** Fields an update body may carry. `disabledRules` is listed only to explain its refusal. */
210
- const UPDATE_BODY_FIELDS = new Set([...SHARED_BODY_FIELDS, 'password', 'enableRules', 'disableRules', 'disabledRules'])
211
-
212
- /**
213
- * Reject a body key the panel has no business sending.
214
- *
215
- * This is where a plaintext secret would arrive by mistake (`auth.passphrase`,
216
- * a top-level `secret`): silently ignoring it would let the panel believe it was
217
- * stored, so an unknown key is a 400 instead.
218
- * @param {Record<string, unknown>} body
219
- * @param {ReadonlySet<string>} allowed
220
- * @returns {void}
221
- */
222
- function assertKnownBodyFields(body, allowed) {
223
- for (const key of Object.keys(body)) {
224
- if (allowed.has(key)) continue
225
- throw new HttpError(400, 'INVALID_SERVER', `Unknown field "${key}" in the request body.`)
226
- }
227
- }
228
-
229
- /**
230
- * The decisions a rule may take from the panel (§4.2.3, §8.4).
231
- *
232
- * A built-in `block` rule may be lowered to `confirm` but never straight to
233
- * `allow`; a `confirm` rule may be relaxed to `allow`, tightened to `block`, or
234
- * switched off through the pending queue.
235
- * @param {{defaultDecision: string}} rule
236
- * @returns {string[]}
237
- */
238
- export function allowedDecisions(rule) {
239
- return rule.defaultDecision === 'block' ? ['confirm', 'block'] : ['allow', 'confirm', 'block']
240
- }
241
-
242
- /**
243
- * Validate one `policy` map from the panel.
244
- *
245
- * Unknown rule ids, non-decision values and a `block → allow` jump are rejected
246
- * here rather than silently coerced by the engine: the panel must never store a
247
- * setting it cannot honour.
248
- * @param {unknown} value
249
- * @returns {Record<string, 'allow'|'confirm'|'block'>}
250
- */
251
- function normalizePolicyBody(value) {
252
- if (!isPlainObject(value)) throw new HttpError(400, 'INVALID_POLICY', 'policy must be an object.')
253
- /** @type {Record<string, 'allow'|'confirm'|'block'>} */
254
- const policy = {}
255
- for (const [ruleId, decision] of Object.entries(value)) {
256
- if (!isKnownRule(ruleId)) {
257
- throw new HttpError(400, 'INVALID_POLICY', `Unknown high-risk rule "${ruleId}".`)
258
- }
259
- if (decision !== 'allow' && decision !== 'confirm' && decision !== 'block') {
260
- throw new HttpError(400, 'INVALID_POLICY', `policy.${ruleId} must be one of ${DECISION_VALUES.join(', ')}.`)
261
- }
262
- if (!allowedDecisions(/** @type {{defaultDecision: string}} */ (ruleOf(ruleId))).includes(decision)) {
263
- throw new HttpError(
264
- 400,
265
- 'INVALID_POLICY',
266
- `policy.${ruleId}: a built-in "block" rule can be lowered to "confirm" but not to "allow".`,
267
- )
268
- }
269
- policy[ruleId] = decision
270
- }
271
- return policy
272
- }
273
-
274
- /**
275
- * Resolve one built-in rule id or fail with 400.
276
- * @param {string} ruleId
277
- * @returns {import('./policy.js').Rule}
278
- */
279
- function ruleOf(ruleId) {
280
- const rule = RULES_BY_ID.get(ruleId)
281
- if (rule === undefined) throw new HttpError(400, 'INVALID_POLICY', `Unknown high-risk rule "${ruleId}".`)
282
- return rule
283
- }
284
-
285
- /**
286
- * @param {unknown} ruleId
287
- * @returns {boolean}
288
- */
289
- function isKnownRule(ruleId) {
290
- return typeof ruleId === 'string' && RULES_BY_ID.has(ruleId)
291
- }
292
-
293
- /**
294
- * The public catalog the panel renders one row per rule from (§8.4).
295
- * @returns {Array<Record<string, unknown>>}
296
- */
297
- export function ruleCatalog() {
298
- return RULES.map((rule) => ({
299
- id: rule.id,
300
- label: rule.label,
301
- description: rule.description,
302
- defaultDecision: rule.defaultDecision,
303
- allowedDecisions: allowedDecisions(rule),
304
- }))
305
- }
306
-
307
- /**
308
- * Per-rule state of one server (§8.4 "高危策略" table).
309
- * @param {import('./registry.js').ServerRecord} server
310
- * @returns {Record<string, {configured: string|null, decision: string|null, disabled: boolean}>}
311
- */
312
- function policyStateOf(server) {
313
- /** @type {Record<string, {configured: string|null, decision: string|null, disabled: boolean}>} */
314
- const state = {}
315
- for (const rule of RULES) {
316
- const disabled = server.disabledRules.includes(rule.id)
317
- const configured = server.policy[rule.id]
318
- state[rule.id] = {
319
- configured: configured ?? null,
320
- decision: disabled ? null : effectiveDecision(rule, server.policy),
321
- disabled,
322
- }
323
- }
324
- return state
325
- }
326
-
327
- /**
328
- * Public view of one server: everything the panel needs, no secret, and
329
- * `passwordConfigured` instead of the credential reference (§6.3).
330
- * @param {import('./registry.js').ServerRecord} server
331
- * @param {{connected?: boolean, passwordConfigured?: boolean|null}} [extra]
332
- * @returns {Record<string, unknown>}
333
- */
334
- export function presentServer(server, extra = {}) {
335
- return {
336
- id: server.id,
337
- name: server.name,
338
- host: server.host,
339
- port: server.port,
340
- username: server.username,
341
- auth:
342
- server.auth.kind === 'key'
343
- ? { kind: 'key', privateKeyPath: server.auth.privateKeyPath }
344
- : { kind: server.auth.kind },
345
- hostKeyMode: server.hostKeyMode,
346
- defaultWorkDir: server.defaultWorkDir,
347
- policy: server.policy,
348
- disabledRules: server.disabledRules,
349
- createdAt: server.createdAt,
350
- lastConnectedAt: server.lastConnectedAt,
351
- connected: extra.connected === true,
352
- passwordConfigured: extra.passwordConfigured ?? null,
353
- policyState: policyStateOf(server),
354
- }
355
- }
356
-
357
- /**
358
- * Read the host-key mode of a body, defaulting to the current value.
359
- * @param {unknown} value
360
- * @returns {'accept-new'|'verify'|'off'}
361
- */
362
- function hostKeyModeOf(value) {
363
- if (value === undefined) return /** @type {any} */ (undefined)
364
- if (typeof value !== 'string' || !HOST_KEY_MODES.includes(value)) {
365
- throw new HttpError(400, 'INVALID_SERVER', `hostKeyMode must be one of ${HOST_KEY_MODES.join(', ')}.`)
366
- }
367
- return /** @type {'accept-new'|'verify'|'off'} */ (value)
368
- }
369
-
370
- /**
371
- * @param {unknown} value
372
- * @returns {string|null}
373
- */
374
- function workDirOf(value) {
375
- if (value === undefined || value === null || value === '') return null
376
- return absolutePath(value, 'defaultWorkDir')
377
- }
378
-
379
- /**
380
- * Map a domain error to an HTTP failure (§5.5 codes stay visible to the panel).
381
- * @param {unknown} error
382
- * @returns {HttpError}
383
- */
384
- export function toHttpError(error) {
385
- if (error instanceof HttpError) return error
386
- if (error instanceof RegistryError) {
387
- const status =
388
- error.code === 'UNKNOWN_SERVER'
389
- ? 404
390
- : error.code === 'DUPLICATE_NAME' || error.code === 'AMBIGUOUS_SERVER'
391
- ? 409
392
- : error.code === 'INVALID_REGISTRY'
393
- ? 500
394
- : 400
395
- return new HttpError(status, error.code, error.message)
396
- }
397
- if (error instanceof PendingError) {
398
- const status =
399
- error.code === 'UNKNOWN_PENDING'
400
- ? 404
401
- : error.code === 'EXPIRED_PENDING'
402
- ? 410
403
- : error.code === 'PENDING_UNAVAILABLE'
404
- ? 503
405
- : error.code === 'APPLY_FAILED'
406
- ? 502
407
- : 400
408
- return new HttpError(status, error.code, error.message)
409
- }
410
- if (error instanceof PoolError) {
411
- const status =
412
- error.code === 'HOST_KEY_UNKNOWN' || error.code === 'NO_CREDENTIAL'
413
- ? 409
414
- : error.code === 'TIMEOUT'
415
- ? 504
416
- : error.code === 'INVALID_SERVER'
417
- ? 400
418
- : 502
419
- return new HttpError(status, error.code, error.message, {
420
- ...(error.host === undefined ? {} : { host: error.host }),
421
- ...(error.serverId === undefined ? {} : { serverId: error.serverId }),
422
- })
423
- }
424
- if (error instanceof AuditError) return new HttpError(500, error.code, error.message)
425
- const message = error instanceof Error ? error.message : String(error)
426
- return new HttpError(500, 'INTERNAL', message)
427
- }
428
-
429
- /**
430
- * Build the route handlers over the plugin's units.
431
- *
432
- * Everything is injected, so this module knows nothing about cordis: the tests
433
- * build the same object with a real `Registry`, `Pool`, `AuditLog` and
434
- * `PendingQueue` over temporary files (doc §2.2).
435
- * @param {{
436
- * registry: import('./registry.js').Registry,
437
- * pool: {connect: Function, exec: Function, disconnect: Function, connectedServerIds: Function},
438
- * audit?: import('./audit.js').AuditLog|null,
439
- * pending?: import('./pending.js').PendingQueue|null,
440
- * getCredentials?: () => ({describe?: Function, set?: Function, unset?: Function}|null|undefined),
441
- * config?: Partial<import('./index.js').PluginConfig>,
442
- * onChange?: () => (void|Promise<void>)|null,
443
- * logger?: {info?: Function, warn?: Function, error?: Function}|null,
444
- * }} options
445
- * @returns {{dispatch: (request: {method: string, path: string, query?: Record<string, string>, body?: unknown}) => Promise<{status: number, body: unknown}>, describe: () => ReadonlyArray<{method: string, path: string}>}}
446
- */
447
- export function createApi(options) {
448
- if (options?.registry === undefined || options.registry === null) throw new TypeError('createApi requires a registry.')
449
- if (options?.pool === undefined || options.pool === null) throw new TypeError('createApi requires a pool.')
450
- const { registry, pool, audit = null, pending = null, logger = null } = options
451
- const config = options.config ?? {}
452
- const onChange = typeof options.onChange === 'function' ? options.onChange : null
453
- const getCredentials = typeof options.getCredentials === 'function' ? options.getCredentials : () => null
454
- const probeTimeoutMs = Math.min(Number(config.commandTimeoutMs ?? 60000), 15000)
455
-
456
- /**
457
- * Report one non-fatal failure without changing the request outcome.
458
- * @param {string} message - printf-style format.
459
- * @param {...unknown} args
460
- * @returns {void}
461
- */
462
- const warn = (message, ...args) => {
463
- const method = logger === null ? undefined : logger.warn
464
- if (typeof method !== 'function') return
465
- method.call(
466
- logger,
467
- message,
468
- ...args.map((value) => (value instanceof Error ? value.message : String(value))),
469
- )
470
- }
471
-
472
- /** Refresh the system-prompt cache after a mutation (§9.4). */
473
- const notify = async () => {
474
- if (onChange === null) return
475
- try {
476
- await onChange()
477
- } catch (error) {
478
- warn('dsh-server-connector: could not refresh the server list after a panel change: %s', error)
479
- }
480
- }
481
-
482
- /**
483
- * Whether a password is stored for this server (`null` = cannot tell).
484
- * @param {import('./registry.js').ServerRecord} server
485
- * @returns {Promise<boolean|null>}
486
- */
487
- const passwordConfigured = async (server) => {
488
- if (server.auth.kind !== 'password') return null
489
- const credentials = getCredentials()
490
- if (credentials === null || credentials === undefined || typeof credentials.describe !== 'function') return null
491
- try {
492
- const info = await credentials.describe(server.auth.credentialRef)
493
- return info?.configured === true
494
- } catch (error) {
495
- warn('dsh-server-connector: could not describe the credential of %s: %s', server.id, error)
496
- return null
497
- }
498
- }
499
-
500
- /** @returns {Set<string>} ids with a live pooled connection */
501
- const connectedIds = () => {
502
- try {
503
- return new Set(/** @type {string[]} */ (pool.connectedServerIds()))
504
- } catch {
505
- return new Set()
506
- }
507
- }
508
-
509
- /**
510
- * Load one server or fail with 404.
511
- * @param {string} id
512
- * @returns {Promise<import('./registry.js').ServerRecord>}
513
- */
514
- const requireServer = async (id) => {
515
- const server = await registry.get(id)
516
- if (server === null) {
517
- throw new HttpError(404, 'UNKNOWN_SERVER', `No server "${id}"; it may have been deleted.`)
518
- }
519
- return server
520
- }
521
-
522
- /**
523
- * Require the pending queue where an operation needs it.
524
- * @returns {import('./pending.js').PendingQueue}
525
- */
526
- const requirePending = () => {
527
- if (pending === null) {
528
- throw new HttpError(503, 'PENDING_UNAVAILABLE', 'This deployment has no pending queue, so dangerous operations are unavailable.')
529
- }
530
- return pending
531
- }
532
-
533
- /**
534
- * Write a password to the credential service (doc §6.3). The record only ever
535
- * holds the reference.
536
- * @param {import('./registry.js').ServerRecord} server
537
- * @param {unknown} value - the write-only body field; validated here.
538
- * @returns {Promise<void>}
539
- */
540
- const storePassword = async (server, value) => {
541
- if (server.auth.kind !== 'password') {
542
- throw new HttpError(400, 'INVALID_SERVER', 'password is only accepted for auth.kind "password".')
543
- }
544
- const credentials = getCredentials()
545
- if (credentials === null || credentials === undefined || typeof credentials.set !== 'function') {
546
- throw new HttpError(
547
- 503,
548
- 'CREDENTIALS_UNAVAILABLE',
549
- 'This deployment has no credential service, so a password cannot be stored. Use private-key or agent authentication.',
550
- )
551
- }
552
- if (typeof value !== 'string' || value === '') {
553
- throw new HttpError(400, 'INVALID_SERVER', 'password must be a non-empty string.')
554
- }
555
- await credentials.set(server.auth.credentialRef, value)
556
- }
557
-
558
- /**
559
- * Validate a key path on this machine (§8.4 "绝对路径、文件存在").
560
- * @param {string} path
561
- * @returns {Promise<void>}
562
- */
563
- const assertKeyFile = async (path) => {
564
- try {
565
- const info = await stat(path)
566
- if (!info.isFile()) {
567
- throw new HttpError(400, 'INVALID_SERVER', `auth.privateKeyPath ${path} is not a regular file.`)
568
- }
569
- } catch (error) {
570
- if (error instanceof HttpError) throw error
571
- throw new HttpError(400, 'INVALID_SERVER', `auth.privateKeyPath ${path} does not exist on this machine.`)
572
- }
573
- }
574
-
575
- /**
576
- * Queue one dangerous operation and report it (§8.3).
577
- * @param {import('./pending.js').PendingRequestInput} input
578
- * @returns {Promise<import('./pending.js').PendingRecord>}
579
- */
580
- const enqueue = async (input) => {
581
- const queue = requirePending()
582
- const { record } = await queue.request(input)
583
- return record
584
- }
585
-
586
- // ------------------------------------------------------------- handlers --
587
-
588
- /**
589
- * GET /servers — every server, the rule catalog, the public config and the
590
- * pending count, so the panel and the settings page need one round trip.
591
- * @returns {Promise<{status: number, body: unknown}>}
592
- */
593
- const listServers = async () => {
594
- const servers = await registry.list()
595
- const connected = connectedIds()
596
- /** @type {Array<Record<string, unknown>>} */
597
- const present = []
598
- for (const server of servers) {
599
- present.push(
600
- presentServer(server, { connected: connected.has(server.id), passwordConfigured: await passwordConfigured(server) }),
601
- )
602
- }
603
- /** @type {unknown[]} */
604
- let queued = []
605
- if (pending !== null) {
606
- try {
607
- queued = await pending.list()
608
- } catch (error) {
609
- warn('dsh-server-connector: could not read pending.json: %s', error)
610
- }
611
- }
612
- return {
613
- status: 200,
614
- body: {
615
- servers: present,
616
- rules: ruleCatalog(),
617
- config: {
618
- connectTimeoutMs: config.connectTimeoutMs ?? 15000,
619
- commandTimeoutMs: config.commandTimeoutMs ?? 60000,
620
- syncTimeoutMs: config.syncTimeoutMs ?? 20000,
621
- maxOutputChars: config.maxOutputChars ?? 200000,
622
- idleTimeoutMs: config.idleTimeoutMs ?? 600000,
623
- auditEnabled: config.auditEnabled ?? true,
624
- auditMaxBytes: config.auditMaxBytes ?? 0,
625
- pendingTtlHours: config.pendingTtlHours ?? 24,
626
- maxListedServers: config.maxListedServers ?? 8,
627
- },
628
- hostKeyModes: [...HOST_KEY_MODES],
629
- decisions: [...DECISION_VALUES],
630
- pendingCount: Array.isArray(queued) ? queued.length : 0,
631
- },
632
- }
633
- }
634
-
635
- /**
636
- * POST /servers — create.
637
- *
638
- * `hostKeyMode: "off"` obeys the same two-step rule as an update (§8.4/§8.3):
639
- * the record is created with `accept-new` and an `disable-hostkey` request is
640
- * queued, so choosing "off" never takes effect in the same click that creates
641
- * the server.
642
- * @param {unknown} body
643
- * @returns {Promise<{status: number, body: unknown}>}
644
- */
645
- const createServer = async (body) => {
646
- if (!isPlainObject(body)) throw new HttpError(400, 'INVALID_SERVER', 'Request body must be a JSON object.')
647
- assertKnownBodyFields(body, CREATE_BODY_FIELDS)
648
- const auth = normalizeAuthBody(body.auth)
649
- if (auth.kind !== 'password' && body.password !== undefined) {
650
- throw new HttpError(
651
- 400,
652
- 'INVALID_SERVER',
653
- 'A password is only accepted for auth.kind "password"; the record never stores a plaintext secret.',
654
- )
655
- }
656
- const requestedMode = hostKeyModeOf(body.hostKeyMode) ?? 'accept-new'
657
- if (auth.kind === 'key') await assertKeyFile(auth.privateKeyPath)
658
- if (auth.kind === 'password' && body.password === undefined) {
659
- throw new HttpError(400, 'INVALID_SERVER', 'A password server needs a password on create.')
660
- }
661
-
662
- /** @type {Record<string, unknown>} */
663
- const input = {
664
- name: requiredString(body.name, 'name'),
665
- host: requiredString(body.host, 'host'),
666
- username: requiredString(body.username, 'username'),
667
- auth,
668
- // §8.3: "off" is never the value a record starts with; it is requested.
669
- hostKeyMode: requestedMode === 'off' ? 'accept-new' : requestedMode,
670
- defaultWorkDir: workDirOf(body.defaultWorkDir),
671
- policy: body.policy === undefined ? {} : normalizePolicyBody(body.policy),
672
- }
673
- if (body.port !== undefined) input.port = body.port
674
- const server = await registry.create(input)
675
- try {
676
- if (auth.kind === 'password') await storePassword(server, body.password)
677
- } catch (error) {
678
- // The record without its password would be unusable; undo the create.
679
- await registry.remove(server.id).catch((cleanup) => {
680
- warn('dsh-server-connector: could not roll back server %s: %s', server.id, cleanup)
681
- })
682
- throw error
683
- }
684
- /** @type {import('./pending.js').PendingRecord[]} */
685
- const queued = []
686
- if (requestedMode === 'off') queued.push(await enqueue({ kind: 'disable-hostkey', serverId: server.id }))
687
- if (body.disableRules !== undefined) {
688
- if (!Array.isArray(body.disableRules)) {
689
- throw new HttpError(400, 'INVALID_POLICY', 'disableRules must be an array of rule ids.')
690
- }
691
- for (const ruleId of body.disableRules) {
692
- if (!isKnownRule(ruleId)) {
693
- throw new HttpError(400, 'INVALID_POLICY', `Unknown high-risk rule ${JSON.stringify(ruleId)}.`)
694
- }
695
- queued.push(await enqueue({ kind: 'disable-rule', serverId: server.id, ruleId }))
696
- }
697
- }
698
- await notify()
699
- return {
700
- status: queued.length > 0 ? 202 : 201,
701
- body: {
702
- server: presentServer(server, { passwordConfigured: await passwordConfigured(server) }),
703
- pending: queued,
704
- applied: queued.length === 0,
705
- ...(queued.length === 0
706
- ? {}
707
- : { note: 'The server was created with host-key verification on; switching it off is queued and needs confirmation.' }),
708
- },
709
- }
710
- }
711
-
712
- /**
713
- * PUT /servers/:id — update.
714
- *
715
- * `hostKeyMode: "off"`, `disableRules` and rule *enabling* have different
716
- * lifetimes: the first two go to the pending queue (§8.3) and are **not**
717
- * applied, re-enabling a rule is safe and applies immediately.
718
- * @param {string} id
719
- * @param {unknown} body
720
- * @returns {Promise<{status: number, body: unknown}>}
721
- */
722
- const updateServer = async (id, body) => {
723
- if (!isPlainObject(body)) throw new HttpError(400, 'INVALID_SERVER', 'Request body must be a JSON object.')
724
- assertKnownBodyFields(body, UPDATE_BODY_FIELDS)
725
- if (body.disabledRules !== undefined) {
726
- throw new HttpError(
727
- 400,
728
- 'INVALID_PENDING',
729
- 'disabledRules cannot be written directly; switch a rule off with "disableRules" (pending) or on with "enableRules".',
730
- )
731
- }
732
- const existing = await requireServer(id)
733
- /** @type {Record<string, unknown>} */
734
- const patch = {}
735
-
736
- for (const field of ['name', 'host', 'username']) {
737
- if (body[field] !== undefined) patch[field] = requiredString(body[field], field)
738
- }
739
- if (body.port !== undefined) patch.port = body.port
740
- if (body.defaultWorkDir !== undefined) patch.defaultWorkDir = workDirOf(body.defaultWorkDir)
741
- if (body.policy !== undefined) patch.policy = normalizePolicyBody(body.policy)
742
-
743
- /** @type {{kind:'key', privateKeyPath:string}|{kind:'password'}|{kind:'agent'}|undefined} */
744
- let nextAuth
745
- if (body.auth !== undefined) {
746
- nextAuth = normalizeAuthBody(body.auth)
747
- if (nextAuth.kind === 'key') await assertKeyFile(nextAuth.privateKeyPath)
748
- patch.auth = nextAuth
749
- } else if (body.password !== undefined) {
750
- if (existing.auth.kind !== 'password') {
751
- throw new HttpError(400, 'INVALID_SERVER', 'password is only accepted for auth.kind "password".')
752
- }
753
- }
754
-
755
- const requested = hostKeyModeOf(body.hostKeyMode)
756
- /** @type {import('./pending.js').PendingRecord[]} */
757
- const queued = []
758
- /** @type {string} */
759
- let note = ''
760
- if (requested !== undefined && requested !== existing.hostKeyMode) {
761
- if (requested === 'off') {
762
- // §8.3: the server keeps connecting under its current policy.
763
- queued.push(await enqueue({ kind: 'disable-hostkey', serverId: existing.id }))
764
- note = 'hostKeyMode stays unchanged until the request is confirmed'
765
- } else {
766
- patch.hostKeyMode = requested
767
- }
768
- }
769
-
770
- /** @type {string[]} */
771
- const enableRules = []
772
- if (body.enableRules !== undefined) {
773
- if (!Array.isArray(body.enableRules)) {
774
- throw new HttpError(400, 'INVALID_POLICY', 'enableRules must be an array of rule ids.')
775
- }
776
- for (const ruleId of body.enableRules) {
777
- if (typeof ruleId !== 'string' || !isKnownRule(ruleId)) {
778
- throw new HttpError(400, 'INVALID_POLICY', `Unknown high-risk rule ${JSON.stringify(ruleId)}.`)
779
- }
780
- if (existing.disabledRules.includes(ruleId)) enableRules.push(ruleId)
781
- }
782
- }
783
- /** @type {string[]} */
784
- const disableRules = []
785
- if (body.disableRules !== undefined) {
786
- if (!Array.isArray(body.disableRules)) {
787
- throw new HttpError(400, 'INVALID_POLICY', 'disableRules must be an array of rule ids.')
788
- }
789
- for (const ruleId of body.disableRules) {
790
- if (typeof ruleId !== 'string' || !isKnownRule(ruleId)) {
791
- throw new HttpError(400, 'INVALID_POLICY', `Unknown high-risk rule ${JSON.stringify(ruleId)}.`)
792
- }
793
- // A rule the panel already disabled needs no second request; one being
794
- // re-enabled in this same call would contradict itself.
795
- if (existing.disabledRules.includes(ruleId) || enableRules.includes(ruleId)) continue
796
- disableRules.push(ruleId)
797
- }
798
- }
799
- for (const ruleId of disableRules) {
800
- queued.push(await enqueue({ kind: 'disable-rule', serverId: existing.id, ruleId }))
801
- }
802
-
803
- if (enableRules.length > 0) {
804
- patch.disabledRules = existing.disabledRules.filter((ruleId) => !enableRules.includes(ruleId))
805
- }
806
-
807
- /** @type {import('./registry.js').ServerRecord} */
808
- let server = existing
809
- if (Object.keys(patch).length > 0) server = await registry.update(existing.id, patch)
810
-
811
- if (body.password !== undefined) await storePassword(server, body.password)
812
-
813
- if (existing.auth.kind === 'password' && server.auth.kind !== 'password') {
814
- const credentials = getCredentials()
815
- if (credentials !== null && credentials !== undefined && typeof credentials.unset === 'function') {
816
- try {
817
- await credentials.unset(existing.auth.credentialRef)
818
- } catch (error) {
819
- warn('dsh-server-connector: could not remove the credential of %s: %s', existing.id, error)
820
- }
821
- }
822
- }
823
-
824
- await notify()
825
- const connected = connectedIds()
826
- return {
827
- status: queued.length > 0 ? 202 : 200,
828
- body: {
829
- server: presentServer(server, {
830
- connected: connected.has(server.id),
831
- passwordConfigured: await passwordConfigured(server),
832
- }),
833
- pending: queued,
834
- applied: queued.length === 0,
835
- ...(note === '' ? {} : { note }),
836
- },
837
- }
838
- }
839
-
840
- /**
841
- * DELETE /servers/:id — a delete *request* (§8.3). The server stays present
842
- * and usable; the snapshot keeps the pending row self-describing.
843
- * @param {string} id
844
- * @returns {Promise<{status: number, body: unknown}>}
845
- */
846
- const requestDelete = async (id) => {
847
- const server = await requireServer(id)
848
- const record = await enqueue({ kind: 'delete-server', serverId: server.id, snapshot: server })
849
- await notify()
850
- return {
851
- status: 202,
852
- body: {
853
- pending: record,
854
- server: presentServer(server, { connected: connectedIds().has(server.id) }),
855
- note: 'The server stays configured until the deletion is confirmed in Settings.',
856
- },
857
- }
858
- }
859
-
860
- /**
861
- * A connection probe: connect, run the §5.1 platform/shell/sudo probe, report.
862
- * @param {import('./registry.js').ServerRecord} server
863
- * @returns {Promise<Record<string, unknown>>}
864
- */
865
- const probe = async (server) => {
866
- const startedAt = Date.now()
867
- await pool.connect(server)
868
- const result = await pool.exec(server, { command: CONNECT_PROBE, timeoutMs: probeTimeoutMs })
869
- return { ...parseProbe(String(result.stdout ?? '')), durationMs: Date.now() - startedAt }
870
- }
871
-
872
- /**
873
- * POST /test — test one saved server. This is outside a turn, so no
874
- * `srv_exec` policy gate applies (doc §8.5).
875
- * @param {unknown} body
876
- * @returns {Promise<{status: number, body: unknown}>}
877
- */
878
- const testConnection = async (body) => {
879
- if (!isPlainObject(body)) throw new HttpError(400, 'INVALID_SERVER', 'Request body must be a JSON object.')
880
- const id = requiredString(body.id, 'id')
881
- const server = await requireServer(id)
882
- const outcome = await probe(server)
883
- try {
884
- await registry.update(server.id, { lastConnectedAt: new Date().toISOString() })
885
- } catch (error) {
886
- warn('dsh-server-connector: could not record lastConnectedAt of %s: %s', server.id, error)
887
- }
888
- await notify()
889
- return { status: 200, body: { ok: true, serverId: server.id, ...outcome } }
890
- }
891
-
892
- /**
893
- * POST /connect/:id — open and keep the pooled connection.
894
- * @param {string} id
895
- * @returns {Promise<{status: number, body: unknown}>}
896
- */
897
- const connectServer = async (id) => {
898
- const server = await requireServer(id)
899
- const outcome = await probe(server)
900
- try {
901
- await registry.update(server.id, { lastConnectedAt: new Date().toISOString() })
902
- } catch (error) {
903
- warn('dsh-server-connector: could not record lastConnectedAt of %s: %s', server.id, error)
904
- }
905
- await notify()
906
- return {
907
- status: 200,
908
- body: {
909
- serverId: server.id,
910
- connected: true,
911
- probe: outcome,
912
- server: presentServer(server, { connected: true, passwordConfigured: await passwordConfigured(server) }),
913
- },
914
- }
915
- }
916
-
917
- /**
918
- * POST /disconnect/:id
919
- * @param {string} id
920
- * @returns {Promise<{status: number, body: unknown}>}
921
- */
922
- const disconnectServer = async (id) => {
923
- const server = await requireServer(id)
924
- await pool.disconnect(server.id)
925
- return { status: 200, body: { serverId: server.id, connected: false } }
926
- }
927
-
928
- /**
929
- * GET /audit?limit=20[&serverId=…] — the newest records, newest first, so the
930
- * panel can render the detail page's last-20 list without sorting.
931
- * @param {Record<string, string>} query
932
- * @returns {Promise<{status: number, body: unknown}>}
933
- */
934
- const listAudit = async (query) => {
935
- if (audit === null) return { status: 200, body: { records: [] } }
936
- const rawLimit = query.limit === undefined ? 20 : Number(query.limit)
937
- if (!Number.isSafeInteger(rawLimit) || rawLimit <= 0) {
938
- throw new HttpError(400, 'INVALID_ARGUMENT', 'limit must be a positive integer.')
939
- }
940
- const limit = Math.min(rawLimit, MAX_AUDIT_LIMIT)
941
- const serverId = query.serverId
942
- // Filtering happens here rather than in the log: the tail is bounded and the
943
- // panel only ever asks for a handful of records.
944
- const records = await audit.tail(serverId === undefined ? limit : MAX_AUDIT_LIMIT)
945
- const filtered = serverId === undefined ? records : records.filter((record) => record.serverId === serverId)
946
- return { status: 200, body: { records: filtered.slice(-limit).reverse() } }
947
- }
948
-
949
- /**
950
- * The pending queue, enriched with the server name each row targets. A
951
- * `delete-server` snapshot is reduced to its display fields: the row's job is
952
- * to say *what* would be deleted, not to publish the record.
953
- * @returns {Promise<{status: number, body: unknown}>}
954
- */
955
- const listPending = async () => {
956
- if (pending === null) return { status: 200, body: { pending: [] } }
957
- const records = await pending.list()
958
- const servers = await registry.list()
959
- /** @type {Map<string, import('./registry.js').ServerRecord>} */
960
- const byId = new Map(servers.map((server) => [server.id, server]))
961
- return {
962
- status: 200,
963
- body: {
964
- pending: records.map((record) => {
965
- const live = byId.get(record.serverId)
966
- const snapshot = isPlainObject(record.snapshot) ? record.snapshot : null
967
- const name =
968
- typeof snapshot?.name === 'string'
969
- ? snapshot.name
970
- : live === undefined
971
- ? record.serverId
972
- : live.name
973
- /** @type {Record<string, unknown>} */
974
- const target = { present: live !== undefined }
975
- if (typeof snapshot?.host === 'string') target.host = snapshot.host
976
- else if (live !== undefined) target.host = live.host
977
- if (typeof snapshot?.port === 'number') target.port = snapshot.port
978
- else if (live !== undefined) target.port = live.port
979
- if (typeof snapshot?.username === 'string') target.username = snapshot.username
980
- else if (live !== undefined) target.username = live.username
981
- return { ...record, serverName: name, target }
982
- }),
983
- },
984
- }
985
- }
986
-
987
- /**
988
- * POST /pending/:id/confirm — apply one queued operation (§8.3).
989
- * @param {string} id
990
- * @returns {Promise<{status: number, body: unknown}>}
991
- */
992
- const confirmPending = async (id) => {
993
- const queue = requirePending()
994
- const outcome = await queue.confirm(id)
995
- await notify()
996
- return { status: 200, body: { status: outcome.status, record: outcome.record, result: outcome.result } }
997
- }
998
-
999
- /**
1000
- * POST /pending/:id/cancel — drop the request, leaving the target unchanged.
1001
- * @param {string} id
1002
- * @returns {Promise<{status: number, body: unknown}>}
1003
- */
1004
- const cancelPending = async (id) => {
1005
- const queue = requirePending()
1006
- const outcome = await queue.cancel(id)
1007
- await notify()
1008
- return { status: 200, body: { status: outcome.status, record: outcome.record } }
1009
- }
1010
-
1011
- /**
1012
- * Run one request.
1013
- * @param {{method: string, path: string, query?: Record<string, string>, body?: unknown}} request
1014
- * `path` is relative to {@link API_PREFIX} (`/servers/srv-01`).
1015
- * @returns {Promise<{status: number, body: unknown}>}
1016
- */
1017
- const dispatch = async (request) => {
1018
- const method = String(request.method ?? 'GET').toUpperCase()
1019
- const segments = String(request.path ?? '/')
1020
- .split('/')
1021
- .filter((segment) => segment !== '')
1022
- const query = request.query ?? {}
1023
-
1024
- // A path is matched first, then its method: a known path with an unexpected
1025
- // method is a caller bug (405), a path nothing declares is a missing endpoint
1026
- // (404). Both passes run over the same table, so the surface stays declared
1027
- // in exactly one place.
1028
- /** @type {typeof API_ROUTES[number]|null} */
1029
- let matched = null
1030
- /** @type {Record<string, string>|null} */
1031
- let matchedParams = null
1032
- /** @type {string[]} */
1033
- const methods = []
1034
- for (const route of API_ROUTES) {
1035
- const params = matchRoute(route.path, segments)
1036
- if (params === null) continue
1037
- methods.push(route.method)
1038
- if (route.method !== method || matched !== null) continue
1039
- matched = route
1040
- matchedParams = params
1041
- }
1042
- if (matched === null) {
1043
- if (methods.length === 0) {
1044
- throw new HttpError(404, 'NOT_FOUND', `No route for ${method} ${API_PREFIX}${request.path}.`)
1045
- }
1046
- const allow = methods.join(', ')
1047
- throw new HttpError(405, 'METHOD_NOT_ALLOWED', `${request.path} only supports ${allow}.`, { allow })
1048
- }
1049
- /** @type {Record<string, string>} */
1050
- const params = matchedParams ?? {}
1051
- const id = /** @type {string} */ (params.id)
1052
- switch (matched.op) {
1053
- case 'listServers':
1054
- return listServers()
1055
- case 'createServer':
1056
- return createServer(request.body)
1057
- case 'updateServer':
1058
- return updateServer(id, request.body)
1059
- case 'requestDelete':
1060
- return requestDelete(id)
1061
- case 'testConnection':
1062
- return testConnection(request.body)
1063
- case 'connectServer':
1064
- return connectServer(id)
1065
- case 'disconnectServer':
1066
- return disconnectServer(id)
1067
- case 'listAudit':
1068
- return listAudit(query)
1069
- case 'listPending':
1070
- return listPending()
1071
- case 'confirmPending':
1072
- return confirmPending(id)
1073
- case 'cancelPending':
1074
- return cancelPending(id)
1075
- default:
1076
- throw new HttpError(500, 'INTERNAL', `Unhandled route operation ${String(matched.op)}.`)
1077
- }
1078
- }
1079
-
1080
- return {
1081
- dispatch,
1082
- describe: () => API_ROUTES.map(({ method, path }) => ({ method, path: `${API_PREFIX}${path}` })),
1083
- }
1084
- }
1085
-
1086
- /**
1087
- * Match one route template against decoded path segments.
1088
- * @param {string} template - e.g. `/pending/:id/confirm`
1089
- * @param {string[]} segments - segments relative to the mount path.
1090
- * @returns {Record<string, string>|null} the parameters, or null when it does not match.
1091
- */
1092
- function matchRoute(template, segments) {
1093
- const parts = template.split('/').filter((part) => part !== '')
1094
- if (parts.length !== segments.length) return null
1095
- /** @type {Record<string, string>} */
1096
- const params = {}
1097
- for (let index = 0; index < parts.length; index += 1) {
1098
- const part = parts[index]
1099
- const segment = segments[index]
1100
- if (part === undefined || segment === undefined) return null
1101
- if (part.startsWith(':')) {
1102
- if (segment === '') return null
1103
- params[part.slice(1)] = segment
1104
- continue
1105
- }
1106
- if (part !== segment) return null
1107
- }
1108
- return params
1109
- }
1110
-
1111
- /**
1112
- * Collect one bounded JSON body.
1113
- *
1114
- * An absent body is `undefined` (the POST endpoints that take no arguments);
1115
- * anything past the ceiling is reported as 413 and the stream is drained so the
1116
- * socket does not stall.
1117
- * @param {import('node:http').IncomingMessage} req
1118
- * @param {number} [limit]
1119
- * @returns {Promise<unknown>}
1120
- */
1121
- export async function readJsonBody(req, limit = MAX_BODY_BYTES) {
1122
- /** @type {Buffer[]} */
1123
- const chunks = []
1124
- let size = 0
1125
- for await (const chunk of req) {
1126
- const buffer = typeof chunk === 'string' ? Buffer.from(chunk) : chunk
1127
- size += buffer.byteLength
1128
- if (size > limit) {
1129
- req.resume()
1130
- throw new HttpError(413, 'BODY_TOO_LARGE', `The request body exceeds ${String(limit)} bytes.`)
1131
- }
1132
- chunks.push(buffer)
1133
- }
1134
- const text = Buffer.concat(chunks).toString('utf8').trim()
1135
- if (text === '') return undefined
1136
- try {
1137
- return JSON.parse(text)
1138
- } catch {
1139
- throw new HttpError(400, 'INVALID_JSON', 'The request body is not valid JSON.')
1140
- }
1141
- }
1142
-
1143
- /**
1144
- * Send one JSON response. `no-store` because every payload is live state.
1145
- * @param {import('node:http').ServerResponse} res
1146
- * @param {number} status
1147
- * @param {unknown} body
1148
- * @param {Record<string, string>} [headers]
1149
- * @returns {void}
1150
- */
1151
- function sendJson(res, status, body, headers = {}) {
1152
- res.statusCode = status
1153
- res.setHeader('content-type', 'application/json; charset=utf-8')
1154
- res.setHeader('cache-control', 'no-store')
1155
- for (const [name, value] of Object.entries(headers)) res.setHeader(name, value)
1156
- res.end(JSON.stringify(body ?? null))
1157
- }
1158
-
1159
- /**
1160
- * The node:http adapter: parse, dispatch, serialise.
1161
- *
1162
- * `rejected` is the composition's own request fence (`connection.requestRejection`
1163
- * in the shipped Web app). The routes live on the same trust boundary as the
1164
- * GUI (doc §8.5), so a deployment that has the connection service refuses an
1165
- * unauthenticated or untrusted request here rather than in each handler.
1166
- * @param {ReturnType<typeof createApi>} api
1167
- * @param {{rejected?: (req: import('node:http').IncomingMessage) => number|undefined, logger?: {warn?: Function}|null}} [options]
1168
- * @returns {(req: import('node:http').IncomingMessage, res: import('node:http').ServerResponse) => Promise<void>}
1169
- */
1170
- export function createHandler(api, options = {}) {
1171
- const rejected = typeof options.rejected === 'function' ? options.rejected : () => undefined
1172
- const logger = options.logger ?? null
1173
- return async (req, res) => {
1174
- const rejection = rejected(req)
1175
- if (rejection !== undefined) {
1176
- res.statusCode = rejection
1177
- res.end(rejection === 401 ? 'unauthorized' : 'forbidden')
1178
- return
1179
- }
1180
- /** @type {URL} */
1181
- let url
1182
- try {
1183
- url = new URL(String(req.url ?? '/'), 'http://server-connector.invalid')
1184
- } catch {
1185
- sendJson(res, 400, { error: { code: 'INVALID_URL', message: 'The request URL could not be parsed.' } })
1186
- return
1187
- }
1188
- if (url.pathname !== API_PREFIX && !url.pathname.startsWith(`${API_PREFIX}/`)) {
1189
- sendJson(res, 404, { error: { code: 'NOT_FOUND', message: `No route for ${url.pathname}.` } })
1190
- return
1191
- }
1192
- // Decode each segment: the panel encodes ids, and a route parameter is the
1193
- // decoded id it addresses (`srv-01` today, but the adapter stays generic).
1194
- let relative = url.pathname.slice(API_PREFIX.length)
1195
- if (relative === '') relative = '/'
1196
- try {
1197
- relative = relative
1198
- .split('/')
1199
- .map((segment) => (segment === '' ? segment : decodeURIComponent(segment)))
1200
- .join('/')
1201
- } catch {
1202
- sendJson(res, 400, { error: { code: 'INVALID_URL', message: 'The request path is not valid percent-encoding.' } })
1203
- return
1204
- }
1205
- /** @type {Record<string, string>} */
1206
- const query = {}
1207
- for (const [name, value] of url.searchParams) query[name] = value
1208
- const method = String(req.method ?? 'GET').toUpperCase()
1209
- try {
1210
- const body = method === 'GET' || method === 'HEAD' ? undefined : await readJsonBody(req)
1211
- const outcome = await api.dispatch({
1212
- method,
1213
- path: relative,
1214
- query,
1215
- body,
1216
- })
1217
- sendJson(res, outcome.status, outcome.body)
1218
- } catch (error) {
1219
- const failure = toHttpError(error)
1220
- if (failure.status >= 500 && logger !== null && typeof logger.warn === 'function') {
1221
- logger.warn('dsh-server-connector: %s %s failed: %s', method, relative, failure.message)
1222
- }
1223
- /** @type {Record<string, string>} */
1224
- const headers = {}
1225
- if (typeof failure.details.allow === 'string') headers.allow = failure.details.allow
1226
- sendJson(
1227
- res,
1228
- failure.status,
1229
- {
1230
- error: {
1231
- code: failure.code,
1232
- message: failure.message,
1233
- ...(Object.keys(failure.details).length === 0 ? {} : { details: failure.details }),
1234
- },
1235
- },
1236
- headers,
1237
- )
1238
- }
1239
- }
1240
- }
1241
-
1242
- /**
1243
- * Register the routes on the Web carrier (§8.5).
1244
- *
1245
- * One prefix route serves all eleven endpoints: `webServer.register` patterns
1246
- * are exact or prefix-only, and a single prefix keeps method dispatch, 405 and
1247
- * 404 in one place.
1248
- * @param {import('@deepseek-ai/cordis').Context} ctx - context that carries `webServer`.
1249
- * @param {Parameters<typeof createApi>[0]} options
1250
- * @returns {{api: ReturnType<typeof createApi>, dispose: () => void}} the dispatcher and the route disposer.
1251
- */
1252
- export function registerRoutes(ctx, options) {
1253
- const api = createApi(options)
1254
- const handler = createHandler(api, {
1255
- // Read the fence per request: the connection service may register after
1256
- // this plugin, and a plugin reload can replace it.
1257
- rejected: (req) => {
1258
- const connection = /** @type {any} */ (ctx.get('connection'))
1259
- if (connection === null || connection === undefined) return undefined
1260
- if (typeof connection.requestRejection !== 'function') return undefined
1261
- return connection.requestRejection(req)
1262
- },
1263
- logger: ctx.logger ?? null,
1264
- })
1265
- const webServer = /** @type {any} */ (ctx).webServer
1266
- const dispose = webServer.register({
1267
- kind: 'prefix',
1268
- path: API_PREFIX,
1269
- handler,
1270
- })
1271
- return { api, dispose }
1272
- }
1
+ // dsh-server-connector — HTTP routes for the Client half (design doc §8.5).
2
+ //
3
+ // Eleven endpoints read and write the same units the tools use:
4
+ //
5
+ // GET /server-connector/servers list (no secrets)
6
+ // POST /server-connector/servers create
7
+ // PUT /server-connector/servers/:id update (off / 停用 go to the pending queue)
8
+ // DELETE /server-connector/servers/:id delete *request* → pending queue
9
+ // POST /server-connector/test connection test (outside a turn, no policy gate)
10
+ // POST /server-connector/connect/:id open the pooled connection
11
+ // POST /server-connector/disconnect/:id close it
12
+ // GET /server-connector/audit?limit=20 audit tail
13
+ // GET /server-connector/pending two-step queue
14
+ // POST /server-connector/pending/:id/confirm
15
+ // POST /server-connector/pending/:id/cancel
16
+ //
17
+ // Two rules shape this module:
18
+ //
19
+ // 1. **The panel has no turn**, so `ctx.approval` is unavailable (§11). Every
20
+ // destructive operation therefore goes through the pending queue (§8.3):
21
+ // nothing here deletes a server, sets `hostKeyMode: off` or disables a rule
22
+ // directly. The handlers *request*; only `pending.confirm` applies.
23
+ // 2. **The response never carries a secret** (§6.3). A stored password is a
24
+ // credential reference the panel cannot read; the panel only learns whether
25
+ // one is configured. A password arrives write-only on create/update and goes
26
+ // straight to `ctx.credentials`.
27
+ //
28
+ // The HTTP layer is split from the handlers so the contract is testable without
29
+ // a socket: {@link createApi} is a pure dispatcher over `{method, path, query,
30
+ // body}` and {@link createHandler} is the thin node:http adapter.
31
+
32
+ import { stat } from 'node:fs/promises'
33
+
34
+ import { AuditError } from './audit.js'
35
+ import { PendingError } from './pending.js'
36
+ import { PoolError } from './pool.js'
37
+ import { MAX_CUSTOM_RULES, RULES, RULES_BY_ID, RuleError, check as checkPolicy, effectiveDecision, validateCustomRule } from './policy.js'
38
+ import { DECISION_VALUES, RegistryError } from './registry.js'
39
+ import { CONNECT_PROBE, parseProbe } from './tools.js'
40
+
41
+ /** Mount path of every route below (doc §8.5). */
42
+ export const API_PREFIX = '/server-connector'
43
+
44
+ /** Largest request body the panel may send; the forms are small JSON objects. */
45
+ export const MAX_BODY_BYTES = 64 * 1024
46
+
47
+ /** Audit tail ceiling; the panel asks for 20, the API never reads more than this. */
48
+ export const MAX_AUDIT_LIMIT = 200
49
+
50
+ /**
51
+ * The eleven endpoints of doc §8.5, in table order.
52
+ *
53
+ * `path` is relative to {@link API_PREFIX}; `:name` segments are parameters.
54
+ * Tests and the handler's 405 response read this table, so it is the single
55
+ * declaration of the surface.
56
+ * @type {ReadonlyArray<{method: string, path: string, op: string}>}
57
+ */
58
+ export const API_ROUTES = Object.freeze([
59
+ Object.freeze({ method: 'GET', path: '/servers', op: 'listServers' }),
60
+ Object.freeze({ method: 'POST', path: '/servers', op: 'createServer' }),
61
+ Object.freeze({ method: 'PUT', path: '/servers/:id', op: 'updateServer' }),
62
+ Object.freeze({ method: 'DELETE', path: '/servers/:id', op: 'requestDelete' }),
63
+ Object.freeze({ method: 'POST', path: '/test', op: 'testConnection' }),
64
+ Object.freeze({ method: 'POST', path: '/connect/:id', op: 'connectServer' }),
65
+ Object.freeze({ method: 'POST', path: '/disconnect/:id', op: 'disconnectServer' }),
66
+ Object.freeze({ method: 'GET', path: '/audit', op: 'listAudit' }),
67
+ Object.freeze({ method: 'GET', path: '/pending', op: 'listPending' }),
68
+ Object.freeze({ method: 'POST', path: '/pending/:id/confirm', op: 'confirmPending' }),
69
+ Object.freeze({ method: 'POST', path: '/pending/:id/cancel', op: 'cancelPending' }),
70
+ Object.freeze({ method: 'POST', path: '/servers/:id/rules', op: 'createRule' }),
71
+ Object.freeze({ method: 'PUT', path: '/servers/:id/rules/:ruleId', op: 'updateRule' }),
72
+ Object.freeze({ method: 'DELETE', path: '/servers/:id/rules/:ruleId', op: 'deleteRule' }),
73
+ Object.freeze({ method: 'POST', path: '/servers/:id/rules/preview', op: 'previewRule' }),
74
+ ])
75
+
76
+ /**
77
+ * A handler failure carrying the status the browser should show.
78
+ *
79
+ * Codes are stable strings the panel may branch on; `message` is written for a
80
+ * person and is safe to display (it never contains a secret).
81
+ */
82
+ export class HttpError extends Error {
83
+ /**
84
+ * @param {number} status - HTTP status code.
85
+ * @param {string} code - stable machine-readable code, e.g. `INVALID_SERVER`.
86
+ * @param {string} message - displayable message.
87
+ * @param {Record<string, unknown>} [details] - extra fields for the panel.
88
+ */
89
+ constructor(status, code, message, details = {}) {
90
+ super(message)
91
+ this.name = 'HttpError'
92
+ /** @type {number} */
93
+ this.status = status
94
+ /** @type {string} */
95
+ this.code = code
96
+ /** @type {Record<string, unknown>} */
97
+ this.details = details
98
+ }
99
+ }
100
+
101
+ /** @param {unknown} value @returns {value is Record<string, unknown>} */
102
+ function isPlainObject(value) {
103
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
104
+ }
105
+
106
+ /**
107
+ * @param {unknown} value
108
+ * @param {string} field
109
+ * @returns {string}
110
+ */
111
+ function requiredString(value, field) {
112
+ if (typeof value !== 'string' || value.trim() === '') {
113
+ throw new HttpError(400, 'INVALID_SERVER', `${field} must be a non-empty string.`)
114
+ }
115
+ return value.trim()
116
+ }
117
+
118
+ /**
119
+ * @param {unknown} value
120
+ * @param {string} field
121
+ * @returns {string} an absolute POSIX path, as doc §8.4 requires.
122
+ */
123
+ function absolutePath(value, field) {
124
+ if (typeof value !== 'string' || value.trim() === '') {
125
+ throw new HttpError(400, 'INVALID_SERVER', `${field} must be a non-empty absolute path.`)
126
+ }
127
+ const path = value.trim()
128
+ if (!path.startsWith('/')) {
129
+ throw new HttpError(400, 'INVALID_SERVER', `${field} must be an absolute path.`)
130
+ }
131
+ return path
132
+ }
133
+
134
+ /**
135
+ * A path on *this* machine, which is not necessarily a POSIX one.
136
+ *
137
+ * `auth.privateKeyPath` points at a local file the Host reads itself, so on
138
+ * Windows it is an ordinary drive-letter (`C:\Users\me\.ssh\id_ed25519`) or
139
+ * UNC (`\\server\share\key`) path. Requiring a leading `/` here rejected every
140
+ * Windows path the panel could offer, which left key auth unusable on Windows
141
+ * even though the Host-side file check accepted the very same path.
142
+ *
143
+ * `defaultWorkDir` deliberately keeps using {@link absolutePath}: it is a path
144
+ * on the *remote* host, which is POSIX by contract.
145
+ * @param {unknown} value
146
+ * @param {string} field
147
+ * @returns {string}
148
+ */
149
+ function localAbsolutePath(value, field) {
150
+ if (typeof value !== 'string' || value.trim() === '') {
151
+ throw new HttpError(400, 'INVALID_SERVER', `${field} must be a non-empty absolute path.`)
152
+ }
153
+ const path = value.trim()
154
+ if (path.startsWith('/')) return path
155
+ if (/^[A-Za-z]:[\\/]/.test(path)) return path
156
+ if (path.startsWith('\\\\')) return path
157
+ throw new HttpError(400, 'INVALID_SERVER', `${field} must be an absolute path.`)
158
+ }
159
+
160
+ /** The three auth kinds of doc §3.1. @type {ReadonlyArray<string>} */
161
+ const AUTH_KINDS = Object.freeze(['key', 'password', 'agent'])
162
+
163
+ /** The three host-key modes of doc §6.4. @type {ReadonlyArray<string>} */
164
+ const HOST_KEY_MODES = Object.freeze(['accept-new', 'verify', 'off'])
165
+
166
+ /**
167
+ * Validate the public `auth` object of a create/update body (§3.1, §8.4).
168
+ *
169
+ * A password never appears here: it arrives in the separate write-only
170
+ * `password` field and is stored through `ctx.credentials` (doc §6.3).
171
+ * @param {unknown} value
172
+ * @returns {{kind:'key', privateKeyPath:string}|{kind:'password'}|{kind:'agent'}}
173
+ */
174
+ function normalizeAuthBody(value) {
175
+ if (value === undefined) return { kind: 'agent' }
176
+ if (!isPlainObject(value)) throw new HttpError(400, 'INVALID_SERVER', 'auth must be an object.')
177
+ const kind = value.kind
178
+ if (kind === 'key') {
179
+ for (const key of Object.keys(value)) {
180
+ if (key !== 'kind' && key !== 'privateKeyPath') {
181
+ throw new HttpError(400, 'INVALID_SERVER', `Unknown auth field "${key}".`)
182
+ }
183
+ }
184
+ return { kind: 'key', privateKeyPath: localAbsolutePath(value.privateKeyPath, 'auth.privateKeyPath') }
185
+ }
186
+ if (kind === 'password') {
187
+ for (const key of Object.keys(value)) {
188
+ if (key !== 'kind') throw new HttpError(400, 'INVALID_SERVER', `Unknown auth field "${key}".`)
189
+ }
190
+ return { kind: 'password' }
191
+ }
192
+ if (kind === 'agent') {
193
+ for (const key of Object.keys(value)) {
194
+ if (key !== 'kind') throw new HttpError(400, 'INVALID_SERVER', `Unknown auth field "${key}".`)
195
+ }
196
+ return { kind: 'agent' }
197
+ }
198
+ throw new HttpError(400, 'INVALID_SERVER', `auth.kind must be one of ${AUTH_KINDS.join(', ')}.`)
199
+ }
200
+
201
+ /**
202
+ * The server fields both mutations share, spelled once.
203
+ *
204
+ * The write-only credential is deliberately not in this list: it is a request
205
+ * field, not a record field (`servers.json` never holds it, §6.3), so each
206
+ * operation adds it where it applies.
207
+ */
208
+ const SHARED_BODY_FIELDS = Object.freeze(['name', 'host', 'port', 'username', 'auth', 'hostKeyMode', 'defaultWorkDir', 'policy', 'enabled'])
209
+
210
+ /** Fields a create body may carry; everything else is rejected by name. */
211
+ const CREATE_BODY_FIELDS = new Set([...SHARED_BODY_FIELDS, 'password', 'disableRules'])
212
+
213
+ /** Fields an update body may carry. `disabledRules` is listed only to explain its refusal. */
214
+ const UPDATE_BODY_FIELDS = new Set([...SHARED_BODY_FIELDS, 'password', 'enableRules', 'disableRules', 'disabledRules'])
215
+
216
+ /**
217
+ * Reject a body key the panel has no business sending.
218
+ *
219
+ * This is where a plaintext secret would arrive by mistake (`auth.passphrase`,
220
+ * a top-level `secret`): silently ignoring it would let the panel believe it was
221
+ * stored, so an unknown key is a 400 instead.
222
+ * @param {Record<string, unknown>} body
223
+ * @param {ReadonlySet<string>} allowed
224
+ * @returns {void}
225
+ */
226
+ function assertKnownBodyFields(body, allowed) {
227
+ for (const key of Object.keys(body)) {
228
+ if (allowed.has(key)) continue
229
+ throw new HttpError(400, 'INVALID_SERVER', `Unknown field "${key}" in the request body.`)
230
+ }
231
+ }
232
+
233
+ /**
234
+ * The decisions a rule may take from the panel (§4.2.3, §8.4).
235
+ *
236
+ * A built-in `block` rule may be lowered to `confirm` but never straight to
237
+ * `allow`; a `confirm` rule may be relaxed to `allow`, tightened to `block`, or
238
+ * switched off through the pending queue.
239
+ * @param {{defaultDecision: string}} rule
240
+ * @returns {string[]}
241
+ */
242
+ export function allowedDecisions(rule) {
243
+ return rule.defaultDecision === 'block' ? ['confirm', 'block'] : ['allow', 'confirm', 'block']
244
+ }
245
+
246
+ /**
247
+ * Validate one `policy` map from the panel.
248
+ *
249
+ * Unknown rule ids, non-decision values and a `block → allow` jump are rejected
250
+ * here rather than silently coerced by the engine: the panel must never store a
251
+ * setting it cannot honour.
252
+ *
253
+ * `customRules` widens the accepted id set to include this server's own rules
254
+ * (doc §4.4.4): a custom rule can be tightened or loosened through `policy`
255
+ * exactly like a built-in one, and the same `block → allow` ban applies.
256
+ * @param {unknown} value
257
+ * @param {ReadonlyArray<import('./policy.js').CustomRule>} [customRules]
258
+ * @returns {Record<string, 'allow'|'confirm'|'block'>}
259
+ */
260
+ function normalizePolicyBody(value, customRules = []) {
261
+ if (!isPlainObject(value)) throw new HttpError(400, 'INVALID_POLICY', 'policy must be an object.')
262
+ const custom = new Map(customRules.map((rule) => [rule.id, rule]))
263
+ /** @type {Record<string, 'allow'|'confirm'|'block'>} */
264
+ const policy = {}
265
+ for (const [ruleId, decision] of Object.entries(value)) {
266
+ const rule = RULES_BY_ID.get(ruleId) ?? custom.get(ruleId)
267
+ if (rule === undefined) {
268
+ throw new HttpError(400, 'INVALID_POLICY', `Unknown high-risk rule "${ruleId}".`)
269
+ }
270
+ if (decision !== 'allow' && decision !== 'confirm' && decision !== 'block') {
271
+ throw new HttpError(400, 'INVALID_POLICY', `policy.${ruleId} must be one of ${DECISION_VALUES.join(', ')}.`)
272
+ }
273
+ if (!allowedDecisions(/** @type {{defaultDecision: string}} */ (rule)).includes(decision)) {
274
+ throw new HttpError(
275
+ 400,
276
+ 'INVALID_POLICY',
277
+ `policy.${ruleId}: a "block" rule can be lowered to "confirm" but not to "allow".`,
278
+ )
279
+ }
280
+ policy[ruleId] = decision
281
+ }
282
+ return policy
283
+ }
284
+
285
+ /**
286
+ * Resolve one built-in rule id or fail with 400.
287
+ * @param {string} ruleId
288
+ * @returns {import('./policy.js').Rule}
289
+ */
290
+ function ruleOf(ruleId) {
291
+ const rule = RULES_BY_ID.get(ruleId)
292
+ if (rule === undefined) throw new HttpError(400, 'INVALID_POLICY', `Unknown high-risk rule "${ruleId}".`)
293
+ return rule
294
+ }
295
+
296
+ /**
297
+ * @param {unknown} ruleId
298
+ * @returns {boolean}
299
+ */
300
+ function isKnownRule(ruleId) {
301
+ return typeof ruleId === 'string' && RULES_BY_ID.has(ruleId)
302
+ }
303
+
304
+ /**
305
+ * Pull the sample commands out of a custom-rule body (doc §4.4.3).
306
+ *
307
+ * Samples are optional everywhere except the preview endpoint. They are the
308
+ * cheap guard against "I wrote a regex that never fires": at least one sample
309
+ * must match before the rule is stored.
310
+ * @param {Record<string, unknown>} body
311
+ * @returns {string[]}
312
+ */
313
+ function samplesOf(body) {
314
+ if (!Array.isArray(body.samples)) return []
315
+ return body.samples.filter((item) => typeof item === 'string' && item.trim() !== '').map((item) => item.trim())
316
+ }
317
+
318
+ /**
319
+ * The public catalog the panel renders one row per rule from (§8.4).
320
+ * @returns {Array<Record<string, unknown>>}
321
+ */
322
+ export function ruleCatalog() {
323
+ return RULES.map((rule) => ({
324
+ id: rule.id,
325
+ label: rule.label,
326
+ description: rule.description,
327
+ defaultDecision: rule.defaultDecision,
328
+ allowedDecisions: allowedDecisions(rule),
329
+ }))
330
+ }
331
+
332
+ /**
333
+ * Per-rule state of one server (§8.4 "高危策略" table).
334
+ * @param {import('./registry.js').ServerRecord} server
335
+ * @returns {Record<string, {configured: string|null, decision: string|null, disabled: boolean}>}
336
+ */
337
+ function policyStateOf(server) {
338
+ /** @type {Record<string, {configured: string|null, decision: string|null, disabled: boolean}>} */
339
+ const state = {}
340
+ for (const rule of RULES) {
341
+ const disabled = server.disabledRules.includes(rule.id)
342
+ const configured = server.policy[rule.id]
343
+ state[rule.id] = {
344
+ configured: configured ?? null,
345
+ decision: disabled ? null : effectiveDecision(rule, server.policy),
346
+ disabled,
347
+ }
348
+ }
349
+ return state
350
+ }
351
+
352
+ /**
353
+ * Public view of one server: everything the panel needs, no secret, and
354
+ * `passwordConfigured` instead of the credential reference (§6.3).
355
+ * @param {import('./registry.js').ServerRecord} server
356
+ * @param {{connected?: boolean, passwordConfigured?: boolean|null}} [extra]
357
+ * @returns {Record<string, unknown>}
358
+ */
359
+ export function presentServer(server, extra = {}) {
360
+ return {
361
+ id: server.id,
362
+ name: server.name,
363
+ host: server.host,
364
+ port: server.port,
365
+ username: server.username,
366
+ auth:
367
+ server.auth.kind === 'key'
368
+ ? { kind: 'key', privateKeyPath: server.auth.privateKeyPath }
369
+ : { kind: server.auth.kind },
370
+ hostKeyMode: server.hostKeyMode,
371
+ defaultWorkDir: server.defaultWorkDir,
372
+ enabled: server.enabled !== false,
373
+ policy: server.policy,
374
+ disabledRules: server.disabledRules,
375
+ customRules: server.customRules ?? [],
376
+ createdAt: server.createdAt,
377
+ lastConnectedAt: server.lastConnectedAt,
378
+ connected: extra.connected === true,
379
+ passwordConfigured: extra.passwordConfigured ?? null,
380
+ policyState: policyStateOf(server),
381
+ }
382
+ }
383
+
384
+ /**
385
+ * Read the host-key mode of a body, defaulting to the current value.
386
+ * @param {unknown} value
387
+ * @returns {'accept-new'|'verify'|'off'}
388
+ */
389
+ function hostKeyModeOf(value) {
390
+ if (value === undefined) return /** @type {any} */ (undefined)
391
+ if (typeof value !== 'string' || !HOST_KEY_MODES.includes(value)) {
392
+ throw new HttpError(400, 'INVALID_SERVER', `hostKeyMode must be one of ${HOST_KEY_MODES.join(', ')}.`)
393
+ }
394
+ return /** @type {'accept-new'|'verify'|'off'} */ (value)
395
+ }
396
+
397
+ /**
398
+ * @param {unknown} value
399
+ * @returns {string|null}
400
+ */
401
+ function workDirOf(value) {
402
+ if (value === undefined || value === null || value === '') return null
403
+ return absolutePath(value, 'defaultWorkDir')
404
+ }
405
+
406
+ /**
407
+ * Map a domain error to an HTTP failure (§5.5 codes stay visible to the panel).
408
+ * @param {unknown} error
409
+ * @returns {HttpError}
410
+ */
411
+ export function toHttpError(error) {
412
+ if (error instanceof HttpError) return error
413
+ if (error instanceof RegistryError) {
414
+ const status =
415
+ error.code === 'UNKNOWN_SERVER'
416
+ ? 404
417
+ : error.code === 'DUPLICATE_NAME' || error.code === 'AMBIGUOUS_SERVER'
418
+ ? 409
419
+ : error.code === 'INVALID_REGISTRY'
420
+ ? 500
421
+ : 400
422
+ return new HttpError(status, error.code, error.message)
423
+ }
424
+ if (error instanceof PendingError) {
425
+ const status =
426
+ error.code === 'UNKNOWN_PENDING'
427
+ ? 404
428
+ : error.code === 'EXPIRED_PENDING'
429
+ ? 410
430
+ : error.code === 'PENDING_UNAVAILABLE'
431
+ ? 503
432
+ : error.code === 'APPLY_FAILED'
433
+ ? 502
434
+ : 400
435
+ return new HttpError(status, error.code, error.message)
436
+ }
437
+ if (error instanceof PoolError) {
438
+ const status =
439
+ error.code === 'HOST_KEY_UNKNOWN' || error.code === 'NO_CREDENTIAL'
440
+ ? 409
441
+ : error.code === 'TIMEOUT'
442
+ ? 504
443
+ : error.code === 'INVALID_SERVER'
444
+ ? 400
445
+ : 502
446
+ return new HttpError(status, error.code, error.message, {
447
+ ...(error.host === undefined ? {} : { host: error.host }),
448
+ ...(error.serverId === undefined ? {} : { serverId: error.serverId }),
449
+ })
450
+ }
451
+ if (error instanceof AuditError) return new HttpError(500, error.code, error.message)
452
+ const message = error instanceof Error ? error.message : String(error)
453
+ return new HttpError(500, 'INTERNAL', message)
454
+ }
455
+
456
+ /**
457
+ * Build the route handlers over the plugin's units.
458
+ *
459
+ * Everything is injected, so this module knows nothing about cordis: the tests
460
+ * build the same object with a real `Registry`, `Pool`, `AuditLog` and
461
+ * `PendingQueue` over temporary files (doc §2.2).
462
+ * @param {{
463
+ * registry: import('./registry.js').Registry,
464
+ * pool: {connect: Function, exec: Function, disconnect: Function, connectedServerIds: Function},
465
+ * audit?: import('./audit.js').AuditLog|null,
466
+ * pending?: import('./pending.js').PendingQueue|null,
467
+ * getCredentials?: () => ({describe?: Function, set?: Function, unset?: Function}|null|undefined),
468
+ * config?: Partial<import('./index.js').PluginConfig>,
469
+ * onChange?: () => (void|Promise<void>)|null,
470
+ * logger?: {info?: Function, warn?: Function, error?: Function}|null,
471
+ * }} options
472
+ * @returns {{dispatch: (request: {method: string, path: string, query?: Record<string, string>, body?: unknown}) => Promise<{status: number, body: unknown}>, describe: () => ReadonlyArray<{method: string, path: string}>}}
473
+ */
474
+ export function createApi(options) {
475
+ if (options?.registry === undefined || options.registry === null) throw new TypeError('createApi requires a registry.')
476
+ if (options?.pool === undefined || options.pool === null) throw new TypeError('createApi requires a pool.')
477
+ const { registry, pool, audit = null, pending = null, logger = null } = options
478
+ const config = options.config ?? {}
479
+ const onChange = typeof options.onChange === 'function' ? options.onChange : null
480
+ const getCredentials = typeof options.getCredentials === 'function' ? options.getCredentials : () => null
481
+ const probeTimeoutMs = Math.min(Number(config.commandTimeoutMs ?? 60000), 15000)
482
+
483
+ /**
484
+ * Report one non-fatal failure without changing the request outcome.
485
+ * @param {string} message - printf-style format.
486
+ * @param {...unknown} args
487
+ * @returns {void}
488
+ */
489
+ const warn = (message, ...args) => {
490
+ const method = logger === null ? undefined : logger.warn
491
+ if (typeof method !== 'function') return
492
+ method.call(
493
+ logger,
494
+ message,
495
+ ...args.map((value) => (value instanceof Error ? value.message : String(value))),
496
+ )
497
+ }
498
+
499
+ /** Refresh the system-prompt cache after a mutation (§9.4). */
500
+ const notify = async () => {
501
+ if (onChange === null) return
502
+ try {
503
+ await onChange()
504
+ } catch (error) {
505
+ warn('dsh-server-connector: could not refresh the server list after a panel change: %s', error)
506
+ }
507
+ }
508
+
509
+ /**
510
+ * Write one panel-originated audit event (doc §3.4).
511
+ *
512
+ * The pending applier records its own events when a queued change is
513
+ * confirmed; the enable switch is applied immediately, so it records here.
514
+ * @param {'server-enabled'|'server-disabled'} event
515
+ * @param {import('./registry.js').ServerRecord} server
516
+ * @returns {Promise<void>}
517
+ */
518
+ const recordPanelEvent = async (event, server) => {
519
+ if (audit === null) return
520
+ try {
521
+ await audit.append({
522
+ event,
523
+ serverId: server.id,
524
+ serverName: server.name,
525
+ detail: event === 'server-disabled' ? 'server disabled (hidden from srv_list)' : 'server enabled',
526
+ })
527
+ } catch (error) {
528
+ warn('dsh-server-connector: could not write the %s audit event: %s', event, String(error))
529
+ }
530
+ }
531
+
532
+ /**
533
+ * Whether a password is stored for this server (`null` = cannot tell).
534
+ * @param {import('./registry.js').ServerRecord} server
535
+ * @returns {Promise<boolean|null>}
536
+ */
537
+ const passwordConfigured = async (server) => {
538
+ if (server.auth.kind !== 'password') return null
539
+ const credentials = getCredentials()
540
+ if (credentials === null || credentials === undefined || typeof credentials.describe !== 'function') return null
541
+ try {
542
+ const info = await credentials.describe(server.auth.credentialRef)
543
+ return info?.configured === true
544
+ } catch (error) {
545
+ warn('dsh-server-connector: could not describe the credential of %s: %s', server.id, error)
546
+ return null
547
+ }
548
+ }
549
+
550
+ /** @returns {Set<string>} ids with a live pooled connection */
551
+ const connectedIds = () => {
552
+ try {
553
+ return new Set(/** @type {string[]} */ (pool.connectedServerIds()))
554
+ } catch {
555
+ return new Set()
556
+ }
557
+ }
558
+
559
+ /**
560
+ * Load one server or fail with 404.
561
+ * @param {string} id
562
+ * @returns {Promise<import('./registry.js').ServerRecord>}
563
+ */
564
+ const requireServer = async (id) => {
565
+ const server = await registry.get(id)
566
+ if (server === null) {
567
+ throw new HttpError(404, 'UNKNOWN_SERVER', `No server "${id}"; it may have been deleted.`)
568
+ }
569
+ return server
570
+ }
571
+
572
+ /**
573
+ * Require the pending queue where an operation needs it.
574
+ * @returns {import('./pending.js').PendingQueue}
575
+ */
576
+ const requirePending = () => {
577
+ if (pending === null) {
578
+ throw new HttpError(503, 'PENDING_UNAVAILABLE', 'This deployment has no pending queue, so dangerous operations are unavailable.')
579
+ }
580
+ return pending
581
+ }
582
+
583
+ /**
584
+ * Write a password to the credential service (doc §6.3). The record only ever
585
+ * holds the reference.
586
+ * @param {import('./registry.js').ServerRecord} server
587
+ * @param {unknown} value - the write-only body field; validated here.
588
+ * @returns {Promise<void>}
589
+ */
590
+ const storePassword = async (server, value) => {
591
+ if (server.auth.kind !== 'password') {
592
+ throw new HttpError(400, 'INVALID_SERVER', 'password is only accepted for auth.kind "password".')
593
+ }
594
+ const credentials = getCredentials()
595
+ if (credentials === null || credentials === undefined || typeof credentials.set !== 'function') {
596
+ throw new HttpError(
597
+ 503,
598
+ 'CREDENTIALS_UNAVAILABLE',
599
+ 'This deployment has no credential service, so a password cannot be stored. Use private-key or agent authentication.',
600
+ )
601
+ }
602
+ if (typeof value !== 'string' || value === '') {
603
+ throw new HttpError(400, 'INVALID_SERVER', 'password must be a non-empty string.')
604
+ }
605
+ await credentials.set(server.auth.credentialRef, value)
606
+ }
607
+
608
+ /**
609
+ * Validate a key path on this machine (§8.4 "绝对路径、文件存在").
610
+ * @param {string} path
611
+ * @returns {Promise<void>}
612
+ */
613
+ const assertKeyFile = async (path) => {
614
+ try {
615
+ const info = await stat(path)
616
+ if (!info.isFile()) {
617
+ throw new HttpError(400, 'INVALID_SERVER', `auth.privateKeyPath ${path} is not a regular file.`)
618
+ }
619
+ } catch (error) {
620
+ if (error instanceof HttpError) throw error
621
+ throw new HttpError(400, 'INVALID_SERVER', `auth.privateKeyPath ${path} does not exist on this machine.`)
622
+ }
623
+ }
624
+
625
+ /**
626
+ * Queue one dangerous operation and report it (§8.3).
627
+ * @param {import('./pending.js').PendingRequestInput} input
628
+ * @returns {Promise<import('./pending.js').PendingRecord>}
629
+ */
630
+ const enqueue = async (input) => {
631
+ const queue = requirePending()
632
+ const { record } = await queue.request(input)
633
+ return record
634
+ }
635
+
636
+ // ------------------------------------------------------------- handlers --
637
+
638
+ /**
639
+ * GET /servers — every server, the rule catalog, the public config and the
640
+ * pending count, so the panel and the settings page need one round trip.
641
+ * @returns {Promise<{status: number, body: unknown}>}
642
+ */
643
+ const listServers = async () => {
644
+ const servers = await registry.list()
645
+ const connected = connectedIds()
646
+ /** @type {Array<Record<string, unknown>>} */
647
+ const present = []
648
+ for (const server of servers) {
649
+ present.push(
650
+ presentServer(server, { connected: connected.has(server.id), passwordConfigured: await passwordConfigured(server) }),
651
+ )
652
+ }
653
+ /** @type {unknown[]} */
654
+ let queued = []
655
+ if (pending !== null) {
656
+ try {
657
+ queued = await pending.list()
658
+ } catch (error) {
659
+ warn('dsh-server-connector: could not read pending.json: %s', error)
660
+ }
661
+ }
662
+ return {
663
+ status: 200,
664
+ body: {
665
+ servers: present,
666
+ rules: ruleCatalog(),
667
+ config: {
668
+ connectTimeoutMs: config.connectTimeoutMs ?? 15000,
669
+ commandTimeoutMs: config.commandTimeoutMs ?? 60000,
670
+ syncTimeoutMs: config.syncTimeoutMs ?? 20000,
671
+ maxOutputChars: config.maxOutputChars ?? 200000,
672
+ idleTimeoutMs: config.idleTimeoutMs ?? 600000,
673
+ auditEnabled: config.auditEnabled ?? true,
674
+ auditMaxBytes: config.auditMaxBytes ?? 0,
675
+ pendingTtlHours: config.pendingTtlHours ?? 24,
676
+ maxListedServers: config.maxListedServers ?? 8,
677
+ },
678
+ hostKeyModes: [...HOST_KEY_MODES],
679
+ decisions: [...DECISION_VALUES],
680
+ pendingCount: Array.isArray(queued) ? queued.length : 0,
681
+ },
682
+ }
683
+ }
684
+
685
+ /**
686
+ * POST /servers — create.
687
+ *
688
+ * `hostKeyMode: "off"` obeys the same two-step rule as an update (§8.4/§8.3):
689
+ * the record is created with `accept-new` and an `disable-hostkey` request is
690
+ * queued, so choosing "off" never takes effect in the same click that creates
691
+ * the server.
692
+ * @param {unknown} body
693
+ * @returns {Promise<{status: number, body: unknown}>}
694
+ */
695
+ const createServer = async (body) => {
696
+ if (!isPlainObject(body)) throw new HttpError(400, 'INVALID_SERVER', 'Request body must be a JSON object.')
697
+ assertKnownBodyFields(body, CREATE_BODY_FIELDS)
698
+ const auth = normalizeAuthBody(body.auth)
699
+ if (auth.kind !== 'password' && body.password !== undefined) {
700
+ throw new HttpError(
701
+ 400,
702
+ 'INVALID_SERVER',
703
+ 'A password is only accepted for auth.kind "password"; the record never stores a plaintext secret.',
704
+ )
705
+ }
706
+ const requestedMode = hostKeyModeOf(body.hostKeyMode) ?? 'accept-new'
707
+ if (auth.kind === 'key') await assertKeyFile(auth.privateKeyPath)
708
+ if (auth.kind === 'password' && body.password === undefined) {
709
+ throw new HttpError(400, 'INVALID_SERVER', 'A password server needs a password on create.')
710
+ }
711
+
712
+ /** @type {Record<string, unknown>} */
713
+ const input = {
714
+ name: requiredString(body.name, 'name'),
715
+ host: requiredString(body.host, 'host'),
716
+ username: requiredString(body.username, 'username'),
717
+ auth,
718
+ // §8.3: "off" is never the value a record starts with; it is requested.
719
+ hostKeyMode: requestedMode === 'off' ? 'accept-new' : requestedMode,
720
+ defaultWorkDir: workDirOf(body.defaultWorkDir),
721
+ policy: body.policy === undefined ? {} : normalizePolicyBody(body.policy),
722
+ }
723
+ if (body.port !== undefined) input.port = body.port
724
+ const server = await registry.create(input)
725
+ try {
726
+ if (auth.kind === 'password') await storePassword(server, body.password)
727
+ } catch (error) {
728
+ // The record without its password would be unusable; undo the create.
729
+ await registry.remove(server.id).catch((cleanup) => {
730
+ warn('dsh-server-connector: could not roll back server %s: %s', server.id, cleanup)
731
+ })
732
+ throw error
733
+ }
734
+ /** @type {import('./pending.js').PendingRecord[]} */
735
+ const queued = []
736
+ if (requestedMode === 'off') queued.push(await enqueue({ kind: 'disable-hostkey', serverId: server.id }))
737
+ if (body.disableRules !== undefined) {
738
+ if (!Array.isArray(body.disableRules)) {
739
+ throw new HttpError(400, 'INVALID_POLICY', 'disableRules must be an array of rule ids.')
740
+ }
741
+ for (const ruleId of body.disableRules) {
742
+ if (!isKnownRule(ruleId)) {
743
+ throw new HttpError(400, 'INVALID_POLICY', `Unknown high-risk rule ${JSON.stringify(ruleId)}.`)
744
+ }
745
+ queued.push(await enqueue({ kind: 'disable-rule', serverId: server.id, ruleId }))
746
+ }
747
+ }
748
+ await notify()
749
+ return {
750
+ status: queued.length > 0 ? 202 : 201,
751
+ body: {
752
+ server: presentServer(server, { passwordConfigured: await passwordConfigured(server) }),
753
+ pending: queued,
754
+ applied: queued.length === 0,
755
+ ...(queued.length === 0
756
+ ? {}
757
+ : { note: 'The server was created with host-key verification on; switching it off is queued and needs confirmation.' }),
758
+ },
759
+ }
760
+ }
761
+
762
+ /**
763
+ * PUT /servers/:id — update.
764
+ *
765
+ * `hostKeyMode: "off"`, `disableRules` and rule *enabling* have different
766
+ * lifetimes: the first two go to the pending queue (§8.3) and are **not**
767
+ * applied, re-enabling a rule is safe and applies immediately.
768
+ * @param {string} id
769
+ * @param {unknown} body
770
+ * @returns {Promise<{status: number, body: unknown}>}
771
+ */
772
+ const updateServer = async (id, body) => {
773
+ if (!isPlainObject(body)) throw new HttpError(400, 'INVALID_SERVER', 'Request body must be a JSON object.')
774
+ assertKnownBodyFields(body, UPDATE_BODY_FIELDS)
775
+ if (body.disabledRules !== undefined) {
776
+ throw new HttpError(
777
+ 400,
778
+ 'INVALID_PENDING',
779
+ 'disabledRules cannot be written directly; switch a rule off with "disableRules" (pending) or on with "enableRules".',
780
+ )
781
+ }
782
+ const existing = await requireServer(id)
783
+ /** @type {Record<string, unknown>} */
784
+ const patch = {}
785
+
786
+ for (const field of ['name', 'host', 'username']) {
787
+ if (body[field] !== undefined) patch[field] = requiredString(body[field], field)
788
+ }
789
+ if (body.port !== undefined) patch.port = body.port
790
+ if (body.defaultWorkDir !== undefined) patch.defaultWorkDir = workDirOf(body.defaultWorkDir)
791
+ if (body.policy !== undefined) patch.policy = normalizePolicyBody(body.policy, existing.customRules)
792
+
793
+ // The enable switch (doc §8.5). Disabling while a connection is open is
794
+ // refused rather than silently cut: the operator asked for "no more use of
795
+ // this server", not "kill whatever is running on it".
796
+ let toggledEnabled = false
797
+ if (body.enabled !== undefined) {
798
+ if (typeof body.enabled !== 'boolean') {
799
+ throw new HttpError(400, 'INVALID_SERVER', 'enabled must be a boolean.')
800
+ }
801
+ if (body.enabled === false && connectedIds().has(existing.id)) {
802
+ throw new HttpError(
803
+ 409,
804
+ 'SERVER_CONNECTED',
805
+ 'this server has an open connection; disconnect it before disabling.',
806
+ )
807
+ }
808
+ patch.enabled = body.enabled
809
+ toggledEnabled = body.enabled !== (existing.enabled !== false)
810
+ }
811
+
812
+ /** @type {{kind:'key', privateKeyPath:string}|{kind:'password'}|{kind:'agent'}|undefined} */
813
+ let nextAuth
814
+ if (body.auth !== undefined) {
815
+ nextAuth = normalizeAuthBody(body.auth)
816
+ if (nextAuth.kind === 'key') await assertKeyFile(nextAuth.privateKeyPath)
817
+ patch.auth = nextAuth
818
+ } else if (body.password !== undefined) {
819
+ if (existing.auth.kind !== 'password') {
820
+ throw new HttpError(400, 'INVALID_SERVER', 'password is only accepted for auth.kind "password".')
821
+ }
822
+ }
823
+
824
+ const requested = hostKeyModeOf(body.hostKeyMode)
825
+ /** @type {import('./pending.js').PendingRecord[]} */
826
+ const queued = []
827
+ /** @type {string} */
828
+ let note = ''
829
+ if (requested !== undefined && requested !== existing.hostKeyMode) {
830
+ if (requested === 'off') {
831
+ // §8.3: the server keeps connecting under its current policy.
832
+ queued.push(await enqueue({ kind: 'disable-hostkey', serverId: existing.id }))
833
+ note = 'hostKeyMode stays unchanged until the request is confirmed'
834
+ } else {
835
+ patch.hostKeyMode = requested
836
+ }
837
+ }
838
+
839
+ /** @type {string[]} */
840
+ const enableRules = []
841
+ if (body.enableRules !== undefined) {
842
+ if (!Array.isArray(body.enableRules)) {
843
+ throw new HttpError(400, 'INVALID_POLICY', 'enableRules must be an array of rule ids.')
844
+ }
845
+ for (const ruleId of body.enableRules) {
846
+ if (typeof ruleId !== 'string' || !isKnownRule(ruleId)) {
847
+ throw new HttpError(400, 'INVALID_POLICY', `Unknown high-risk rule ${JSON.stringify(ruleId)}.`)
848
+ }
849
+ if (existing.disabledRules.includes(ruleId)) enableRules.push(ruleId)
850
+ }
851
+ }
852
+ /** @type {string[]} */
853
+ const disableRules = []
854
+ if (body.disableRules !== undefined) {
855
+ if (!Array.isArray(body.disableRules)) {
856
+ throw new HttpError(400, 'INVALID_POLICY', 'disableRules must be an array of rule ids.')
857
+ }
858
+ for (const ruleId of body.disableRules) {
859
+ if (typeof ruleId !== 'string' || !isKnownRule(ruleId)) {
860
+ throw new HttpError(400, 'INVALID_POLICY', `Unknown high-risk rule ${JSON.stringify(ruleId)}.`)
861
+ }
862
+ // A rule the panel already disabled needs no second request; one being
863
+ // re-enabled in this same call would contradict itself.
864
+ if (existing.disabledRules.includes(ruleId) || enableRules.includes(ruleId)) continue
865
+ disableRules.push(ruleId)
866
+ }
867
+ }
868
+ for (const ruleId of disableRules) {
869
+ queued.push(await enqueue({ kind: 'disable-rule', serverId: existing.id, ruleId }))
870
+ }
871
+
872
+ if (enableRules.length > 0) {
873
+ patch.disabledRules = existing.disabledRules.filter((ruleId) => !enableRules.includes(ruleId))
874
+ }
875
+
876
+ /** @type {import('./registry.js').ServerRecord} */
877
+ let server = existing
878
+ if (Object.keys(patch).length > 0) server = await registry.update(existing.id, patch)
879
+
880
+ if (body.password !== undefined) await storePassword(server, body.password)
881
+
882
+ if (existing.auth.kind === 'password' && server.auth.kind !== 'password') {
883
+ const credentials = getCredentials()
884
+ if (credentials !== null && credentials !== undefined && typeof credentials.unset === 'function') {
885
+ try {
886
+ await credentials.unset(existing.auth.credentialRef)
887
+ } catch (error) {
888
+ warn('dsh-server-connector: could not remove the credential of %s: %s', existing.id, error)
889
+ }
890
+ }
891
+ }
892
+
893
+ if (toggledEnabled) {
894
+ await recordPanelEvent(server.enabled === false ? 'server-disabled' : 'server-enabled', server)
895
+ }
896
+
897
+ await notify()
898
+ const connected = connectedIds()
899
+ return {
900
+ status: queued.length > 0 ? 202 : 200,
901
+ body: {
902
+ server: presentServer(server, {
903
+ connected: connected.has(server.id),
904
+ passwordConfigured: await passwordConfigured(server),
905
+ }),
906
+ pending: queued,
907
+ applied: queued.length === 0,
908
+ ...(note === '' ? {} : { note }),
909
+ },
910
+ }
911
+ }
912
+
913
+ /**
914
+ * DELETE /servers/:id — a delete *request* (§8.3). The server stays present
915
+ * and usable; the snapshot keeps the pending row self-describing.
916
+ * @param {string} id
917
+ * @returns {Promise<{status: number, body: unknown}>}
918
+ */
919
+ const requestDelete = async (id) => {
920
+ const server = await requireServer(id)
921
+ const record = await enqueue({ kind: 'delete-server', serverId: server.id, snapshot: server })
922
+ await notify()
923
+ return {
924
+ status: 202,
925
+ body: {
926
+ pending: record,
927
+ server: presentServer(server, { connected: connectedIds().has(server.id) }),
928
+ note: 'The server stays configured until the deletion is confirmed in Settings.',
929
+ },
930
+ }
931
+ }
932
+
933
+ // --------------------------------------------------- custom rules (§4.4) --
934
+
935
+ /**
936
+ * Turn a `RuleError` into a 400 with a stable code.
937
+ * @param {unknown} error
938
+ * @returns {never}
939
+ */
940
+ const rethrowRuleError = (error) => {
941
+ if (error instanceof RuleError) throw new HttpError(400, error.code, error.message)
942
+ throw error
943
+ }
944
+
945
+ /**
946
+ * The next free `custom-N` id for this server (doc §4.4.1: the panel never
947
+ * chooses the id, so it cannot collide with a built-in or go stale).
948
+ * @param {ReadonlyArray<import('./policy.js').CustomRule>} rules
949
+ * @returns {string}
950
+ */
951
+ const nextRuleId = (rules) => {
952
+ const taken = new Set(rules.map((rule) => rule.id))
953
+ let n = 1
954
+ while (taken.has(`custom-${n}`)) n += 1
955
+ return `custom-${n}`
956
+ }
957
+
958
+ /**
959
+ * Add one custom rule to a server (doc §4.4.2). Validation happens before the
960
+ * write, so a rule that cannot be honoured is never stored.
961
+ * @param {string} id - server id
962
+ * @param {unknown} body
963
+ * @returns {Promise<{status: number, body: unknown}>}
964
+ */
965
+ const createRule = async (id, body) => {
966
+ const server = await requireServer(id)
967
+ if (!isPlainObject(body)) throw new HttpError(400, 'INVALID_RULE', 'a custom rule must be an object.')
968
+ const existing = server.customRules ?? []
969
+ if (existing.length >= MAX_CUSTOM_RULES) {
970
+ throw new HttpError(400, 'INVALID_RULE', `a server may carry at most ${MAX_CUSTOM_RULES} custom rules.`)
971
+ }
972
+ /** @type {import('./policy.js').CustomRule} */
973
+ let rule
974
+ try {
975
+ rule = validateCustomRule(
976
+ { ...body, id: nextRuleId(existing) },
977
+ { takenIds: existing.map((item) => item.id), samples: samplesOf(body) },
978
+ )
979
+ } catch (error) {
980
+ return rethrowRuleError(error)
981
+ }
982
+ const updated = await registry.update(server.id, { customRules: [...existing, rule] })
983
+ await notify()
984
+ return {
985
+ status: 201,
986
+ body: {
987
+ rule,
988
+ server: presentServer(updated, { connected: connectedIds().has(updated.id) }),
989
+ },
990
+ }
991
+ }
992
+
993
+ /**
994
+ * Replace one custom rule's mutable fields. The id is immutable: `policy` and
995
+ * `disabledRules` refer to it.
996
+ * @param {string} id - server id
997
+ * @param {string} ruleId
998
+ * @param {unknown} body
999
+ * @returns {Promise<{status: number, body: unknown}>}
1000
+ */
1001
+ const updateRule = async (id, ruleId, body) => {
1002
+ const server = await requireServer(id)
1003
+ if (!isPlainObject(body)) throw new HttpError(400, 'INVALID_RULE', 'a custom rule must be an object.')
1004
+ const existing = server.customRules ?? []
1005
+ const current = existing.find((rule) => rule.id === ruleId)
1006
+ if (current === undefined) throw new HttpError(404, 'RULE_NOT_FOUND', `no custom rule "${ruleId}" on this server.`)
1007
+ /** @type {import('./policy.js').CustomRule} */
1008
+ let rule
1009
+ try {
1010
+ rule = validateCustomRule(
1011
+ { ...current, ...body, id: current.id },
1012
+ { takenIds: existing.map((item) => item.id).filter((item) => item !== current.id), samples: samplesOf(body) },
1013
+ )
1014
+ } catch (error) {
1015
+ return rethrowRuleError(error)
1016
+ }
1017
+ const updated = await registry.update(server.id, {
1018
+ customRules: existing.map((item) => (item.id === ruleId ? rule : item)),
1019
+ })
1020
+ await notify()
1021
+ return {
1022
+ status: 200,
1023
+ body: {
1024
+ rule,
1025
+ server: presentServer(updated, { connected: connectedIds().has(updated.id) }),
1026
+ },
1027
+ }
1028
+ }
1029
+
1030
+ /**
1031
+ * Remove one custom rule. Deleting a rule only tightens the surface back to the
1032
+ * built-in default, so — like adding one — it needs no two-step confirmation;
1033
+ * the loosening path is "disable a rule", which still goes through the queue.
1034
+ * @param {string} id - server id
1035
+ * @param {string} ruleId
1036
+ * @returns {Promise<{status: number, body: unknown}>}
1037
+ */
1038
+ const deleteRule = async (id, ruleId) => {
1039
+ const server = await requireServer(id)
1040
+ const existing = server.customRules ?? []
1041
+ if (!existing.some((rule) => rule.id === ruleId)) {
1042
+ throw new HttpError(404, 'RULE_NOT_FOUND', `no custom rule "${ruleId}" on this server.`)
1043
+ }
1044
+ // Drop the id from `policy` and `disabledRules` too, so neither keeps a key
1045
+ // that no longer resolves to anything.
1046
+ const policy = { ...server.policy }
1047
+ delete policy[ruleId]
1048
+ const updated = await registry.update(server.id, {
1049
+ customRules: existing.filter((rule) => rule.id !== ruleId),
1050
+ policy,
1051
+ disabledRules: (server.disabledRules ?? []).filter((item) => item !== ruleId),
1052
+ })
1053
+ await notify()
1054
+ return {
1055
+ status: 200,
1056
+ body: { server: presentServer(updated, { connected: connectedIds().has(updated.id) }) },
1057
+ }
1058
+ }
1059
+
1060
+ /**
1061
+ * Dry-run a candidate pattern against sample commands without writing anything
1062
+ * (doc §4.4.3). It calls the same `check()` the executor calls, so a preview
1063
+ * cannot disagree with a real execution.
1064
+ * @param {string} id - server id
1065
+ * @param {unknown} body
1066
+ * @returns {Promise<{status: number, body: unknown}>}
1067
+ */
1068
+ const previewRule = async (id, body) => {
1069
+ const server = await requireServer(id)
1070
+ if (!isPlainObject(body)) throw new HttpError(400, 'INVALID_RULE', 'preview needs an object body.')
1071
+ const samples = samplesOf(body)
1072
+ if (samples.length === 0) {
1073
+ throw new HttpError(400, 'INVALID_RULE', 'preview needs at least one sample command.')
1074
+ }
1075
+ // A preview compiles the rule but skips the "must match a sample" check —
1076
+ // that check is exactly what the caller is here to find out.
1077
+ /** @type {import('./policy.js').CustomRule} */
1078
+ let rule
1079
+ try {
1080
+ rule = validateCustomRule(
1081
+ { ...body, id: 'preview', defaultDecision: body.defaultDecision ?? 'confirm', label: body.label ?? 'preview' },
1082
+ { takenIds: [] },
1083
+ )
1084
+ } catch (error) {
1085
+ return rethrowRuleError(error)
1086
+ }
1087
+ const probe = { ...server, customRules: [...(server.customRules ?? []).filter((item) => item.id !== 'preview'), rule] }
1088
+ const results = samples.map((sample) => {
1089
+ const verdict = checkPolicy(sample, probe)
1090
+ return {
1091
+ sample,
1092
+ matched: verdict.matches.some((match) => match.id === 'preview'),
1093
+ decision: verdict.decision,
1094
+ matchedRule: verdict.matchedRule === null ? null : verdict.matchedRule.id,
1095
+ }
1096
+ })
1097
+ return {
1098
+ status: 200,
1099
+ body: {
1100
+ rule: { label: rule.label, pattern: rule.pattern, defaultDecision: rule.defaultDecision },
1101
+ results,
1102
+ anyMatched: results.some((item) => item.matched),
1103
+ },
1104
+ }
1105
+ }
1106
+
1107
+ /**
1108
+ * A connection probe: connect, run the §5.1 platform/shell/sudo probe, report.
1109
+ * @param {import('./registry.js').ServerRecord} server
1110
+ * @returns {Promise<Record<string, unknown>>}
1111
+ */
1112
+ const probe = async (server) => {
1113
+ const startedAt = Date.now()
1114
+ await pool.connect(server)
1115
+ const result = await pool.exec(server, { command: CONNECT_PROBE, timeoutMs: probeTimeoutMs })
1116
+ return { ...parseProbe(String(result.stdout ?? '')), durationMs: Date.now() - startedAt }
1117
+ }
1118
+
1119
+ /**
1120
+ * POST /test — test one saved server. This is outside a turn, so no
1121
+ * `srv_exec` policy gate applies (doc §8.5).
1122
+ * @param {unknown} body
1123
+ * @returns {Promise<{status: number, body: unknown}>}
1124
+ */
1125
+ const testConnection = async (body) => {
1126
+ if (!isPlainObject(body)) throw new HttpError(400, 'INVALID_SERVER', 'Request body must be a JSON object.')
1127
+ const id = requiredString(body.id, 'id')
1128
+ const server = await requireServer(id)
1129
+ const outcome = await probe(server)
1130
+ try {
1131
+ await registry.update(server.id, { lastConnectedAt: new Date().toISOString() })
1132
+ } catch (error) {
1133
+ warn('dsh-server-connector: could not record lastConnectedAt of %s: %s', server.id, error)
1134
+ }
1135
+ await notify()
1136
+ return { status: 200, body: { ok: true, serverId: server.id, ...outcome } }
1137
+ }
1138
+
1139
+ /**
1140
+ * POST /connect/:id — open and keep the pooled connection.
1141
+ * @param {string} id
1142
+ * @returns {Promise<{status: number, body: unknown}>}
1143
+ */
1144
+ const connectServer = async (id) => {
1145
+ const server = await requireServer(id)
1146
+ const outcome = await probe(server)
1147
+ try {
1148
+ await registry.update(server.id, { lastConnectedAt: new Date().toISOString() })
1149
+ } catch (error) {
1150
+ warn('dsh-server-connector: could not record lastConnectedAt of %s: %s', server.id, error)
1151
+ }
1152
+ await notify()
1153
+ return {
1154
+ status: 200,
1155
+ body: {
1156
+ serverId: server.id,
1157
+ connected: true,
1158
+ probe: outcome,
1159
+ server: presentServer(server, { connected: true, passwordConfigured: await passwordConfigured(server) }),
1160
+ },
1161
+ }
1162
+ }
1163
+
1164
+ /**
1165
+ * POST /disconnect/:id
1166
+ * @param {string} id
1167
+ * @returns {Promise<{status: number, body: unknown}>}
1168
+ */
1169
+ const disconnectServer = async (id) => {
1170
+ const server = await requireServer(id)
1171
+ await pool.disconnect(server.id)
1172
+ return { status: 200, body: { serverId: server.id, connected: false } }
1173
+ }
1174
+
1175
+ /**
1176
+ * GET /audit?limit=20[&serverId=…] — the newest records, newest first, so the
1177
+ * panel can render the detail page's last-20 list without sorting.
1178
+ * @param {Record<string, string>} query
1179
+ * @returns {Promise<{status: number, body: unknown}>}
1180
+ */
1181
+ const listAudit = async (query) => {
1182
+ if (audit === null) return { status: 200, body: { records: [] } }
1183
+ const rawLimit = query.limit === undefined ? 20 : Number(query.limit)
1184
+ if (!Number.isSafeInteger(rawLimit) || rawLimit <= 0) {
1185
+ throw new HttpError(400, 'INVALID_ARGUMENT', 'limit must be a positive integer.')
1186
+ }
1187
+ const limit = Math.min(rawLimit, MAX_AUDIT_LIMIT)
1188
+ const serverId = query.serverId
1189
+ // Filtering happens here rather than in the log: the tail is bounded and the
1190
+ // panel only ever asks for a handful of records.
1191
+ const records = await audit.tail(serverId === undefined ? limit : MAX_AUDIT_LIMIT)
1192
+ const filtered = serverId === undefined ? records : records.filter((record) => record.serverId === serverId)
1193
+ return { status: 200, body: { records: filtered.slice(-limit).reverse() } }
1194
+ }
1195
+
1196
+ /**
1197
+ * The pending queue, enriched with the server name each row targets. A
1198
+ * `delete-server` snapshot is reduced to its display fields: the row's job is
1199
+ * to say *what* would be deleted, not to publish the record.
1200
+ * @returns {Promise<{status: number, body: unknown}>}
1201
+ */
1202
+ const listPending = async () => {
1203
+ if (pending === null) return { status: 200, body: { pending: [] } }
1204
+ const records = await pending.list()
1205
+ const servers = await registry.list()
1206
+ /** @type {Map<string, import('./registry.js').ServerRecord>} */
1207
+ const byId = new Map(servers.map((server) => [server.id, server]))
1208
+ return {
1209
+ status: 200,
1210
+ body: {
1211
+ pending: records.map((record) => {
1212
+ const live = byId.get(record.serverId)
1213
+ const snapshot = isPlainObject(record.snapshot) ? record.snapshot : null
1214
+ const name =
1215
+ typeof snapshot?.name === 'string'
1216
+ ? snapshot.name
1217
+ : live === undefined
1218
+ ? record.serverId
1219
+ : live.name
1220
+ /** @type {Record<string, unknown>} */
1221
+ const target = { present: live !== undefined }
1222
+ if (typeof snapshot?.host === 'string') target.host = snapshot.host
1223
+ else if (live !== undefined) target.host = live.host
1224
+ if (typeof snapshot?.port === 'number') target.port = snapshot.port
1225
+ else if (live !== undefined) target.port = live.port
1226
+ if (typeof snapshot?.username === 'string') target.username = snapshot.username
1227
+ else if (live !== undefined) target.username = live.username
1228
+ return { ...record, serverName: name, target }
1229
+ }),
1230
+ },
1231
+ }
1232
+ }
1233
+
1234
+ /**
1235
+ * POST /pending/:id/confirm — apply one queued operation (§8.3).
1236
+ * @param {string} id
1237
+ * @returns {Promise<{status: number, body: unknown}>}
1238
+ */
1239
+ const confirmPending = async (id) => {
1240
+ const queue = requirePending()
1241
+ const outcome = await queue.confirm(id)
1242
+ await notify()
1243
+ return { status: 200, body: { status: outcome.status, record: outcome.record, result: outcome.result } }
1244
+ }
1245
+
1246
+ /**
1247
+ * POST /pending/:id/cancel — drop the request, leaving the target unchanged.
1248
+ * @param {string} id
1249
+ * @returns {Promise<{status: number, body: unknown}>}
1250
+ */
1251
+ const cancelPending = async (id) => {
1252
+ const queue = requirePending()
1253
+ const outcome = await queue.cancel(id)
1254
+ await notify()
1255
+ return { status: 200, body: { status: outcome.status, record: outcome.record } }
1256
+ }
1257
+
1258
+ /**
1259
+ * Run one request.
1260
+ * @param {{method: string, path: string, query?: Record<string, string>, body?: unknown}} request
1261
+ * `path` is relative to {@link API_PREFIX} (`/servers/srv-01`).
1262
+ * @returns {Promise<{status: number, body: unknown}>}
1263
+ */
1264
+ const dispatch = async (request) => {
1265
+ const method = String(request.method ?? 'GET').toUpperCase()
1266
+ const segments = String(request.path ?? '/')
1267
+ .split('/')
1268
+ .filter((segment) => segment !== '')
1269
+ const query = request.query ?? {}
1270
+
1271
+ // A path is matched first, then its method: a known path with an unexpected
1272
+ // method is a caller bug (405), a path nothing declares is a missing endpoint
1273
+ // (404). Both passes run over the same table, so the surface stays declared
1274
+ // in exactly one place.
1275
+ /** @type {typeof API_ROUTES[number]|null} */
1276
+ let matched = null
1277
+ /** @type {Record<string, string>|null} */
1278
+ let matchedParams = null
1279
+ /** @type {string[]} */
1280
+ const methods = []
1281
+ for (const route of API_ROUTES) {
1282
+ const params = matchRoute(route.path, segments)
1283
+ if (params === null) continue
1284
+ methods.push(route.method)
1285
+ if (route.method !== method || matched !== null) continue
1286
+ matched = route
1287
+ matchedParams = params
1288
+ }
1289
+ if (matched === null) {
1290
+ if (methods.length === 0) {
1291
+ throw new HttpError(404, 'NOT_FOUND', `No route for ${method} ${API_PREFIX}${request.path}.`)
1292
+ }
1293
+ const allow = methods.join(', ')
1294
+ throw new HttpError(405, 'METHOD_NOT_ALLOWED', `${request.path} only supports ${allow}.`, { allow })
1295
+ }
1296
+ /** @type {Record<string, string>} */
1297
+ const params = matchedParams ?? {}
1298
+ const id = /** @type {string} */ (params.id)
1299
+ switch (matched.op) {
1300
+ case 'listServers':
1301
+ return listServers()
1302
+ case 'createServer':
1303
+ return createServer(request.body)
1304
+ case 'updateServer':
1305
+ return updateServer(id, request.body)
1306
+ case 'requestDelete':
1307
+ return requestDelete(id)
1308
+ case 'testConnection':
1309
+ return testConnection(request.body)
1310
+ case 'connectServer':
1311
+ return connectServer(id)
1312
+ case 'disconnectServer':
1313
+ return disconnectServer(id)
1314
+ case 'listAudit':
1315
+ return listAudit(query)
1316
+ case 'listPending':
1317
+ return listPending()
1318
+ case 'confirmPending':
1319
+ return confirmPending(id)
1320
+ case 'cancelPending':
1321
+ return cancelPending(id)
1322
+ case 'createRule':
1323
+ return createRule(id, request.body)
1324
+ case 'updateRule':
1325
+ return updateRule(id, /** @type {string} */ (params.ruleId), request.body)
1326
+ case 'deleteRule':
1327
+ return deleteRule(id, /** @type {string} */ (params.ruleId))
1328
+ case 'previewRule':
1329
+ return previewRule(id, request.body)
1330
+ default:
1331
+ throw new HttpError(500, 'INTERNAL', `Unhandled route operation ${String(matched.op)}.`)
1332
+ }
1333
+ }
1334
+
1335
+ return {
1336
+ dispatch,
1337
+ describe: () => API_ROUTES.map(({ method, path }) => ({ method, path: `${API_PREFIX}${path}` })),
1338
+ }
1339
+ }
1340
+
1341
+ /**
1342
+ * Match one route template against decoded path segments.
1343
+ * @param {string} template - e.g. `/pending/:id/confirm`
1344
+ * @param {string[]} segments - segments relative to the mount path.
1345
+ * @returns {Record<string, string>|null} the parameters, or null when it does not match.
1346
+ */
1347
+ function matchRoute(template, segments) {
1348
+ const parts = template.split('/').filter((part) => part !== '')
1349
+ if (parts.length !== segments.length) return null
1350
+ /** @type {Record<string, string>} */
1351
+ const params = {}
1352
+ for (let index = 0; index < parts.length; index += 1) {
1353
+ const part = parts[index]
1354
+ const segment = segments[index]
1355
+ if (part === undefined || segment === undefined) return null
1356
+ if (part.startsWith(':')) {
1357
+ if (segment === '') return null
1358
+ params[part.slice(1)] = segment
1359
+ continue
1360
+ }
1361
+ if (part !== segment) return null
1362
+ }
1363
+ return params
1364
+ }
1365
+
1366
+ /**
1367
+ * Collect one bounded JSON body.
1368
+ *
1369
+ * An absent body is `undefined` (the POST endpoints that take no arguments);
1370
+ * anything past the ceiling is reported as 413 and the stream is drained so the
1371
+ * socket does not stall.
1372
+ * @param {import('node:http').IncomingMessage} req
1373
+ * @param {number} [limit]
1374
+ * @returns {Promise<unknown>}
1375
+ */
1376
+ export async function readJsonBody(req, limit = MAX_BODY_BYTES) {
1377
+ /** @type {Buffer[]} */
1378
+ const chunks = []
1379
+ let size = 0
1380
+ for await (const chunk of req) {
1381
+ const buffer = typeof chunk === 'string' ? Buffer.from(chunk) : chunk
1382
+ size += buffer.byteLength
1383
+ if (size > limit) {
1384
+ req.resume()
1385
+ throw new HttpError(413, 'BODY_TOO_LARGE', `The request body exceeds ${String(limit)} bytes.`)
1386
+ }
1387
+ chunks.push(buffer)
1388
+ }
1389
+ const text = Buffer.concat(chunks).toString('utf8').trim()
1390
+ if (text === '') return undefined
1391
+ try {
1392
+ return JSON.parse(text)
1393
+ } catch {
1394
+ throw new HttpError(400, 'INVALID_JSON', 'The request body is not valid JSON.')
1395
+ }
1396
+ }
1397
+
1398
+ /**
1399
+ * Send one JSON response. `no-store` because every payload is live state.
1400
+ * @param {import('node:http').ServerResponse} res
1401
+ * @param {number} status
1402
+ * @param {unknown} body
1403
+ * @param {Record<string, string>} [headers]
1404
+ * @returns {void}
1405
+ */
1406
+ function sendJson(res, status, body, headers = {}) {
1407
+ res.statusCode = status
1408
+ res.setHeader('content-type', 'application/json; charset=utf-8')
1409
+ res.setHeader('cache-control', 'no-store')
1410
+ for (const [name, value] of Object.entries(headers)) res.setHeader(name, value)
1411
+ res.end(JSON.stringify(body ?? null))
1412
+ }
1413
+
1414
+ /**
1415
+ * The node:http adapter: parse, dispatch, serialise.
1416
+ *
1417
+ * `rejected` is the composition's own request fence (`connection.requestRejection`
1418
+ * in the shipped Web app). The routes live on the same trust boundary as the
1419
+ * GUI (doc §8.5), so a deployment that has the connection service refuses an
1420
+ * unauthenticated or untrusted request here rather than in each handler.
1421
+ * @param {ReturnType<typeof createApi>} api
1422
+ * @param {{rejected?: (req: import('node:http').IncomingMessage) => number|undefined, logger?: {warn?: Function}|null}} [options]
1423
+ * @returns {(req: import('node:http').IncomingMessage, res: import('node:http').ServerResponse) => Promise<void>}
1424
+ */
1425
+ export function createHandler(api, options = {}) {
1426
+ const rejected = typeof options.rejected === 'function' ? options.rejected : () => undefined
1427
+ const logger = options.logger ?? null
1428
+ return async (req, res) => {
1429
+ const rejection = rejected(req)
1430
+ if (rejection !== undefined) {
1431
+ res.statusCode = rejection
1432
+ res.end(rejection === 401 ? 'unauthorized' : 'forbidden')
1433
+ return
1434
+ }
1435
+ /** @type {URL} */
1436
+ let url
1437
+ try {
1438
+ url = new URL(String(req.url ?? '/'), 'http://server-connector.invalid')
1439
+ } catch {
1440
+ sendJson(res, 400, { error: { code: 'INVALID_URL', message: 'The request URL could not be parsed.' } })
1441
+ return
1442
+ }
1443
+ if (url.pathname !== API_PREFIX && !url.pathname.startsWith(`${API_PREFIX}/`)) {
1444
+ sendJson(res, 404, { error: { code: 'NOT_FOUND', message: `No route for ${url.pathname}.` } })
1445
+ return
1446
+ }
1447
+ // Decode each segment: the panel encodes ids, and a route parameter is the
1448
+ // decoded id it addresses (`srv-01` today, but the adapter stays generic).
1449
+ let relative = url.pathname.slice(API_PREFIX.length)
1450
+ if (relative === '') relative = '/'
1451
+ try {
1452
+ relative = relative
1453
+ .split('/')
1454
+ .map((segment) => (segment === '' ? segment : decodeURIComponent(segment)))
1455
+ .join('/')
1456
+ } catch {
1457
+ sendJson(res, 400, { error: { code: 'INVALID_URL', message: 'The request path is not valid percent-encoding.' } })
1458
+ return
1459
+ }
1460
+ /** @type {Record<string, string>} */
1461
+ const query = {}
1462
+ for (const [name, value] of url.searchParams) query[name] = value
1463
+ const method = String(req.method ?? 'GET').toUpperCase()
1464
+ try {
1465
+ const body = method === 'GET' || method === 'HEAD' ? undefined : await readJsonBody(req)
1466
+ const outcome = await api.dispatch({
1467
+ method,
1468
+ path: relative,
1469
+ query,
1470
+ body,
1471
+ })
1472
+ sendJson(res, outcome.status, outcome.body)
1473
+ } catch (error) {
1474
+ const failure = toHttpError(error)
1475
+ if (failure.status >= 500 && logger !== null && typeof logger.warn === 'function') {
1476
+ logger.warn('dsh-server-connector: %s %s failed: %s', method, relative, failure.message)
1477
+ }
1478
+ /** @type {Record<string, string>} */
1479
+ const headers = {}
1480
+ if (typeof failure.details.allow === 'string') headers.allow = failure.details.allow
1481
+ sendJson(
1482
+ res,
1483
+ failure.status,
1484
+ {
1485
+ error: {
1486
+ code: failure.code,
1487
+ message: failure.message,
1488
+ ...(Object.keys(failure.details).length === 0 ? {} : { details: failure.details }),
1489
+ },
1490
+ },
1491
+ headers,
1492
+ )
1493
+ }
1494
+ }
1495
+ }
1496
+
1497
+ /**
1498
+ * Register the routes on the Web carrier (§8.5).
1499
+ *
1500
+ * One prefix route serves all eleven endpoints: `webServer.register` patterns
1501
+ * are exact or prefix-only, and a single prefix keeps method dispatch, 405 and
1502
+ * 404 in one place.
1503
+ * @param {import('@deepseek-ai/cordis').Context} ctx - context that carries `webServer`.
1504
+ * @param {Parameters<typeof createApi>[0]} options
1505
+ * @returns {{api: ReturnType<typeof createApi>, dispose: () => void}} the dispatcher and the route disposer.
1506
+ */
1507
+ export function registerRoutes(ctx, options) {
1508
+ const api = createApi(options)
1509
+ const handler = createHandler(api, {
1510
+ // Read the fence per request: the connection service may register after
1511
+ // this plugin, and a plugin reload can replace it.
1512
+ rejected: (req) => {
1513
+ const connection = /** @type {any} */ (ctx.get('connection'))
1514
+ if (connection === null || connection === undefined) return undefined
1515
+ if (typeof connection.requestRejection !== 'function') return undefined
1516
+ return connection.requestRejection(req)
1517
+ },
1518
+ logger: ctx.logger ?? null,
1519
+ })
1520
+ const webServer = /** @type {any} */ (ctx).webServer
1521
+ const dispose = webServer.register({
1522
+ kind: 'prefix',
1523
+ path: API_PREFIX,
1524
+ handler,
1525
+ })
1526
+ return { api, dispose }
1527
+ }