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.
package/src/httpApi.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { randomUUID } from 'node:crypto'
2
- import { QUARANTINE_RECOVERY, QuarantinedArchiveError } from './deviceStore.js'
2
+ import { LastCandidateHostError, QUARANTINE_RECOVERY, QuarantinedArchiveError } from './deviceStore.js'
3
3
  import type { DeviceRecord } from './deviceStore.js'
4
4
  import type { DeviceStore } from './deviceStore.js'
5
5
  import type { DeviceState, EffectiveLimits } from './connection.js'
@@ -9,6 +9,7 @@ import { CONTRACT_DIFFERENCES, buildDiagnostics, readScopeSegment } from './diag
9
9
  import type { DiagnosticsError, DiagnosticsStats, SubscriptionStat } from './diagnostics.js'
10
10
  import type { ScopeDoc, ToolScopeState } from './toolScope.js'
11
11
  import type { RefreshSummary } from './toolScopeHost.js'
12
+ import { failureText } from './text.js'
12
13
 
13
14
  /**
14
15
  * The plugin's same-origin HTTP face (spec §3.7): device management REST plus
@@ -114,6 +115,18 @@ export interface DeviceSummary {
114
115
  enabled: boolean
115
116
  hosts: string[]
116
117
  port: number
118
+ /** The scheme each host is dialed with (issue #126); see `DeviceRecord`. */
119
+ security: Record<string, 'ws' | 'wss'>
120
+ /** The port each host is dialed with (issue #135); see `DeviceRecord`. */
121
+ ports: Record<string, number>
122
+ /**
123
+ * The addresses the operator has forgotten (issue #185): not dialed, and not
124
+ * re-learned until `cherry_devices` remembers them (or a PATCH puts one back
125
+ * into `hosts`, which clears its mark). Reported beside the address maps for
126
+ * the same reason they are: it is archive state an operator edits, not a
127
+ * credential.
128
+ */
129
+ forgottenHosts: string[]
117
130
  }
118
131
 
119
132
  /** The `device-state` SSE payload. */
