dsh-cherry-bridge 0.1.11 → 0.2.1

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.
@@ -11,6 +11,7 @@ import {
11
11
  writeSync,
12
12
  } from 'node:fs'
13
13
  import { dirname } from 'node:path'
14
+ import type { DirectEndpoint } from '@cherrystudio/remote-protocol'
14
15
 
15
16
  /**
16
17
  * Crash-durable, corruption-safe archive of paired Cherry Studio desktop
@@ -45,8 +46,51 @@ export interface DeviceRecord {
45
46
  schemaVersion: number
46
47
  deviceId: string
47
48
  displayName: string
49
+ /**
50
+ * Ordered dial candidates. `security` and `ports` are keyed by these same
51
+ * hosts — one scheme and one port per candidate, exactly the shape the
52
+ * pairing dial already carries (`PairCandidate`), so a candidate and its
53
+ * address are added and replaced as one unit.
54
+ */
48
55
  hosts: string[]
56
+ /**
57
+ * The record-level dial port: the WINNER candidate's port at pairing, the
58
+ * operator's after a PATCH, and always the dial's fallback. A stored v4
59
+ * record carries an explicit `ports` entry for every host; this value is only
60
+ * the fallback for an in-memory record assembled before validation. Before
61
+ * schemaVersion 4 it was the only port — see `ports`.
62
+ */
49
63
  port: number
64
+ /**
65
+ * Dial scheme per host (issue #126). Every entry of `hosts` has exactly one
66
+ * key here; `wss` is a TLS-proxied desktop. Introduced with schemaVersion 3:
67
+ * a v2 archive had `hosts` + `port` only, and the migration backfills `ws`.
68
+ */
69
+ security: Record<string, 'ws' | 'wss'>
70
+ /**
71
+ * Dial port per host (issue #135). Every entry of `hosts` has exactly one
72
+ * key here, and the value is the port that host was paired on. Introduced
73
+ * with schemaVersion 4: before it, one `port` was shared by every host (the
74
+ * winner candidate's), so a steady-state reconnect to a non-winner host
75
+ * dialed the right scheme at the WRONG port. The v3→v4 migration backfills
76
+ * `ports` from `port`; `port` itself stays the record-level fallback.
77
+ */
78
+ ports: Record<string, number>
79
+ /**
80
+ * Addresses the operator has FORGOTTEN (issue #185): never dialed and never
81
+ * learned again, matched CASE-INSENSITIVELY and disjoint from `hosts` — a
82
+ * record may not dial what it has forgotten, and `assertValidRecord` refuses
83
+ * the overlap rather than picking a winner at read time.
84
+ *
85
+ * OPTIONAL on purpose, and therefore not in {@link REQUIRED_KEYS}. Absence is
86
+ * the total default ("nothing forgotten"); no invariant depends on the key
87
+ * being present, unlike `security`/`ports` whose absence would make a host
88
+ * undialable; and an older build ignores an unknown key instead of refusing
89
+ * the archive. A `schemaVersion` bump would invalidate every v4 archive (and
90
+ * every fixture built from the v4 shape) for a list whose empty value needs
91
+ * no migration.
92
+ */
93
+ forgottenHosts?: string[]
50
94
  desktopIdentity: string
51
95
  grants: 'agent'[]
52
96
  /** Paired with `tokenExpiresAt`: present together or absent together. */
