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.
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'
@@ -119,6 +119,14 @@ export interface DeviceSummary {
119
119
  security: Record<string, 'ws' | 'wss'>
120
120
  /** The port each host is dialed with (issue #135); see `DeviceRecord`. */
121
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[]
122
130
  }
123
131
 
124
132
  /** The `device-state` SSE payload. */
@@ -592,11 +600,17 @@ function parsePatch(body: Record<string, unknown>): DevicePatch {
592
600
  if (typeof entry !== 'string') throw new PatchError('hosts entries must be strings')
593
601
  return normalizeHost(entry)
594
602
  })
595
- // Checked AFTER normalization: `[::1]` and `::1` collapse to one host. The
596
- // scheme/port maps are keyed by host, so a duplicate has no single entry
597
- // and silently last-wins — an ambiguous record, not an edit.
598
- if (new Set(patch.hosts).size !== patch.hosts.length) {
599
- throw new PatchError('hosts must not contain duplicates')
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)
600
614
  }
601
615
  }
602
616
 
@@ -1017,6 +1031,28 @@ async function handleRequest(req: HttpRequestLike, res: HttpResponseLike, route:
1017
1031
  return
1018
1032
  }
1019
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
+
1020
1056
  if (first === 'pair' && match.segments.length === 1) {
1021
1057
  if (method !== 'POST') {
1022
1058
  writeError(res, 405, 'method-not-allowed', 'POST only')
@@ -1071,7 +1107,23 @@ async function handleRequest(req: HttpRequestLike, res: HttpResponseLike, route:
1071
1107
  }
1072
1108
 
1073
1109
  function listDevices(res: HttpResponseLike, route: RouteContext): void {
1074
- 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 {
1075
1127
  deviceId: device.deviceId,
1076
1128
  displayName: device.displayName,
1077
1129
  state: projectState(device, route.deps.connect),
@@ -1080,8 +1132,10 @@ function listDevices(res: HttpResponseLike, route: RouteContext): void {
1080
1132
  port: device.port,
1081
1133
  security: { ...device.security },
1082
1134
  ports: { ...device.ports },
1083
- }))
1084
- writeJson(res, 200, devices)
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
+ }
1085
1139
  }
1086
1140
 
1087
1141
  /**
@@ -1407,8 +1461,20 @@ async function patchDevice(
1407
1461
  : patch.hosts === undefined
1408
1462
  ? previousPorts
1409
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
+ )
1410
1475
  const next: DeviceRecord = {
1411
1476
  ...existing,
1477
+ ...(nextForgotten === undefined ? {} : { forgottenHosts: nextForgotten }),
1412
1478
  ...(patch.displayName === undefined ? {} : { displayName: patch.displayName }),
1413
1479
  ...(patch.enabled === undefined ? {} : { enabled: patch.enabled }),
1414
1480
  // hosts is WHOLE-REPLACE: the patch wins outright, never merges. The
@@ -1473,22 +1539,14 @@ async function patchDevice(
1473
1539
 
1474
1540
  // The response and the SSE frame always report the projection of the record
1475
1541
  // we just wrote: a disabled device is `disabled` regardless of what its
1476
- // connection last said, and an enabled one reports its live state.
1477
- 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)
1478
1545
  if (patch.enabled !== undefined && patch.enabled !== previousEnabled) {
1479
- route.hub.publish('device-state', { deviceId, state })
1546
+ route.hub.publish('device-state', { deviceId, state: summary.state })
1480
1547
  }
1481
1548
 
1482
- writeJson(res, 200, {
1483
- deviceId: next.deviceId,
1484
- displayName: next.displayName,
1485
- state,
1486
- enabled: next.enabled,
1487
- hosts: [...next.hosts],
1488
- port: next.port,
1489
- security: { ...next.security },
1490
- ports: { ...next.ports },
1491
- })
1549
+ writeJson(res, 200, summary)
1492
1550
  }
1493
1551
 
1494
1552
  function warnConnect(deviceId: string): (error: unknown) => void {
@@ -1511,12 +1569,129 @@ function sameHosts(a: readonly string[], b: readonly string[]): boolean {
1511
1569
  return a.length === b.length && a.every((host, index) => host === b[index])
1512
1570
  }
1513
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
+
1514
1682
  /**
1515
1683
  * The scheme map for a WHOLE-REPLACE host list (issue #126): each named host
1516
1684
  * keeps the scheme it was dialed with, a host the list just gained defaults to
1517
1685
  * `ws`, and hosts the list dropped leave no entry behind. An edit is an
1518
1686
  * address edit, not a scheme edit — re-typing the same host must not silently
1519
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.
1520
1695
  */