@@ -432,6 +445,9 @@ function writeQuarantineRefusal(res: HttpResponseLike, route: RouteContext, erro
432
445
  try {
433
446
  route.deps.onArchiveQuarantined?.(error)
434
447
  } catch (reportError) {
448
+ // LOCAL observer catch (issue #161 audit): the diagnostics ring is ours, so
449
+ // no peer text can reach this error — the object print (with its stack) is
450
+ // deliberate.
435
451
  console.warn('[cherry-remote] reporting the quarantined archive failed', reportError)
436
452
  }
437
453
  writeJson(res, 503, {
@@ -538,9 +554,20 @@ interface DevicePatch {
538
554
  }
539
555
 
540
556
  /**
541
- * Validates a PATCH body. `hosts` is WHOLE-REPLACE by contract, and `port` is
542
- * shared by every host (spec §3.2) — there is no per-host port and no merge
543
- * semantics to get wrong.
557
+ * Validates a PATCH body. `hosts` is WHOLE-REPLACE by contract.
558
+ *
559
+ * The map semantics this face owes (the record's per-host maps are DERIVED,
560
+ * never patched directly — there is no `ports` field in this body):
561
+ *
562
+ * - neither `hosts` nor `port` patched → the maps are unchanged;
563
+ * - `hosts` replaced → a host that stays keeps ITS OWN scheme and port, a new
564
+ * host gets `ws` and the record-level port;
565
+ * - a CHANGED `port` (with or without `hosts`) → EVERY final host is
566
+ * re-pointed to it. The face has one port field, so leaving per-host
567
+ * entries would make the edit inert for every listed host that already had
568
+ * one. Sending the CURRENT value is a no-op for the maps — the settings page
569
+ * sends both fields on every save, and a mixed-port record must not be
570
+ * flattened by a save that did not change the port.
544
571
  */
545
572
  function parsePatch(body: Record<string, unknown>): DevicePatch {
546
573
  const patch: DevicePatch = {}
@@ -573,6 +600,18 @@ function parsePatch(body: Record<string, unknown>): DevicePatch {
573
600
  if (typeof entry !== 'string') throw new PatchError('hosts entries must be strings')
574
601
  return normalizeHost(entry)
575
602
  })
603
+ // Checked AFTER normalization: `[::1]` and `::1` collapse to one host, and
604
+ // host comparison is CASE-INSENSITIVE everywhere else on this face (the
605
+ // learn merge, the forget/remember matching, the mark clearing), so
606
+ // `NAS.example` and `nas.example` are one address too. The scheme/port maps
607
+ // are keyed by host, so a duplicate has no single entry and silently
608
+ // last-wins — an ambiguous record, not an edit.
609
+ const seen = new Set<string>()
610
+ for (const host of patch.hosts) {
611
+ const key = host.toLowerCase()
612
+ if (seen.has(key)) throw new PatchError('hosts must not contain duplicates')
613
+ seen.add(key)
614
+ }
576
615
  }
577
616
 
578
617
  const port = body['port']
@@ -992,6 +1031,28 @@ async function handleRequest(req: HttpRequestLike, res: HttpResponseLike, route:
992
1031
  return
993
1032
  }
994
1033
 
1034
+ // The two endpoint actions (issue #204) sit UNDER the device resource
1035
+ // because that is what they act on: they are the page's door onto #185's
1036
+ // forget/remember, one action per request, so a "patch AND forget" body
1037
+ // with a contradictory ordering never exists.
1038
+ if (first === 'devices' && match.segments.length === 3 && second !== undefined) {
1039
+ const deviceId = decodeSegment(second)
1040
+ if (deviceId === undefined) {
1041
+ writeError(res, 400, 'invalid-request', 'device id is not valid URL encoding')
1042
+ return
1043
+ }
1044
+ const action = match.segments[2]
1045
+ if (action !== 'forget' && action !== 'remember') {
1046
+ writeError(res, 404, 'not-found', 'unknown route')
1047
+ return
1048
+ }
1049
+ if (method !== 'POST') {
1050
+ writeError(res, 405, 'method-not-allowed', 'POST only')
1051
+ return
1052
+ }
1053
+ return await changeEndpoints(req, res, route, deviceId, action)
1054
+ }
1055
+
995
1056
  if (first === 'pair' && match.segments.length === 1) {
996
1057
  if (method !== 'POST') {
997
1058
  writeError(res, 405, 'method-not-allowed', 'POST only')
@@ -1046,15 +1107,35 @@ async function handleRequest(req: HttpRequestLike, res: HttpResponseLike, route:
1046
1107
  }
1047
1108
 
1048
1109
  function listDevices(res: HttpResponseLike, route: RouteContext): void {
1049
- const devices: DeviceSummary[] = route.deps.store.list().map((device) => ({
1110
+ writeJson(
1111
+ res,
1112
+ 200,
1113
+ route.deps.store.list().map((device) => deviceSummary(route, device)),
1114
+ )
1115
+ }
1116
+
1117
+ /**
1118
+ * One device as the browser sees it: the public archive projection, never a
1119
+ * credential.
1120
+ *
1121
+ * ONE builder, because FOUR faces answer this shape — `GET /devices`, the
1122
+ * `PATCH`, and the two endpoint actions of issue #204 — and a hand-written copy
1123
+ * per face is how a field ends up on one and not the others.
1124
+ */
1125
+ function deviceSummary(route: RouteContext, device: DeviceRecord): DeviceSummary {
1126
+ return {
1050
1127
  deviceId: device.deviceId,
1051
1128
  displayName: device.displayName,
1052
1129
  state: projectState(device, route.deps.connect),
1053
1130
  enabled: device.enabled,
1054
1131
  hosts: [...device.hosts],
1055
1132
  port: device.port,
1056
- }))
1057
- writeJson(res, 200, devices)
1133
+ security: { ...device.security },
1134
+ ports: { ...device.ports },
1135
+ // An empty list is the honest "none", not an absent key: the page renders
1136
+ // the forgotten row only when it is non-empty.
1137
+ forgottenHosts: [...(device.forgottenHosts ?? [])],
1138
+ }
1058
1139
  }
1059
1140
 
1060
1141
  /**
@@ -1255,11 +1336,13 @@ function deviceById(deps: CherryHttpDeps, deviceId: string): DeviceRecord {
1255
1336
  const found = deps.store.list().find((device) => device.deviceId === deviceId)
1256
1337
  if (found !== undefined) return found
1257
1338
  return {
1258
- schemaVersion: 2,
1339
+ schemaVersion: 4,
1259
1340
  deviceId,
1260
1341
  displayName: '',
1261
1342
  hosts: [],
1262
1343
  port: 0,
1344
+ security: {},
1345
+ ports: {},
1263
1346
  desktopIdentity: '',
1264
1347
  grants: [],
1265
1348
  enabled: false,
@@ -1360,13 +1443,48 @@ async function patchDevice(
1360
1443
  const previousEnabled = existing.enabled
1361
1444
  const previousHosts = existing.hosts
1362
1445
  const previousPort = existing.port
1446
+ const previousSecurity = existing.security
1447
+ const previousPorts = existing.ports
1448
+ // The per-host port map is DERIVED from the patch, never patched directly
1449
+ // (there is no `ports` field in the body):
1450
+ // - no address field → unchanged;
1451
+ // - `hosts` replaced → a staying host keeps its own port, a new host gets the
1452
+ // record-level `port`;
1453
+ // - a CHANGED `port` (with or without `hosts`) → EVERY final host is
1454
+ // re-pointed; one port field on this face, so leaving per-host entries
1455
+ // would make the edit inert. Sending the CURRENT value is a no-op for the
1456
+ // map (see `parsePatch`), because the settings page sends both fields on
1457
+ // every save.
1458
+ const nextPorts =
1459
+ patch.port !== undefined && patch.port !== previousPort
1460
+ ? portsFor(patch.hosts ?? previousHosts, {}, patch.port)
1461
+ : patch.hosts === undefined
1462
+ ? previousPorts
1463
+ : portsFor(patch.hosts, previousPorts, previousPort)
1464
+ // A host the operator puts BACK into `hosts` is dialed again, so its forget
1465
+ // mark must go: `hosts` and `forgottenHosts` are disjoint by contract, and an
1466
+ // explicit list is intent to dial. A PATCH that does not touch `hosts`
1467
+ // (displayName/enabled/port) leaves every mark alone. Case-insensitive, the
1468
+ // same comparison the learn merge uses.
1469
+ const nextForgotten =
1470
+ patch.hosts === undefined || existing.forgottenHosts === undefined
1471
+ ? undefined
1472
+ : existing.forgottenHosts.filter(
1473
+ (host) => !patch.hosts!.some((nextHost) => nextHost.toLowerCase() === host.toLowerCase()),
1474
+ )
1363
1475
  const next: DeviceRecord = {
1364
1476
  ...existing,
1477
+ ...(nextForgotten === undefined ? {} : { forgottenHosts: nextForgotten }),
1365
1478
  ...(patch.displayName === undefined ? {} : { displayName: patch.displayName }),
1366
1479
  ...(patch.enabled === undefined ? {} : { enabled: patch.enabled }),
1367
- // hosts is WHOLE-REPLACE: the patch wins outright, never merges.
1368
- ...(patch.hosts === undefined ? {} : { hosts: patch.hosts }),
1480
+ // hosts is WHOLE-REPLACE: the patch wins outright, never merges. The
1481
+ // scheme map is keyed by host, so replacing the host list drops the
1482
+ // entries for hosts that are gone and defaults the new ones to `ws` —
1483
+ // keeping the OLD schemes indexed by the NEW list would be a stale
1484
+ // pairing of host to scheme (issue #126).
1485
+ ...(patch.hosts === undefined ? {} : { hosts: patch.hosts, security: securityFor(patch.hosts, previousSecurity) }),
1369
1486
  ...(patch.port === undefined ? {} : { port: patch.port }),
1487
+ ports: nextPorts,
1370
1488
  }
1371
1489
 
1372
1490
  try {
@@ -1389,7 +1507,8 @@ async function patchDevice(
1389
1507
  // `connect()` to rebuild from the record we just wrote.
1390
1508
  const addressChanged =
1391
1509
  (patch.hosts !== undefined && !sameHosts(patch.hosts, previousHosts)) ||
1392
- (patch.port !== undefined && patch.port !== previousPort)
1510
+ (patch.port !== undefined && patch.port !== previousPort) ||
1511
+ !sameSecurity(next.security, previousSecurity)
1393
1512
  const enabledChanged = patch.enabled !== undefined && patch.enabled !== previousEnabled
1394
1513
 
1395
1514
  if (addressChanged) {
@@ -1409,31 +1528,39 @@ async function patchDevice(
1409
1528
  try {
1410
1529
  track(route.deps.connect(deviceId).connect(), warnConnect(deviceId))
1411
1530
  } catch (error) {
1531
+ // LOCAL: only the synchronous construction failure lands here
1532
+ // (`connectionFor` cannot find the record, or the identity failed to
1533
+ // load). The DIAL's rejection carries peer text and is rendered by
1534
+ // `warnConnect` below; this arm keeps the object print and its stack
1535
+ // (issue #161's boundary).
1412
1536
  console.warn(`[cherry-remote] device ${deviceId} reconnect failed`, error)
1413
1537
  }
1414
1538
  }
1415
1539
 
1416
1540
  // The response and the SSE frame always report the projection of the record
1417
1541
  // we just wrote: a disabled device is `disabled` regardless of what its
1418
- // connection last said, and an enabled one reports its live state.
1419
- const state = projectState(next, route.deps.connect)
1542
+ // connection last said, and an enabled one reports its live state. ONE
1543
+ // projection feeds both, so the frame and the body cannot disagree.
1544
+ const summary = deviceSummary(route, next)
1420
1545
  if (patch.enabled !== undefined && patch.enabled !== previousEnabled) {
1421
- route.hub.publish('device-state', { deviceId, state })
1546
+ route.hub.publish('device-state', { deviceId, state: summary.state })
1422
1547
  }
1423
1548
 
1424
- writeJson(res, 200, {
1425
- deviceId: next.deviceId,
1426
- displayName: next.displayName,
1427
- state,
1428
- enabled: next.enabled,
1429
- hosts: [...next.hosts],
1430
- port: next.port,
1431
- })
1549
+ writeJson(res, 200, summary)
1432
1550
  }
1433
1551
 
1434
1552
  function warnConnect(deviceId: string): (error: unknown) => void {
1435
1553
  return (error: unknown) => {
1436
- console.warn(`[cherry-remote] device ${deviceId} lifecycle change failed`, error)
1554
+ // ONE bounded STRING (issue #161). This is the arm that receives the
1555
+ // lifecycle rejection. The common case is `AllHostsUnreachableError`, whose
1556
+ // message embeds the last per-host error as `last: <message>` — a `ws` /
1557
+ // channel failure, or an RPC refusal's peer message (capped at 512 by the
1558
+ // failure schema). The terminal arms add only local wrappers (`identity
1559
+ // mismatch`, `protocol version incompatible`, `authenticate rejected
1560
+ // (<reason>)` — an enum). The old form handed the error OBJECT to the
1561
+ // console. Ceiling: `[cherry-remote] device ` (23) + deviceId +
1562
+ // ` lifecycle change failed: ` (26) + LOG_TEXT_MAX (240).
1563
+ console.warn(`[cherry-remote] device ${deviceId} lifecycle change failed: ${failureText(error)}`)
1437
1564
  }
1438
1565
  }
1439
1566
 
@@ -1442,6 +1569,205 @@ function sameHosts(a: readonly string[], b: readonly string[]): boolean {
1442
1569
  return a.length === b.length && a.every((host, index) => host === b[index])
1443
1570
  }
1444
1571
 
1572
+ /**
1573
+ * `POST /devices/:id/forget` and `POST /devices/:id/remember` (issue #204): the
1574
+ * settings page's door onto #185's endpoint maintenance, which until now only
1575
+ * the `cherry_devices` tool could open.
1576
+ *
1577
+ * One action per request, and the action IS the request: there is no composite
1578
+ * "patch the record AND forget a host" body whose ordering would have to be
1579
+ * defined. Every judgement is the store's — the last-candidate refusal, the
1580
+ * case-insensitive matching, the disjointness of `hosts`/`forgottenHosts` — so
1581
+ * the two doors share ONE set of STORE rules; this route only maps the verdicts
1582
+ * onto HTTP and answers the projection the PATCH answers. (The BODY PARSERS do
1583
+ * differ on purpose — see `parseEndpointHosts`.)
1584
+ *
1585
+ * EVICTION on a change, exactly like the tool's own arm and a PATCH's address
1586
+ * maintenance, and for a DIFFERENT reason on each action — which is why both
1587
+ * are stated here rather than waved through as "parity":
1588
+ *
1589
+ * - FORGET: a pooled connection CAPTURES its record at construction, so the
1590
+ * socket built before this write would keep dialing the host we just forgot.
1591
+ * (The mark itself is honoured by the store's learn merge, so a reconnect is
1592
+ * about the DIAL LIST, not about suppressing the address.)
1593
+ * - REMEMBER: learning happens only INSIDE a handshake, against a clone of the
1594
+ * stored record. Without the eviction a desktop that stays connected would
1595
+ * not re-handshake for as long as the link holds, so the restored address
1596
+ * would come back only after some later flap — the button would look inert.
1597
+ *
1598
+ * The cost is real and inherited from the tool's arm: an eviction disposes the
1599
+ * subscription layer and closes a healthy socket, so a write can drop an
1600
+ * in-flight command's link. One operator click now reaches that churn; that is
1601
+ * the price of "the archive is the truth" for a live connection.
1602
+ */
1603
+ async function changeEndpoints(
1604
+ req: HttpRequestLike,
1605
+ res: HttpResponseLike,
1606
+ route: RouteContext,
1607
+ deviceId: string,
1608
+ action: 'forget' | 'remember',
1609
+ ): Promise<void> {
1610
+ const existing = route.deps.store.list().find((device) => device.deviceId === deviceId)
1611
+ if (existing === undefined) {
1612
+ writeError(res, 404, 'not-found', `no device ${deviceId}`)
1613
+ return
1614
+ }
1615
+
1616
+ let hosts: string[]
1617
+ try {
1618
+ hosts = parseEndpointHosts(await readJsonBody(req))
1619
+ } catch (error) {
1620
+ writeError(res, 400, 'invalid-request', error instanceof Error ? error.message : String(error))
1621
+ return
1622
+ }
1623
+
1624
+ try {
1625
+ if (action === 'forget') {
1626
+ const { removed, marked } = route.deps.store.forgetEndpoints(deviceId, hosts)
1627
+ if (removed.length > 0 || marked.length > 0) evict(route, deviceId)
1628
+ } else {
1629
+ const { restored } = route.deps.store.rememberEndpoints(deviceId, hosts)
1630
+ if (restored.length > 0) evict(route, deviceId)
1631
+ }
1632
+ } catch (error) {
1633
+ // Quarantine first, and distinctly: it is not the request's fault (the 400s
1634
+ // below) and the browser cannot fix it — the archive is READ-ONLY until a
1635
+ // human moves the bad file aside. A store that was ALREADY quarantined
1636
+ // never reaches this arm: its view is empty, so the `list()` above answered
1637
+ // 404 before any write — the same precedence PATCH and DELETE have. This is
1638
+ // the defence for a store that begins refusing after that read, and it is
1639
+ // what keeps a damaged archive from being reported as a bad request.
1640
+ if (writeQuarantineRefusal(res, route, error)) return
1641
+ // The tool's own code for the same refusal, so the page can translate it by
1642
+ // code rather than by prose.
1643
+ if (error instanceof LastCandidateHostError) {
1644
+ writeError(res, 400, 'last-address', error.message)
1645
+ return
1646
+ }
1647
+ writeError(res, 400, 'invalid-device', error instanceof Error ? error.message : String(error))
1648
+ return
1649
+ }
1650
+
1651
+ // The projection of the record we just wrote, never the object we read before
1652
+ // the write: `REMOVED from a declined call` and `UNCHANGED by a no-op` are
1653
+ // both answered from the archive's current row.
1654
+ const fresh = route.deps.store.list().find((device) => device.deviceId === deviceId)
1655
+ writeJson(res, 200, deviceSummary(route, fresh ?? existing))
1656
+ }
1657
+
1658
+ /**
1659
+ * The body of an endpoint action: `{hosts: [...]}`, non-empty, every entry
1660
+ * normalized the same way the PATCH normalizes an address.
1661
+ *
1662
+ * The normalization is not cosmetic: an IPv6 literal is stored bare but a
1663
+ * browser copy-pastes it bracketed, and the store matches by host spelling —
1664
+ * so `[::1]` would be treated as an address the record does not dial and merely
1665
+ * marked, leaving the real `::1` in `hosts`. The PATCH's rule is reused for the
1666
+ * spelling (same `normalizeHost`), and deliberately NOT for its duplicate
1667
+ * refusal: a repeated host in an action body names one host twice, which the
1668
+ * store's own sets collapse, while the PATCH's duplicates would build an
1669
+ * ambiguous record (`hosts`, `security` and `ports` are keyed by host).
1670
+ */
1671
+ function parseEndpointHosts(body: Record<string, unknown>): string[] {
1672
+ const hosts = body['hosts']
1673
+ if (!Array.isArray(hosts) || hosts.length === 0) {
1674
+ throw new PatchError('hosts must be a non-empty array')
1675
+ }
1676
+ return hosts.map((entry) => {
1677
+ if (typeof entry !== 'string') throw new PatchError('hosts entries must be strings')
1678
+ return normalizeHost(entry)
1679
+ })
1680
+ }
1681
+
1682
+ /**
1683
+ * The scheme map for a WHOLE-REPLACE host list (issue #126): each named host
1684
+ * keeps the scheme it was dialed with, a host the list just gained defaults to
1685
+ * `ws`, and hosts the list dropped leave no entry behind. An edit is an
1686
+ * address edit, not a scheme edit — re-typing the same host must not silently
1687
+ * demote a TLS-proxied desktop to a plain dial.
1688
+ *
1689
+ * "The same host" is CASE-INSENSITIVE (issue #189), the same comparison the
1690
+ * learn merge, the forget/remember actions and the mark clearing already use:
1691
+ * `NAS.example` and `nas.example` are one address, so a PATCH that re-types
1692
+ * the case keeps the remembered scheme instead of resetting it to `ws`.
1693
+ * `sameHosts`/`sameSecurity` stay EXACT on purpose — they decide whether an
1694
+ * address edit happened at all, and a new spelling IS a change to persist.
1695
+ */
1696
+ function securityFor(
1697
+ hosts: readonly string[],
1698
+ previous: Record<string, 'ws' | 'wss'>,
1699
+ ): Record<string, 'ws' | 'wss'> {
1700
+ // `Object.hasOwn`, never `previous[host] ?? 'ws'`: a host named `constructor`
1701
+ // (or `__proto__`/`toString`) would otherwise inherit a FUNCTION from
1702
+ // Object.prototype and poison the map the store then rejects. The index is
1703
+ // built from OWN entries only, so it is safe for the same reason.
1704
+ const remembered = indexByHost(previous)
1705
+ return Object.fromEntries(
1706
+ hosts.map((host) => [host, exactOrRemembered(previous, remembered, host) ?? 'ws']),
1707
+ )
1708
+ }
1709
+
1710
+ /**
1711
+ * A case-insensitive index over a per-host map (issue #189).
1712
+ *
1713
+ * FIRST spelling wins on a collision. A hand-edited archive CAN hold two
1714
+ * spellings of one host (the store does not reject it, and this face does not
1715
+ * touch `previous`), so callers look their exact spelling up FIRST and consult
1716
+ * this index only as the fallback: a surviving spelling therefore keeps its
1717
+ * OWN entry, and the collision rule only decides what a THIRD spelling
1718
+ * inherits.
1719
+ */
1720
+ function indexByHost<T>(previous: Record<string, T>): Map<string, T> {
1721
+ const index = new Map<string, T>()
1722
+ for (const [host, value] of Object.entries(previous)) {
1723
+ const key = host.toLowerCase()
1724
+ if (!index.has(key)) index.set(key, value)
1725
+ }
1726
+ return index
1727
+ }
1728
+
1729
+ /** The exact spelling's entry, else the case-insensitive one, else nothing. */
1730
+ function exactOrRemembered<T>(
1731
+ previous: Record<string, T>,
1732
+ remembered: Map<string, T>,
1733
+ host: string,
1734
+ ): T | undefined {
1735
+ return Object.hasOwn(previous, host) ? previous[host] : remembered.get(host.toLowerCase())
1736
+ }
1737
+
1738
+ /**
1739
+ * The port map for a host list (issue #135): each named host keeps the port it
1740
+ * was dialed with, a host the list just gained defaults to `fallback` (the
1741
+ * record-level port), and hosts the list dropped leave no entry behind.
1742
+ *
1743
+ * `previous` is empty when the caller is re-pointing EVERY host (a CHANGED
1744
+ * `port` patch): there is no remembered entry to keep, by design — one port
1745
+ * field on this face must move the whole list. Same `Object.hasOwn`/
1746
+ * case-insensitive rules as `securityFor` (issue #189): a re-typed spelling
1747
+ * keeps its own port, and an inherited function is never a remembered port.
1748
+ */
1749
+ function portsFor(
1750
+ hosts: readonly string[],
1751
+ previous: Record<string, number>,
1752
+ fallback: number,
1753
+ ): Record<string, number> {
1754
+ const remembered = indexByHost(previous)
1755
+ return Object.fromEntries(
1756
+ hosts.map((host) => [host, exactOrRemembered(previous, remembered, host) ?? fallback]),
1757
+ )
1758
+ }
1759
+
1760
+ /** Order-insensitive comparison for two scheme maps. */
1761
+ function sameSecurity(
1762
+ a: Record<string, 'ws' | 'wss'>,
1763
+ b: Record<string, 'ws' | 'wss'>,
1764
+ ): boolean {
1765
+ const keys = Object.keys(a)
1766
+ if (keys.length !== Object.keys(b).length) return false
1767
+ return keys.every((key) => a[key] === b[key])
1768
+ }
1769
+
1770
+
1445
1771
  /** Drops the pooled connection, closing any live socket first. */
1446
1772
  function evict(route: RouteContext, deviceId: string): void {
1447
1773
  try {
@@ -1555,6 +1881,8 @@ async function startPairing(req: HttpRequestLike, res: HttpResponseLike, route:
1555
1881
  try {
1556
1882
  route.deps.onArchiveQuarantined?.(error)
1557
1883
  } catch (reportError) {
1884
+ // LOCAL observer catch (issue #161 audit): same ring as the PATCH
1885
+ // route's, no peer text — the object print is deliberate.
1558
1886
  console.warn('[cherry-remote] reporting the quarantined archive failed', reportError)
1559
1887
  }
1560
1888
  // The shared instruction VERBATIM, with only the two contextual paths