dsh-cherry-bridge 0.2.0 → 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.
package/dist/index.mjs CHANGED
@@ -5,8 +5,60 @@ import { chmod, copyFile, mkdir, readFile, writeFile } from "node:fs/promises";
5
5
  import { RemoteSocketStream, connectSecureChannel, createDeviceIdentity, deviceIdentityId } from "@cherrystudio/remote-transport";
6
6
  import { createHash, randomUUID } from "node:crypto";
7
7
  import { WebSocket } from "ws";
8
- import { directEndpointUrl, jsonRpcNotificationSchema, jsonRpcRequestSchema, jsonRpcResponseSchema, pairingQrSchema, remoteFailureSchema } from "@cherrystudio/remote-protocol";
8
+ import { advertisedEndpointSchema, directEndpointUrl, jsonRpcNotificationSchema, jsonRpcRequestSchema, jsonRpcResponseSchema, pairingQrSchema, remoteFailureSchema } from "@cherrystudio/remote-protocol";
9
9
  import { peerIdFromString } from "@libp2p/peer-id";
10
+ /**
11
+ * Adds the candidate addresses a peer ADVERTISED that this record does not
12
+ * already dial, returning how many hosts were added (issue #143).
13
+ *
14
+ * The rule is ADD-ONLY, deliberately:
15
+ *
16
+ * - a host already in `hosts` is left exactly as it is, INCLUDING its port and
17
+ * its scheme. The peer's snapshot describes where it listens NOW, while a
18
+ * record entry may be an operator's choice (a pinned port, a TLS proxy) that
19
+ * every reconnect must keep honouring; re-pointing it would silently rewrite
20
+ * the operator's address book on a schedule. "Already dials" is judged
21
+ * CASE-INSENSITIVELY: a hostname is case-insensitive, the peer's schema
22
+ * lowercases what it advertises, and a PATCHed record stores what the
23
+ * operator typed — without this, `MyNAS.example` and `mynas.example` would
24
+ * both hold a dial slot. (An IPv6 literal in a different EXPANSION is NOT
25
+ * reconciled here; the PATCH path owns the archive's spelling, and it does
26
+ * not canonicalize either.)
27
+ * - nothing is removed HERE: a host the peer still advertises is re-learned
28
+ * after an operator PATCH drops it, because the peer is the source of truth
29
+ * for where it listens. The operator's answer to that is `forgetEndpoints`
30
+ * (issue #185), whose mark this function honours — addition stays add-only,
31
+ * and forgetting is a separate, explicit decision;
32
+ * - host, port and scheme are appended TOGETHER, which is what keeps the v4
33
+ * one-entry-per-host invariant true. That invariant is enforced on the READ
34
+ * path by `assertValidSecurity`/`assertValidPorts`, and a semantic failure
35
+ * there is NOT a quarantine (that class covers unreadable bytes): the throw
36
+ * escapes `parseArchive` out of `list()`, so a split entry would break every
37
+ * tool until an operator repaired the file;
38
+ * - past {@link MAX_CANDIDATE_HOSTS} nothing is added, and the loop stops
39
+ * rather than scanning the rest.
40
+ *
41
+ * `endpoints` must already have passed the peer's own `advertisedEndpointSchema`
42
+ * (which bounds the port to 1..65535, the scheme to `ws`/`wss`, and rejects a
43
+ * loopback destination), so every value written here is archive-valid without a
44
+ * second validation pass.
45
+ */
46
+ function mergeAdvertisedEndpoints(record, endpoints) {
47
+ const dialed = new Set(record.hosts.map((host) => host.toLowerCase()));
48
+ const forgotten = new Set((record.forgottenHosts ?? []).map((host) => host.toLowerCase()));
49
+ let added = 0;
50
+ for (const endpoint of endpoints) {
51
+ const key = endpoint.host.toLowerCase();
52
+ if (dialed.has(key) || forgotten.has(key)) continue;
53
+ if (record.hosts.length >= 32) break;
54
+ record.hosts.push(endpoint.host);
55
+ record.ports[endpoint.host] = endpoint.port;
56
+ record.security[endpoint.host] = endpoint.security;
57
+ dialed.add(key);
58
+ added += 1;
59
+ }
60
+ return added;
61
+ }
10
62
  const FILE_MODE = 384;
