@deepseek-ai/dsh-client-connection 0.2.1-alpha.1 → 0.2.1-alpha.3
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.i18n.yaml +8 -8
- package/README.md +14 -7
- package/README.zh.md +14 -7
- package/lib/{client.js → client.browser.js} +22 -2
- package/lib/client.node.js +624 -0
- package/lib/index.js +296 -74
- package/lib/types/api-request-trust.d.ts +16 -9
- package/lib/types/browser-auth.d.ts +25 -2
- package/lib/types/client/index.d.ts +19 -3
- package/lib/types/client/socket.d.ts +50 -0
- package/lib/types/connect-protocol.d.ts +6 -0
- package/lib/types/http-bridge.d.ts +3 -1
- package/lib/types/index.d.ts +9 -9
- package/lib/types/peer.d.ts +36 -0
- package/lib/types/rpc-host.d.ts +29 -7
- package/lib/types/rpc.d.ts +47 -11
- package/locale/en.json +5 -0
- package/locale/zh.json +6 -0
- package/package.json +22 -17
- package/lib/types/operator-peer.d.ts +0 -23
package/lib/index.js
CHANGED
|
@@ -13,6 +13,11 @@ import { createScope } from "@deepseek-ai/dsh-scope";
|
|
|
13
13
|
/** Route prefix owning every api request (`/api` and `/api/<anything>`). */
|
|
14
14
|
const API_PATH = "/api";
|
|
15
15
|
//#endregion
|
|
16
|
+
//#region lib/types/connect-protocol.js
|
|
17
|
+
/** API connection inputs shared by the Host and its clients. */
|
|
18
|
+
/** Launch-token exchange route for API clients. */
|
|
19
|
+
const CONNECT_PATH = "/connect";
|
|
20
|
+
//#endregion
|
|
16
21
|
//#region lib/types/http-bridge.js
|
|
17
22
|
/**
|
|
18
23
|
* node:http ↔ WHATWG fetch bridge for the /api transport (host side of the
|
|
@@ -29,9 +34,10 @@ const DEFAULT_MAX_REQUEST_BODY_BYTES = 300 * 1024 * 1024;
|
|
|
29
34
|
* @param req - incoming node:http request.
|
|
30
35
|
* @param res - node:http response the bridge writes and owns to completion.
|
|
31
36
|
* @param apiHandler - fetch-shaped API carrier the request is dispatched to.
|
|
37
|
+
* @param peer - Peer the carrier admitted the request as.
|
|
32
38
|
* @param maxRequestBodyBytes - maximum bytes buffered for a buffered route.
|
|
33
39
|
*/
|
|
34
|
-
async function bridge(req, res, apiHandler, maxRequestBodyBytes = DEFAULT_MAX_REQUEST_BODY_BYTES) {
|
|
40
|
+
async function bridge(req, res, apiHandler, peer, maxRequestBodyBytes = DEFAULT_MAX_REQUEST_BODY_BYTES) {
|
|
35
41
|
const abort = new AbortController();
|
|
36
42
|
res.on("close", () => {
|
|
37
43
|
if (!res.writableEnded) abort.abort();
|
|
@@ -79,7 +85,7 @@ async function bridge(req, res, apiHandler, maxRequestBodyBytes = DEFAULT_MAX_RE
|
|
|
79
85
|
signal: abort.signal,
|
|
80
86
|
duplex: "half"
|
|
81
87
|
});
|
|
82
|
-
const response = await apiHandler.fetch(request);
|
|
88
|
+
const response = await apiHandler.fetch(request, peer);
|
|
83
89
|
const requestUnread = bodyMode === "streaming" && !req.readableEnded;
|
|
84
90
|
const responseHeaders = Object.fromEntries(response.headers.entries());
|
|
85
91
|
res.writeHead(response.status, requestUnread ? {
|
|
@@ -131,23 +137,27 @@ function isLoopbackHostname(hostname) {
|
|
|
131
137
|
* the attacker's domain while the socket reaches this server) and cross-site
|
|
132
138
|
* requests fired from a malicious page. The Host fence binds every request,
|
|
133
139
|
* browser-looking or not: over plain HTTP a browser attaches neither Origin
|
|
134
|
-
* nor Fetch-Metadata to reads (images and navigations — those
|
|
135
|
-
*
|
|
136
|
-
*
|
|
137
|
-
*
|
|
138
|
-
*
|
|
139
|
-
*
|
|
140
|
-
* belongs to the webserver config, and this fence is not an auth layer.
|
|
140
|
+
* nor Fetch-Metadata to reads (images and navigations — those headers go only
|
|
141
|
+
* to trustworthy destinations), so an unmarked request may still be a rebound
|
|
142
|
+
* browser read and Host is the one header rebinding cannot forge. Non-browser
|
|
143
|
+
* and remote clients pass the same fence via loopback, the listener's bind IP
|
|
144
|
+
* literal, or a declared `trustedHosts` authority. Binding policy belongs to
|
|
145
|
+
* the webserver config; this fence never establishes identity.
|
|
141
146
|
*/
|
|
142
147
|
function header$1(headers, name) {
|
|
143
148
|
if (headers instanceof Headers) return headers.get(name) ?? void 0;
|
|
144
149
|
const value = headers[name];
|
|
145
150
|
return typeof value === "string" ? value : void 0;
|
|
146
151
|
}
|
|
147
|
-
/**
|
|
148
|
-
|
|
152
|
+
/**
|
|
153
|
+
* Normalized URL of a bare authority (hostname lowercased, default port
|
|
154
|
+
* stripped, IPv6 bracketed), or undefined when unparsable.
|
|
155
|
+
* @param authority - `host` or `host:port` text.
|
|
156
|
+
* @param scheme - special scheme to parse under; its default port is the one stripped.
|
|
157
|
+
*/
|
|
158
|
+
function parseAuthority(authority, scheme = "http:") {
|
|
149
159
|
try {
|
|
150
|
-
return new URL(
|
|
160
|
+
return new URL(`${scheme}//${authority}`);
|
|
151
161
|
} catch {
|
|
152
162
|
return;
|
|
153
163
|
}
|
|
@@ -179,43 +189,83 @@ function assertTrustedAuthority(entry) {
|
|
|
179
189
|
* shapes like `host:port ` as port-less.
|
|
180
190
|
*/
|
|
181
191
|
function canonicalAuthority(entry, entryUrl) {
|
|
182
|
-
const port = entryUrl.port !== "" ? entryUrl.port : new URL(
|
|
192
|
+
const port = entryUrl.port !== "" ? entryUrl.port : new URL(`${entryUrl.protocol === "http:" ? "https:" : "http:"}//${entry}`).port;
|
|
183
193
|
return port === "" ? entryUrl.hostname : `${entryUrl.hostname}:${port}`;
|
|
184
194
|
}
|
|
185
195
|
/**
|
|
186
196
|
* Whether the request authority matches a `trustedHosts` entry. An entry with
|
|
187
197
|
* an explicit port matches that exact authority; a port-less entry matches the
|
|
188
|
-
* hostname on any port
|
|
189
|
-
*
|
|
190
|
-
* normalization, so case and a redundant `:80` never decide trust.
|
|
198
|
+
* hostname on any port. Both sides compare through WHATWG normalization,
|
|
199
|
+
* so case and a redundant scheme-default port never decide trust.
|
|
191
200
|
*/
|
|
192
|
-
function isTrustedAuthority(hostUrl, trustedHosts) {
|
|
201
|
+
function isTrustedAuthority(hostUrl, trustedHosts, protocol) {
|
|
193
202
|
return trustedHosts.some((entry) => {
|
|
194
|
-
const entryUrl = parseAuthority(entry);
|
|
203
|
+
const entryUrl = parseAuthority(entry, protocol);
|
|
195
204
|
if (entryUrl === void 0) return false;
|
|
196
205
|
return canonicalAuthority(entry, entryUrl) === entryUrl.hostname ? entryUrl.hostname === hostUrl.hostname : entryUrl.host === hostUrl.host;
|
|
197
206
|
});
|
|
198
207
|
}
|
|
199
208
|
/**
|
|
209
|
+
* Classify an authority already accepted by {@link assertTrustedAuthority}.
|
|
210
|
+
* @param entry - the configured authority, verbatim.
|
|
211
|
+
* @returns true unless the entry parses as a loopback hostname.
|
|
212
|
+
*/
|
|
213
|
+
function isRemoteAuthority(entry) {
|
|
214
|
+
const url = parseAuthority(entry);
|
|
215
|
+
return url === void 0 || !isLoopbackHostname(url.hostname);
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* Whether a parsed authority names the deployment's own bind address. A browser
|
|
219
|
+
* dialing that literal sends it as Host, and accepting it grants nothing
|
|
220
|
+
* further: the literal is no rebinding target, the Origin and cross-site checks
|
|
221
|
+
* still apply, and it stays outside `trustedHosts`. Matching ignores the port,
|
|
222
|
+
* like a port-less entry, and any `%zone` id (an interface selector, not
|
|
223
|
+
* address text).
|
|
224
|
+
*/
|
|
225
|
+
function isBindAddressAuthority(hostUrl, bindHost) {
|
|
226
|
+
if (bindHost === void 0) return false;
|
|
227
|
+
const zoneAt = bindHost.indexOf("%");
|
|
228
|
+
const address = zoneAt === -1 ? bindHost : bindHost.slice(0, zoneAt);
|
|
229
|
+
const bindUrl = parseAuthority(address.includes(":") ? `[${address}]` : address);
|
|
230
|
+
return bindUrl !== void 0 && bindUrl.hostname === hostUrl.hostname;
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
200
233
|
* Decide whether one /api request may reach the RPC bridge.
|
|
201
234
|
* @param request - Node HTTP or Fetch request facts (headers).
|
|
202
235
|
* @param trustedHosts - non-loopback authorities this deployment serves: exact `host:port`, or port-less `host` matching any port.
|
|
203
|
-
* @
|
|
236
|
+
* @param bindHost - the listener's own bind IP literal, accepted on any port independently of `trustedHosts`.
|
|
237
|
+
* @param protocol - listener protocol for default Host ports; HTTP when no Web carrier is mounted.
|
|
238
|
+
* @returns true when the Host is ours (loopback, the bind address, or trusted) and any attached browser markers are same-origin.
|
|
204
239
|
*/
|
|
205
|
-
function isTrustedApiRequest(request, trustedHosts) {
|
|
240
|
+
function isTrustedApiRequest(request, trustedHosts, bindHost, protocol = "http:") {
|
|
206
241
|
const host = header$1(request.headers, "host");
|
|
207
242
|
if (host === void 0) return false;
|
|
208
|
-
const hostUrl = parseAuthority(host);
|
|
243
|
+
const hostUrl = parseAuthority(host, protocol);
|
|
209
244
|
if (hostUrl === void 0) return false;
|
|
210
|
-
if (!isLoopbackHostname(hostUrl.hostname) && !isTrustedAuthority(hostUrl, trustedHosts)) return false;
|
|
245
|
+
if (!isLoopbackHostname(hostUrl.hostname) && !isTrustedAuthority(hostUrl, trustedHosts, protocol) && !isBindAddressAuthority(hostUrl, bindHost)) return false;
|
|
211
246
|
if (header$1(request.headers, "sec-fetch-site") === "cross-site") return false;
|
|
212
247
|
const origin = header$1(request.headers, "origin");
|
|
213
248
|
if (origin === void 0) return true;
|
|
249
|
+
return isSameAuthorityOrigin(origin, host);
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* Compare Host under the Origin's HTTP(S) scheme so default ports normalize
|
|
253
|
+
* consistently. The transport may remain HTTP behind an HTTPS proxy.
|
|
254
|
+
* @param origin - verbatim Origin header value.
|
|
255
|
+
* @param host - verbatim Host header value already accepted by the Host fence.
|
|
256
|
+
* @returns true only when the Origin is this listener's own `http(s)` authority.
|
|
257
|
+
*/
|
|
258
|
+
function isSameAuthorityOrigin(origin, host) {
|
|
259
|
+
let originUrl;
|
|
214
260
|
try {
|
|
215
|
-
|
|
261
|
+
originUrl = new URL(origin);
|
|
216
262
|
} catch {
|
|
217
263
|
return false;
|
|
218
264
|
}
|
|
265
|
+
const scheme = originUrl.protocol === "https:" ? "https:" : originUrl.protocol === "http:" ? "http:" : void 0;
|
|
266
|
+
if (scheme === void 0) return false;
|
|
267
|
+
const hostUrl = parseAuthority(host, scheme);
|
|
268
|
+
return hostUrl !== void 0 && originUrl.host === hostUrl.host;
|
|
219
269
|
}
|
|
220
270
|
//#endregion
|
|
221
271
|
//#region lib/types/browser-auth.js
|
|
@@ -253,12 +303,13 @@ function header(headers, name) {
|
|
|
253
303
|
const value = headers[name];
|
|
254
304
|
return typeof value === "string" ? value : void 0;
|
|
255
305
|
}
|
|
256
|
-
/**
|
|
257
|
-
function
|
|
306
|
+
/** Protocol-scoped cookie audience; HTTP retains its existing authority-only spelling. */
|
|
307
|
+
function requestAudience(headers, secure) {
|
|
258
308
|
const host = header(headers, "host");
|
|
259
309
|
if (host === void 0) return void 0;
|
|
260
310
|
try {
|
|
261
|
-
|
|
311
|
+
const url = new URL(`${secure ? "https:" : "http:"}//${host}`);
|
|
312
|
+
return secure ? url.origin : url.host;
|
|
262
313
|
} catch {
|
|
263
314
|
return;
|
|
264
315
|
}
|
|
@@ -293,20 +344,21 @@ function cookieValue(headerValue, name) {
|
|
|
293
344
|
}
|
|
294
345
|
}
|
|
295
346
|
/** Serialize the fixed browser-session attributes; generated names and values are cookie-safe base64url. */
|
|
296
|
-
function sessionCookie(name, value, expiresAt, maxAgeSeconds) {
|
|
297
|
-
|
|
347
|
+
function sessionCookie(name, value, expiresAt, maxAgeSeconds, secure) {
|
|
348
|
+
const attributes = `${name}=${value}; Max-Age=${String(maxAgeSeconds)}; Path=/; Expires=${new Date(expiresAt).toUTCString()}; HttpOnly; SameSite=Strict`;
|
|
349
|
+
return secure ? `${attributes}; Secure` : attributes;
|
|
298
350
|
}
|
|
299
351
|
function signature(secret, body) {
|
|
300
352
|
return createHmac("sha256", secret).update(body).digest();
|
|
301
353
|
}
|
|
302
354
|
function encodeCookie(payload, secret) {
|
|
303
355
|
const body = encodeBase64Url(Buffer.from(JSON.stringify(payload), "utf8"));
|
|
304
|
-
return `
|
|
356
|
+
return `v${payload.version}.${body}.${encodeBase64Url(signature(secret, body))}`;
|
|
305
357
|
}
|
|
306
358
|
function decodeCookie(value, secret) {
|
|
307
359
|
const parts = value.split(".");
|
|
308
360
|
const [version, body, encodedSignature] = parts;
|
|
309
|
-
if (parts.length !== 3 || version !== "v1" || body === void 0 || encodedSignature === void 0) return;
|
|
361
|
+
if (parts.length !== 3 || version !== "v1" && version !== "v2" || body === void 0 || encodedSignature === void 0) return;
|
|
310
362
|
const actualSignature = decodeBase64Url(encodedSignature);
|
|
311
363
|
if (actualSignature === void 0) return void 0;
|
|
312
364
|
const expectedSignature = signature(secret, body);
|
|
@@ -319,8 +371,22 @@ function decodeCookie(value, secret) {
|
|
|
319
371
|
} catch {
|
|
320
372
|
return;
|
|
321
373
|
}
|
|
322
|
-
if (!isRecord(decoded) || decoded.version !== COOKIE_PAYLOAD_VERSION || typeof decoded.authority !== "string" || !Number.isSafeInteger(decoded.issuedAt) || !Number.isSafeInteger(decoded.expiresAt)) return void 0;
|
|
323
|
-
|
|
374
|
+
if (!isRecord(decoded) || decoded.version !== COOKIE_PAYLOAD_VERSION && decoded.version !== 2 || version !== `v${decoded.version}` || typeof decoded.authority !== "string" || typeof decoded.issuedAt !== "number" || !Number.isSafeInteger(decoded.issuedAt) || typeof decoded.expiresAt !== "number" || !Number.isSafeInteger(decoded.expiresAt)) return void 0;
|
|
375
|
+
const payload = {
|
|
376
|
+
authority: decoded.authority,
|
|
377
|
+
issuedAt: decoded.issuedAt,
|
|
378
|
+
expiresAt: decoded.expiresAt
|
|
379
|
+
};
|
|
380
|
+
if (decoded.version === COOKIE_PAYLOAD_VERSION) return {
|
|
381
|
+
version: COOKIE_PAYLOAD_VERSION,
|
|
382
|
+
...payload
|
|
383
|
+
};
|
|
384
|
+
if (typeof decoded.id !== "string" || decoded.id.length === 0) return void 0;
|
|
385
|
+
return {
|
|
386
|
+
version: 2,
|
|
387
|
+
...payload,
|
|
388
|
+
id: decoded.id
|
|
389
|
+
};
|
|
324
390
|
}
|
|
325
391
|
async function initializeSecret(credentials) {
|
|
326
392
|
const generated = {
|
|
@@ -383,14 +449,18 @@ var BrowserAuth = class BrowserAuth {
|
|
|
383
449
|
* 401 response.
|
|
384
450
|
* @param req - incoming root or configured-index request.
|
|
385
451
|
* @param res - response owned when this method returns false.
|
|
452
|
+
* @param secure - whether the listener receiving this request serves TLS; the
|
|
453
|
+
* minted cookie carries `Secure` when it does. Only that listener's own
|
|
454
|
+
* protocol decides, never a `publicUrl` announcement or a forwarded header:
|
|
455
|
+
* those describe a hop this process does not terminate.
|
|
386
456
|
* @returns true only when the caller may serve index.html.
|
|
387
457
|
*/
|
|
388
|
-
authorizeIndex(req, res) {
|
|
458
|
+
authorizeIndex(req, res, secure = false) {
|
|
389
459
|
/* v8 ignore next -- node:http always supplies url on server requests. */
|
|
390
460
|
const url = new URL(req.url ?? "/", "http://dsh.invalid");
|
|
391
461
|
const tokens = url.searchParams.getAll(TOKEN_QUERY);
|
|
392
462
|
if (tokens.length > 0) {
|
|
393
|
-
const authority =
|
|
463
|
+
const authority = requestAudience(req.headers, secure);
|
|
394
464
|
if (req.method === "GET" && url.pathname === "/" && tokens.length === 1 && authority !== void 0 && tokenMatches(tokens.join(""), this.launchToken)) {
|
|
395
465
|
const issuedAt = Date.now();
|
|
396
466
|
const expiresAt = issuedAt + this.maxAgeMilliseconds;
|
|
@@ -404,12 +474,12 @@ var BrowserAuth = class BrowserAuth {
|
|
|
404
474
|
"cache-control": "no-store",
|
|
405
475
|
"location": "./",
|
|
406
476
|
"referrer-policy": "no-referrer",
|
|
407
|
-
"set-cookie": sessionCookie(cookieName(authority), value, expiresAt, Math.floor(this.maxAgeMilliseconds / 1e3))
|
|
477
|
+
"set-cookie": sessionCookie(cookieName(authority), value, expiresAt, Math.floor(this.maxAgeMilliseconds / 1e3), secure)
|
|
408
478
|
});
|
|
409
479
|
res.end();
|
|
410
480
|
return false;
|
|
411
481
|
}
|
|
412
|
-
if (req.method === "GET" && url.pathname === "/" && this.isAuthenticated(req)) {
|
|
482
|
+
if (req.method === "GET" && url.pathname === "/" && this.isAuthenticated(req, secure)) {
|
|
413
483
|
res.writeHead(303, {
|
|
414
484
|
"cache-control": "no-store",
|
|
415
485
|
"location": "./",
|
|
@@ -421,25 +491,82 @@ var BrowserAuth = class BrowserAuth {
|
|
|
421
491
|
this.writeUnauthorized(req, res);
|
|
422
492
|
return false;
|
|
423
493
|
}
|
|
424
|
-
if (this.isAuthenticated(req)) return true;
|
|
494
|
+
if (this.isAuthenticated(req, secure)) return true;
|
|
425
495
|
this.writeUnauthorized(req, res);
|
|
426
496
|
return false;
|
|
427
497
|
}
|
|
428
498
|
/**
|
|
499
|
+
* Exchange the launch token for an independent API operator; omitted interactions default to none.
|
|
500
|
+
* @param req - trusted GET request on /connect with at most one interactions declaration.
|
|
501
|
+
* @param res - response owned to completion, redirecting to the API mux on success.
|
|
502
|
+
* @param secure - whether the receiving listener serves TLS.
|
|
503
|
+
* @param publish - synchronously create the operator before exposing its signed credential.
|
|
504
|
+
*/
|
|
505
|
+
authorizeConnect(req, res, secure, publish) {
|
|
506
|
+
const url = new URL(req.url ?? "/", "http://dsh.invalid");
|
|
507
|
+
const declarations = url.searchParams.getAll("interactions");
|
|
508
|
+
const interactions = declarations[0] ?? "none";
|
|
509
|
+
if (declarations.length > 1 || interactions !== "answer" && interactions !== "none") {
|
|
510
|
+
res.writeHead(400, {
|
|
511
|
+
"cache-control": "no-store",
|
|
512
|
+
"content-type": "text/plain; charset=utf-8"
|
|
513
|
+
});
|
|
514
|
+
res.end("interactions must be declared once as answer or none\n");
|
|
515
|
+
return;
|
|
516
|
+
}
|
|
517
|
+
const tokens = url.searchParams.getAll(TOKEN_QUERY);
|
|
518
|
+
const authority = requestAudience(req.headers, secure);
|
|
519
|
+
if (req.method !== "GET" || url.pathname !== "/connect" || tokens.length !== 1 || authority === void 0 || !tokenMatches(tokens.join(""), this.launchToken)) {
|
|
520
|
+
this.writeUnauthorized(req, res);
|
|
521
|
+
return;
|
|
522
|
+
}
|
|
523
|
+
const issuedAt = Date.now();
|
|
524
|
+
const expiresAt = issuedAt + this.maxAgeMilliseconds;
|
|
525
|
+
const value = encodeCookie({
|
|
526
|
+
version: 2,
|
|
527
|
+
authority,
|
|
528
|
+
id: publish(interactions),
|
|
529
|
+
issuedAt,
|
|
530
|
+
expiresAt
|
|
531
|
+
}, this.secret);
|
|
532
|
+
res.writeHead(302, {
|
|
533
|
+
"cache-control": "no-store",
|
|
534
|
+
"location": "./api/remote.mux",
|
|
535
|
+
"referrer-policy": "no-referrer",
|
|
536
|
+
"set-cookie": sessionCookie(cookieName(authority), value, expiresAt, Math.floor(this.maxAgeMilliseconds / 1e3), secure)
|
|
537
|
+
});
|
|
538
|
+
res.end();
|
|
539
|
+
}
|
|
540
|
+
/**
|
|
429
541
|
* Verify the authority-bound browser cookie on a Host request.
|
|
430
542
|
* @param request - request headers carrying Host and Cookie.
|
|
543
|
+
* @param secure - whether the receiving listener uses TLS, including its default port.
|
|
431
544
|
* @returns true only for an unexpired cookie signed by this activation's loaded secret.
|
|
432
545
|
*/
|
|
433
|
-
isAuthenticated(request) {
|
|
434
|
-
|
|
546
|
+
isAuthenticated(request, secure = false) {
|
|
547
|
+
return this.authenticate(request, secure)?.version === COOKIE_PAYLOAD_VERSION;
|
|
548
|
+
}
|
|
549
|
+
/**
|
|
550
|
+
* Verify an API credential without creating or reviving its operator.
|
|
551
|
+
* @param request - request headers carrying Host and Cookie.
|
|
552
|
+
* @param secure - whether the receiving listener uses TLS.
|
|
553
|
+
* @returns the signed operator id, or undefined for invalid, expired, or browser cookies.
|
|
554
|
+
*/
|
|
555
|
+
authenticateApi(request, secure = false) {
|
|
556
|
+
const payload = this.authenticate(request, secure);
|
|
557
|
+
return payload?.version === 2 ? payload.id : void 0;
|
|
558
|
+
}
|
|
559
|
+
authenticate(request, secure) {
|
|
560
|
+
const authority = requestAudience(request.headers, secure);
|
|
435
561
|
const rawCookie = header(request.headers, "cookie");
|
|
436
|
-
if (authority === void 0 || rawCookie === void 0) return
|
|
562
|
+
if (authority === void 0 || rawCookie === void 0) return void 0;
|
|
437
563
|
const value = cookieValue(rawCookie, cookieName(authority));
|
|
438
|
-
if (value === void 0) return
|
|
564
|
+
if (value === void 0) return void 0;
|
|
439
565
|
const payload = decodeCookie(value, this.secret);
|
|
440
|
-
if (payload === void 0 || payload.authority !== authority) return
|
|
566
|
+
if (payload === void 0 || payload.authority !== authority) return void 0;
|
|
441
567
|
const now = Date.now();
|
|
442
|
-
|
|
568
|
+
if (payload.issuedAt > now || payload.expiresAt <= now || payload.expiresAt <= payload.issuedAt || payload.expiresAt - payload.issuedAt > this.maxAgeMilliseconds) return void 0;
|
|
569
|
+
return payload;
|
|
443
570
|
}
|
|
444
571
|
writeUnauthorized(req, res) {
|
|
445
572
|
res.writeHead(401, {
|
|
@@ -516,26 +643,39 @@ const serverResponseSchema = z$1.object({
|
|
|
516
643
|
/** Either Connection RPC envelope direction. */
|
|
517
644
|
const rpcMessageSchema = z$1.discriminatedUnion("type", [clientRequestSchema, serverResponseSchema]);
|
|
518
645
|
//#endregion
|
|
519
|
-
//#region lib/types/
|
|
646
|
+
//#region lib/types/peer.js
|
|
520
647
|
/**
|
|
521
|
-
* The
|
|
522
|
-
*
|
|
523
|
-
*
|
|
524
|
-
* @module @deepseek-ai/dsh-client-connection/src/
|
|
648
|
+
* The Peer: one party admitted to this Host. Its creator fixes the principal
|
|
649
|
+
* it speaks for and owns its lifetime; Gateway hands it to each Remote call as
|
|
650
|
+
* `invocation.peer`.
|
|
651
|
+
* @module @deepseek-ai/dsh-client-connection/src/peer
|
|
525
652
|
*/
|
|
526
653
|
/**
|
|
527
|
-
*
|
|
654
|
+
* One Peer's scope. The instance is its own scope key, so `scopeOf(peer.ctx)`
|
|
528
655
|
* returns it and events dispatched with `scopeTarget(subject, peer)` reach
|
|
529
656
|
* listeners registered through `peer.ctx` and nobody else.
|
|
530
657
|
*/
|
|
531
|
-
var
|
|
658
|
+
var Peer = class {
|
|
532
659
|
id = randomUUID();
|
|
533
660
|
ctx;
|
|
661
|
+
principal;
|
|
662
|
+
interactions;
|
|
663
|
+
signal;
|
|
534
664
|
scope;
|
|
535
|
-
/**
|
|
536
|
-
|
|
665
|
+
/**
|
|
666
|
+
* @param owner - Context the scope fiber hangs under; disposing it disposes the Peer.
|
|
667
|
+
* @param options - the principal fixed for this Peer's lifetime.
|
|
668
|
+
*/
|
|
669
|
+
constructor(owner, options) {
|
|
670
|
+
this.principal = options.principal;
|
|
671
|
+
this.interactions = options.interactions ?? "answer";
|
|
537
672
|
this.scope = createScope(owner, this);
|
|
538
673
|
this.ctx = this.scope.ctx;
|
|
674
|
+
const lifetime = new AbortController();
|
|
675
|
+
this.signal = lifetime.signal;
|
|
676
|
+
this.ctx.effect(() => () => {
|
|
677
|
+
lifetime.abort(/* @__PURE__ */ new Error(`Peer ${this.id} was disposed`));
|
|
678
|
+
}, "client-connection: Peer lifetime");
|
|
539
679
|
}
|
|
540
680
|
/** Tear down every connection-lifetime registration; racing calls share one completion. */
|
|
541
681
|
dispose() {
|
|
@@ -552,10 +692,14 @@ const ENDPOINT_SEGMENT_PATTERN = /^[A-Za-z0-9_$.-]+$/;
|
|
|
552
692
|
var HostConnectionService = class extends Service {
|
|
553
693
|
trustedHosts;
|
|
554
694
|
browserAuth;
|
|
555
|
-
/** The
|
|
556
|
-
|
|
695
|
+
/** The Peer every authenticated browser connection shares. */
|
|
696
|
+
browserPeer;
|
|
697
|
+
peers = /* @__PURE__ */ new Map();
|
|
698
|
+
owner;
|
|
699
|
+
disposed = false;
|
|
557
700
|
interceptors = /* @__PURE__ */ new Map();
|
|
558
701
|
fetchRoutes = /* @__PURE__ */ new Map();
|
|
702
|
+
allowsRemoteAuthorities;
|
|
559
703
|
/**
|
|
560
704
|
* Provide the Host half over the active HTTP server.
|
|
561
705
|
* @param ctx - owning Connection plugin context.
|
|
@@ -566,8 +710,15 @@ var HostConnectionService = class extends Service {
|
|
|
566
710
|
super(ctx, "connection");
|
|
567
711
|
this.trustedHosts = trustedHosts;
|
|
568
712
|
this.browserAuth = browserAuth;
|
|
569
|
-
this.
|
|
570
|
-
|
|
713
|
+
this.owner = ctx;
|
|
714
|
+
this.browserPeer = new Peer(ctx, { principal: { kind: "owner" } });
|
|
715
|
+
ctx.effect(() => async () => {
|
|
716
|
+
this.disposed = true;
|
|
717
|
+
const peers = [this.browserPeer, ...Array.from(this.peers.values(), (record) => record.peer)];
|
|
718
|
+
this.peers.clear();
|
|
719
|
+
await Promise.all(peers.map((peer) => peer.dispose()));
|
|
720
|
+
}, "client-connection: Peer scopes");
|
|
721
|
+
this.allowsRemoteAuthorities = trustedHosts.some(isRemoteAuthority);
|
|
571
722
|
}
|
|
572
723
|
/** Generic channel registry scoped to the Context reading this service. */
|
|
573
724
|
get rpc() {
|
|
@@ -582,19 +733,80 @@ var HostConnectionService = class extends Service {
|
|
|
582
733
|
const owner = this.ctx;
|
|
583
734
|
return { register: (route) => this.registerFetchRoute(owner, route) };
|
|
584
735
|
}
|
|
585
|
-
/** Apply the
|
|
736
|
+
/** Apply the Host/Origin fence and require an authenticated live Peer. */
|
|
586
737
|
requestRejection(request) {
|
|
587
|
-
|
|
588
|
-
return
|
|
738
|
+
const admission = this.admit(request);
|
|
739
|
+
return "rejection" in admission ? admission.rejection : void 0;
|
|
589
740
|
}
|
|
590
|
-
/**
|
|
741
|
+
/** Browser requests share an owner Peer; API HTTP does not depend on an attached socket. */
|
|
591
742
|
admit(request) {
|
|
592
|
-
|
|
593
|
-
|
|
743
|
+
if (!this.isTrusted(request)) return { rejection: 403 };
|
|
744
|
+
if (this.disposed) return { rejection: 401 };
|
|
745
|
+
const secure = this.owner.get("webServer")?.protocol === "https:";
|
|
746
|
+
if (this.browserAuth.isAuthenticated(request, secure)) return { peer: this.browserPeer };
|
|
747
|
+
const id = this.browserAuth.authenticateApi(request, secure);
|
|
748
|
+
const record = id === void 0 ? void 0 : this.peers.get(id);
|
|
749
|
+
return record === void 0 ? { rejection: 401 } : { peer: record.peer };
|
|
750
|
+
}
|
|
751
|
+
/** Create one in-process Peer owned by the Context that read this service. */
|
|
752
|
+
createPeer(options) {
|
|
753
|
+
return new Peer(this.ctx, options);
|
|
754
|
+
}
|
|
755
|
+
/** Claim API socket ownership synchronously; callers release on termination or failed upgrade. */
|
|
756
|
+
admitConnection(request) {
|
|
757
|
+
const admission = this.admit(request);
|
|
758
|
+
if ("rejection" in admission) return admission;
|
|
759
|
+
const record = this.peers.get(admission.peer.id);
|
|
760
|
+
if (record === void 0) return {
|
|
761
|
+
...admission,
|
|
762
|
+
release: () => Promise.resolve()
|
|
763
|
+
};
|
|
764
|
+
if (record.ownsSocket) return { rejection: 409 };
|
|
765
|
+
record.ownsSocket = true;
|
|
766
|
+
let released;
|
|
767
|
+
return {
|
|
768
|
+
peer: record.peer,
|
|
769
|
+
release: () => {
|
|
770
|
+
if (released !== void 0) return released;
|
|
771
|
+
this.peers.delete(record.peer.id);
|
|
772
|
+
released = record.peer.dispose();
|
|
773
|
+
return released;
|
|
774
|
+
}
|
|
775
|
+
};
|
|
776
|
+
}
|
|
777
|
+
isTrusted(request) {
|
|
778
|
+
const carrier = this.owner.get("webServer");
|
|
779
|
+
return isTrustedApiRequest(request, this.trustedHosts, carrier?.host, carrier?.protocol ?? "http:");
|
|
780
|
+
}
|
|
781
|
+
publishPeer(interactions) {
|
|
782
|
+
const peer = new Peer(this.owner, {
|
|
783
|
+
principal: { kind: "owner" },
|
|
784
|
+
interactions
|
|
785
|
+
});
|
|
786
|
+
this.peers.set(peer.id, {
|
|
787
|
+
peer,
|
|
788
|
+
ownsSocket: false
|
|
789
|
+
});
|
|
790
|
+
return peer.id;
|
|
791
|
+
}
|
|
792
|
+
/**
|
|
793
|
+
* Authenticate and publish a credential before sending the connect redirect.
|
|
794
|
+
* @param request - launch-token request on /connect.
|
|
795
|
+
* @param response - response owned to completion.
|
|
796
|
+
*/
|
|
797
|
+
authorizeConnect(request, response) {
|
|
798
|
+
if (!this.isTrusted(request) || this.disposed) {
|
|
799
|
+
response.writeHead(this.disposed ? 401 : 403);
|
|
800
|
+
response.end(this.disposed ? "unauthorized" : "forbidden");
|
|
801
|
+
return;
|
|
802
|
+
}
|
|
803
|
+
this.browserAuth.authorizeConnect(request, response, this.owner.get("webServer")?.protocol === "https:", (interactions) => this.publishPeer(interactions));
|
|
594
804
|
}
|
|
595
|
-
/**
|
|
805
|
+
/**
|
|
806
|
+
* Authenticate an index request; native TLS listeners mint Secure cookies.
|
|
807
|
+
*/
|
|
596
808
|
authorizeIndex(request, response) {
|
|
597
|
-
return this.browserAuth.authorizeIndex(request, response);
|
|
809
|
+
return this.browserAuth.authorizeIndex(request, response, this.ctx.get("webServer")?.protocol === "https:");
|
|
598
810
|
}
|
|
599
811
|
/** Add this process's launch token to the clean application URL. */
|
|
600
812
|
authenticatedUrl(baseUrl) {
|
|
@@ -611,14 +823,17 @@ var HostConnectionService = class extends Service {
|
|
|
611
823
|
const route = this.fetchRoutes.get(url.pathname);
|
|
612
824
|
return route?.methods.has(method) === true ? route.requestBody : "buffered";
|
|
613
825
|
},
|
|
614
|
-
fetch: (request) => {
|
|
826
|
+
fetch: (request, peer) => {
|
|
615
827
|
const pathname = new URL(request.url).pathname;
|
|
616
828
|
const route = this.fetchRoutes.get(pathname);
|
|
617
|
-
if (route?.methods.has(request.method) === true)
|
|
829
|
+
if (route?.methods.has(request.method) === true) {
|
|
830
|
+
if (peer.principal.kind !== "owner") return Promise.resolve(new Response("forbidden", { status: 403 }));
|
|
831
|
+
return route.fetch(request);
|
|
832
|
+
}
|
|
618
833
|
const endpoint = endpointFromPath(channel, pathname);
|
|
619
834
|
const interceptor = this.interceptors.get(channel);
|
|
620
835
|
if (endpoint === void 0 || interceptor === void 0 || !interceptor.matches(endpoint)) return Promise.resolve(new Response("not found", { status: 404 }));
|
|
621
|
-
return interceptor.fetchHandler.fetch(request);
|
|
836
|
+
return interceptor.fetchHandler.fetch(request, peer);
|
|
622
837
|
}
|
|
623
838
|
};
|
|
624
839
|
}
|
|
@@ -639,7 +854,7 @@ var HostConnectionService = class extends Service {
|
|
|
639
854
|
}
|
|
640
855
|
register(owner, channel, handler) {
|
|
641
856
|
assertChannel(channel);
|
|
642
|
-
const fetchHandler = rpcFetchHandler(channel, handler
|
|
857
|
+
const fetchHandler = rpcFetchHandler(channel, handler);
|
|
643
858
|
const route = {
|
|
644
859
|
kind: "prefix",
|
|
645
860
|
path: channel,
|
|
@@ -650,7 +865,7 @@ var HostConnectionService = class extends Service {
|
|
|
650
865
|
res.end(admission.rejection === 401 ? "unauthorized" : "forbidden");
|
|
651
866
|
return;
|
|
652
867
|
}
|
|
653
|
-
await bridge(req, res, fetchHandler);
|
|
868
|
+
await bridge(req, res, fetchHandler, admission.peer);
|
|
654
869
|
}
|
|
655
870
|
};
|
|
656
871
|
return owner.effect(() => owner.webServer.register(route), `client-connection: ${channel} rpc channel`);
|
|
@@ -659,7 +874,7 @@ var HostConnectionService = class extends Service {
|
|
|
659
874
|
if (channel !== "/api") throw new Error(`connection: invalid shared RPC channel ${JSON.stringify(channel)}`);
|
|
660
875
|
const interceptor = {
|
|
661
876
|
matches,
|
|
662
|
-
fetchHandler: rpcFetchHandler(channel, handler
|
|
877
|
+
fetchHandler: rpcFetchHandler(channel, handler)
|
|
663
878
|
};
|
|
664
879
|
return owner.effect(() => {
|
|
665
880
|
if (this.interceptors.has(channel)) throw new Error(`connection: shared RPC channel ${JSON.stringify(channel)} already has an interceptor`);
|
|
@@ -670,10 +885,10 @@ var HostConnectionService = class extends Service {
|
|
|
670
885
|
}, `client-connection: ${channel} rpc interceptor`);
|
|
671
886
|
}
|
|
672
887
|
};
|
|
673
|
-
function rpcFetchHandler(channel, handler
|
|
888
|
+
function rpcFetchHandler(channel, handler) {
|
|
674
889
|
return {
|
|
675
890
|
requestBodyMode: () => "buffered",
|
|
676
|
-
async fetch(request) {
|
|
891
|
+
async fetch(request, peer) {
|
|
677
892
|
const endpoint = endpointFromPath(channel, new URL(request.url).pathname);
|
|
678
893
|
if (request.method !== "POST" || endpoint === void 0) return new Response("not found", { status: 404 });
|
|
679
894
|
if (request.headers.get("content-type")?.split(";", 1)[0]?.trim().toLowerCase() !== "application/json") return new Response("content type must be application/json", { status: 415 });
|
|
@@ -837,14 +1052,21 @@ async function apply(ctx, config) {
|
|
|
837
1052
|
res.end(admission.rejection === 401 ? "unauthorized" : "forbidden");
|
|
838
1053
|
return;
|
|
839
1054
|
}
|
|
840
|
-
await webCtx.waterfall("connection/request", req, res, () => bridge(req, res, fetchHandler, maxRequestBodyBytes));
|
|
1055
|
+
await webCtx.waterfall("connection/request", req, res, () => bridge(req, res, fetchHandler, admission.peer, maxRequestBodyBytes));
|
|
841
1056
|
}
|
|
842
1057
|
};
|
|
843
1058
|
webCtx.effect(() => webCtx.webServer.register(route), "client-connection: /api route");
|
|
1059
|
+
webCtx.effect(() => webCtx.webServer.register({
|
|
1060
|
+
kind: "exact",
|
|
1061
|
+
path: CONNECT_PATH,
|
|
1062
|
+
handler: (req, res) => {
|
|
1063
|
+
connection.authorizeConnect(req, res);
|
|
1064
|
+
}
|
|
1065
|
+
}), "client-connection: /connect route");
|
|
844
1066
|
});
|
|
845
1067
|
ctx.inject(["attachments"], (attachmentCtx) => {
|
|
846
1068
|
assertImageBodyCapacity(attachmentCtx, maxRequestBodyBytes);
|
|
847
1069
|
});
|
|
848
1070
|
}
|
|
849
1071
|
//#endregion
|
|
850
|
-
export { API_PATH, Config, HostConnectionService,
|
|
1072
|
+
export { API_PATH, CONNECT_PATH, Config, HostConnectionService, Peer, RpcId, apply, clientRequestSchema, inject, name, rpcErrorSchema, rpcIdSchema, rpcMessageSchema, rpcResultSchema, serverResponseSchema, transportError };
|
|
@@ -4,13 +4,12 @@
|
|
|
4
4
|
* the attacker's domain while the socket reaches this server) and cross-site
|
|
5
5
|
* requests fired from a malicious page. The Host fence binds every request,
|
|
6
6
|
* browser-looking or not: over plain HTTP a browser attaches neither Origin
|
|
7
|
-
* nor Fetch-Metadata to reads (images and navigations — those
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* belongs to the webserver config, and this fence is not an auth layer.
|
|
7
|
+
* nor Fetch-Metadata to reads (images and navigations — those headers go only
|
|
8
|
+
* to trustworthy destinations), so an unmarked request may still be a rebound
|
|
9
|
+
* browser read and Host is the one header rebinding cannot forge. Non-browser
|
|
10
|
+
* and remote clients pass the same fence via loopback, the listener's bind IP
|
|
11
|
+
* literal, or a declared `trustedHosts` authority. Binding policy belongs to
|
|
12
|
+
* the webserver config; this fence never establishes identity.
|
|
14
13
|
*/
|
|
15
14
|
import type { ConnectionTrustRequest } from './rpc.ts';
|
|
16
15
|
/**
|
|
@@ -28,11 +27,19 @@ import type { ConnectionTrustRequest } from './rpc.ts';
|
|
|
28
27
|
* @param entry - the configured value, verbatim.
|
|
29
28
|
*/
|
|
30
29
|
export declare function assertTrustedAuthority(entry: string): void;
|
|
30
|
+
/**
|
|
31
|
+
* Classify an authority already accepted by {@link assertTrustedAuthority}.
|
|
32
|
+
* @param entry - the configured authority, verbatim.
|
|
33
|
+
* @returns true unless the entry parses as a loopback hostname.
|
|
34
|
+
*/
|
|
35
|
+
export declare function isRemoteAuthority(entry: string): boolean;
|
|
31
36
|
/**
|
|
32
37
|
* Decide whether one /api request may reach the RPC bridge.
|
|
33
38
|
* @param request - Node HTTP or Fetch request facts (headers).
|
|
34
39
|
* @param trustedHosts - non-loopback authorities this deployment serves: exact `host:port`, or port-less `host` matching any port.
|
|
35
|
-
* @
|
|
40
|
+
* @param bindHost - the listener's own bind IP literal, accepted on any port independently of `trustedHosts`.
|
|
41
|
+
* @param protocol - listener protocol for default Host ports; HTTP when no Web carrier is mounted.
|
|
42
|
+
* @returns true when the Host is ours (loopback, the bind address, or trusted) and any attached browser markers are same-origin.
|
|
36
43
|
*/
|
|
37
|
-
export declare function isTrustedApiRequest(request: ConnectionTrustRequest, trustedHosts: readonly string[]): boolean;
|
|
44
|
+
export declare function isTrustedApiRequest(request: ConnectionTrustRequest, trustedHosts: readonly string[], bindHost?: string, protocol?: 'http:' | 'https:'): boolean;
|
|
38
45
|
//# sourceMappingURL=api-request-trust.d.ts.map
|