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/pending.js CHANGED
@@ -1,753 +1,766 @@
1
- // dsh-server-connector — two-step confirmation queue for dangerous operations
2
- // (design doc §3.3, §8.3).
3
- //
4
- // A dangerous panel action (delete a server, switch host-key verification off,
5
- // disable a high-risk rule) is *requested* first and applied later, after the
6
- // user comes back to the settings page and sees the full context. This module
7
- // owns that queue and nothing else:
8
- //
9
- // $DSH_HOME/server-connector/pending.json
10
- // { "version": 1, "pending": [{
11
- // "id": "pend-01",
12
- // "kind": "delete-server" | "disable-hostkey" | "disable-rule",
13
- // "serverId": "srv-01",
14
- // "ruleId": "rm-recursive", // disable-rule only
15
- // "requestedAt": "2026-11-04T09:20:11Z",
16
- // "expiresAt": "2026-11-05T09:20:11Z", // requestedAt + pendingTtlHours
17
- // "snapshot": { /* delete-server: the complete record */ }
18
- // }] }
19
- //
20
- // State machine (§8.3):
21
- //
22
- // 无记录 ──request()──► pending ──confirm()──► applied (执行 + 审计留痕)
23
- // └──cancel() / 24h 过期──► discarded
24
- //
25
- // `request()` performs no side effect at all: the server keeps working, the
26
- // rule keeps blocking, host-key verification keeps its mode. Only `confirm()`
27
- // runs the operation, and it does so through an injected applier
28
- // ({@link createPendingApplier} is the production one) so this module stays a
29
- // durable queue rather than a second place that mutates `servers.json`
30
- // (doc §2.2: "pending:危险操作的两段式队列,不执行任何副作用").
31
- //
32
- // Guarantees, in the same spirit as the registry (§3.1):
33
- // - a missing or empty file is an empty queue; a read never creates the file;
34
- // - a malformed document is reported (`INVALID_PENDING`), never rewritten;
35
- // - every mutation is an atomic replace and is serialised per file across the
36
- // instances of this process;
37
- // - an expired record is discarded on load, on every read and on every write
38
- // (doc §8.3: "进程启动时与每次读写时各检查一次"), and an expired record can
39
- // never be confirmed — the user has to request it again.
40
-
41
- import { randomBytes } from 'node:crypto'
42
- import { mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises'
43
- import { dirname, join, resolve } from 'node:path'
44
-
45
- import { connectorDirectory } from './registry.js'
46
-
47
- /** File name of the queue (doc §3.5). */
48
- export const PENDING_FILE_NAME = 'pending.json'
49
-
50
- /** Document version of `pending.json`. */
51
- export const PENDING_VERSION = 1
52
-
53
- /** The three dangerous operations the queue knows (doc §8.3). */
54
- export const PENDING_KINDS = Object.freeze(['delete-server', 'disable-hostkey', 'disable-rule'])
55
-
56
- /** Default lifetime of a pending record (doc §10 `pendingTtlHours`). */
57
- export const DEFAULT_PENDING_TTL_HOURS = 24
58
-
59
- const HOUR_MS = 60 * 60 * 1000
60
-
61
- /**
62
- * One write promise per queue file, so every PendingQueue instance in this
63
- * process serialises its mutations against the same document (the registry and
64
- * the known-hosts store use the same pattern).
65
- * @type {Map<string, Promise<void>>}
66
- */
67
- const MUTATION_QUEUES = new Map()
68
-
69
- /**
70
- * Machine-readable queue failure. `INVALID_PENDING` marks a malformed call or
71
- * document, `UNKNOWN_PENDING` a record that is not in the queue,
72
- * `EXPIRED_PENDING` a record whose 24 h window closed, `PENDING_UNAVAILABLE` a
73
- * queue built without an applier, and `APPLY_FAILED` an applier that threw (the
74
- * record is kept, so the user can retry).
75
- * @typedef {'INVALID_PENDING'|'UNKNOWN_PENDING'|'EXPIRED_PENDING'|'PENDING_UNAVAILABLE'|'APPLY_FAILED'} PendingErrorCode
76
- */
77
- export class PendingError extends Error {
78
- /**
79
- * @param {PendingErrorCode} code
80
- * @param {string} message
81
- * @param {{cause?: unknown, record?: PendingRecord}} [details]
82
- */
83
- constructor(code, message, details = {}) {
84
- super(message)
85
- this.name = 'PendingError'
86
- /** @type {PendingErrorCode} */
87
- this.code = code
88
- if (details.cause !== undefined) this.cause = details.cause
89
- if (details.record !== undefined) this.record = details.record
90
- }
91
- }
92
-
93
- /**
94
- * The three dangerous operations (doc §8.3).
95
- * @typedef {'delete-server'|'disable-hostkey'|'disable-rule'} PendingKind
96
- */
97
-
98
- /**
99
- * One queued request, exactly as persisted (doc §3.3).
100
- * @typedef {object} PendingRecord
101
- * @property {string} id - `pend-01`, `pend-02`, …; assigned by the queue.
102
- * @property {PendingKind} kind
103
- * @property {string} serverId
104
- * @property {string} [ruleId] - present only for `disable-rule`.
105
- * @property {string} requestedAt - ISO-8601.
106
- * @property {string} expiresAt - requestedAt + pendingTtlHours, ISO-8601.
107
- * @property {Record<string, unknown>} [snapshot] - `delete-server` keeps the
108
- * complete server record here so a cancelled request leaves no trace, and a
109
- * confirmed one can still delete the credentials of an already-gone record.
110
- */
111
-
112
- /**
113
- * The persisted document.
114
- * @typedef {object} PendingDocument
115
- * @property {number} version
116
- * @property {PendingRecord[]} pending
117
- */
118
-
119
- /**
120
- * What `request()` accepts.
121
- * @typedef {object} PendingRequestInput
122
- * @property {PendingKind} kind
123
- * @property {string} serverId
124
- * @property {string} [ruleId] - required for `disable-rule`.
125
- * @property {Record<string, unknown>} [snapshot] - optional, `delete-server` uses it.
126
- */
127
-
128
- /**
129
- * What `confirm()` returns. `status` is the §8.3 state the record reached.
130
- * @typedef {{record: PendingRecord, status: 'applied', result: unknown}} PendingApplied
131
- * @typedef {{record: PendingRecord, status: 'discarded'}} PendingDiscarded
132
- */
133
-
134
- /**
135
- * Default location of `pending.json` (doc §3.5).
136
- * @param {NodeJS.ProcessEnv} [env]
137
- * @returns {string}
138
- */
139
- export function defaultPendingFile(env = process.env) {
140
- return join(connectorDirectory(env), PENDING_FILE_NAME)
141
- }
142
-
143
- /**
144
- * @param {unknown} value
145
- * @returns {value is Record<string, unknown>}
146
- */
147
- function isPlainObject(value) {
148
- return typeof value === 'object' && value !== null && !Array.isArray(value)
149
- }
150
-
151
- /**
152
- * @param {unknown} error
153
- * @param {string} code
154
- * @returns {boolean}
155
- */
156
- function isErrno(error, code) {
157
- return isPlainObject(error) && error.code === code
158
- }
159
-
160
- /**
161
- * @param {unknown} value
162
- * @returns {value is PendingKind}
163
- */
164
- function isPendingKind(value) {
165
- return value === 'delete-server' || value === 'disable-hostkey' || value === 'disable-rule'
166
- }
167
-
168
- /**
169
- * @param {unknown} value
170
- * @param {string} label
171
- * @param {PendingError['code']} code
172
- * @returns {string}
173
- */
174
- function requiredString(value, label, code = 'INVALID_PENDING') {
175
- if (typeof value !== 'string' || value.trim() === '') {
176
- throw new PendingError(code, `${label} must be a non-empty string.`)
177
- }
178
- return value.trim()
179
- }
180
-
181
- /**
182
- * @param {unknown} value
183
- * @param {string} label
184
- * @returns {string}
185
- */
186
- function requiredTimestamp(value, label) {
187
- if (typeof value !== 'string' || value.trim() === '') {
188
- throw new PendingError('INVALID_PENDING', `${label} must be an ISO-8601 timestamp.`)
189
- }
190
- const parsed = new Date(value)
191
- if (Number.isNaN(parsed.getTime())) {
192
- throw new PendingError('INVALID_PENDING', `${label} must be an ISO-8601 timestamp.`)
193
- }
194
- return parsed.toISOString()
195
- }
196
-
197
- /**
198
- * Validate one stored record. Load, request and the document check all go
199
- * through this function, so a stored record and a freshly requested one have
200
- * exactly the same shape.
201
- * @param {unknown} raw
202
- * @param {string} label
203
- * @returns {PendingRecord}
204
- */
205
- function normalizeRecord(raw, label) {
206
- if (!isPlainObject(raw)) throw new PendingError('INVALID_PENDING', `${label} must be an object.`)
207
- const id = requiredString(raw.id, `${label}.id`)
208
- if (!/^pend-\d{2,}$/.test(id)) {
209
- throw new PendingError('INVALID_PENDING', `${label}.id must look like "pend-01".`)
210
- }
211
- if (!isPendingKind(raw.kind)) {
212
- throw new PendingError('INVALID_PENDING', `${label}.kind must be one of ${PENDING_KINDS.join(', ')}.`)
213
- }
214
- const kind = raw.kind
215
- const serverId = requiredString(raw.serverId, `${label}.serverId`)
216
- const requestedAt = requiredTimestamp(raw.requestedAt, `${label}.requestedAt`)
217
- const expiresAt = requiredTimestamp(raw.expiresAt, `${label}.expiresAt`)
218
- if (new Date(expiresAt).getTime() < new Date(requestedAt).getTime()) {
219
- throw new PendingError('INVALID_PENDING', `${label}.expiresAt must not precede requestedAt.`)
220
- }
221
-
222
- /** @type {PendingRecord} */
223
- const record = { id, kind, serverId, requestedAt, expiresAt }
224
- if (kind === 'disable-rule') {
225
- record.ruleId = requiredString(raw.ruleId, `${label}.ruleId`)
226
- } else if (raw.ruleId !== undefined) {
227
- throw new PendingError('INVALID_PENDING', `${label}.ruleId is only valid for kind "disable-rule".`)
228
- }
229
- if (raw.snapshot !== undefined) {
230
- if (!isPlainObject(raw.snapshot)) {
231
- throw new PendingError('INVALID_PENDING', `${label}.snapshot must be an object.`)
232
- }
233
- record.snapshot = raw.snapshot
234
- }
235
- return record
236
- }
237
-
238
- /**
239
- * Allocate the next `pend-NN` id: a numeric suffix one past the highest in the
240
- * document, so a cancelled record's id is never reused by accident.
241
- * @param {PendingRecord[]} records
242
- * @returns {string}
243
- */
244
- function nextPendingId(records) {
245
- let highest = 0
246
- for (const record of records) {
247
- const match = /^pend-(\d+)$/.exec(record.id)
248
- if (match !== null) highest = Math.max(highest, Number(match[1]))
249
- }
250
- return `pend-${String(highest + 1).padStart(2, '0')}`
251
- }
252
-
253
- /**
254
- * Drop the records whose window closed (doc §8.3). The caller persists the
255
- * document when it mutated it.
256
- * @param {PendingDocument} document
257
- * @param {number} nowMs
258
- * @returns {PendingRecord[]} the discarded records, oldest first.
259
- */
260
- function dropExpired(document, nowMs) {
261
- const removed = []
262
- const kept = []
263
- for (const record of document.pending) {
264
- if (new Date(record.expiresAt).getTime() <= nowMs) removed.push(record)
265
- else kept.push(record)
266
- }
267
- document.pending = kept
268
- return removed
269
- }
270
-
271
- /**
272
- * Deep copy a record so callers cannot mutate this instance's state.
273
- * @template T
274
- * @param {T} value
275
- * @returns {T}
276
- */
277
- function clone(value) {
278
- return structuredClone(value)
279
- }
280
-
281
- /**
282
- * The durable two-step queue (doc §3.3, §8.3).
283
- */
284
- export class PendingQueue {
285
- /** @type {string} */
286
- #file
287
- /** @type {number} */
288
- #ttlHours
289
- /** @type {() => Date} */
290
- #now
291
- /** @type {((record: PendingRecord) => Promise<unknown>) | null} */
292
- #apply
293
- /** @type {{info?: Function, warn?: Function, error?: Function}|null} */
294
- #logger
295
-
296
- /**
297
- * @param {{
298
- * file?: string,
299
- * ttlHours?: number,
300
- * now?: () => Date,
301
- * apply?: (record: PendingRecord) => Promise<unknown>,
302
- * logger?: {info?: Function, warn?: Function, error?: Function} | null,
303
- * }} [options]
304
- * `file` defaults to `$DSH_HOME/server-connector/pending.json`; `now` is
305
- * injectable so expiry is testable; `apply` is the side-effecting half and
306
- * is required only by `confirm()`.
307
- */
308
- constructor(options = {}) {
309
- this.#file = options.file ?? defaultPendingFile()
310
- const ttlHours = options.ttlHours ?? DEFAULT_PENDING_TTL_HOURS
311
- if (!(typeof ttlHours === 'number' && Number.isFinite(ttlHours) && ttlHours > 0)) {
312
- throw new PendingError('INVALID_PENDING', 'ttlHours must be a positive number.')
313
- }
314
- this.#ttlHours = ttlHours
315
- this.#now = options.now ?? (() => new Date())
316
- this.#apply = options.apply ?? null
317
- this.#logger = options.logger ?? null
318
- }
319
-
320
- /** Absolute path of the queue file this instance reads and writes. */
321
- get file() {
322
- return this.#file
323
- }
324
-
325
- /** Lifetime of a record, in hours (`pendingTtlHours`). */
326
- get ttlHours() {
327
- return this.#ttlHours
328
- }
329
-
330
- /** Whether this queue can apply a confirmed record. */
331
- get applicable() {
332
- return this.#apply !== null
333
- }
334
-
335
- /**
336
- * Install the side-effecting half (the tools/HTTP layer builds it with
337
- * {@link createPendingApplier}). A queue without one can list, request,
338
- * cancel and expire — everything a read-only panel needs — but not confirm.
339
- * @param {(record: PendingRecord) => Promise<unknown>} apply
340
- * @returns {void}
341
- */
342
- setApplier(apply) {
343
- if (typeof apply !== 'function') throw new PendingError('INVALID_PENDING', 'apply must be a function.')
344
- this.#apply = apply
345
- }
346
-
347
- /**
348
- * Every live record, oldest request first. Expired records are discarded
349
- * (and the file rewritten when that removed anything).
350
- * @returns {Promise<PendingRecord[]>}
351
- */
352
- async list() {
353
- const { document, removed } = await this.#load()
354
- if (removed.length > 0) await this.#save(document)
355
- return document.pending.map((record) => clone(record))
356
- }
357
-
358
- /**
359
- * One record by id, or null. An expired record is discarded and reported as
360
- * null, never returned.
361
- * @param {string} id
362
- * @returns {Promise<PendingRecord|null>}
363
- */
364
- async get(id) {
365
- const { document, removed } = await this.#load()
366
- if (removed.length > 0) await this.#save(document)
367
- const record = document.pending.find((entry) => entry.id === id)
368
- return record === undefined ? null : clone(record)
369
- }
370
-
371
- /**
372
- * Queue one dangerous operation. **No side effect happens here** (doc §8.3):
373
- * the server keeps working, the rule keeps blocking, the host-key mode keeps
374
- * its value. Requesting the same operation again while it is pending returns
375
- * the existing record (`created: false`) instead of stacking duplicates.
376
- * @param {PendingRequestInput} input
377
- * @returns {Promise<{record: PendingRecord, created: boolean}>}
378
- * @throws {PendingError} `INVALID_PENDING`.
379
- */
380
- async request(input) {
381
- if (!isPlainObject(input)) throw new PendingError('INVALID_PENDING', 'request input must be an object.')
382
- if (!isPendingKind(input.kind)) {
383
- throw new PendingError('INVALID_PENDING', `kind must be one of ${PENDING_KINDS.join(', ')}.`)
384
- }
385
- const serverId = requiredString(input.serverId, 'serverId')
386
- /** @type {string|undefined} */
387
- let ruleId
388
- if (input.kind === 'disable-rule') ruleId = requiredString(input.ruleId, 'ruleId')
389
- else if (input.ruleId !== undefined) {
390
- throw new PendingError('INVALID_PENDING', 'ruleId is only valid for kind "disable-rule".')
391
- }
392
- if (input.snapshot !== undefined && !isPlainObject(input.snapshot)) {
393
- throw new PendingError('INVALID_PENDING', 'snapshot must be an object.')
394
- }
395
-
396
- const now = this.#now()
397
- const requestedAt = now.toISOString()
398
- const expiresAt = new Date(now.getTime() + this.#ttlHours * HOUR_MS).toISOString()
399
- return this.#mutate((document) => {
400
- const existing = document.pending.find(
401
- (record) => record.kind === input.kind && record.serverId === serverId && record.ruleId === ruleId,
402
- )
403
- if (existing !== undefined) return { record: clone(existing), created: false }
404
- /** @type {PendingRecord} */
405
- const record = {
406
- id: nextPendingId(document.pending),
407
- kind: input.kind,
408
- serverId,
409
- requestedAt,
410
- expiresAt,
411
- }
412
- if (ruleId !== undefined) record.ruleId = ruleId
413
- if (input.snapshot !== undefined) record.snapshot = clone(input.snapshot)
414
- document.pending.push(record)
415
- return { record: clone(record), created: true }
416
- })
417
- }
418
-
419
- /**
420
- * Apply one queued operation: run the injected applier (which performs the
421
- * real deletion / mode change / rule disable and writes the audit event),
422
- * then remove the record. A throwing applier keeps the record so the user can
423
- * retry.
424
- * @param {string} id
425
- * @returns {Promise<PendingApplied>}
426
- * @throws {PendingError} `UNKNOWN_PENDING`, `EXPIRED_PENDING`,
427
- * `PENDING_UNAVAILABLE` or `APPLY_FAILED`.
428
- */
429
- async confirm(id) {
430
- const apply = this.#apply
431
- if (apply === null) {
432
- throw new PendingError(
433
- 'PENDING_UNAVAILABLE',
434
- 'This pending queue has no applier; confirmations cannot be executed in this composition.',
435
- )
436
- }
437
- // The applier runs *outside* the mutation queue: it talks to the registry,
438
- // the pool and the credentials service, and holding the queue lock across
439
- // those awaits would block every other queue operation. The record is
440
- // re-checked and removed in a second, short mutation.
441
- const record = await this.#requireLive(id)
442
- let result
443
- try {
444
- result = await apply(clone(record))
445
- } catch (error) {
446
- throw new PendingError(
447
- 'APPLY_FAILED',
448
- `Applying ${record.kind} for ${record.serverId} failed: ${error instanceof Error ? error.message : String(error)}. The request stays pending; fix the cause and confirm again.`,
449
- { cause: error, record: clone(record) },
450
- )
451
- }
452
- await this.#mutate((document) => {
453
- const index = document.pending.findIndex((entry) => entry.id === id)
454
- // Another confirm/cancel may have won the race while the applier ran.
455
- if (index !== -1) document.pending.splice(index, 1)
456
- })
457
- return { record, status: 'applied', result }
458
- }
459
-
460
- /**
461
- * Drop one queued request. The target keeps its current state — cancelling
462
- * never reverts anything, because nothing happened yet.
463
- * @param {string} id
464
- * @returns {Promise<PendingDiscarded>}
465
- * @throws {PendingError} `UNKNOWN_PENDING`.
466
- */
467
- async cancel(id) {
468
- const discarded = await this.#mutate((document) => {
469
- const index = document.pending.findIndex((entry) => entry.id === id)
470
- if (index === -1) {
471
- throw new PendingError('UNKNOWN_PENDING', `No pending record "${id}"; it may have been confirmed, cancelled or expired already.`)
472
- }
473
- const [record] = document.pending.splice(index, 1)
474
- return record
475
- })
476
- return { record: clone(/** @type {PendingRecord} */ (discarded)), status: 'discarded' }
477
- }
478
-
479
- /**
480
- * Discard every record whose 24 h window closed (doc §8.3). Called once at
481
- * startup and implicitly by every read/write, so the panel never shows a
482
- * stale row.
483
- * @returns {Promise<PendingRecord[]>} the discarded records.
484
- */
485
- async expire() {
486
- /** @type {PendingRecord[]} */
487
- let discarded = []
488
- await this.#mutate((document, removed) => {
489
- // `#mutate` already dropped the expired records while loading; this write
490
- // persists that removal (and is a harmless no-op when nothing expired).
491
- void document
492
- discarded = removed
493
- })
494
- return discarded.map((record) => clone(record))
495
- }
496
-
497
- /**
498
- * Load the document, dropping expired records in memory and reporting them so
499
- * a read can persist the removal.
500
- * @returns {Promise<{document: PendingDocument, removed: PendingRecord[]}>}
501
- */
502
- async #load() {
503
- /** @type {string} */
504
- let text
505
- try {
506
- text = await readFile(this.#file, 'utf8')
507
- } catch (error) {
508
- if (isErrno(error, 'ENOENT')) return { document: { version: PENDING_VERSION, pending: [] }, removed: [] }
509
- throw error
510
- }
511
- if (text.trim() === '') return { document: { version: PENDING_VERSION, pending: [] }, removed: [] }
512
-
513
- /** @type {unknown} */
514
- let parsed
515
- try {
516
- parsed = JSON.parse(text)
517
- } catch {
518
- throw new PendingError('INVALID_PENDING', `${this.#file} is not valid JSON; fix or remove it.`)
519
- }
520
- if (!isPlainObject(parsed)) {
521
- throw new PendingError('INVALID_PENDING', `${this.#file} must contain a JSON object.`)
522
- }
523
- if (parsed.version !== PENDING_VERSION) {
524
- throw new PendingError(
525
- 'INVALID_PENDING',
526
- `${this.#file} has unsupported version ${String(parsed.version)}; expected ${PENDING_VERSION}.`,
527
- )
528
- }
529
- if (!Array.isArray(parsed.pending)) {
530
- throw new PendingError('INVALID_PENDING', `${this.#file} must contain a "pending" array.`)
531
- }
532
- /** @type {PendingRecord[]} */
533
- const pending = []
534
- const seen = new Set()
535
- for (let index = 0; index < parsed.pending.length; index += 1) {
536
- const record = normalizeRecord(parsed.pending[index], `pending[${index}]`)
537
- if (seen.has(record.id)) {
538
- throw new PendingError('INVALID_PENDING', `${this.#file}: duplicate pending id "${record.id}".`)
539
- }
540
- seen.add(record.id)
541
- pending.push(record)
542
- }
543
- /** @type {PendingDocument} */
544
- const document = { version: PENDING_VERSION, pending }
545
- const removed = dropExpired(document, this.#now().getTime())
546
- return { document, removed }
547
- }
548
-
549
- /**
550
- * Commit the document atomically: write a sibling temp file, then rename it
551
- * over the queue, so a reader sees either the old or the new complete file.
552
- * @param {PendingDocument} document
553
- * @returns {Promise<void>}
554
- */
555
- async #save(document) {
556
- await mkdir(dirname(this.#file), { recursive: true, mode: 0o700 })
557
- const temporary = `${this.#file}.${process.pid}.${randomBytes(6).toString('hex')}.tmp`
558
- try {
559
- await writeFile(temporary, `${JSON.stringify(document, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 })
560
- await rename(temporary, this.#file)
561
- } catch (error) {
562
- await unlink(temporary).catch(() => {})
563
- throw error
564
- }
565
- }
566
-
567
- /**
568
- * Run one read-modify-write after every earlier mutation of the same file
569
- * finished, in this process and across instances. Expired records are
570
- * discarded before the callback sees the document.
571
- * @template T
572
- * @param {(document: PendingDocument, removed: PendingRecord[]) => T} mutate
573
- * @returns {Promise<T>}
574
- */
575
- async #mutate(mutate) {
576
- const key = resolve(this.#file)
577
- const previous = MUTATION_QUEUES.get(key) ?? Promise.resolve()
578
- const operation = previous.then(async () => {
579
- const { document, removed } = await this.#load()
580
- for (const record of removed) {
581
- this.#log('info', 'dsh-server-connector: discarded expired pending %s (%s)', record.id, record.kind)
582
- }
583
- const result = mutate(document, removed)
584
- await this.#save(document)
585
- return result
586
- })
587
- const settled = operation.then(
588
- () => undefined,
589
- () => undefined,
590
- )
591
- MUTATION_QUEUES.set(key, settled)
592
- void settled.then(() => {
593
- if (MUTATION_QUEUES.get(key) === settled) MUTATION_QUEUES.delete(key)
594
- })
595
- return operation
596
- }
597
-
598
- /**
599
- * Read one record that is still pending and not expired, without mutating.
600
- * @param {string} id
601
- * @returns {Promise<PendingRecord>}
602
- */
603
- async #requireLive(id) {
604
- const { document, removed } = await this.#load()
605
- if (removed.length > 0) await this.#save(document)
606
- const record = document.pending.find((entry) => entry.id === id)
607
- if (record === undefined) {
608
- const expired = removed.find((entry) => entry.id === id)
609
- if (expired !== undefined) {
610
- throw new PendingError(
611
- 'EXPIRED_PENDING',
612
- `Pending record "${id}" expired at ${expired.expiresAt} and was discarded; request the ${expired.kind} again if it is still wanted.`,
613
- { record: clone(expired) },
614
- )
615
- }
616
- throw new PendingError(
617
- 'UNKNOWN_PENDING',
618
- `No pending record "${id}"; it may have been confirmed, cancelled or expired already.`,
619
- )
620
- }
621
- return clone(record)
622
- }
623
-
624
- /**
625
- * @param {string} level
626
- * @param {string} message
627
- * @param {...unknown} args
628
- * @returns {void}
629
- */
630
- #log(level, message, ...args) {
631
- const logger = this.#logger
632
- if (logger === null || logger === undefined) return
633
- const write = logger[/** @type {'info'|'warn'|'error'} */ (level)] ?? logger.info
634
- if (typeof write === 'function') write.call(logger, message, ...args)
635
- }
636
- }
637
-
638
- /**
639
- * The production applier: the side-effecting half of `confirm()` (doc §8.3).
640
- *
641
- * - `delete-server` → remove the record from `servers.json`, close the
642
- * pool connection, drop the stored password, and write
643
- * a `server-deleted` audit event;
644
- * - `disable-hostkey` → set `hostKeyMode: "off"` and write `hostkey-disabled`;
645
- * - `disable-rule` → add the rule id to `disabledRules` and write
646
- * `rule-disabled`.
647
- *
648
- * Everything is injected, so the queue module itself never imports the
649
- * registry, the pool or the audit log (doc §2.2).
650
- * @param {{
651
- * registry: import('./registry.js').Registry,
652
- * pool?: {disconnect(serverId: string): Promise<void>} | null,
653
- * audit?: {append(entry: Record<string, unknown>): Promise<unknown>} | null,
654
- * getCredentials?: () => ({unset?(ref: string): Promise<void>} | null | undefined),
655
- * logger?: {info?: Function, warn?: Function, error?: Function} | null,
656
- * }} options
657
- * @returns {(record: PendingRecord) => Promise<{event: string, serverId: string, serverName: string|null, detail: string}>}
658
- */
659
- export function createPendingApplier(options) {
660
- const { registry, pool = null, audit = null, getCredentials, logger = null } = options
661
- if (registry === undefined || registry === null) {
662
- throw new PendingError('INVALID_PENDING', 'createPendingApplier requires a registry.')
663
- }
664
-
665
- /**
666
- * @param {'info'|'warn'|'error'} level
667
- * @param {string} message
668
- * @param {...unknown} args
669
- * @returns {void}
670
- */
671
- const log = (level, message, ...args) => {
672
- if (logger === null) return
673
- const write = logger[level] ?? logger.info
674
- if (typeof write === 'function') write.call(logger, message, ...args)
675
- }
676
-
677
- /**
678
- * @param {PendingRecord} record
679
- * @param {string} event
680
- * @param {string|null} serverName
681
- * @param {string} detail
682
- * @returns {Promise<{event: string, serverId: string, serverName: string|null, detail: string}>}
683
- */
684
- const recordEvent = async (record, event, serverName, detail) => {
685
- const summary = { event, serverId: record.serverId, serverName, detail }
686
- if (audit === null) return summary
687
- try {
688
- await audit.append({
689
- event: /** @type {'server-deleted'|'hostkey-disabled'|'rule-disabled'} */ (event),
690
- serverId: record.serverId,
691
- ...(serverName === null ? {} : { serverName }),
692
- detail,
693
- })
694
- } catch (error) {
695
- // The operation already committed; a broken audit file must not make the
696
- // panel believe the deletion failed. Report it in the log instead.
697
- log('error', 'dsh-server-connector: could not write the %s audit event: %s', event, String(error))
698
- }
699
- return summary
700
- }
701
-
702
- return async function apply(record) {
703
- if (record.kind === 'delete-server') {
704
- /** @type {import('./registry.js').ServerRecord|null} */
705
- const snapshot = /** @type {import('./registry.js').ServerRecord|null} */ (record.snapshot ?? null)
706
- try {
707
- await registry.remove(record.serverId)
708
- } catch (error) {
709
- // A record already gone is the desired end state; anything else is a
710
- // real failure and must keep the pending row (confirm() rethrows).
711
- if (!(error instanceof Error && /** @type {{code?: string}} */ (error).code === 'UNKNOWN_SERVER')) throw error
712
- }
713
- if (pool !== null) await pool.disconnect(record.serverId)
714
- if (snapshot !== null && snapshot.auth !== undefined && snapshot.auth.kind === 'password') {
715
- const credentials = getCredentials?.()
716
- if (credentials !== null && credentials !== undefined) {
717
- try {
718
- await credentials.unset?.(snapshot.auth.credentialRef)
719
- } catch (error) {
720
- log('warn', 'dsh-server-connector: could not remove the credential of %s: %s', record.serverId, String(error))
721
- }
722
- }
723
- }
724
- const serverName = snapshot === null ? null : String(snapshot.name)
725
- return recordEvent(record, 'server-deleted', serverName, `server ${serverName ?? record.serverId} removed`)
726
- }
727
-
728
- if (record.kind === 'disable-hostkey') {
729
- const server = await registry.update(record.serverId, { hostKeyMode: 'off' })
730
- return recordEvent(record, 'hostkey-disabled', server.name, 'hostKeyMode changed to off (host-key verification disabled)')
731
- }
732
-
733
- if (record.kind === 'disable-rule') {
734
- const server = await registry.get(record.serverId)
735
- if (server === null) {
736
- // The record vanished between the request and the confirmation; the
737
- // applier reports it with the registry's own code so confirm() keeps
738
- // the pending row instead of silently discarding it.
739
- throw Object.assign(new Error(`Unknown server "${record.serverId}"; request the rule change again.`), {
740
- code: 'UNKNOWN_SERVER',
741
- })
742
- }
743
- const ruleId = String(record.ruleId)
744
- const disabledRules = server.disabledRules.includes(ruleId)
745
- ? server.disabledRules
746
- : [...server.disabledRules, ruleId]
747
- await registry.update(record.serverId, { disabledRules })
748
- return recordEvent(record, 'rule-disabled', server.name, `high-risk rule ${ruleId} disabled`)
749
- }
750
-
751
- throw new PendingError('INVALID_PENDING', `Unsupported pending kind "${String(record.kind)}".`)
752
- }
753
- }
1
+ // dsh-server-connector — two-step confirmation queue for dangerous operations
2
+ // (design doc §3.3, §8.3).
3
+ //
4
+ // A dangerous panel action (delete a server, switch host-key verification off,
5
+ // disable a high-risk rule) is *requested* first and applied later, after the
6
+ // user comes back to the settings page and sees the full context. This module
7
+ // owns that queue and nothing else:
8
+ //
9
+ // $DSH_HOME/server-connector/pending.json
10
+ // { "version": 1, "pending": [{
11
+ // "id": "pend-01",
12
+ // "kind": "delete-server" | "disable-hostkey" | "disable-rule",
13
+ // "serverId": "srv-01",
14
+ // "ruleId": "rm-recursive", // disable-rule only
15
+ // "requestedAt": "2026-11-04T09:20:11Z",
16
+ // "expiresAt": "2026-11-05T09:20:11Z", // requestedAt + pendingTtlHours
17
+ // "snapshot": { /* delete-server: the complete record */ }
18
+ // }] }
19
+ //
20
+ // State machine (§8.3):
21
+ //
22
+ // 无记录 ──request()──► pending ──confirm()──► applied (执行 + 审计留痕)
23
+ // └──cancel() / 24h 过期──► discarded
24
+ //
25
+ // `request()` performs no side effect at all: the server keeps working, the
26
+ // rule keeps blocking, host-key verification keeps its mode. Only `confirm()`
27
+ // runs the operation, and it does so through an injected applier
28
+ // ({@link createPendingApplier} is the production one) so this module stays a
29
+ // durable queue rather than a second place that mutates `servers.json`
30
+ // (doc §2.2: "pending:危险操作的两段式队列,不执行任何副作用").
31
+ //
32
+ // Guarantees, in the same spirit as the registry (§3.1):
33
+ // - a missing or empty file is an empty queue; a read never creates the file;
34
+ // - a malformed document is reported (`INVALID_PENDING`), never rewritten;
35
+ // - every mutation is an atomic replace and is serialised per file across the
36
+ // instances of this process;
37
+ // - an expired record is discarded on load, on every read and on every write
38
+ // (doc §8.3: "进程启动时与每次读写时各检查一次"), and an expired record can
39
+ // never be confirmed — the user has to request it again.
40
+
41
+ import { randomBytes } from 'node:crypto'
42
+ import { mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises'
43
+ import { dirname, join, resolve } from 'node:path'
44
+
45
+ import { connectorDirectory } from './registry.js'
46
+
47
+ /** File name of the queue (doc §3.5). */
48
+ export const PENDING_FILE_NAME = 'pending.json'
49
+
50
+ /** Document version of `pending.json`. */
51
+ export const PENDING_VERSION = 1
52
+
53
+ /** The three dangerous operations the queue knows (doc §8.3). */
54
+ export const PENDING_KINDS = Object.freeze(['delete-server', 'disable-hostkey', 'disable-rule'])
55
+
56
+ /** Default lifetime of a pending record (doc §10 `pendingTtlHours`). */
57
+ export const DEFAULT_PENDING_TTL_HOURS = 24
58
+
59
+ const HOUR_MS = 60 * 60 * 1000
60
+
61
+ /**
62
+ * One write promise per queue file, so every PendingQueue instance in this
63
+ * process serialises its mutations against the same document (the registry and
64
+ * the known-hosts store use the same pattern).
65
+ * @type {Map<string, Promise<void>>}
66
+ */
67
+ const MUTATION_QUEUES = new Map()
68
+
69
+ /**
70
+ * Machine-readable queue failure. `INVALID_PENDING` marks a malformed call or
71
+ * document, `UNKNOWN_PENDING` a record that is not in the queue,
72
+ * `EXPIRED_PENDING` a record whose 24 h window closed, `PENDING_UNAVAILABLE` a
73
+ * queue built without an applier, and `APPLY_FAILED` an applier that threw (the
74
+ * record is kept, so the user can retry).
75
+ * @typedef {'INVALID_PENDING'|'UNKNOWN_PENDING'|'EXPIRED_PENDING'|'PENDING_UNAVAILABLE'|'APPLY_FAILED'} PendingErrorCode
76
+ */
77
+ export class PendingError extends Error {
78
+ /**
79
+ * @param {PendingErrorCode} code
80
+ * @param {string} message
81
+ * @param {{cause?: unknown, record?: PendingRecord}} [details]
82
+ */
83
+ constructor(code, message, details = {}) {
84
+ super(message)
85
+ this.name = 'PendingError'
86
+ /** @type {PendingErrorCode} */
87
+ this.code = code
88
+ if (details.cause !== undefined) this.cause = details.cause
89
+ if (details.record !== undefined) this.record = details.record
90
+ }
91
+ }
92
+
93
+ /**
94
+ * The three dangerous operations (doc §8.3).
95
+ * @typedef {'delete-server'|'disable-hostkey'|'disable-rule'} PendingKind
96
+ */
97
+
98
+ /**
99
+ * One queued request, exactly as persisted (doc §3.3).
100
+ * @typedef {object} PendingRecord
101
+ * @property {string} id - `pend-01`, `pend-02`, …; assigned by the queue.
102
+ * @property {PendingKind} kind
103
+ * @property {string} serverId
104
+ * @property {string} [ruleId] - present only for `disable-rule`.
105
+ * @property {string} requestedAt - ISO-8601.
106
+ * @property {string} expiresAt - requestedAt + pendingTtlHours, ISO-8601.
107
+ * @property {Record<string, unknown>} [snapshot] - `delete-server` keeps the
108
+ * complete server record here so a cancelled request leaves no trace, and a
109
+ * confirmed one can still delete the credentials of an already-gone record.
110
+ */
111
+
112
+ /**
113
+ * The persisted document.
114
+ * @typedef {object} PendingDocument
115
+ * @property {number} version
116
+ * @property {PendingRecord[]} pending
117
+ */
118
+
119
+ /**
120
+ * What `request()` accepts.
121
+ * @typedef {object} PendingRequestInput
122
+ * @property {PendingKind} kind
123
+ * @property {string} serverId
124
+ * @property {string} [ruleId] - required for `disable-rule`.
125
+ * @property {Record<string, unknown>} [snapshot] - optional, `delete-server` uses it.
126
+ */
127
+
128
+ /**
129
+ * What `confirm()` returns. `status` is the §8.3 state the record reached.
130
+ * @typedef {{record: PendingRecord, status: 'applied', result: unknown}} PendingApplied
131
+ * @typedef {{record: PendingRecord, status: 'discarded'}} PendingDiscarded
132
+ */
133
+
134
+ /**
135
+ * Default location of `pending.json` (doc §3.5).
136
+ * @param {NodeJS.ProcessEnv} [env]
137
+ * @returns {string}
138
+ */
139
+ export function defaultPendingFile(env = process.env) {
140
+ return join(connectorDirectory(env), PENDING_FILE_NAME)
141
+ }
142
+
143
+ /**
144
+ * @param {unknown} value
145
+ * @returns {value is Record<string, unknown>}
146
+ */
147
+ function isPlainObject(value) {
148
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
149
+ }
150
+
151
+ /**
152
+ * @param {unknown} error
153
+ * @param {string} code
154
+ * @returns {boolean}
155
+ */
156
+ function isErrno(error, code) {
157
+ return isPlainObject(error) && error.code === code
158
+ }
159
+
160
+ /**
161
+ * @param {unknown} value
162
+ * @returns {value is PendingKind}
163
+ */
164
+ function isPendingKind(value) {
165
+ return value === 'delete-server' || value === 'disable-hostkey' || value === 'disable-rule'
166
+ }
167
+
168
+ /**
169
+ * @param {unknown} value
170
+ * @param {string} label
171
+ * @param {PendingError['code']} code
172
+ * @returns {string}
173
+ */
174
+ function requiredString(value, label, code = 'INVALID_PENDING') {
175
+ if (typeof value !== 'string' || value.trim() === '') {
176
+ throw new PendingError(code, `${label} must be a non-empty string.`)
177
+ }
178
+ return value.trim()
179
+ }
180
+
181
+ /**
182
+ * @param {unknown} value
183
+ * @param {string} label
184
+ * @returns {string}
185
+ */
186
+ function requiredTimestamp(value, label) {
187
+ if (typeof value !== 'string' || value.trim() === '') {
188
+ throw new PendingError('INVALID_PENDING', `${label} must be an ISO-8601 timestamp.`)
189
+ }
190
+ const parsed = new Date(value)
191
+ if (Number.isNaN(parsed.getTime())) {
192
+ throw new PendingError('INVALID_PENDING', `${label} must be an ISO-8601 timestamp.`)
193
+ }
194
+ return parsed.toISOString()
195
+ }
196
+
197
+ /**
198
+ * Validate one stored record. Load, request and the document check all go
199
+ * through this function, so a stored record and a freshly requested one have
200
+ * exactly the same shape.
201
+ * @param {unknown} raw
202
+ * @param {string} label
203
+ * @returns {PendingRecord}
204
+ */
205
+ function normalizeRecord(raw, label) {
206
+ if (!isPlainObject(raw)) throw new PendingError('INVALID_PENDING', `${label} must be an object.`)
207
+ const id = requiredString(raw.id, `${label}.id`)
208
+ if (!/^pend-\d{2,}$/.test(id)) {
209
+ throw new PendingError('INVALID_PENDING', `${label}.id must look like "pend-01".`)
210
+ }
211
+ if (!isPendingKind(raw.kind)) {
212
+ throw new PendingError('INVALID_PENDING', `${label}.kind must be one of ${PENDING_KINDS.join(', ')}.`)
213
+ }
214
+ const kind = raw.kind
215
+ const serverId = requiredString(raw.serverId, `${label}.serverId`)
216
+ const requestedAt = requiredTimestamp(raw.requestedAt, `${label}.requestedAt`)
217
+ const expiresAt = requiredTimestamp(raw.expiresAt, `${label}.expiresAt`)
218
+ if (new Date(expiresAt).getTime() < new Date(requestedAt).getTime()) {
219
+ throw new PendingError('INVALID_PENDING', `${label}.expiresAt must not precede requestedAt.`)
220
+ }
221
+
222
+ /** @type {PendingRecord} */
223
+ const record = { id, kind, serverId, requestedAt, expiresAt }
224
+ if (kind === 'disable-rule') {
225
+ record.ruleId = requiredString(raw.ruleId, `${label}.ruleId`)
226
+ } else if (raw.ruleId !== undefined) {
227
+ throw new PendingError('INVALID_PENDING', `${label}.ruleId is only valid for kind "disable-rule".`)
228
+ }
229
+ if (raw.snapshot !== undefined) {
230
+ if (!isPlainObject(raw.snapshot)) {
231
+ throw new PendingError('INVALID_PENDING', `${label}.snapshot must be an object.`)
232
+ }
233
+ record.snapshot = raw.snapshot
234
+ }
235
+ return record
236
+ }
237
+
238
+ /**
239
+ * Allocate the next `pend-NN` id: a numeric suffix one past the highest in the
240
+ * document, so a cancelled record's id is never reused by accident.
241
+ * @param {PendingRecord[]} records
242
+ * @returns {string}
243
+ */
244
+ function nextPendingId(records) {
245
+ let highest = 0
246
+ for (const record of records) {
247
+ const match = /^pend-(\d+)$/.exec(record.id)
248
+ if (match !== null) highest = Math.max(highest, Number(match[1]))
249
+ }
250
+ return `pend-${String(highest + 1).padStart(2, '0')}`
251
+ }
252
+
253
+ /**
254
+ * Drop the records whose window closed (doc §8.3). The caller persists the
255
+ * document when it mutated it.
256
+ * @param {PendingDocument} document
257
+ * @param {number} nowMs
258
+ * @returns {PendingRecord[]} the discarded records, oldest first.
259
+ */
260
+ function dropExpired(document, nowMs) {
261
+ const removed = []
262
+ const kept = []
263
+ for (const record of document.pending) {
264
+ if (new Date(record.expiresAt).getTime() <= nowMs) removed.push(record)
265
+ else kept.push(record)
266
+ }
267
+ document.pending = kept
268
+ return removed
269
+ }
270
+
271
+ /**
272
+ * Deep copy a record so callers cannot mutate this instance's state.
273
+ * @template T
274
+ * @param {T} value
275
+ * @returns {T}
276
+ */
277
+ function clone(value) {
278
+ return structuredClone(value)
279
+ }
280
+
281
+ /**
282
+ * The durable two-step queue (doc §3.3, §8.3).
283
+ */
284
+ export class PendingQueue {
285
+ /** @type {string} */
286
+ #file
287
+ /** @type {number} */
288
+ #ttlHours
289
+ /** @type {() => Date} */
290
+ #now
291
+ /** @type {((record: PendingRecord) => Promise<unknown>) | null} */
292
+ #apply
293
+ /** @type {{info?: Function, warn?: Function, error?: Function}|null} */
294
+ #logger
295
+
296
+ /**
297
+ * @param {{
298
+ * file?: string,
299
+ * ttlHours?: number,
300
+ * now?: () => Date,
301
+ * apply?: (record: PendingRecord) => Promise<unknown>,
302
+ * logger?: {info?: Function, warn?: Function, error?: Function} | null,
303
+ * }} [options]
304
+ * `file` defaults to `$DSH_HOME/server-connector/pending.json`; `now` is
305
+ * injectable so expiry is testable; `apply` is the side-effecting half and
306
+ * is required only by `confirm()`.
307
+ */
308
+ constructor(options = {}) {
309
+ this.#file = options.file ?? defaultPendingFile()
310
+ const ttlHours = options.ttlHours ?? DEFAULT_PENDING_TTL_HOURS
311
+ if (!(typeof ttlHours === 'number' && Number.isFinite(ttlHours) && ttlHours > 0)) {
312
+ throw new PendingError('INVALID_PENDING', 'ttlHours must be a positive number.')
313
+ }
314
+ this.#ttlHours = ttlHours
315
+ this.#now = options.now ?? (() => new Date())
316
+ this.#apply = options.apply ?? null
317
+ this.#logger = options.logger ?? null
318
+ }
319
+
320
+ /** Absolute path of the queue file this instance reads and writes. */
321
+ get file() {
322
+ return this.#file
323
+ }
324
+
325
+ /** Lifetime of a record, in hours (`pendingTtlHours`). */
326
+ get ttlHours() {
327
+ return this.#ttlHours
328
+ }
329
+
330
+ /** Whether this queue can apply a confirmed record. */
331
+ get applicable() {
332
+ return this.#apply !== null
333
+ }
334
+
335
+ /**
336
+ * Apply an edited TTL to this queue (doc §10, live settings).
337
+ *
338
+ * The TTL is read when a request is created (`expiresAt`), so a new value
339
+ * applies to the next request; records already queued keep the deadline they
340
+ * were promised, which is why this does not rewrite the file.
341
+ * @param {number} ttlHours
342
+ * @returns {void}
343
+ */
344
+ configure(ttlHours) {
345
+ if (typeof ttlHours === 'number' && Number.isFinite(ttlHours) && ttlHours > 0) this.#ttlHours = ttlHours
346
+ }
347
+
348
+ /**
349
+ * Install the side-effecting half (the tools/HTTP layer builds it with
350
+ * {@link createPendingApplier}). A queue without one can list, request,
351
+ * cancel and expire — everything a read-only panel needs — but not confirm.
352
+ * @param {(record: PendingRecord) => Promise<unknown>} apply
353
+ * @returns {void}
354
+ */
355
+ setApplier(apply) {
356
+ if (typeof apply !== 'function') throw new PendingError('INVALID_PENDING', 'apply must be a function.')
357
+ this.#apply = apply
358
+ }
359
+
360
+ /**
361
+ * Every live record, oldest request first. Expired records are discarded
362
+ * (and the file rewritten when that removed anything).
363
+ * @returns {Promise<PendingRecord[]>}
364
+ */
365
+ async list() {
366
+ const { document, removed } = await this.#load()
367
+ if (removed.length > 0) await this.#save(document)
368
+ return document.pending.map((record) => clone(record))
369
+ }
370
+
371
+ /**
372
+ * One record by id, or null. An expired record is discarded and reported as
373
+ * null, never returned.
374
+ * @param {string} id
375
+ * @returns {Promise<PendingRecord|null>}
376
+ */
377
+ async get(id) {
378
+ const { document, removed } = await this.#load()
379
+ if (removed.length > 0) await this.#save(document)
380
+ const record = document.pending.find((entry) => entry.id === id)
381
+ return record === undefined ? null : clone(record)
382
+ }
383
+
384
+ /**
385
+ * Queue one dangerous operation. **No side effect happens here** (doc §8.3):
386
+ * the server keeps working, the rule keeps blocking, the host-key mode keeps
387
+ * its value. Requesting the same operation again while it is pending returns
388
+ * the existing record (`created: false`) instead of stacking duplicates.
389
+ * @param {PendingRequestInput} input
390
+ * @returns {Promise<{record: PendingRecord, created: boolean}>}
391
+ * @throws {PendingError} `INVALID_PENDING`.
392
+ */
393
+ async request(input) {
394
+ if (!isPlainObject(input)) throw new PendingError('INVALID_PENDING', 'request input must be an object.')
395
+ if (!isPendingKind(input.kind)) {
396
+ throw new PendingError('INVALID_PENDING', `kind must be one of ${PENDING_KINDS.join(', ')}.`)
397
+ }
398
+ const serverId = requiredString(input.serverId, 'serverId')
399
+ /** @type {string|undefined} */
400
+ let ruleId
401
+ if (input.kind === 'disable-rule') ruleId = requiredString(input.ruleId, 'ruleId')
402
+ else if (input.ruleId !== undefined) {
403
+ throw new PendingError('INVALID_PENDING', 'ruleId is only valid for kind "disable-rule".')
404
+ }
405
+ if (input.snapshot !== undefined && !isPlainObject(input.snapshot)) {
406
+ throw new PendingError('INVALID_PENDING', 'snapshot must be an object.')
407
+ }
408
+
409
+ const now = this.#now()
410
+ const requestedAt = now.toISOString()
411
+ const expiresAt = new Date(now.getTime() + this.#ttlHours * HOUR_MS).toISOString()
412
+ return this.#mutate((document) => {
413
+ const existing = document.pending.find(
414
+ (record) => record.kind === input.kind && record.serverId === serverId && record.ruleId === ruleId,
415
+ )
416
+ if (existing !== undefined) return { record: clone(existing), created: false }
417
+ /** @type {PendingRecord} */
418
+ const record = {
419
+ id: nextPendingId(document.pending),
420
+ kind: input.kind,
421
+ serverId,
422
+ requestedAt,
423
+ expiresAt,
424
+ }
425
+ if (ruleId !== undefined) record.ruleId = ruleId
426
+ if (input.snapshot !== undefined) record.snapshot = clone(input.snapshot)
427
+ document.pending.push(record)
428
+ return { record: clone(record), created: true }
429
+ })
430
+ }
431
+
432
+ /**
433
+ * Apply one queued operation: run the injected applier (which performs the
434
+ * real deletion / mode change / rule disable and writes the audit event),
435
+ * then remove the record. A throwing applier keeps the record so the user can
436
+ * retry.
437
+ * @param {string} id
438
+ * @returns {Promise<PendingApplied>}
439
+ * @throws {PendingError} `UNKNOWN_PENDING`, `EXPIRED_PENDING`,
440
+ * `PENDING_UNAVAILABLE` or `APPLY_FAILED`.
441
+ */
442
+ async confirm(id) {
443
+ const apply = this.#apply
444
+ if (apply === null) {
445
+ throw new PendingError(
446
+ 'PENDING_UNAVAILABLE',
447
+ 'This pending queue has no applier; confirmations cannot be executed in this composition.',
448
+ )
449
+ }
450
+ // The applier runs *outside* the mutation queue: it talks to the registry,
451
+ // the pool and the credentials service, and holding the queue lock across
452
+ // those awaits would block every other queue operation. The record is
453
+ // re-checked and removed in a second, short mutation.
454
+ const record = await this.#requireLive(id)
455
+ let result
456
+ try {
457
+ result = await apply(clone(record))
458
+ } catch (error) {
459
+ throw new PendingError(
460
+ 'APPLY_FAILED',
461
+ `Applying ${record.kind} for ${record.serverId} failed: ${error instanceof Error ? error.message : String(error)}. The request stays pending; fix the cause and confirm again.`,
462
+ { cause: error, record: clone(record) },
463
+ )
464
+ }
465
+ await this.#mutate((document) => {
466
+ const index = document.pending.findIndex((entry) => entry.id === id)
467
+ // Another confirm/cancel may have won the race while the applier ran.
468
+ if (index !== -1) document.pending.splice(index, 1)
469
+ })
470
+ return { record, status: 'applied', result }
471
+ }
472
+
473
+ /**
474
+ * Drop one queued request. The target keeps its current state — cancelling
475
+ * never reverts anything, because nothing happened yet.
476
+ * @param {string} id
477
+ * @returns {Promise<PendingDiscarded>}
478
+ * @throws {PendingError} `UNKNOWN_PENDING`.
479
+ */
480
+ async cancel(id) {
481
+ const discarded = await this.#mutate((document) => {
482
+ const index = document.pending.findIndex((entry) => entry.id === id)
483
+ if (index === -1) {
484
+ throw new PendingError('UNKNOWN_PENDING', `No pending record "${id}"; it may have been confirmed, cancelled or expired already.`)
485
+ }
486
+ const [record] = document.pending.splice(index, 1)
487
+ return record
488
+ })
489
+ return { record: clone(/** @type {PendingRecord} */ (discarded)), status: 'discarded' }
490
+ }
491
+
492
+ /**
493
+ * Discard every record whose 24 h window closed (doc §8.3). Called once at
494
+ * startup and implicitly by every read/write, so the panel never shows a
495
+ * stale row.
496
+ * @returns {Promise<PendingRecord[]>} the discarded records.
497
+ */
498
+ async expire() {
499
+ /** @type {PendingRecord[]} */
500
+ let discarded = []
501
+ await this.#mutate((document, removed) => {
502
+ // `#mutate` already dropped the expired records while loading; this write
503
+ // persists that removal (and is a harmless no-op when nothing expired).
504
+ void document
505
+ discarded = removed
506
+ })
507
+ return discarded.map((record) => clone(record))
508
+ }
509
+
510
+ /**
511
+ * Load the document, dropping expired records in memory and reporting them so
512
+ * a read can persist the removal.
513
+ * @returns {Promise<{document: PendingDocument, removed: PendingRecord[]}>}
514
+ */
515
+ async #load() {
516
+ /** @type {string} */
517
+ let text
518
+ try {
519
+ text = await readFile(this.#file, 'utf8')
520
+ } catch (error) {
521
+ if (isErrno(error, 'ENOENT')) return { document: { version: PENDING_VERSION, pending: [] }, removed: [] }
522
+ throw error
523
+ }
524
+ if (text.trim() === '') return { document: { version: PENDING_VERSION, pending: [] }, removed: [] }
525
+
526
+ /** @type {unknown} */
527
+ let parsed
528
+ try {
529
+ parsed = JSON.parse(text)
530
+ } catch {
531
+ throw new PendingError('INVALID_PENDING', `${this.#file} is not valid JSON; fix or remove it.`)
532
+ }
533
+ if (!isPlainObject(parsed)) {
534
+ throw new PendingError('INVALID_PENDING', `${this.#file} must contain a JSON object.`)
535
+ }
536
+ if (parsed.version !== PENDING_VERSION) {
537
+ throw new PendingError(
538
+ 'INVALID_PENDING',
539
+ `${this.#file} has unsupported version ${String(parsed.version)}; expected ${PENDING_VERSION}.`,
540
+ )
541
+ }
542
+ if (!Array.isArray(parsed.pending)) {
543
+ throw new PendingError('INVALID_PENDING', `${this.#file} must contain a "pending" array.`)
544
+ }
545
+ /** @type {PendingRecord[]} */
546
+ const pending = []
547
+ const seen = new Set()
548
+ for (let index = 0; index < parsed.pending.length; index += 1) {
549
+ const record = normalizeRecord(parsed.pending[index], `pending[${index}]`)
550
+ if (seen.has(record.id)) {
551
+ throw new PendingError('INVALID_PENDING', `${this.#file}: duplicate pending id "${record.id}".`)
552
+ }
553
+ seen.add(record.id)
554
+ pending.push(record)
555
+ }
556
+ /** @type {PendingDocument} */
557
+ const document = { version: PENDING_VERSION, pending }
558
+ const removed = dropExpired(document, this.#now().getTime())
559
+ return { document, removed }
560
+ }
561
+
562
+ /**
563
+ * Commit the document atomically: write a sibling temp file, then rename it
564
+ * over the queue, so a reader sees either the old or the new complete file.
565
+ * @param {PendingDocument} document
566
+ * @returns {Promise<void>}
567
+ */
568
+ async #save(document) {
569
+ await mkdir(dirname(this.#file), { recursive: true, mode: 0o700 })
570
+ const temporary = `${this.#file}.${process.pid}.${randomBytes(6).toString('hex')}.tmp`
571
+ try {
572
+ await writeFile(temporary, `${JSON.stringify(document, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 })
573
+ await rename(temporary, this.#file)
574
+ } catch (error) {
575
+ await unlink(temporary).catch(() => {})
576
+ throw error
577
+ }
578
+ }
579
+
580
+ /**
581
+ * Run one read-modify-write after every earlier mutation of the same file
582
+ * finished, in this process and across instances. Expired records are
583
+ * discarded before the callback sees the document.
584
+ * @template T
585
+ * @param {(document: PendingDocument, removed: PendingRecord[]) => T} mutate
586
+ * @returns {Promise<T>}
587
+ */
588
+ async #mutate(mutate) {
589
+ const key = resolve(this.#file)
590
+ const previous = MUTATION_QUEUES.get(key) ?? Promise.resolve()
591
+ const operation = previous.then(async () => {
592
+ const { document, removed } = await this.#load()
593
+ for (const record of removed) {
594
+ this.#log('info', 'dsh-server-connector: discarded expired pending %s (%s)', record.id, record.kind)
595
+ }
596
+ const result = mutate(document, removed)
597
+ await this.#save(document)
598
+ return result
599
+ })
600
+ const settled = operation.then(
601
+ () => undefined,
602
+ () => undefined,
603
+ )
604
+ MUTATION_QUEUES.set(key, settled)
605
+ void settled.then(() => {
606
+ if (MUTATION_QUEUES.get(key) === settled) MUTATION_QUEUES.delete(key)
607
+ })
608
+ return operation
609
+ }
610
+
611
+ /**
612
+ * Read one record that is still pending and not expired, without mutating.
613
+ * @param {string} id
614
+ * @returns {Promise<PendingRecord>}
615
+ */
616
+ async #requireLive(id) {
617
+ const { document, removed } = await this.#load()
618
+ if (removed.length > 0) await this.#save(document)
619
+ const record = document.pending.find((entry) => entry.id === id)
620
+ if (record === undefined) {
621
+ const expired = removed.find((entry) => entry.id === id)
622
+ if (expired !== undefined) {
623
+ throw new PendingError(
624
+ 'EXPIRED_PENDING',
625
+ `Pending record "${id}" expired at ${expired.expiresAt} and was discarded; request the ${expired.kind} again if it is still wanted.`,
626
+ { record: clone(expired) },
627
+ )
628
+ }
629
+ throw new PendingError(
630
+ 'UNKNOWN_PENDING',
631
+ `No pending record "${id}"; it may have been confirmed, cancelled or expired already.`,
632
+ )
633
+ }
634
+ return clone(record)
635
+ }
636
+
637
+ /**
638
+ * @param {string} level
639
+ * @param {string} message
640
+ * @param {...unknown} args
641
+ * @returns {void}
642
+ */
643
+ #log(level, message, ...args) {
644
+ const logger = this.#logger
645
+ if (logger === null || logger === undefined) return
646
+ const write = logger[/** @type {'info'|'warn'|'error'} */ (level)] ?? logger.info
647
+ if (typeof write === 'function') write.call(logger, message, ...args)
648
+ }
649
+ }
650
+
651
+ /**
652
+ * The production applier: the side-effecting half of `confirm()` (doc §8.3).
653
+ *
654
+ * - `delete-server` → remove the record from `servers.json`, close the
655
+ * pool connection, drop the stored password, and write
656
+ * a `server-deleted` audit event;
657
+ * - `disable-hostkey` → set `hostKeyMode: "off"` and write `hostkey-disabled`;
658
+ * - `disable-rule` → add the rule id to `disabledRules` and write
659
+ * `rule-disabled`.
660
+ *
661
+ * Everything is injected, so the queue module itself never imports the
662
+ * registry, the pool or the audit log (doc §2.2).
663
+ * @param {{
664
+ * registry: import('./registry.js').Registry,
665
+ * pool?: {disconnect(serverId: string): Promise<void>} | null,
666
+ * audit?: {append(entry: Record<string, unknown>): Promise<unknown>} | null,
667
+ * getCredentials?: () => ({unset?(ref: string): Promise<void>} | null | undefined),
668
+ * logger?: {info?: Function, warn?: Function, error?: Function} | null,
669
+ * }} options
670
+ * @returns {(record: PendingRecord) => Promise<{event: string, serverId: string, serverName: string|null, detail: string}>}
671
+ */
672
+ export function createPendingApplier(options) {
673
+ const { registry, pool = null, audit = null, getCredentials, logger = null } = options
674
+ if (registry === undefined || registry === null) {
675
+ throw new PendingError('INVALID_PENDING', 'createPendingApplier requires a registry.')
676
+ }
677
+
678
+ /**
679
+ * @param {'info'|'warn'|'error'} level
680
+ * @param {string} message
681
+ * @param {...unknown} args
682
+ * @returns {void}
683
+ */
684
+ const log = (level, message, ...args) => {
685
+ if (logger === null) return
686
+ const write = logger[level] ?? logger.info
687
+ if (typeof write === 'function') write.call(logger, message, ...args)
688
+ }
689
+
690
+ /**
691
+ * @param {PendingRecord} record
692
+ * @param {string} event
693
+ * @param {string|null} serverName
694
+ * @param {string} detail
695
+ * @returns {Promise<{event: string, serverId: string, serverName: string|null, detail: string}>}
696
+ */
697
+ const recordEvent = async (record, event, serverName, detail) => {
698
+ const summary = { event, serverId: record.serverId, serverName, detail }
699
+ if (audit === null) return summary
700
+ try {
701
+ await audit.append({
702
+ event: /** @type {'server-deleted'|'hostkey-disabled'|'rule-disabled'} */ (event),
703
+ serverId: record.serverId,
704
+ ...(serverName === null ? {} : { serverName }),
705
+ detail,
706
+ })
707
+ } catch (error) {
708
+ // The operation already committed; a broken audit file must not make the
709
+ // panel believe the deletion failed. Report it in the log instead.
710
+ log('error', 'dsh-server-connector: could not write the %s audit event: %s', event, String(error))
711
+ }
712
+ return summary
713
+ }
714
+
715
+ return async function apply(record) {
716
+ if (record.kind === 'delete-server') {
717
+ /** @type {import('./registry.js').ServerRecord|null} */
718
+ const snapshot = /** @type {import('./registry.js').ServerRecord|null} */ (record.snapshot ?? null)
719
+ try {
720
+ await registry.remove(record.serverId)
721
+ } catch (error) {
722
+ // A record already gone is the desired end state; anything else is a
723
+ // real failure and must keep the pending row (confirm() rethrows).
724
+ if (!(error instanceof Error && /** @type {{code?: string}} */ (error).code === 'UNKNOWN_SERVER')) throw error
725
+ }
726
+ if (pool !== null) await pool.disconnect(record.serverId)
727
+ if (snapshot !== null && snapshot.auth !== undefined && snapshot.auth.kind === 'password') {
728
+ const credentials = getCredentials?.()
729
+ if (credentials !== null && credentials !== undefined) {
730
+ try {
731
+ await credentials.unset?.(snapshot.auth.credentialRef)
732
+ } catch (error) {
733
+ log('warn', 'dsh-server-connector: could not remove the credential of %s: %s', record.serverId, String(error))
734
+ }
735
+ }
736
+ }
737
+ const serverName = snapshot === null ? null : String(snapshot.name)
738
+ return recordEvent(record, 'server-deleted', serverName, `server ${serverName ?? record.serverId} removed`)
739
+ }
740
+
741
+ if (record.kind === 'disable-hostkey') {
742
+ const server = await registry.update(record.serverId, { hostKeyMode: 'off' })
743
+ return recordEvent(record, 'hostkey-disabled', server.name, 'hostKeyMode changed to off (host-key verification disabled)')
744
+ }
745
+
746
+ if (record.kind === 'disable-rule') {
747
+ const server = await registry.get(record.serverId)
748
+ if (server === null) {
749
+ // The record vanished between the request and the confirmation; the
750
+ // applier reports it with the registry's own code so confirm() keeps
751
+ // the pending row instead of silently discarding it.
752
+ throw Object.assign(new Error(`Unknown server "${record.serverId}"; request the rule change again.`), {
753
+ code: 'UNKNOWN_SERVER',
754
+ })
755
+ }
756
+ const ruleId = String(record.ruleId)
757
+ const disabledRules = server.disabledRules.includes(ruleId)
758
+ ? server.disabledRules
759
+ : [...server.disabledRules, ruleId]
760
+ await registry.update(record.serverId, { disabledRules })
761
+ return recordEvent(record, 'rule-disabled', server.name, `high-risk rule ${ruleId} disabled`)
762
+ }
763
+
764
+ throw new PendingError('INVALID_PENDING', `Unsupported pending kind "${String(record.kind)}".`)
765
+ }
766
+ }