@claxedo/workspace-relay 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/main.mjs CHANGED
@@ -8935,10 +8935,10 @@ var require_abort = __commonJS({
8935
8935
  "../../node_modules/.bun/asynckit@0.4.0/node_modules/asynckit/lib/abort.js"(exports, module) {
8936
8936
  module.exports = abort;
8937
8937
  function abort(state) {
8938
- Object.keys(state.jobs).forEach(clean2.bind(state));
8938
+ Object.keys(state.jobs).forEach(clean.bind(state));
8939
8939
  state.jobs = {};
8940
8940
  }
8941
- function clean2(key) {
8941
+ function clean(key) {
8942
8942
  if (typeof this.jobs[key] == "function") {
8943
8943
  this.jobs[key]();
8944
8944
  }
@@ -16548,9 +16548,6 @@ var require_axios = __commonJS({
16548
16548
  }
16549
16549
  });
16550
16550
 
16551
- // src/main.ts
16552
- import { createHash } from "node:crypto";
16553
-
16554
16551
  // ../../node_modules/.bun/posthog-node@4.18.0+302f8eed98f6f908/node_modules/posthog-node/lib/node/index.mjs
16555
16552
  import { posix, dirname, sep } from "path";
16556
16553
  import { createReadStream } from "node:fs";
@@ -20331,12 +20328,12 @@ var PostHog = class extends PostHogBackendClient {
20331
20328
  // src/main.ts
20332
20329
  import {
20333
20330
  createRemoteJWKSet,
20334
- exportJWK as exportJWK2,
20335
20331
  exportPKCS8,
20336
20332
  generateKeyPair,
20337
20333
  importPKCS8,
20338
20334
  importSPKI
20339
20335
  } from "jose";
20336
+ import { trimToUndefined } from "@claxedo/helpers/string";
20340
20337
 
20341
20338
  // src/bun.ts
20342
20339
  import {
@@ -20349,10 +20346,24 @@ import {
20349
20346
  // src/server.ts
20350
20347
  import { Hono } from "hono";
20351
20348
  import { cors } from "hono/cors";
20352
- import { exportJWK } from "jose";
20349
+ import { exportJWK as exportJWK2 } from "jose";
20350
+
20351
+ // ../claxedo-helpers/dist/guards.mjs
20352
+ function isRecord(value) {
20353
+ return value !== null && typeof value === "object" && !Array.isArray(value);
20354
+ }
20355
+ function isFiniteNumber(value) {
20356
+ return typeof value === "number" && Number.isFinite(value);
20357
+ }
20358
+ function asFiniteNumber(value) {
20359
+ return isFiniteNumber(value) ? value : void 0;
20360
+ }
20361
+ function numberClaim(payload, key) {
20362
+ return asFiniteNumber(payload[key]);
20363
+ }
20353
20364
 
20354
20365
  // src/auth.ts
20355
- import { SignJWT, errors, jwtVerify } from "jose";
20366
+ import { SignJWT, errors, exportJWK, importJWK, jwtVerify } from "jose";
20356
20367
  var algorithms = ["EdDSA", "ES256", "RS256"];
20357
20368
  var runtimeAccessTokenIssuer = "claxedo-control-plane";
20358
20369
  var runtimeAccessTokenAudience = "workspace-relay";
@@ -20372,25 +20383,51 @@ function seconds(input = Date.now()) {
20372
20383
  return Math.floor(input / 1e3);
20373
20384
  }
20374
20385
  function requireAlgorithm(input) {
20375
- if (algorithms.includes(input)) return input;
20386
+ const algorithm = algorithms.find((candidate) => candidate === input);
20387
+ if (algorithm) return algorithm;
20376
20388
  throw new WorkspaceRelayAuthError("invalid_relay_token", "Unsupported relay token algorithm");
20377
20389
  }
20378
20390
  function stringClaim(payload, key) {
20379
20391
  const value = payload[key];
20380
20392
  return typeof value === "string" && value.trim() ? value : void 0;
20381
20393
  }
20382
- function numberClaim(payload, key) {
20383
- const value = payload[key];
20384
- return typeof value === "number" && Number.isFinite(value) ? value : void 0;
20385
- }
20386
20394
  function stringArrayClaim(payload, key) {
20387
20395
  const value = payload[key];
20388
20396
  return Array.isArray(value) && value.every((item) => typeof item === "string" && item.trim()) ? value : void 0;
20389
20397
  }
20398
+ function isHostGeneration(value) {
20399
+ return typeof value === "number" && Number.isInteger(value) && value >= 0;
20400
+ }
20401
+ function hostGenerationClaims(payload) {
20402
+ const enrollment_id = stringClaim(payload, "enrollment_id");
20403
+ const generation = payload.generation;
20404
+ if (generation === void 0) return enrollment_id ? { enrollment_id } : {};
20405
+ if (!isHostGeneration(generation) || !enrollment_id) return null;
20406
+ return { enrollment_id, generation };
20407
+ }
20390
20408
  function roleClaim(payload) {
20391
20409
  const value = stringClaim(payload, "role");
20392
20410
  return value === "viewer" || value === "editor" || value === "admin" || value === "owner" ? value : void 0;
20393
20411
  }
20412
+ function actorProfileClaims(payload) {
20413
+ const actor_public_id = stringClaim(payload, "actor_public_id");
20414
+ const actor_name = stringClaim(payload, "actor_name");
20415
+ const actor_avatar_url = stringClaim(payload, "actor_avatar_url");
20416
+ if (!actor_public_id && !actor_name && !actor_avatar_url) return {};
20417
+ if (!actor_public_id || !actor_name) return void 0;
20418
+ return { actor_public_id, actor_name, ...actor_avatar_url ? { actor_avatar_url } : {} };
20419
+ }
20420
+ function actorProfilePayload(input) {
20421
+ if (!input.actorPublicId && !input.actorName && !input.actorAvatarUrl) return {};
20422
+ if (!input.actorPublicId || !input.actorName) {
20423
+ throw new WorkspaceRelayAuthError("relay_token_claims_invalid", "Actor display profile claims are incomplete");
20424
+ }
20425
+ return {
20426
+ actor_public_id: input.actorPublicId,
20427
+ actor_name: input.actorName,
20428
+ ...input.actorAvatarUrl ? { actor_avatar_url: input.actorAvatarUrl } : {}
20429
+ };
20430
+ }
20394
20431
  function isRelayClaimPair(input) {
20395
20432
  return input.access === "cloud" && input.backing === "cloud-vm" || input.access === "user-hosted" && input.backing === "local-worktree";
20396
20433
  }
@@ -20411,6 +20448,21 @@ function checkTarget(payload, expected) {
20411
20448
  throw new WorkspaceRelayAuthError("relay_token_host_mismatch", "Relay token host does not match request");
20412
20449
  }
20413
20450
  }
20451
+ async function deriveRelayHostPublicKey(privateKey) {
20452
+ const jwk = await exportJWK(privateKey);
20453
+ const imported = await importJWK({ kty: jwk.kty, crv: jwk.crv, x: jwk.x }, "EdDSA", { extractable: true });
20454
+ if (imported instanceof Uint8Array) {
20455
+ throw new Error("Relay host public key imported as raw bytes; expected an EdDSA public key");
20456
+ }
20457
+ return imported;
20458
+ }
20459
+ async function deriveRelayHostKid(publicKey) {
20460
+ const jwk = await exportJWK(publicKey);
20461
+ const material = jwk.x ?? jwk.n ?? "";
20462
+ if (!material) throw new Error("Cannot derive kid: public key has no public component");
20463
+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(material));
20464
+ return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("").slice(0, 16);
20465
+ }
20414
20466
  async function verifyJwt(token, key, input) {
20415
20467
  try {
20416
20468
  if (typeof key === "function") {
@@ -20477,28 +20529,40 @@ async function mintRelayHostToken(input, key, alg) {
20477
20529
  const protectedHeader = { alg: requireAlgorithm(alg) };
20478
20530
  if (input.kid) protectedHeader.kid = input.kid;
20479
20531
  return await new SignJWT({
20532
+ principal_kind: input.principalKind,
20533
+ actor_id: input.actorId,
20534
+ actor_kind: input.actorKind,
20535
+ ...actorProfilePayload(input),
20480
20536
  org_id: input.orgId,
20481
20537
  workspace_id: input.workspaceId,
20482
20538
  host_id: input.hostId,
20483
20539
  role: input.role,
20484
20540
  access: input.access,
20485
- backing: input.backing
20486
- }).setProtectedHeader(protectedHeader).setIssuer(relayHostTokenIssuer).setAudience(relayHostTokenAudience).setSubject(input.subject).setIssuedAt(now).setExpirationTime(now + (input.ttlSeconds ?? 60)).setJti(input.jti ?? jti()).sign(key);
20541
+ backing: input.backing,
20542
+ parent_jti: input.parentJti
20543
+ }).setProtectedHeader(protectedHeader).setIssuer(relayHostTokenIssuer).setAudience(relayHostTokenAudience).setIssuedAt(now).setExpirationTime(now + (input.ttlSeconds ?? 60)).setJti(input.jti ?? jti()).sign(key);
20487
20544
  }
20488
20545
  function runtimeClaims(payload) {
20489
20546
  const exp = numberClaim(payload, "exp");
20490
20547
  const iat = numberClaim(payload, "iat");
20491
- const sub = stringClaim(payload, "sub");
20548
+ const principal_kind = stringClaim(payload, "principal_kind");
20549
+ const actor_id = stringClaim(payload, "actor_id");
20550
+ const actor_kind = stringClaim(payload, "actor_kind");
20492
20551
  const jti2 = stringClaim(payload, "jti");
20493
20552
  const org_id = stringClaim(payload, "org_id");
20494
20553
  const workspace_id = stringClaim(payload, "workspace_id");
20495
20554
  const host_id = stringClaim(payload, "host_id");
20496
20555
  const role = roleClaim(payload);
20497
- if (!exp || !iat || !sub || !jti2 || !org_id || !workspace_id || !host_id || !role) return;
20556
+ if (!exp || !iat || !jti2 || !org_id || !workspace_id || !host_id || !role || !actor_id || principal_kind !== "user" && principal_kind !== "service" || actor_kind !== "human" && actor_kind !== "agent" || principal_kind === "user" && actor_kind !== "human" || principal_kind === "service" && actor_kind !== "agent") return void 0;
20557
+ const actorProfile = actorProfileClaims(payload);
20558
+ if (!actorProfile) return void 0;
20498
20559
  return {
20499
20560
  iss: runtimeAccessTokenIssuer,
20500
20561
  aud: runtimeAccessTokenAudience,
20501
- sub,
20562
+ principal_kind,
20563
+ actor_id,
20564
+ actor_kind,
20565
+ ...actorProfile,
20502
20566
  org_id,
20503
20567
  workspace_id,
20504
20568
  host_id,
@@ -20515,13 +20579,15 @@ function hostTunnelClaims(payload) {
20515
20579
  const jti2 = stringClaim(payload, "jti");
20516
20580
  const host_id = stringClaim(payload, "host_id");
20517
20581
  const workspace_ids = stringArrayClaim(payload, "workspace_ids");
20518
- if (!exp || !iat || !sub || !jti2 || !host_id || !workspace_ids?.length) return;
20582
+ const fence = hostGenerationClaims(payload);
20583
+ if (!exp || !iat || !sub || !jti2 || !host_id || !workspace_ids?.length || fence === null) return void 0;
20519
20584
  return {
20520
20585
  iss: runtimeAccessTokenIssuer,
20521
20586
  aud: hostTunnelTokenAudience,
20522
20587
  sub,
20523
20588
  host_id,
20524
20589
  workspace_ids,
20590
+ ...fence,
20525
20591
  exp,
20526
20592
  iat,
20527
20593
  jti: jti2
@@ -20569,11 +20635,7 @@ function createOriginMatcher(patterns) {
20569
20635
  }
20570
20636
 
20571
20637
  // src/http.ts
20572
- function bearerToken(header) {
20573
- if (!header) return;
20574
- const match = /^Bearer\s+(.+)$/i.exec(header.trim());
20575
- return match?.[1]?.trim() || void 0;
20576
- }
20638
+ import { bearerToken } from "@claxedo/helpers/string";
20577
20639
  function errorBody(code, message) {
20578
20640
  return {
20579
20641
  error: {
@@ -20583,7 +20645,76 @@ function errorBody(code, message) {
20583
20645
  };
20584
20646
  }
20585
20647
 
20648
+ // src/user-hosted-forwarding.ts
20649
+ function isUserHostedTarget(target) {
20650
+ return target.access === "user-hosted";
20651
+ }
20652
+
20586
20653
  // src/server.ts
20654
+ function parseWorkspaceRelayTarget(input) {
20655
+ if (!isRecord(input)) return void 0;
20656
+ const row = input;
20657
+ const { workspaceId, hostId, baseUrl, upstreamHeaders } = row;
20658
+ if (typeof workspaceId !== "string" || typeof hostId !== "string" || typeof baseUrl !== "string") return void 0;
20659
+ if (upstreamHeaders !== void 0 && !isStringRecord(upstreamHeaders)) return void 0;
20660
+ const pair = relayClaimPair(row.access, row.backing);
20661
+ if (!pair) return void 0;
20662
+ return {
20663
+ workspaceId,
20664
+ hostId,
20665
+ baseUrl,
20666
+ ...upstreamHeaders ? { upstreamHeaders } : {},
20667
+ ...pair
20668
+ };
20669
+ }
20670
+ function parseRuntimeAccessTokenActiveResult(input) {
20671
+ if (!isRecord(input)) return void 0;
20672
+ const row = input;
20673
+ if (row.active === true) return { active: true };
20674
+ if (row.active !== false) return void 0;
20675
+ if (typeof row.code !== "string" || typeof row.reason !== "string") return void 0;
20676
+ return { active: false, code: row.code, reason: row.reason };
20677
+ }
20678
+ function parseHostGenerationResult(input) {
20679
+ if (!isRecord(input)) return void 0;
20680
+ const row = input;
20681
+ if (typeof row.enrollmentId !== "string" || !row.enrollmentId.trim()) return void 0;
20682
+ if (!isHostGeneration(row.generation) || typeof row.revoked !== "boolean") return void 0;
20683
+ return { enrollmentId: row.enrollmentId, generation: row.generation, revoked: row.revoked };
20684
+ }
20685
+ var HOST_GENERATION_LOOKUP_TIMEOUT_MS_DEFAULT = 5e3;
20686
+ var HOST_GENERATION_ENROLLMENT_NOT_FOUND_CODE = "relay_resolver_enrollment_not_found";
20687
+ function createHostGenerationResolverLookup(url, options) {
20688
+ const fetcher = options.fetch ?? ((target, init) => fetch(target, init));
20689
+ const timeoutMs = options.timeoutMs ?? HOST_GENERATION_LOOKUP_TIMEOUT_MS_DEFAULT;
20690
+ return async ({ enrollmentId }) => {
20691
+ const target = new URL(url);
20692
+ target.searchParams.set("enrollmentId", enrollmentId);
20693
+ const res = await fetcher(target, { headers: options.headers, signal: AbortSignal.timeout(timeoutMs) });
20694
+ if (res.status === 404) {
20695
+ const body = await res.json().catch(() => void 0);
20696
+ const error = isRecord(body) && isRecord(body.error) ? body.error : void 0;
20697
+ if (error?.code === HOST_GENERATION_ENROLLMENT_NOT_FOUND_CODE) return void 0;
20698
+ throw new Error(`relay host-generation resolver failed: 404 (no host-generation route at ${url})`);
20699
+ }
20700
+ if (!res.ok) throw new Error(`relay host-generation resolver failed: ${res.status} ${await res.text()}`);
20701
+ const result = parseHostGenerationResult(await res.json());
20702
+ if (!result) throw new Error("relay host-generation resolver returned a malformed result");
20703
+ return result;
20704
+ };
20705
+ }
20706
+ function hostTunnelIncumbentOutranks(incumbentGeneration, candidateGeneration) {
20707
+ if (incumbentGeneration === void 0) return false;
20708
+ return candidateGeneration === void 0 || incumbentGeneration > candidateGeneration;
20709
+ }
20710
+ function relayClaimPair(access, backing) {
20711
+ if (access === "cloud" && backing === "cloud-vm") return { access, backing };
20712
+ if (access === "user-hosted" && backing === "local-worktree") return { access, backing };
20713
+ return void 0;
20714
+ }
20715
+ function isStringRecord(input) {
20716
+ return isRecord(input) && Object.values(input).every((value) => typeof value === "string");
20717
+ }
20587
20718
  var RELAY_HOST_TOKEN_CACHE_MAX_ENTRIES = 4096;
20588
20719
  var RUNTIME_ACCESS_TOKEN_CACHE_MAX_ENTRIES = 8192;
20589
20720
  var RESOLVER_CACHE_MAX_ENTRIES = 8192;
@@ -20623,6 +20754,71 @@ function createCachedRevocationClient(inner, options = {}) {
20623
20754
  return await promise;
20624
20755
  };
20625
20756
  }
20757
+ function createCachedHostGenerationClient(inner, options = {}) {
20758
+ const ttlMs = options.ttlMs ?? 1e4;
20759
+ const now = options.now ?? Date.now;
20760
+ const cache = /* @__PURE__ */ new Map();
20761
+ return async (args) => {
20762
+ for (; ; ) {
20763
+ const at = now();
20764
+ const entry = cache.get(args.enrollmentId);
20765
+ if (entry && entry.expiresAt > at) {
20766
+ if (entry.result && entry.result.generation >= args.generation) return entry.result;
20767
+ if (!entry.result && entry.promise) {
20768
+ const shared = await entry.promise;
20769
+ if (!shared || shared.generation >= args.generation) return shared;
20770
+ continue;
20771
+ }
20772
+ }
20773
+ const promise = inner(args).then((result) => {
20774
+ pruneExpiringCache(cache, now(), RESOLVER_CACHE_MAX_ENTRIES);
20775
+ if (result) cache.set(args.enrollmentId, { result, expiresAt: now() + ttlMs });
20776
+ else cache.delete(args.enrollmentId);
20777
+ return result;
20778
+ }).catch((err) => {
20779
+ cache.delete(args.enrollmentId);
20780
+ throw err;
20781
+ });
20782
+ cache.set(args.enrollmentId, { promise, expiresAt: at + ttlMs });
20783
+ return await promise;
20784
+ }
20785
+ };
20786
+ }
20787
+ async function checkHostTunnelGeneration(lookup, claims) {
20788
+ if (claims.generation === void 0 || !claims.enrollment_id) return { ok: true };
20789
+ if (!lookup) {
20790
+ return {
20791
+ ok: false,
20792
+ retryable: false,
20793
+ code: "host_generation_unverifiable",
20794
+ reason: "Host tunnel generation cannot be verified by a relay without a host-generation resolver"
20795
+ };
20796
+ }
20797
+ let result;
20798
+ try {
20799
+ result = await lookup({ enrollmentId: claims.enrollment_id, generation: claims.generation });
20800
+ } catch (err) {
20801
+ return {
20802
+ ok: false,
20803
+ retryable: true,
20804
+ code: "host_generation_lookup_unavailable",
20805
+ reason: `Host generation lookup is unavailable: ${err instanceof Error ? err.message : String(err)}`
20806
+ };
20807
+ }
20808
+ if (!result) {
20809
+ return { ok: false, retryable: false, code: "host_enrollment_unknown", reason: "Host enrollment is unknown" };
20810
+ }
20811
+ if (result.revoked) {
20812
+ return { ok: false, retryable: false, code: "host_enrollment_revoked", reason: "Host enrollment was revoked" };
20813
+ }
20814
+ if (result.generation > claims.generation) {
20815
+ return { ok: false, retryable: false, code: "host_generation_superseded", reason: "Host tunnel generation was superseded" };
20816
+ }
20817
+ if (result.generation < claims.generation) {
20818
+ return { ok: false, retryable: false, code: "host_generation_unknown", reason: "Host tunnel generation is ahead of the control plane" };
20819
+ }
20820
+ return { ok: true };
20821
+ }
20626
20822
  function createCachedTargetClient(inner, options = {}) {
20627
20823
  const ttlMs = options.ttlMs ?? 5e3;
20628
20824
  const now = options.now ?? Date.now;
@@ -20713,11 +20909,9 @@ function targetUrl(target, path, search) {
20713
20909
  }
20714
20910
  var RELAY_VIEWER_DENIED_PATH = /^\/api\/wr\/pty(?:\/|$)/;
20715
20911
  function relayRuntimePath(request) {
20716
- return new URL(request.url).pathname.replace(/^\/workspaces\/[^/]+\/?/, "/");
20717
- }
20718
- function relayAuthorizationPath(path) {
20719
20912
  try {
20720
- return decodeURIComponent(path);
20913
+ const decoded2 = decodeURIComponent(new URL(request.url).pathname.replace(/^\/workspaces\/[^/]+\/?/, "/")).replace(/^\/+/, "/");
20914
+ return new URL(decoded2.replace(/^\/+/, ""), "http://relay.invalid/").pathname;
20721
20915
  } catch {
20722
20916
  return void 0;
20723
20917
  }
@@ -20725,8 +20919,7 @@ function relayAuthorizationPath(path) {
20725
20919
  function roleAllowsRelayRequest(role, method, path) {
20726
20920
  if (role === "owner" || role === "admin" || role === "editor") return true;
20727
20921
  if (role === "viewer") {
20728
- const authorizationPath = relayAuthorizationPath(path);
20729
- if (!authorizationPath || RELAY_VIEWER_DENIED_PATH.test(authorizationPath)) return false;
20922
+ if (RELAY_VIEWER_DENIED_PATH.test(path)) return false;
20730
20923
  return method === "GET" || method === "HEAD" || method === "OPTIONS";
20731
20924
  }
20732
20925
  return false;
@@ -20740,7 +20933,9 @@ function relayHostTokenCacheTtlMs(options, claims) {
20740
20933
  function relayHostTokenCacheKey(options, claims, target) {
20741
20934
  return [
20742
20935
  claims.jti,
20743
- claims.sub,
20936
+ claims.principal_kind,
20937
+ claims.actor_id,
20938
+ claims.actor_kind,
20744
20939
  claims.org_id,
20745
20940
  claims.role,
20746
20941
  claims.workspace_id,
@@ -20845,7 +21040,15 @@ async function relayHostTokenFor(options, claims, target, trace) {
20845
21040
  try {
20846
21041
  const token = await trace.span("rht-mint", async () => {
20847
21042
  promise = mintRelayHostToken({
20848
- subject: claims.sub,
21043
+ principalKind: claims.principal_kind,
21044
+ actorId: claims.actor_id,
21045
+ actorKind: claims.actor_kind,
21046
+ parentJti: claims.jti,
21047
+ ...claims.actor_public_id && claims.actor_name ? {
21048
+ actorPublicId: claims.actor_public_id,
21049
+ actorName: claims.actor_name,
21050
+ ...claims.actor_avatar_url ? { actorAvatarUrl: claims.actor_avatar_url } : {}
21051
+ } : {},
20849
21052
  orgId: claims.org_id,
20850
21053
  role: claims.role,
20851
21054
  ...target,
@@ -20869,7 +21072,15 @@ async function uncachedRelayHostTokenFor(options, claims, target, trace) {
20869
21072
  return await trace.span(
20870
21073
  "rht-mint",
20871
21074
  async () => await mintRelayHostToken({
20872
- subject: claims.sub,
21075
+ principalKind: claims.principal_kind,
21076
+ actorId: claims.actor_id,
21077
+ actorKind: claims.actor_kind,
21078
+ parentJti: claims.jti,
21079
+ ...claims.actor_public_id && claims.actor_name ? {
21080
+ actorPublicId: claims.actor_public_id,
21081
+ actorName: claims.actor_name,
21082
+ ...claims.actor_avatar_url ? { actorAvatarUrl: claims.actor_avatar_url } : {}
21083
+ } : {},
20873
21084
  orgId: claims.org_id,
20874
21085
  role: claims.role,
20875
21086
  ...target,
@@ -21008,13 +21219,30 @@ function originMatcherFor(options) {
21008
21219
  }
21009
21220
  return matcher;
21010
21221
  }
21222
+ var RELAY_ALLOWED_REQUEST_HEADER_LIST = [
21223
+ "Accept",
21224
+ "Authorization",
21225
+ "Content-Type",
21226
+ "Last-Event-ID",
21227
+ "Traceparent",
21228
+ "Tracestate",
21229
+ "X-Fetch-Bypass-Throttle",
21230
+ "X-Daytona-Skip-Preview-Warning",
21231
+ "X-Workspace-Id",
21232
+ "X-OpenCode-Directory",
21233
+ "X-Claxedo-Runner",
21234
+ "X-Claxedo-Model",
21235
+ "X-Claxedo-Draft-Id",
21236
+ "X-Claxedo-Binary"
21237
+ ];
21238
+ var RELAY_ALLOWED_REQUEST_HEADERS = RELAY_ALLOWED_REQUEST_HEADER_LIST.join(", ");
21011
21239
  function denyCorsHeaders(request, originAllowed = defaultOriginMatcher) {
21012
21240
  const origin = request.headers.get("origin");
21013
21241
  if (!origin) return void 0;
21014
21242
  if (!originAllowed(origin)) return void 0;
21015
21243
  return {
21016
21244
  "access-control-allow-origin": origin,
21017
- "access-control-allow-headers": "Accept, Authorization, Content-Type, Last-Event-ID, X-Fetch-Bypass-Throttle, X-Daytona-Skip-Preview-Warning, X-Workspace-Id, X-OpenCode-Directory, X-Claxedo-Runner, X-Claxedo-Model, X-Claxedo-Draft-Id, X-Claxedo-Binary",
21245
+ "access-control-allow-headers": RELAY_ALLOWED_REQUEST_HEADERS,
21018
21246
  "access-control-allow-methods": "GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS"
21019
21247
  };
21020
21248
  }
@@ -21024,7 +21252,9 @@ async function deny(options, input) {
21024
21252
  action: "relay.request.denied",
21025
21253
  result: "deny",
21026
21254
  reason: input.code,
21027
- subject: input.claims?.sub,
21255
+ actorId: input.claims?.actor_id,
21256
+ actorKind: input.claims?.actor_kind,
21257
+ principalKind: input.claims?.principal_kind,
21028
21258
  ...input.claims ? { role: input.claims.role } : {},
21029
21259
  workspaceId: input.claims?.workspace_id,
21030
21260
  hostId: input.claims?.host_id,
@@ -21073,7 +21303,7 @@ async function authorizeWorkspaceRelayRequest(options, request, workspaceId, tra
21073
21303
  };
21074
21304
  }
21075
21305
  const path = relayRuntimePath(request);
21076
- if (!roleAllowsRelayRequest(claims.role, request.method, path)) {
21306
+ if (!path || !roleAllowsRelayRequest(claims.role, request.method, path)) {
21077
21307
  return {
21078
21308
  ok: false,
21079
21309
  code: "relay_role_denied",
@@ -21100,7 +21330,7 @@ async function authorizeWorkspaceRelayRequest(options, request, workspaceId, tra
21100
21330
  })
21101
21331
  };
21102
21332
  }
21103
- if (target.access === "user-hosted" && !options.directory?.activeHost({ hostId: target.hostId, workspaceId: target.workspaceId })) {
21333
+ if (isUserHostedTarget(target) && !options.directory?.activeHost({ hostId: target.hostId, workspaceId: target.workspaceId })) {
21104
21334
  return {
21105
21335
  ok: false,
21106
21336
  code: "user_hosted_app_offline",
@@ -21116,7 +21346,9 @@ async function authorizeWorkspaceRelayRequest(options, request, workspaceId, tra
21116
21346
  await span("audit", async () => await audit(options, {
21117
21347
  action: "relay.request.accepted",
21118
21348
  result: "allow",
21119
- subject: claims.sub,
21349
+ actorId: claims.actor_id,
21350
+ actorKind: claims.actor_kind,
21351
+ principalKind: claims.principal_kind,
21120
21352
  role: claims.role,
21121
21353
  workspaceId: claims.workspace_id,
21122
21354
  hostId: claims.host_id,
@@ -21199,7 +21431,7 @@ function isLoopbackAddress(address) {
21199
21431
  if (!address) return false;
21200
21432
  const trimmed = address.trim();
21201
21433
  if (LOOPBACK_REMOTES.has(trimmed)) return true;
21202
- if (/^127\./.test(trimmed)) return true;
21434
+ if (trimmed.startsWith("127.")) return true;
21203
21435
  if (/^::ffff:127\./i.test(trimmed)) return true;
21204
21436
  return false;
21205
21437
  }
@@ -21255,25 +21487,26 @@ function buildMetricsBody(options) {
21255
21487
  return body;
21256
21488
  }
21257
21489
  function createWorkspaceRelay(options) {
21258
- const app = new Hono();
21490
+ const app = Object.assign(new Hono(), {
21491
+ disposeAuditSampler: () => disposeAuditSampler(options)
21492
+ });
21259
21493
  const originAllowed = originMatcherFor(options);
21494
+ app.use("*", async (c, next) => {
21495
+ await next();
21496
+ const origin = c.res.headers.get("access-control-allow-origin");
21497
+ if (origin && origin !== "*" && !c.res.headers.has("timing-allow-origin")) {
21498
+ c.res.headers.set("timing-allow-origin", origin);
21499
+ }
21500
+ });
21260
21501
  app.use("*", cors({
21261
21502
  origin: (origin) => {
21262
21503
  if (!origin) return void 0;
21263
21504
  return originAllowed(origin) ? origin : void 0;
21264
21505
  },
21265
- allowHeaders: [
21266
- "Accept",
21267
- "Authorization",
21268
- "Content-Type",
21269
- "X-Daytona-Skip-Preview-Warning",
21270
- "X-Workspace-Id",
21271
- "X-OpenCode-Directory",
21272
- "X-Claxedo-Runner",
21273
- "X-Claxedo-Model",
21274
- "X-Claxedo-Draft-Id",
21275
- "X-Claxedo-Binary"
21276
- ],
21506
+ // Spread from the shared list, not hand-maintained here: a duplicate
21507
+ // array drifts out of sync and silently drops headers like
21508
+ // `Last-Event-ID` or `X-Fetch-Bypass-Throttle`.
21509
+ allowHeaders: [...RELAY_ALLOWED_REQUEST_HEADER_LIST],
21277
21510
  allowMethods: ["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"]
21278
21511
  }));
21279
21512
  app.get("/health", (c) => {
@@ -21301,7 +21534,7 @@ function createWorkspaceRelay(options) {
21301
21534
  let keys;
21302
21535
  try {
21303
21536
  keys = await Promise.all(sources.map(async (source) => {
21304
- const jwk = await exportJWK(source.publicKey);
21537
+ const jwk = await exportJWK2(source.publicKey);
21305
21538
  return {
21306
21539
  ...jwk,
21307
21540
  kid: source.kid,
@@ -21324,7 +21557,6 @@ function createWorkspaceRelay(options) {
21324
21557
  c.header("cache-control", "public, max-age=300");
21325
21558
  return c.json({ keys });
21326
21559
  });
21327
- app.disposeAuditSampler = () => disposeAuditSampler(options);
21328
21560
  app.get("/metrics", (c) => {
21329
21561
  const authResult = checkMetricsAuth(options, c.req.raw);
21330
21562
  if (!authResult.ok) {
@@ -21362,7 +21594,16 @@ function createWorkspaceRelay(options) {
21362
21594
  return app;
21363
21595
  }
21364
21596
 
21597
+ // src/upstream-websocket.ts
21598
+ function resolveUpstreamWebSocket(override) {
21599
+ return override ?? WebSocket;
21600
+ }
21601
+
21365
21602
  // src/bun.ts
21603
+ var WORKSPACE_RELAY_IDLE_TIMEOUT_SECONDS = 45;
21604
+ var isRelayClientSocket = (ws) => ws.data.kind === "client";
21605
+ var isHostTunnelSocket = (ws) => ws.data.kind === "host-tunnel";
21606
+ var isUserHostedClientSocket = (ws) => ws.data.kind === "user-hosted-client";
21366
21607
  var TUNNEL_PENDING_HTTP_CAP = 32;
21367
21608
  var TUNNEL_CHANNEL_CAP = 16;
21368
21609
  var HOST_TUNNEL_REGISTRATION_RECONNECT_CAP = 5;
@@ -21376,12 +21617,12 @@ var UPSTREAM_WS_PRE_OPEN_QUEUE_MAX_FRAMES_DEFAULT = 64;
21376
21617
  var UPSTREAM_WS_PRE_OPEN_QUEUE_MAX_BYTES_DEFAULT = 8 * 1024 * 1024;
21377
21618
  var WS_BUFFERED_AMOUNT_MAX_BYTES_DEFAULT = 8 * 1024 * 1024;
21378
21619
  var relayBufferedBytes = (socket) => {
21379
- const method = socket.getBufferedAmount;
21380
- if (typeof method === "function") {
21381
- const measured = method.call(socket);
21382
- if (typeof measured === "number" && Number.isFinite(measured)) return measured;
21383
- return void 0;
21620
+ if (typeof socket !== "object" || socket === null) return void 0;
21621
+ if ("getBufferedAmount" in socket && typeof socket.getBufferedAmount === "function") {
21622
+ const measured = socket.getBufferedAmount();
21623
+ return typeof measured === "number" && Number.isFinite(measured) ? measured : void 0;
21384
21624
  }
21625
+ if (!("bufferedAmount" in socket)) return void 0;
21385
21626
  const property = socket.bufferedAmount;
21386
21627
  return typeof property === "number" && Number.isFinite(property) ? property : void 0;
21387
21628
  };
@@ -21390,6 +21631,8 @@ var relayOverBackpressureLimit = (socket, limitBytes) => {
21390
21631
  return queued !== void 0 && queued > limitBytes;
21391
21632
  };
21392
21633
  var HOST_TUNNEL_MAX_MISSED_PONGS_DEFAULT = 2;
21634
+ var HOST_GENERATION_CHECK_INTERVAL_MS_DEFAULT = 3e4;
21635
+ var HOST_GENERATION_OUTAGE_GRACE_ATTEMPTS_DEFAULT = 3;
21393
21636
  var TUNNEL_MESSAGE_BUFFER_CAP_BYTES = 4 * 1024 * 1024;
21394
21637
  var SLOW_CONSUMER_HIGH_WATER_MARK_BYTES_DEFAULT = 8 * 1024 * 1024;
21395
21638
  var SLOW_CONSUMER_TIMEOUT_MS_DEFAULT = 3e4;
@@ -21460,8 +21703,8 @@ function jsonError(code, message, status) {
21460
21703
  headers: { "content-type": "application/json" }
21461
21704
  });
21462
21705
  }
21463
- function corsJsonError(request, code, message, status) {
21464
- const headers2 = relayCorsHeaders(request);
21706
+ function corsJsonError(request, originAllowed, code, message, status) {
21707
+ const headers2 = relayCorsHeaders(request, originAllowed);
21465
21708
  headers2.set("content-type", "application/json");
21466
21709
  return new Response(JSON.stringify({ error: { code, message } }), { status, headers: headers2 });
21467
21710
  }
@@ -21469,7 +21712,7 @@ function preOpenFrameBytes(message) {
21469
21712
  return typeof message === "string" ? Buffer.byteLength(message) : message.byteLength;
21470
21713
  }
21471
21714
  function safeCloseCode(input, fallback = 1011) {
21472
- if (!Number.isInteger(input)) return fallback;
21715
+ if (input === void 0 || !Number.isInteger(input)) return fallback;
21473
21716
  const code = input;
21474
21717
  if (code === 1e3) return code;
21475
21718
  if (code >= 1001 && code <= 1014 && code !== 1004 && code !== 1005 && code !== 1006) return code;
@@ -21498,6 +21741,18 @@ function workspaceIdFromPath(pathname) {
21498
21741
  function hostIdFromTunnelPath(pathname) {
21499
21742
  return /^\/host-tunnels\/([^/]+)(?:\/|$)/.exec(pathname)?.[1];
21500
21743
  }
21744
+ function tunnelKey(hostId, workspaceId) {
21745
+ return `${hostId}\0${workspaceId}`;
21746
+ }
21747
+ function tunnelIdentity(hostId, workspaceIds) {
21748
+ return `${hostId}\0${[...new Set(workspaceIds)].sort().join(",")}`;
21749
+ }
21750
+ function ownedWorkspaceIds(hostTunnels, ws) {
21751
+ return ws.data.workspaceIds.filter((workspaceId) => hostTunnels.get(tunnelKey(ws.data.hostId, workspaceId)) === ws);
21752
+ }
21753
+ function outranks(incumbent, candidateGeneration) {
21754
+ return hostTunnelIncumbentOutranks(incumbent.data.generation, candidateGeneration);
21755
+ }
21501
21756
  function websocketRequest(request) {
21502
21757
  return request.headers.get("upgrade")?.toLowerCase() === "websocket";
21503
21758
  }
@@ -21526,6 +21781,7 @@ function relayWebSocketPayload(input) {
21526
21781
  copy.set(new Uint8Array(input.buffer, input.byteOffset, input.byteLength));
21527
21782
  return copy;
21528
21783
  }
21784
+ return void 0;
21529
21785
  }
21530
21786
  function headersRecord(headers2) {
21531
21787
  const result = {};
@@ -21547,20 +21803,20 @@ function isEventStream(input) {
21547
21803
  );
21548
21804
  }
21549
21805
  var defaultRelayOriginMatcher = createOriginMatcher(DEFAULT_RELAY_APP_ORIGINS);
21550
- function allowedCorsOrigin(origin) {
21551
- if (!origin) return;
21552
- if (defaultRelayOriginMatcher(origin)) return origin;
21806
+ function allowedCorsOrigin(origin, matcher) {
21807
+ if (!origin) return void 0;
21808
+ return matcher(origin) ? origin : void 0;
21553
21809
  }
21554
- function requireAllowedOrigin(request) {
21810
+ function requireAllowedOrigin(request, matcher) {
21555
21811
  const origin = request.headers.get("origin");
21556
- if (allowedCorsOrigin(origin)) return null;
21812
+ if (allowedCorsOrigin(origin, matcher)) return null;
21557
21813
  return jsonError(
21558
21814
  "origin_not_allowed",
21559
21815
  "Origin is not in the allowlist",
21560
21816
  403
21561
21817
  );
21562
21818
  }
21563
- function relayCorsHeaders(request, input = new Headers()) {
21819
+ function relayCorsHeaders(request, originAllowed, input = new Headers()) {
21564
21820
  const result = new Headers(input);
21565
21821
  result.delete("access-control-allow-origin");
21566
21822
  result.delete("access-control-allow-credentials");
@@ -21568,10 +21824,10 @@ function relayCorsHeaders(request, input = new Headers()) {
21568
21824
  result.delete("access-control-allow-methods");
21569
21825
  result.delete("access-control-expose-headers");
21570
21826
  result.delete("access-control-max-age");
21571
- const origin = allowedCorsOrigin(request.headers.get("origin"));
21827
+ const origin = allowedCorsOrigin(request.headers.get("origin"), originAllowed);
21572
21828
  if (origin) {
21573
21829
  result.set("access-control-allow-origin", origin);
21574
- result.set("access-control-allow-headers", "Accept, Authorization, Content-Type, Last-Event-ID, X-Fetch-Bypass-Throttle, X-Daytona-Skip-Preview-Warning, X-Workspace-Id, X-OpenCode-Directory, X-Claxedo-Runner, X-Claxedo-Model, X-Claxedo-Draft-Id, X-Claxedo-Binary");
21830
+ result.set("access-control-allow-headers", RELAY_ALLOWED_REQUEST_HEADERS);
21575
21831
  result.set("access-control-allow-methods", "GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS");
21576
21832
  }
21577
21833
  return result;
@@ -21599,7 +21855,7 @@ function decodedFrame(frame) {
21599
21855
  return frame.binary ? new Uint8Array(body) : body.toString("utf8");
21600
21856
  }
21601
21857
  function tunnelMessage(ws, input, stats) {
21602
- if (typeof input !== "string") return;
21858
+ if (typeof input !== "string") return void 0;
21603
21859
  const combined = ws.data.messageBuffer.length > 0 ? ws.data.messageBuffer + input : input;
21604
21860
  let parsed;
21605
21861
  try {
@@ -21609,11 +21865,11 @@ function tunnelMessage(ws, input, stats) {
21609
21865
  stats.oversizedClosed += 1;
21610
21866
  ws.data.messageBuffer = "";
21611
21867
  ws.close(1009, "Tunnel message buffer exceeded 4 MB");
21612
- return;
21868
+ return void 0;
21613
21869
  }
21614
21870
  stats.fragmentsBuffered += 1;
21615
21871
  ws.data.messageBuffer = combined;
21616
- return;
21872
+ return void 0;
21617
21873
  }
21618
21874
  ws.data.messageBuffer = "";
21619
21875
  const validated = validateTunnelMessage(parsed);
@@ -21621,7 +21877,7 @@ function tunnelMessage(ws, input, stats) {
21621
21877
  if (validated.reason === "protocol_mismatch") {
21622
21878
  ws.close(1002, `Tunnel protocol mismatch: expected ${validated.expected_protocol}`);
21623
21879
  }
21624
- return;
21880
+ return void 0;
21625
21881
  }
21626
21882
  function sendTunnelPing(ws) {
21627
21883
  if (ws.readyState !== WebSocket.OPEN) return;
@@ -21659,7 +21915,7 @@ function cleanupHostTunnelSocket(input) {
21659
21915
  for (const pending of input.ws.data.pending.values()) {
21660
21916
  failPendingHttpResponse({
21661
21917
  entry: pending,
21662
- response: input.request ? corsJsonError(input.request, "user_hosted_app_offline", "User-hosted workspace is offline", 503) : jsonError("user_hosted_app_offline", "User-hosted workspace is offline", 503),
21918
+ response: input.request ? corsJsonError(input.request, input.originAllowed, "user_hosted_app_offline", "User-hosted workspace is offline", 503) : jsonError("user_hosted_app_offline", "User-hosted workspace is offline", 503),
21663
21919
  error: new Error("User-hosted tunnel disconnected")
21664
21920
  });
21665
21921
  }
@@ -21669,17 +21925,17 @@ function cleanupHostTunnelSocket(input) {
21669
21925
  }
21670
21926
  input.ws.data.channels.clear();
21671
21927
  if (!input.disconnectDirectory) return;
21672
- if (input.hostTunnels.get(input.ws.data.hostId) === input.ws) {
21673
- input.hostTunnels.delete(input.ws.data.hostId);
21674
- input.options.directory?.disconnectHost(input.ws.data.hostId);
21675
- scheduleHostTunnelStateChange(input.hostTunnelStateDebounce, input.options, {
21676
- hostId: input.ws.data.hostId,
21677
- workspaceId: input.ws.data.workspaceIds[0],
21678
- path: `/host-tunnels/${input.ws.data.hostId}`,
21679
- state: "disconnected",
21680
- debounceMs: input.bunOptions.hostTunnelStateDebounceMs ?? HOST_TUNNEL_STATE_DEBOUNCE_MS_DEFAULT
21681
- });
21682
- }
21928
+ const owned = ownedWorkspaceIds(input.hostTunnels, input.ws);
21929
+ if (owned.length === 0) return;
21930
+ for (const workspaceId of owned) input.hostTunnels.delete(tunnelKey(input.ws.data.hostId, workspaceId));
21931
+ input.options.directory?.disconnectHost(input.ws.data.hostId, owned);
21932
+ scheduleHostTunnelStateChange(input.hostTunnelStateDebounce, input.options, {
21933
+ hostId: input.ws.data.hostId,
21934
+ workspaceIds: input.ws.data.workspaceIds,
21935
+ path: `/host-tunnels/${input.ws.data.hostId}`,
21936
+ state: "disconnected",
21937
+ debounceMs: input.bunOptions.hostTunnelStateDebounceMs ?? HOST_TUNNEL_STATE_DEBOUNCE_MS_DEFAULT
21938
+ });
21683
21939
  }
21684
21940
  async function audit2(options, input) {
21685
21941
  await options.audit?.({
@@ -21691,22 +21947,43 @@ async function audit2(options, input) {
21691
21947
  path: input.path
21692
21948
  });
21693
21949
  }
21950
+ async function auditHostTunnelDenial(options, input) {
21951
+ await options.audit?.({
21952
+ action: "host_tunnel.denied",
21953
+ result: "deny",
21954
+ reason: input.code,
21955
+ hostId: input.hostId,
21956
+ ...input.workspaceId ? { workspaceId: input.workspaceId } : {},
21957
+ method: "WEBSOCKET",
21958
+ path: `/host-tunnels/${input.hostId}`
21959
+ });
21960
+ }
21961
+ async function denyHostTunnel(options, input) {
21962
+ await auditHostTunnelDenial(options, input);
21963
+ return jsonError(input.code, input.message, input.status);
21964
+ }
21965
+ async function refuseHostTunnelSocket(options, ws, input) {
21966
+ await auditHostTunnelDenial(options, { code: input.code, hostId: ws.data.hostId, ...input.workspaceId ? { workspaceId: input.workspaceId } : {} });
21967
+ closeWebSocket(ws, input.close, input.reason, input.close);
21968
+ }
21694
21969
  function scheduleHostTunnelStateChange(state, options, input) {
21695
- const existing = state.get(input.hostId);
21970
+ const identity = tunnelIdentity(input.hostId, input.workspaceIds);
21971
+ const workspaceId = input.workspaceIds[0];
21972
+ const existing = state.get(identity);
21696
21973
  const entry = existing ?? {
21697
21974
  intendedState: "disconnected",
21698
21975
  lastWritten: "disconnected",
21699
21976
  flushTimer: void 0,
21700
- pending: { workspaceId: input.workspaceId, path: input.path }
21977
+ pending: { workspaceId, path: input.path }
21701
21978
  };
21702
21979
  entry.intendedState = input.state;
21703
- entry.pending = { workspaceId: input.workspaceId, path: input.path };
21980
+ entry.pending = { workspaceId, path: input.path };
21704
21981
  if (entry.flushTimer) clearTimeout(entry.flushTimer);
21705
21982
  entry.flushTimer = setTimeout(() => {
21706
21983
  entry.flushTimer = void 0;
21707
21984
  if (entry.intendedState === entry.lastWritten) {
21708
21985
  if (entry.intendedState === "disconnected") {
21709
- state.delete(input.hostId);
21986
+ state.delete(identity);
21710
21987
  }
21711
21988
  return;
21712
21989
  }
@@ -21718,7 +21995,7 @@ function scheduleHostTunnelStateChange(state, options, input) {
21718
21995
  path: entry.pending.path
21719
21996
  });
21720
21997
  }, input.debounceMs);
21721
- state.set(input.hostId, entry);
21998
+ state.set(identity, entry);
21722
21999
  }
21723
22000
  function drainPendingChunks(entry) {
21724
22001
  while (entry.pendingChunks.length > 0) {
@@ -21830,7 +22107,7 @@ async function tunnelHttpRequest(input) {
21830
22107
  const error = new Error("User-hosted tunnel response timed out");
21831
22108
  failPendingHttpResponse({
21832
22109
  entry,
21833
- response: corsJsonError(input.request, "user_hosted_tunnel_timeout", "User-hosted tunnel response timed out", 504),
22110
+ response: corsJsonError(input.request, input.originAllowed, "user_hosted_tunnel_timeout", "User-hosted tunnel response timed out", 504),
21834
22111
  error
21835
22112
  });
21836
22113
  }, input.responseTimeoutMs);
@@ -21842,10 +22119,11 @@ async function tunnelHttpRequest(input) {
21842
22119
  timeout,
21843
22120
  pendingChunks: [],
21844
22121
  bytesQueued: 0,
21845
- responseStarted: false
22122
+ responseStarted: false,
22123
+ corsHeaders: (upstream) => relayCorsHeaders(input.request, input.originAllowed, upstream)
21846
22124
  });
21847
22125
  });
21848
- const body = input.request.method === "GET" || input.request.method === "HEAD" ? void 0 : await readBoundedBody(input.request, input.requestBodyMaxBytes);
22126
+ const body = input.request.method === "GET" || input.request.method === "HEAD" ? void 0 : await readBoundedBody(input.request, input.originAllowed, input.requestBodyMaxBytes);
21849
22127
  if (body && "response" in body) {
21850
22128
  const entry = input.ws.data.pending.get(requestId);
21851
22129
  if (entry) clearPendingTimers(entry);
@@ -21863,8 +22141,8 @@ async function tunnelHttpRequest(input) {
21863
22141
  input.request.headers,
21864
22142
  input.relayHostToken,
21865
22143
  input.workspaceId,
21866
- // T26: user-hosted tunnel — strip Cookie to avoid leaking browser
21867
- // cookies to the host process running on the user's laptop.
22144
+ // User-hosted tunnel: strip Cookie so browser cookies never reach the
22145
+ // host process on the user's laptop.
21868
22146
  { userHosted: true }
21869
22147
  )),
21870
22148
  ...body?.bodyBase64 ? { body_base64: body.bodyBase64 } : {},
@@ -21872,7 +22150,7 @@ async function tunnelHttpRequest(input) {
21872
22150
  }));
21873
22151
  return await pending;
21874
22152
  }
21875
- async function readBoundedBody(request, maxBytes) {
22153
+ async function readBoundedBody(request, originAllowed, maxBytes) {
21876
22154
  if (!request.body) return void 0;
21877
22155
  const reader = request.body.getReader();
21878
22156
  const chunks = [];
@@ -21887,7 +22165,7 @@ async function readBoundedBody(request, maxBytes) {
21887
22165
  } catch {
21888
22166
  }
21889
22167
  return {
21890
- response: corsJsonError(request, "request_body_too_large", "Tunnel request body exceeds the relay limit", 413)
22168
+ response: corsJsonError(request, originAllowed, "request_body_too_large", "Tunnel request body exceeds the relay limit", 413)
21891
22169
  };
21892
22170
  }
21893
22171
  chunks.push(next.value);
@@ -21909,8 +22187,8 @@ async function directHttpRequest(input) {
21909
22187
  input.relayHostToken,
21910
22188
  input.workspaceId,
21911
22189
  {
21912
- // T26: cloud-vm direct path. Cookies pass through (default) — workspace
21913
- // dashboards in the VM may legitimately need session cookies.
22190
+ // Cloud-vm direct path: cookies pass through — workspace dashboards in
22191
+ // the VM may legitimately need session cookies.
21914
22192
  userHosted: false,
21915
22193
  upstreamHeaders: input.upstreamHeaders,
21916
22194
  signal: controller.signal
@@ -21923,10 +22201,21 @@ async function directHttpRequest(input) {
21923
22201
  const release = limiter ? await span("direct-http-queue", () => limiter.acquire()) : void 0;
21924
22202
  try {
21925
22203
  const upstream = await span("upstream-fetch", async () => await fetch(input.targetUrl, init));
21926
- return new Response(upstream.body, {
22204
+ const headers2 = relayCorsHeaders(input.request, input.originAllowed, upstream.headers);
22205
+ const contentType = upstream.headers.get("content-type") ?? "";
22206
+ const streamResponse = contentType.includes("text/event-stream") || contentType.includes("application/octet-stream");
22207
+ if (streamResponse) {
22208
+ return new Response(upstream.body, {
22209
+ status: upstream.status,
22210
+ statusText: upstream.statusText,
22211
+ headers: headers2
22212
+ });
22213
+ }
22214
+ const body = await upstream.arrayBuffer();
22215
+ return new Response(body, {
21927
22216
  status: upstream.status,
21928
22217
  statusText: upstream.statusText,
21929
- headers: relayCorsHeaders(input.request, upstream.headers)
22218
+ headers: headers2
21930
22219
  });
21931
22220
  } finally {
21932
22221
  release?.();
@@ -21935,6 +22224,7 @@ async function directHttpRequest(input) {
21935
22224
  const aborted = err instanceof Error && err.name === "AbortError";
21936
22225
  return corsJsonError(
21937
22226
  input.request,
22227
+ input.originAllowed,
21938
22228
  aborted ? "upstream_timeout" : "upstream_unavailable",
21939
22229
  aborted ? "Workspace upstream timed out" : "Workspace upstream is unavailable",
21940
22230
  aborted ? 504 : 503
@@ -21943,23 +22233,82 @@ async function directHttpRequest(input) {
21943
22233
  clearTimeout(timer);
21944
22234
  }
21945
22235
  }
21946
- function hostTunnel(hostTunnels, hostId) {
21947
- const tunnel = hostTunnels.get(hostId);
22236
+ function hostTunnel(hostTunnels, hostId, workspaceId) {
22237
+ const tunnel = hostTunnels.get(tunnelKey(hostId, workspaceId));
21948
22238
  if (tunnel?.readyState === WebSocket.OPEN) return tunnel;
21949
22239
  return void 0;
21950
22240
  }
21951
22241
  async function authorizeHostTunnel(options, bunOptions, request, input) {
21952
- if (bunOptions.authorizeHostTunnel) return await bunOptions.authorizeHostTunnel(request, input);
22242
+ if (bunOptions.authorizeHostTunnel) {
22243
+ return { authorized: await bunOptions.authorizeHostTunnel(request, input) };
22244
+ }
21953
22245
  const token = bearerToken(request.headers.get("authorization"));
21954
- if (!token) return false;
22246
+ if (!token) return { authorized: false };
21955
22247
  try {
21956
- await verifyHostTunnelToken(token, options.runtimeAccessKey, input);
21957
- return true;
22248
+ return { authorized: true, claims: await verifyHostTunnelToken(token, options.runtimeAccessKey, input) };
21958
22249
  } catch (err) {
21959
- if (err instanceof WorkspaceRelayAuthError) return false;
22250
+ if (err instanceof WorkspaceRelayAuthError) return { authorized: false };
21960
22251
  throw err;
21961
22252
  }
21962
22253
  }
22254
+ function clearHostGenerationWatcher(data) {
22255
+ if (data.generationCheckTimer) clearInterval(data.generationCheckTimer);
22256
+ data.generationCheckTimer = void 0;
22257
+ }
22258
+ function watchHostGeneration(ws, options, bunOptions) {
22259
+ const intervalMs = bunOptions.hostGenerationCheckIntervalMs ?? HOST_GENERATION_CHECK_INTERVAL_MS_DEFAULT;
22260
+ if (ws.data.generationCheckTimer || !options.resolveHostGeneration || ws.data.generation === void 0 || intervalMs <= 0) return;
22261
+ const graceAttempts = bunOptions.hostGenerationOutageGraceAttempts ?? HOST_GENERATION_OUTAGE_GRACE_ATTEMPTS_DEFAULT;
22262
+ ws.data.generationCheckTimer = setInterval(() => {
22263
+ void checkHostTunnelGeneration(options.resolveHostGeneration, {
22264
+ enrollment_id: ws.data.enrollmentId,
22265
+ generation: ws.data.generation
22266
+ }).then((decision) => {
22267
+ if (decision.ok) {
22268
+ ws.data.generationCheckFailures = 0;
22269
+ return;
22270
+ }
22271
+ if (!decision.retryable) {
22272
+ clearHostGenerationWatcher(ws.data);
22273
+ closeWebSocket(ws, 1008, decision.reason, 1008);
22274
+ return;
22275
+ }
22276
+ ws.data.generationCheckFailures += 1;
22277
+ if (ws.data.generationCheckFailures < graceAttempts) return;
22278
+ clearHostGenerationWatcher(ws.data);
22279
+ closeWebSocket(ws, 1012, "Host generation check unavailable", 1012);
22280
+ });
22281
+ }, intervalMs);
22282
+ if (typeof ws.data.generationCheckTimer.unref === "function") ws.data.generationCheckTimer.unref();
22283
+ }
22284
+ var RUNTIME_ACCESS_TOKEN_ACTIVE_CHECK_INTERVAL_MS_DEFAULT = 3e4;
22285
+ function clearClientAccessWatchers(data) {
22286
+ if (data.accessCheckTimer) clearInterval(data.accessCheckTimer);
22287
+ if (data.expiryTimer) clearTimeout(data.expiryTimer);
22288
+ data.accessCheckTimer = void 0;
22289
+ data.expiryTimer = void 0;
22290
+ }
22291
+ function watchClientAccess(ws, options, bunOptions) {
22292
+ const now = bunOptions.now ?? Date.now;
22293
+ const close = (reason) => {
22294
+ clearClientAccessWatchers(ws.data);
22295
+ closeWebSocket(ws, 1008, reason, 1008);
22296
+ };
22297
+ ws.data.expiryTimer = setTimeout(
22298
+ () => close("Runtime Access Token expired"),
22299
+ Math.max(0, ws.data.claims.exp * 1e3 - now())
22300
+ );
22301
+ if (typeof ws.data.expiryTimer.unref === "function") ws.data.expiryTimer.unref();
22302
+ const intervalMs = bunOptions.runtimeAccessTokenActiveCheckIntervalMs ?? RUNTIME_ACCESS_TOKEN_ACTIVE_CHECK_INTERVAL_MS_DEFAULT;
22303
+ if (!options.isRuntimeAccessTokenActive || intervalMs <= 0) return;
22304
+ ws.data.accessCheckTimer = setInterval(() => {
22305
+ void Promise.resolve(options.isRuntimeAccessTokenActive(ws.data.claims)).then((active) => {
22306
+ if (!active.active) close(active.reason);
22307
+ }).catch(() => {
22308
+ });
22309
+ }, intervalMs);
22310
+ if (typeof ws.data.accessCheckTimer.unref === "function") ws.data.accessCheckTimer.unref();
22311
+ }
21963
22312
  function createWorkspaceRelayBun(options, bunOptions = {}) {
21964
22313
  const hostTunnels = /* @__PURE__ */ new Map();
21965
22314
  const relayClients = /* @__PURE__ */ new Set();
@@ -21968,6 +22317,7 @@ function createWorkspaceRelayBun(options, bunOptions = {}) {
21968
22317
  const hostTunnelStateDebounce = /* @__PURE__ */ new Map();
21969
22318
  const fragmentationStats = createFragmentationStats();
21970
22319
  const slowConsumerStats = createSlowConsumerStats();
22320
+ const relayOriginMatcher = options.allowedOrigins ? createOriginMatcher(options.allowedOrigins) : defaultRelayOriginMatcher;
21971
22321
  const telemetry = {
21972
22322
  getFragmentationStats: () => readFragmentationStats(fragmentationStats),
21973
22323
  resetFragmentationStats: () => resetFragmentationStats(fragmentationStats),
@@ -21980,7 +22330,7 @@ function createWorkspaceRelayBun(options, bunOptions = {}) {
21980
22330
  setDraining: (value) => {
21981
22331
  draining = value;
21982
22332
  if (!value) return;
21983
- for (const tunnel of hostTunnels.values()) {
22333
+ for (const tunnel of new Set(hostTunnels.values())) {
21984
22334
  closeWebSocket(tunnel, 1012, "Workspace relay is draining", 1012);
21985
22335
  }
21986
22336
  for (const client of relayClients) {
@@ -21989,7 +22339,7 @@ function createWorkspaceRelayBun(options, bunOptions = {}) {
21989
22339
  },
21990
22340
  pendingCount: () => {
21991
22341
  let total = 0;
21992
- for (const tunnel of hostTunnels.values()) {
22342
+ for (const tunnel of new Set(hostTunnels.values())) {
21993
22343
  total += tunnel.data.pending.size;
21994
22344
  }
21995
22345
  return total;
@@ -22028,6 +22378,90 @@ function createWorkspaceRelayBun(options, bunOptions = {}) {
22028
22378
  }
22029
22379
  return tracker;
22030
22380
  }
22381
+ function claimTunnelIdentities(ws, workspaceIds) {
22382
+ const hostId = ws.data.hostId;
22383
+ const displaced = /* @__PURE__ */ new Set();
22384
+ for (const workspaceId of workspaceIds) {
22385
+ const previous = hostTunnels.get(tunnelKey(hostId, workspaceId));
22386
+ if (!previous || previous === ws) continue;
22387
+ if (outranks(previous, ws.data.generation)) return workspaceId;
22388
+ displaced.add(previous);
22389
+ }
22390
+ for (const workspaceId of workspaceIds) hostTunnels.set(tunnelKey(hostId, workspaceId), ws);
22391
+ for (const previous of displaced) {
22392
+ if (ownedWorkspaceIds(hostTunnels, previous).length > 0) continue;
22393
+ cleanupHostTunnelSocket({
22394
+ ws: previous,
22395
+ hostTunnels,
22396
+ hostTunnelStateDebounce,
22397
+ options,
22398
+ bunOptions,
22399
+ originAllowed: relayOriginMatcher,
22400
+ disconnectDirectory: false,
22401
+ closeChannels: true
22402
+ });
22403
+ clearHostGenerationWatcher(previous.data);
22404
+ closeWebSocket(previous, 1012, "Host tunnel replaced by a newer connection", 1012);
22405
+ }
22406
+ return void 0;
22407
+ }
22408
+ async function applyRegistrationUpdate(hostSocket, workspaceIds, token) {
22409
+ const hostId = hostSocket.data.hostId;
22410
+ const authorizationRequest = new Request(
22411
+ `http://relay.local/host-tunnels/${encodeURIComponent(hostId)}`,
22412
+ { headers: { authorization: `Bearer ${token}` } }
22413
+ );
22414
+ const authorization = await authorizeHostTunnel(options, bunOptions, authorizationRequest, { hostId, workspaceIds });
22415
+ if (!authorization.authorized) {
22416
+ await refuseHostTunnelSocket(options, hostSocket, {
22417
+ code: "host_tunnel_token_invalid",
22418
+ close: 1008,
22419
+ reason: "Host tunnel registration update denied"
22420
+ });
22421
+ return;
22422
+ }
22423
+ const claims = authorization.claims;
22424
+ if (hostTunnelIncumbentOutranks(hostSocket.data.generation, claims?.generation)) {
22425
+ await refuseHostTunnelSocket(options, hostSocket, {
22426
+ code: "host_generation_superseded",
22427
+ close: 1008,
22428
+ reason: "Host tunnel registration update superseded"
22429
+ });
22430
+ return;
22431
+ }
22432
+ const generation = await checkHostTunnelGeneration(options.resolveHostGeneration, {
22433
+ enrollment_id: claims?.enrollment_id,
22434
+ generation: claims?.generation
22435
+ });
22436
+ if (!generation.ok) {
22437
+ await refuseHostTunnelSocket(options, hostSocket, {
22438
+ code: generation.code,
22439
+ close: generation.retryable ? 1012 : 1008,
22440
+ reason: generation.retryable ? "Host generation check unavailable" : generation.reason
22441
+ });
22442
+ return;
22443
+ }
22444
+ const owned = ownedWorkspaceIds(hostTunnels, hostSocket);
22445
+ if (owned.length === 0 || hostSocket.readyState !== WebSocket.OPEN) return;
22446
+ const released = owned.filter((workspaceId) => !workspaceIds.includes(workspaceId));
22447
+ for (const workspaceId of released) hostTunnels.delete(tunnelKey(hostId, workspaceId));
22448
+ if (released.length) options.directory?.disconnectHost(hostId, released);
22449
+ hostSocket.data.enrollmentId = claims?.enrollment_id;
22450
+ hostSocket.data.generation = claims?.generation;
22451
+ const outranked = claimTunnelIdentities(hostSocket, workspaceIds);
22452
+ if (outranked) {
22453
+ await refuseHostTunnelSocket(options, hostSocket, {
22454
+ code: "host_generation_superseded",
22455
+ close: 1008,
22456
+ reason: "Host tunnel generation was superseded",
22457
+ workspaceId: outranked
22458
+ });
22459
+ return;
22460
+ }
22461
+ hostSocket.data.workspaceIds = workspaceIds;
22462
+ options.directory?.registerHostTunnel({ hostId, workspaceIds });
22463
+ watchHostGeneration(hostSocket, options, bunOptions);
22464
+ }
22031
22465
  function pruneReconnects(tracker, now) {
22032
22466
  const cutoff = now - HOST_TUNNEL_REGISTRATION_RECONNECT_WINDOW_MS;
22033
22467
  while (tracker.recent.length && tracker.recent[0] < cutoff) {
@@ -22035,7 +22469,14 @@ function createWorkspaceRelayBun(options, bunOptions = {}) {
22035
22469
  }
22036
22470
  }
22037
22471
  return {
22038
- async fetch(request, server) {
22472
+ // A function-valued property rather than a method: `main.ts` hands it
22473
+ // straight to `Bun.serve({ fetch: handler.fetch })`, detached from this
22474
+ // object, and it closes over the adapter's state rather than reading `this`.
22475
+ //
22476
+ // `undefined` is Bun's contract for "this request became a WebSocket":
22477
+ // `server.upgrade()` has already taken ownership of the socket, so there is
22478
+ // no HTTP response left to return.
22479
+ fetch: async (request, server) => {
22039
22480
  const url = new URL(request.url);
22040
22481
  const workspaceId = workspaceIdFromPath(url.pathname);
22041
22482
  const hostId = hostIdFromTunnelPath(url.pathname);
@@ -22058,7 +22499,7 @@ function createWorkspaceRelayBun(options, bunOptions = {}) {
22058
22499
  );
22059
22500
  }
22060
22501
  if (draining && workspaceId) {
22061
- const headers2 = relayCorsHeaders(request);
22502
+ const headers2 = relayCorsHeaders(request, relayOriginMatcher);
22062
22503
  const body = JSON.stringify({
22063
22504
  error: {
22064
22505
  code: "relay_draining",
@@ -22073,32 +22514,63 @@ function createWorkspaceRelayBun(options, bunOptions = {}) {
22073
22514
  if (!workspaceIds.length || !options.directory) {
22074
22515
  return new Response("Host tunnel registration is unavailable", { status: 503 });
22075
22516
  }
22076
- if (!await authorizeHostTunnel(options, bunOptions, request, { hostId, workspaceIds })) {
22517
+ const authorization = await authorizeHostTunnel(options, bunOptions, request, { hostId, workspaceIds });
22518
+ if (!authorization.authorized) {
22519
+ await denyHostTunnel(options, { code: "host_tunnel_token_invalid", message: "Host tunnel registration denied", status: 403, hostId });
22077
22520
  return new Response("Host tunnel registration denied", { status: 403 });
22078
22521
  }
22522
+ const claims = authorization.claims;
22523
+ const generation = await checkHostTunnelGeneration(options.resolveHostGeneration, {
22524
+ enrollment_id: claims?.enrollment_id,
22525
+ generation: claims?.generation
22526
+ });
22527
+ if (!generation.ok) {
22528
+ return denyHostTunnel(options, {
22529
+ code: generation.code,
22530
+ message: generation.reason,
22531
+ status: generation.retryable ? 503 : 403,
22532
+ hostId
22533
+ });
22534
+ }
22535
+ for (const workspaceId2 of workspaceIds) {
22536
+ const incumbent = hostTunnels.get(tunnelKey(hostId, workspaceId2));
22537
+ if (incumbent && outranks(incumbent, claims?.generation)) {
22538
+ return denyHostTunnel(options, {
22539
+ code: "host_generation_superseded",
22540
+ message: "Host tunnel generation was superseded",
22541
+ status: 403,
22542
+ hostId,
22543
+ workspaceId: workspaceId2
22544
+ });
22545
+ }
22546
+ }
22079
22547
  const tracker = getRegistrationTracker(hostId);
22080
22548
  const now = Date.now();
22081
22549
  pruneReconnects(tracker, now);
22082
22550
  if (tracker.recent.length >= HOST_TUNNEL_REGISTRATION_RECONNECT_CAP) {
22083
- return jsonError(
22084
- "too_many_host_tunnel_reconnects",
22085
- "Too many host-tunnel reconnects for this host within the last 60 seconds",
22086
- 429
22087
- );
22551
+ return denyHostTunnel(options, {
22552
+ code: "too_many_host_tunnel_reconnects",
22553
+ message: "Too many host-tunnel reconnects for this host within the last 60 seconds",
22554
+ status: 429,
22555
+ hostId
22556
+ });
22088
22557
  }
22089
22558
  if (server.upgrade(request, {
22090
22559
  data: {
22091
22560
  kind: "host-tunnel",
22092
22561
  hostId,
22093
22562
  workspaceIds,
22563
+ ...claims?.enrollment_id ? { enrollmentId: claims.enrollment_id } : {},
22564
+ ...claims?.generation !== void 0 ? { generation: claims.generation } : {},
22094
22565
  pending: /* @__PURE__ */ new Map(),
22095
22566
  channels: /* @__PURE__ */ new Map(),
22096
22567
  missedPongs: 0,
22097
- messageBuffer: ""
22568
+ messageBuffer: "",
22569
+ generationCheckFailures: 0
22098
22570
  }
22099
22571
  })) {
22100
22572
  tracker.recent.push(now);
22101
- return;
22573
+ return void 0;
22102
22574
  }
22103
22575
  return new Response("WebSocket upgrade failed", { status: 400 });
22104
22576
  }
@@ -22106,7 +22578,7 @@ function createWorkspaceRelayBun(options, bunOptions = {}) {
22106
22578
  if (request.method === "OPTIONS") {
22107
22579
  return new Response(null, {
22108
22580
  status: 204,
22109
- headers: relayCorsHeaders(request)
22581
+ headers: relayCorsHeaders(request, relayOriginMatcher)
22110
22582
  });
22111
22583
  }
22112
22584
  if (!websocketRequest(request)) {
@@ -22114,11 +22586,12 @@ function createWorkspaceRelayBun(options, bunOptions = {}) {
22114
22586
  const response = await trace.span("relay-total", async () => {
22115
22587
  const relay2 = await authorizeWorkspaceRelayRequest(options, request, workspaceId, trace);
22116
22588
  if (!relay2.ok) return relay2.response;
22117
- if (relay2.request.target.access === "user-hosted") {
22118
- const tunnel = hostTunnel(hostTunnels, relay2.request.target.hostId);
22589
+ if (isUserHostedTarget(relay2.request.target)) {
22590
+ const tunnel = hostTunnel(hostTunnels, relay2.request.target.hostId, relay2.request.target.workspaceId);
22119
22591
  return tunnel ? await trace.span("tunnel-http", async () => await tunnelHttpRequest({
22120
22592
  ws: tunnel,
22121
22593
  request,
22594
+ originAllowed: relayOriginMatcher,
22122
22595
  workspaceId: relay2.request.target.workspaceId,
22123
22596
  path: `${relay2.request.path}${url.search}`,
22124
22597
  relayHostToken: relay2.request.relayHostToken,
@@ -22131,6 +22604,7 @@ function createWorkspaceRelayBun(options, bunOptions = {}) {
22131
22604
  }
22132
22605
  return await directHttpRequest({
22133
22606
  request,
22607
+ originAllowed: relayOriginMatcher,
22134
22608
  targetUrl: workspaceRelayTargetUrl(
22135
22609
  relay2.request.target,
22136
22610
  relay2.request.path,
@@ -22148,10 +22622,10 @@ function createWorkspaceRelayBun(options, bunOptions = {}) {
22148
22622
  }
22149
22623
  const relay = await authorizeWorkspaceRelayRequest(options, request, workspaceId);
22150
22624
  if (!relay.ok) return relay.response;
22151
- const originDenied = requireAllowedOrigin(request);
22625
+ const originDenied = requireAllowedOrigin(request, relayOriginMatcher);
22152
22626
  if (originDenied) return originDenied;
22153
- if (relay.request.target.access === "user-hosted") {
22154
- const tunnel = hostTunnel(hostTunnels, relay.request.target.hostId);
22627
+ if (isUserHostedTarget(relay.request.target)) {
22628
+ const tunnel = hostTunnel(hostTunnels, relay.request.target.hostId, relay.request.target.workspaceId);
22155
22629
  if (!tunnel) {
22156
22630
  return new Response("User-hosted workspace is offline", { status: 503 });
22157
22631
  }
@@ -22165,6 +22639,7 @@ function createWorkspaceRelayBun(options, bunOptions = {}) {
22165
22639
  if (server.upgrade(request, {
22166
22640
  data: {
22167
22641
  kind: "user-hosted-client",
22642
+ claims: relay.request.claims,
22168
22643
  hostId: relay.request.target.hostId,
22169
22644
  workspaceId: relay.request.target.workspaceId,
22170
22645
  channelId: crypto.randomUUID(),
@@ -22172,13 +22647,14 @@ function createWorkspaceRelayBun(options, bunOptions = {}) {
22172
22647
  relayHostToken: relay.request.relayHostToken
22173
22648
  }
22174
22649
  })) {
22175
- return;
22650
+ return void 0;
22176
22651
  }
22177
22652
  return new Response("WebSocket upgrade failed", { status: 400 });
22178
22653
  }
22179
22654
  if (server.upgrade(request, {
22180
22655
  data: {
22181
22656
  kind: "client",
22657
+ claims: relay.request.claims,
22182
22658
  upstreamUrl: workspaceRelayTargetUrl(
22183
22659
  relay.request.target,
22184
22660
  relay.request.path,
@@ -22188,7 +22664,7 @@ function createWorkspaceRelayBun(options, bunOptions = {}) {
22188
22664
  new Headers(),
22189
22665
  relay.request.relayHostToken,
22190
22666
  relay.request.target.workspaceId,
22191
- // T26: cloud-vm WS upgrade. Cookies pass through (default).
22667
+ // Cloud-vm WS upgrade: cookies pass through.
22192
22668
  { userHosted: false, upstreamHeaders: relay.request.target.upstreamHeaders }
22193
22669
  )),
22194
22670
  queue: [],
@@ -22202,48 +22678,30 @@ function createWorkspaceRelayBun(options, bunOptions = {}) {
22202
22678
  } : {}
22203
22679
  }
22204
22680
  })) {
22205
- return;
22681
+ return void 0;
22206
22682
  }
22207
22683
  return new Response("WebSocket upgrade failed", { status: 400 });
22208
22684
  },
22209
22685
  websocket: {
22210
22686
  maxPayloadLength: WS_MAX_PAYLOAD_LENGTH_BYTES,
22211
22687
  message(ws, message) {
22212
- if (ws.data.kind === "host-tunnel") {
22688
+ if (isHostTunnelSocket(ws)) {
22213
22689
  const parsed = tunnelMessage(
22214
22690
  ws,
22215
22691
  message,
22216
22692
  fragmentationStats
22217
22693
  );
22218
22694
  if (parsed?.type === "ping") {
22219
- options.directory?.recordPong(ws.data.hostId);
22695
+ options.directory?.recordPong(ws.data.hostId, ownedWorkspaceIds(hostTunnels, ws));
22220
22696
  ws.send(JSON.stringify(makeTunnelPong(parsed)));
22221
22697
  }
22222
22698
  if (parsed?.type === "pong") {
22223
22699
  ws.data.missedPongs = 0;
22224
- options.directory?.recordPong(ws.data.hostId);
22700
+ options.directory?.recordPong(ws.data.hostId, ownedWorkspaceIds(hostTunnels, ws));
22225
22701
  }
22226
22702
  if (parsed?.type === "host.registration.update") {
22227
- const update = parsed;
22228
- const workspaceIds = [...new Set(update.workspace_ids)];
22229
22703
  const hostSocket = ws;
22230
- const hostId = hostSocket.data.hostId;
22231
- const authorizationRequest = new Request(
22232
- `http://relay.local/host-tunnels/${encodeURIComponent(hostId)}`,
22233
- { headers: { authorization: `Bearer ${update.token}` } }
22234
- );
22235
- void authorizeHostTunnel(options, bunOptions, authorizationRequest, {
22236
- hostId,
22237
- workspaceIds
22238
- }).then((authorized) => {
22239
- if (!authorized) {
22240
- closeWebSocket(hostSocket, 1008, "Host tunnel registration update denied", 1008);
22241
- return;
22242
- }
22243
- if (hostTunnels.get(hostId) !== hostSocket || hostSocket.readyState !== WebSocket.OPEN) return;
22244
- hostSocket.data.workspaceIds = workspaceIds;
22245
- options.directory?.registerHostTunnel({ hostId, workspaceIds });
22246
- }).catch(() => closeWebSocket(hostSocket, 1008, "Host tunnel registration update denied", 1008));
22704
+ void applyRegistrationUpdate(hostSocket, [...new Set(parsed.workspace_ids)], parsed.token).catch(() => closeWebSocket(hostSocket, 1008, "Host tunnel registration update denied", 1008));
22247
22705
  return;
22248
22706
  }
22249
22707
  if (parsed?.type === "error") {
@@ -22267,7 +22725,7 @@ function createWorkspaceRelayBun(options, bunOptions = {}) {
22267
22725
  if (isEventStream(response.headers)) clearTimeout(pending.timeout);
22268
22726
  pending.resolve(new Response(pending.stream, {
22269
22727
  status: response.status,
22270
- headers: headers(response.headers)
22728
+ headers: pending.corsHeaders(headers(response.headers))
22271
22729
  }));
22272
22730
  }
22273
22731
  }
@@ -22335,8 +22793,8 @@ function createWorkspaceRelayBun(options, bunOptions = {}) {
22335
22793
  }
22336
22794
  return;
22337
22795
  }
22338
- if (ws.data.kind === "user-hosted-client") {
22339
- const tunnel = hostTunnel(hostTunnels, ws.data.hostId);
22796
+ if (isUserHostedClientSocket(ws)) {
22797
+ const tunnel = hostTunnel(hostTunnels, ws.data.hostId, ws.data.workspaceId);
22340
22798
  if (!tunnel) {
22341
22799
  ws.close(1011, "User-hosted tunnel disconnected");
22342
22800
  return;
@@ -22353,6 +22811,7 @@ function createWorkspaceRelayBun(options, bunOptions = {}) {
22353
22811
  }));
22354
22812
  return;
22355
22813
  }
22814
+ if (!isRelayClientSocket(ws)) return;
22356
22815
  if (ws.data.upstream?.readyState === WebSocket.OPEN) {
22357
22816
  ws.data.upstream.send(message);
22358
22817
  return;
@@ -22367,21 +22826,17 @@ function createWorkspaceRelayBun(options, bunOptions = {}) {
22367
22826
  ws.data.queue.push({ payload: message, queuedAt: performance.now() });
22368
22827
  },
22369
22828
  open(ws) {
22370
- if (ws.data.kind === "host-tunnel") {
22371
- const previous = hostTunnels.get(ws.data.hostId);
22372
- if (previous && previous !== ws) {
22373
- cleanupHostTunnelSocket({
22374
- ws: previous,
22375
- hostTunnels,
22376
- hostTunnelStateDebounce,
22377
- options,
22378
- bunOptions,
22379
- disconnectDirectory: false,
22380
- closeChannels: true
22829
+ if (isHostTunnelSocket(ws)) {
22830
+ const outranked = claimTunnelIdentities(ws, ws.data.workspaceIds);
22831
+ if (outranked) {
22832
+ void refuseHostTunnelSocket(options, ws, {
22833
+ code: "host_generation_superseded",
22834
+ close: 1008,
22835
+ reason: "Host tunnel generation was superseded",
22836
+ workspaceId: outranked
22381
22837
  });
22382
- closeWebSocket(previous, 1012, "Host tunnel replaced by a newer connection", 1012);
22838
+ return;
22383
22839
  }
22384
- hostTunnels.set(ws.data.hostId, ws);
22385
22840
  options.directory?.registerHostTunnel({
22386
22841
  hostId: ws.data.hostId,
22387
22842
  workspaceIds: ws.data.workspaceIds
@@ -22394,17 +22849,19 @@ function createWorkspaceRelayBun(options, bunOptions = {}) {
22394
22849
  }
22395
22850
  sendTunnelPing(hostWs);
22396
22851
  }, bunOptions.hostTunnelPingIntervalMs ?? 15e3);
22852
+ watchHostGeneration(ws, options, bunOptions);
22397
22853
  scheduleHostTunnelStateChange(hostTunnelStateDebounce, options, {
22398
22854
  hostId: ws.data.hostId,
22399
- workspaceId: ws.data.workspaceIds[0],
22855
+ workspaceIds: ws.data.workspaceIds,
22400
22856
  path: `/host-tunnels/${ws.data.hostId}`,
22401
22857
  state: "connected",
22402
22858
  debounceMs: bunOptions.hostTunnelStateDebounceMs ?? HOST_TUNNEL_STATE_DEBOUNCE_MS_DEFAULT
22403
22859
  });
22404
22860
  return;
22405
22861
  }
22406
- if (ws.data.kind === "user-hosted-client") {
22407
- const tunnel = hostTunnel(hostTunnels, ws.data.hostId);
22862
+ if (isUserHostedClientSocket(ws)) {
22863
+ watchClientAccess(ws, options, bunOptions);
22864
+ const tunnel = hostTunnel(hostTunnels, ws.data.hostId, ws.data.workspaceId);
22408
22865
  if (!tunnel) {
22409
22866
  ws.close(1011, "User-hosted tunnel disconnected");
22410
22867
  return;
@@ -22424,17 +22881,18 @@ function createWorkspaceRelayBun(options, bunOptions = {}) {
22424
22881
  new Headers(),
22425
22882
  ws.data.relayHostToken,
22426
22883
  ws.data.workspaceId,
22427
- // T26: user-hosted tunnel WS upgrade — strip Cookie. Headers are
22428
- // empty here today, but pass the flag for consistency in case
22429
- // upstream client headers are forwarded in the future.
22884
+ // User-hosted tunnel WS upgrade: strip Cookie. Headers are empty
22885
+ // here today; the flag keeps the rule uniform.
22430
22886
  { userHosted: true }
22431
22887
  ))
22432
22888
  }));
22433
22889
  return;
22434
22890
  }
22891
+ if (!isRelayClientSocket(ws)) return;
22435
22892
  relayClients.add(ws);
22893
+ watchClientAccess(ws, options, bunOptions);
22436
22894
  const data = ws.data;
22437
- const UpstreamWebSocket = bunOptions.upstreamWebSocket ?? WebSocket;
22895
+ const UpstreamWebSocket = resolveUpstreamWebSocket(bunOptions.upstreamWebSocket);
22438
22896
  if (data.trace) data.trace.upstreamStartedAt = performance.now();
22439
22897
  const upstream = new UpstreamWebSocket(data.upstreamUrl, {
22440
22898
  headers: data.headers
@@ -22484,13 +22942,15 @@ function createWorkspaceRelayBun(options, bunOptions = {}) {
22484
22942
  };
22485
22943
  },
22486
22944
  close(ws, code, reason) {
22487
- if (ws.data.kind === "host-tunnel") {
22945
+ if (isHostTunnelSocket(ws)) {
22946
+ clearHostGenerationWatcher(ws.data);
22488
22947
  cleanupHostTunnelSocket({
22489
22948
  ws,
22490
22949
  hostTunnels,
22491
22950
  hostTunnelStateDebounce,
22492
22951
  options,
22493
22952
  bunOptions,
22953
+ originAllowed: relayOriginMatcher,
22494
22954
  disconnectDirectory: true,
22495
22955
  closeChannels: true
22496
22956
  });
@@ -22501,8 +22961,9 @@ function createWorkspaceRelayBun(options, bunOptions = {}) {
22501
22961
  }
22502
22962
  return;
22503
22963
  }
22504
- if (ws.data.kind === "user-hosted-client") {
22505
- const tunnel = hostTunnel(hostTunnels, ws.data.hostId);
22964
+ if (isUserHostedClientSocket(ws)) {
22965
+ clearClientAccessWatchers(ws.data);
22966
+ const tunnel = hostTunnel(hostTunnels, ws.data.hostId, ws.data.workspaceId);
22506
22967
  tunnel?.data.channels.delete(ws.data.channelId);
22507
22968
  if (tunnel?.readyState === WebSocket.OPEN) {
22508
22969
  tunnel.send(JSON.stringify({
@@ -22515,7 +22976,9 @@ function createWorkspaceRelayBun(options, bunOptions = {}) {
22515
22976
  }
22516
22977
  return;
22517
22978
  }
22979
+ if (!isRelayClientSocket(ws)) return;
22518
22980
  relayClients.delete(ws);
22981
+ clearClientAccessWatchers(ws.data);
22519
22982
  if (ws.data.upstreamOpenTimer) clearTimeout(ws.data.upstreamOpenTimer);
22520
22983
  if (ws.data.upstream) closeWebSocket(ws.data.upstream, code, reason, 1e3);
22521
22984
  }
@@ -22526,34 +22989,32 @@ function createWorkspaceRelayBun(options, bunOptions = {}) {
22526
22989
  }
22527
22990
 
22528
22991
  // src/directory.ts
22992
+ function presenceKey(hostId, workspaceId) {
22993
+ return `${hostId}\0${workspaceId}`;
22994
+ }
22529
22995
  function createWorkspaceRelayDirectory(options = {}) {
22530
22996
  const ttlMs = Math.max(1, options.ttlMs ?? 45e3);
22531
22997
  const now = options.now ?? Date.now;
22532
22998
  const sweepIntervalMs = options.sweepIntervalMs ?? 3e4;
22533
- const hosts = /* @__PURE__ */ new Map();
22534
- const alive = (presence) => {
22535
- if (!presence) return;
22999
+ const entries = /* @__PURE__ */ new Map();
23000
+ const alive = (key) => {
23001
+ const presence = entries.get(key);
23002
+ if (!presence) return void 0;
22536
23003
  if (presence.expiresAt <= now()) {
22537
- hosts.delete(presence.hostId);
22538
- return;
23004
+ entries.delete(key);
23005
+ return void 0;
22539
23006
  }
22540
23007
  return presence;
22541
23008
  };
22542
- const touch = (presence) => {
22543
- const timestamp = now();
22544
- const next = {
22545
- ...presence,
22546
- lastPongAt: timestamp,
22547
- expiresAt: timestamp + ttlMs
22548
- };
22549
- hosts.set(next.hostId, next);
22550
- return next;
23009
+ const keysOf = (hostId, workspaceIds) => {
23010
+ if (workspaceIds) return [...new Set(workspaceIds)].map((workspaceId) => presenceKey(hostId, workspaceId));
23011
+ return [...entries].filter(([, presence]) => presence.hostId === hostId).map(([key]) => key);
22551
23012
  };
22552
23013
  const sweep = () => {
22553
23014
  const at = now();
22554
- for (const [hostId, presence] of hosts) {
23015
+ for (const [key, presence] of entries) {
22555
23016
  if (presence.expiresAt <= at) {
22556
- hosts.delete(hostId);
23017
+ entries.delete(key);
22557
23018
  }
22558
23019
  }
22559
23020
  };
@@ -22571,29 +23032,35 @@ function createWorkspaceRelayDirectory(options = {}) {
22571
23032
  lastPongAt: timestamp,
22572
23033
  expiresAt: timestamp + ttlMs
22573
23034
  };
22574
- hosts.set(next.hostId, next);
23035
+ for (const workspaceId of next.workspaceIds) entries.set(presenceKey(next.hostId, workspaceId), next);
22575
23036
  return next;
22576
23037
  },
22577
- recordPong(hostId) {
22578
- const presence = alive(hosts.get(hostId));
22579
- return presence ? touch(presence) : void 0;
23038
+ recordPong(hostId, workspaceIds) {
23039
+ const timestamp = now();
23040
+ let touched;
23041
+ for (const key of keysOf(hostId, workspaceIds)) {
23042
+ const presence = alive(key);
23043
+ if (!presence) continue;
23044
+ const next = { ...presence, lastPongAt: timestamp, expiresAt: timestamp + ttlMs };
23045
+ entries.set(key, next);
23046
+ touched ??= next;
23047
+ }
23048
+ return touched;
22580
23049
  },
22581
- disconnectHost(hostId) {
22582
- hosts.delete(hostId);
23050
+ disconnectHost(hostId, workspaceIds) {
23051
+ for (const key of keysOf(hostId, workspaceIds)) entries.delete(key);
22583
23052
  },
22584
23053
  activeHost(input) {
22585
- const presence = alive(hosts.get(input.hostId));
22586
- if (!presence?.workspaceIds.includes(input.workspaceId)) return;
22587
- return presence;
23054
+ return alive(presenceKey(input.hostId, input.workspaceId));
22588
23055
  },
22589
23056
  sweep,
22590
23057
  size() {
22591
23058
  const at = now();
22592
- let count = 0;
22593
- for (const presence of hosts.values()) {
22594
- if (presence.expiresAt > at) count += 1;
23059
+ const hosts = /* @__PURE__ */ new Set();
23060
+ for (const presence of entries.values()) {
23061
+ if (presence.expiresAt > at) hosts.add(presence.hostId);
22595
23062
  }
22596
- return count;
23063
+ return hosts.size;
22597
23064
  },
22598
23065
  dispose() {
22599
23066
  if (intervalHandle !== void 0) {
@@ -22725,20 +23192,17 @@ function startSyntheticProbe(options) {
22725
23192
  // src/main.ts
22726
23193
  var BUN_TARGET_CACHE_TTL_MS_DEFAULT = 3e4;
22727
23194
  var BUN_REVOCATION_CACHE_TTL_MS_DEFAULT = 1e4;
23195
+ var BUN_HOST_GENERATION_CACHE_TTL_MS_DEFAULT = 1e4;
22728
23196
  var BUN_RUNTIME_ACCESS_TOKEN_CACHE_TTL_MS_DEFAULT = 1e4;
22729
- function clean(input) {
22730
- const value = input?.trim();
22731
- return value ? value : void 0;
22732
- }
22733
23197
  function positiveInteger(input) {
22734
- const parsed = Number(clean(input));
23198
+ const parsed = Number(trimToUndefined(input));
22735
23199
  return Number.isInteger(parsed) && parsed > 0 ? parsed : void 0;
22736
23200
  }
22737
23201
  function pem(input) {
22738
- return clean(input)?.replaceAll("\\n", "\n");
23202
+ return trimToUndefined(input)?.replaceAll("\\n", "\n");
22739
23203
  }
22740
23204
  function requireEnv(name) {
22741
- const value = clean(process.env[name]);
23205
+ const value = trimToUndefined(process.env[name]);
22742
23206
  if (!value) {
22743
23207
  console.error(`[workspace-relay] missing required env: ${name}`);
22744
23208
  process.exit(2);
@@ -22746,17 +23210,17 @@ function requireEnv(name) {
22746
23210
  return value;
22747
23211
  }
22748
23212
  function relayTelemetryOptions(env) {
22749
- if (clean(env.CLAXEDO_TELEMETRY_MODE)?.toLowerCase() !== "on") return void 0;
22750
- const key = clean(env.CLAXEDO_POSTHOG_KEY) ?? clean(env.POSTHOG_KEY);
23213
+ if (trimToUndefined(env.CLAXEDO_TELEMETRY_MODE)?.toLowerCase() !== "on") return void 0;
23214
+ const key = trimToUndefined(env.CLAXEDO_POSTHOG_KEY) ?? trimToUndefined(env.POSTHOG_KEY);
22751
23215
  if (!key) return void 0;
22752
- const release = clean(env.CLAXEDO_RELEASE) ?? clean(env.GIT_SHA);
23216
+ const release = trimToUndefined(env.CLAXEDO_RELEASE) ?? trimToUndefined(env.GIT_SHA);
22753
23217
  return {
22754
23218
  key,
22755
- host: clean(env.CLAXEDO_POSTHOG_HOST) ?? "https://us.i.posthog.com",
23219
+ host: trimToUndefined(env.CLAXEDO_POSTHOG_HOST) ?? "https://us.i.posthog.com",
22756
23220
  ...release ? { release } : {},
22757
23221
  tags: {
22758
23222
  unit: "relay",
22759
- deployment_mode: clean(env.CLAXEDO_DEPLOYMENT_MODE)?.toLowerCase() ?? "self-host"
23223
+ deployment_mode: trimToUndefined(env.CLAXEDO_DEPLOYMENT_MODE)?.toLowerCase() ?? "local"
22760
23224
  }
22761
23225
  };
22762
23226
  }
@@ -22783,9 +23247,9 @@ async function reportFatal(error) {
22783
23247
  }
22784
23248
  }
22785
23249
  function validateProductionEnv(env) {
22786
- const isProduction = clean(env.NODE_ENV) === "production";
23250
+ const isProduction = trimToUndefined(env.NODE_ENV) === "production";
22787
23251
  if (!isProduction) return { ok: true };
22788
- const resolverToken = clean(env.CLAXEDO_RELAY_RESOLVER_TOKEN);
23252
+ const resolverToken = trimToUndefined(env.CLAXEDO_RELAY_RESOLVER_TOKEN);
22789
23253
  if (!resolverToken) {
22790
23254
  return {
22791
23255
  ok: false,
@@ -22796,7 +23260,7 @@ function validateProductionEnv(env) {
22796
23260
  return { ok: true };
22797
23261
  }
22798
23262
  async function loadRuntimeAccessKeyOrJwks(env) {
22799
- const jwksUrl = clean(env.CLAXEDO_CONTROL_PLANE_JWKS_URL);
23263
+ const jwksUrl = trimToUndefined(env.CLAXEDO_CONTROL_PLANE_JWKS_URL);
22800
23264
  if (jwksUrl) {
22801
23265
  return createRemoteJWKSet(new URL(jwksUrl));
22802
23266
  }
@@ -22808,14 +23272,6 @@ async function loadRuntimeAccessKeyOrJwks(env) {
22808
23272
  }
22809
23273
  return importSPKI(verifyPem, "EdDSA");
22810
23274
  }
22811
- async function deriveKidFromPublicKey(publicKey) {
22812
- const jwk = await exportJWK2(publicKey);
22813
- const material = String(jwk.x ?? jwk.n ?? "");
22814
- if (!material) {
22815
- throw new Error("Cannot derive kid: public key has no public component");
22816
- }
22817
- return createHash("sha256").update(material).digest("hex").slice(0, 16);
22818
- }
22819
23275
  async function loadRelayHostKeyMaterial(env) {
22820
23276
  const privatePem = pem(env.CLAXEDO_RELAY_HOST_SIGNING_KEY_PEM);
22821
23277
  let privateKey;
@@ -22823,7 +23279,7 @@ async function loadRelayHostKeyMaterial(env) {
22823
23279
  if (privatePem) {
22824
23280
  privateKey = await importPKCS8(privatePem, "EdDSA", { extractable: true });
22825
23281
  } else {
22826
- if (clean(env.NODE_ENV) === "production") {
23282
+ if (trimToUndefined(env.NODE_ENV) === "production") {
22827
23283
  console.error(
22828
23284
  "[workspace-relay] CLAXEDO_RELAY_HOST_SIGNING_KEY_PEM required in production; refusing to start with an ephemeral key"
22829
23285
  );
@@ -22841,21 +23297,18 @@ async function loadRelayHostKeyMaterial(env) {
22841
23297
  } else if (derivedPublicKey) {
22842
23298
  publicKey = derivedPublicKey;
22843
23299
  } else {
22844
- const jwk = await exportJWK2(privateKey);
22845
- const publicJwk = { kty: jwk.kty, crv: jwk.crv, x: jwk.x };
22846
- const { importJWK } = await import("jose");
22847
- publicKey = await importJWK(publicJwk, "EdDSA", { extractable: true });
23300
+ publicKey = await deriveRelayHostPublicKey(privateKey);
22848
23301
  }
22849
- const explicitCurrentKid = clean(env.CLAXEDO_RELAY_HOST_KID);
22850
- const currentKid = explicitCurrentKid ?? await deriveKidFromPublicKey(publicKey);
23302
+ const explicitCurrentKid = trimToUndefined(env.CLAXEDO_RELAY_HOST_KID);
23303
+ const currentKid = explicitCurrentKid ?? await deriveRelayHostKid(publicKey);
22851
23304
  const nextPem = pem(env.CLAXEDO_RELAY_HOST_NEXT_PUBLIC_KEY_PEM);
22852
23305
  let next;
22853
23306
  if (nextPem) {
22854
23307
  const nextPublicKey = await importSPKI(nextPem, "EdDSA", { extractable: true });
22855
- const explicitNextKid = clean(env.CLAXEDO_RELAY_HOST_NEXT_KID);
23308
+ const explicitNextKid = trimToUndefined(env.CLAXEDO_RELAY_HOST_NEXT_KID);
22856
23309
  next = {
22857
23310
  publicKey: nextPublicKey,
22858
- kid: explicitNextKid ?? await deriveKidFromPublicKey(nextPublicKey)
23311
+ kid: explicitNextKid ?? await deriveRelayHostKid(nextPublicKey)
22859
23312
  };
22860
23313
  }
22861
23314
  return {
@@ -22865,9 +23318,9 @@ async function loadRelayHostKeyMaterial(env) {
22865
23318
  };
22866
23319
  }
22867
23320
  function parseAuditAcceptSampleRate(env) {
22868
- const isProduction = clean(env.NODE_ENV) === "production";
23321
+ const isProduction = trimToUndefined(env.NODE_ENV) === "production";
22869
23322
  const fallback = isProduction ? 0.1 : 1;
22870
- const raw = clean(env.CLAXEDO_RELAY_AUDIT_ACCEPT_SAMPLE_RATE);
23323
+ const raw = trimToUndefined(env.CLAXEDO_RELAY_AUDIT_ACCEPT_SAMPLE_RATE);
22871
23324
  if (!raw) return fallback;
22872
23325
  const value = Number(raw);
22873
23326
  if (!Number.isFinite(value)) return fallback;
@@ -22875,7 +23328,7 @@ function parseAuditAcceptSampleRate(env) {
22875
23328
  return value;
22876
23329
  }
22877
23330
  function parseMetricsToken(env) {
22878
- return clean(env.CLAXEDO_RELAY_METRICS_TOKEN);
23331
+ return trimToUndefined(env.CLAXEDO_RELAY_METRICS_TOKEN);
22879
23332
  }
22880
23333
  function runtimeAccessTokenCacheTtlMsFromEnv(env) {
22881
23334
  return positiveInteger(env.CLAXEDO_RELAY_RUNTIME_ACCESS_TOKEN_CACHE_TTL_MS) ?? BUN_RUNTIME_ACCESS_TOKEN_CACHE_TTL_MS_DEFAULT;
@@ -22884,9 +23337,12 @@ function directHttpConcurrencyFromEnv(env) {
22884
23337
  return positiveInteger(env.CLAXEDO_RELAY_DIRECT_HTTP_CONCURRENCY);
22885
23338
  }
22886
23339
  function resolverClientCacheOptionsFromEnv(env) {
23340
+ const hostGenerationUrl = trimToUndefined(env.CLAXEDO_RELAY_HOST_GENERATION_URL);
22887
23341
  return {
22888
23342
  targetCacheTtlMs: positiveInteger(env.CLAXEDO_RELAY_TARGET_CACHE_TTL_MS) ?? BUN_TARGET_CACHE_TTL_MS_DEFAULT,
22889
- revocationCacheTtlMs: positiveInteger(env.CLAXEDO_RELAY_REVOCATION_CACHE_TTL_MS) ?? BUN_REVOCATION_CACHE_TTL_MS_DEFAULT
23343
+ revocationCacheTtlMs: positiveInteger(env.CLAXEDO_RELAY_REVOCATION_CACHE_TTL_MS) ?? BUN_REVOCATION_CACHE_TTL_MS_DEFAULT,
23344
+ ...hostGenerationUrl ? { hostGenerationUrl } : {},
23345
+ hostGenerationCacheTtlMs: positiveInteger(env.CLAXEDO_RELAY_HOST_GENERATION_CACHE_TTL_MS) ?? BUN_HOST_GENERATION_CACHE_TTL_MS_DEFAULT
22890
23346
  };
22891
23347
  }
22892
23348
  function createResolverClient(baseUrl, token, options = {}) {
@@ -22900,7 +23356,9 @@ function createResolverClient(baseUrl, token, options = {}) {
22900
23356
  const res = await fetch(url, { headers: headers2 });
22901
23357
  if (res.status === 404) return void 0;
22902
23358
  if (!res.ok) throw new Error(`relay target resolver failed: ${res.status} ${await res.text()}`);
22903
- return await res.json();
23359
+ const target2 = parseWorkspaceRelayTarget(await res.json());
23360
+ if (!target2) throw new Error("relay target resolver returned a malformed target");
23361
+ return target2;
22904
23362
  };
22905
23363
  const revocationUncached = async (args) => {
22906
23364
  const url = new URL(`${root}/revocation`);
@@ -22915,7 +23373,9 @@ function createResolverClient(baseUrl, token, options = {}) {
22915
23373
  reason: `revocation resolver returned ${res.status}`
22916
23374
  };
22917
23375
  }
22918
- return await res.json();
23376
+ const result = parseRuntimeAccessTokenActiveResult(await res.json());
23377
+ if (!result) throw new Error("relay revocation resolver returned a malformed result");
23378
+ return result;
22919
23379
  };
22920
23380
  const target = createCachedTargetClient(targetUncached, {
22921
23381
  ttlMs: options.targetCacheTtlMs ?? BUN_TARGET_CACHE_TTL_MS_DEFAULT
@@ -22923,11 +23383,39 @@ function createResolverClient(baseUrl, token, options = {}) {
22923
23383
  const revocation = createCachedRevocationClient(revocationUncached, {
22924
23384
  ttlMs: options.revocationCacheTtlMs ?? BUN_REVOCATION_CACHE_TTL_MS_DEFAULT
22925
23385
  });
23386
+ const hostGeneration = createCachedHostGenerationClient(
23387
+ createHostGenerationResolverLookup(options.hostGenerationUrl ?? `${root}/host-generation`, { headers: headers2 }),
23388
+ { ttlMs: options.hostGenerationCacheTtlMs ?? BUN_HOST_GENERATION_CACHE_TTL_MS_DEFAULT }
23389
+ );
22926
23390
  return {
22927
23391
  target: (workspaceId, hostId) => target({ workspaceId, hostId }),
22928
- revocation
23392
+ revocation,
23393
+ hostGeneration
22929
23394
  };
22930
23395
  }
23396
+ var STOP_SERVER_TIMEOUT_MS_DEFAULT = 5e3;
23397
+ async function runBoundedTeardown(step, label, timeoutMs, log) {
23398
+ if (!step) return;
23399
+ let timer;
23400
+ const attempt = (async () => {
23401
+ await step();
23402
+ })().catch((err) => {
23403
+ log(`[workspace-relay] ${label} failed: ${err instanceof Error ? err.message : String(err)}`);
23404
+ });
23405
+ try {
23406
+ await Promise.race([
23407
+ attempt,
23408
+ new Promise((resolve) => {
23409
+ timer = setTimeout(() => {
23410
+ log(`[workspace-relay] ${label} did not finish within ${timeoutMs}ms; exiting anyway`);
23411
+ resolve();
23412
+ }, timeoutMs);
23413
+ })
23414
+ ]);
23415
+ } finally {
23416
+ if (timer) clearTimeout(timer);
23417
+ }
23418
+ }
22931
23419
  function installShutdownDrainHandler(options) {
22932
23420
  const exit = options.exit ?? ((code) => process.exit(code));
22933
23421
  const log = options.log ?? (() => {
@@ -22950,11 +23438,12 @@ function installShutdownDrainHandler(options) {
22950
23438
  `[workspace-relay] drain timeout: ${result.remaining} pending request(s) remaining after ${options.drainTimeoutMs}ms; force-closing`
22951
23439
  );
22952
23440
  }
22953
- try {
22954
- await options.stopServer?.();
22955
- } catch (err) {
22956
- log(`[workspace-relay] stopServer failed: ${err instanceof Error ? err.message : String(err)}`);
22957
- }
23441
+ await runBoundedTeardown(
23442
+ options.stopServer,
23443
+ "stopServer",
23444
+ options.stopTimeoutMs ?? STOP_SERVER_TIMEOUT_MS_DEFAULT,
23445
+ log
23446
+ );
22958
23447
  try {
22959
23448
  options.directory.dispose();
22960
23449
  } catch (err) {
@@ -22995,11 +23484,12 @@ function installFatalProcessHandlers(options) {
22995
23484
  log(`[workspace-relay] fatal report failed: ${err instanceof Error ? err.message : String(err)}`);
22996
23485
  }
22997
23486
  options.drain.setDraining(true);
22998
- try {
22999
- await options.stopServer?.();
23000
- } catch (err) {
23001
- log(`[workspace-relay] fatal stopServer failed: ${err instanceof Error ? err.message : String(err)}`);
23002
- }
23487
+ await runBoundedTeardown(
23488
+ options.stopServer,
23489
+ "fatal stopServer",
23490
+ options.stopTimeoutMs ?? STOP_SERVER_TIMEOUT_MS_DEFAULT,
23491
+ log
23492
+ );
23003
23493
  try {
23004
23494
  options.directory.dispose();
23005
23495
  } catch (err) {
@@ -23035,14 +23525,14 @@ async function main() {
23035
23525
  console.error(`[workspace-relay] ${validation.message}`);
23036
23526
  process.exit(validation.exitCode);
23037
23527
  }
23038
- const port = Number(clean(process.env.CLAXEDO_WORKSPACE_RELAY_PORT) ?? "7777");
23528
+ const port = Number(trimToUndefined(process.env.CLAXEDO_WORKSPACE_RELAY_PORT) ?? "7777");
23039
23529
  if (!Number.isFinite(port) || port <= 0) {
23040
23530
  console.error(`[workspace-relay] invalid CLAXEDO_WORKSPACE_RELAY_PORT: ${process.env.CLAXEDO_WORKSPACE_RELAY_PORT}`);
23041
23531
  process.exit(2);
23042
23532
  }
23043
- const hostname = clean(process.env.CLAXEDO_WORKSPACE_RELAY_HOST) ?? "127.0.0.1";
23533
+ const hostname = trimToUndefined(process.env.CLAXEDO_WORKSPACE_RELAY_HOST) ?? "127.0.0.1";
23044
23534
  const resolverUrl = requireEnv("CLAXEDO_RELAY_RESOLVER_URL");
23045
- const resolverToken = clean(process.env.CLAXEDO_RELAY_RESOLVER_TOKEN);
23535
+ const resolverToken = trimToUndefined(process.env.CLAXEDO_RELAY_RESOLVER_TOKEN);
23046
23536
  let runtimeAccessKey;
23047
23537
  try {
23048
23538
  runtimeAccessKey = await loadRuntimeAccessKeyOrJwks({
@@ -23072,7 +23562,9 @@ async function main() {
23072
23562
  if (relayHostMaterial.next) relayHostPublicKeys.push(relayHostMaterial.next);
23073
23563
  const resolver = createResolverClient(resolverUrl, resolverToken, resolverClientCacheOptionsFromEnv({
23074
23564
  CLAXEDO_RELAY_TARGET_CACHE_TTL_MS: process.env.CLAXEDO_RELAY_TARGET_CACHE_TTL_MS,
23075
- CLAXEDO_RELAY_REVOCATION_CACHE_TTL_MS: process.env.CLAXEDO_RELAY_REVOCATION_CACHE_TTL_MS
23565
+ CLAXEDO_RELAY_REVOCATION_CACHE_TTL_MS: process.env.CLAXEDO_RELAY_REVOCATION_CACHE_TTL_MS,
23566
+ CLAXEDO_RELAY_HOST_GENERATION_URL: process.env.CLAXEDO_RELAY_HOST_GENERATION_URL,
23567
+ CLAXEDO_RELAY_HOST_GENERATION_CACHE_TTL_MS: process.env.CLAXEDO_RELAY_HOST_GENERATION_CACHE_TTL_MS
23076
23568
  }));
23077
23569
  const directory = createWorkspaceRelayDirectory();
23078
23570
  const auditAcceptSampleRate = parseAuditAcceptSampleRate({
@@ -23088,7 +23580,7 @@ async function main() {
23088
23580
  const directHttpConcurrency = directHttpConcurrencyFromEnv({
23089
23581
  CLAXEDO_RELAY_DIRECT_HTTP_CONCURRENCY: process.env.CLAXEDO_RELAY_DIRECT_HTTP_CONCURRENCY
23090
23582
  });
23091
- const allowedOrigins = parseAllowedOrigins(clean(process.env.CLAXEDO_RELAY_ALLOWED_ORIGINS));
23583
+ const allowedOrigins = parseAllowedOrigins(trimToUndefined(process.env.CLAXEDO_RELAY_ALLOWED_ORIGINS));
23092
23584
  const handler = createWorkspaceRelayBun({
23093
23585
  runtimeAccessKey,
23094
23586
  runtimeAccessTokenCacheTtlMs: runtimeAccessTokenCacheTtlMs2,
@@ -23106,6 +23598,7 @@ async function main() {
23106
23598
  workspaceId: claims.workspace_id,
23107
23599
  hostId: claims.host_id
23108
23600
  }),
23601
+ resolveHostGeneration: resolver.hostGeneration,
23109
23602
  audit: (event) => {
23110
23603
  if (event.result === "deny") {
23111
23604
  console.warn(
@@ -23113,23 +23606,25 @@ async function main() {
23113
23606
  );
23114
23607
  }
23115
23608
  }
23116
- }, {
23117
- ...directHttpConcurrency ? { directHttpConcurrency } : {}
23118
- });
23609
+ }, directHttpConcurrency ? { directHttpConcurrency } : {});
23119
23610
  const server = Bun.serve({
23120
23611
  port,
23121
23612
  hostname,
23613
+ // Runtime SSE heartbeats are intentionally 30s apart. Keep a bounded
23614
+ // timeout above that interval so quiet streams survive without leaving
23615
+ // unauthenticated or incomplete HTTP sockets open indefinitely.
23616
+ idleTimeout: WORKSPACE_RELAY_IDLE_TIMEOUT_SECONDS,
23122
23617
  fetch: handler.fetch,
23123
23618
  websocket: handler.websocket
23124
23619
  });
23125
23620
  console.log(`[workspace-relay] listening on http://${hostname}:${port} resolver=${resolverUrl}`);
23126
- const rawDrainTimeout = clean(process.env.CLAXEDO_RELAY_DRAIN_TIMEOUT_MS);
23621
+ const rawDrainTimeout = trimToUndefined(process.env.CLAXEDO_RELAY_DRAIN_TIMEOUT_MS);
23127
23622
  const parsedDrainTimeout = rawDrainTimeout ? Number(rawDrainTimeout) : NaN;
23128
23623
  const drainTimeoutMs = Number.isFinite(parsedDrainTimeout) && parsedDrainTimeout > 0 ? parsedDrainTimeout : 3e4;
23129
23624
  let syntheticProbe;
23130
- const syntheticDisabled = clean(process.env.CLAXEDO_RELAY_SYNTHETIC_PROBE_DISABLED) === "1";
23625
+ const syntheticDisabled = trimToUndefined(process.env.CLAXEDO_RELAY_SYNTHETIC_PROBE_DISABLED) === "1";
23131
23626
  if (!syntheticDisabled) {
23132
- const rawProbeInterval = clean(process.env.CLAXEDO_RELAY_SYNTHETIC_PROBE_INTERVAL_MS);
23627
+ const rawProbeInterval = trimToUndefined(process.env.CLAXEDO_RELAY_SYNTHETIC_PROBE_INTERVAL_MS);
23133
23628
  const parsedProbeInterval = rawProbeInterval ? Number(rawProbeInterval) : NaN;
23134
23629
  const probeIntervalMs = Number.isFinite(parsedProbeInterval) && parsedProbeInterval > 0 ? parsedProbeInterval : 6e4;
23135
23630
  syntheticProbe = startSyntheticProbe({
@@ -23144,7 +23639,7 @@ async function main() {
23144
23639
  drainTimeoutMs,
23145
23640
  log: (message) => console.log(message),
23146
23641
  stopServer: async () => {
23147
- server.stop(true);
23642
+ await server.stop(true);
23148
23643
  syntheticProbe?.stop();
23149
23644
  }
23150
23645
  });
@@ -23152,11 +23647,10 @@ async function main() {
23152
23647
  drain: handler.drain,
23153
23648
  directory,
23154
23649
  log: (message) => console.error(message),
23155
- // W2c: fatal crashes are exactly the events a solo operator never sees in
23156
- // a log buffer — flush them to PostHog (no-op when no key) before exiting.
23650
+ // Flush fatal crashes to PostHog (no-op without a key) before exiting.
23157
23651
  report: (error) => reportFatal(error),
23158
23652
  stopServer: async () => {
23159
- server.stop(true);
23653
+ await server.stop(true);
23160
23654
  syntheticProbe?.stop();
23161
23655
  }
23162
23656
  });