dsh-server-connector 0.2.3 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/registry.js CHANGED
@@ -1,968 +1,968 @@
1
- // dsh-server-connector — server registry (design doc §3.1/§3.5).
2
- //
3
- // The registry owns exactly one thing: CRUD over server records in
4
- // `$DSH_HOME/server-connector/servers.json`. It knows nothing about SSH,
5
- // policy evaluation or DSH; `lib/tools.js` (a later phase) composes it.
6
- //
7
- // Guarantees:
8
- // - every mutation validates a complete record before anything is written;
9
- // - the write is an atomic replacement (temp file in the same directory +
10
- // rename), so a crash never leaves a half-written JSON document;
11
- // - mutations are serialised per instance, so two concurrent create() calls
12
- // cannot read the same document and silently lose one server;
13
- // - secrets are never persisted: password auth keeps only a `credentialRef`.
14
- //
15
- // File layout (doc §3.5):
16
- // $DSH_HOME/server-connector/servers.json
17
- // { "version": 1, "servers": [ ... ] }
18
-
19
- import { randomBytes } from 'node:crypto'
20
- import { mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises'
21
- import { homedir } from 'node:os'
22
- import { dirname, join, resolve } from 'node:path'
23
-
24
- import { MAX_CUSTOM_RULES, RuleError, validateCustomRule } from './policy.js'
25
-
26
- /** Schema version written to, and required from, `servers.json`. */
27
- export const REGISTRY_VERSION = 1
28
-
29
- /** Directory below `$DSH_HOME` that holds every file of this plugin (doc §3.5). */
30
- export const CONNECTOR_DIR_NAME = 'server-connector'
31
-
32
- /**
33
- * One tail promise per registry file, so every Registry instance in this
34
- * process serialises its mutations against the same document.
35
- * @type {Map<string, Promise<void>>}
36
- */
37
- const MUTATION_QUEUES = new Map()
38
-
39
- /** File name of the server registry. */
40
- export const SERVERS_FILE_NAME = 'servers.json'
41
-
42
- /** The three auth shapes of doc §3.1. */
43
- export const AUTH_KINDS = Object.freeze(['key', 'password', 'agent'])
44
-
45
- /** The three host-key verification modes of doc §6.4. */
46
- export const HOST_KEY_MODES = Object.freeze(['accept-new', 'verify', 'off'])
47
-
48
- /** The three per-server policy levels of doc §4.2. */
49
- export const DECISION_VALUES = Object.freeze(['allow', 'confirm', 'block'])
50
-
51
- /**
52
- * One server record exactly as persisted (doc §3.1).
53
- * @typedef {object} ServerRecord
54
- * @property {string} id - stable addressing key; names may change, ids must not.
55
- * @property {string} name - unique display name.
56
- * @property {string} host
57
- * @property {number} port
58
- * @property {string} username
59
- * @property {{kind:'key', privateKeyPath:string}|{kind:'password', credentialRef:string}|{kind:'agent'}} auth
60
- * @property {'accept-new'|'verify'|'off'} hostKeyMode
61
- * @property {string|null} defaultWorkDir
62
- * @property {boolean} enabled
63
- * @property {Record<string, 'allow'|'confirm'|'block'>} policy
64
- * @property {string[]} disabledRules
65
- * @property {import('./policy.js').CustomRule[]} customRules
66
- * @property {string} createdAt
67
- * @property {string|null} lastConnectedAt
68
- */
69
-
70
- /**
71
- * The parsed file: a schema version plus the records.
72
- * @typedef {object} RegistryDocument
73
- * @property {number} version
74
- * @property {ServerRecord[]} servers
75
- */
76
-
77
- /**
78
- * Machine-readable registry failure. `UNKNOWN_SERVER` and `AMBIGUOUS_SERVER`
79
- * are the tool-facing codes of doc §5.5; the rest describe input or storage
80
- * problems. The message is written for both the model and the panel: unknown
81
- * servers list every configured server, ambiguous ones list the candidates, so
82
- * a caller can self-correct in one turn (doc §5.2).
83
- * @typedef {'UNKNOWN_SERVER'|'AMBIGUOUS_SERVER'|'DUPLICATE_NAME'|'INVALID_SERVER'|'INVALID_REGISTRY'} RegistryErrorCode
84
- */
85
- export class RegistryError extends Error {
86
- /**
87
- * @param {RegistryErrorCode} code
88
- * @param {string} message
89
- * @param {{available?: string[], candidates?: string[]}} [details]
90
- */
91
- constructor(code, message, details = {}) {
92
- super(message)
93
- this.name = 'RegistryError'
94
- /** @type {RegistryErrorCode} */
95
- this.code = code
96
- /** @type {string[]|undefined} */
97
- this.available = details.available
98
- /** @type {string[]|undefined} */
99
- this.candidates = details.candidates
100
- }
101
- }
102
-
103
- /**
104
- * The harness home: `$DSH_HOME` when set, otherwise `~/.dsh` (the same default
105
- * the harness itself uses).
106
- * @param {NodeJS.ProcessEnv} [env]
107
- * @returns {string}
108
- */
109
- export function dshHome(env = process.env) {
110
- const configured = env.DSH_HOME
111
- return configured !== undefined && configured.trim() !== '' ? configured.trim() : join(homedir(), '.dsh')
112
- }
113
-
114
- /**
115
- * The plugin's data directory below the harness home.
116
- * @param {NodeJS.ProcessEnv} [env]
117
- * @returns {string}
118
- */
119
- export function connectorDirectory(env = process.env) {
120
- return join(dshHome(env), CONNECTOR_DIR_NAME)
121
- }
122
-
123
- /**
124
- * Default location of `servers.json` (doc §3.5).
125
- * @param {NodeJS.ProcessEnv} [env]
126
- * @returns {string}
127
- */
128
- export function defaultServersFile(env = process.env) {
129
- return join(connectorDirectory(env), SERVERS_FILE_NAME)
130
- }
131
-
132
- /** Fields a create() call accepts. */
133
- const CREATE_FIELDS = new Set([
134
- 'name',
135
- 'host',
136
- 'port',
137
- 'username',
138
- 'auth',
139
- 'hostKeyMode',
140
- 'enabled',
141
- 'defaultWorkDir',
142
- 'policy',
143
- 'disabledRules',
144
- 'customRules',
145
- ])
146
-
147
- /** Fields an update() call accepts; `id` and `createdAt` are immutable. */
148
- const UPDATE_FIELDS = new Set([...CREATE_FIELDS, 'lastConnectedAt'])
149
-
150
- /** Fields a persisted record carries. */
151
- const STORED_FIELDS = new Set(['id', ...CREATE_FIELDS, 'createdAt', 'lastConnectedAt'])
152
-
153
- /** Allowed keys inside each auth shape. */
154
- /** @type {Record<'key'|'password'|'agent', ReadonlySet<string>>} */
155
- const AUTH_FIELDS = {
156
- key: new Set(['kind', 'privateKeyPath']),
157
- password: new Set(['kind', 'credentialRef']),
158
- agent: new Set(['kind']),
159
- }
160
-
161
- /**
162
- * Fields that would put a secret into `servers.json`. They are rejected with a
163
- * dedicated message rather than a generic "unknown field" one, because the
164
- * constraint is security-relevant (doc §6.3).
165
- */
166
- const SECRET_FIELDS = new Set(['password', 'passphrase', 'privateKey', 'privateKeyContent', 'secret', 'token'])
167
-
168
- /**
169
- * Deep copy a record so callers cannot mutate this instance's state.
170
- * @template T
171
- * @param {T} value
172
- * @returns {T}
173
- */
174
- function clone(value) {
175
- return structuredClone(value)
176
- }
177
-
178
- /**
179
- * @param {unknown} value
180
- * @returns {value is Record<string, unknown>}
181
- */
182
- function isPlainObject(value) {
183
- return typeof value === 'object' && value !== null && !Array.isArray(value)
184
- }
185
-
186
- /**
187
- * @param {unknown} error
188
- * @param {string} code
189
- * @returns {boolean}
190
- */
191
- function isErrno(error, code) {
192
- return isPlainObject(error) && error.code === code
193
- }
194
-
195
- /**
196
- * @param {unknown} value
197
- * @returns {value is 'allow'|'confirm'|'block'}
198
- */
199
- function isDecisionValue(value) {
200
- return value === 'allow' || value === 'confirm' || value === 'block'
201
- }
202
-
203
- /**
204
- * @param {string} field
205
- * @param {RegistryErrorCode} errorCode
206
- * @returns {RegistryError}
207
- */
208
- function plaintextError(field, errorCode) {
209
- return new RegistryError(
210
- errorCode,
211
- `Refusing to store "${field}" in servers.json: plaintext secrets are never stored. ` +
212
- 'Use auth.kind "key" with privateKeyPath, or auth.kind "password" with a credentialRef backed by ctx.credentials.',
213
- )
214
- }
215
-
216
- /**
217
- * Reject unknown fields and plaintext secrets before any value is used.
218
- * @param {Record<string, unknown>} source
219
- * @param {ReadonlySet<string>} allowed
220
- * @param {RegistryErrorCode} errorCode
221
- * @param {string} label
222
- * @returns {void}
223
- */
224
- function assertKnownFields(source, allowed, errorCode, label) {
225
- for (const key of Object.keys(source)) {
226
- if (allowed.has(key)) continue
227
- if (SECRET_FIELDS.has(key)) throw plaintextError(key, errorCode)
228
- throw new RegistryError(
229
- errorCode,
230
- `Unknown field "${key}" in ${label}. Allowed fields: ${[...allowed].join(', ')}.`,
231
- )
232
- }
233
- }
234
-
235
- /**
236
- * @param {unknown} value
237
- * @param {string} field
238
- * @param {RegistryErrorCode} errorCode
239
- * @returns {string}
240
- */
241
- function requiredString(value, field, errorCode) {
242
- if (typeof value !== 'string' || value.trim() === '') {
243
- throw new RegistryError(errorCode, `${field} must be a non-empty string.`)
244
- }
245
- return value.trim()
246
- }
247
-
248
- /**
249
- * @param {unknown} value
250
- * @param {RegistryErrorCode} errorCode
251
- * @returns {string}
252
- */
253
- function normalizeHost(value, errorCode) {
254
- const host = requiredString(value, 'host', errorCode)
255
- if (/\s/.test(host)) throw new RegistryError(errorCode, 'host must not contain whitespace.')
256
- return host
257
- }
258
-
259
- /**
260
- * @param {unknown} value
261
- * @param {RegistryErrorCode} errorCode
262
- * @returns {number}
263
- */
264
- function normalizePort(value, errorCode) {
265
- if (value === undefined) return 22
266
- if (typeof value !== 'number' || !Number.isInteger(value) || value < 1 || value > 65535) {
267
- throw new RegistryError(errorCode, 'port must be an integer between 1 and 65535.')
268
- }
269
- return value
270
- }
271
-
272
- /**
273
- * @param {unknown} value
274
- * @param {RegistryErrorCode} errorCode
275
- * @returns {'accept-new'|'verify'|'off'}
276
- */
277
- function normalizeHostKeyMode(value, errorCode) {
278
- if (value === undefined) return 'accept-new'
279
- if (value === 'accept-new' || value === 'verify' || value === 'off') return value
280
- throw new RegistryError(errorCode, `hostKeyMode must be one of ${HOST_KEY_MODES.join(', ')}.`)
281
- }
282
-
283
- /**
284
- * @param {unknown} value
285
- * @param {RegistryErrorCode} errorCode
286
- * @returns {string|null}
287
- */
288
- function normalizeWorkDir(value, errorCode) {
289
- if (value === undefined || value === null) return null
290
- if (typeof value !== 'string') throw new RegistryError(errorCode, 'defaultWorkDir must be a string or null.')
291
- const trimmed = value.trim()
292
- return trimmed === '' ? null : trimmed
293
- }
294
-
295
- /**
296
- * The enable switch (doc §3.1, enable-switch spec).
297
- *
298
- * Absent means enabled: files written before the switch existed carry no
299
- * `enabled` key, and silently disabling every server on load would be a
300
- * destructive surprise.
301
- * @param {unknown} value
302
- * @param {RegistryErrorCode} errorCode
303
- * @returns {boolean}
304
- */
305
- function normalizeEnabled(value, errorCode) {
306
- if (value === undefined || value === null) return true
307
- if (typeof value !== 'boolean') throw new RegistryError(errorCode, 'enabled must be a boolean.')
308
- return value
309
- }
310
-
311
- /**
312
- * @param {unknown} value
313
- * @param {string} field
314
- * @param {RegistryErrorCode} errorCode
315
- * @returns {string}
316
- */
317
- function normalizeTimestamp(value, field, errorCode) {
318
- if (typeof value !== 'string' || value.trim() === '') {
319
- throw new RegistryError(errorCode, `${field} must be an ISO-8601 timestamp.`)
320
- }
321
- const parsed = new Date(value)
322
- if (Number.isNaN(parsed.getTime())) {
323
- throw new RegistryError(errorCode, `${field} must be an ISO-8601 timestamp.`)
324
- }
325
- return parsed.toISOString()
326
- }
327
-
328
- /**
329
- * @param {unknown} value
330
- * @param {string} field
331
- * @param {RegistryErrorCode} errorCode
332
- * @returns {string|null}
333
- */
334
- function normalizeOptionalTimestamp(value, field, errorCode) {
335
- if (value === undefined || value === null) return null
336
- return normalizeTimestamp(value, field, errorCode)
337
- }
338
-
339
- /**
340
- * The grammar every credential ref must satisfy: a POSIX shell identifier.
341
- *
342
- * This is @deepseek-ai/dsh-credentials' own contract (`REF_PATTERN`), not an
343
- * invention of this plugin. Keeping a local copy is deliberate: a ref this
344
- * plugin persists is later parsed by that package, and a mismatch is not a
345
- * local error — it prevents `credentials` from activating, which fails the whole
346
- * Host startup.
347
- */
348
- const CREDENTIAL_REF_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/
349
-
350
- /**
351
- * The ref this plugin derives for a server's password (doc §6.3).
352
- *
353
- * `server-connector:<id>:password` was the old shape and it was illegal: the
354
- * colon and the dash fall outside the grammar above, so persisting it broke the
355
- * next startup. The id is folded into a single identifier instead
356
- * (`DSH_SERVER_CONNECTOR_SRV_01_PASSWORD`).
357
- * @param {string} serverId - e.g. `srv-01`
358
- * @returns {string}
359
- */
360
- function deriveCredentialRef(serverId) {
361
- const normalised = String(serverId)
362
- .toUpperCase()
363
- .replace(/[^A-Z0-9]+/g, '_')
364
- .replace(/^_+|_+$/g, '')
365
- const safe = normalised === '' ? 'SERVER' : normalised
366
- return `DSH_SERVER_CONNECTOR_${safe}_PASSWORD`
367
- }
368
-
369
- /**
370
- * Validate a caller-supplied credential ref against the same grammar.
371
- *
372
- * A caller may pass its own ref (an environment-variable name, typically), but
373
- * an illegal one must be refused here rather than stored: storing it would
374
- * break the next startup instead of this request.
375
- * @param {unknown} value
376
- * @param {RegistryErrorCode} errorCode
377
- * @returns {string}
378
- */
379
- function assertCredentialRef(value, errorCode) {
380
- const ref = requiredString(value, 'auth.credentialRef', errorCode)
381
- if (!CREDENTIAL_REF_PATTERN.test(ref)) {
382
- throw new RegistryError(
383
- errorCode,
384
- `auth.credentialRef ${JSON.stringify(ref)} must be a POSIX identifier ` +
385
- `(letters, digits and underscores, not starting with a digit), because DSH credentials rejects ` +
386
- `any other ref and the rejection fails the whole startup.`,
387
- )
388
- }
389
- return ref
390
- }
391
-
392
- /**
393
- * Read-time recovery for a ref an older build persisted (doc §6.3).
394
- *
395
- * Before 0.2.1 this plugin derived `server-connector:<id>:password`, which is
396
- * outside the grammar. Those records exist on disk in the wild, and refusing
397
- * them at load would leave the plugin unable to start — trading a Host crash for
398
- * a plugin that never loads. Instead the illegal legacy shape is mapped onto the
399
- * derived one, so the record keeps working and the next write persists the fixed
400
- * ref.
401
- *
402
- * Only the exact legacy shape is migrated. Any other illegal ref is still
403
- * refused: silently rewriting a ref this plugin never wrote could point at a
404
- * different secret.
405
- * @param {unknown} value
406
- * @param {string} serverId
407
- * @param {RegistryErrorCode} errorCode
408
- * @returns {string}
409
- */
410
- function migrateCredentialRef(value, serverId, errorCode) {
411
- const ref = requiredString(value, 'auth.credentialRef', errorCode)
412
- if (CREDENTIAL_REF_PATTERN.test(ref)) return ref
413
- if (ref === `server-connector:${serverId}:password`) return deriveCredentialRef(serverId)
414
- return assertCredentialRef(ref, errorCode)
415
- }
416
-
417
- /**
418
- * @param {unknown} value
419
- * @param {string} serverId
420
- * @param {RegistryErrorCode} errorCode
421
- * @param {{migrate?: boolean}} [options] - `migrate` is set only by the load path.
422
- * @returns {{kind:'key', privateKeyPath:string}|{kind:'password', credentialRef:string}|{kind:'agent'}}
423
- */
424
- function normalizeAuth(value, serverId, errorCode, options = {}) {
425
- if (value === undefined) return { kind: 'agent' }
426
- if (!isPlainObject(value)) throw new RegistryError(errorCode, 'auth must be an object.')
427
- const kind = value.kind
428
- if (kind === 'key') {
429
- assertKnownFields(value, AUTH_FIELDS.key, errorCode, 'auth')
430
- return { kind: 'key', privateKeyPath: requiredString(value.privateKeyPath, 'auth.privateKeyPath', errorCode) }
431
- }
432
- if (kind === 'password') {
433
- assertKnownFields(value, AUTH_FIELDS.password, errorCode, 'auth')
434
- // Doc §6.3: the record keeps a reference, never the password itself.
435
- //
436
- // The ref MUST be a POSIX shell identifier: @deepseek-ai/dsh-credentials
437
- // validates every stored ref with /^[A-Za-z_][A-Za-z0-9_]*$/ while loading
438
- // credentials.yaml, and a ref outside that grammar throws during
439
- // `credentials` activation. Because `credentials` is a required plugin, that
440
- // takes the entire Host down at startup — a persisted bad ref bricks DSH
441
- // until the file is edited by hand. Derive and validate accordingly.
442
- const credentialRef =
443
- value.credentialRef === undefined
444
- ? deriveCredentialRef(serverId)
445
- : options.migrate === true
446
- ? migrateCredentialRef(value.credentialRef, serverId, errorCode)
447
- : assertCredentialRef(value.credentialRef, errorCode)
448
- return { kind: 'password', credentialRef }
449
- }
450
- if (kind === 'agent') {
451
- assertKnownFields(value, AUTH_FIELDS.agent, errorCode, 'auth')
452
- return { kind: 'agent' }
453
- }
454
- throw new RegistryError(errorCode, `auth.kind must be one of ${AUTH_KINDS.join(', ')}.`)
455
- }
456
-
457
- /**
458
- * @param {unknown} value
459
- * @param {RegistryErrorCode} errorCode
460
- * @returns {Record<string, 'allow'|'confirm'|'block'>}
461
- */
462
- function normalizePolicy(value, errorCode) {
463
- if (value === undefined) return {}
464
- if (!isPlainObject(value)) {
465
- throw new RegistryError(errorCode, 'policy must be an object of ruleId → allow|confirm|block.')
466
- }
467
- /** @type {Record<string, 'allow'|'confirm'|'block'>} */
468
- const policy = {}
469
- for (const [ruleId, decision] of Object.entries(value)) {
470
- if (ruleId.trim() === '') throw new RegistryError(errorCode, 'policy keys must be non-empty rule ids.')
471
- if (!isDecisionValue(decision)) {
472
- throw new RegistryError(errorCode, `policy.${ruleId} must be one of ${DECISION_VALUES.join(', ')}.`)
473
- }
474
- policy[ruleId] = decision
475
- }
476
- return policy
477
- }
478
-
479
- /**
480
- * @param {unknown} value
481
- * @param {RegistryErrorCode} errorCode
482
- * @returns {string[]}
483
- */
484
- function normalizeDisabledRules(value, errorCode) {
485
- if (value === undefined) return []
486
- if (!Array.isArray(value)) throw new RegistryError(errorCode, 'disabledRules must be an array of rule ids.')
487
- /** @type {string[]} */
488
- const ids = []
489
- for (const item of value) {
490
- if (typeof item !== 'string' || item.trim() === '') {
491
- throw new RegistryError(errorCode, 'disabledRules must contain non-empty rule ids.')
492
- }
493
- const id = item.trim()
494
- if (!ids.includes(id)) ids.push(id)
495
- }
496
- return ids
497
- }
498
-
499
- /**
500
- * Validate the custom-rule list of one record (doc §4.4.2).
501
- *
502
- * Ids are unique per server and must not collide with a built-in id. The
503
- * decision may only be `confirm` or `block` — a rule that permits what the
504
- * engine already permits buys nothing.
505
- * @param {unknown} value
506
- * @param {RegistryErrorCode} errorCode
507
- * @returns {import('./policy.js').CustomRule[]}
508
- */
509
- function normalizeCustomRules(value, errorCode) {
510
- if (value === undefined) return []
511
- if (!Array.isArray(value)) throw new RegistryError(errorCode, 'customRules must be an array of rules.')
512
- if (value.length > MAX_CUSTOM_RULES) {
513
- throw new RegistryError(errorCode, `customRules may hold at most ${MAX_CUSTOM_RULES} rules.`)
514
- }
515
- /** @type {import('./policy.js').CustomRule[]} */
516
- const rules = []
517
- /** @type {string[]} */
518
- const seen = []
519
- for (const item of value) {
520
- /** @type {import('./policy.js').CustomRule} */
521
- let rule
522
- try {
523
- rule = validateCustomRule(item, { takenIds: seen })
524
- } catch (error) {
525
- if (error instanceof RuleError) throw new RegistryError(errorCode, `customRules: ${error.message}`)
526
- throw error
527
- }
528
- seen.push(rule.id)
529
- rules.push(rule)
530
- }
531
- return rules
532
- }
533
-
534
- /**
535
- * Validate and canonicalise one complete record. Create, update and load all
536
- * go through this function, so a stored record and a freshly created one have
537
- * exactly the same shape.
538
- * @param {Record<string, unknown>} source
539
- * @param {{
540
- * allowed: ReadonlySet<string>,
541
- * id: string,
542
- * errorCode: RegistryErrorCode,
543
- * label: string,
544
- * createdAt?: string,
545
- * migrate?: boolean,
546
- * }} options
547
- * @returns {ServerRecord}
548
- */
549
- function normalizeRecord(source, options) {
550
- const { allowed, id, errorCode, label } = options
551
- if (!isPlainObject(source)) throw new RegistryError(errorCode, `${label} must be an object.`)
552
- assertKnownFields(source, allowed, errorCode, label)
553
- const createdAt =
554
- options.createdAt !== undefined
555
- ? normalizeTimestamp(options.createdAt, 'createdAt', errorCode)
556
- : normalizeTimestamp(source.createdAt, 'createdAt', errorCode)
557
- return {
558
- id,
559
- name: requiredString(source.name, 'name', errorCode),
560
- host: normalizeHost(source.host, errorCode),
561
- port: normalizePort(source.port, errorCode),
562
- username: requiredString(source.username, 'username', errorCode),
563
- auth: normalizeAuth(source.auth, id, errorCode, { migrate: options.migrate === true }),
564
- hostKeyMode: normalizeHostKeyMode(source.hostKeyMode, errorCode),
565
- defaultWorkDir: normalizeWorkDir(source.defaultWorkDir, errorCode),
566
- enabled: normalizeEnabled(source.enabled, errorCode),
567
- policy: normalizePolicy(source.policy, errorCode),
568
- disabledRules: normalizeDisabledRules(source.disabledRules, errorCode),
569
- customRules: normalizeCustomRules(source.customRules, errorCode),
570
- createdAt,
571
- lastConnectedAt: normalizeOptionalTimestamp(source.lastConnectedAt, 'lastConnectedAt', errorCode),
572
- }
573
- }
574
-
575
- /**
576
- * Case- and whitespace-insensitive comparison key for the unique display name.
577
- * @param {string} name
578
- * @returns {string}
579
- */
580
- function normalizeNameKey(name) {
581
- return name.trim().toLowerCase()
582
- }
583
-
584
- /**
585
- * @param {ServerRecord[]} servers
586
- * @returns {string}
587
- */
588
- function describeServers(servers) {
589
- if (servers.length === 0) return 'none configured'
590
- return servers.map((server) => `${server.id} (${server.name})`).join(', ')
591
- }
592
-
593
- /**
594
- * @param {string} id
595
- * @param {ServerRecord[]} servers
596
- * @returns {RegistryError}
597
- */
598
- function unknownServer(id, servers) {
599
- return new RegistryError('UNKNOWN_SERVER', `Unknown server "${id}". Available servers: ${describeServers(servers)}.`, {
600
- available: servers.map((server) => server.id),
601
- })
602
- }
603
-
604
- /**
605
- * @param {ServerRecord[]} servers
606
- * @param {string} name
607
- * @param {string|null} exceptId - the record being renamed, or null on create.
608
- * @returns {void}
609
- */
610
- function assertNameAvailable(servers, name, exceptId) {
611
- const key = normalizeNameKey(name)
612
- for (const server of servers) {
613
- if (server.id === exceptId) continue
614
- if (normalizeNameKey(server.name) === key) {
615
- throw new RegistryError(
616
- 'DUPLICATE_NAME',
617
- `A server named ${JSON.stringify(name)} already exists (id ${server.id}). Server names are unique.`,
618
- )
619
- }
620
- }
621
- }
622
-
623
- /**
624
- * Next sequential id, e.g. `srv-01`. The width grows to fit the number.
625
- * @param {ServerRecord[]} servers
626
- * @returns {string}
627
- */
628
- function nextServerId(servers) {
629
- let highest = 0
630
- for (const server of servers) {
631
- const match = /^srv-(\d+)$/.exec(server.id)
632
- if (match === null) continue
633
- const digits = match[1]
634
- if (digits === undefined) continue
635
- const value = Number(digits)
636
- if (Number.isSafeInteger(value) && value > highest) highest = value
637
- }
638
- const next = highest + 1
639
- const width = next >= 100 ? String(next).length : 2
640
- return `srv-${String(next).padStart(width, '0')}`
641
- }
642
-
643
- /**
644
- * Drop undefined patch entries and reject immutable or unknown fields before
645
- * they are merged into a record.
646
- * @param {Record<string, unknown>} patch
647
- * @returns {Record<string, unknown>}
648
- */
649
- function normalizePatch(patch) {
650
- if (!isPlainObject(patch)) throw new RegistryError('INVALID_SERVER', 'update patch must be an object.')
651
- /** @type {Record<string, unknown>} */
652
- const changes = {}
653
- for (const [key, value] of Object.entries(patch)) {
654
- if (value === undefined) continue
655
- if (key === 'id' || key === 'createdAt') {
656
- throw new RegistryError('INVALID_SERVER', `${key} is immutable; create a new server instead.`)
657
- }
658
- if (!UPDATE_FIELDS.has(key)) {
659
- if (SECRET_FIELDS.has(key)) throw plaintextError(key, 'INVALID_SERVER')
660
- throw new RegistryError('INVALID_SERVER', `Unknown field "${key}" in update.`)
661
- }
662
- changes[key] = value
663
- }
664
- return changes
665
- }
666
-
667
- /**
668
- * CRUD facade over one `servers.json` file.
669
- *
670
- * All methods are asynchronous; reads go straight to disk, mutations are
671
- * queued per file and committed atomically. Use {@link Registry.resolve} for
672
- * the forgiving server addressing described in doc §5.2.
673
- */
674
- export class Registry {
675
- /** @type {string} */
676
- #file
677
- /**
678
- * @param {{file?: string}} [options] - `file` overrides the default path and is
679
- * what the tests use; production code relies on `$DSH_HOME`.
680
- */
681
- constructor(options = {}) {
682
- this.#file = options.file ?? defaultServersFile()
683
- }
684
-
685
- /** Absolute path of the registry file this instance reads and writes. */
686
- get file() {
687
- return this.#file
688
- }
689
-
690
- /**
691
- * Every server, in file order.
692
- * @returns {Promise<ServerRecord[]>}
693
- */
694
- async list() {
695
- const document = await this.#load()
696
- return document.servers.map((server) => clone(server))
697
- }
698
-
699
- /**
700
- * Exact lookup by stable id.
701
- * @param {string} id
702
- * @returns {Promise<ServerRecord|null>} null when no server has that id.
703
- */
704
- async get(id) {
705
- const document = await this.#load()
706
- const record = document.servers.find((server) => server.id === id)
707
- return record === undefined ? null : clone(record)
708
- }
709
-
710
- /**
711
- * Forgiving lookup for a tool's `server` argument (doc §5.2):
712
- * 1. exact `id`;
713
- * 2. exact `name` (case-insensitive);
714
- * 3. unique prefix of an id or a name (case-insensitive);
715
- * 4. zero matches → `UNKNOWN_SERVER`, message listing every server;
716
- * 5. several matches → `AMBIGUOUS_SERVER`, message listing the candidates.
717
- *
718
- * Exact id/name matches win over prefix matches, so a name that prefixes
719
- * another name remains addressable.
720
- * @param {string} query
721
- * @returns {Promise<ServerRecord>}
722
- * @throws {RegistryError} `INVALID_SERVER`, `UNKNOWN_SERVER` or `AMBIGUOUS_SERVER`.
723
- */
724
- async resolve(query) {
725
- if (typeof query !== 'string' || query.trim() === '') {
726
- throw new RegistryError('INVALID_SERVER', 'server must be a non-empty id, name or unique prefix.')
727
- }
728
- const wanted = query.trim()
729
- const document = await this.#load()
730
- const servers = document.servers
731
-
732
- const exactId = servers.find((server) => server.id === wanted)
733
- if (exactId !== undefined) return clone(exactId)
734
-
735
- const wantedName = wanted.toLowerCase()
736
- const exactName = servers.find((server) => server.name.toLowerCase() === wantedName)
737
- if (exactName !== undefined) return clone(exactName)
738
-
739
- const prefix = wanted.toLowerCase()
740
- const candidates = servers.filter(
741
- (server) => server.id.toLowerCase().startsWith(prefix) || server.name.toLowerCase().startsWith(prefix),
742
- )
743
- const only = candidates.length === 1 ? candidates[0] : undefined
744
- if (only !== undefined) return clone(only)
745
- if (candidates.length === 0) throw unknownServer(wanted, servers)
746
- throw new RegistryError(
747
- 'AMBIGUOUS_SERVER',
748
- `Server "${wanted}" is ambiguous: matches ${candidates
749
- .map((server) => `${server.id} (${server.name})`)
750
- .join(', ')}. Use the full id or name.`,
751
- { candidates: candidates.map((server) => server.id) },
752
- )
753
- }
754
-
755
- /**
756
- * Create a server. The id is assigned by the registry; `createdAt` is now.
757
- * @param {Record<string, unknown>} input
758
- * @returns {Promise<ServerRecord>} the stored record.
759
- * @throws {RegistryError} `INVALID_SERVER` or `DUPLICATE_NAME`.
760
- */
761
- async create(input) {
762
- return this.#mutate((document) => {
763
- const id = nextServerId(document.servers)
764
- const record = normalizeRecord(input, {
765
- allowed: CREATE_FIELDS,
766
- id,
767
- errorCode: 'INVALID_SERVER',
768
- label: 'server',
769
- createdAt: new Date().toISOString(),
770
- })
771
- assertNameAvailable(document.servers, record.name, null)
772
- document.servers.push(record)
773
- return clone(record)
774
- })
775
- }
776
-
777
- /**
778
- * Change some fields of one server. `id` and `createdAt` cannot be changed;
779
- * omitted fields keep their current value. The merged record is validated as
780
- * a whole before it replaces the stored one.
781
- * @param {string} id
782
- * @param {Record<string, unknown>} patch
783
- * @returns {Promise<ServerRecord>} the stored record.
784
- * @throws {RegistryError} `UNKNOWN_SERVER`, `INVALID_SERVER` or `DUPLICATE_NAME`.
785
- */
786
- async update(id, patch) {
787
- return this.#mutate((document) => {
788
- const index = document.servers.findIndex((server) => server.id === id)
789
- const existing = index === -1 ? undefined : document.servers[index]
790
- if (existing === undefined) throw unknownServer(id, document.servers)
791
- const changes = normalizePatch(patch)
792
- /** @type {Record<string, unknown>} */
793
- const merged = { ...existing }
794
- for (const [key, value] of Object.entries(changes)) merged[key] = value
795
- const record = normalizeRecord(merged, {
796
- allowed: STORED_FIELDS,
797
- id: existing.id,
798
- errorCode: 'INVALID_SERVER',
799
- label: 'server',
800
- createdAt: existing.createdAt,
801
- })
802
- assertNameAvailable(document.servers, record.name, existing.id)
803
- document.servers[index] = record
804
- return clone(record)
805
- })
806
- }
807
-
808
- /**
809
- * Delete one server, returning the record that was removed.
810
- * @param {string} id
811
- * @returns {Promise<ServerRecord>}
812
- * @throws {RegistryError} `UNKNOWN_SERVER`.
813
- */
814
- async remove(id) {
815
- return this.#mutate((document) => {
816
- const index = document.servers.findIndex((server) => server.id === id)
817
- const existing = index === -1 ? undefined : document.servers[index]
818
- if (existing === undefined) throw unknownServer(id, document.servers)
819
- document.servers.splice(index, 1)
820
- return clone(existing)
821
- })
822
- }
823
-
824
- /**
825
- * Read and validate the whole file. A missing or empty file is an empty
826
- * registry; invalid JSON, an unknown version, duplicate ids/names or a
827
- * malformed record is reported as `INVALID_REGISTRY` instead of being
828
- * silently rewritten.
829
- * @returns {Promise<RegistryDocument>}
830
- */
831
- async #load() {
832
- /** @type {string} */
833
- let text
834
- try {
835
- text = await readFile(this.#file, 'utf8')
836
- } catch (error) {
837
- if (isErrno(error, 'ENOENT')) return { version: REGISTRY_VERSION, servers: [] }
838
- throw error
839
- }
840
- if (text.trim() === '') return { version: REGISTRY_VERSION, servers: [] }
841
-
842
- /** @type {unknown} */
843
- let parsed
844
- try {
845
- parsed = JSON.parse(text)
846
- } catch {
847
- throw new RegistryError('INVALID_REGISTRY', `${this.#file} is not valid JSON; fix or remove it.`)
848
- }
849
- if (!isPlainObject(parsed)) {
850
- throw new RegistryError('INVALID_REGISTRY', `${this.#file} must contain a JSON object.`)
851
- }
852
- if (parsed.version !== REGISTRY_VERSION) {
853
- throw new RegistryError(
854
- 'INVALID_REGISTRY',
855
- `${this.#file} has unsupported version ${String(parsed.version)}; expected ${REGISTRY_VERSION}.`,
856
- )
857
- }
858
- if (!Array.isArray(parsed.servers)) {
859
- throw new RegistryError('INVALID_REGISTRY', `${this.#file} must contain a "servers" array.`)
860
- }
861
-
862
- /** @type {ServerRecord[]} */
863
- const servers = []
864
- const seenIds = new Set()
865
- const seenNames = new Set()
866
- for (let index = 0; index < parsed.servers.length; index += 1) {
867
- const raw = parsed.servers[index]
868
- if (!isPlainObject(raw)) {
869
- throw new RegistryError('INVALID_REGISTRY', `${this.#file}: servers[${index}] is not an object.`)
870
- }
871
- const recordId = requiredString(raw.id, `servers[${index}].id`, 'INVALID_REGISTRY')
872
- const record = normalizeRecord(raw, {
873
- allowed: STORED_FIELDS,
874
- id: recordId,
875
- errorCode: 'INVALID_REGISTRY',
876
- label: `servers[${index}]`,
877
- // Loading is the one path allowed to repair a legacy credential ref;
878
- // writes are strict so this plugin can never persist a bad one again.
879
- migrate: true,
880
- })
881
- if (seenIds.has(record.id)) {
882
- throw new RegistryError('INVALID_REGISTRY', `${this.#file}: duplicate server id "${record.id}".`)
883
- }
884
- seenIds.add(record.id)
885
- const nameKey = normalizeNameKey(record.name)
886
- if (seenNames.has(nameKey)) {
887
- throw new RegistryError('INVALID_REGISTRY', `${this.#file}: duplicate server name ${record.name}.`)
888
- }
889
- seenNames.add(nameKey)
890
- servers.push(record)
891
- }
892
- const document = { version: REGISTRY_VERSION, servers }
893
- // Persist a repaired legacy ref immediately. Without this the migration
894
- // lives only in memory: every later read re-runs it, and a copy of the file
895
- // taken before the first write would still carry the illegal ref that breaks
896
- // the next startup. The rewrite is conditional so a clean file is never
897
- // touched (its mtime stays put).
898
- if (this.#migrated(text, document)) await this.#save(document)
899
- return document
900
- }
901
-
902
- /**
903
- * Whether the loaded document differs from what is on disk, i.e. whether the
904
- * legacy-ref migration actually changed a record and the file must be rewritten.
905
- * @param {string} text - the raw file text that was parsed.
906
- * @param {RegistryDocument} document - the normalised document.
907
- * @returns {boolean}
908
- */
909
- #migrated(text, document) {
910
- for (const record of document.servers) {
911
- if (record.auth.kind !== 'password') continue
912
- const ref = record.auth.credentialRef
913
- // The normalised ref is legal by construction; if the raw text does not
914
- // contain it, this record was migrated.
915
- if (!text.includes(JSON.stringify(ref))) return true
916
- }
917
- return false
918
- }
919
-
920
- /**
921
- * Commit the document by writing a sibling temp file and renaming it over
922
- * the registry. Readers only ever observe the old or the new complete file.
923
- * @param {RegistryDocument} document
924
- * @returns {Promise<void>}
925
- */
926
- async #save(document) {
927
- await mkdir(dirname(this.#file), { recursive: true, mode: 0o700 })
928
- const temporary = `${this.#file}.${process.pid}.${randomBytes(6).toString('hex')}.tmp`
929
- try {
930
- await writeFile(temporary, `${JSON.stringify(document, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 })
931
- await rename(temporary, this.#file)
932
- } catch (error) {
933
- await unlink(temporary).catch(() => {})
934
- throw error
935
- }
936
- }
937
-
938
- /**
939
- * Run one read-modify-write operation after every earlier mutation finished.
940
- *
941
- * The queue is keyed by the absolute file path, not by this instance: the
942
- * tools surface, the HTTP routes and the tests each build their own Registry,
943
- * and two instances on the same file must not lose each other's writes.
944
- * @template T
945
- * @param {(document: RegistryDocument) => T} mutate
946
- * @returns {Promise<T>}
947
- */
948
- async #mutate(mutate) {
949
- const key = resolve(this.#file)
950
- const previous = MUTATION_QUEUES.get(key) ?? Promise.resolve()
951
- const operation = previous.then(async () => {
952
- const document = await this.#load()
953
- const result = mutate(document)
954
- await this.#save(document)
955
- return result
956
- })
957
- const settled = operation.then(
958
- () => undefined,
959
- () => undefined,
960
- )
961
- MUTATION_QUEUES.set(key, settled)
962
- // Keep the map from growing once a file goes quiet.
963
- void settled.then(() => {
964
- if (MUTATION_QUEUES.get(key) === settled) MUTATION_QUEUES.delete(key)
965
- })
966
- return operation
967
- }
968
- }
1
+ // dsh-server-connector — server registry (design doc §3.1/§3.5).
2
+ //
3
+ // The registry owns exactly one thing: CRUD over server records in
4
+ // `$DSH_HOME/server-connector/servers.json`. It knows nothing about SSH,
5
+ // policy evaluation or DSH; `lib/tools.js` (a later phase) composes it.
6
+ //
7
+ // Guarantees:
8
+ // - every mutation validates a complete record before anything is written;
9
+ // - the write is an atomic replacement (temp file in the same directory +
10
+ // rename), so a crash never leaves a half-written JSON document;
11
+ // - mutations are serialised per instance, so two concurrent create() calls
12
+ // cannot read the same document and silently lose one server;
13
+ // - secrets are never persisted: password auth keeps only a `credentialRef`.
14
+ //
15
+ // File layout (doc §3.5):
16
+ // $DSH_HOME/server-connector/servers.json
17
+ // { "version": 1, "servers": [ ... ] }
18
+
19
+ import { randomBytes } from 'node:crypto'
20
+ import { mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises'
21
+ import { homedir } from 'node:os'
22
+ import { dirname, join, resolve } from 'node:path'
23
+
24
+ import { MAX_CUSTOM_RULES, RuleError, validateCustomRule } from './policy.js'
25
+
26
+ /** Schema version written to, and required from, `servers.json`. */
27
+ export const REGISTRY_VERSION = 1
28
+
29
+ /** Directory below `$DSH_HOME` that holds every file of this plugin (doc §3.5). */
30
+ export const CONNECTOR_DIR_NAME = 'server-connector'
31
+
32
+ /**
33
+ * One tail promise per registry file, so every Registry instance in this
34
+ * process serialises its mutations against the same document.
35
+ * @type {Map<string, Promise<void>>}
36
+ */
37
+ const MUTATION_QUEUES = new Map()
38
+
39
+ /** File name of the server registry. */
40
+ export const SERVERS_FILE_NAME = 'servers.json'
41
+
42
+ /** The three auth shapes of doc §3.1. */
43
+ export const AUTH_KINDS = Object.freeze(['key', 'password', 'agent'])
44
+
45
+ /** The three host-key verification modes of doc §6.4. */
46
+ export const HOST_KEY_MODES = Object.freeze(['accept-new', 'verify', 'off'])
47
+
48
+ /** The three per-server policy levels of doc §4.2. */
49
+ export const DECISION_VALUES = Object.freeze(['allow', 'confirm', 'block'])
50
+
51
+ /**
52
+ * One server record exactly as persisted (doc §3.1).
53
+ * @typedef {object} ServerRecord
54
+ * @property {string} id - stable addressing key; names may change, ids must not.
55
+ * @property {string} name - unique display name.
56
+ * @property {string} host
57
+ * @property {number} port
58
+ * @property {string} username
59
+ * @property {{kind:'key', privateKeyPath:string}|{kind:'password', credentialRef:string}|{kind:'agent'}} auth
60
+ * @property {'accept-new'|'verify'|'off'} hostKeyMode
61
+ * @property {string|null} defaultWorkDir
62
+ * @property {boolean} enabled
63
+ * @property {Record<string, 'allow'|'confirm'|'block'>} policy
64
+ * @property {string[]} disabledRules
65
+ * @property {import('./policy.js').CustomRule[]} customRules
66
+ * @property {string} createdAt
67
+ * @property {string|null} lastConnectedAt
68
+ */
69
+
70
+ /**
71
+ * The parsed file: a schema version plus the records.
72
+ * @typedef {object} RegistryDocument
73
+ * @property {number} version
74
+ * @property {ServerRecord[]} servers
75
+ */
76
+
77
+ /**
78
+ * Machine-readable registry failure. `UNKNOWN_SERVER` and `AMBIGUOUS_SERVER`
79
+ * are the tool-facing codes of doc §5.5; the rest describe input or storage
80
+ * problems. The message is written for both the model and the panel: unknown
81
+ * servers list every configured server, ambiguous ones list the candidates, so
82
+ * a caller can self-correct in one turn (doc §5.2).
83
+ * @typedef {'UNKNOWN_SERVER'|'AMBIGUOUS_SERVER'|'DUPLICATE_NAME'|'INVALID_SERVER'|'INVALID_REGISTRY'} RegistryErrorCode
84
+ */
85
+ export class RegistryError extends Error {
86
+ /**
87
+ * @param {RegistryErrorCode} code
88
+ * @param {string} message
89
+ * @param {{available?: string[], candidates?: string[]}} [details]
90
+ */
91
+ constructor(code, message, details = {}) {
92
+ super(message)
93
+ this.name = 'RegistryError'
94
+ /** @type {RegistryErrorCode} */
95
+ this.code = code
96
+ /** @type {string[]|undefined} */
97
+ this.available = details.available
98
+ /** @type {string[]|undefined} */
99
+ this.candidates = details.candidates
100
+ }
101
+ }
102
+
103
+ /**
104
+ * The harness home: `$DSH_HOME` when set, otherwise `~/.dsh` (the same default
105
+ * the harness itself uses).
106
+ * @param {NodeJS.ProcessEnv} [env]
107
+ * @returns {string}
108
+ */
109
+ export function dshHome(env = process.env) {
110
+ const configured = env.DSH_HOME
111
+ return configured !== undefined && configured.trim() !== '' ? configured.trim() : join(homedir(), '.dsh')
112
+ }
113
+
114
+ /**
115
+ * The plugin's data directory below the harness home.
116
+ * @param {NodeJS.ProcessEnv} [env]
117
+ * @returns {string}
118
+ */
119
+ export function connectorDirectory(env = process.env) {
120
+ return join(dshHome(env), CONNECTOR_DIR_NAME)
121
+ }
122
+
123
+ /**
124
+ * Default location of `servers.json` (doc §3.5).
125
+ * @param {NodeJS.ProcessEnv} [env]
126
+ * @returns {string}
127
+ */
128
+ export function defaultServersFile(env = process.env) {
129
+ return join(connectorDirectory(env), SERVERS_FILE_NAME)
130
+ }
131
+
132
+ /** Fields a create() call accepts. */
133
+ const CREATE_FIELDS = new Set([
134
+ 'name',
135
+ 'host',
136
+ 'port',
137
+ 'username',
138
+ 'auth',
139
+ 'hostKeyMode',
140
+ 'enabled',
141
+ 'defaultWorkDir',
142
+ 'policy',
143
+ 'disabledRules',
144
+ 'customRules',
145
+ ])
146
+
147
+ /** Fields an update() call accepts; `id` and `createdAt` are immutable. */
148
+ const UPDATE_FIELDS = new Set([...CREATE_FIELDS, 'lastConnectedAt'])
149
+
150
+ /** Fields a persisted record carries. */
151
+ const STORED_FIELDS = new Set(['id', ...CREATE_FIELDS, 'createdAt', 'lastConnectedAt'])
152
+
153
+ /** Allowed keys inside each auth shape. */
154
+ /** @type {Record<'key'|'password'|'agent', ReadonlySet<string>>} */
155
+ const AUTH_FIELDS = {
156
+ key: new Set(['kind', 'privateKeyPath']),
157
+ password: new Set(['kind', 'credentialRef']),
158
+ agent: new Set(['kind']),
159
+ }
160
+
161
+ /**
162
+ * Fields that would put a secret into `servers.json`. They are rejected with a
163
+ * dedicated message rather than a generic "unknown field" one, because the
164
+ * constraint is security-relevant (doc §6.3).
165
+ */
166
+ const SECRET_FIELDS = new Set(['password', 'passphrase', 'privateKey', 'privateKeyContent', 'secret', 'token'])
167
+
168
+ /**
169
+ * Deep copy a record so callers cannot mutate this instance's state.
170
+ * @template T
171
+ * @param {T} value
172
+ * @returns {T}
173
+ */
174
+ function clone(value) {
175
+ return structuredClone(value)
176
+ }
177
+
178
+ /**
179
+ * @param {unknown} value
180
+ * @returns {value is Record<string, unknown>}
181
+ */
182
+ function isPlainObject(value) {
183
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
184
+ }
185
+
186
+ /**
187
+ * @param {unknown} error
188
+ * @param {string} code
189
+ * @returns {boolean}
190
+ */
191
+ function isErrno(error, code) {
192
+ return isPlainObject(error) && error.code === code
193
+ }
194
+
195
+ /**
196
+ * @param {unknown} value
197
+ * @returns {value is 'allow'|'confirm'|'block'}
198
+ */
199
+ function isDecisionValue(value) {
200
+ return value === 'allow' || value === 'confirm' || value === 'block'
201
+ }
202
+
203
+ /**
204
+ * @param {string} field
205
+ * @param {RegistryErrorCode} errorCode
206
+ * @returns {RegistryError}
207
+ */
208
+ function plaintextError(field, errorCode) {
209
+ return new RegistryError(
210
+ errorCode,
211
+ `Refusing to store "${field}" in servers.json: plaintext secrets are never stored. ` +
212
+ 'Use auth.kind "key" with privateKeyPath, or auth.kind "password" with a credentialRef backed by ctx.credentials.',
213
+ )
214
+ }
215
+
216
+ /**
217
+ * Reject unknown fields and plaintext secrets before any value is used.
218
+ * @param {Record<string, unknown>} source
219
+ * @param {ReadonlySet<string>} allowed
220
+ * @param {RegistryErrorCode} errorCode
221
+ * @param {string} label
222
+ * @returns {void}
223
+ */
224
+ function assertKnownFields(source, allowed, errorCode, label) {
225
+ for (const key of Object.keys(source)) {
226
+ if (allowed.has(key)) continue
227
+ if (SECRET_FIELDS.has(key)) throw plaintextError(key, errorCode)
228
+ throw new RegistryError(
229
+ errorCode,
230
+ `Unknown field "${key}" in ${label}. Allowed fields: ${[...allowed].join(', ')}.`,
231
+ )
232
+ }
233
+ }
234
+
235
+ /**
236
+ * @param {unknown} value
237
+ * @param {string} field
238
+ * @param {RegistryErrorCode} errorCode
239
+ * @returns {string}
240
+ */
241
+ function requiredString(value, field, errorCode) {
242
+ if (typeof value !== 'string' || value.trim() === '') {
243
+ throw new RegistryError(errorCode, `${field} must be a non-empty string.`)
244
+ }
245
+ return value.trim()
246
+ }
247
+
248
+ /**
249
+ * @param {unknown} value
250
+ * @param {RegistryErrorCode} errorCode
251
+ * @returns {string}
252
+ */
253
+ function normalizeHost(value, errorCode) {
254
+ const host = requiredString(value, 'host', errorCode)
255
+ if (/\s/.test(host)) throw new RegistryError(errorCode, 'host must not contain whitespace.')
256
+ return host
257
+ }
258
+
259
+ /**
260
+ * @param {unknown} value
261
+ * @param {RegistryErrorCode} errorCode
262
+ * @returns {number}
263
+ */
264
+ function normalizePort(value, errorCode) {
265
+ if (value === undefined) return 22
266
+ if (typeof value !== 'number' || !Number.isInteger(value) || value < 1 || value > 65535) {
267
+ throw new RegistryError(errorCode, 'port must be an integer between 1 and 65535.')
268
+ }
269
+ return value
270
+ }
271
+
272
+ /**
273
+ * @param {unknown} value
274
+ * @param {RegistryErrorCode} errorCode
275
+ * @returns {'accept-new'|'verify'|'off'}
276
+ */
277
+ function normalizeHostKeyMode(value, errorCode) {
278
+ if (value === undefined) return 'accept-new'
279
+ if (value === 'accept-new' || value === 'verify' || value === 'off') return value
280
+ throw new RegistryError(errorCode, `hostKeyMode must be one of ${HOST_KEY_MODES.join(', ')}.`)
281
+ }
282
+
283
+ /**
284
+ * @param {unknown} value
285
+ * @param {RegistryErrorCode} errorCode
286
+ * @returns {string|null}
287
+ */
288
+ function normalizeWorkDir(value, errorCode) {
289
+ if (value === undefined || value === null) return null
290
+ if (typeof value !== 'string') throw new RegistryError(errorCode, 'defaultWorkDir must be a string or null.')
291
+ const trimmed = value.trim()
292
+ return trimmed === '' ? null : trimmed
293
+ }
294
+
295
+ /**
296
+ * The enable switch (doc §3.1, enable-switch spec).
297
+ *
298
+ * Absent means enabled: files written before the switch existed carry no
299
+ * `enabled` key, and silently disabling every server on load would be a
300
+ * destructive surprise.
301
+ * @param {unknown} value
302
+ * @param {RegistryErrorCode} errorCode
303
+ * @returns {boolean}
304
+ */
305
+ function normalizeEnabled(value, errorCode) {
306
+ if (value === undefined || value === null) return true
307
+ if (typeof value !== 'boolean') throw new RegistryError(errorCode, 'enabled must be a boolean.')
308
+ return value
309
+ }
310
+
311
+ /**
312
+ * @param {unknown} value
313
+ * @param {string} field
314
+ * @param {RegistryErrorCode} errorCode
315
+ * @returns {string}
316
+ */
317
+ function normalizeTimestamp(value, field, errorCode) {
318
+ if (typeof value !== 'string' || value.trim() === '') {
319
+ throw new RegistryError(errorCode, `${field} must be an ISO-8601 timestamp.`)
320
+ }
321
+ const parsed = new Date(value)
322
+ if (Number.isNaN(parsed.getTime())) {
323
+ throw new RegistryError(errorCode, `${field} must be an ISO-8601 timestamp.`)
324
+ }
325
+ return parsed.toISOString()
326
+ }
327
+
328
+ /**
329
+ * @param {unknown} value
330
+ * @param {string} field
331
+ * @param {RegistryErrorCode} errorCode
332
+ * @returns {string|null}
333
+ */
334
+ function normalizeOptionalTimestamp(value, field, errorCode) {
335
+ if (value === undefined || value === null) return null
336
+ return normalizeTimestamp(value, field, errorCode)
337
+ }
338
+
339
+ /**
340
+ * The grammar every credential ref must satisfy: a POSIX shell identifier.
341
+ *
342
+ * This is @deepseek-ai/dsh-credentials' own contract (`REF_PATTERN`), not an
343
+ * invention of this plugin. Keeping a local copy is deliberate: a ref this
344
+ * plugin persists is later parsed by that package, and a mismatch is not a
345
+ * local error — it prevents `credentials` from activating, which fails the whole
346
+ * Host startup.
347
+ */
348
+ const CREDENTIAL_REF_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/
349
+
350
+ /**
351
+ * The ref this plugin derives for a server's password (doc §6.3).
352
+ *
353
+ * `server-connector:<id>:password` was the old shape and it was illegal: the
354
+ * colon and the dash fall outside the grammar above, so persisting it broke the
355
+ * next startup. The id is folded into a single identifier instead
356
+ * (`DSH_SERVER_CONNECTOR_SRV_01_PASSWORD`).
357
+ * @param {string} serverId - e.g. `srv-01`
358
+ * @returns {string}
359
+ */
360
+ function deriveCredentialRef(serverId) {
361
+ const normalised = String(serverId)
362
+ .toUpperCase()
363
+ .replace(/[^A-Z0-9]+/g, '_')
364
+ .replace(/^_+|_+$/g, '')
365
+ const safe = normalised === '' ? 'SERVER' : normalised
366
+ return `DSH_SERVER_CONNECTOR_${safe}_PASSWORD`
367
+ }
368
+
369
+ /**
370
+ * Validate a caller-supplied credential ref against the same grammar.
371
+ *
372
+ * A caller may pass its own ref (an environment-variable name, typically), but
373
+ * an illegal one must be refused here rather than stored: storing it would
374
+ * break the next startup instead of this request.
375
+ * @param {unknown} value
376
+ * @param {RegistryErrorCode} errorCode
377
+ * @returns {string}
378
+ */
379
+ function assertCredentialRef(value, errorCode) {
380
+ const ref = requiredString(value, 'auth.credentialRef', errorCode)
381
+ if (!CREDENTIAL_REF_PATTERN.test(ref)) {
382
+ throw new RegistryError(
383
+ errorCode,
384
+ `auth.credentialRef ${JSON.stringify(ref)} must be a POSIX identifier ` +
385
+ `(letters, digits and underscores, not starting with a digit), because DSH credentials rejects ` +
386
+ `any other ref and the rejection fails the whole startup.`,
387
+ )
388
+ }
389
+ return ref
390
+ }
391
+
392
+ /**
393
+ * Read-time recovery for a ref an older build persisted (doc §6.3).
394
+ *
395
+ * Before 0.2.1 this plugin derived `server-connector:<id>:password`, which is
396
+ * outside the grammar. Those records exist on disk in the wild, and refusing
397
+ * them at load would leave the plugin unable to start — trading a Host crash for
398
+ * a plugin that never loads. Instead the illegal legacy shape is mapped onto the
399
+ * derived one, so the record keeps working and the next write persists the fixed
400
+ * ref.
401
+ *
402
+ * Only the exact legacy shape is migrated. Any other illegal ref is still
403
+ * refused: silently rewriting a ref this plugin never wrote could point at a
404
+ * different secret.
405
+ * @param {unknown} value
406
+ * @param {string} serverId
407
+ * @param {RegistryErrorCode} errorCode
408
+ * @returns {string}
409
+ */
410
+ function migrateCredentialRef(value, serverId, errorCode) {
411
+ const ref = requiredString(value, 'auth.credentialRef', errorCode)
412
+ if (CREDENTIAL_REF_PATTERN.test(ref)) return ref
413
+ if (ref === `server-connector:${serverId}:password`) return deriveCredentialRef(serverId)
414
+ return assertCredentialRef(ref, errorCode)
415
+ }
416
+
417
+ /**
418
+ * @param {unknown} value
419
+ * @param {string} serverId
420
+ * @param {RegistryErrorCode} errorCode
421
+ * @param {{migrate?: boolean}} [options] - `migrate` is set only by the load path.
422
+ * @returns {{kind:'key', privateKeyPath:string}|{kind:'password', credentialRef:string}|{kind:'agent'}}
423
+ */
424
+ function normalizeAuth(value, serverId, errorCode, options = {}) {
425
+ if (value === undefined) return { kind: 'agent' }
426
+ if (!isPlainObject(value)) throw new RegistryError(errorCode, 'auth must be an object.')
427
+ const kind = value.kind
428
+ if (kind === 'key') {
429
+ assertKnownFields(value, AUTH_FIELDS.key, errorCode, 'auth')
430
+ return { kind: 'key', privateKeyPath: requiredString(value.privateKeyPath, 'auth.privateKeyPath', errorCode) }
431
+ }
432
+ if (kind === 'password') {
433
+ assertKnownFields(value, AUTH_FIELDS.password, errorCode, 'auth')
434
+ // Doc §6.3: the record keeps a reference, never the password itself.
435
+ //
436
+ // The ref MUST be a POSIX shell identifier: @deepseek-ai/dsh-credentials
437
+ // validates every stored ref with /^[A-Za-z_][A-Za-z0-9_]*$/ while loading
438
+ // credentials.yaml, and a ref outside that grammar throws during
439
+ // `credentials` activation. Because `credentials` is a required plugin, that
440
+ // takes the entire Host down at startup — a persisted bad ref bricks DSH
441
+ // until the file is edited by hand. Derive and validate accordingly.
442
+ const credentialRef =
443
+ value.credentialRef === undefined
444
+ ? deriveCredentialRef(serverId)
445
+ : options.migrate === true
446
+ ? migrateCredentialRef(value.credentialRef, serverId, errorCode)
447
+ : assertCredentialRef(value.credentialRef, errorCode)
448
+ return { kind: 'password', credentialRef }
449
+ }
450
+ if (kind === 'agent') {
451
+ assertKnownFields(value, AUTH_FIELDS.agent, errorCode, 'auth')
452
+ return { kind: 'agent' }
453
+ }
454
+ throw new RegistryError(errorCode, `auth.kind must be one of ${AUTH_KINDS.join(', ')}.`)
455
+ }
456
+
457
+ /**
458
+ * @param {unknown} value
459
+ * @param {RegistryErrorCode} errorCode
460
+ * @returns {Record<string, 'allow'|'confirm'|'block'>}
461
+ */
462
+ function normalizePolicy(value, errorCode) {
463
+ if (value === undefined) return {}
464
+ if (!isPlainObject(value)) {
465
+ throw new RegistryError(errorCode, 'policy must be an object of ruleId → allow|confirm|block.')
466
+ }
467
+ /** @type {Record<string, 'allow'|'confirm'|'block'>} */
468
+ const policy = {}
469
+ for (const [ruleId, decision] of Object.entries(value)) {
470
+ if (ruleId.trim() === '') throw new RegistryError(errorCode, 'policy keys must be non-empty rule ids.')
471
+ if (!isDecisionValue(decision)) {
472
+ throw new RegistryError(errorCode, `policy.${ruleId} must be one of ${DECISION_VALUES.join(', ')}.`)
473
+ }
474
+ policy[ruleId] = decision
475
+ }
476
+ return policy
477
+ }
478
+
479
+ /**
480
+ * @param {unknown} value
481
+ * @param {RegistryErrorCode} errorCode
482
+ * @returns {string[]}
483
+ */
484
+ function normalizeDisabledRules(value, errorCode) {
485
+ if (value === undefined) return []
486
+ if (!Array.isArray(value)) throw new RegistryError(errorCode, 'disabledRules must be an array of rule ids.')
487
+ /** @type {string[]} */
488
+ const ids = []
489
+ for (const item of value) {
490
+ if (typeof item !== 'string' || item.trim() === '') {
491
+ throw new RegistryError(errorCode, 'disabledRules must contain non-empty rule ids.')
492
+ }
493
+ const id = item.trim()
494
+ if (!ids.includes(id)) ids.push(id)
495
+ }
496
+ return ids
497
+ }
498
+
499
+ /**
500
+ * Validate the custom-rule list of one record (doc §4.4.2).
501
+ *
502
+ * Ids are unique per server and must not collide with a built-in id. The
503
+ * decision may only be `confirm` or `block` — a rule that permits what the
504
+ * engine already permits buys nothing.
505
+ * @param {unknown} value
506
+ * @param {RegistryErrorCode} errorCode
507
+ * @returns {import('./policy.js').CustomRule[]}
508
+ */
509
+ function normalizeCustomRules(value, errorCode) {
510
+ if (value === undefined) return []
511
+ if (!Array.isArray(value)) throw new RegistryError(errorCode, 'customRules must be an array of rules.')
512
+ if (value.length > MAX_CUSTOM_RULES) {
513
+ throw new RegistryError(errorCode, `customRules may hold at most ${MAX_CUSTOM_RULES} rules.`)
514
+ }
515
+ /** @type {import('./policy.js').CustomRule[]} */
516
+ const rules = []
517
+ /** @type {string[]} */
518
+ const seen = []
519
+ for (const item of value) {
520
+ /** @type {import('./policy.js').CustomRule} */
521
+ let rule
522
+ try {
523
+ rule = validateCustomRule(item, { takenIds: seen })
524
+ } catch (error) {
525
+ if (error instanceof RuleError) throw new RegistryError(errorCode, `customRules: ${error.message}`)
526
+ throw error
527
+ }
528
+ seen.push(rule.id)
529
+ rules.push(rule)
530
+ }
531
+ return rules
532
+ }
533
+
534
+ /**
535
+ * Validate and canonicalise one complete record. Create, update and load all
536
+ * go through this function, so a stored record and a freshly created one have
537
+ * exactly the same shape.
538
+ * @param {Record<string, unknown>} source
539
+ * @param {{
540
+ * allowed: ReadonlySet<string>,
541
+ * id: string,
542
+ * errorCode: RegistryErrorCode,
543
+ * label: string,
544
+ * createdAt?: string,
545
+ * migrate?: boolean,
546
+ * }} options
547
+ * @returns {ServerRecord}
548
+ */
549
+ function normalizeRecord(source, options) {
550
+ const { allowed, id, errorCode, label } = options
551
+ if (!isPlainObject(source)) throw new RegistryError(errorCode, `${label} must be an object.`)
552
+ assertKnownFields(source, allowed, errorCode, label)
553
+ const createdAt =
554
+ options.createdAt !== undefined
555
+ ? normalizeTimestamp(options.createdAt, 'createdAt', errorCode)
556
+ : normalizeTimestamp(source.createdAt, 'createdAt', errorCode)
557
+ return {
558
+ id,
559
+ name: requiredString(source.name, 'name', errorCode),
560
+ host: normalizeHost(source.host, errorCode),
561
+ port: normalizePort(source.port, errorCode),
562
+ username: requiredString(source.username, 'username', errorCode),
563
+ auth: normalizeAuth(source.auth, id, errorCode, { migrate: options.migrate === true }),
564
+ hostKeyMode: normalizeHostKeyMode(source.hostKeyMode, errorCode),
565
+ defaultWorkDir: normalizeWorkDir(source.defaultWorkDir, errorCode),
566
+ enabled: normalizeEnabled(source.enabled, errorCode),
567
+ policy: normalizePolicy(source.policy, errorCode),
568
+ disabledRules: normalizeDisabledRules(source.disabledRules, errorCode),
569
+ customRules: normalizeCustomRules(source.customRules, errorCode),
570
+ createdAt,
571
+ lastConnectedAt: normalizeOptionalTimestamp(source.lastConnectedAt, 'lastConnectedAt', errorCode),
572
+ }
573
+ }
574
+
575
+ /**
576
+ * Case- and whitespace-insensitive comparison key for the unique display name.
577
+ * @param {string} name
578
+ * @returns {string}
579
+ */
580
+ function normalizeNameKey(name) {
581
+ return name.trim().toLowerCase()
582
+ }
583
+
584
+ /**
585
+ * @param {ServerRecord[]} servers
586
+ * @returns {string}
587
+ */
588
+ function describeServers(servers) {
589
+ if (servers.length === 0) return 'none configured'
590
+ return servers.map((server) => `${server.id} (${server.name})`).join(', ')
591
+ }
592
+
593
+ /**
594
+ * @param {string} id
595
+ * @param {ServerRecord[]} servers
596
+ * @returns {RegistryError}
597
+ */
598
+ function unknownServer(id, servers) {
599
+ return new RegistryError('UNKNOWN_SERVER', `Unknown server "${id}". Available servers: ${describeServers(servers)}.`, {
600
+ available: servers.map((server) => server.id),
601
+ })
602
+ }
603
+
604
+ /**
605
+ * @param {ServerRecord[]} servers
606
+ * @param {string} name
607
+ * @param {string|null} exceptId - the record being renamed, or null on create.
608
+ * @returns {void}
609
+ */
610
+ function assertNameAvailable(servers, name, exceptId) {
611
+ const key = normalizeNameKey(name)
612
+ for (const server of servers) {
613
+ if (server.id === exceptId) continue
614
+ if (normalizeNameKey(server.name) === key) {
615
+ throw new RegistryError(
616
+ 'DUPLICATE_NAME',
617
+ `A server named ${JSON.stringify(name)} already exists (id ${server.id}). Server names are unique.`,
618
+ )
619
+ }
620
+ }
621
+ }
622
+
623
+ /**
624
+ * Next sequential id, e.g. `srv-01`. The width grows to fit the number.
625
+ * @param {ServerRecord[]} servers
626
+ * @returns {string}
627
+ */
628
+ function nextServerId(servers) {
629
+ let highest = 0
630
+ for (const server of servers) {
631
+ const match = /^srv-(\d+)$/.exec(server.id)
632
+ if (match === null) continue
633
+ const digits = match[1]
634
+ if (digits === undefined) continue
635
+ const value = Number(digits)
636
+ if (Number.isSafeInteger(value) && value > highest) highest = value
637
+ }
638
+ const next = highest + 1
639
+ const width = next >= 100 ? String(next).length : 2
640
+ return `srv-${String(next).padStart(width, '0')}`
641
+ }
642
+
643
+ /**
644
+ * Drop undefined patch entries and reject immutable or unknown fields before
645
+ * they are merged into a record.
646
+ * @param {Record<string, unknown>} patch
647
+ * @returns {Record<string, unknown>}
648
+ */
649
+ function normalizePatch(patch) {
650
+ if (!isPlainObject(patch)) throw new RegistryError('INVALID_SERVER', 'update patch must be an object.')
651
+ /** @type {Record<string, unknown>} */
652
+ const changes = {}
653
+ for (const [key, value] of Object.entries(patch)) {
654
+ if (value === undefined) continue
655
+ if (key === 'id' || key === 'createdAt') {
656
+ throw new RegistryError('INVALID_SERVER', `${key} is immutable; create a new server instead.`)
657
+ }
658
+ if (!UPDATE_FIELDS.has(key)) {
659
+ if (SECRET_FIELDS.has(key)) throw plaintextError(key, 'INVALID_SERVER')
660
+ throw new RegistryError('INVALID_SERVER', `Unknown field "${key}" in update.`)
661
+ }
662
+ changes[key] = value
663
+ }
664
+ return changes
665
+ }
666
+
667
+ /**
668
+ * CRUD facade over one `servers.json` file.
669
+ *
670
+ * All methods are asynchronous; reads go straight to disk, mutations are
671
+ * queued per file and committed atomically. Use {@link Registry.resolve} for
672
+ * the forgiving server addressing described in doc §5.2.
673
+ */
674
+ export class Registry {
675
+ /** @type {string} */
676
+ #file
677
+ /**
678
+ * @param {{file?: string}} [options] - `file` overrides the default path and is
679
+ * what the tests use; production code relies on `$DSH_HOME`.
680
+ */
681
+ constructor(options = {}) {
682
+ this.#file = options.file ?? defaultServersFile()
683
+ }
684
+
685
+ /** Absolute path of the registry file this instance reads and writes. */
686
+ get file() {
687
+ return this.#file
688
+ }
689
+
690
+ /**
691
+ * Every server, in file order.
692
+ * @returns {Promise<ServerRecord[]>}
693
+ */
694
+ async list() {
695
+ const document = await this.#load()
696
+ return document.servers.map((server) => clone(server))
697
+ }
698
+
699
+ /**
700
+ * Exact lookup by stable id.
701
+ * @param {string} id
702
+ * @returns {Promise<ServerRecord|null>} null when no server has that id.
703
+ */
704
+ async get(id) {
705
+ const document = await this.#load()
706
+ const record = document.servers.find((server) => server.id === id)
707
+ return record === undefined ? null : clone(record)
708
+ }
709
+
710
+ /**
711
+ * Forgiving lookup for a tool's `server` argument (doc §5.2):
712
+ * 1. exact `id`;
713
+ * 2. exact `name` (case-insensitive);
714
+ * 3. unique prefix of an id or a name (case-insensitive);
715
+ * 4. zero matches → `UNKNOWN_SERVER`, message listing every server;
716
+ * 5. several matches → `AMBIGUOUS_SERVER`, message listing the candidates.
717
+ *
718
+ * Exact id/name matches win over prefix matches, so a name that prefixes
719
+ * another name remains addressable.
720
+ * @param {string} query
721
+ * @returns {Promise<ServerRecord>}
722
+ * @throws {RegistryError} `INVALID_SERVER`, `UNKNOWN_SERVER` or `AMBIGUOUS_SERVER`.
723
+ */
724
+ async resolve(query) {
725
+ if (typeof query !== 'string' || query.trim() === '') {
726
+ throw new RegistryError('INVALID_SERVER', 'server must be a non-empty id, name or unique prefix.')
727
+ }
728
+ const wanted = query.trim()
729
+ const document = await this.#load()
730
+ const servers = document.servers
731
+
732
+ const exactId = servers.find((server) => server.id === wanted)
733
+ if (exactId !== undefined) return clone(exactId)
734
+
735
+ const wantedName = wanted.toLowerCase()
736
+ const exactName = servers.find((server) => server.name.toLowerCase() === wantedName)
737
+ if (exactName !== undefined) return clone(exactName)
738
+
739
+ const prefix = wanted.toLowerCase()
740
+ const candidates = servers.filter(
741
+ (server) => server.id.toLowerCase().startsWith(prefix) || server.name.toLowerCase().startsWith(prefix),
742
+ )
743
+ const only = candidates.length === 1 ? candidates[0] : undefined
744
+ if (only !== undefined) return clone(only)
745
+ if (candidates.length === 0) throw unknownServer(wanted, servers)
746
+ throw new RegistryError(
747
+ 'AMBIGUOUS_SERVER',
748
+ `Server "${wanted}" is ambiguous: matches ${candidates
749
+ .map((server) => `${server.id} (${server.name})`)
750
+ .join(', ')}. Use the full id or name.`,
751
+ { candidates: candidates.map((server) => server.id) },
752
+ )
753
+ }
754
+
755
+ /**
756
+ * Create a server. The id is assigned by the registry; `createdAt` is now.
757
+ * @param {Record<string, unknown>} input
758
+ * @returns {Promise<ServerRecord>} the stored record.
759
+ * @throws {RegistryError} `INVALID_SERVER` or `DUPLICATE_NAME`.
760
+ */
761
+ async create(input) {
762
+ return this.#mutate((document) => {
763
+ const id = nextServerId(document.servers)
764
+ const record = normalizeRecord(input, {
765
+ allowed: CREATE_FIELDS,
766
+ id,
767
+ errorCode: 'INVALID_SERVER',
768
+ label: 'server',
769
+ createdAt: new Date().toISOString(),
770
+ })
771
+ assertNameAvailable(document.servers, record.name, null)
772
+ document.servers.push(record)
773
+ return clone(record)
774
+ })
775
+ }
776
+
777
+ /**
778
+ * Change some fields of one server. `id` and `createdAt` cannot be changed;
779
+ * omitted fields keep their current value. The merged record is validated as
780
+ * a whole before it replaces the stored one.
781
+ * @param {string} id
782
+ * @param {Record<string, unknown>} patch
783
+ * @returns {Promise<ServerRecord>} the stored record.
784
+ * @throws {RegistryError} `UNKNOWN_SERVER`, `INVALID_SERVER` or `DUPLICATE_NAME`.
785
+ */
786
+ async update(id, patch) {
787
+ return this.#mutate((document) => {
788
+ const index = document.servers.findIndex((server) => server.id === id)
789
+ const existing = index === -1 ? undefined : document.servers[index]
790
+ if (existing === undefined) throw unknownServer(id, document.servers)
791
+ const changes = normalizePatch(patch)
792
+ /** @type {Record<string, unknown>} */
793
+ const merged = { ...existing }
794
+ for (const [key, value] of Object.entries(changes)) merged[key] = value
795
+ const record = normalizeRecord(merged, {
796
+ allowed: STORED_FIELDS,
797
+ id: existing.id,
798
+ errorCode: 'INVALID_SERVER',
799
+ label: 'server',
800
+ createdAt: existing.createdAt,
801
+ })
802
+ assertNameAvailable(document.servers, record.name, existing.id)
803
+ document.servers[index] = record
804
+ return clone(record)
805
+ })
806
+ }
807
+
808
+ /**
809
+ * Delete one server, returning the record that was removed.
810
+ * @param {string} id
811
+ * @returns {Promise<ServerRecord>}
812
+ * @throws {RegistryError} `UNKNOWN_SERVER`.
813
+ */
814
+ async remove(id) {
815
+ return this.#mutate((document) => {
816
+ const index = document.servers.findIndex((server) => server.id === id)
817
+ const existing = index === -1 ? undefined : document.servers[index]
818
+ if (existing === undefined) throw unknownServer(id, document.servers)
819
+ document.servers.splice(index, 1)
820
+ return clone(existing)
821
+ })
822
+ }
823
+
824
+ /**
825
+ * Read and validate the whole file. A missing or empty file is an empty
826
+ * registry; invalid JSON, an unknown version, duplicate ids/names or a
827
+ * malformed record is reported as `INVALID_REGISTRY` instead of being
828
+ * silently rewritten.
829
+ * @returns {Promise<RegistryDocument>}
830
+ */
831
+ async #load() {
832
+ /** @type {string} */
833
+ let text
834
+ try {
835
+ text = await readFile(this.#file, 'utf8')
836
+ } catch (error) {
837
+ if (isErrno(error, 'ENOENT')) return { version: REGISTRY_VERSION, servers: [] }
838
+ throw error
839
+ }
840
+ if (text.trim() === '') return { version: REGISTRY_VERSION, servers: [] }
841
+
842
+ /** @type {unknown} */
843
+ let parsed
844
+ try {
845
+ parsed = JSON.parse(text)
846
+ } catch {
847
+ throw new RegistryError('INVALID_REGISTRY', `${this.#file} is not valid JSON; fix or remove it.`)
848
+ }
849
+ if (!isPlainObject(parsed)) {
850
+ throw new RegistryError('INVALID_REGISTRY', `${this.#file} must contain a JSON object.`)
851
+ }
852
+ if (parsed.version !== REGISTRY_VERSION) {
853
+ throw new RegistryError(
854
+ 'INVALID_REGISTRY',
855
+ `${this.#file} has unsupported version ${String(parsed.version)}; expected ${REGISTRY_VERSION}.`,
856
+ )
857
+ }
858
+ if (!Array.isArray(parsed.servers)) {
859
+ throw new RegistryError('INVALID_REGISTRY', `${this.#file} must contain a "servers" array.`)
860
+ }
861
+
862
+ /** @type {ServerRecord[]} */
863
+ const servers = []
864
+ const seenIds = new Set()
865
+ const seenNames = new Set()
866
+ for (let index = 0; index < parsed.servers.length; index += 1) {
867
+ const raw = parsed.servers[index]
868
+ if (!isPlainObject(raw)) {
869
+ throw new RegistryError('INVALID_REGISTRY', `${this.#file}: servers[${index}] is not an object.`)
870
+ }
871
+ const recordId = requiredString(raw.id, `servers[${index}].id`, 'INVALID_REGISTRY')
872
+ const record = normalizeRecord(raw, {
873
+ allowed: STORED_FIELDS,
874
+ id: recordId,
875
+ errorCode: 'INVALID_REGISTRY',
876
+ label: `servers[${index}]`,
877
+ // Loading is the one path allowed to repair a legacy credential ref;
878
+ // writes are strict so this plugin can never persist a bad one again.
879
+ migrate: true,
880
+ })
881
+ if (seenIds.has(record.id)) {
882
+ throw new RegistryError('INVALID_REGISTRY', `${this.#file}: duplicate server id "${record.id}".`)
883
+ }
884
+ seenIds.add(record.id)
885
+ const nameKey = normalizeNameKey(record.name)
886
+ if (seenNames.has(nameKey)) {
887
+ throw new RegistryError('INVALID_REGISTRY', `${this.#file}: duplicate server name ${record.name}.`)
888
+ }
889
+ seenNames.add(nameKey)
890
+ servers.push(record)
891
+ }
892
+ const document = { version: REGISTRY_VERSION, servers }
893
+ // Persist a repaired legacy ref immediately. Without this the migration
894
+ // lives only in memory: every later read re-runs it, and a copy of the file
895
+ // taken before the first write would still carry the illegal ref that breaks
896
+ // the next startup. The rewrite is conditional so a clean file is never
897
+ // touched (its mtime stays put).
898
+ if (this.#migrated(text, document)) await this.#save(document)
899
+ return document
900
+ }
901
+
902
+ /**
903
+ * Whether the loaded document differs from what is on disk, i.e. whether the
904
+ * legacy-ref migration actually changed a record and the file must be rewritten.
905
+ * @param {string} text - the raw file text that was parsed.
906
+ * @param {RegistryDocument} document - the normalised document.
907
+ * @returns {boolean}
908
+ */
909
+ #migrated(text, document) {
910
+ for (const record of document.servers) {
911
+ if (record.auth.kind !== 'password') continue
912
+ const ref = record.auth.credentialRef
913
+ // The normalised ref is legal by construction; if the raw text does not
914
+ // contain it, this record was migrated.
915
+ if (!text.includes(JSON.stringify(ref))) return true
916
+ }
917
+ return false
918
+ }
919
+
920
+ /**
921
+ * Commit the document by writing a sibling temp file and renaming it over
922
+ * the registry. Readers only ever observe the old or the new complete file.
923
+ * @param {RegistryDocument} document
924
+ * @returns {Promise<void>}
925
+ */
926
+ async #save(document) {
927
+ await mkdir(dirname(this.#file), { recursive: true, mode: 0o700 })
928
+ const temporary = `${this.#file}.${process.pid}.${randomBytes(6).toString('hex')}.tmp`
929
+ try {
930
+ await writeFile(temporary, `${JSON.stringify(document, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 })
931
+ await rename(temporary, this.#file)
932
+ } catch (error) {
933
+ await unlink(temporary).catch(() => {})
934
+ throw error
935
+ }
936
+ }
937
+
938
+ /**
939
+ * Run one read-modify-write operation after every earlier mutation finished.
940
+ *
941
+ * The queue is keyed by the absolute file path, not by this instance: the
942
+ * tools surface, the HTTP routes and the tests each build their own Registry,
943
+ * and two instances on the same file must not lose each other's writes.
944
+ * @template T
945
+ * @param {(document: RegistryDocument) => T} mutate
946
+ * @returns {Promise<T>}
947
+ */
948
+ async #mutate(mutate) {
949
+ const key = resolve(this.#file)
950
+ const previous = MUTATION_QUEUES.get(key) ?? Promise.resolve()
951
+ const operation = previous.then(async () => {
952
+ const document = await this.#load()
953
+ const result = mutate(document)
954
+ await this.#save(document)
955
+ return result
956
+ })
957
+ const settled = operation.then(
958
+ () => undefined,
959
+ () => undefined,
960
+ )
961
+ MUTATION_QUEUES.set(key, settled)
962
+ // Keep the map from growing once a file goes quiet.
963
+ void settled.then(() => {
964
+ if (MUTATION_QUEUES.get(key) === settled) MUTATION_QUEUES.delete(key)
965
+ })
966
+ return operation
967
+ }
968
+ }