dsh-zen-remote 1.1.16 → 2.0.0
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/AGENTS.md +54 -9
- package/README.md +249 -190
- package/README.zh-CN.md +247 -201
- package/assets/home.png +0 -0
- package/assets/info.png +0 -0
- package/assets/pairing.png +0 -0
- package/assets/session.png +0 -0
- package/assets/sheet.png +0 -0
- package/cordis.patch.yml +12 -11
- package/cordis.patch.yml.example +24 -9
- package/docs/interface.md +57 -2
- package/docs/remote-access.en.md +99 -58
- package/docs/remote-access.md +99 -42
- package/dsh-push.mjs +67 -54
- package/lan-gate.mjs +88 -20
- package/lib/activity.js +168 -0
- package/lib/activity.js.map +1 -0
- package/lib/admin-routes.js +526 -0
- package/lib/admin-routes.js.map +1 -0
- package/lib/client-pairing.js +224 -0
- package/lib/client-pairing.js.map +1 -0
- package/lib/client-routes.js +674 -0
- package/lib/client-routes.js.map +1 -0
- package/lib/client.js +10051 -4777
- package/lib/config.js +346 -0
- package/lib/config.js.map +1 -0
- package/lib/fetch-route-intercept.js +418 -0
- package/lib/fetch-route-intercept.js.map +1 -0
- package/lib/fingerprint.js +363 -0
- package/lib/fingerprint.js.map +1 -0
- package/lib/http.js +69 -0
- package/lib/http.js.map +1 -0
- package/lib/index.js +675 -54
- package/lib/index.js.map +1 -1
- package/lib/intercept-shape.js +81 -0
- package/lib/intercept-shape.js.map +1 -0
- package/lib/intercept.js +3040 -0
- package/lib/intercept.js.map +1 -0
- package/lib/lan-gate-server.cjs +532 -121
- package/lib/merge-streams.js +1165 -0
- package/lib/merge-streams.js.map +1 -0
- package/lib/relay-access.js +496 -0
- package/lib/relay-access.js.map +1 -0
- package/lib/relay-client.js +1303 -0
- package/lib/relay-client.js.map +1 -0
- package/lib/relay-filter.js +282 -0
- package/lib/relay-filter.js.map +1 -0
- package/lib/relay-server.js +1792 -0
- package/lib/relay-server.js.map +1 -0
- package/lib/restart-fields.js +34 -0
- package/lib/restart-fields.js.map +1 -0
- package/lib/restart-watcher.js +106 -0
- package/lib/restart-watcher.js.map +1 -0
- package/lib/session-reference.js +157 -0
- package/lib/session-reference.js.map +1 -0
- package/lib/share-export.js +16 -1
- package/lib/share-export.js.map +1 -1
- package/lib/share-ops.js +86 -0
- package/lib/share-ops.js.map +1 -0
- package/lib/share-store.js +294 -0
- package/lib/share-store.js.map +1 -0
- package/lib/types/activity.d.ts +99 -0
- package/lib/types/activity.d.ts.map +1 -0
- package/lib/types/admin-routes.d.ts +129 -0
- package/lib/types/admin-routes.d.ts.map +1 -0
- package/lib/types/client/MobileDrawerFooter.d.ts.map +1 -1
- package/lib/types/client/MobileHome.d.ts +1 -1
- package/lib/types/client/MobileHome.d.ts.map +1 -1
- package/lib/types/client/MobileHomeChips.d.ts +1 -1
- package/lib/types/client/MobileHomeChips.d.ts.map +1 -1
- package/lib/types/client/MobileSessionHeader.d.ts +13 -2
- package/lib/types/client/MobileSessionHeader.d.ts.map +1 -1
- package/lib/types/client/MobileSessionInfo.d.ts +20 -3
- package/lib/types/client/MobileSessionInfo.d.ts.map +1 -1
- package/lib/types/client/RemoteComposerBanner.d.ts +42 -0
- package/lib/types/client/RemoteComposerBanner.d.ts.map +1 -0
- package/lib/types/client/RemoteHeaderIcon.d.ts +37 -0
- package/lib/types/client/RemoteHeaderIcon.d.ts.map +1 -0
- package/lib/types/client/RemoteShareMenu.d.ts +28 -0
- package/lib/types/client/RemoteShareMenu.d.ts.map +1 -0
- package/lib/types/client/RemoteStatusIcon.d.ts +36 -0
- package/lib/types/client/RemoteStatusIcon.d.ts.map +1 -0
- package/lib/types/client/compat/desktop.d.ts +21 -0
- package/lib/types/client/compat/desktop.d.ts.map +1 -0
- package/lib/types/client/compat/types.d.ts +57 -0
- package/lib/types/client/compat/types.d.ts.map +1 -1
- package/lib/types/client/current-session-report.d.ts +37 -0
- package/lib/types/client/current-session-report.d.ts.map +1 -0
- package/lib/types/client/effects/header-status.d.ts.map +1 -1
- package/lib/types/client/effects/keyboard-guard.d.ts.map +1 -1
- package/lib/types/client/effects/permission-sheet.d.ts +39 -0
- package/lib/types/client/effects/permission-sheet.d.ts.map +1 -0
- package/lib/types/client/effects/remote-session.d.ts +25 -0
- package/lib/types/client/effects/remote-session.d.ts.map +1 -0
- package/lib/types/client/effects/turn-fold.d.ts +9 -0
- package/lib/types/client/effects/turn-fold.d.ts.map +1 -1
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/client/locales.d.ts +187 -2
- package/lib/types/client/locales.d.ts.map +1 -1
- package/lib/types/client/remote-fetch.d.ts +51 -0
- package/lib/types/client/remote-fetch.d.ts.map +1 -0
- package/lib/types/client/remote-group-expansion.d.ts +41 -0
- package/lib/types/client/remote-group-expansion.d.ts.map +1 -0
- package/lib/types/client/remote-share-css.d.ts +19 -0
- package/lib/types/client/remote-share-css.d.ts.map +1 -0
- package/lib/types/client/remote-share-register.d.ts +113 -0
- package/lib/types/client/remote-share-register.d.ts.map +1 -0
- package/lib/types/client/remote-status-css.d.ts +15 -0
- package/lib/types/client/remote-status-css.d.ts.map +1 -0
- package/lib/types/client/remote-status-register.d.ts +46 -0
- package/lib/types/client/remote-status-register.d.ts.map +1 -0
- package/lib/types/client/settings/SettingsSection.d.ts +46 -0
- package/lib/types/client/settings/SettingsSection.d.ts.map +1 -0
- package/lib/types/client/settings/register-settings.d.ts +58 -0
- package/lib/types/client/settings/register-settings.d.ts.map +1 -0
- package/lib/types/client/settings/settings-css.d.ts +10 -0
- package/lib/types/client/settings/settings-css.d.ts.map +1 -0
- package/lib/types/client/share/share-preview.d.ts.map +1 -1
- package/lib/types/client/styles/composer.css.d.ts +1 -1
- package/lib/types/client/styles/composer.css.d.ts.map +1 -1
- package/lib/types/client/styles/header.css.d.ts +1 -1
- package/lib/types/client/styles/header.css.d.ts.map +1 -1
- package/lib/types/client/styles/info.css.d.ts +1 -1
- package/lib/types/client/styles/info.css.d.ts.map +1 -1
- package/lib/types/client/styles/remote-session.css.d.ts +2 -0
- package/lib/types/client/styles/remote-session.css.d.ts.map +1 -0
- package/lib/types/client-data/current-session.d.ts +41 -0
- package/lib/types/client-data/current-session.d.ts.map +1 -0
- package/lib/types/client-data/remote-group-expansion.d.ts +76 -0
- package/lib/types/client-data/remote-group-expansion.d.ts.map +1 -0
- package/lib/types/client-data/remote-status.d.ts +148 -0
- package/lib/types/client-data/remote-status.d.ts.map +1 -0
- package/lib/types/client-data/settings-form.d.ts +774 -0
- package/lib/types/client-data/settings-form.d.ts.map +1 -0
- package/lib/types/client-data/shares.d.ts +211 -0
- package/lib/types/client-data/shares.d.ts.map +1 -0
- package/lib/types/client-pairing.d.ts +114 -0
- package/lib/types/client-pairing.d.ts.map +1 -0
- package/lib/types/client-routes.d.ts +112 -0
- package/lib/types/client-routes.d.ts.map +1 -0
- package/lib/types/config.d.ts +200 -0
- package/lib/types/config.d.ts.map +1 -0
- package/lib/types/fetch-route-intercept.d.ts +156 -0
- package/lib/types/fetch-route-intercept.d.ts.map +1 -0
- package/lib/types/fingerprint.d.ts +164 -0
- package/lib/types/fingerprint.d.ts.map +1 -0
- package/lib/types/http.d.ts +41 -0
- package/lib/types/http.d.ts.map +1 -0
- package/lib/types/index.d.ts +56 -24
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/intercept-shape.d.ts +32 -0
- package/lib/types/intercept-shape.d.ts.map +1 -0
- package/lib/types/intercept.d.ts +432 -0
- package/lib/types/intercept.d.ts.map +1 -0
- package/lib/types/merge-streams.d.ts +393 -0
- package/lib/types/merge-streams.d.ts.map +1 -0
- package/lib/types/relay-access.d.ts +310 -0
- package/lib/types/relay-access.d.ts.map +1 -0
- package/lib/types/relay-client.d.ts +274 -0
- package/lib/types/relay-client.d.ts.map +1 -0
- package/lib/types/relay-filter.d.ts +103 -0
- package/lib/types/relay-filter.d.ts.map +1 -0
- package/lib/types/relay-server.d.ts +208 -0
- package/lib/types/relay-server.d.ts.map +1 -0
- package/lib/types/restart-fields.d.ts +22 -0
- package/lib/types/restart-fields.d.ts.map +1 -0
- package/lib/types/restart-watcher.d.ts +83 -0
- package/lib/types/restart-watcher.d.ts.map +1 -0
- package/lib/types/session-reference.d.ts +66 -0
- package/lib/types/session-reference.d.ts.map +1 -0
- package/lib/types/share-export.d.ts +14 -1
- package/lib/types/share-export.d.ts.map +1 -1
- package/lib/types/share-ops.d.ts +79 -0
- package/lib/types/share-ops.d.ts.map +1 -0
- package/lib/types/share-store.d.ts +87 -0
- package/lib/types/share-store.d.ts.map +1 -0
- package/lib/types/virtual-id.d.ts +38 -0
- package/lib/types/virtual-id.d.ts.map +1 -0
- package/lib/virtual-id.js +52 -0
- package/lib/virtual-id.js.map +1 -0
- package/llms.txt +17 -14
- package/package.json +60 -50
- package/scripts/build-client.mjs +5 -3
- package/scripts/check-host-shadow.mjs +19 -2
- package/scripts/check-settings-form.mjs +933 -0
- package/scripts/check-shares.mjs +397 -0
- package/scripts/check-upload-endpoint.mjs +59 -3
- package/scripts/sync-doc-version.mjs +8 -15
- package/scripts/verify-fingerprint-live.mjs +111 -0
- package/src/activity.ts +218 -0
- package/src/admin-routes.ts +627 -0
- package/src/client/MobileAttachButton.tsx +2 -2
- package/src/client/MobileAttachChips.tsx +3 -3
- package/src/client/MobileDrawerFooter.tsx +7 -4
- package/src/client/MobileHome.tsx +20 -16
- package/src/client/MobileHomeChips.tsx +16 -16
- package/src/client/MobileNavOverlay.tsx +2 -2
- package/src/client/MobileNavToggle.tsx +3 -3
- package/src/client/MobileSessionHeader.tsx +63 -42
- package/src/client/MobileSessionInfo.tsx +118 -17
- package/src/client/RemoteComposerBanner.tsx +76 -0
- package/src/client/RemoteHeaderIcon.tsx +103 -0
- package/src/client/RemoteShareMenu.tsx +57 -0
- package/src/client/RemoteStatusIcon.tsx +91 -0
- package/src/client/compat/desktop.ts +22 -0
- package/src/client/compat/types.ts +62 -0
- package/src/client/current-session-report.ts +164 -0
- package/src/client/effects/header-status.ts +21 -17
- package/src/client/effects/keyboard-guard.ts +65 -1
- package/src/client/effects/permission-sheet.ts +149 -0
- package/src/client/effects/remote-session.ts +68 -0
- package/src/client/effects/turn-fold.ts +52 -0
- package/src/client/index.tsx +151 -36
- package/src/client/locales.ts +396 -2
- package/src/client/remote-fetch.ts +125 -0
- package/src/client/remote-group-expansion.ts +190 -0
- package/src/client/remote-share-css.ts +54 -0
- package/src/client/remote-share-register.ts +244 -0
- package/src/client/remote-status-css.ts +64 -0
- package/src/client/remote-status-register.ts +107 -0
- package/src/client/settings/SettingsSection.tsx +1229 -0
- package/src/client/settings/register-settings.ts +116 -0
- package/src/client/settings/settings-css.ts +236 -0
- package/src/client/share/share-preview.tsx +6 -3
- package/src/client/share/share-sheet.tsx +3 -3
- package/src/client/styles/composer.css.ts +119 -26
- package/src/client/styles/header.css.ts +7 -7
- package/src/client/styles/info.css.ts +74 -0
- package/src/client/styles/remote-session.css.ts +251 -0
- package/src/client-data/current-session.ts +57 -0
- package/src/client-data/remote-group-expansion.ts +145 -0
- package/src/client-data/remote-status.ts +374 -0
- package/src/client-data/settings-form.ts +1371 -0
- package/src/client-data/shares.ts +467 -0
- package/src/client-pairing.ts +244 -0
- package/src/client-routes.ts +732 -0
- package/src/config.ts +447 -0
- package/src/fetch-route-intercept.ts +510 -0
- package/src/fingerprint.ts +408 -0
- package/src/http.ts +68 -0
- package/src/index.ts +719 -62
- package/src/intercept-shape.ts +87 -0
- package/src/intercept.ts +3362 -0
- package/src/merge-streams.ts +1272 -0
- package/src/relay-access.ts +669 -0
- package/src/relay-client.ts +1514 -0
- package/src/relay-filter.ts +291 -0
- package/src/relay-server.ts +2025 -0
- package/src/restart-fields.ts +36 -0
- package/src/restart-watcher.ts +140 -0
- package/src/session-reference.ts +156 -0
- package/src/share-export.ts +26 -1
- package/src/share-ops.ts +114 -0
- package/src/share-store.ts +373 -0
- package/src/virtual-id.ts +59 -0
|
@@ -0,0 +1,674 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sub-client backend routes (T16): pairing claim and connection status for a
|
|
3
|
+
* DSH process running `role: 'client'` — this machine's settings page is the
|
|
4
|
+
* ONLY browser surface that talks to them, same-origin like the host's admin
|
|
5
|
+
* routes (T14). The claim route forwards the pairing code to the SERVER's
|
|
6
|
+
* gateway (`<serverUrl>/lan-gate/pair/claim-desktop`) and hands the returned
|
|
7
|
+
* token straight back to the browser, which writes it into the row's
|
|
8
|
+
* `deviceToken` secret field; this process itself never persists or logs the
|
|
9
|
+
* token — it only ever puts it into an Authorization header for the status
|
|
10
|
+
* probe. The status route reads `serverUrl` / `deviceToken` from the plugin
|
|
11
|
+
* ROW PER REQUEST (both are volatile `{ get() }`-wrapped fields, so the row
|
|
12
|
+
* object `apply()` received is the only live source) and probes the server's
|
|
13
|
+
* relay ping, reporting one of the five connection states.
|
|
14
|
+
*
|
|
15
|
+
* Same walls as the admin routes, in the same order: `admit` first (webServer
|
|
16
|
+
* routes skip DSH's /api authentication), then for the POST also the
|
|
17
|
+
* same-origin gate and a 16 KiB JSON-object body cap. Built by a factory so
|
|
18
|
+
* the tests drive it over a real socket with a mock server gateway.
|
|
19
|
+
*
|
|
20
|
+
* T41b adds the plain-HTTP relay under `client/http/<route>`: the GETs the
|
|
21
|
+
* browser fetch wrapper intercepted (`/api/changes.summary` / `changes.diff`
|
|
22
|
+
* naming a virtual session id) are re-issued here through the relay client's
|
|
23
|
+
* `http()` with the original id restored, and the upstream status,
|
|
24
|
+
* content type and body travel back verbatim.
|
|
25
|
+
*/
|
|
26
|
+
import { classifyClaimResponse, classifyProbe, CLAIM_PATH, normalizeServerUrl, RELAY_PING_PATH } from './client-pairing.js';
|
|
27
|
+
import { unwrapVolatile } from './config.js';
|
|
28
|
+
import { responseJson, sameOriginPost } from './http.js';
|
|
29
|
+
import { RELAY_HTTP_ROUTES } from './relay-access.js';
|
|
30
|
+
import { relayCredentialsDigest, RelayError } from './relay-client.js';
|
|
31
|
+
import { fromVirtual } from './virtual-id.js';
|
|
32
|
+
/** Prefix all client routes live under (one webServer prefix registration). */
|
|
33
|
+
export const CLIENT_ROUTE_PREFIX = '/_dsh/zen-remote/client';
|
|
34
|
+
/** POST `{serverUrl, code, name}`: redeem a desktop pairing code. */
|
|
35
|
+
export const CLIENT_CLAIM_ROUTE = `${CLIENT_ROUTE_PREFIX}/claim`;
|
|
36
|
+
/** GET: the current connection state (never carries the token). */
|
|
37
|
+
export const CLIENT_STATUS_ROUTE = `${CLIENT_ROUTE_PREFIX}/status`;
|
|
38
|
+
/** POST: one immediate reconnect attempt (T43, the settings page's 立即重连);
|
|
39
|
+
* answered 409 unless the relay client is currently `offline`. */
|
|
40
|
+
export const CLIENT_RECONNECT_ROUTE = `${CLIENT_ROUTE_PREFIX}/reconnect`;
|
|
41
|
+
/** GET: the remote-session status the T34 client parts render from (T34) —
|
|
42
|
+
* the relay's serving state, the interface-compatibility verdict, the
|
|
43
|
+
* handshake's server name, and the closed-session map. The prefix is
|
|
44
|
+
* registered on BOTH roles (T34-fix): a host — no relay client wired —
|
|
45
|
+
* answers the empty `{state:'unpaired', versionMismatch:false, serverName:'',
|
|
46
|
+
* closed:{}}` conclusion. It carries no token and no server address. */
|
|
47
|
+
export const CLIENT_REMOTE_STATUS_ROUTE = `${CLIENT_ROUTE_PREFIX}/remote-status`;
|
|
48
|
+
/** POST `{sessionId: <虚拟 id>}`: close one remote session from THIS machine
|
|
49
|
+
* (T34) — the backend forwards the ORIGINAL id through the relay's
|
|
50
|
+
* `POST relay/v1/unshare`, so the server closes it with reason `'client'`. */
|
|
51
|
+
export const CLIENT_UNSHARE_ROUTE = `${CLIENT_ROUTE_PREFIX}/unshare`;
|
|
52
|
+
/** POST `{sessionId: string | null}`: the browser's report of which session
|
|
53
|
+
* the host UI currently has open (T62) — the intercept layer runs in this
|
|
54
|
+
* backend process and cannot read the browser's localStorage itself. `null`
|
|
55
|
+
* means a readable signal says nothing is open; the sink stores the value
|
|
56
|
+
* and re-judges the mergers immediately. */
|
|
57
|
+
export const CLIENT_CURRENT_SESSION_ROUTE = `${CLIENT_ROUTE_PREFIX}/current-session`;
|
|
58
|
+
/** Longest `sessionId` the current-session route accepts (T62): real ids —
|
|
59
|
+
* a local `session-<uuid>` or a `zr~<serverId>~<id>` virtual id — are far
|
|
60
|
+
* shorter; the cap bounds garbage, not reality. */
|
|
61
|
+
const MAX_CURRENT_SESSION_ID = 512;
|
|
62
|
+
/** Prefix of the plain-HTTP relay routes (T41b):
|
|
63
|
+
* `GET ${CLIENT_HTTP_ROUTE_PREFIX}<route>?<query>` relays one intercepted
|
|
64
|
+
* `/api/<route>` call (the fetch wrapper's rewrites land here) to the
|
|
65
|
+
* server, with the virtual session id swapped back to the original. */
|
|
66
|
+
export const CLIENT_HTTP_ROUTE_PREFIX = `${CLIENT_ROUTE_PREFIX}/http/`;
|
|
67
|
+
/** Longest wait for one pairing round-trip to the server's gateway. */
|
|
68
|
+
const CLAIM_TIMEOUT_MS = 10_000;
|
|
69
|
+
/** Longest wait for one connection probe. */
|
|
70
|
+
const PROBE_TIMEOUT_MS = 5_000;
|
|
71
|
+
/** Inbound claim body cap — the settings form sends a few hundred bytes. */
|
|
72
|
+
const MAX_BODY_BYTES = 16 * 1024;
|
|
73
|
+
/**
|
|
74
|
+
* One refusal this module raises on purpose, carrying the status and code the
|
|
75
|
+
* browser should see.
|
|
76
|
+
*
|
|
77
|
+
* Fields are assigned in the body rather than declared as constructor
|
|
78
|
+
* parameter properties: check scripts import these modules through Node's
|
|
79
|
+
* strip-only type stripping, which rejects that syntax (same reason as
|
|
80
|
+
* UploadError in index.ts and AdminError in admin-routes.ts).
|
|
81
|
+
*/
|
|
82
|
+
class ClientRouteError extends Error {
|
|
83
|
+
status;
|
|
84
|
+
code;
|
|
85
|
+
constructor(status, code, message) {
|
|
86
|
+
super(message);
|
|
87
|
+
this.name = 'ClientRouteError';
|
|
88
|
+
this.status = status;
|
|
89
|
+
this.code = code;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Read the whole body under the cap and demand one JSON object. Same contract
|
|
94
|
+
* as the admin routes' reader: an empty body counts as `{}`, a declared
|
|
95
|
+
* oversized body length is refused before a byte is read, and the streaming
|
|
96
|
+
* cap stays as the chunked-body guard.
|
|
97
|
+
*/
|
|
98
|
+
async function readJsonBody(req) {
|
|
99
|
+
const declared = req.headers['content-length'];
|
|
100
|
+
const expected = declared === undefined ? undefined : Number(declared);
|
|
101
|
+
if (expected !== undefined && Number.isSafeInteger(expected) && expected > MAX_BODY_BYTES) {
|
|
102
|
+
throw new ClientRouteError(400, 'bad-request', `request body exceeds the ${MAX_BODY_BYTES}-byte limit`);
|
|
103
|
+
}
|
|
104
|
+
const chunks = [];
|
|
105
|
+
let size = 0;
|
|
106
|
+
for await (const chunk of req) {
|
|
107
|
+
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
108
|
+
size += bytes.length;
|
|
109
|
+
if (size > MAX_BODY_BYTES)
|
|
110
|
+
throw new ClientRouteError(400, 'bad-request', `request body exceeds the ${MAX_BODY_BYTES}-byte limit`);
|
|
111
|
+
chunks.push(bytes);
|
|
112
|
+
}
|
|
113
|
+
const text = Buffer.concat(chunks).toString('utf8');
|
|
114
|
+
if (text.trim() === '')
|
|
115
|
+
return {};
|
|
116
|
+
let parsed;
|
|
117
|
+
try {
|
|
118
|
+
parsed = JSON.parse(text);
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
throw new ClientRouteError(400, 'bad-request', 'request body must be valid JSON');
|
|
122
|
+
}
|
|
123
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
124
|
+
throw new ClientRouteError(400, 'bad-request', 'request body must be a JSON object');
|
|
125
|
+
}
|
|
126
|
+
return parsed;
|
|
127
|
+
}
|
|
128
|
+
function stringOrEmpty(value) {
|
|
129
|
+
return typeof value === 'string' ? value : '';
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Write one relayed upstream answer (T41b): the underlying `/api` route's
|
|
133
|
+
* status with its body as received, and a content type held to JSON — the
|
|
134
|
+
* two registered routes answer JSON, and a compromised server must not land
|
|
135
|
+
* a scriptable type (`text/html`) on this same-origin browser path (T41b-fix),
|
|
136
|
+
* so anything else downgrades to inert `text/plain`. `nosniff` backs the
|
|
137
|
+
* downgrade up, and the no-store discipline every route here answers with.
|
|
138
|
+
*/
|
|
139
|
+
function respondUpstream(res, result) {
|
|
140
|
+
const bytes = Buffer.from(result.body, 'utf8');
|
|
141
|
+
const mediaType = result.contentType?.split(';', 1)[0]?.trim().toLowerCase();
|
|
142
|
+
const contentType = mediaType === 'application/json' && result.contentType !== undefined ? result.contentType : 'text/plain; charset=utf-8';
|
|
143
|
+
res.setHeader('Content-Type', contentType);
|
|
144
|
+
res.setHeader('Content-Length', String(bytes.length));
|
|
145
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
146
|
+
res.setHeader('X-Content-Type-Options', 'nosniff');
|
|
147
|
+
res.writeHead(result.status);
|
|
148
|
+
res.end(bytes);
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Map one relay failure onto the plain-HTTP answer (T41b): the four
|
|
152
|
+
* link-level codes mean "the server chain is not usable" (503
|
|
153
|
+
* `remote-offline`); a refusal the relay route itself answered keeps ITS
|
|
154
|
+
* status and code (an unshared session is the relay's 403 `not-shared`,
|
|
155
|
+
* verbatim); anything else is a bug and answers a bare 502.
|
|
156
|
+
*/
|
|
157
|
+
function respondRelayFailure(res, error) {
|
|
158
|
+
if (error instanceof RelayError) {
|
|
159
|
+
if (error.code === 'offline' || error.code === 'unpaired' || error.code === 'revoked' || error.code === 'incompatible') {
|
|
160
|
+
responseJson(res, 503, { ok: false, error: { code: 'remote-offline' } });
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
if (error.status !== undefined && error.status >= 400 && error.status <= 599) {
|
|
164
|
+
responseJson(res, error.status, { ok: false, error: { code: error.code } });
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
responseJson(res, 502, { ok: false, error: { code: 'internal' } });
|
|
169
|
+
}
|
|
170
|
+
/** One probe of the server's relay ping with the row's token. Only the
|
|
171
|
+
* Authorization header ever sees the token. */
|
|
172
|
+
async function probe(fetchImpl, serverUrl, token) {
|
|
173
|
+
let response;
|
|
174
|
+
try {
|
|
175
|
+
response = await fetchImpl(serverUrl.replace(/\/+$/u, '') + RELAY_PING_PATH, {
|
|
176
|
+
method: 'GET',
|
|
177
|
+
headers: { authorization: `Bearer ${token}` },
|
|
178
|
+
// A wrong server must not walk the probe through a redirect chain.
|
|
179
|
+
redirect: 'manual',
|
|
180
|
+
signal: AbortSignal.timeout(PROBE_TIMEOUT_MS),
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
catch {
|
|
184
|
+
return classifyProbe({ kind: 'error' });
|
|
185
|
+
}
|
|
186
|
+
const text = await response.text().catch(() => undefined);
|
|
187
|
+
let body;
|
|
188
|
+
if (text !== undefined) {
|
|
189
|
+
try {
|
|
190
|
+
body = JSON.parse(text);
|
|
191
|
+
}
|
|
192
|
+
catch {
|
|
193
|
+
body = undefined;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
return classifyProbe({ kind: 'response', status: response.status, body });
|
|
197
|
+
}
|
|
198
|
+
/** Relay one classified claim outcome to the browser. The success envelope
|
|
199
|
+
* additionally echoes the NORMALIZED address — the exact string this route
|
|
200
|
+
* validated, so the browser writes back what the backend approved. */
|
|
201
|
+
function respondClaim(res, outcome, serverUrl) {
|
|
202
|
+
if (outcome.ok) {
|
|
203
|
+
responseJson(res, 200, { ...outcome, serverUrl });
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
responseJson(res, 200, outcome);
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* Map one relay failure onto the status vocabulary (T23a-fix, extended by
|
|
210
|
+
* T43): ONLY the gateway's unpaired wall is `revoked`. A server that refuses
|
|
211
|
+
* the relay prefix or speaks another protocol version answers
|
|
212
|
+
* `incompatible` — the settings page renders it with its own "upgrade both
|
|
213
|
+
* ends" copy. The relay route's own 401 `relay-unauthorized` is a
|
|
214
|
+
* server-side secret fault, and every other odd shape lands in `unexpected`
|
|
215
|
+
* — never in a verdict that would unpair a validly-paired device. Transport
|
|
216
|
+
* death is `unreachable`.
|
|
217
|
+
*/
|
|
218
|
+
function probeStateOfRelayError(error) {
|
|
219
|
+
if (error instanceof RelayError) {
|
|
220
|
+
if (error.code === 'revoked')
|
|
221
|
+
return 'revoked';
|
|
222
|
+
if (error.code === 'offline')
|
|
223
|
+
return 'unreachable';
|
|
224
|
+
if (error.code === 'unpaired')
|
|
225
|
+
return 'unpaired';
|
|
226
|
+
if (error.code === 'incompatible')
|
|
227
|
+
return 'incompatible';
|
|
228
|
+
}
|
|
229
|
+
return 'unexpected';
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* The online status body (T59): the handshake facts plus the device's own
|
|
233
|
+
* name — the SERVER's record of this device (T59), which the settings page
|
|
234
|
+
* follows into the row while the user is not mid-edit. Absent from an older
|
|
235
|
+
* relay client (no handshake field) and then the body carries it as '' —
|
|
236
|
+
* the settings page treats empty as "nothing to follow". While a
|
|
237
|
+
* locally-pushed rename is queued or in flight the name is FORCED empty
|
|
238
|
+
* (T59-fix): the answer could predate the push, and a page that followed it
|
|
239
|
+
* would bounce the user's fresh save back to the old name.
|
|
240
|
+
*/
|
|
241
|
+
function deviceStatusBody(state, info, serverUrl, deviceNameSyncing) {
|
|
242
|
+
return {
|
|
243
|
+
state,
|
|
244
|
+
serverName: info.serverName,
|
|
245
|
+
serverUrl,
|
|
246
|
+
deviceName: !deviceNameSyncing && typeof info.deviceName === 'string' ? info.deviceName : '',
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* One live handshake bounded by the probe timeout. An abandoned connect
|
|
251
|
+
* keeps running in the background — its result lands in the relay client's
|
|
252
|
+
* state and digest either way — but the status answer never waits longer
|
|
253
|
+
* than a probe would have.
|
|
254
|
+
*/
|
|
255
|
+
async function withProbeTimeout(promise) {
|
|
256
|
+
let timer;
|
|
257
|
+
try {
|
|
258
|
+
return await Promise.race([
|
|
259
|
+
promise,
|
|
260
|
+
new Promise((_, reject) => {
|
|
261
|
+
timer = setTimeout(() => reject(new RelayError('offline', `no handshake within ${PROBE_TIMEOUT_MS} ms`)), PROBE_TIMEOUT_MS);
|
|
262
|
+
if (typeof timer.unref === 'function')
|
|
263
|
+
timer.unref();
|
|
264
|
+
}),
|
|
265
|
+
]);
|
|
266
|
+
}
|
|
267
|
+
finally {
|
|
268
|
+
if (timer !== undefined)
|
|
269
|
+
clearTimeout(timer);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
/**
|
|
273
|
+
* The relay client's live state in the `remote-status` vocabulary (T34):
|
|
274
|
+
* `connecting` and `incompatible` report as `offline` — both are "the link
|
|
275
|
+
* is not serving, recovery pending" as far as a remote session is concerned.
|
|
276
|
+
* No relay client wired (a host role) is `unpaired`, the honest "no
|
|
277
|
+
* connection configured".
|
|
278
|
+
*/
|
|
279
|
+
function remoteStatusStateOf(relay) {
|
|
280
|
+
if (relay === undefined)
|
|
281
|
+
return 'unpaired';
|
|
282
|
+
switch (relay.state) {
|
|
283
|
+
case 'online':
|
|
284
|
+
return 'online';
|
|
285
|
+
case 'revoked':
|
|
286
|
+
return 'revoked';
|
|
287
|
+
case 'unpaired':
|
|
288
|
+
return 'unpaired';
|
|
289
|
+
default:
|
|
290
|
+
return 'offline';
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
/**
|
|
294
|
+
* Build the client route handler for one plugin row. The returned handler
|
|
295
|
+
* owns the full response lifecycle of every request under
|
|
296
|
+
* {@link CLIENT_ROUTE_PREFIX} and never throws.
|
|
297
|
+
*/
|
|
298
|
+
export function createClientHandler(options) {
|
|
299
|
+
const fetchImpl = options.fetchImpl ?? ((url, init) => fetch(url, init));
|
|
300
|
+
return async (req, res) => {
|
|
301
|
+
// Wall 1: DSH's own admission, relaying its refusal verbatim.
|
|
302
|
+
const admission = options.admit(req);
|
|
303
|
+
if ('rejection' in admission) {
|
|
304
|
+
responseJson(res, admission.rejection, {
|
|
305
|
+
ok: false,
|
|
306
|
+
error: { code: admission.rejection === 401 ? 'unauthorized' : 'forbidden' },
|
|
307
|
+
});
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
const route = new URL(req.url ?? '/', 'http://dsh.internal').pathname;
|
|
311
|
+
const method = req.method ?? 'GET';
|
|
312
|
+
try {
|
|
313
|
+
// The host mount's least-exposure wall (T41a-fix2): only remote-status
|
|
314
|
+
// exists here, and it sits BEHIND the admission wall like every other
|
|
315
|
+
// route. The shape of the refusal matches the unknown-path fallthrough.
|
|
316
|
+
if (options.remoteStatusOnly === true && route !== CLIENT_REMOTE_STATUS_ROUTE) {
|
|
317
|
+
responseJson(res, 404, { ok: false, error: { code: 'not-found', message: 'Unknown client route' } });
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
if (route === CLIENT_CLAIM_ROUTE) {
|
|
321
|
+
if (method !== 'POST') {
|
|
322
|
+
res.setHeader('Allow', 'POST');
|
|
323
|
+
responseJson(res, 405, { ok: false, error: { code: 'method-not-allowed', message: 'Use POST' } });
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
if (!sameOriginPost(req)) {
|
|
327
|
+
responseJson(res, 403, {
|
|
328
|
+
ok: false,
|
|
329
|
+
error: { code: 'origin-rejected', message: 'The request must originate from this DSH Web application' },
|
|
330
|
+
});
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
const body = await readJsonBody(req);
|
|
334
|
+
const normalized = normalizeServerUrl(stringOrEmpty(body.serverUrl));
|
|
335
|
+
if (!normalized.ok) {
|
|
336
|
+
responseJson(res, 400, { ok: false, code: normalized.reason });
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
const payload = JSON.stringify({
|
|
340
|
+
code: stringOrEmpty(body.code),
|
|
341
|
+
name: stringOrEmpty(body.name),
|
|
342
|
+
});
|
|
343
|
+
let status;
|
|
344
|
+
let gatewayBody;
|
|
345
|
+
try {
|
|
346
|
+
// No hand-set body-length (or host/connection/transfer-encoding)
|
|
347
|
+
// header here: DSH swaps the global fetch's dispatcher for its
|
|
348
|
+
// bundled undici 8.x, which refuses any fetch that carries one
|
|
349
|
+
// (UND_ERR_INVALID_ARG → the whole pairing round-trip answers
|
|
350
|
+
// "unreachable"), while the dispatcher computes the length itself
|
|
351
|
+
// from the string body.
|
|
352
|
+
const response = await fetchImpl(new URL(CLAIM_PATH, normalized.url), {
|
|
353
|
+
method: 'POST',
|
|
354
|
+
headers: { 'content-type': 'application/json; charset=utf-8' },
|
|
355
|
+
body: payload,
|
|
356
|
+
// A wrong server must not walk the pairing round-trip through a
|
|
357
|
+
// redirect chain (T16-fix): any 3xx lands in the classifier's
|
|
358
|
+
// `unexpected` bucket instead of being followed.
|
|
359
|
+
redirect: 'manual',
|
|
360
|
+
signal: AbortSignal.timeout(CLAIM_TIMEOUT_MS),
|
|
361
|
+
});
|
|
362
|
+
status = response.status;
|
|
363
|
+
const text = await response.text();
|
|
364
|
+
try {
|
|
365
|
+
gatewayBody = JSON.parse(text);
|
|
366
|
+
}
|
|
367
|
+
catch {
|
|
368
|
+
gatewayBody = undefined;
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
catch {
|
|
372
|
+
responseJson(res, 502, { ok: false, code: 'unreachable' });
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
respondClaim(res, classifyClaimResponse(status, gatewayBody), normalized.url);
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
if (route === CLIENT_STATUS_ROUTE) {
|
|
379
|
+
if (method !== 'GET') {
|
|
380
|
+
res.setHeader('Allow', 'GET');
|
|
381
|
+
responseJson(res, 405, { ok: false, error: { code: 'method-not-allowed', message: 'Use GET' } });
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
// The typert interception's view (T23b-1) rides on EVERY status
|
|
385
|
+
// answer, whatever the connection state — the settings surface needs
|
|
386
|
+
// it to explain a refused install even while unpaired. The interface
|
|
387
|
+
// compat verdict (T42) appears once there is something to report: a
|
|
388
|
+
// stored comparison from the relay client's last handshake, or any
|
|
389
|
+
// incompatible call the interceptor recorded. Absent otherwise —
|
|
390
|
+
// nothing to show, and the settings block presence-gates on exactly
|
|
391
|
+
// that.
|
|
392
|
+
const intercept = options.getIntercept?.();
|
|
393
|
+
const fetchRouteIntercept = options.getFetchRouteIntercept?.();
|
|
394
|
+
const compatRelay = options.getRelayClient?.();
|
|
395
|
+
const compat = compatRelay?.compat !== undefined || (intercept?.incompatibleCalls.length ?? 0) > 0
|
|
396
|
+
? {
|
|
397
|
+
identical: compatRelay?.compat?.identical ?? [],
|
|
398
|
+
different: compatRelay?.compat?.different ?? [],
|
|
399
|
+
unavailable: compatRelay?.compat?.unavailable ?? [],
|
|
400
|
+
incompatibleCalls: intercept?.incompatibleCalls ?? [],
|
|
401
|
+
}
|
|
402
|
+
: undefined;
|
|
403
|
+
const withDiagnostics = (body) => {
|
|
404
|
+
let next = intercept === undefined ? body : { ...body, intercept };
|
|
405
|
+
if (fetchRouteIntercept !== undefined)
|
|
406
|
+
next = { ...next, fetchRouteIntercept };
|
|
407
|
+
return compat === undefined ? next : { ...next, compat };
|
|
408
|
+
};
|
|
409
|
+
// The ROW decides its own two shapes first, per request (both fields
|
|
410
|
+
// are volatile { get() } wrappers — only the row object apply()
|
|
411
|
+
// received is a live source): without both credentials nothing is
|
|
412
|
+
// paired, and a stored address that no longer normalizes must never
|
|
413
|
+
// see the token (T16-fix).
|
|
414
|
+
const row = options.getRowConfig();
|
|
415
|
+
const record = row !== null && typeof row === 'object' && !Array.isArray(row) ? row : {};
|
|
416
|
+
const serverUrl = stringOrEmpty(unwrapVolatile(record.serverUrl)).trim();
|
|
417
|
+
const token = stringOrEmpty(unwrapVolatile(record.deviceToken));
|
|
418
|
+
if (token === '' || serverUrl === '') {
|
|
419
|
+
responseJson(res, 200, withDiagnostics({ state: 'unpaired' }));
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
const normalized = normalizeServerUrl(serverUrl);
|
|
423
|
+
if (!normalized.ok) {
|
|
424
|
+
responseJson(res, 200, withDiagnostics({ state: 'invalid-url' }));
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
const relay = options.getRelayClient?.();
|
|
428
|
+
if (relay === undefined) {
|
|
429
|
+
// T16 shape: no relay client wired — the ping probe is the answer.
|
|
430
|
+
const state = await probe(fetchImpl, normalized.url, token);
|
|
431
|
+
responseJson(res, 200, withDiagnostics({ state, serverUrl: normalized.url }));
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
434
|
+
// The cached verdict is trusted only over the EXACT credentials the
|
|
435
|
+
// handshake earned it with: an online state whose digest no longer
|
|
436
|
+
// matches the row (address edited, re-pair) is an answer about a
|
|
437
|
+
// DIFFERENT server and falls through to a live connect instead
|
|
438
|
+
// (T23a-fix). The token never enters this body — the digest is a
|
|
439
|
+
// one-way sha256 prefix computed on both sides.
|
|
440
|
+
if (relay.state === 'online' &&
|
|
441
|
+
relay.handshakeInfo !== undefined &&
|
|
442
|
+
relay.lastHandshakeDigest !== undefined &&
|
|
443
|
+
relay.lastHandshakeDigest === relayCredentialsDigest(normalized.url, token)) {
|
|
444
|
+
responseJson(res, 200, withDiagnostics(deviceStatusBody(relay.state, relay.handshakeInfo, normalized.url, relay.deviceNameSyncing)));
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
447
|
+
// Everything else — never connected, offline, connecting, revoked,
|
|
448
|
+
// stale credentials — gets ONE live connect bounded by the probe
|
|
449
|
+
// timeout; its FRESH result is the answer, never the possibly stale
|
|
450
|
+
// cached state. A failed attempt additionally carries the relay
|
|
451
|
+
// client's reconnect machinery readout (T43): when the next
|
|
452
|
+
// automatic retry fires and which code failed last — both free of
|
|
453
|
+
// credential material by contract.
|
|
454
|
+
try {
|
|
455
|
+
const info = await withProbeTimeout(relay.connect());
|
|
456
|
+
responseJson(res, 200, withDiagnostics(deviceStatusBody('online', info, normalized.url, relay.deviceNameSyncing)));
|
|
457
|
+
}
|
|
458
|
+
catch (error) {
|
|
459
|
+
const body = { state: probeStateOfRelayError(error), serverUrl: normalized.url };
|
|
460
|
+
if (relay.nextRetryAt !== null && relay.nextRetryAt !== undefined)
|
|
461
|
+
body.nextRetryAt = relay.nextRetryAt;
|
|
462
|
+
if (relay.lastError !== undefined)
|
|
463
|
+
body.lastError = relay.lastError;
|
|
464
|
+
responseJson(res, 200, withDiagnostics(body));
|
|
465
|
+
}
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
468
|
+
if (route === CLIENT_RECONNECT_ROUTE) {
|
|
469
|
+
if (method !== 'POST') {
|
|
470
|
+
res.setHeader('Allow', 'POST');
|
|
471
|
+
responseJson(res, 405, { ok: false, error: { code: 'method-not-allowed', message: 'Use POST' } });
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
474
|
+
if (!sameOriginPost(req)) {
|
|
475
|
+
responseJson(res, 403, {
|
|
476
|
+
ok: false,
|
|
477
|
+
error: { code: 'origin-rejected', message: 'The request must originate from this DSH Web application' },
|
|
478
|
+
});
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
481
|
+
const relay = options.getRelayClient?.();
|
|
482
|
+
// No relay client (a composition that never built one): there is
|
|
483
|
+
// nothing to reconnect, and the probe-shaped status route is the
|
|
484
|
+
// only connection surface this deployment has.
|
|
485
|
+
if (relay === undefined) {
|
|
486
|
+
responseJson(res, 503, { ok: false, error: { code: 'unavailable', message: 'No relay client is running' } });
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
489
|
+
// The button is only clickable while offline; anything else is a
|
|
490
|
+
// conflict with the state the page just rendered.
|
|
491
|
+
if (relay.state !== 'offline' || !relay.reconnect()) {
|
|
492
|
+
responseJson(res, 409, { ok: false, error: { code: 'not-offline', message: 'The client is not offline' } });
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
495
|
+
// Fired, not awaited: the attempt runs on the relay client (bounded
|
|
496
|
+
// by its own request timeout), the state lands there either way, and
|
|
497
|
+
// the page's status refresh reports the outcome.
|
|
498
|
+
responseJson(res, 200, { ok: true });
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
501
|
+
if (route === CLIENT_REMOTE_STATUS_ROUTE) {
|
|
502
|
+
if (method !== 'GET') {
|
|
503
|
+
res.setHeader('Allow', 'GET');
|
|
504
|
+
responseJson(res, 405, { ok: false, error: { code: 'method-not-allowed', message: 'Use GET' } });
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
507
|
+
// T34: the T34 client parts' data source — no token, no server
|
|
508
|
+
// address, nothing but the serving state, the compat verdict's
|
|
509
|
+
// difference flag, the display name and the closed-session map.
|
|
510
|
+
const relay = options.getRelayClient?.();
|
|
511
|
+
const intercept = options.getIntercept?.();
|
|
512
|
+
responseJson(res, 200, {
|
|
513
|
+
state: remoteStatusStateOf(relay),
|
|
514
|
+
versionMismatch: (relay?.compat?.different?.length ?? 0) > 0,
|
|
515
|
+
serverName: relay?.handshakeInfo?.serverName ?? '',
|
|
516
|
+
closed: Object.fromEntries((intercept?.closedSessions ?? []).map((record) => [record.sessionId, record.reason])),
|
|
517
|
+
});
|
|
518
|
+
return;
|
|
519
|
+
}
|
|
520
|
+
if (route === CLIENT_CURRENT_SESSION_ROUTE) {
|
|
521
|
+
// No sink wired: a client mount without the intercept half — same
|
|
522
|
+
// answer an unknown path gets (the host mount never reaches here,
|
|
523
|
+
// its remoteStatusOnly wall 404s first).
|
|
524
|
+
const sink = options.onCurrentSession;
|
|
525
|
+
if (sink === undefined) {
|
|
526
|
+
responseJson(res, 404, { ok: false, error: { code: 'not-found', message: 'Unknown client route' } });
|
|
527
|
+
return;
|
|
528
|
+
}
|
|
529
|
+
if (method !== 'POST') {
|
|
530
|
+
res.setHeader('Allow', 'POST');
|
|
531
|
+
responseJson(res, 405, { ok: false, error: { code: 'method-not-allowed', message: 'Use POST' } });
|
|
532
|
+
return;
|
|
533
|
+
}
|
|
534
|
+
if (!sameOriginPost(req)) {
|
|
535
|
+
responseJson(res, 403, {
|
|
536
|
+
ok: false,
|
|
537
|
+
error: { code: 'origin-rejected', message: 'The request must originate from this DSH Web application' },
|
|
538
|
+
});
|
|
539
|
+
return;
|
|
540
|
+
}
|
|
541
|
+
const body = await readJsonBody(req);
|
|
542
|
+
// Exactly `sessionId`: a non-empty string (length-capped) or null —
|
|
543
|
+
// null is a REAL "nothing is open" answer; anything else (absent,
|
|
544
|
+
// wrong type, blank, over-long) is a malformed report, refused.
|
|
545
|
+
const raw = body.sessionId;
|
|
546
|
+
if (raw !== null && (typeof raw !== 'string' || raw === '' || raw.length > MAX_CURRENT_SESSION_ID)) {
|
|
547
|
+
responseJson(res, 400, { ok: false, error: { code: 'bad-request', message: 'sessionId must be a non-empty string of at most 512 characters, or null' } });
|
|
548
|
+
return;
|
|
549
|
+
}
|
|
550
|
+
const read = raw === null ? { kind: 'none' } : { kind: 'open', sessionId: raw };
|
|
551
|
+
sink(read);
|
|
552
|
+
responseJson(res, 200, { ok: true });
|
|
553
|
+
return;
|
|
554
|
+
}
|
|
555
|
+
if (route === CLIENT_UNSHARE_ROUTE) {
|
|
556
|
+
if (method !== 'POST') {
|
|
557
|
+
res.setHeader('Allow', 'POST');
|
|
558
|
+
responseJson(res, 405, { ok: false, error: { code: 'method-not-allowed', message: 'Use POST' } });
|
|
559
|
+
return;
|
|
560
|
+
}
|
|
561
|
+
if (!sameOriginPost(req)) {
|
|
562
|
+
responseJson(res, 403, {
|
|
563
|
+
ok: false,
|
|
564
|
+
error: { code: 'origin-rejected', message: 'The request must originate from this DSH Web application' },
|
|
565
|
+
});
|
|
566
|
+
return;
|
|
567
|
+
}
|
|
568
|
+
const body = await readJsonBody(req);
|
|
569
|
+
// Only a VIRTUAL id is a remote session of this deployment — a local
|
|
570
|
+
// id here is a caller bug, refused before anything travels.
|
|
571
|
+
const parts = fromVirtual(body.sessionId);
|
|
572
|
+
if (parts === undefined) {
|
|
573
|
+
responseJson(res, 400, { ok: false, error: { code: 'not-virtual', message: 'sessionId must be a remote (virtual) session id' } });
|
|
574
|
+
return;
|
|
575
|
+
}
|
|
576
|
+
const relay = options.getRelayClient?.();
|
|
577
|
+
if (relay === undefined) {
|
|
578
|
+
responseJson(res, 503, { ok: false, error: { code: 'unavailable', message: 'No relay client is running' } });
|
|
579
|
+
return;
|
|
580
|
+
}
|
|
581
|
+
// A session of a DIFFERENT server than the current handshake would
|
|
582
|
+
// just answer not-shared out there — the mismatch is answerable
|
|
583
|
+
// locally, with the code the interceptor uses for the same fact.
|
|
584
|
+
if (relay.handshakeInfo === undefined || relay.handshakeInfo.serverId !== parts.serverId) {
|
|
585
|
+
responseJson(res, 200, { ok: false, error: { code: 'remote-mismatch', message: '此远程会话属于其他主服务端' } });
|
|
586
|
+
return;
|
|
587
|
+
}
|
|
588
|
+
try {
|
|
589
|
+
await relay.unshare(parts.id);
|
|
590
|
+
responseJson(res, 200, { ok: true });
|
|
591
|
+
}
|
|
592
|
+
catch (error) {
|
|
593
|
+
// The relay's refusal travels with its code — the icon's alert
|
|
594
|
+
// keys the short copy on it.
|
|
595
|
+
const code = error instanceof RelayError ? error.code : 'internal';
|
|
596
|
+
responseJson(res, 200, { ok: false, error: { code, message: error instanceof Error ? error.message : String(error) } });
|
|
597
|
+
}
|
|
598
|
+
return;
|
|
599
|
+
}
|
|
600
|
+
if (route.startsWith(CLIENT_HTTP_ROUTE_PREFIX)) {
|
|
601
|
+
// The plain-HTTP relay (T41b): the browser fetch wrapper sends the
|
|
602
|
+
// `/api/changes.*` calls it intercepted here. Same first wall as
|
|
603
|
+
// every client route (admit, above), then the four checks the
|
|
604
|
+
// interceptor's own routes apply — registered route, exactly one
|
|
605
|
+
// virtual session id, the relay online, and THAT id belonging to the
|
|
606
|
+
// connected server — before the query travels with the original id
|
|
607
|
+
// restored.
|
|
608
|
+
if (method !== 'GET') {
|
|
609
|
+
res.setHeader('Allow', 'GET');
|
|
610
|
+
responseJson(res, 405, { ok: false, error: { code: 'method-not-allowed', message: 'Use GET' } });
|
|
611
|
+
return;
|
|
612
|
+
}
|
|
613
|
+
const httpRoute = route.slice(CLIENT_HTTP_ROUTE_PREFIX.length);
|
|
614
|
+
// Registry lookup by hasOwnProperty, never a bare index: `route` is
|
|
615
|
+
// a wire string, and `constructor` must not resolve through the
|
|
616
|
+
// object prototype.
|
|
617
|
+
if (httpRoute === '' || !Object.prototype.hasOwnProperty.call(RELAY_HTTP_ROUTES, httpRoute)) {
|
|
618
|
+
responseJson(res, 404, { ok: false, error: { code: 'unknown-route' } });
|
|
619
|
+
return;
|
|
620
|
+
}
|
|
621
|
+
const url = new URL(req.url ?? '/', 'http://dsh.internal');
|
|
622
|
+
const sessionIds = url.searchParams.getAll('sessionId');
|
|
623
|
+
const virtualId = sessionIds.length === 1 ? sessionIds[0] : undefined;
|
|
624
|
+
if (virtualId === undefined || virtualId === '') {
|
|
625
|
+
responseJson(res, 400, { ok: false, error: { code: 'bad-request', message: 'sessionId is required exactly once' } });
|
|
626
|
+
return;
|
|
627
|
+
}
|
|
628
|
+
const parts = fromVirtual(virtualId);
|
|
629
|
+
if (parts === undefined) {
|
|
630
|
+
responseJson(res, 400, { ok: false, error: { code: 'not-virtual', message: 'sessionId is not a remote session id' } });
|
|
631
|
+
return;
|
|
632
|
+
}
|
|
633
|
+
const relay = options.getRelayClient?.();
|
|
634
|
+
if (relay === undefined || relay.state !== 'online') {
|
|
635
|
+
responseJson(res, 503, { ok: false, error: { code: 'remote-offline' } });
|
|
636
|
+
return;
|
|
637
|
+
}
|
|
638
|
+
if (relay.handshakeInfo?.serverId !== parts.serverId) {
|
|
639
|
+
responseJson(res, 400, { ok: false, error: { code: 'remote-mismatch', message: '此远程会话属于其他主服务端' } });
|
|
640
|
+
return;
|
|
641
|
+
}
|
|
642
|
+
url.searchParams.set('sessionId', parts.id);
|
|
643
|
+
// A browser walk-off cancels the round-trip mid-flight; the write
|
|
644
|
+
// guard in the catch keeps a settled answer from racing the close.
|
|
645
|
+
const hangUp = new AbortController();
|
|
646
|
+
res.once('close', () => {
|
|
647
|
+
if (!res.writableEnded)
|
|
648
|
+
hangUp.abort();
|
|
649
|
+
});
|
|
650
|
+
try {
|
|
651
|
+
const result = await relay.http(httpRoute, url.searchParams.toString(), hangUp.signal);
|
|
652
|
+
respondUpstream(res, result);
|
|
653
|
+
}
|
|
654
|
+
catch (error) {
|
|
655
|
+
if (!res.writableEnded && !res.destroyed)
|
|
656
|
+
respondRelayFailure(res, error);
|
|
657
|
+
}
|
|
658
|
+
return;
|
|
659
|
+
}
|
|
660
|
+
responseJson(res, 404, { ok: false, error: { code: 'not-found', message: 'Unknown client route' } });
|
|
661
|
+
}
|
|
662
|
+
catch (error) {
|
|
663
|
+
// Only the route's OWN refusals carry a message; anything else is a bug
|
|
664
|
+
// and answers a bare 500 — internal error text (absolute paths, stack
|
|
665
|
+
// fragments) never reaches the browser (T16-fix).
|
|
666
|
+
if (error instanceof ClientRouteError) {
|
|
667
|
+
responseJson(res, error.status, { ok: false, error: { code: error.code, message: error.message } });
|
|
668
|
+
return;
|
|
669
|
+
}
|
|
670
|
+
responseJson(res, 500, { ok: false, error: { code: 'internal' } });
|
|
671
|
+
}
|
|
672
|
+
};
|
|
673
|
+
}
|
|
674
|
+
//# sourceMappingURL=client-routes.js.map
|