@@ -77,7 +121,81 @@ export interface UnresolvedDevice {
77
121
  export type DeviceResolution = DeviceRecord | AmbiguousDevice | UnresolvedDevice
78
122
 
79
123
  /** The only `schemaVersion` this build reads and writes. */
80
- export const CURRENT_SCHEMA_VERSION = 2
124
+ export const CURRENT_SCHEMA_VERSION = 4
125
+
126
+ /**
127
+ * The most candidate hosts one record may carry (issue #143).
128
+ *
129
+ * `hosts` is a DIAL list, not a table: every reconnect walks it with one
130
+ * `connectTimeoutMs` per address, so its LENGTH is a time cost — a 32-host
131
+ * record already allows a 320 s pass, which is the number the pairing walk's
132
+ * aggregate budget exists for. Pairing can only ever produce 32
133
+ * (`pairingQrSchema`'s `.max(32)`), and learning is held to the same bound: an
134
+ * enrichment may not build a record that no pairing could have produced.
135
+ */
136
+ export const MAX_CANDIDATE_HOSTS = 32
137
+
138
+ /**
139
+ * Adds the candidate addresses a peer ADVERTISED that this record does not
140
+ * already dial, returning how many hosts were added (issue #143).
141
+ *
142
+ * The rule is ADD-ONLY, deliberately:
143
+ *
144
+ * - a host already in `hosts` is left exactly as it is, INCLUDING its port and
145
+ * its scheme. The peer's snapshot describes where it listens NOW, while a
146
+ * record entry may be an operator's choice (a pinned port, a TLS proxy) that
147
+ * every reconnect must keep honouring; re-pointing it would silently rewrite
148
+ * the operator's address book on a schedule. "Already dials" is judged
149
+ * CASE-INSENSITIVELY: a hostname is case-insensitive, the peer's schema
150
+ * lowercases what it advertises, and a PATCHed record stores what the
151
+ * operator typed — without this, `MyNAS.example` and `mynas.example` would
152
+ * both hold a dial slot. (An IPv6 literal in a different EXPANSION is NOT
153
+ * reconciled here; the PATCH path owns the archive's spelling, and it does
154
+ * not canonicalize either.)
155
+ * - nothing is removed HERE: a host the peer still advertises is re-learned
156
+ * after an operator PATCH drops it, because the peer is the source of truth
157
+ * for where it listens. The operator's answer to that is `forgetEndpoints`
158
+ * (issue #185), whose mark this function honours — addition stays add-only,
159
+ * and forgetting is a separate, explicit decision;
160
+ * - host, port and scheme are appended TOGETHER, which is what keeps the v4
161
+ * one-entry-per-host invariant true. That invariant is enforced on the READ
162
+ * path by `assertValidSecurity`/`assertValidPorts`, and a semantic failure
163
+ * there is NOT a quarantine (that class covers unreadable bytes): the throw
164
+ * escapes `parseArchive` out of `list()`, so a split entry would break every
165
+ * tool until an operator repaired the file;
166
+ * - past {@link MAX_CANDIDATE_HOSTS} nothing is added, and the loop stops
167
+ * rather than scanning the rest.
168
+ *
169
+ * `endpoints` must already have passed the peer's own `advertisedEndpointSchema`
170
+ * (which bounds the port to 1..65535, the scheme to `ws`/`wss`, and rejects a
171
+ * loopback destination), so every value written here is archive-valid without a
172
+ * second validation pass.
173
+ */
174
+ export function mergeAdvertisedEndpoints(
175
+ record: DeviceRecord,
176
+ endpoints: readonly DirectEndpoint[],
177
+ ): number {
178
+ // One comparison rule, applied to what the record already dials AND to what
179
+ // this answer has added so far: a duplicate inside one answer must not take a
180
+ // second slot either.
181
+ const dialed = new Set(record.hosts.map((host) => host.toLowerCase()))
182
+ // A FORGOTTEN address is skipped here, not removed afterwards: the operator's
183
+ // decision outranks the peer's snapshot, and an answer that names it must not
184
+ // even spend a dial slot on it.
185
+ const forgotten = new Set((record.forgottenHosts ?? []).map((host) => host.toLowerCase()))
186
+ let added = 0
187
+ for (const endpoint of endpoints) {
188
+ const key = endpoint.host.toLowerCase()
189
+ if (dialed.has(key) || forgotten.has(key)) continue
190
+ if (record.hosts.length >= MAX_CANDIDATE_HOSTS) break
191
+ record.hosts.push(endpoint.host)
192
+ record.ports[endpoint.host] = endpoint.port
193
+ record.security[endpoint.host] = endpoint.security
194
+ dialed.add(key)
195
+ added += 1
196
+ }
197
+ return added
198
+ }
81
199
 
82
200
  const FILE_MODE = 0o600
83
201
 
@@ -105,15 +223,83 @@ type Migration = (record: DeviceRecordOnDisk) => DeviceRecordOnDisk
105
223
  * `{ 1 -> 2 }` is a mechanism sample only, mirroring the plan's convention that
106
224
  * real schema evolution starts at version 2 after release. A v1 archive is the
107
225
  * current field set minus `schemaVersion`.
226
+ *
227
+ * `{ 2 -> 3 }` (issue #126) adds the per-host `security` map. v2 had `hosts` +
228
+ * one `port` and no scheme at all, so every host is backfilled `ws` — the
229
+ * plain dial v2 always used. An archive that already carries SOME entries
230
+ * (a half-migrated file, or one a newer build wrote) keeps them: a scheme the
231
+ * record remembers is never overwritten by the default, because that would
232
+ * downgrade a TLS-proxied desktop to a dial that cannot connect.
233
+ *
234
+ * `{ 3 -> 4 }` (issue #135) adds the per-host `ports` map. v3 had `hosts` + one
235
+ * shared `port` (the winner candidate's), so the step backfills `ports` from
236
+ * `port` for every host — the only one v3 ever dialed. A half-migrated file
237
+ * keeps the entries it already carries, for the same reason the scheme step
238
+ * does: a remembered port is the address the pairing actually used, and
239
+ * overwriting it would point the host at the winner's port again.
240
+ *
241
+ * **A legacy file is read loosely, this build's own output strictly —
242
+ * deliberately.** Each step fills its map for the CURRENT host list and
243
+ * nothing else: an entry naming a host the older file no longer lists is
244
+ * silently DROPPED (it was unreachable anyway), a non-object `security`/`ports`
245
+ * degrades to "every host on `ws`"/"every host at `port`", and a junk scheme or
246
+ * port VALUE is not repaired here at all — the migrated entry then continues
247
+ * down the chain and `assertValidSecurity`/`assertValidPorts` reject the whole
248
+ * archive. That split is the point: a v2 or v3 file is a supported legacy shape
249
+ * and is normalised into one, while a v4 file is this build's own output and
250
+ * any deviation from it is a hand edit worth failing loudly on.
108
251
  */
109
252
  const MIGRATIONS: Record<number, Migration> = {
110
253
  1: (record) => ({ ...record, schemaVersion: 2 }),
254
+ 2: (record) => ({ ...record, schemaVersion: 3, security: backfilledSecurity(record) }),
255
+ 3: (record) => ({ ...record, schemaVersion: 4, ports: backfilledPorts(record) }),
256
+ }
257
+
258
+ /** Every host of a v2 record, defaulting anything it did not already say. */
259
+ function backfilledSecurity(
260
+ disk: Partial<DeviceRecord>,
261
+ ): Record<string, 'ws' | 'wss'> {
262
+ // A hand-edited file can carry a non-array `hosts`; read it as empty here and
263
+ // let `assertValidRecord` name the real rule, instead of throwing a TypeError
264
+ // out of `.map` first.
265
+ const hosts = Array.isArray(disk.hosts) ? disk.hosts : []
266
+ const existing = disk.security ?? {}
267
+ // `Object.hasOwn`, never `existing[host] ?? …`: on an empty map a host named
268
+ // `constructor`/`__proto__`/`toString` would otherwise pick up an INHERITED
269
+ // function and poison the map. A remembered own entry wins even when its
270
+ // value is null — a junk value is the validator's to reject, not to repair.
271
+ return Object.fromEntries(
272
+ hosts.map((host) => [host, Object.hasOwn(existing, host) ? existing[host] : 'ws']),
273
+ )
274
+ }
275
+
276
+ /** Every host of a v3 record, defaulting anything it did not already say. */
277
+ function backfilledPorts(disk: Partial<DeviceRecord>): Record<string, number> {
278
+ // Same two rules as `backfilledSecurity`: non-array hosts read as empty, and
279
+ // only an OWN entry is remembered (an inherited function is not a port).
280
+ const hosts = Array.isArray(disk.hosts) ? disk.hosts : []
281
+ const existing = disk.ports ?? {}
282
+ // The cast states this function's EXIT shape, not a guarantee about the
283
+ // input: a malformed entry may omit `port`, and the `undefined` that would
284
+ // produce is rejected by `assertValidPorts` right after the migration chain.
285
+ const fallback = disk.port as number
286
+ return Object.fromEntries(
287
+ hosts.map((host): [string, number] => [
288
+ host,
289
+ Object.hasOwn(existing, host) ? existing[host] : fallback,
290
+ ]),
291
+ )
111
292
  }
112
293
 
113
294
  /**
114
- * Every field of the *current* set except `schemaVersion` — v1 is defined as
115
- * the current field set minus the version key, so these are exactly the keys a
116
- * complete v1 archive must carry.
295
+ * The field set of a v1 entry: what the store wrote before it carried a
296
+ * version at all.
297
+ *
298
+ * FROZEN. It must not track the current record — a v1 archive predates every
299
+ * later field, so demanding one here (as `'security'` was, briefly) fails the
300
+ * completeness gate on an archive that is perfectly intact and quarantines it
301
+ * instead of migrating it. Later fields are the migration chain's business
302
+ * (`backfilledSecurity`/`backfilledPorts` supply `security`/`ports`).
117
303
  */
118
304
  const V1_KEYS = [
119
305
  'deviceId',
@@ -125,7 +311,18 @@ const V1_KEYS = [
125
311
  'enabled',
126
312
  ] as const satisfies readonly (keyof DeviceRecord)[]
127
313
 
128
- const REQUIRED_KEYS = [...V1_KEYS, 'schemaVersion'] as const satisfies readonly (keyof DeviceRecord)[]
314
+ /**
315
+ * Every field of the CURRENT set plus `schemaVersion`. Unlike {@link V1_KEYS}
316
+ * this is not a historical snapshot: it grows with the record, and it is what
317
+ * `upsert` and the post-migration exit demand (which is why a v4 entry without
318
+ * `security`/`ports` never reaches its own validator).
319
+ */
320
+ const REQUIRED_KEYS = [
321
+ ...V1_KEYS,
322
+ 'security',
323
+ 'ports',
324
+ 'schemaVersion',
325
+ ] as const satisfies readonly (keyof DeviceRecord)[]
129
326
 
130
327
  /**
131
328
  * A version-less entry reads as the legacy (v1) shape only when every v1 field
@@ -153,6 +350,22 @@ function assertValidRecord(disk: DeviceRecordOnDisk): DeviceRecord {
153
350
  if (!Array.isArray(disk.hosts)) {
154
351
  throw new Error('device archive entry field hosts must be an array')
155
352
  }
353
+ // The record-level port is the fallback a hosts-only PATCH derives a NEW
354
+ // host's entry from, so it is judged like any map value: a hand-edited v4
355
+ // record with a valid `ports` map but a junk `port` would otherwise make that
356
+ // PATCH 400 (naming a field absent from its body). The message names the
357
+ // field, never the value. Before this it was the one unchecked record number.
358
+ if (
359
+ typeof disk.port !== 'number' ||
360
+ !Number.isInteger(disk.port) ||
361
+ disk.port < 1 ||
362
+ disk.port > 65_535
363
+ ) {
364
+ throw new Error('device archive entry field port must be an integer in 1..65535')
365
+ }
366
+ assertValidSecurity(disk.hosts, disk.security)
367
+ assertValidPorts(disk.hosts, disk.ports)
368
+ assertValidForgotten(disk.hosts, disk.forgottenHosts)
156
369
 
157
370
  // accessToken / tokenExpiresAt live and die together.
158
371
  const hasToken = disk.accessToken !== undefined
@@ -166,6 +379,147 @@ function assertValidRecord(disk: DeviceRecordOnDisk): DeviceRecord {
166
379
  return disk as DeviceRecord
167
380
  }
168
381
 
382
+ /**
383
+ * The `security` map must be EXACTLY one entry per host, each a known scheme.
384
+ *
385
+ * Keyed by host, so a record can carry an entry the dial will never consult
386
+ * (a host it no longer lists) and a host the dial will fall back on silently.
387
+ * Both are hand-edited-archive states, and both are rejected rather than
388
+ * repaired in memory: the archive a human edits is the archive the store
389
+ * judges, and a silent repair would hide the very mistake. The message names
390
+ * the offending HOSTS (they are addresses an operator chose, not secrets).
391
+ */
392
+ function assertValidSecurity(hosts: string[], security: unknown): void {
393
+ // NOT reached with `undefined` through the store: `REQUIRED_KEYS` rejects a
394
+ // missing `security` before this runs (a current-version archive is written
395
+ // by this build, so an absent map there is breakage, not a legacy shape). The
396
+ // arm exists for the OTHER entry point — `upsert`, which callers may reach
397
+ // with an in-memory record assembled by hand — and for the length of time a
398
+ // record exists between construction and its first validation.
399
+ if (security === undefined) return
400
+ if (security === null || typeof security !== 'object' || Array.isArray(security)) {
401
+ throw new Error('device archive entry field security must be an object keyed by host')
402
+ }
403
+ const map = security as Record<string, unknown>
404
+ const wanted = new Set(hosts)
405
+ const unknownHost = Object.keys(map).find((host) => !wanted.has(host))
406
+ if (unknownHost !== undefined) {
407
+ throw new Error(
408
+ `device archive entry field security names host "${unknownHost}", which is not in hosts`,
409
+ )
410
+ }
411
+ for (const host of hosts) {
412
+ const scheme = map[host]
413
+ if (scheme !== 'ws' && scheme !== 'wss') {
414
+ throw new Error(
415
+ `device archive entry field security["${host}"] must be "ws" or "wss"`,
416
+ )
417
+ }
418
+ }
419
+ }
420
+
421
+ /**
422
+ * The `ports` map must be EXACTLY one entry per host, each an integer port.
423
+ *
424
+ * The same reasoning as {@link assertValidSecurity}: an entry for a host the
425
+ * record no longer lists would never be consulted, and a missing one would
426
+ * leave that host on the record-level (winner's) port — the exact failure the
427
+ * map exists to prevent. Both are rejected rather than repaired. The
428
+ * message names the offending HOST, never the value (a port is not a secret, but
429
+ * echoing a junk value back from a hand-edited file adds nothing actionable).
430
+ */
431
+ function assertValidPorts(hosts: string[], ports: unknown): void {
432
+ // NOT reached with `undefined` through the store: `REQUIRED_KEYS` rejects a
433
+ // missing `ports` before this runs. Same other-entry-point reason as
434
+ // `assertValidSecurity`: `upsert` and construction-to-first-validation.
435
+ if (ports === undefined) return
436
+ if (ports === null || typeof ports !== 'object' || Array.isArray(ports)) {
437
+ throw new Error('device archive entry field ports must be an object keyed by host')
438
+ }
439
+ const map = ports as Record<string, unknown>
440
+ const wanted = new Set(hosts)
441
+ const unknownHost = Object.keys(map).find((host) => !wanted.has(host))
442
+ if (unknownHost !== undefined) {
443
+ throw new Error(
444
+ `device archive entry field ports names host "${unknownHost}", which is not in hosts`,
445
+ )
446
+ }
447
+ for (const host of hosts) {
448
+ const port = map[host]
449
+ if (typeof port !== 'number' || !Number.isInteger(port) || port < 1 || port > 65_535) {
450
+ throw new Error(
451
+ `device archive entry field ports["${host}"] must be an integer in 1..65535`,
452
+ )
453
+ }
454
+ }
455
+ }
456
+
457
+ /**
458
+ * `forgottenHosts`, when present, must be a list of non-empty strings with no
459
+ * case-variant duplicates and no overlap with `hosts`.
460
+ *
461
+ * Each rule refuses a state whose MEANING is ambiguous: two spellings of one
462
+ * host make "is this address suppressed?" depend on which entry the comparison
463
+ * hit first, and a host that is both dialed and forgotten makes the dial list
464
+ * and the suppression list disagree about the same address. The message names
465
+ * the offending host — these are addresses an operator chose, not secrets.
466
+ */
467
+ function assertValidForgotten(hosts: string[], forgotten: unknown): void {
468
+ if (forgotten === undefined) return
469
+ if (!Array.isArray(forgotten)) {
470
+ throw new Error('device archive entry field forgottenHosts must be an array')
471
+ }
472
+ const dialed = new Set(hosts.map((host) => host.toLowerCase()))
473
+ const seen = new Set<string>()
474
+ for (const entry of forgotten) {
475
+ if (typeof entry !== 'string' || entry.length === 0) {
476
+ throw new Error('device archive entry field forgottenHosts must list non-empty strings')
477
+ }
478
+ const key = entry.toLowerCase()
479
+ if (seen.has(key)) {
480
+ throw new Error(`device archive entry lists host "${entry}" in forgottenHosts more than once`)
481
+ }
482
+ if (dialed.has(key)) {
483
+ throw new Error(`device archive entry names host "${entry}" in both hosts and forgottenHosts`)
484
+ }
485
+ seen.add(key)
486
+ }
487
+ }
488
+
489
+ /**
490
+ * Repairs ONE cross-build state on the READ path: a host listed in both
491
+ * `hosts` and `forgottenHosts` (issue #185).
492
+ *
493
+ * A build that predates the field PRESERVES it but cannot honour it, so a
494
+ * downgrade followed by a still-advertising desktop appends the host to
495
+ * `hosts` while the mark stays. Upgrading again then yields a record that says
496
+ * both "dial it" and "never dial it". Refusing it would brick every device tool
497
+ * until an operator hand-edits a 0600 file, so the READ path keeps the DIAL
498
+ * list — what the running build actually did — and drops the mark, with a
499
+ * warning naming the device and host (addresses are not secrets).
500
+ *
501
+ * The WRITE paths stay strict: `upsert`, `forgetEndpoints` and
502
+ * `rememberEndpoints` never produce the overlap, and `assertValidRecord`
503
+ * refuses it. This runs ONLY on the read path, before that assertion.
504
+ */
505
+ function reconcileForgottenMarks(disk: DeviceRecordOnDisk): DeviceRecordOnDisk {
506
+ const forgotten = disk.forgottenHosts
507
+ if (!Array.isArray(forgotten) || !Array.isArray(disk.hosts)) return disk
508
+ const dialed = new Set(disk.hosts.map((host) => host.toLowerCase()))
509
+ const kept = forgotten.filter(
510
+ (host) => !(typeof host === 'string' && dialed.has(host.toLowerCase())),
511
+ )
512
+ if (kept.length === forgotten.length) return disk
513
+ for (const host of forgotten) {
514
+ if (typeof host !== 'string' || !dialed.has(host.toLowerCase())) continue
515
+ console.warn(
516
+ `device archive entry ${String(disk.deviceId)} lists host "${host}" as both dialed and ` +
517
+ `forgotten; keeping it dialed and dropping the mark (a plugin downgrade can produce this)`,
518
+ )
519
+ }
520
+ return { ...disk, forgottenHosts: kept }
521
+ }
522
+
169
523
  /**
170
524
  * Read path, fixed order: loose parse -> completeness gate -> version judgement
171
525
  * -> migration -> assert the required `DeviceRecord` type.
@@ -228,7 +582,9 @@ function parseRecord(raw: unknown, archiveVersion: number): DeviceRecord {
228
582
  migrated = migrate(migrated)
229
583
  }
230
584
 
231
- return assertValidRecord(migrated)
585
+ // A cross-build repair, never a write-path policy: see
586
+ // `reconcileForgottenMarks` for why the read path is the forgiving one.
587
+ return assertValidRecord(reconcileForgottenMarks(migrated))
232
588
  }
233
589
 
234
590
  function parseArchive(parsed: unknown): ArchiveFile {
@@ -382,6 +738,31 @@ export class QuarantinedArchiveError extends Error {
382
738
  }
383
739
  }
384
740
 
741
+ /**
742
+ * Refusal raised by {@link DeviceStore.forgetEndpoints} when the operation
743
+ * would leave a record with NO dial candidate.
744
+ *
745
+ * A record with nothing to dial is worse than the stale address the operator
746
+ * wanted gone: every tool would answer `offline` until a RE-PAIR or a PATCH
747
+ * with a non-empty `hosts` list writes a fresh one, and the archive would look
748
+ * healthy the whole time. The check is ATOMIC — the whole call refuses, so a
749
+ * name for the last host cannot remove a different host on its way to failing.
750
+ * Nothing is written when this is thrown.
751
+ */
752
+ export class LastCandidateHostError extends Error {
753
+ readonly deviceId: string
754
+
755
+ constructor(deviceId: string) {
756
+ super(
757
+ `device ${deviceId}: refusing to forget the last dial candidate — the record would have ` +
758
+ 'nothing to dial. Keep at least one address.',
759
+ )
760
+ this.name = 'LastCandidateHostError'
761
+ this.deviceId = deviceId
762
+ Object.setPrototypeOf(this, new.target.prototype)
763
+ }
764
+ }
765
+
385
766
  /**
386
767
  * Preserves an unreadable archive as `<file>.corrupt` (best effort — a failed
387
768
  * copy must not mask the original problem) and returns the path it landed at.
@@ -575,6 +956,135 @@ export class DeviceStore {
575
956
  this.#persist(devices)
576
957
  }
577
958
 
959
+ /**
960
+ * Merges freshly learned addresses into one record and writes ONLY when that
961
+ * record gained a host (issue #143), returning the number added.
962
+ *
963
+ * It exists so the enrichment is an ARCHIVE operation rather than an
964
+ * `upsert` the assembly assembles by hand, and so a merge that adds nothing
965
+ * never rewrites (and fsyncs) the archive: `connection.endpoints` is asked on
966
+ * every connect, and a stable desktop would otherwise pay a full atomic write
967
+ * per reconnect. The merge runs on a CLONE and is committed as a new list,
968
+ * mirroring `upsert`'s discipline — the store never adopts a caller's object
969
+ * graph, and a write refused at {@link #persist}'s own gate cannot have
970
+ * half-edited the record the archive still holds.
971
+ *
972
+ * `desktopIdentity` is the pin the answering connection belongs to: the
973
+ * record must still name that desktop. A RE-PAIR under the same `deviceId`
974
+ * (the desktop regenerated its identity) would otherwise take an address
975
+ * learned from the previous pairing — a stale candidate whose every dial then
976
+ * fails the Noise pin, permanently, because learning never removes.
977
+ *
978
+ * An unknown `deviceId` is a no-op — the record may have been removed (or
979
+ * re-paired) between the RPC and this write.
980
+ */
981
+ learnEndpoints(deviceId: string, endpoints: readonly DirectEndpoint[], desktopIdentity: string): number {
982
+ const devices = this.#load()
983
+ const index = devices.findIndex((record) => record.deviceId === deviceId)
984
+ if (index < 0) return 0
985
+ if (devices[index]!.desktopIdentity !== desktopIdentity) return 0
986
+ const merged = structuredClone(devices[index]!)
987
+ const added = mergeAdvertisedEndpoints(merged, endpoints)
988
+ if (added === 0) return 0
989
+ const next = [...devices]
990
+ next[index] = merged
991
+ this.#persist(next)
992
+ return added
993
+ }
994
+
995
+ /**
996
+ * FORGETS addresses (issue #185): drops each named host from the dial list —
997
+ * host, scheme and port as one unit — and records it so the next
998
+ * advertisement cannot put it back. Returns the hosts that left the dial list
999
+ * (`removed`) and the marks this call added (`marked`).
1000
+ *
1001
+ * This is the operation learning cannot provide. `connection.endpoints` is
1002
+ * asked on every handshake, so an address the desktop still advertises
1003
+ * returns after any removal the operator makes by PATCH; forgetting is the
1004
+ * explicit decision that outranks it.
1005
+ *
1006
+ * - Matching is CASE-INSENSITIVE (a PATCHed record keeps the operator's
1007
+ * spelling, the peer's schema lowercases what it advertises), and the mark
1008
+ * records the ARCHIVE's spelling when the host was dialed, the caller's
1009
+ * otherwise.
1010
+ * - Naming an address the record does not dial is legal and still marks it:
1011
+ * suppression is the point, and a pre-emptive forget must survive until
1012
+ * the peer advertises the address.
1013
+ * - Removing the LAST candidate throws {@link LastCandidateHostError} and
1014
+ * writes NOTHING — no host, no scheme, no port, no mark.
1015
+ * - A call that changes no byte (the host was already forgotten) does not
1016
+ * rewrite (or fsync) the archive.
1017
+ *
1018
+ * The write goes through `#persist` on a CLONE with the result re-validated,
1019
+ * so a caller-supplied junk host can never land a record the next read would
1020
+ * refuse. An unknown `deviceId` is a no-op, like {@link learnEndpoints}.
1021
+ */
1022
+ forgetEndpoints(deviceId: string, hosts: readonly string[]): { removed: string[]; marked: string[] } {
1023
+ const devices = this.#load()
1024
+ const index = devices.findIndex((record) => record.deviceId === deviceId)
1025
+ if (index < 0) return { removed: [], marked: [] }
1026
+ const merged = structuredClone(devices[index]!)
1027
+ const wanted = new Set(hosts.map((host) => host.toLowerCase()))
1028
+ const removed = merged.hosts.filter((host) => wanted.has(host.toLowerCase()))
1029
+ if (removed.length > 0 && removed.length === merged.hosts.length) {
1030
+ throw new LastCandidateHostError(deviceId)
1031
+ }
1032
+ for (const host of removed) {
1033
+ delete merged.security[host]
1034
+ delete merged.ports[host]
1035
+ }
1036
+ merged.hosts = merged.hosts.filter((host) => !wanted.has(host.toLowerCase()))
1037
+ const forgotten = merged.forgottenHosts ?? []
1038
+ const known = new Set(forgotten.map((host) => host.toLowerCase()))
1039
+ const marked: string[] = []
1040
+ for (const host of hosts) {
1041
+ const key = host.toLowerCase()
1042
+ if (known.has(key)) continue
1043
+ const spelling = removed.find((entry) => entry.toLowerCase() === key) ?? host
1044
+ forgotten.push(spelling)
1045
+ known.add(key)
1046
+ marked.push(spelling)
1047
+ }
1048
+ if (removed.length === 0 && marked.length === 0) return { removed: [], marked: [] }
1049
+ merged.forgottenHosts = forgotten
1050
+ assertValidRecord(merged)
1051
+ const next = [...devices]
1052
+ next[index] = merged
1053
+ this.#persist(next)
1054
+ return { removed, marked }
1055
+ }
1056
+
1057
+ /**
1058
+ * Lifts a forget mark (issue #185), returning the marks that were removed.
1059
+ *
1060
+ * It does NOT dial the host again: it only lets the next handshake's
1061
+ * advertisement add it back through the ordinary learning path, with the
1062
+ * port and scheme the peer reports at that moment. An operator who wants the
1063
+ * address back immediately PATCHes it into `hosts`, which clears the mark on
1064
+ * the same write.
1065
+ *
1066
+ * No-op (and no write) when the device is unknown, nothing is forgotten, or
1067
+ * none of the named hosts is marked.
1068
+ */
1069
+ rememberEndpoints(deviceId: string, hosts: readonly string[]): { restored: string[] } {
1070
+ const devices = this.#load()
1071
+ const index = devices.findIndex((record) => record.deviceId === deviceId)
1072
+ if (index < 0) return { restored: [] }
1073
+ const current = devices[index]!.forgottenHosts ?? []
1074
+ if (current.length === 0) return { restored: [] }
1075
+ const wanted = new Set(hosts.map((host) => host.toLowerCase()))
1076
+ const restored = current.filter((host) => wanted.has(host.toLowerCase()))
1077
+ if (restored.length === 0) return { restored: [] }
1078
+ const merged = structuredClone(devices[index]!)
1079
+ merged.forgottenHosts = (merged.forgottenHosts ?? []).filter(
1080
+ (host) => !wanted.has(host.toLowerCase()),
1081
+ )
1082
+ const next = [...devices]
1083
+ next[index] = merged
1084
+ this.#persist(next)
1085
+ return { restored }
1086
+ }
1087
+
578
1088
  #load(): DeviceRecord[] {
579
1089
  if (this.#devices !== undefined) return this.#devices
580
1090
 
@@ -587,6 +1097,8 @@ export class DeviceStore {
587
1097
  // `list`/`resolve` keep working (RF4's spirit — a corrupt archive never
588
1098
  // takes the plugin down), but nothing may write over the evidence.
589
1099
  this.#quarantine = error.withCorruptPath(quarantine(error.file))
1100
+ // LOCAL fs/parse cause, never peer text (issue #161 audit): the object
1101
+ // print keeps the cause's stack, which is the useful part here.
590
1102
  console.warn(
591
1103
  `device archive ${this.#file} is unreadable and was quarantined; serving an empty ` +
592
1104
  `read-only archive and refusing all writes until the bad file is moved aside:`,
@@ -193,6 +193,21 @@ export interface DiagnosticsDevice {
193
193
  */
194
194
  desktopIdentity: string
195
195
  limits?: EffectiveLimits
196
+ /**
197
+ * The peer's hello-advertised `agentMessageSummariesVersion` (issue #105) —
198
+ * the capability bit that decides whether `agent.messages.list` may carry
199
+ * `partsMode: 'paged'`.
200
+ *
201
+ * Its own field, not a fifth key inside `limits`: it is a VERSION, not a
202
+ * count, and `limits` is documented as the four numeric caps. Present only
203
+ * when a LIVE connection reported it (the same rule as `limits`: the endpoint
204
+ * never dials to find out). ABSENT therefore means "not advertised **or** not
205
+ * connected" — every desktop through 2.1.7, and any device whose link is
206
+ * down at the moment of the call. One `curl` against
207
+ * `GET /cherry-remote/api/diagnostics` **while the device is connected**
208
+ * answers "does this desktop speak paged summaries?" without a packet capture.
209
+ */
210
+ messageSummaries?: number
196
211
  }
197
212
 
198
213
  /** One session's LOCAL subscription bookkeeping. */
@@ -500,6 +515,9 @@ export function sanitizeDevice(
500
515
  pageItems: limits.pageItems,
501
516
  inFlightRequests: limits.inFlightRequests,
502
517
  },
518
+ // Own field, own whitelist entry: a single number copied out of the
519
+ // hello, never the reply itself.
520
+ ...(limits.messageSummaries === undefined ? {} : { messageSummaries: limits.messageSummaries }),
503
521
  }),
504
522
  }
505
523
  }
@@ -522,9 +540,20 @@ function projectDevice(raw: unknown): DiagnosticsDevice {
522
540
  ? desktopIdentity
523
541
  : digestIdentity(desktopIdentity),
524
542
  ...projectLimits(value['limits']),
543
+ ...projectMessageSummaries(value['messageSummaries']),
525
544
  }
526
545
  }
527
546
 
547
+ /**
548
+ * Re-projects the ONE capability field, dropping anything that is not a finite
549
+ * number: a version this report cannot read is reported as absent (not
550
+ * advertised), which is the safe side — the field only ever amplifies what the
551
+ * peer's hello said.
552
+ */
553
+ function projectMessageSummaries(raw: unknown): { messageSummaries?: number } {
554
+ return typeof raw === 'number' && Number.isFinite(raw) ? { messageSummaries: raw } : {}
555
+ }
556
+
528
557
  /**
529
558
  * Re-projects the four numeric limit keys, dropping any foreign key that a
530
559
  * caller's `limits` object might carry. A whitelist inside the whitelist: the
@@ -843,4 +872,5 @@ export const DIAGNOSTICS_DEVICE_KEYS: readonly string[] = [
843
872
  'port',
844
873
  'desktopIdentity',
845
874
  'limits',
875
+ 'messageSummaries',
846
876
  ]