1521
1696
  function securityFor(
1522
1697
  hosts: readonly string[],
@@ -1524,12 +1699,42 @@ function securityFor(
1524
1699
  ): Record<string, 'ws' | 'wss'> {
1525
1700
  // `Object.hasOwn`, never `previous[host] ?? 'ws'`: a host named `constructor`
1526
1701
  // (or `__proto__`/`toString`) would otherwise inherit a FUNCTION from
1527
- // Object.prototype and poison the map the store then rejects.
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)
1528
1705
  return Object.fromEntries(
1529
- hosts.map((host) => [host, Object.hasOwn(previous, host) ? previous[host] : 'ws']),
1706
+ hosts.map((host) => [host, exactOrRemembered(previous, remembered, host) ?? 'ws']),
1530
1707
  )
1531
1708
  }
1532
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
+
1533
1738
  /**
1534
1739
  * The port map for a host list (issue #135): each named host keeps the port it
1535
1740
  * was dialed with, a host the list just gained defaults to `fallback` (the
@@ -1537,16 +1742,18 @@ function securityFor(
1537
1742
  *
1538
1743
  * `previous` is empty when the caller is re-pointing EVERY host (a CHANGED
1539
1744
  * `port` patch): there is no remembered entry to keep, by design — one port
1540
- * field on this face must move the whole list. Same `Object.hasOwn` rule as
1541
- * `securityFor`: an inherited function is not a remembered 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.
1542
1748
  */
1543
1749
  function portsFor(
1544
1750
  hosts: readonly string[],
1545
1751
  previous: Record<string, number>,
1546
1752
  fallback: number,
1547
1753
  ): Record<string, number> {
1754
+ const remembered = indexByHost(previous)
1548
1755
  return Object.fromEntries(
1549
- hosts.map((host) => [host, Object.hasOwn(previous, host) ? previous[host] : fallback]),
1756
+ hosts.map((host) => [host, exactOrRemembered(previous, remembered, host) ?? fallback]),
1550
1757
  )
1551
1758
  }
1552
1759
 
package/src/index.ts CHANGED
@@ -489,6 +489,19 @@ function connectionFor(deviceId: string): DeviceConnection {
489
489
  // Spread-if-present, never `wsOptions: undefined`: the option is optional,
490
490
  // and an absent key is the honest "no override" (Node's trust store).
491
491
  ...(state.wsTls === undefined ? {} : { wsOptions: state.wsTls }),
492
+ // The archive write-back (issue #143). The connection has already merged the
493
+ // addresses into its OWN record — this instance's next reconnect dials them
494
+ // — so the only job here is durability. It goes through
495
+ // `DeviceStore.learnEndpoints`, NOT `upsert(record)`: the store's record
496
+ // carries the token of the PAIRING while the connection's mirrors the live
497
+ // one, and an `upsert` of the connection's copy would put a
498
+ // connection-scoped credential into the archive.
499
+ onAdvertisedEndpoints: (endpoints) => {
500
+ // `record` is THIS connection's clone: its `desktopIdentity` is the pin the
501
+ // answer was validated against, so a re-pair under the same deviceId cannot
502
+ // receive an address learned from the previous pairing.
503
+ state.store.learnEndpoints(deviceId, endpoints, record.desktopIdentity)
504
+ },
492
505
  })
493
506
  state.connectionsBuilt += 1
494
507
  connection.onState((deviceState: DeviceState) => {
@@ -651,6 +664,11 @@ function subsFor(deviceId: string): SessionSubscriptions {
651
664
  const created = new SessionSubscriptions(connection.rpc, {
652
665
  subscriptionsLimit: connection.limits.subscriptions,
653
666
  perSessionCacheBytes: state.perSessionCacheBytes,
667
+ // The peer's OWN un-acked window when hello advertised it (protocol 0.7.0
668
+ // peers do); absent falls back to the pinned constant inside the layer.
669
+ // A PROVIDER, not a value: the layer outlives a reconnect, and `limits` is
670
+ // rebuilt by every hello — a pinned number would freeze the first answer.
671
+ unackedBytes: () => connection.limits.unackedBytes,
654
672
  })
655
673
  state.subscriptions.set(deviceId, created)
656
674
  // INFO (spec §5 「订阅建立」): the layer is announced with the cap it was