@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/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
- * headers go only to trustworthy destinations), so an unmarked request may
136
- * still be a rebound browser read and Host is the one header rebinding cannot
137
- * forge. Non-browser and remote clients pass the same fence via loopback,
138
- * deployment-derived LAN IP literals, or a declared `trustedHosts` authority.
139
- * Network reachability and authentication stay out of scope: binding policy
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
- /** Normalized URL of a Host-header authority (hostname lowercased, default port stripped, IPv6 bracketed), or undefined when unparsable. */
148
- function parseAuthority(authority) {
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(`http://${authority}`);
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(`https://${entry}`).port;
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 (the shape the CLI derives for IP-literal LAN serving,
189
- * where the bound port may be OS-assigned). Both sides compare through WHATWG
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
- * @returns true when the Host is ours (loopback or trusted) and any attached browser markers are same-origin.
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
- return new URL(origin).host === hostUrl.host;
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
- /** Canonical request authority used as the cookie name and signed audience. */
257
- function requestAuthority(headers) {
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
- return new URL(`http://${host}`).host;
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
- return `${name}=${value}; Max-Age=${String(maxAgeSeconds)}; Path=/; Expires=${new Date(expiresAt).toUTCString()}; HttpOnly; SameSite=Strict`;
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 `v1.${body}.${encodeBase64Url(signature(secret, body))}`;
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
- return decoded;
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 = requestAuthority(req.headers);
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
- const authority = requestAuthority(request.headers);
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 false;
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 false;
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 false;
566
+ if (payload === void 0 || payload.authority !== authority) return void 0;
441
567
  const now = Date.now();
442
- return payload.issuedAt <= now && payload.expiresAt > now && payload.expiresAt > payload.issuedAt && payload.expiresAt - payload.issuedAt <= this.maxAgeMilliseconds;
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/operator-peer.js
646
+ //#region lib/types/peer.js
520
647
  /**
521
- * The operator Peer: the one party this Host answers to. Connection owns it
522
- * for its own lifetime, admits every request as it, and hands it to each
523
- * Remote call as `invocation.peer`.
524
- * @module @deepseek-ai/dsh-client-connection/src/operator-peer
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
- * The operator's scope. The instance is its own scope key, so `scopeOf(peer.ctx)`
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 OperatorPeer = class {
658
+ var Peer = class {
532
659
  id = randomUUID();
533
660
  ctx;
661
+ principal;
662
+ interactions;
663
+ signal;
534
664
  scope;
535
- /** @param owner - Connection plugin context the scope fiber hangs under. */
536
- constructor(owner) {
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 operator Peer every admitted request speaks for. */
556
- operator;
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.operator = new OperatorPeer(ctx);
570
- ctx.effect(() => () => this.operator.dispose(), "client-connection: operator Peer");
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 configured Host/Origin fence, then browser authentication. */
736
+ /** Apply the Host/Origin fence and require an authenticated live Peer. */
586
737
  requestRejection(request) {
587
- if (!isTrustedApiRequest(request, this.trustedHosts)) return 403;
588
- return this.browserAuth.isAuthenticated(request) ? void 0 : 401;
738
+ const admission = this.admit(request);
739
+ return "rejection" in admission ? admission.rejection : void 0;
589
740
  }
590
- /** A request that passes the fence and authentication speaks for the operator. */
741
+ /** Browser requests share an owner Peer; API HTTP does not depend on an attached socket. */
591
742
  admit(request) {
592
- const rejection = this.requestRejection(request);
593
- return rejection === void 0 ? { peer: this.operator } : { rejection };
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
- /** Authenticate an index request through the process-token exchange or cookie. */
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) return route.fetch(request);
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, this.operator);
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, this.operator)
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, peer) {
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, OperatorPeer, RpcId, apply, clientRequestSchema, inject, name, rpcErrorSchema, rpcIdSchema, rpcMessageSchema, rpcResultSchema, serverResponseSchema, transportError };
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
- * headers go only to trustworthy destinations), so an unmarked request may
9
- * still be a rebound browser read and Host is the one header rebinding cannot
10
- * forge. Non-browser and remote clients pass the same fence via loopback,
11
- * deployment-derived LAN IP literals, or a declared `trustedHosts` authority.
12
- * Network reachability and authentication stay out of scope: binding policy
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
- * @returns true when the Host is ours (loopback or trusted) and any attached browser markers are same-origin.
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