11
63
  /**
12
64
  * Version-to-version migrations, keyed by the version they upgrade from.
@@ -118,6 +170,7 @@ function assertValidRecord(disk) {
118
170
  if (typeof disk.port !== "number" || !Number.isInteger(disk.port) || disk.port < 1 || disk.port > 65535) throw new Error("device archive entry field port must be an integer in 1..65535");
119
171
  assertValidSecurity(disk.hosts, disk.security);
120
172
  assertValidPorts(disk.hosts, disk.ports);
173
+ assertValidForgotten(disk.hosts, disk.forgottenHosts);
121
174
  if (disk.accessToken !== void 0 !== (disk.tokenExpiresAt !== void 0)) throw new Error("device archive entry must carry accessToken and tokenExpiresAt together or not at all");
122
175
  return disk;
123
176
  }
@@ -166,6 +219,60 @@ function assertValidPorts(hosts, ports) {
166
219
  }
167
220
  }
168
221
  /**
222
+ * `forgottenHosts`, when present, must be a list of non-empty strings with no
223
+ * case-variant duplicates and no overlap with `hosts`.
224
+ *
225
+ * Each rule refuses a state whose MEANING is ambiguous: two spellings of one
226
+ * host make "is this address suppressed?" depend on which entry the comparison
227
+ * hit first, and a host that is both dialed and forgotten makes the dial list
228
+ * and the suppression list disagree about the same address. The message names
229
+ * the offending host — these are addresses an operator chose, not secrets.
230
+ */
231
+ function assertValidForgotten(hosts, forgotten) {
232
+ if (forgotten === void 0) return;
233
+ if (!Array.isArray(forgotten)) throw new Error("device archive entry field forgottenHosts must be an array");
234
+ const dialed = new Set(hosts.map((host) => host.toLowerCase()));
235
+ const seen = /* @__PURE__ */ new Set();
236
+ for (const entry of forgotten) {
237
+ if (typeof entry !== "string" || entry.length === 0) throw new Error("device archive entry field forgottenHosts must list non-empty strings");
238
+ const key = entry.toLowerCase();
239
+ if (seen.has(key)) throw new Error(`device archive entry lists host "${entry}" in forgottenHosts more than once`);
240
+ if (dialed.has(key)) throw new Error(`device archive entry names host "${entry}" in both hosts and forgottenHosts`);
241
+ seen.add(key);
242
+ }
243
+ }
244
+ /**
245
+ * Repairs ONE cross-build state on the READ path: a host listed in both
246
+ * `hosts` and `forgottenHosts` (issue #185).
247
+ *
248
+ * A build that predates the field PRESERVES it but cannot honour it, so a
249
+ * downgrade followed by a still-advertising desktop appends the host to
250
+ * `hosts` while the mark stays. Upgrading again then yields a record that says
251
+ * both "dial it" and "never dial it". Refusing it would brick every device tool
252
+ * until an operator hand-edits a 0600 file, so the READ path keeps the DIAL
253
+ * list — what the running build actually did — and drops the mark, with a
254
+ * warning naming the device and host (addresses are not secrets).
255
+ *
256
+ * The WRITE paths stay strict: `upsert`, `forgetEndpoints` and
257
+ * `rememberEndpoints` never produce the overlap, and `assertValidRecord`
258
+ * refuses it. This runs ONLY on the read path, before that assertion.
259
+ */
260
+ function reconcileForgottenMarks(disk) {
261
+ const forgotten = disk.forgottenHosts;
262
+ if (!Array.isArray(forgotten) || !Array.isArray(disk.hosts)) return disk;
263
+ const dialed = new Set(disk.hosts.map((host) => host.toLowerCase()));
264
+ const kept = forgotten.filter((host) => !(typeof host === "string" && dialed.has(host.toLowerCase())));
265
+ if (kept.length === forgotten.length) return disk;
266
+ for (const host of forgotten) {
267
+ if (typeof host !== "string" || !dialed.has(host.toLowerCase())) continue;
268
+ console.warn(`device archive entry ${String(disk.deviceId)} lists host "${host}" as both dialed and forgotten; keeping it dialed and dropping the mark (a plugin downgrade can produce this)`);
269
+ }
270
+ return {
271
+ ...disk,
272
+ forgottenHosts: kept
273
+ };
274
+ }
275
+ /**
169
276
  * Read path, fixed order: loose parse -> completeness gate -> version judgement
170
277
  * -> migration -> assert the required `DeviceRecord` type.
171
278
  *
@@ -193,7 +300,7 @@ function parseRecord(raw, archiveVersion) {
193
300
  if (migrate === void 0) throw new Error(`no migration registered from device archive schemaVersion ${step}`);
194
301
  migrated = migrate(migrated);
195
302
  }
196
- return assertValidRecord(migrated);
303
+ return assertValidRecord(reconcileForgottenMarks(migrated));
197
304
  }
198
305
  function parseArchive(parsed) {
199
306
  if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new Error("device archive must be a JSON object");
@@ -311,6 +418,26 @@ var QuarantinedArchiveError = class QuarantinedArchiveError extends Error {
311
418
  }
312
419
  };
313
420
  /**
421
+ * Refusal raised by {@link DeviceStore.forgetEndpoints} when the operation
422
+ * would leave a record with NO dial candidate.
423
+ *
424
+ * A record with nothing to dial is worse than the stale address the operator
425
+ * wanted gone: every tool would answer `offline` until a RE-PAIR or a PATCH
426
+ * with a non-empty `hosts` list writes a fresh one, and the archive would look
427
+ * healthy the whole time. The check is ATOMIC — the whole call refuses, so a
428
+ * name for the last host cannot remove a different host on its way to failing.
429
+ * Nothing is written when this is thrown.
430
+ */
431
+ var LastCandidateHostError = class extends Error {
432
+ deviceId;
433
+ constructor(deviceId) {
434
+ super(`device ${deviceId}: refusing to forget the last dial candidate — the record would have nothing to dial. Keep at least one address.`);
435
+ this.name = "LastCandidateHostError";
436
+ this.deviceId = deviceId;
437
+ Object.setPrototypeOf(this, new.target.prototype);
438
+ }
439
+ };
440
+ /**
314
441
  * Preserves an unreadable archive as `<file>.corrupt` (best effort — a failed
315
442
  * copy must not mask the original problem) and returns the path it landed at.
316
443
  */
@@ -477,6 +604,137 @@ var DeviceStore = class {
477
604
  const devices = this.#load().filter((record) => record.deviceId !== deviceId);
478
605
  this.#persist(devices);
479
606
  }
