dsh-cherry-bridge 0.2.0 → 0.2.2

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
@@ -75,6 +76,21 @@ export interface DeviceRecord {
75
76
  * `ports` from `port`; `port` itself stays the record-level fallback.
76
77
  */
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[]
78
94
  desktopIdentity: string
79
95
  grants: 'agent'[]
80
96
  /** Paired with `tokenExpiresAt`: present together or absent together. */
@@ -107,6 +123,80 @@ export type DeviceResolution = DeviceRecord | AmbiguousDevice | UnresolvedDevice
107
123
  /** The only `schemaVersion` this build reads and writes. */
108
124
  export const CURRENT_SCHEMA_VERSION = 4
109
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
+ }
199
+
110
200
  const FILE_MODE = 0o600
111
201
 
112
202
  /**
@@ -275,6 +365,7 @@ function assertValidRecord(disk: DeviceRecordOnDisk): DeviceRecord {
275
365
  }
276
366
  assertValidSecurity(disk.hosts, disk.security)
277
367
  assertValidPorts(disk.hosts, disk.ports)
368
+ assertValidForgotten(disk.hosts, disk.forgottenHosts)
278
369
 
279
370
  // accessToken / tokenExpiresAt live and die together.
280
371
  const hasToken = disk.accessToken !== undefined
@@ -363,6 +454,72 @@ function assertValidPorts(hosts: string[], ports: unknown): void {
363
454
  }
364
455
  }
365
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
+
366
523
  /**
367
524
  * Read path, fixed order: loose parse -> completeness gate -> version judgement
368
525
  * -> migration -> assert the required `DeviceRecord` type.
@@ -425,7 +582,9 @@ function parseRecord(raw: unknown, archiveVersion: number): DeviceRecord {
425
582
  migrated = migrate(migrated)
426
583
  }
427
584
 
428
- 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))
429
588
  }
430
589
 
431
590
  function parseArchive(parsed: unknown): ArchiveFile {
@@ -579,6 +738,31 @@ export class QuarantinedArchiveError extends Error {
579
738
  }
580
739
  }
581
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
+
582
766
  /**
583
767
  * Preserves an unreadable archive as `<file>.corrupt` (best effort — a failed
584
768
  * copy must not mask the original problem) and returns the path it landed at.
@@ -772,6 +956,135 @@ export class DeviceStore {
772
956
  this.#persist(devices)
773
957
  }
774
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
+
775
1088
  #load(): DeviceRecord[] {
776
1089
  if (this.#devices !== undefined) return this.#devices
777
1090
 
@@ -199,7 +199,7 @@ export interface DiagnosticsDevice {
199
199
  * `partsMode: 'paged'`.
200
200
  *
201
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
202
+ * count, and `limits` here is the four numeric caps this report CARRIES. Present only
203
203
  * when a LIVE connection reported it (the same rule as `limits`: the endpoint
204
204
  * never dials to find out). ABSENT therefore means "not advertised **or** not
205
205
  * connected" — every desktop through 2.1.7, and any device whose link is
@@ -555,10 +555,11 @@ function projectMessageSummaries(raw: unknown): { messageSummaries?: number } {
555
555
  }
556
556
 
557
557
  /**
558
- * Re-projects the four numeric limit keys, dropping any foreign key that a
559
- * caller's `limits` object might carry. A whitelist inside the whitelist: the
560
- * shape is rebuilt field by field rather than spread, so a future addition to
561
- * `EffectiveLimits` cannot arrive here unvetted.
558
+ * Re-projects the four numeric limit keys this report carries, dropping any
559
+ * foreign key that a caller's `limits` object might carry — including
560
+ * `EffectiveLimits`' newer `unackedBytes`, which no diagnostics consumer needs.
561
+ * A whitelist inside the whitelist: the shape is rebuilt field by field rather
562
+ * than spread, so a future addition cannot arrive here unvetted.
562
563
  */
563
564
  function projectLimits(raw: unknown): { limits?: EffectiveLimits } {
564
565
  if (raw === null || typeof raw !== 'object') return {}