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/README.en.md +15 -8
- package/README.md +13 -6
- package/dist/index.d.mts +32 -3
- package/dist/index.mjs +1563 -168
- package/docs/e2e-checklist.md +50 -2
- package/lib/client.js +311 -31
- package/package.json +4 -3
- package/src/AGENTS.md +949 -0
- package/src/connection.ts +287 -11
- package/src/deviceStore.ts +518 -6
- package/src/diagnostics.ts +30 -0
- package/src/httpApi.ts +351 -23
- package/src/index.ts +203 -8
- package/src/pairing.ts +504 -112
- package/src/rpc/jsonRpcClient.ts +19 -1
- package/src/subscriptions.ts +10 -2
- package/src/text.ts +158 -0
- package/src/tools.ts +329 -23
- package/ui/AGENTS.md +44 -0
- package/ui/src/App.tsx +26 -10
- package/ui/src/DeviceCard.tsx +141 -4
- package/ui/src/PairingWizard.tsx +80 -5
- package/ui/src/ToolScopePanel.tsx +19 -6
- package/ui/src/api.ts +111 -17
- package/ui/src/i18n.ts +49 -0
- package/ui/src/styles.css +24 -0
- package/ui/src/types.ts +16 -1
- package/src/urlHost.ts +0 -15
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
|
|
542
|
-
*
|
|
543
|
-
* semantics
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|