607
+ /**
608
+ * Merges freshly learned addresses into one record and writes ONLY when that
609
+ * record gained a host (issue #143), returning the number added.
610
+ *
611
+ * It exists so the enrichment is an ARCHIVE operation rather than an
612
+ * `upsert` the assembly assembles by hand, and so a merge that adds nothing
613
+ * never rewrites (and fsyncs) the archive: `connection.endpoints` is asked on
614
+ * every connect, and a stable desktop would otherwise pay a full atomic write
615
+ * per reconnect. The merge runs on a CLONE and is committed as a new list,
616
+ * mirroring `upsert`'s discipline — the store never adopts a caller's object
617
+ * graph, and a write refused at {@link #persist}'s own gate cannot have
618
+ * half-edited the record the archive still holds.
619
+ *
620
+ * `desktopIdentity` is the pin the answering connection belongs to: the
621
+ * record must still name that desktop. A RE-PAIR under the same `deviceId`
622
+ * (the desktop regenerated its identity) would otherwise take an address
623
+ * learned from the previous pairing — a stale candidate whose every dial then
624
+ * fails the Noise pin, permanently, because learning never removes.
625
+ *
626
+ * An unknown `deviceId` is a no-op — the record may have been removed (or
627
+ * re-paired) between the RPC and this write.
628
+ */
629
+ learnEndpoints(deviceId, endpoints, desktopIdentity) {
630
+ const devices = this.#load();
631
+ const index = devices.findIndex((record) => record.deviceId === deviceId);
632
+ if (index < 0) return 0;
633
+ if (devices[index].desktopIdentity !== desktopIdentity) return 0;
634
+ const merged = structuredClone(devices[index]);
635
+ const added = mergeAdvertisedEndpoints(merged, endpoints);
636
+ if (added === 0) return 0;
637
+ const next = [...devices];
638
+ next[index] = merged;
639
+ this.#persist(next);
640
+ return added;
641
+ }
642
+ /**
643
+ * FORGETS addresses (issue #185): drops each named host from the dial list —
644
+ * host, scheme and port as one unit — and records it so the next
645
+ * advertisement cannot put it back. Returns the hosts that left the dial list
646
+ * (`removed`) and the marks this call added (`marked`).
647
+ *
648
+ * This is the operation learning cannot provide. `connection.endpoints` is
649
+ * asked on every handshake, so an address the desktop still advertises
650
+ * returns after any removal the operator makes by PATCH; forgetting is the
651
+ * explicit decision that outranks it.
652
+ *
653
+ * - Matching is CASE-INSENSITIVE (a PATCHed record keeps the operator's
654
+ * spelling, the peer's schema lowercases what it advertises), and the mark
655
+ * records the ARCHIVE's spelling when the host was dialed, the caller's
656
+ * otherwise.
657
+ * - Naming an address the record does not dial is legal and still marks it:
658
+ * suppression is the point, and a pre-emptive forget must survive until
659
+ * the peer advertises the address.
660
+ * - Removing the LAST candidate throws {@link LastCandidateHostError} and
661
+ * writes NOTHING — no host, no scheme, no port, no mark.
662
+ * - A call that changes no byte (the host was already forgotten) does not
663
+ * rewrite (or fsync) the archive.
664
+ *
665
+ * The write goes through `#persist` on a CLONE with the result re-validated,
666
+ * so a caller-supplied junk host can never land a record the next read would
667
+ * refuse. An unknown `deviceId` is a no-op, like {@link learnEndpoints}.
668
+ */
669
+ forgetEndpoints(deviceId, hosts) {
670
+ const devices = this.#load();
671
+ const index = devices.findIndex((record) => record.deviceId === deviceId);
672
+ if (index < 0) return {
673
+ removed: [],
674
+ marked: []
675
+ };
676
+ const merged = structuredClone(devices[index]);
677
+ const wanted = new Set(hosts.map((host) => host.toLowerCase()));
678
+ const removed = merged.hosts.filter((host) => wanted.has(host.toLowerCase()));
679
+ if (removed.length > 0 && removed.length === merged.hosts.length) throw new LastCandidateHostError(deviceId);
680
+ for (const host of removed) {
681
+ delete merged.security[host];
682
+ delete merged.ports[host];
683
+ }
684
+ merged.hosts = merged.hosts.filter((host) => !wanted.has(host.toLowerCase()));
685
+ const forgotten = merged.forgottenHosts ?? [];
686
+ const known = new Set(forgotten.map((host) => host.toLowerCase()));
687
+ const marked = [];
688
+ for (const host of hosts) {
689
+ const key = host.toLowerCase();
690
+ if (known.has(key)) continue;
691
+ const spelling = removed.find((entry) => entry.toLowerCase() === key) ?? host;
692
+ forgotten.push(spelling);
693
+ known.add(key);
694
+ marked.push(spelling);
695
+ }
696
+ if (removed.length === 0 && marked.length === 0) return {
697
+ removed: [],
698
+ marked: []
699
+ };
700
+ merged.forgottenHosts = forgotten;
701
+ assertValidRecord(merged);
702
+ const next = [...devices];
703
+ next[index] = merged;
704
+ this.#persist(next);
705
+ return {
706
+ removed,
707
+ marked
708
+ };
709
+ }
710
+ /**
711
+ * Lifts a forget mark (issue #185), returning the marks that were removed.
712
+ *
713
+ * It does NOT dial the host again: it only lets the next handshake's
714
+ * advertisement add it back through the ordinary learning path, with the
715
+ * port and scheme the peer reports at that moment. An operator who wants the
716
+ * address back immediately PATCHes it into `hosts`, which clears the mark on
717
+ * the same write.
718
+ *
719
+ * No-op (and no write) when the device is unknown, nothing is forgotten, or
720
+ * none of the named hosts is marked.
721
+ */
722
+ rememberEndpoints(deviceId, hosts) {
723
+ const devices = this.#load();
724
+ const index = devices.findIndex((record) => record.deviceId === deviceId);
725
+ if (index < 0) return { restored: [] };
726
+ const current = devices[index].forgottenHosts ?? [];
727
+ if (current.length === 0) return { restored: [] };
728
+ const wanted = new Set(hosts.map((host) => host.toLowerCase()));
729
+ const restored = current.filter((host) => wanted.has(host.toLowerCase()));
730
+ if (restored.length === 0) return { restored: [] };
731
+ const merged = structuredClone(devices[index]);
732
+ merged.forgottenHosts = (merged.forgottenHosts ?? []).filter((host) => !wanted.has(host.toLowerCase()));
733
+ const next = [...devices];
734
+ next[index] = merged;
735
+ this.#persist(next);
736
+ return { restored };
737
+ }
480
738
  #load() {
