@deepseek-ai/dsh-client-connection 0.1.1-rc.2 → 0.1.2-alpha.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js CHANGED
@@ -1,21 +1,15 @@
1
1
  import z from "@deepseek-ai/schemastery";
2
- import { toFetchHandler } from "@deepseek-ai/dsh-host-apiproxy";
2
+ import { createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypto";
3
+ import { credentialKey } from "@deepseek-ai/dsh-credentials";
3
4
  import { Service } from "@deepseek-ai/cordis";
4
- import { RpcId, clientRequestSchema } from "@deepseek-ai/dsh-host-apiproxy/api";
5
- import { randomUUID } from "node:crypto";
6
- import WebSocket, { WebSocketServer } from "ws";
5
+ import { z as z$1 } from "zod";
7
6
  //#region lib/types/api-path.js
8
7
  /**
9
8
  * The /api URL prefix — single source for both halves of the web transport.
10
- * The node half registers this prefix on the web server; both halves share the
11
- * event paths below for the browser WebSocket downlinks.
9
+ * The node half registers this prefix on the web server.
12
10
  */
13
11
  /** Route prefix owning every api request (`/api` and `/api/<anything>`). */
14
12
  const API_PATH = "/api";
15
- /** Browser mux-frame WebSocket pathname. */
16
- const MUX_EVENTS_PATH = `${API_PATH}/events.mux`;
17
- /** Browser host-frame WebSocket pathname. */
18
- const HOST_EVENTS_PATH = `${API_PATH}/events.host`;
19
13
  //#endregion
20
14
  //#region lib/types/http-bridge.js
21
15
  /**
@@ -29,7 +23,7 @@ const HOST_EVENTS_PATH = `${API_PATH}/events.host`;
29
23
  const DEFAULT_MAX_REQUEST_BODY_BYTES = 300 * 1024 * 1024;
30
24
  /**
31
25
  * Bridge one node:http request to the fetch-shaped handler (client close
32
- * aborts; SSE bodies stream out chunk by chunk).
26
+ * aborts; response bodies stream out chunk by chunk).
33
27
  * @param req - incoming node:http request (fully read before dispatch).
34
28
  * @param res - node:http response the bridge writes and owns to completion.
35
29
  * @param apiHandler - fetch-shaped API carrier the request is dispatched to.
@@ -118,7 +112,7 @@ function isLoopbackHostname(hostname) {
118
112
  * Network reachability and authentication stay out of scope: binding policy
119
113
  * belongs to the webserver config, and this fence is not an auth layer.
120
114
  */
121
- function header(headers, name) {
115
+ function header$1(headers, name) {
122
116
  if (headers instanceof Headers) return headers.get(name) ?? void 0;
123
117
  const value = headers[name];
124
118
  return typeof value === "string" ? value : void 0;
@@ -182,13 +176,13 @@ function isTrustedAuthority(hostUrl, trustedHosts) {
182
176
  * @returns true when the Host is ours (loopback or trusted) and any attached browser markers are same-origin.
183
177
  */
184
178
  function isTrustedApiRequest(request, trustedHosts) {
185
- const host = header(request.headers, "host");
179
+ const host = header$1(request.headers, "host");
186
180
  if (host === void 0) return false;
187
181
  const hostUrl = parseAuthority(host);
188
182
  if (hostUrl === void 0) return false;
189
183
  if (!isLoopbackHostname(hostUrl.hostname) && !isTrustedAuthority(hostUrl, trustedHosts)) return false;
190
- if (header(request.headers, "sec-fetch-site") === "cross-site") return false;
191
- const origin = header(request.headers, "origin");
184
+ if (header$1(request.headers, "sec-fetch-site") === "cross-site") return false;
185
+ const origin = header$1(request.headers, "origin");
192
186
  if (origin === void 0) return true;
193
187
  try {
194
188
  return new URL(origin).host === hostUrl.host;
@@ -197,6 +191,306 @@ function isTrustedApiRequest(request, trustedHosts) {
197
191
  }
198
192
  }
199
193
  //#endregion
194
+ //#region lib/types/browser-auth.js
195
+ /** Browser-session authentication for the Host Connection carrier. */
196
+ const AUTH_RECORD_KEY = credentialKey("client-connection", "browser-session");
197
+ const DAY_MILLISECONDS = 1440 * 60 * 1e3;
198
+ const SECRET_BYTES = 32;
199
+ const TOKEN_QUERY = "token";
200
+ const COOKIE_PREFIX = "dsh-auth-";
201
+ const COOKIE_PAYLOAD_VERSION = 1;
202
+ const STORED_SECRET_VERSION = 1;
203
+ const BASE64URL_PATTERN = /^[A-Za-z0-9_-]*$/;
204
+ const PROCESS_LAUNCH_TOKENS = /* @__PURE__ */ new WeakMap();
205
+ function isRecord(value) {
206
+ return typeof value === "object" && value !== null && !Array.isArray(value);
207
+ }
208
+ function encodeBase64Url(value) {
209
+ return Buffer.from(value).toString("base64").replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/u, "");
210
+ }
211
+ function decodeBase64Url(value) {
212
+ if (!BASE64URL_PATTERN.test(value) || value.length % 4 === 1) return void 0;
213
+ const padding = "=".repeat((4 - value.length % 4) % 4);
214
+ const decoded = Buffer.from(value.replaceAll("-", "+").replaceAll("_", "/") + padding, "base64");
215
+ return encodeBase64Url(decoded) === value ? decoded : void 0;
216
+ }
217
+ function processLaunchToken(owner) {
218
+ const existing = PROCESS_LAUNCH_TOKENS.get(owner);
219
+ if (existing !== void 0) return existing;
220
+ const created = encodeBase64Url(randomBytes(SECRET_BYTES));
221
+ PROCESS_LAUNCH_TOKENS.set(owner, created);
222
+ return created;
223
+ }
224
+ function header(headers, name) {
225
+ if (headers instanceof Headers) return headers.get(name) ?? void 0;
226
+ const value = headers[name];
227
+ return typeof value === "string" ? value : void 0;
228
+ }
229
+ /** Canonical request authority used as the cookie name and signed audience. */
230
+ function requestAuthority(headers) {
231
+ const host = header(headers, "host");
232
+ if (host === void 0) return void 0;
233
+ try {
234
+ return new URL(`http://${host}`).host;
235
+ } catch {
236
+ return;
237
+ }
238
+ }
239
+ function canonicalSecret(value) {
240
+ if (typeof value !== "string") return void 0;
241
+ const decoded = decodeBase64Url(value);
242
+ if (decoded === void 0 || decoded.byteLength !== SECRET_BYTES) return void 0;
243
+ return decoded;
244
+ }
245
+ function storedSecret(record) {
246
+ if (record === void 0) return void 0;
247
+ if (record.kind !== "grant" || !isRecord(record.payload) || record.payload.version !== STORED_SECRET_VERSION) throw new Error("client-connection: browser-session credential record has an unsupported format");
248
+ const secret = canonicalSecret(record.payload.secret);
249
+ if (secret === void 0) throw new Error("client-connection: browser-session credential record has an invalid secret");
250
+ return secret;
251
+ }
252
+ function tokenMatches(actual, expected) {
253
+ const actualBytes = Buffer.from(actual, "utf8");
254
+ const expectedBytes = Buffer.from(expected, "utf8");
255
+ return actualBytes.byteLength === expectedBytes.byteLength && timingSafeEqual(actualBytes, expectedBytes);
256
+ }
257
+ function cookieName(authority) {
258
+ return COOKIE_PREFIX + encodeBase64Url(createHash("sha256").update(authority).digest());
259
+ }
260
+ /** Read the exact generated cookie without implementing general Cookie decoding. */
261
+ function cookieValue(headerValue, name) {
262
+ for (const segment of headerValue.split(";")) {
263
+ const at = segment.indexOf("=");
264
+ if (at === -1 || segment.slice(0, at).trim() !== name) continue;
265
+ return segment.slice(at + 1).trim();
266
+ }
267
+ }
268
+ /** Serialize the fixed browser-session attributes; generated names and values are cookie-safe base64url. */
269
+ function sessionCookie(name, value, expiresAt, maxAgeSeconds) {
270
+ return `${name}=${value}; Max-Age=${String(maxAgeSeconds)}; Path=/; Expires=${new Date(expiresAt).toUTCString()}; HttpOnly; SameSite=Strict`;
271
+ }
272
+ function signature(secret, body) {
273
+ return createHmac("sha256", secret).update(body).digest();
274
+ }
275
+ function encodeCookie(payload, secret) {
276
+ const body = encodeBase64Url(Buffer.from(JSON.stringify(payload), "utf8"));
277
+ return `v1.${body}.${encodeBase64Url(signature(secret, body))}`;
278
+ }
279
+ function decodeCookie(value, secret) {
280
+ const parts = value.split(".");
281
+ const [version, body, encodedSignature] = parts;
282
+ if (parts.length !== 3 || version !== "v1" || body === void 0 || encodedSignature === void 0) return;
283
+ const actualSignature = decodeBase64Url(encodedSignature);
284
+ if (actualSignature === void 0) return void 0;
285
+ const expectedSignature = signature(secret, body);
286
+ if (actualSignature.byteLength !== expectedSignature.byteLength || !timingSafeEqual(actualSignature, expectedSignature)) return void 0;
287
+ let decoded;
288
+ try {
289
+ const bodyBytes = decodeBase64Url(body);
290
+ if (bodyBytes === void 0) return void 0;
291
+ decoded = JSON.parse(bodyBytes.toString("utf8"));
292
+ } catch {
293
+ return;
294
+ }
295
+ if (!isRecord(decoded) || decoded.version !== COOKIE_PAYLOAD_VERSION || typeof decoded.authority !== "string" || !Number.isSafeInteger(decoded.issuedAt) || !Number.isSafeInteger(decoded.expiresAt)) return void 0;
296
+ return decoded;
297
+ }
298
+ async function initializeSecret(credentials) {
299
+ const generated = {
300
+ version: STORED_SECRET_VERSION,
301
+ secret: encodeBase64Url(randomBytes(SECRET_BYTES))
302
+ };
303
+ const secret = storedSecret(await credentials.modifyRecord(AUTH_RECORD_KEY, (current) => {
304
+ if (current !== void 0) {
305
+ storedSecret(current);
306
+ return Promise.resolve(void 0);
307
+ }
308
+ return Promise.resolve({
309
+ kind: "grant",
310
+ payload: generated
311
+ });
312
+ }));
313
+ if (secret === void 0) throw new Error("client-connection: browser-session credential record was not created");
314
+ return secret;
315
+ }
316
+ /**
317
+ * Process launch-token exchange and persistent signed-cookie verification.
318
+ * Connection loads the credential provider's signing secret during activation
319
+ * and retains it for synchronous request authentication.
320
+ */
321
+ var BrowserAuth = class BrowserAuth {
322
+ secret;
323
+ launchToken;
324
+ maxAgeMilliseconds;
325
+ constructor(processOwner, secret, maxAgeDays) {
326
+ this.secret = secret;
327
+ this.launchToken = processLaunchToken(processOwner);
328
+ this.maxAgeMilliseconds = maxAgeDays * DAY_MILLISECONDS;
329
+ if (!Number.isSafeInteger(this.maxAgeMilliseconds) || !Number.isSafeInteger(Date.now() + this.maxAgeMilliseconds)) throw new Error("client-connection: cookieMaxAgeDays exceeds the safe timestamp range");
330
+ }
331
+ /**
332
+ * Initialize browser authentication and create its durable signing secret
333
+ * when this Harness home has none.
334
+ * @param processOwner - root application context retaining one token across Connection reloads.
335
+ * @param credentials - persistent credential provider for the Web profile.
336
+ * @param maxAgeDays - positive absolute browser-cookie lifetime in days.
337
+ * @returns initialized authentication owner with the process owner's launch token.
338
+ */
339
+ static async create(processOwner, credentials, maxAgeDays) {
340
+ return new BrowserAuth(processOwner, await initializeSecret(credentials), maxAgeDays);
341
+ }
342
+ /**
343
+ * Add this process's launch token to the ordinary application root URL.
344
+ * @param baseUrl - canonical browser origin without credentials.
345
+ * @returns root URL carrying the process token as its sole authentication input.
346
+ */
347
+ authenticatedUrl(baseUrl) {
348
+ const url = new URL(baseUrl);
349
+ url.pathname = "/";
350
+ url.search = "";
351
+ url.hash = "";
352
+ url.searchParams.set(TOKEN_QUERY, this.launchToken);
353
+ return url.href;
354
+ }
355
+ /**
356
+ * Authenticate an index request. A valid root query token mints the cookie
357
+ * and redirects to clean `/`; a valid cookie lets the caller serve the
358
+ * index; every other request receives the same minimal 401 response.
359
+ * @param req - incoming root or configured-index request.
360
+ * @param res - response owned when this method returns false.
361
+ * @returns true only when the caller may serve index.html.
362
+ */
363
+ authorizeIndex(req, res) {
364
+ /* v8 ignore next -- node:http always supplies url on server requests. */
365
+ const url = new URL(req.url ?? "/", "http://dsh.invalid");
366
+ const tokens = url.searchParams.getAll(TOKEN_QUERY);
367
+ if (tokens.length > 0) {
368
+ const authority = requestAuthority(req.headers);
369
+ if (req.method === "GET" && url.pathname === "/" && tokens.length === 1 && authority !== void 0 && tokenMatches(tokens.join(""), this.launchToken)) {
370
+ const issuedAt = Date.now();
371
+ const expiresAt = issuedAt + this.maxAgeMilliseconds;
372
+ const value = encodeCookie({
373
+ version: COOKIE_PAYLOAD_VERSION,
374
+ authority,
375
+ issuedAt,
376
+ expiresAt
377
+ }, this.secret);
378
+ res.writeHead(303, {
379
+ "cache-control": "no-store",
380
+ "location": "/",
381
+ "referrer-policy": "no-referrer",
382
+ "set-cookie": sessionCookie(cookieName(authority), value, expiresAt, Math.floor(this.maxAgeMilliseconds / 1e3))
383
+ });
384
+ res.end();
385
+ return false;
386
+ }
387
+ if (req.method === "GET" && url.pathname === "/" && this.isAuthenticated(req)) {
388
+ res.writeHead(303, {
389
+ "cache-control": "no-store",
390
+ "location": "/",
391
+ "referrer-policy": "no-referrer"
392
+ });
393
+ res.end();
394
+ return false;
395
+ }
396
+ this.writeUnauthorized(req, res);
397
+ return false;
398
+ }
399
+ if (this.isAuthenticated(req)) return true;
400
+ this.writeUnauthorized(req, res);
401
+ return false;
402
+ }
403
+ /**
404
+ * Verify the authority-bound browser cookie on a Host request.
405
+ * @param request - request headers carrying Host and Cookie.
406
+ * @returns true only for an unexpired cookie signed by this activation's loaded secret.
407
+ */
408
+ isAuthenticated(request) {
409
+ const authority = requestAuthority(request.headers);
410
+ const rawCookie = header(request.headers, "cookie");
411
+ if (authority === void 0 || rawCookie === void 0) return false;
412
+ const value = cookieValue(rawCookie, cookieName(authority));
413
+ if (value === void 0) return false;
414
+ const payload = decodeCookie(value, this.secret);
415
+ if (payload === void 0 || payload.authority !== authority) return false;
416
+ const now = Date.now();
417
+ return payload.issuedAt <= now && payload.expiresAt > now && payload.expiresAt > payload.issuedAt && payload.expiresAt - payload.issuedAt <= this.maxAgeMilliseconds;
418
+ }
419
+ writeUnauthorized(req, res) {
420
+ res.writeHead(401, {
421
+ "cache-control": "no-store",
422
+ "content-type": "text/plain; charset=utf-8"
423
+ });
424
+ res.end(req.method === "HEAD" ? void 0 : "dsh web authentication required; reopen the URL printed by dsh web.\n");
425
+ }
426
+ };
427
+ //#endregion
428
+ //#region lib/types/rpc.js
429
+ /** Generic unary RPC contracts shared by the Host and Client Connection halves. */
430
+ /**
431
+ * Brand one validated string as a Connection correlation id.
432
+ * @param id - validated wire identity.
433
+ * @returns the same string with the correlation-id brand.
434
+ */
435
+ function RpcId(id) {
436
+ return id;
437
+ }
438
+ /**
439
+ * Convert a rejected transport operation into a generic failure result.
440
+ * @param error - rejected transport value.
441
+ * @returns an `internal` failure preserving the available message.
442
+ */
443
+ function transportError(error) {
444
+ return {
445
+ ok: false,
446
+ error: {
447
+ code: "gateway/internal",
448
+ message: error instanceof Error ? error.message : String(error),
449
+ details: {}
450
+ }
451
+ };
452
+ }
453
+ //#endregion
454
+ //#region lib/types/rpc-schema.js
455
+ /** Runtime validation for Connection RPC envelopes. */
456
+ /** Correlation id after wire validation. */
457
+ const rpcIdSchema = z$1.string();
458
+ /** Generic endpoint failure carried in a response envelope. */
459
+ const rpcErrorSchema = z$1.object({
460
+ code: z$1.string(),
461
+ message: z$1.string(),
462
+ details: z$1.record(z$1.string(), z$1.unknown())
463
+ });
464
+ /**
465
+ * Build the result parser for one endpoint value parser.
466
+ * @param value - endpoint-owned success-value parser.
467
+ * @returns parser for either a success value or generic failure.
468
+ */
469
+ function rpcResultSchema(value) {
470
+ return z$1.union([z$1.object({
471
+ ok: z$1.literal(true),
472
+ value
473
+ }), z$1.object({
474
+ ok: z$1.literal(false),
475
+ error: rpcErrorSchema
476
+ })]);
477
+ }
478
+ /** Client request envelope; endpoint payload validation belongs to its owner. */
479
+ const clientRequestSchema = z$1.object({
480
+ type: z$1.literal("client-request"),
481
+ rpcId: rpcIdSchema,
482
+ method: z$1.string(),
483
+ payload: z$1.unknown()
484
+ });
485
+ /** Server response envelope; endpoint value validation belongs to its caller. */
486
+ const serverResponseSchema = z$1.object({
487
+ type: z$1.literal("server-response"),
488
+ rpcId: rpcIdSchema,
489
+ result: rpcResultSchema(z$1.unknown().optional())
490
+ });
491
+ /** Either Connection RPC envelope direction. */
492
+ const rpcMessageSchema = z$1.discriminatedUnion("type", [clientRequestSchema, serverResponseSchema]);
493
+ //#endregion
200
494
  //#region lib/types/rpc-host.js
201
495
  /** Host registry and HTTP adapter for generic Connection RPC channels. */
202
496
  const INVALID_REQUEST_RPC_ID = RpcId("invalid-request");
@@ -205,50 +499,87 @@ const ENDPOINT_SEGMENT_PATTERN = /^[A-Za-z0-9_$.-]+$/;
205
499
  /** Host Connection service whose channel registrations belong to the caller fiber. */
206
500
  var HostConnectionService = class extends Service {
207
501
  trustedHosts;
502
+ browserAuth;
208
503
  interceptors = /* @__PURE__ */ new Map();
504
+ fetchRoutes = /* @__PURE__ */ new Map();
209
505
  /**
210
506
  * Provide the Host half over the active HTTP server.
211
507
  * @param ctx - owning Connection plugin context.
212
- * @param trustedHosts - deployment authorities accepted by trusted-host channels.
508
+ * @param trustedHosts - deployment authorities accepted by the Host/Origin fence.
509
+ * @param browserAuth - process token and persistent browser-session owner.
213
510
  */
214
- constructor(ctx, trustedHosts) {
511
+ constructor(ctx, trustedHosts, browserAuth) {
215
512
  super(ctx, "connection");
216
513
  this.trustedHosts = trustedHosts;
514
+ this.browserAuth = browserAuth;
217
515
  }
218
516
  /** Generic channel registry scoped to the Context reading this service. */
219
517
  get rpc() {
220
518
  const owner = this.ctx;
221
519
  return {
222
- handle: (channel, handler, options) => this.register(owner, channel, handler, options),
223
- intercept: (channel, matches, handler, options) => this.registerInterceptor(owner, channel, matches, handler, options)
520
+ handle: (channel, handler) => this.register(owner, channel, handler),
521
+ intercept: (channel, matches, handler) => this.registerInterceptor(owner, channel, matches, handler)
224
522
  };
225
523
  }
524
+ /** Exact Fetch-route registry scoped to the Context reading this service. */
525
+ get fetch() {
526
+ const owner = this.ctx;
527
+ return { register: (route) => this.registerFetchRoute(owner, route) };
528
+ }
529
+ /** Apply the configured Host/Origin fence, then browser authentication. */
530
+ requestRejection(request) {
531
+ if (!isTrustedApiRequest(request, this.trustedHosts)) return 403;
532
+ return this.browserAuth.isAuthenticated(request) ? void 0 : 401;
533
+ }
534
+ /** Authenticate an index request through the process-token exchange or cookie. */
535
+ authorizeIndex(request, response) {
536
+ return this.browserAuth.authorizeIndex(request, response);
537
+ }
538
+ /** Add this process's launch token to the clean application URL. */
539
+ authenticatedUrl(baseUrl) {
540
+ return this.browserAuth.authenticatedUrl(baseUrl);
541
+ }
226
542
  /**
227
- * Compose one shared-channel Fetch handler from its interceptor and fallback.
543
+ * Compose one shared-channel Fetch handler from exact routes and its interceptor.
228
544
  * @param channel - shared channel mounted by Connection.
229
- * @param fallback - handler for endpoints not claimed by the interceptor.
230
- * @returns Fetch handler that selects exactly one target for each request.
545
+ * @returns Fetch handler that selects one owner or returns 404.
231
546
  */
232
- createSharedFetchHandler(channel, fallback) {
547
+ createSharedFetchHandler(channel) {
233
548
  return { fetch: (request) => {
234
- const endpoint = endpointFromPath(channel, new URL(request.url).pathname);
549
+ const pathname = new URL(request.url).pathname;
550
+ const route = this.fetchRoutes.get(pathname);
551
+ if (route?.methods.has(request.method) === true) return route.fetch(request);
552
+ const endpoint = endpointFromPath(channel, pathname);
235
553
  const interceptor = this.interceptors.get(channel);
236
- if (endpoint === void 0 || interceptor === void 0 || !interceptor.matches(endpoint)) return fallback.fetch(request);
237
- if (interceptor.options.authority === "loopback" && !isTrustedApiRequest(request, [])) return Promise.resolve(new Response("forbidden", { status: 403 }));
554
+ if (endpoint === void 0 || interceptor === void 0 || !interceptor.matches(endpoint)) return Promise.resolve(new Response("not found", { status: 404 }));
238
555
  return interceptor.fetchHandler.fetch(request);
239
556
  } };
240
557
  }
241
- register(owner, channel, handler, options) {
558
+ registerFetchRoute(owner, route) {
559
+ assertFetchRoute(route);
560
+ const registered = {
561
+ methods: new Set(route.methods),
562
+ fetch: route.fetch
563
+ };
564
+ return owner.effect(() => {
565
+ if (this.fetchRoutes.has(route.path)) throw new Error(`connection: exact Fetch route ${JSON.stringify(route.path)} is already registered`);
566
+ this.fetchRoutes.set(route.path, registered);
567
+ return () => {
568
+ this.fetchRoutes.delete(route.path);
569
+ };
570
+ }, `client-connection: ${route.path} Fetch route`);
571
+ }
572
+ register(owner, channel, handler) {
242
573
  assertChannel(channel);
243
- const trustedHosts = options.authority === "loopback" ? [] : this.trustedHosts;
244
574
  const fetchHandler = rpcFetchHandler(channel, handler);
245
575
  const route = {
246
576
  kind: "prefix",
247
577
  path: channel,
248
578
  handler: async (req, res) => {
249
- if (!isTrustedApiRequest(req, trustedHosts)) {
250
- res.writeHead(403);
251
- res.end("forbidden");
579
+ const rejection = this.requestRejection(req);
580
+ if (rejection !== void 0) {
581
+ res.writeHead(rejection);
582
+ res.end(rejection === 401 ? "unauthorized" : "forbidden");
252
583
  return;
253
584
  }
254
585
  await bridge(req, res, fetchHandler);
@@ -256,12 +587,11 @@ var HostConnectionService = class extends Service {
256
587
  };
257
588
  return owner.effect(() => owner.webServer.register(route), `client-connection: ${channel} rpc channel`);
258
589
  }
259
- registerInterceptor(owner, channel, matches, handler, options) {
590
+ registerInterceptor(owner, channel, matches, handler) {
260
591
  if (channel !== "/api") throw new Error(`connection: invalid shared RPC channel ${JSON.stringify(channel)}`);
261
592
  const interceptor = {
262
593
  matches,
263
- fetchHandler: rpcFetchHandler(channel, handler),
264
- options
594
+ fetchHandler: rpcFetchHandler(channel, handler)
265
595
  };
266
596
  return owner.effect(() => {
267
597
  if (this.interceptors.has(channel)) throw new Error(`connection: shared RPC channel ${JSON.stringify(channel)} already has an interceptor`);
@@ -287,7 +617,7 @@ function rpcFetchHandler(channel, handler) {
287
617
  if (!envelope.success) return invalidEnvelopeResponse(body, envelope.error.issues);
288
618
  const message = envelope.data;
289
619
  if (message.method !== endpoint) return errorResponse(message.rpcId, {
290
- code: "bad-request",
620
+ code: "gateway/bad-request",
291
621
  message: `method ${JSON.stringify(message.method)} does not match endpoint ${JSON.stringify(endpoint)}`,
292
622
  details: { issues: [] }
293
623
  });
@@ -302,7 +632,7 @@ function rpcFetchHandler(channel, handler) {
302
632
  function invalidEnvelopeResponse(body, issues) {
303
633
  const rawId = body?.rpcId;
304
634
  return errorResponse(typeof rawId === "string" ? RpcId(rawId) : INVALID_REQUEST_RPC_ID, {
305
- code: "bad-request",
635
+ code: "gateway/bad-request",
306
636
  message: "invalid client-request message",
307
637
  details: { issues }
308
638
  });
@@ -330,138 +660,10 @@ function fullResponse(rpcId, result) {
330
660
  function assertChannel(channel) {
331
661
  if (!CHANNEL_PATTERN.test(channel) || channel === "/api") throw new Error(`connection: invalid or reserved RPC channel ${JSON.stringify(channel)}`);
332
662
  }
333
- //#endregion
334
- //#region lib/types/websocket-downlink.js
335
- /** Host-side WebSocket carrier for the two server-to-browser event streams. */
336
- function serverRequest(frame) {
337
- return {
338
- type: "server-request",
339
- rpcId: frame.rpcId,
340
- method: frame.payload.type,
341
- payload: frame.payload
342
- };
343
- }
344
- function send(socket, frame) {
345
- return new Promise((resolve, reject) => {
346
- if (socket.readyState !== WebSocket.OPEN) {
347
- reject(/* @__PURE__ */ new Error("websocket downlink closed before frame delivery"));
348
- return;
349
- }
350
- socket.send(JSON.stringify(serverRequest(frame)), (error) => {
351
- if (error) reject(error);
352
- else resolve();
353
- });
354
- });
355
- }
356
- function failureFrame(error) {
357
- return {
358
- rpcId: RpcId(randomUUID()),
359
- payload: {
360
- type: "stream/error",
361
- error: {
362
- code: "internal",
363
- message: String(error),
364
- details: {}
365
- }
366
- }
367
- };
368
- }
369
- /**
370
- * Owns WebSocket negotiation and frame pumping for the connection plugin's
371
- * two downlinks. Client messages are a protocol violation: upstream traffic
372
- * remains on HTTP.
373
- */
374
- var WebSocketDownlinks = class {
375
- api;
376
- server = new WebSocketServer({ noServer: true });
377
- pumps = /* @__PURE__ */ new Set();
378
- /** @param api - host API supplying the typed event streams. */
379
- constructor(api) {
380
- this.api = api;
381
- }
382
- /**
383
- * Upgrade one socket and pump the mux stream until either side closes.
384
- * @param req - HTTP upgrade request.
385
- * @param socket - Raw socket transferred by the HTTP server.
386
- * @param head - Bytes already read after the upgrade headers.
387
- */
388
- handleMux(req, socket, head) {
389
- this.upgrade(req, socket, head, (signal) => this.api.events.mux({
390
- rpcId: RpcId(randomUUID()),
391
- payload: {}
392
- }, signal));
393
- }
394
- /**
395
- * Upgrade one socket and pump the host stream until either side closes.
396
- * @param req - HTTP upgrade request.
397
- * @param socket - Raw socket transferred by the HTTP server.
398
- * @param head - Bytes already read after the upgrade headers.
399
- */
400
- handleHost(req, socket, head) {
401
- this.upgrade(req, socket, head, (signal) => this.api.events.host({
402
- rpcId: RpcId(randomUUID()),
403
- payload: {}
404
- }, signal));
405
- }
406
- /**
407
- * Terminate owned sockets and await the no-server acceptor plus frame pumps.
408
- * @returns A promise resolving after every socket and source iterator stops.
409
- */
410
- async close() {
411
- for (const socket of this.server.clients) socket.terminate();
412
- await new Promise((resolve, reject) => {
413
- this.server.close((error) => {
414
- if (error === void 0) resolve();
415
- else reject(error);
416
- });
417
- });
418
- await Promise.all(this.pumps);
419
- }
420
- upgrade(req, socket, head, open) {
421
- this.server.handleUpgrade(req, socket, head, (websocket) => {
422
- const abort = new AbortController();
423
- websocket.once("close", () => {
424
- abort.abort();
425
- });
426
- websocket.once("error", () => {
427
- abort.abort();
428
- });
429
- websocket.once("message", () => {
430
- websocket.close(1008, "downlink only");
431
- });
432
- const pump = this.pump(websocket, open(abort.signal), abort);
433
- this.pumps.add(pump);
434
- pump.then(() => {
435
- this.pumps.delete(pump);
436
- });
437
- });
438
- }
439
- async pump(socket, frames, abort) {
440
- try {
441
- for await (const frame of frames) await send(socket, frame);
442
- } catch (error) {
443
- if (!abort.signal.aborted) try {
444
- await send(socket, failureFrame(error));
445
- } catch {}
446
- } finally {
447
- abort.abort();
448
- if (socket.readyState === WebSocket.OPEN) socket.close();
449
- }
450
- }
451
- };
452
- /**
453
- * Reject an untrusted upgrade before protocol negotiation.
454
- * @param socket - Raw HTTP socket that remains owned by the caller.
455
- */
456
- function rejectWebSocketUpgrade(socket) {
457
- socket.end([
458
- "HTTP/1.1 403 Forbidden",
459
- "Connection: close",
460
- "Content-Type: text/plain; charset=utf-8",
461
- "Content-Length: 9",
462
- "",
463
- "forbidden"
464
- ].join("\r\n"));
663
+ function assertFetchRoute(route) {
664
+ if (endpointFromPath("/api", route.path) === void 0) throw new Error(`connection: invalid exact Fetch route ${JSON.stringify(route.path)}`);
665
+ if (route.methods.length === 0) throw new Error(`connection: exact Fetch route ${JSON.stringify(route.path)} declares no methods`);
666
+ if (new Set(route.methods).size !== route.methods.length) throw new Error(`connection: exact Fetch route ${JSON.stringify(route.path)} repeats a method`);
465
667
  }
466
668
  //#endregion
467
669
  //#region lib/types/index.js
@@ -475,114 +677,45 @@ function assertImageBodyCapacity(ctx, maxRequestBodyBytes) {
475
677
  const requiredImageBodyBytes = Math.ceil(attachments.imageLimits.maxMessageImageBytes * 4 / 3) + REQUEST_ENVELOPE_HEADROOM_BYTES;
476
678
  if (maxRequestBodyBytes < requiredImageBodyBytes) throw new Error(`client-connection maxRequestBodyBytes (${String(maxRequestBodyBytes)}) must be at least ${String(requiredImageBodyBytes)} for the configured aggregate image limit`);
477
679
  }
478
- /** Services required before providing Connection; API Proxy is an optional `/api` fallback. */
479
- const inject = ["webServer"];
680
+ /** Services required before providing Connection. */
681
+ const inject = ["webServer", "credentials"];
480
682
  const Config = z.object({
481
683
  trustedHosts: z.array(String).default([]),
684
+ cookieMaxAgeDays: z.natural().min(1).default(30),
482
685
  maxRequestBodyBytes: z.natural().min(1).default(DEFAULT_MAX_REQUEST_BODY_BYTES)
483
686
  });
484
687
  /**
485
- * Methods gated to loopback even on a trusted-host deployment. Native dialogs
486
- * act on the host machine; the settings and credential domains mutate the
487
- * user's configuration and secret store, and READING them is equally
488
- * privileged — `settings.describe` returns every exposed namespace's
489
- * configuration and `credentials.describe` reports whether an arbitrary
490
- * environment-variable name is configured and where from, which is
491
- * reconnaissance no anonymous caller should have. `trustedHosts` is a
492
- * DNS-rebinding fence, explicitly not authentication, so the whole
493
- * configuration plane stays loopback-same-origin until a real authentication
494
- * layer exists. `llm.discoverModels` belongs to that plane on both counts: it
495
- * carries a draft credential, and it makes the HOST issue a GET to a URL the
496
- * caller chose and reports back the status or the parsed body — an anonymous
497
- * LAN caller would have a probe for whatever the host can reach and the
498
- * browser cannot.
499
- *
500
- * The model catalog (`llm.providers`, `llm.models`) is deliberately NOT here:
501
- * it carries provider ids, display names, and model lists — no endpoints,
502
- * keys, or key state — and a LAN client's model picker legitimately needs it.
503
- */
504
- const PRIVILEGED_METHODS = new Set([
505
- "agentPreset.read",
506
- "agentPreset.copy",
507
- "agentPreset.openDocument",
508
- "agentPreset.remove",
509
- "host.pickDirectory",
510
- "host.openPath",
511
- "settings.describe",
512
- "settings.openDocument",
513
- "settings.update",
514
- "settings.replace",
515
- "settings.mutate",
516
- "credentials.describe",
517
- "credentials.set",
518
- "credentials.unset",
519
- "llm.discoverModels"
520
- ]);
521
- /**
522
688
  * Mounts the API gateway under the browser transport prefix. Every request on
523
- * the prefix passes the browser-trust fence first (DNS-rebinding and
524
- * cross-site defense — [api-request-trust](./api-request-trust.ts));
525
- * privileged methods additionally pass it with an empty trust list, which
526
- * pins them to loopback.
689
+ * the prefix passes the Host/Origin browser-trust fence and persistent browser
690
+ * authentication before dispatch.
527
691
  * @param ctx - Host plugin context.
528
692
  * @param config - resolved plugin config (schema defaults applied).
529
693
  */
530
- function apply(ctx, config) {
694
+ async function apply(ctx, config) {
531
695
  const trustedHosts = config?.trustedHosts ?? [];
696
+ const cookieMaxAgeDays = config?.cookieMaxAgeDays ?? 30;
532
697
  const maxRequestBodyBytes = config?.maxRequestBodyBytes ?? 314572800;
533
698
  for (const entry of trustedHosts) assertTrustedAuthority(entry);
534
- if (ctx.get("apiProxy") !== void 0) assertImageBodyCapacity(ctx, maxRequestBodyBytes);
535
- const fetchHandler = new HostConnectionService(ctx, trustedHosts).createSharedFetchHandler(API_PATH, { async fetch(request) {
536
- const pathname = new URL(request.url).pathname;
537
- const method = pathname.startsWith(`/api/`) ? pathname.slice(5) : void 0;
538
- if (method !== void 0 && PRIVILEGED_METHODS.has(method) && !isTrustedApiRequest(request, [])) return new Response("forbidden", { status: 403 });
539
- if (request.method === "GET" && (pathname === MUX_EVENTS_PATH || pathname === HOST_EVENTS_PATH)) return new Response("upgrade required", {
540
- status: 426,
541
- headers: {
542
- connection: "Upgrade",
543
- upgrade: "websocket"
544
- }
545
- });
546
- const apiProxy = ctx.get("apiProxy");
547
- if (apiProxy === void 0) return new Response("not found", { status: 404 });
548
- return toFetchHandler(apiProxy).fetch(request);
549
- } });
699
+ assertImageBodyCapacity(ctx, maxRequestBodyBytes);
700
+ const connection = new HostConnectionService(ctx, trustedHosts, await BrowserAuth.create(ctx.root, ctx.credentials, cookieMaxAgeDays));
701
+ const fetchHandler = connection.createSharedFetchHandler(API_PATH);
550
702
  const route = {
551
703
  kind: "prefix",
552
704
  path: API_PATH,
553
705
  handler: async (req, res) => {
554
- if (!isTrustedApiRequest(req, trustedHosts)) {
555
- res.writeHead(403);
556
- res.end("forbidden");
706
+ const rejection = connection.requestRejection(req);
707
+ if (rejection !== void 0) {
708
+ res.writeHead(rejection);
709
+ res.end(rejection === 401 ? "unauthorized" : "forbidden");
557
710
  return;
558
711
  }
559
712
  await bridge(req, res, fetchHandler, maxRequestBodyBytes);
560
713
  }
561
714
  };
562
715
  ctx.effect(() => ctx.webServer.register(route), "client-connection: /api route");
563
- ctx.inject(["apiProxy"], (apiCtx) => {
564
- assertImageBodyCapacity(apiCtx, maxRequestBodyBytes);
565
- const downlinks = new WebSocketDownlinks(apiCtx.apiProxy);
566
- const registerDownlink = (path, handle) => {
567
- apiCtx.effect(() => apiCtx.webServer.registerUpgrade({
568
- path,
569
- handler: (req, socket, head) => {
570
- if (!isTrustedApiRequest(req, trustedHosts)) {
571
- rejectWebSocketUpgrade(socket);
572
- return;
573
- }
574
- return handle(req, socket, head);
575
- }
576
- }), `client-connection: ${path} WebSocket`);
577
- };
578
- apiCtx.effect(() => () => downlinks.close(), "client-connection: WebSocket downlinks");
579
- registerDownlink(MUX_EVENTS_PATH, (req, socket, head) => {
580
- downlinks.handleMux(req, socket, head);
581
- });
582
- registerDownlink(HOST_EVENTS_PATH, (req, socket, head) => {
583
- downlinks.handleHost(req, socket, head);
584
- });
716
+ ctx.inject(["attachments"], (attachmentCtx) => {
717
+ assertImageBodyCapacity(attachmentCtx, maxRequestBodyBytes);
585
718
  });
586
719
  }
587
720
  //#endregion
588
- export { API_PATH, Config, HOST_EVENTS_PATH, HostConnectionService, MUX_EVENTS_PATH, apply, inject, name };
721
+ export { API_PATH, Config, HostConnectionService, RpcId, apply, clientRequestSchema, inject, name, rpcErrorSchema, rpcIdSchema, rpcMessageSchema, rpcResultSchema, serverResponseSchema, transportError };