481
739
  if (this.#devices !== void 0) return this.#devices;
482
740
  let archive;
@@ -2647,6 +2905,33 @@ const DEFAULT_BACKOFF_MAX_MS = 6e4;
2647
2905
  const DEFAULT_REFRESH_GRACE_MS = 3e4;
2648
2906
  const DEFAULT_CONNECT_TIMEOUT_MS$1 = 1e4;
2649
2907
  const DEFAULT_PROTOCOL_VERSIONS = [1];
2908
+ /**
2909
+ * How long the optional `connection.endpoints` enrichment (issue #143) may hold
2910
+ * up ONE handshake before the connection gives up on it.
2911
+ *
2912
+ * The call runs INSIDE the handshake, so without a bound of its own it would
2913
+ * inherit the rpc layer's 60 s default: a peer that advertises
2914
+ * `connectionEndpointsVersion: 1` and then never answers (a wedged API gateway)
2915
+ * would keep the device in `connecting` for a minute, and every reconnect would
2916
+ * pay it again. THIS layer bounds it because the enrichment is worth
2917
+ * milliseconds, not a retry.
2918
+ *
2919
+ * WHY 2 s. The peer's handler is a local computation
2920
+ * (`getRemoteEndpoint()`: `networkInterfaces()` plus two config reads), so a
2921
+ * round trip is sub-millisecond on a LAN and 2 s is ~4 orders of magnitude of
2922
+ * slack.
2923
+ *
2924
+ * The UPPER bound is NOT derived from a claim about the whole handshake: a dial
2925
+ * already burns up to `DEFAULT_CONNECT_TIMEOUT_MS` (10 s) and hello/authenticate
2926
+ * ride the rpc layer's 60 s default, so `connected` can arrive long after a
2927
+ * tool's `DEVICE_SETTLE_WAIT_MS` (5 s) wait whatever this constant is. What is
2928
+ * derived is narrower and true: THIS wait alone must stay below that window, so
2929
+ * a peer that advertises the capability and then hangs on this one call cannot
2930
+ * by itself push the state flip past the window a tool already waited out. A
2931
+ * timeout costs one reconnect's worth of enrichment and says nothing about the
2932
+ * device.
2933
+ */
2934
+ const ENDPOINTS_LEARN_TIMEOUT_MS = 2e3;
2650
2935
  /** Ping-silence multiplier: 2×heartbeatMs without a pong judges the line dead. */
2651
2936
  const SILENCE_FACTOR = 2;
2652
2937
  /** The deliberate local-shutdown face of a disposed client (see the replay path). */
@@ -2735,7 +3020,8 @@ var SecondWindowRateLimiter = class {
2735
3020
  async acquire() {
2736
3021
  let release;
2737
3022
  const ahead = this.#queue;
2738
- this.#queue = ahead.then(() => new Promise((resolve) => release = resolve));
3023
+ const next = () => new Promise((resolve) => release = resolve);
3024
+ this.#queue = ahead.then(next, next);
2739
3025
  await ahead;
2740
3026
  try {
2741
3027
  for (;;) {
@@ -2920,6 +3206,50 @@ function businessWindowFor(reportedInFlightRequests) {
2920
3206
  return Math.max(1, Math.min(BUSINESS_MAX_IN_FLIGHT, Math.floor(reportedInFlightRequests / 2)));
2921
3207
  }
2922
3208
  /**
3209
+ * The destinations a `connection.endpoints` answer may contribute, in the order
3210
+ * the peer listed them (issue #143). Empty when the answer must be discarded.
3211
+ *
3212
+ * TWO answers are refused WHOLE:
3213
+ *
3214
+ * - **the identity pin.** The answer must name the very desktop this record is
3215
+ * pinned to (`DeviceRecord.desktopIdentity`). Noise already pins the peer's
3216
+ * static key, so this is not the adversarial case it protects against — it
3217
+ * is the defence against a CONFUSED answer (a proxy, a desktop whose identity
3218
+ * was regenerated, a future protocol that answers for more than one host):
3219
+ * the list decides where this client dials, so a list that cannot be
3220
+ * attributed to our own desktop may not change it;
3221
+ * - a MISSING or NON-STRING `desktopIdentity`: `undefined !== pin`, and a
3222
+ * number is not the pin either, so both fall out of the same comparison.
3223
+ *
3224
+ * One refusal is per ENTRY: `advertisedEndpointSchema`, the peer's own
3225
+ * destination schema, rejects a LOOPBACK destination (a shared address that
3226
+ * resolves to our own machine would send the next reconnect to the wrong host)
3227
+ * and normalizes the host (case, IPv6), so the archive never stores a spelling
3228
+ * the dial cannot use. One unusable row must not discard its neighbours.
3229
+ *
3230
+ * NOT total on the wire shape, deliberately: a non-object answer or a non-array
3231
+ * `endpoints` throws here, and `#learnEndpoints`' one structural `catch` is what
3232
+ * turns that into the silent no-op the contract promises. Defending each shape
3233
+ * twice would look safer and be worse: the redundancy masks which guard a test
3234
+ * actually pins (issue #114's lesson), and the whole ANSWER is untrusted
3235
+ * anyway, so the boundary belongs at the caller, where the handshake can see
3236
+ * it. `endpoints ?? []` is the one shape that needs no throw: it is the legal
3237
+ * "no addresses right now" answer (the desktop's `addresses` can be empty).
3238
+ *
3239
+ * No length bound is needed here: one JSON-RPC record is capped at 64 KiB by
3240
+ * the transport, and the desktop's own snapshot is `addresses.slice(0, 32)`.
3241
+ */
3242
+ function advertisedEndpoints(result, desktopIdentity) {
3243
+ const answer = result;
3244
+ if (answer.desktopIdentity !== desktopIdentity) return [];
3245
+ const accepted = [];
3246
+ for (const raw of answer.endpoints ?? []) {
3247
+ const parsed = advertisedEndpointSchema.safeParse(raw);
3248
+ if (parsed.success) accepted.push(parsed.data);
3249
+ }
3250
+ return accepted;
3251
+ }
3252
+ /**
2923
3253
  * The desktop's content-read allowance: 512/s, and the SAME in both generations
2924
3254
  * (`RemoteAccessService.ts` counts `agent.content.read` on its own counter,
2925
3255
  * 512 in v2.1.6 and v2.1.7 alike; only the shared line moved, 64 ⇒ 256).
@@ -3000,6 +3330,7 @@ var DeviceConnection = class {
3000
3330
  #protocolVersions;
3001
3331
  #helloProtocolVersions;
3002
3332
  #transportRetryWaitMs;
3333
+ #onAdvertisedEndpoints;
3003
3334
  #stateListeners = /* @__PURE__ */ new Set();
3004
3335
  /**
3005
3336
  * Callers parked in {@link waitForSettled}. Separate from the state listeners
@@ -3062,6 +3393,7 @@ var DeviceConnection = class {
3062
3393
  this.#protocolVersions = opts.protocolVersions ?? DEFAULT_PROTOCOL_VERSIONS;
3063
3394
  this.#helloProtocolVersions = opts.helloProtocolVersions ?? this.#protocolVersions;
3064
3395
  this.#transportRetryWaitMs = opts.transportRetryWaitMs ?? 5e3;
3396
+ this.#onAdvertisedEndpoints = opts.onAdvertisedEndpoints;
3065
3397
  }
3066
3398
  /**
3067
3399
  * The business RPC surface: rate-smoothed and gated on `connected`. Typed
@@ -3388,6 +3720,7 @@ var DeviceConnection = class {
3388
3720
  if (reason === "GRANT_REVOKED" || reason === "UNAUTHENTICATED") throw new AuthRejectedError(reason);
3389
3721
  throw error instanceof Error ? error : new Error(String(error));
3390
3722
  }
3723
+ await this.#learnEndpoints(link, hello.connectionEndpointsVersion);
3391
3724
  if (epoch !== this.#epoch) {
3392
3725
  client.dispose();
3393
3726
  link.channel.close().catch(noop$1);
@@ -3411,6 +3744,51 @@ var DeviceConnection = class {
3411
3744
  console.info(`[cherry-remote] device ${this.record.deviceId} connected (${digestIdentity(link.channel.remoteIdentity)})`);
3412
3745
  }
3413
3746
  /**
3747
+ * Best-effort enrichment of the dial list from the peer's advertised
3748
+ * endpoints (issue #143).
3749
+ *
3750
+ * WHAT THIS BUYS: candidates the pairing never saw — a VPN or second-NIC
3751
+ * address that appeared after the QR was minted. It does NOT heal a total
3752
+ * address change (the list is captured on the old path) and can never carry
3753
+ * TLS (the desktop's snapshot is `security: 'ws'`), so a `wss` route still
3754
+ * comes from the QR or the operator.
3755
+ *
3756
+ * EVERY failure here is SILENT and non-fatal, because this is an enrichment,
3757
+ * not a verdict: a peer that does not advertise the capability (`connection`
3758
+ * through 2.1.3 has neither the hello bit nor this method), one advertising a
3759
+ * version this client cannot speak (strictly `1` — a higher number is not
3760
+ * evidence that it speaks 1, the same rule the paged-summaries bit took), a
3761
+ * refused call (the desktop throws when its gateway is not running), a wedged
3762
+ * one (bounded by {@link ENDPOINTS_LEARN_TIMEOUT_MS}), an unattributable
3763
+ * answer, or a store that cannot write. None of them is a CONNECTION failure:
3764
+ * the link comes up either way, and only the archive is left without the new
3765
+ * addresses — this instance's own record may already carry them, because the
3766
+ * merge runs before the callback (see the paragraph below).
3767
+ *
3768
+ * The MERGE happens in two places on purpose: here (this instance's record,
3769
+ * so its next reconnect dials the new address) and through the callback (the
3770
+ * assembly's archive, so a restart keeps it). They are the same pure rule
3771
+ * against two copies of the record, and each is idempotent.
3772
+ *
3773
+ * ONE `catch` covers everything after the gate, and that is the contract, not
3774
+ * laziness: a refused call, a wedged one (bounded by
3775
+ * {@link ENDPOINTS_LEARN_TIMEOUT_MS}), an answer of the wrong SHAPE (the
3776
+ * parser throws rather than defending every field twice), a throwing assembly
3777
+ * hook (an unwritable archive), or a bug in the merge must all land here —
3778
+ * none of them may fail the handshake. The observable promise is therefore
3779
+ * structural: this method cannot reject.
3780
+ */
3781
+ async #learnEndpoints(link, capability) {
3782
+ if (capability !== 1) return;
3783
+ try {
3784
+ const endpoints = advertisedEndpoints(await this.#houseCall(link, "connection.endpoints", { domain: "agent" }, ENDPOINTS_LEARN_TIMEOUT_MS), this.record.desktopIdentity);
3785
+ if (endpoints.length === 0) return;
3786
+ const added = mergeAdvertisedEndpoints(this.record, endpoints);
3787
+ if (added > 0) console.info(`[cherry-remote] device ${this.record.deviceId} learned ${added} advertised endpoint(s)`);
3788
+ this.#onAdvertisedEndpoints?.(endpoints);
3789
+ } catch {}
3790
+ }
3791
+ /**
3414
3792
  * authenticate with the stored token; a stale token (TOKEN_EXPIRED /
3415
3793
  * UNAUTHENTICATED) is retried exactly once without it before the pairing
3416
3794
  * is declared invalid.
@@ -5892,6 +6270,54 @@ function optionalString(value) {
5892
6270
  return typeof value === "string" && value !== "" ? value : void 0;
5893
6271
  }
5894
6272
  /**
6273
+ * The `hosts` argument of `cherry_devices`' write actions (issue #185): a
6274
+ * non-empty list of non-empty host names, with no case-variant duplicate.
6275
+ *
6276
+ * Each rule is a state the store would have to interpret: an empty name can
6277
+ * never match a dialed host, and two spellings of one host would make "is this
6278
+ * address forgotten?" depend on which entry a comparison hit first. Values are
6279
+ * TRIMMED here (the model-facing surface is where a stray space is caught);
6280
+ * the archive then stores the spelling it already had for a dialed host.
6281
+ */
6282
+ function hostListArgument(value) {
6283
+ if (!Array.isArray(value) || value.length === 0) return {
6284
+ error: "invalid-hosts",
6285
+ message: "`hosts` must be a non-empty array of host names"
6286
+ };
6287
+ const hosts = [];
6288
+ const seen = /* @__PURE__ */ new Set();
6289
+ for (const entry of value) {
6290
+ if (typeof entry !== "string" || entry.trim() === "") return {
6291
+ error: "invalid-hosts",
6292
+ message: "`hosts` entries must be non-empty strings"
6293
+ };
6294
+ let host = entry.trim();
6295
+ if (host.startsWith("[") && host.endsWith("]")) {
6296
+ try {
6297
+ new URL(`http://${host}/`);
6298
+ } catch {
6299
+ return {
6300
+ error: "invalid-hosts",
6301
+ message: `\`hosts\` entry "${host}" is not a valid IPv6 literal`
6302
+ };
6303
+ }
6304
+ host = host.slice(1, -1);
6305
+ }
6306
+ if (host.length > 256) return {
6307
+ error: "invalid-hosts",
6308
+ message: "`hosts` entry exceeds 256 characters"
6309
+ };
6310
+ const key = host.toLowerCase();
6311
+ if (seen.has(key)) return {
6312
+ error: "invalid-hosts",
6313
+ message: `\`hosts\` names "${host}" more than once (host names are case-insensitive)`
6314
+ };
6315
+ seen.add(key);
6316
+ hosts.push(host);
6317
+ }
6318
+ return hosts;
6319
+ }
6320
+ /**
5895
6321
  * The peer's `agent.messages.send` refusal for a STALE `expectedIdleRevision`,
5896
6322
  * in the exact shape Cherry Studio answers it: a `rejected` receipt whose
5897
6323
  * `error` is `{reason: 'CONFLICT', message: 'Session is not idle at the
@@ -6572,26 +6998,86 @@ var CherryToolEngine = class {
6572
6998
  #devicesTool() {
6573
6999
  return defineCherryTool({
6574
7000
  name: "cherry_devices",
6575
- description: "List every paired Cherry Studio desktop with its connection state (connected/connecting/offline/disabled/identity-mismatch). An optional `device` narrows the list to one device (display name prefix or deviceId).",
7001
+ description: "List every paired Cherry Studio desktop with its connection state (connected/connecting/offline/disabled/identity-mismatch). An optional `device` narrows the list to one device (display name prefix or deviceId). `action: \"forget\"` removes the named `hosts` from that device's dial candidates AND stops the desktop's advertisements from adding them back; `action: \"remember\"` lifts that mark, so the next handshake may learn them again. Both write actions require `device` and `hosts`.",
6576
7002
  parameters: {
6577
7003
  type: "object",
6578
- properties: { device: {
6579
- type: "string",
6580
- description: "Optional device selector: exact deviceId, or a unique prefix of a deviceId or display name."
6581
- } },
7004
+ properties: {
7005
+ device: {
7006
+ type: "string",
7007
+ description: "Optional device selector: exact deviceId, or a unique prefix of a deviceId or display name."
7008
+ },
7009
+ action: {
7010
+ type: "string",
7011
+ enum: [
7012
+ "list",
7013
+ "forget",
7014
+ "remember"
7015
+ ],
7016
+ description: "What to do: `list` (the default) reads the devices; `forget`/`remember` edit ONE device's forgotten addresses and require `device` and `hosts`."
7017
+ },
7018
+ hosts: {
7019
+ type: "array",
7020
+ items: { type: "string" },
7021
+ description: "For `forget`/`remember`: the host names to act on, matched case-insensitively. `forget` refuses to remove the last remaining address."
7022
+ }
7023
+ },
6582
7024
  additionalProperties: false
6583
7025
  },
6584
7026
  execute: async (args) => {
7027
+ const action = optionalString(args["action"]) ?? "list";
7028
+ if (action !== "list" && action !== "forget" && action !== "remember") return {
7029
+ error: "invalid-action",
7030
+ message: "`action` accepts exactly \"list\", \"forget\" or \"remember\""
7031
+ };
6585
7032
  const selector = optionalString(args["device"]);
7033
+ if (action === "list") {
7034
+ const quarantined = this.#quarantineVerdict();
7035
+ if (quarantined !== void 0) return quarantined;
7036
+ if (selector === void 0) {
7037
+ const records = this.#deps.store.list();
7038
+ return { devices: await Promise.all(records.map((record) => this.#deviceView(record))) };
7039
+ }
7040
+ const selected = this.#selectRecords(selector);
7041
+ if (!Array.isArray(selected)) return selected;
7042
+ return { devices: await Promise.all(selected.map((record) => this.#deviceView(record))) };
7043
+ }
7044
+ if (selector === void 0) return {
7045
+ error: "device-required",
7046
+ message: "`action` \"forget\"/\"remember\" needs `device`: a selector that names exactly one device"
7047
+ };
7048
+ const hosts = hostListArgument(args["hosts"]);
7049
+ if (!Array.isArray(hosts)) return hosts;
6586
7050
  const quarantined = this.#quarantineVerdict();
6587
7051
  if (quarantined !== void 0) return quarantined;
6588
- if (selector === void 0) {
6589
- const records = this.#deps.store.list();
6590
- return { devices: await Promise.all(records.map((record) => this.#deviceView(record))) };
6591
- }
6592
7052
  const selected = this.#selectRecords(selector);
6593
7053
  if (!Array.isArray(selected)) return selected;
6594
- return { devices: await Promise.all(selected.map((record) => this.#deviceView(record))) };
7054
+ const record = selected[0];
7055
+ let removed = [];
7056
+ let marked = [];
7057
+ let restored = [];
7058
+ try {
7059
+ if (action === "forget") ({removed, marked} = this.#deps.store.forgetEndpoints(record.deviceId, hosts));
7060
+ else ({restored} = this.#deps.store.rememberEndpoints(record.deviceId, hosts));
7061
+ } catch (error) {
7062
+ if (error instanceof LastCandidateHostError) return {
7063
+ error: "last-address",
7064
+ device: record.deviceId,
7065
+ message: error.message
7066
+ };
7067
+ throw error;
7068
+ }
7069
+ if (removed.length > 0 || marked.length > 0 || restored.length > 0) await this.#deps.evict(record.deviceId);
7070
+ const fresh = this.#deps.store.list().find((entry) => entry.deviceId === record.deviceId);
7071
+ if (fresh === void 0) return this.#notFoundVerdict(selector);
7072
+ const device = await this.#deviceView(fresh);
7073
+ return action === "forget" ? {
7074
+ device,
7075
+ removed,
7076
+ marked
7077
+ } : {
7078
+ device,
7079
+ restored
7080
+ };
6595
7081
  }
6596
7082
  });
6597
7083
  }
@@ -6645,7 +7131,8 @@ var CherryToolEngine = class {
6645
7131
  state,
6646
7132
  enabled: record.enabled,
6647
7133
  hosts: record.hosts,
6648
- port: record.port
7134
+ port: record.port,
7135
+ forgottenHosts: record.forgottenHosts ?? []
6649
7136
  };
6650
7137
  }
6651
7138
  #agentsTool() {
@@ -10900,7 +11387,12 @@ function parsePatch(body) {
10900
11387
  if (typeof entry !== "string") throw new PatchError("hosts entries must be strings");
10901
11388
  return normalizeHost(entry);
10902
11389
  });
10903
- if (new Set(patch.hosts).size !== patch.hosts.length) throw new PatchError("hosts must not contain duplicates");
11390
+ const seen = /* @__PURE__ */ new Set();
11391
+ for (const host of patch.hosts) {
11392
+ const key = host.toLowerCase();
11393
+ if (seen.has(key)) throw new PatchError("hosts must not contain duplicates");
11394
+ seen.add(key);
11395
+ }
10904
11396
  }
10905
11397
  const port = body["port"];
10906
11398
  if (port !== void 0) {
@@ -11256,6 +11748,23 @@ async function handleRequest(req, res, route) {
11256
11748
  writeError(res, 405, "method-not-allowed", "DELETE or PATCH only");
11257
11749
  return;
11258
11750
  }
11751
+ if (first === "devices" && match.segments.length === 3 && second !== void 0) {
11752
+ const deviceId = decodeSegment(second);
11753
+ if (deviceId === void 0) {
11754
+ writeError(res, 400, "invalid-request", "device id is not valid URL encoding");
11755
+ return;
11756
+ }
11757
+ const action = match.segments[2];
11758
+ if (action !== "forget" && action !== "remember") {
11759
+ writeError(res, 404, "not-found", "unknown route");
11760
+ return;
11761
+ }
11762
+ if (method !== "POST") {
11763
+ writeError(res, 405, "method-not-allowed", "POST only");
11764
+ return;
11765
+ }
11766
+ return await changeEndpoints(req, res, route, deviceId, action);
11767
+ }
11259
11768
  if (first === "pair" && match.segments.length === 1) {
11260
11769
  if (method !== "POST") {
11261
11770
  writeError(res, 405, "method-not-allowed", "POST only");
@@ -11293,7 +11802,18 @@ async function handleRequest(req, res, route) {
11293
11802
  }
11294
11803
  }
11295
11804
  function listDevices(res, route) {
11296
- writeJson(res, 200, route.deps.store.list().map((device) => ({
11805
+ writeJson(res, 200, route.deps.store.list().map((device) => deviceSummary(route, device)));
11806
+ }
11807
+ /**
11808
+ * One device as the browser sees it: the public archive projection, never a
11809
+ * credential.
11810
+ *
11811
+ * ONE builder, because FOUR faces answer this shape — `GET /devices`, the
11812
+ * `PATCH`, and the two endpoint actions of issue #204 — and a hand-written copy
11813
+ * per face is how a field ends up on one and not the others.
11814
+ */
11815
+ function deviceSummary(route, device) {
11816
+ return {
11297
11817
  deviceId: device.deviceId,
11298
11818
  displayName: device.displayName,
11299
11819
  state: projectState(device, route.deps.connect),
@@ -11301,8 +11821,9 @@ function listDevices(res, route) {
11301
11821
  hosts: [...device.hosts],
11302
11822
  port: device.port,
11303
11823
  security: { ...device.security },
11304
- ports: { ...device.ports }
11305
- })));
11824
+ ports: { ...device.ports },
11825
+ forgottenHosts: [...device.forgottenHosts ?? []]
11826
+ };
11306
11827
  }
11307
11828
  /**
11308
11829
  * The `GET /diagnostics` body (spec §5).
@@ -11530,8 +12051,10 @@ async function patchDevice(req, res, route, deviceId) {
11530
12051
  const previousSecurity = existing.security;
11531
12052
  const previousPorts = existing.ports;
11532
12053
  const nextPorts = patch.port !== void 0 && patch.port !== previousPort ? portsFor(patch.hosts ?? previousHosts, {}, patch.port) : patch.hosts === void 0 ? previousPorts : portsFor(patch.hosts, previousPorts, previousPort);
12054
+ const nextForgotten = patch.hosts === void 0 || existing.forgottenHosts === void 0 ? void 0 : existing.forgottenHosts.filter((host) => !patch.hosts.some((nextHost) => nextHost.toLowerCase() === host.toLowerCase()));
11533
12055
  const next = {
11534
12056
  ...existing,
12057
+ ...nextForgotten === void 0 ? {} : { forgottenHosts: nextForgotten },
11535
12058
  ...patch.displayName === void 0 ? {} : { displayName: patch.displayName },
11536
12059
  ...patch.enabled === void 0 ? {} : { enabled: patch.enabled },
11537
12060
  ...patch.hosts === void 0 ? {} : {
@@ -11557,21 +12080,12 @@ async function patchDevice(req, res, route, deviceId) {
11557
12080
  } catch (error) {
11558
12081
  console.warn(`[cherry-remote] device ${deviceId} reconnect failed`, error);
11559
12082
  }
11560
- const state = projectState(next, route.deps.connect);
12083
+ const summary = deviceSummary(route, next);
11561
12084
  if (patch.enabled !== void 0 && patch.enabled !== previousEnabled) route.hub.publish("device-state", {
11562
12085
  deviceId,
11563
- state
11564
- });
11565
- writeJson(res, 200, {
11566
- deviceId: next.deviceId,
11567
- displayName: next.displayName,
11568
- state,
11569
- enabled: next.enabled,
11570
- hosts: [...next.hosts],
11571
- port: next.port,
11572
- security: { ...next.security },
11573
- ports: { ...next.ports }
12086
+ state: summary.state
11574
12087
  });
12088
+ writeJson(res, 200, summary);
11575
12089
  }
11576
12090
  function warnConnect(deviceId) {
11577
12091
  return (error) => {
@@ -11583,14 +12097,128 @@ function sameHosts(a, b) {
11583
12097
  return a.length === b.length && a.every((host, index) => host === b[index]);
11584
12098
  }
11585
12099
  /**
12100
+ * `POST /devices/:id/forget` and `POST /devices/:id/remember` (issue #204): the
12101
+ * settings page's door onto #185's endpoint maintenance, which until now only
12102
+ * the `cherry_devices` tool could open.
12103
+ *
12104
+ * One action per request, and the action IS the request: there is no composite
12105
+ * "patch the record AND forget a host" body whose ordering would have to be
12106
+ * defined. Every judgement is the store's — the last-candidate refusal, the
12107
+ * case-insensitive matching, the disjointness of `hosts`/`forgottenHosts` — so
12108
+ * the two doors share ONE set of STORE rules; this route only maps the verdicts
12109
+ * onto HTTP and answers the projection the PATCH answers. (The BODY PARSERS do
12110
+ * differ on purpose — see `parseEndpointHosts`.)
12111
+ *
12112
+ * EVICTION on a change, exactly like the tool's own arm and a PATCH's address
12113
+ * maintenance, and for a DIFFERENT reason on each action — which is why both
12114
+ * are stated here rather than waved through as "parity":
12115
+ *
12116
+ * - FORGET: a pooled connection CAPTURES its record at construction, so the
12117
+ * socket built before this write would keep dialing the host we just forgot.
12118
+ * (The mark itself is honoured by the store's learn merge, so a reconnect is
12119
+ * about the DIAL LIST, not about suppressing the address.)
12120
+ * - REMEMBER: learning happens only INSIDE a handshake, against a clone of the
12121
+ * stored record. Without the eviction a desktop that stays connected would
12122
+ * not re-handshake for as long as the link holds, so the restored address
12123
+ * would come back only after some later flap — the button would look inert.
12124
+ *
12125
+ * The cost is real and inherited from the tool's arm: an eviction disposes the
12126
+ * subscription layer and closes a healthy socket, so a write can drop an
12127
+ * in-flight command's link. One operator click now reaches that churn; that is
12128
+ * the price of "the archive is the truth" for a live connection.
12129
+ */
12130
+ async function changeEndpoints(req, res, route, deviceId, action) {
12131
+ const existing = route.deps.store.list().find((device) => device.deviceId === deviceId);
12132
+ if (existing === void 0) {
12133
+ writeError(res, 404, "not-found", `no device ${deviceId}`);
12134
+ return;
12135
+ }
12136
+ let hosts;
12137
+ try {
12138
+ hosts = parseEndpointHosts(await readJsonBody(req));
12139
+ } catch (error) {
12140
+ writeError(res, 400, "invalid-request", error instanceof Error ? error.message : String(error));
12141
+ return;
12142
+ }
12143
+ try {
12144
+ if (action === "forget") {
12145
+ const { removed, marked } = route.deps.store.forgetEndpoints(deviceId, hosts);
12146
+ if (removed.length > 0 || marked.length > 0) evict(route, deviceId);
12147
+ } else {
12148
+ const { restored } = route.deps.store.rememberEndpoints(deviceId, hosts);
12149
+ if (restored.length > 0) evict(route, deviceId);
12150
+ }
12151
+ } catch (error) {
12152
+ if (writeQuarantineRefusal(res, route, error)) return;
12153
+ if (error instanceof LastCandidateHostError) {
12154
+ writeError(res, 400, "last-address", error.message);
12155
+ return;
12156
+ }
12157
+ writeError(res, 400, "invalid-device", error instanceof Error ? error.message : String(error));
12158
+ return;
12159
+ }
12160
+ writeJson(res, 200, deviceSummary(route, route.deps.store.list().find((device) => device.deviceId === deviceId) ?? existing));
12161
+ }
12162
+ /**
12163
+ * The body of an endpoint action: `{hosts: [...]}`, non-empty, every entry
12164
+ * normalized the same way the PATCH normalizes an address.
12165
+ *
12166
+ * The normalization is not cosmetic: an IPv6 literal is stored bare but a
12167
+ * browser copy-pastes it bracketed, and the store matches by host spelling —
12168
+ * so `[::1]` would be treated as an address the record does not dial and merely
12169
+ * marked, leaving the real `::1` in `hosts`. The PATCH's rule is reused for the
12170
+ * spelling (same `normalizeHost`), and deliberately NOT for its duplicate
12171
+ * refusal: a repeated host in an action body names one host twice, which the
12172
+ * store's own sets collapse, while the PATCH's duplicates would build an
12173
+ * ambiguous record (`hosts`, `security` and `ports` are keyed by host).
12174
+ */
12175
+ function parseEndpointHosts(body) {
12176
+ const hosts = body["hosts"];
12177
+ if (!Array.isArray(hosts) || hosts.length === 0) throw new PatchError("hosts must be a non-empty array");
12178
+ return hosts.map((entry) => {
12179
+ if (typeof entry !== "string") throw new PatchError("hosts entries must be strings");
12180
+ return normalizeHost(entry);
12181
+ });
12182
+ }
12183
+ /**
11586
12184
  * The scheme map for a WHOLE-REPLACE host list (issue #126): each named host
11587
12185
  * keeps the scheme it was dialed with, a host the list just gained defaults to
11588
12186
  * `ws`, and hosts the list dropped leave no entry behind. An edit is an
11589
12187
  * address edit, not a scheme edit — re-typing the same host must not silently
11590
12188
  * demote a TLS-proxied desktop to a plain dial.
12189
+ *
12190
+ * "The same host" is CASE-INSENSITIVE (issue #189), the same comparison the
12191
+ * learn merge, the forget/remember actions and the mark clearing already use:
12192
+ * `NAS.example` and `nas.example` are one address, so a PATCH that re-types
12193
+ * the case keeps the remembered scheme instead of resetting it to `ws`.
12194
+ * `sameHosts`/`sameSecurity` stay EXACT on purpose — they decide whether an
12195
+ * address edit happened at all, and a new spelling IS a change to persist.
11591
12196
  */
11592
12197
  function securityFor(hosts, previous) {
11593
- return Object.fromEntries(hosts.map((host) => [host, Object.hasOwn(previous, host) ? previous[host] : "ws"]));
12198
+ const remembered = indexByHost(previous);
12199
+ return Object.fromEntries(hosts.map((host) => [host, exactOrRemembered(previous, remembered, host) ?? "ws"]));
12200
+ }
12201
+ /**
12202
+ * A case-insensitive index over a per-host map (issue #189).
12203
+ *
12204
+ * FIRST spelling wins on a collision. A hand-edited archive CAN hold two
12205
+ * spellings of one host (the store does not reject it, and this face does not
12206
+ * touch `previous`), so callers look their exact spelling up FIRST and consult
12207
+ * this index only as the fallback: a surviving spelling therefore keeps its
12208
+ * OWN entry, and the collision rule only decides what a THIRD spelling
12209
+ * inherits.
12210
+ */
12211
+ function indexByHost(previous) {
12212
+ const index = /* @__PURE__ */ new Map();
12213
+ for (const [host, value] of Object.entries(previous)) {
12214
+ const key = host.toLowerCase();
12215
+ if (!index.has(key)) index.set(key, value);
12216
+ }
12217
+ return index;
12218
+ }
12219
+ /** The exact spelling's entry, else the case-insensitive one, else nothing. */
12220
+ function exactOrRemembered(previous, remembered, host) {
12221
+ return Object.hasOwn(previous, host) ? previous[host] : remembered.get(host.toLowerCase());
11594
12222
  }
11595
12223
  /**
11596
12224
  * The port map for a host list (issue #135): each named host keeps the port it
@@ -11599,11 +12227,13 @@ function securityFor(hosts, previous) {
11599
12227
  *
11600
12228
  * `previous` is empty when the caller is re-pointing EVERY host (a CHANGED
11601
12229
  * `port` patch): there is no remembered entry to keep, by design — one port
11602
- * field on this face must move the whole list. Same `Object.hasOwn` rule as
11603
- * `securityFor`: an inherited function is not a remembered port.
12230
+ * field on this face must move the whole list. Same `Object.hasOwn`/
12231
+ * case-insensitive rules as `securityFor` (issue #189): a re-typed spelling
12232
+ * keeps its own port, and an inherited function is never a remembered port.
11604
12233
  */
11605
12234
  function portsFor(hosts, previous, fallback) {
11606
- return Object.fromEntries(hosts.map((host) => [host, Object.hasOwn(previous, host) ? previous[host] : fallback]));
12235
+ const remembered = indexByHost(previous);
12236
+ return Object.fromEntries(hosts.map((host) => [host, exactOrRemembered(previous, remembered, host) ?? fallback]));
11607
12237
  }
11608
12238
  /** Order-insensitive comparison for two scheme maps. */
11609
12239
  function sameSecurity(a, b) {
@@ -11933,7 +12563,10 @@ function connectionFor(deviceId) {
11933
12563
  identity: requireIdentity(),
11934
12564
  record,
11935
12565
  rateBudgetPerSecond: state.rateBudgetPerSecond,
11936
- ...state.wsTls === void 0 ? {} : { wsOptions: state.wsTls }
12566
+ ...state.wsTls === void 0 ? {} : { wsOptions: state.wsTls },
12567
+ onAdvertisedEndpoints: (endpoints) => {
12568
+ state.store.learnEndpoints(deviceId, endpoints, record.desktopIdentity);
12569
+ }
11937
12570
  });
11938
12571
  state.connectionsBuilt += 1;
11939
12572
  connection.onState((deviceState) => {