@claxedo/workspace-relay 0.8.0 → 0.10.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/index.mjs CHANGED
@@ -5,6 +5,9 @@ import { SignJWT, errors, exportJWK, importJWK, jwtVerify } from "jose";
5
5
  function isRecord(value) {
6
6
  return value !== null && typeof value === "object" && !Array.isArray(value);
7
7
  }
8
+ function asRecord(value) {
9
+ return isRecord(value) ? value : void 0;
10
+ }
8
11
  function isFiniteNumber(value) {
9
12
  return typeof value === "number" && Number.isFinite(value);
10
13
  }
@@ -16,12 +19,21 @@ function numberClaim(payload, key) {
16
19
  }
17
20
 
18
21
  // src/auth.ts
22
+ import { CURRENT_CHANNEL_IDENTITY_VERSION } from "@claxedo/workspace-relay-protocol";
19
23
  var algorithms = ["EdDSA", "ES256", "RS256"];
20
24
  var runtimeAccessTokenIssuer = "claxedo-control-plane";
21
25
  var runtimeAccessTokenAudience = "workspace-relay";
22
26
  var hostTunnelTokenAudience = "workspace-relay-host-tunnel";
23
27
  var relayHostTokenIssuer = "workspace-relay";
24
28
  var relayHostTokenAudience = "workspace-host-service";
29
+ var RUNTIME_ACCESS_TOKEN_ISSUED_AT_FLOOR_SECONDS = 1789948800;
30
+ function toChannelIdentityClaim(input) {
31
+ return {
32
+ channel: input.channel,
33
+ external_user_id: input.externalUserId,
34
+ identity_version: input.identityVersion
35
+ };
36
+ }
25
37
  var WorkspaceRelayAuthError = class extends Error {
26
38
  constructor(code, message) {
27
39
  super(message);
@@ -53,7 +65,6 @@ function isHostGeneration(value) {
53
65
  function hostGenerationClaims(payload) {
54
66
  const enrollment_id = stringClaim(payload, "enrollment_id");
55
67
  const generation = payload.generation;
56
- if (generation === void 0) return enrollment_id ? { enrollment_id } : {};
57
68
  if (!isHostGeneration(generation) || !enrollment_id) return null;
58
69
  return { enrollment_id, generation };
59
70
  }
@@ -80,8 +91,40 @@ function actorProfilePayload(input) {
80
91
  ...input.actorAvatarUrl ? { actor_avatar_url: input.actorAvatarUrl } : {}
81
92
  };
82
93
  }
83
- function isRelayClaimPair(input) {
84
- return input.access === "cloud" && input.backing === "cloud-vm" || input.access === "user-hosted" && input.backing === "local-worktree";
94
+ function channelIdentityPayload(input) {
95
+ return input.channelIdentity ? { channel_identity: toChannelIdentityClaim(input.channelIdentity) } : {};
96
+ }
97
+ function channelIdentityClaims(payload) {
98
+ const value = payload.channel_identity;
99
+ if (value === void 0) return void 0;
100
+ if (!isRecord(value)) {
101
+ throw new WorkspaceRelayAuthError("relay_token_claims_invalid", "Channel provenance claim is not a claims object");
102
+ }
103
+ const claim = value;
104
+ const channel = stringClaim(claim, "channel");
105
+ const external_user_id = stringClaim(claim, "external_user_id");
106
+ const identity_version = numberClaim(claim, "identity_version");
107
+ if (!channel || !external_user_id || identity_version === void 0 || !Number.isInteger(identity_version)) {
108
+ throw new WorkspaceRelayAuthError("relay_token_claims_invalid", "Channel provenance claim is incomplete");
109
+ }
110
+ if (identity_version < CURRENT_CHANNEL_IDENTITY_VERSION) {
111
+ throw new WorkspaceRelayAuthError(
112
+ "relay_token_claims_invalid",
113
+ "Channel provenance claim predates the current channel identity version"
114
+ );
115
+ }
116
+ return { channel, external_user_id, identity_version };
117
+ }
118
+ function checkIssuedAtFloor(iat) {
119
+ if (iat < RUNTIME_ACCESS_TOKEN_ISSUED_AT_FLOOR_SECONDS) {
120
+ throw new WorkspaceRelayAuthError(
121
+ "relay_token_claims_invalid",
122
+ "Runtime Access Token was issued before the channel provenance floor"
123
+ );
124
+ }
125
+ }
126
+ function isRelayBacking(input) {
127
+ return input === "cloud-vm" || input === "local-worktree" || input === "durable-object";
85
128
  }
86
129
  function checkHostTunnelTarget(payload, expected) {
87
130
  if (stringClaim(payload, "host_id") !== expected.hostId) {
@@ -142,12 +185,17 @@ async function mintRuntimeAccessToken(input, key, alg) {
142
185
  return await new SignJWT({
143
186
  principal_kind: input.principalKind,
144
187
  actor_id: input.actorId,
188
+ ...input.userId ? { user_id: input.userId } : {},
145
189
  actor_kind: input.actorKind,
146
190
  ...actorProfilePayload(input),
191
+ ...channelIdentityPayload(input),
147
192
  org_id: input.orgId,
148
193
  workspace_id: input.workspaceId,
149
194
  host_id: input.hostId,
150
- role: input.role
195
+ role: input.role,
196
+ ...tokenScopePayload(input.sessionId),
197
+ ...input.purpose ? { purpose: input.purpose } : {},
198
+ ...input.routingId !== void 0 ? { routing_id: input.routingId } : {}
151
199
  }).setProtectedHeader({ alg: requireAlgorithm(alg) }).setIssuer(runtimeAccessTokenIssuer).setAudience(runtimeAccessTokenAudience).setIssuedAt(now).setExpirationTime(now + (input.ttlSeconds ?? 30 * 60)).setJti(input.jti ?? jti()).sign(key);
152
200
  }
153
201
  async function verifyRuntimeAccessToken(token, key, expected) {
@@ -160,8 +208,27 @@ async function verifyRuntimeAccessToken(token, key, expected) {
160
208
  if (!claims) {
161
209
  throw new WorkspaceRelayAuthError("relay_token_claims_invalid", "Runtime Access Token claims are incomplete");
162
210
  }
211
+ checkIssuedAtFloor(claims.iat);
163
212
  return claims;
164
213
  }
214
+ var TOKEN_CLAIMS_CLOCK_SKEW_SECONDS = 60;
215
+ var TOKEN_CLAIMS_MAX_LIFETIME_SECONDS = 24 * 60 * 60;
216
+ function checkTokenTimeClaims(payload, exp, tokenName) {
217
+ const now = seconds();
218
+ if (exp <= now - TOKEN_CLAIMS_CLOCK_SKEW_SECONDS) {
219
+ throw new WorkspaceRelayAuthError("relay_token_claims_invalid", `${tokenName} is expired`);
220
+ }
221
+ const nbf = numberClaim(payload, "nbf");
222
+ if (payload.nbf !== void 0 && nbf === void 0) {
223
+ throw new WorkspaceRelayAuthError("relay_token_claims_invalid", `${tokenName} nbf claim is not a finite number`);
224
+ }
225
+ if (nbf !== void 0 && nbf > now + TOKEN_CLAIMS_CLOCK_SKEW_SECONDS) {
226
+ throw new WorkspaceRelayAuthError("relay_token_claims_invalid", `${tokenName} is not yet valid`);
227
+ }
228
+ if (exp > now + TOKEN_CLAIMS_MAX_LIFETIME_SECONDS) {
229
+ throw new WorkspaceRelayAuthError("relay_token_claims_invalid", `${tokenName} lifetime exceeds the relay maximum`);
230
+ }
231
+ }
165
232
  function validateRuntimeAccessTokenClaims(input, expected) {
166
233
  const payload = input;
167
234
  if (stringClaim(payload, "iss") !== runtimeAccessTokenIssuer || stringClaim(payload, "aud") !== runtimeAccessTokenAudience) {
@@ -172,18 +239,20 @@ function validateRuntimeAccessTokenClaims(input, expected) {
172
239
  if (!claims) {
173
240
  throw new WorkspaceRelayAuthError("relay_token_claims_invalid", "Runtime Access Token claims are incomplete");
174
241
  }
242
+ checkIssuedAtFloor(claims.iat);
243
+ checkTokenTimeClaims(payload, claims.exp, "Runtime Access Token");
175
244
  return claims;
176
245
  }
177
246
  async function mintHostTunnelToken(input, key, alg) {
178
247
  const now = seconds(input.now);
179
- if (input.generation !== void 0 && (!isHostGeneration(input.generation) || !input.enrollmentId)) {
248
+ if (!isHostGeneration(input.generation) || !input.enrollmentId?.trim()) {
180
249
  throw new WorkspaceRelayAuthError("relay_token_claims_invalid", "Host Tunnel Token generation requires a non-negative integer and an enrollment id");
181
250
  }
182
251
  return await new SignJWT({
183
252
  host_id: input.hostId,
184
253
  workspace_ids: input.workspaceIds,
185
- ...input.enrollmentId ? { enrollment_id: input.enrollmentId } : {},
186
- ...input.generation !== void 0 ? { generation: input.generation } : {}
254
+ enrollment_id: input.enrollmentId,
255
+ generation: input.generation
187
256
  }).setProtectedHeader({ alg: requireAlgorithm(alg) }).setIssuer(runtimeAccessTokenIssuer).setAudience(hostTunnelTokenAudience).setSubject(input.subject).setIssuedAt(now).setExpirationTime(now + (input.ttlSeconds ?? 5 * 60)).setJti(input.jti ?? jti()).sign(key);
188
257
  }
189
258
  async function verifyHostTunnelToken(token, key, expected) {
@@ -198,9 +267,34 @@ async function verifyHostTunnelToken(token, key, expected) {
198
267
  }
199
268
  return claims;
200
269
  }
270
+ function relayHostInputFromRuntimeClaims(claims) {
271
+ return {
272
+ principalKind: claims.principal_kind,
273
+ actorId: claims.actor_id,
274
+ ...claims.user_id ? { userId: claims.user_id } : {},
275
+ actorKind: claims.actor_kind,
276
+ parentJti: claims.jti,
277
+ ...claims.actor_public_id && claims.actor_name ? {
278
+ actorPublicId: claims.actor_public_id,
279
+ actorName: claims.actor_name,
280
+ ...claims.actor_avatar_url ? { actorAvatarUrl: claims.actor_avatar_url } : {}
281
+ } : {},
282
+ ...claims.channel_identity ? {
283
+ channelIdentity: {
284
+ channel: claims.channel_identity.channel,
285
+ externalUserId: claims.channel_identity.external_user_id,
286
+ identityVersion: claims.channel_identity.identity_version
287
+ }
288
+ } : {},
289
+ orgId: claims.org_id,
290
+ role: claims.role,
291
+ ...claims.session_id ? { sessionId: claims.session_id } : {},
292
+ ...claims.purpose ? { purpose: claims.purpose } : {}
293
+ };
294
+ }
201
295
  async function mintRelayHostToken(input, key, alg) {
202
- if (!isRelayClaimPair(input)) {
203
- throw new WorkspaceRelayAuthError("relay_token_claims_invalid", "Relay Host Token access/backing claims are inconsistent");
296
+ if (!isRelayBacking(input.backing)) {
297
+ throw new WorkspaceRelayAuthError("relay_token_claims_invalid", "Relay Host Token backing claim is not a placement");
204
298
  }
205
299
  const now = seconds(input.now);
206
300
  const protectedHeader = { alg: requireAlgorithm(alg) };
@@ -208,13 +302,16 @@ async function mintRelayHostToken(input, key, alg) {
208
302
  return await new SignJWT({
209
303
  principal_kind: input.principalKind,
210
304
  actor_id: input.actorId,
305
+ ...input.userId ? { user_id: input.userId } : {},
211
306
  actor_kind: input.actorKind,
212
307
  ...actorProfilePayload(input),
308
+ ...channelIdentityPayload(input),
213
309
  org_id: input.orgId,
214
310
  workspace_id: input.workspaceId,
215
311
  host_id: input.hostId,
216
312
  role: input.role,
217
- access: input.access,
313
+ ...tokenScopePayload(input.sessionId),
314
+ ...input.purpose ? { purpose: input.purpose } : {},
218
315
  backing: input.backing,
219
316
  parent_jti: input.parentJti
220
317
  }).setProtectedHeader(protectedHeader).setIssuer(relayHostTokenIssuer).setAudience(relayHostTokenAudience).setIssuedAt(now).setExpirationTime(now + (input.ttlSeconds ?? 60)).setJti(input.jti ?? jti()).sign(key);
@@ -236,48 +333,70 @@ function runtimeClaims(payload) {
236
333
  const iat = numberClaim(payload, "iat");
237
334
  const principal_kind = stringClaim(payload, "principal_kind");
238
335
  const actor_id = stringClaim(payload, "actor_id");
336
+ const user_id = stringClaim(payload, "user_id");
239
337
  const actor_kind = stringClaim(payload, "actor_kind");
240
338
  const jti2 = stringClaim(payload, "jti");
241
339
  const org_id = stringClaim(payload, "org_id");
242
340
  const workspace_id = stringClaim(payload, "workspace_id");
243
341
  const host_id = stringClaim(payload, "host_id");
244
342
  const role = roleClaim(payload);
245
- 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;
343
+ const reach = tokenScopeClaims(payload);
344
+ const purpose = tokenPurposeClaims(payload);
345
+ if (!exp || !iat || !jti2 || !org_id || !workspace_id || !host_id || !role || !actor_id || !reach || !purpose || payload.user_id !== void 0 && !user_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;
246
346
  const actorProfile = actorProfileClaims(payload);
247
347
  if (!actorProfile) return void 0;
348
+ const channel_identity = channelIdentityClaims(payload);
349
+ const routing_id = stringClaim(payload, "routing_id");
248
350
  return {
249
351
  iss: runtimeAccessTokenIssuer,
250
352
  aud: runtimeAccessTokenAudience,
251
353
  principal_kind,
252
354
  actor_id,
355
+ ...user_id ? { user_id } : {},
253
356
  actor_kind,
254
357
  ...actorProfile,
358
+ ...channel_identity ? { channel_identity } : {},
359
+ ...routing_id !== void 0 ? { routing_id } : {},
255
360
  org_id,
256
361
  workspace_id,
257
362
  host_id,
258
363
  role,
364
+ ...reach,
365
+ ...purpose,
259
366
  exp,
260
367
  iat,
261
368
  jti: jti2
262
369
  };
263
370
  }
371
+ function tokenScopePayload(sessionId) {
372
+ return sessionId === void 0 ? { scope: "workspace" } : { scope: "session", session_id: sessionId };
373
+ }
374
+ function tokenPurposeClaims(payload) {
375
+ if (payload.purpose === void 0) return {};
376
+ return payload.purpose === "turn-execution" ? { purpose: payload.purpose } : void 0;
377
+ }
378
+ function tokenScopeClaims(payload) {
379
+ const sessionId = stringClaim(payload, "session_id");
380
+ if (payload.scope === "workspace" && payload.session_id === void 0) return { scope: "workspace" };
381
+ if (payload.scope === "session" && sessionId) return { scope: "session", session_id: sessionId };
382
+ return void 0;
383
+ }
264
384
  function relayHostClaims(payload) {
265
385
  const base = runtimeClaims({
266
386
  ...payload,
267
387
  iss: runtimeAccessTokenIssuer,
268
388
  aud: runtimeAccessTokenAudience
269
389
  });
270
- const access = stringClaim(payload, "access");
390
+ if (payload.access !== void 0) return void 0;
271
391
  const backing = stringClaim(payload, "backing");
272
392
  const parent_jti = stringClaim(payload, "parent_jti");
273
- const pair = { access, backing };
274
- if (!base || !parent_jti || !isRelayClaimPair(pair)) return void 0;
393
+ if (!base || !parent_jti || !isRelayBacking(backing)) return void 0;
275
394
  return {
276
395
  ...base,
277
396
  iss: relayHostTokenIssuer,
278
397
  aud: relayHostTokenAudience,
279
398
  parent_jti,
280
- ...pair
399
+ backing
281
400
  };
282
401
  }
283
402
  function hostTunnelClaims(payload) {
@@ -302,11 +421,114 @@ function hostTunnelClaims(payload) {
302
421
  };
303
422
  }
304
423
 
424
+ // ../claxedo-helpers/dist/api-error.mjs
425
+ function isRecord2(value) {
426
+ return value !== null && typeof value === "object" && !Array.isArray(value);
427
+ }
428
+ function asRecord2(value) {
429
+ return isRecord2(value) ? value : void 0;
430
+ }
431
+ var PUBLIC_API_ERRORS = {
432
+ repository_provider_unauthorized: { status: 502, message: "Repository provider refused authorization" },
433
+ repository_provider_unavailable: { status: 502, message: "Repository provider is unavailable" },
434
+ host_tunnel_timeout: { status: 503, retryable: true, message: "Host tunnel timed out" },
435
+ host_tunnel_response_body_too_large: { status: 413, message: "Host tunnel response body exceeds the relay limit" },
436
+ host_tunnel_unavailable: { status: 503, retryable: true, message: "Host tunnel is unavailable" },
437
+ workspace_checkpoint_unavailable: { status: 503, retryable: true, message: "Workspace checkpoint is unavailable" },
438
+ workspace_checkpoint_conflict: { status: 409, message: "Workspace checkpoint conflicted" },
439
+ invalid_input: { family: "access", status: 400, message: "The request names an invalid value" },
440
+ organization_policy_denied: { family: "access", status: 403, message: "Organization creation is disabled for this deployment" },
441
+ org_admin_required: { family: "access", status: 403, message: "Organization administrator authority is required" },
442
+ org_owner_required: { family: "access", status: 403, message: "Only an organization owner may grant, change or remove the owner role" },
443
+ org_owner_protected: { family: "access", status: 409, message: "The organization's founding owner cannot be removed or demoted" },
444
+ team_member_org_membership_required: { family: "access", status: 403, message: "The team member must belong to the team organization" },
445
+ project_member_org_membership_required: { family: "access", status: 403, message: "The project member must belong to the project organization" },
446
+ org_membership_required: { family: "access", status: 403, message: "Organization membership is required" },
447
+ project_admin_required: { family: "access", status: 403, message: "Project administrator authority is required" },
448
+ team_not_allowed_on_personal_org: { family: "access", status: 400, message: "Personal organizations cannot contain teams" },
449
+ team_member_target_required: { family: "access", status: 400, message: "Exactly one team member target is required" },
450
+ org_invitation_invalid: { family: "access", status: 409, message: "Invitation is invalid or unavailable" },
451
+ org_invitation_pending: { family: "access", status: 409, message: "An invitation to this address is already pending" },
452
+ org_invitation_rate_limited: { family: "access", status: 429, message: "Organization invitation limit exceeded" },
453
+ org_invitation_email_mismatch: { family: "access", status: 403, message: "Sign in with the invited verified email address" },
454
+ org_invitation_delivery_unavailable: { family: "access", status: 503, message: "Invitation delivery is unavailable" },
455
+ team_not_found: { family: "access", status: 404, message: "Team not found" },
456
+ team_member_not_found: { family: "access", status: 404, message: "Team member not found" },
457
+ org_member_not_found: { family: "access", status: 404, message: "Organization member not found" },
458
+ project_not_found: { family: "access", status: 404, message: "Project not found" },
459
+ project_member_not_found: { family: "access", status: 404, message: "Project member not found" },
460
+ project_member_owner_immutable: { family: "access", status: 409, message: "The project owner's access cannot be changed" },
461
+ resource_conflict: { family: "access", status: 409, message: "Organization or team authority changed concurrently" },
462
+ session_not_found: { family: "session_share", status: 404, message: "This session is not on the control plane, so it cannot be shared from People yet." },
463
+ session_share_admin_required: { family: "session_share", status: 403, message: "Only the person who started this session can manage its People." },
464
+ session_share_target_required: { family: "session_share", status: 400, message: "A session is shared with exactly one person" },
465
+ session_share_target_not_found: { family: "session_share", status: 404, message: "That person was not found in this organization." },
466
+ session_share_level_invalid: { family: "session_share", status: 400, message: "A share level is either follow or send." },
467
+ workspace_backing_conflict: { status: 409, message: "Workspace is provisioned in the cloud and cannot be placed on a machine" },
468
+ invalid_session_list_cursor: { status: 400, message: "Session list cursor does not match this query" },
469
+ BAD_REQUEST: { status: 400, message: "Invalid request" },
470
+ INTERNAL_SERVER_ERROR: { status: 500, message: "Internal error" },
471
+ CONTROL_PLANE_UNAVAILABLE: { status: 503, message: "Control Plane services are not configured" },
472
+ credential_verification_unsupported: { status: 400, message: "Credential provider does not support verification" },
473
+ credential_shape_invalid: { status: 400, message: "Credential secret has an unsupported shape" },
474
+ credential_provider_unavailable: { status: 502, retryable: true, message: "Credential provider request failed" },
475
+ credential_verification_failed: { status: 502, message: "Credential provider verification failed" },
476
+ credential_redirect_denied: { status: 502, message: "Sandbox provider redirects are not allowed" },
477
+ credential_endpoint_invalid: { status: 400, message: "Invalid credential provider endpoint" },
478
+ host_attestation_denied: { status: 403, message: "host_attestation_denied" },
479
+ signature_replayed: { status: 409, message: "signature_replayed" },
480
+ host_enrollment_not_found: { status: 404, message: "host_enrollment_not_found" },
481
+ enrollment_revoked: { status: 403, message: "enrollment_revoked" },
482
+ enrollment_paused: { status: 403, message: "enrollment_paused" },
483
+ enrollment_owner_ineligible: { status: 403, message: "enrollment_owner_ineligible" },
484
+ enrollment_key_version_mismatch: { status: 403, message: "enrollment_key_version_mismatch" },
485
+ invitation_invalid: { status: 403, message: "invitation_invalid" },
486
+ invitation_expired: { status: 410, message: "invitation_expired" },
487
+ invitation_revoked: { status: 410, message: "invitation_revoked" },
488
+ invitation_redeemed: { status: 409, message: "invitation_redeemed" },
489
+ invitation_host_conflict: { status: 409, message: "invitation_host_conflict" },
490
+ enrollment_generation_superseded: { status: 409, message: "enrollment_generation_superseded" },
491
+ host_assignment_outside_scope: { status: 400, message: "host_assignment_outside_scope" },
492
+ host_sealing_key_undeclared: { status: 409, message: "host_sealing_key_undeclared" },
493
+ host_provider_config_revision_stale: { status: 409, message: "host_provider_config_revision_stale" },
494
+ identity_conflict: { status: 409, message: "identity_conflict" },
495
+ registration_transition_denied: { status: 403, message: "registration_transition_denied" },
496
+ session_reservation_spent: { status: 409, message: "This session's create was undone; reserve it again to retry" },
497
+ actor_authorization_denied: { status: 403, message: "actor_authorization_denied" }
498
+ };
499
+ function encodeApiError(error, defaults = {}) {
500
+ const row = asRecord2(error);
501
+ return {
502
+ error: {
503
+ code: typeof row?.code === "string" && row.code ? row.code : defaults.code ?? "internal_error",
504
+ message: typeof row?.message === "string" && row.message ? row.message : defaults.message ?? "Internal error",
505
+ retryable: row?.retryable === true
506
+ }
507
+ };
508
+ }
509
+ function publicApiErrorShape(code) {
510
+ const entry = PUBLIC_API_ERRORS[code];
511
+ return { status: entry.status, retryable: entry.retryable === true };
512
+ }
513
+ function publicApiFailure(code, message = PUBLIC_API_ERRORS[code].message) {
514
+ return Object.assign(new Error(message), { code, ...publicApiErrorShape(code) });
515
+ }
516
+
517
+ // src/host-tunnel-failure.ts
518
+ function hostTunnelFailure(code) {
519
+ return publicApiFailure(code);
520
+ }
521
+ function hostTunnelFailureResponse(error) {
522
+ const code = asRecord(error)?.code;
523
+ const failure = hostTunnelFailure(code === "host_tunnel_timeout" || code === "host_tunnel_response_body_too_large" ? code : "host_tunnel_unavailable");
524
+ return Response.json(encodeApiError(failure), { status: failure.status });
525
+ }
526
+
305
527
  // src/cloudflare.ts
306
- import { trimToUndefined } from "@claxedo/helpers/string";
528
+ import { trimToUndefined as trimToUndefined4 } from "@claxedo/helpers/string";
307
529
 
308
530
  // src/http.ts
309
- import { bearerToken } from "@claxedo/helpers/string";
531
+ import { bearerToken, trimToUndefined } from "@claxedo/helpers/string";
310
532
  function errorBody(code, message) {
311
533
  return {
312
534
  error: {
@@ -315,6 +537,15 @@ function errorBody(code, message) {
315
537
  }
316
538
  };
317
539
  }
540
+ function hostIdFromTunnelPath(pathname) {
541
+ return /^\/host-tunnels\/([^/]+)(?:\/|$)/.exec(pathname)?.[1];
542
+ }
543
+ function workspaceIdsFromSearch(url) {
544
+ return [...new Set([
545
+ ...url.searchParams.getAll("workspaceId"),
546
+ ...url.searchParams.getAll("workspace_id")
547
+ ].map(trimToUndefined).filter((item) => !!item))];
548
+ }
318
549
 
319
550
  // src/cloudflare.ts
320
551
  import {
@@ -324,191 +555,128 @@ import {
324
555
  } from "@claxedo/workspace-relay-protocol";
325
556
 
326
557
  // src/server.ts
327
- import { Hono } from "hono";
328
- import { cors } from "hono/cors";
329
- import { exportJWK as exportJWK2 } from "jose";
330
-
331
- // src/cors-origins.ts
332
- var DEFAULT_RELAY_APP_ORIGINS = [
333
- "http://localhost:*",
334
- "http://127.0.0.1:*",
335
- "https://claxedo.com",
336
- "https://*.claxedo.com"
337
- ];
338
- function parseAllowedOrigins(raw) {
339
- const entries = (raw ?? "").split(",").map((item) => item.trim()).filter(Boolean);
340
- return entries.length ? entries : void 0;
341
- }
342
- function createOriginMatcher(patterns) {
343
- const exact = /* @__PURE__ */ new Set();
344
- const httpsSuffixes = [];
345
- const httpPortPrefixes = [];
346
- for (const pattern of patterns) {
347
- if (pattern.startsWith("https://*.")) {
348
- httpsSuffixes.push(pattern.slice("https://*".length));
349
- } else if (pattern.startsWith("http://") && pattern.endsWith(":*")) {
350
- httpPortPrefixes.push(pattern.slice(0, -1));
351
- } else {
352
- exact.add(pattern);
353
- }
354
- }
355
- return (origin) => {
356
- if (exact.has(origin)) return true;
357
- if (origin.startsWith("https://")) {
358
- return httpsSuffixes.some(
359
- (suffix) => origin.endsWith(suffix) && origin.length > "https://".length + suffix.length
360
- );
361
- }
362
- if (origin.startsWith("http://")) {
363
- return httpPortPrefixes.some(
364
- (prefix) => origin.startsWith(prefix) && /^\d+$/.test(origin.slice(prefix.length))
365
- );
366
- }
367
- return false;
368
- };
369
- }
558
+ import { sessionScopeReaches } from "@claxedo/workspace-relay-protocol";
370
559
 
371
- // src/user-hosted-forwarding.ts
372
- function isUserHostedTarget(target) {
373
- return target.access === "user-hosted";
560
+ // src/host-tunnel-forwarding.ts
561
+ function isHostTunnelTarget(target) {
562
+ return target.backing === "local-worktree";
374
563
  }
375
564
 
376
- // src/server.ts
565
+ // src/relay-target.ts
377
566
  function parseWorkspaceRelayTarget(input) {
378
567
  if (!isRecord(input)) return void 0;
379
568
  const row = input;
380
- const { workspaceId, hostId, baseUrl, upstreamHeaders } = row;
569
+ const { workspaceId, hostId, baseUrl } = row;
381
570
  if (typeof workspaceId !== "string" || typeof hostId !== "string" || typeof baseUrl !== "string") return void 0;
382
- if (upstreamHeaders !== void 0 && !isStringRecord(upstreamHeaders)) return void 0;
383
- const pair = relayClaimPair(row.access, row.backing);
384
- if (!pair) return void 0;
571
+ if (row.access !== void 0) return void 0;
572
+ if (!isRelayBacking(row.backing)) return void 0;
573
+ if (!isAllowedRelayTargetBaseUrl({ baseUrl, backing: row.backing })) return void 0;
385
574
  return {
386
575
  workspaceId,
387
576
  hostId,
388
577
  baseUrl,
389
- ...upstreamHeaders ? { upstreamHeaders } : {},
390
- ...pair
578
+ backing: row.backing
391
579
  };
392
580
  }
393
- function parseRuntimeAccessTokenActiveResult(input) {
394
- if (!isRecord(input)) return void 0;
395
- const row = input;
396
- if (row.active === true) return { active: true };
397
- if (row.active !== false) return void 0;
398
- if (typeof row.code !== "string" || typeof row.reason !== "string") return void 0;
399
- return { active: false, code: row.code, reason: row.reason };
400
- }
401
- function parseHostGenerationResult(input) {
402
- if (!isRecord(input)) return void 0;
403
- const row = input;
404
- if (typeof row.enrollmentId !== "string" || !row.enrollmentId.trim()) return void 0;
405
- if (!isHostGeneration(row.generation) || typeof row.revoked !== "boolean") return void 0;
406
- return { enrollmentId: row.enrollmentId, generation: row.generation, revoked: row.revoked };
581
+ function parseAbsoluteHttpUrl(baseUrl) {
582
+ let url;
583
+ try {
584
+ url = new URL(baseUrl);
585
+ } catch {
586
+ return void 0;
587
+ }
588
+ return url.protocol === "http:" || url.protocol === "https:" ? url : void 0;
407
589
  }
408
- var HOST_GENERATION_LOOKUP_TIMEOUT_MS_DEFAULT = 5e3;
409
- var HOST_GENERATION_ENROLLMENT_NOT_FOUND_CODE = "relay_resolver_enrollment_not_found";
410
- function createHostGenerationResolverLookup(url, options) {
411
- const fetcher = options.fetch ?? ((target, init) => fetch(target, init));
412
- const timeoutMs = options.timeoutMs ?? HOST_GENERATION_LOOKUP_TIMEOUT_MS_DEFAULT;
413
- return async ({ enrollmentId }) => {
414
- const target = new URL(url);
415
- target.searchParams.set("enrollmentId", enrollmentId);
416
- const res = await fetcher(target, { headers: options.headers, signal: AbortSignal.timeout(timeoutMs) });
417
- if (res.status === 404) {
418
- const body = await res.json().catch(() => void 0);
419
- const error = isRecord(body) && isRecord(body.error) ? body.error : void 0;
420
- if (error?.code === HOST_GENERATION_ENROLLMENT_NOT_FOUND_CODE) return void 0;
421
- throw new Error(`relay host-generation resolver failed: 404 (no host-generation route at ${url})`);
422
- }
423
- if (!res.ok) throw new Error(`relay host-generation resolver failed: ${res.status} ${await res.text()}`);
424
- const result = parseHostGenerationResult(await res.json());
425
- if (!result) throw new Error("relay host-generation resolver returned a malformed result");
426
- return result;
427
- };
590
+ function isLoopbackTargetHostname(hostname) {
591
+ const host = hostname.toLowerCase();
592
+ if (host === "localhost" || host === "[::1]" || host.endsWith(".localhost")) return true;
593
+ const ipv4 = /^127\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(host);
594
+ return !!ipv4 && ipv4.slice(1).every((octet) => Number(octet) <= 255);
428
595
  }
429
- function hostTunnelIncumbentOutranks(incumbentGeneration, candidateGeneration) {
430
- if (incumbentGeneration === void 0) return false;
431
- return candidateGeneration === void 0 || incumbentGeneration > candidateGeneration;
596
+ function isAllowedRelayTargetBaseUrl(target) {
597
+ if (target.backing === "durable-object") return target.baseUrl === "";
598
+ if (isHostTunnelTarget(target)) {
599
+ if (target.baseUrl === "") return true;
600
+ return parseAbsoluteHttpUrl(target.baseUrl) !== void 0;
601
+ }
602
+ const url = parseAbsoluteHttpUrl(target.baseUrl);
603
+ if (!url) return false;
604
+ return url.protocol === "https:" || isLoopbackTargetHostname(url.hostname);
432
605
  }
433
- function relayClaimPair(access, backing) {
434
- if (access === "cloud" && backing === "cloud-vm") return { access, backing };
435
- if (access === "user-hosted" && backing === "local-worktree") return { access, backing };
436
- return void 0;
606
+
607
+ // src/relay-audit.ts
608
+ var auditSamplers = /* @__PURE__ */ new WeakMap();
609
+ function getOrCreateAuditSampler(options) {
610
+ let state = auditSamplers.get(options);
611
+ if (!state) {
612
+ const sampleRate = clampSampleRate(options.auditAcceptSampleRate);
613
+ state = {
614
+ suppressedCount: 0,
615
+ timer: void 0,
616
+ flushIntervalMs: options.auditFlushIntervalMs ?? 6e4,
617
+ random: options.random ?? Math.random,
618
+ sampleRate
619
+ };
620
+ auditSamplers.set(options, state);
621
+ }
622
+ return state;
437
623
  }
438
- function isStringRecord(input) {
439
- return isRecord(input) && Object.values(input).every((value) => typeof value === "string");
624
+ function clampSampleRate(rate) {
625
+ if (rate === void 0) return 1;
626
+ if (!Number.isFinite(rate)) return 1;
627
+ if (rate < 0) return 0;
628
+ if (rate > 1) return 1;
629
+ return rate;
440
630
  }
441
- var RELAY_HOST_TOKEN_CACHE_MAX_ENTRIES = 4096;
442
- var RUNTIME_ACCESS_TOKEN_CACHE_MAX_ENTRIES = 8192;
443
- var RESOLVER_CACHE_MAX_ENTRIES = 8192;
444
- var RUNTIME_ACCESS_TOKEN_CACHE_TTL_MS_DEFAULT = 1e4;
445
- var relayHostTokenCaches = /* @__PURE__ */ new WeakMap();
446
- var runtimeAccessTokenCaches = /* @__PURE__ */ new WeakMap();
447
- function pruneExpiringCache(cache, now, maxEntries) {
448
- for (const [key, entry] of cache) {
449
- if (entry.expiresAt <= now) cache.delete(key);
450
- }
451
- while (cache.size >= maxEntries) {
452
- const key = cache.keys().next().value;
453
- if (!key) return;
454
- cache.delete(key);
631
+ function ensureFlushTimer(options, state) {
632
+ if (state.timer) return;
633
+ if (state.sampleRate >= 1) return;
634
+ if (state.flushIntervalMs <= 0) return;
635
+ const timer = setInterval(() => {
636
+ flushSuppressedSummary(options, state);
637
+ }, state.flushIntervalMs);
638
+ if (typeof timer.unref === "function") {
639
+ timer.unref();
455
640
  }
641
+ state.timer = timer;
456
642
  }
457
- function createCachedRevocationClient(inner, options = {}) {
458
- const ttlMs = options.ttlMs ?? 1e4;
459
- const now = options.now ?? Date.now;
460
- const cache = /* @__PURE__ */ new Map();
461
- return async (args) => {
462
- const at = now();
463
- const entry = cache.get(args.jti);
464
- if (entry && entry.expiresAt > at) {
465
- if (entry.result) return entry.result;
466
- if (entry.promise) return await entry.promise;
467
- }
468
- const promise = inner(args).then((result) => {
469
- pruneExpiringCache(cache, now(), RESOLVER_CACHE_MAX_ENTRIES);
470
- cache.set(args.jti, { result, expiresAt: now() + ttlMs });
471
- return result;
472
- }).catch((err) => {
473
- cache.delete(args.jti);
474
- throw err;
475
- });
476
- cache.set(args.jti, { promise, expiresAt: at + ttlMs });
477
- return await promise;
478
- };
643
+ function flushSuppressedSummary(options, state) {
644
+ const count = state.suppressedCount;
645
+ if (count <= 0) return;
646
+ state.suppressedCount = 0;
647
+ void options.audit?.({
648
+ action: "relay.request.suppressed_summary",
649
+ result: "allow",
650
+ method: "INTERNAL",
651
+ path: "/",
652
+ suppressedCount: count
653
+ });
479
654
  }
480
- function createCachedHostGenerationClient(inner, options = {}) {
481
- const ttlMs = options.ttlMs ?? 1e4;
482
- const now = options.now ?? Date.now;
483
- const cache = /* @__PURE__ */ new Map();
484
- return async (args) => {
485
- for (; ; ) {
486
- const at = now();
487
- const entry = cache.get(args.enrollmentId);
488
- if (entry && entry.expiresAt > at) {
489
- if (entry.result && entry.result.generation >= args.generation) return entry.result;
490
- if (!entry.result && entry.promise) {
491
- const shared = await entry.promise;
492
- if (!shared || shared.generation >= args.generation) return shared;
493
- continue;
494
- }
655
+ async function audit(options, event) {
656
+ if (event.action === "relay.request.accepted") {
657
+ const state = getOrCreateAuditSampler(options);
658
+ if (state.sampleRate < 1) {
659
+ ensureFlushTimer(options, state);
660
+ if (state.random() >= state.sampleRate) {
661
+ state.suppressedCount += 1;
662
+ return;
495
663
  }
496
- const promise = inner(args).then((result) => {
497
- pruneExpiringCache(cache, now(), RESOLVER_CACHE_MAX_ENTRIES);
498
- if (result) cache.set(args.enrollmentId, { result, expiresAt: now() + ttlMs });
499
- else cache.delete(args.enrollmentId);
500
- return result;
501
- }).catch((err) => {
502
- cache.delete(args.enrollmentId);
503
- throw err;
504
- });
505
- cache.set(args.enrollmentId, { promise, expiresAt: at + ttlMs });
506
- return await promise;
507
664
  }
508
- };
665
+ }
666
+ await options.audit?.(event);
667
+ }
668
+
669
+ // src/server.ts
670
+ function hostTunnelIncumbentOutranks(incumbentGeneration, candidateGeneration) {
671
+ return incumbentGeneration > candidateGeneration;
509
672
  }
673
+ var RELAY_HOST_TOKEN_CACHE_MAX_ENTRIES = 4096;
674
+ var RUNTIME_ACCESS_TOKEN_CACHE_MAX_ENTRIES = 8192;
675
+ var RUNTIME_ACCESS_TOKEN_CACHE_TTL_MS_DEFAULT = 1e4;
676
+ var RUNTIME_ACCESS_TOKEN_ACTIVE_CHECK_INTERVAL_MS_DEFAULT = 3e4;
677
+ var relayHostTokenCaches = /* @__PURE__ */ new WeakMap();
678
+ var runtimeAccessTokenCaches = /* @__PURE__ */ new WeakMap();
510
679
  async function checkHostTunnelGeneration(lookup, claims) {
511
- if (claims.generation === void 0 || !claims.enrollment_id) return { ok: true };
512
680
  if (!lookup) {
513
681
  return {
514
682
  ok: false,
@@ -542,31 +710,6 @@ async function checkHostTunnelGeneration(lookup, claims) {
542
710
  }
543
711
  return { ok: true };
544
712
  }
545
- function createCachedTargetClient(inner, options = {}) {
546
- const ttlMs = options.ttlMs ?? 5e3;
547
- const now = options.now ?? Date.now;
548
- const cache = /* @__PURE__ */ new Map();
549
- return async (args) => {
550
- const key = `${args.workspaceId}\0${args.hostId}`;
551
- const at = now();
552
- const entry = cache.get(key);
553
- if (entry && entry.expiresAt > at) {
554
- if (entry.result) return entry.result;
555
- if (entry.promise) return await entry.promise;
556
- }
557
- const promise = inner(args).then((result) => {
558
- pruneExpiringCache(cache, now(), RESOLVER_CACHE_MAX_ENTRIES);
559
- if (result) cache.set(key, { result, expiresAt: now() + ttlMs });
560
- else cache.delete(key);
561
- return result;
562
- }).catch((err) => {
563
- cache.delete(key);
564
- throw err;
565
- });
566
- cache.set(key, { promise, expiresAt: at + ttlMs });
567
- return await promise;
568
- };
569
- }
570
713
  function protocolToken(header) {
571
714
  return header?.split(",").map((item) => item.trim()).find((item) => item.startsWith("claxedo-rat."))?.slice("claxedo-rat.".length);
572
715
  }
@@ -610,27 +753,23 @@ function forwardHeaders(input, relayHostToken, workspaceId, options = {}) {
610
753
  for (const name of inboundNames) {
611
754
  if (isDangerousInboundHeader(name)) headers.delete(name);
612
755
  }
613
- if (options.userHosted) headers.delete("cookie");
756
+ if (options.hostTunnel) headers.delete("cookie");
614
757
  headers.set("accept-encoding", "identity");
615
758
  headers.delete("authorization");
616
759
  headers.delete("Authorization");
617
760
  headers.set("Authorization", `Bearer ${relayHostToken}`);
618
761
  headers.set("x-workspace-id", workspaceId);
619
- headers.set("X-Daytona-Skip-Preview-Warning", "true");
620
- headers.set("X-Daytona-Skip-Last-Activity-Update", "true");
621
- for (const [name, value] of Object.entries(options.upstreamHeaders ?? {})) {
622
- const trimmed = value.trim();
623
- if (trimmed) headers.set(name, trimmed);
624
- }
625
762
  headers.set("x-forwarded-by", "workspace-relay");
626
763
  return headers;
627
764
  }
628
765
  function targetUrl(target, path, search) {
629
- const url = new URL(path.replace(/^\/+/, ""), target.baseUrl.endsWith("/") ? target.baseUrl : `${target.baseUrl}/`);
766
+ const base = new URL(target.baseUrl.endsWith("/") ? target.baseUrl : `${target.baseUrl}/`);
767
+ const url = new URL(base);
768
+ url.pathname = `${base.pathname}${path.replace(/^\/+/, "")}`;
630
769
  url.search = search;
770
+ url.hash = "";
631
771
  return url;
632
772
  }
633
- var RELAY_VIEWER_DENIED_PATH = /^\/api\/wr\/pty(?:\/|$)/;
634
773
  function relayRuntimePath(request) {
635
774
  try {
636
775
  const decoded = decodeURIComponent(new URL(request.url).pathname.replace(/^\/workspaces\/[^/]+\/?/, "/")).replace(/^\/+/, "/");
@@ -639,14 +778,6 @@ function relayRuntimePath(request) {
639
778
  return void 0;
640
779
  }
641
780
  }
642
- function roleAllowsRelayRequest(role, method, path) {
643
- if (role === "owner" || role === "admin" || role === "editor") return true;
644
- if (role === "viewer") {
645
- if (RELAY_VIEWER_DENIED_PATH.test(path)) return false;
646
- return method === "GET" || method === "HEAD" || method === "OPTIONS";
647
- }
648
- return false;
649
- }
650
781
  function relayHostTokenCacheTtlMs(options, claims) {
651
782
  const configured = options.relayHostTokenCacheTtlMs ?? 3e4;
652
783
  if (!Number.isFinite(configured) || configured <= 0) return 0;
@@ -663,7 +794,6 @@ function relayHostTokenCacheKey(options, claims, target) {
663
794
  claims.role,
664
795
  claims.workspace_id,
665
796
  claims.host_id,
666
- target.access,
667
797
  target.backing,
668
798
  options.relayHostMintKid ?? ""
669
799
  ].join("\0");
@@ -742,6 +872,13 @@ function pruneRelayHostTokenCache(cache, now) {
742
872
  cache.delete(key);
743
873
  }
744
874
  }
875
+ function relayHostMintInput(options, claims, target) {
876
+ return {
877
+ ...relayHostInputFromRuntimeClaims(claims),
878
+ ...target,
879
+ ...options.relayHostMintKid ? { kid: options.relayHostMintKid } : {}
880
+ };
881
+ }
745
882
  async function relayHostTokenFor(options, claims, target, trace) {
746
883
  const ttlMs = relayHostTokenCacheTtlMs(options, claims);
747
884
  if (ttlMs <= 0) {
@@ -762,21 +899,11 @@ async function relayHostTokenFor(options, claims, target, trace) {
762
899
  let promise;
763
900
  try {
764
901
  const token = await trace.span("rht-mint", async () => {
765
- promise = mintRelayHostToken({
766
- principalKind: claims.principal_kind,
767
- actorId: claims.actor_id,
768
- actorKind: claims.actor_kind,
769
- parentJti: claims.jti,
770
- ...claims.actor_public_id && claims.actor_name ? {
771
- actorPublicId: claims.actor_public_id,
772
- actorName: claims.actor_name,
773
- ...claims.actor_avatar_url ? { actorAvatarUrl: claims.actor_avatar_url } : {}
774
- } : {},
775
- orgId: claims.org_id,
776
- role: claims.role,
777
- ...target,
778
- ...options.relayHostMintKid ? { kid: options.relayHostMintKid } : {}
779
- }, options.relayHostSigningKey, options.relayHostAlgorithm);
902
+ promise = mintRelayHostToken(
903
+ relayHostMintInput(options, claims, target),
904
+ options.relayHostSigningKey,
905
+ options.relayHostAlgorithm
906
+ );
780
907
  pruneRelayHostTokenCache(cache, now);
781
908
  cache.set(key, { promise, expiresAt: now + ttlMs });
782
909
  return await promise;
@@ -794,21 +921,11 @@ async function relayHostTokenFor(options, claims, target, trace) {
794
921
  async function uncachedRelayHostTokenFor(options, claims, target, trace) {
795
922
  return await trace.span(
796
923
  "rht-mint",
797
- async () => await mintRelayHostToken({
798
- principalKind: claims.principal_kind,
799
- actorId: claims.actor_id,
800
- actorKind: claims.actor_kind,
801
- parentJti: claims.jti,
802
- ...claims.actor_public_id && claims.actor_name ? {
803
- actorPublicId: claims.actor_public_id,
804
- actorName: claims.actor_name,
805
- ...claims.actor_avatar_url ? { actorAvatarUrl: claims.actor_avatar_url } : {}
806
- } : {},
807
- orgId: claims.org_id,
808
- role: claims.role,
809
- ...target,
810
- ...options.relayHostMintKid ? { kid: options.relayHostMintKid } : {}
811
- }, options.relayHostSigningKey, options.relayHostAlgorithm)
924
+ async () => await mintRelayHostToken(
925
+ relayHostMintInput(options, claims, target),
926
+ options.relayHostSigningKey,
927
+ options.relayHostAlgorithm
928
+ )
812
929
  );
813
930
  }
814
931
  function workspaceRelayTargetUrl(target, path, search) {
@@ -829,146 +946,6 @@ function workspaceRelayForwardRequestInit(request, relayHostToken, workspaceId,
829
946
  if (request.method === "GET" || request.method === "HEAD") delete init.body;
830
947
  return init;
831
948
  }
832
- var auditSamplers = /* @__PURE__ */ new WeakMap();
833
- function getOrCreateAuditSampler(options) {
834
- let state = auditSamplers.get(options);
835
- if (!state) {
836
- const sampleRate = clampSampleRate(options.auditAcceptSampleRate);
837
- state = {
838
- suppressedCount: 0,
839
- timer: void 0,
840
- flushIntervalMs: options.auditFlushIntervalMs ?? 6e4,
841
- random: options.random ?? Math.random,
842
- sampleRate
843
- };
844
- auditSamplers.set(options, state);
845
- }
846
- return state;
847
- }
848
- function clampSampleRate(rate) {
849
- if (rate === void 0) return 1;
850
- if (!Number.isFinite(rate)) return 1;
851
- if (rate < 0) return 0;
852
- if (rate > 1) return 1;
853
- return rate;
854
- }
855
- function ensureFlushTimer(options, state) {
856
- if (state.timer) return;
857
- if (state.sampleRate >= 1) return;
858
- if (state.flushIntervalMs <= 0) return;
859
- const timer = setInterval(() => {
860
- flushSuppressedSummary(options, state);
861
- }, state.flushIntervalMs);
862
- if (typeof timer.unref === "function") {
863
- timer.unref();
864
- }
865
- state.timer = timer;
866
- }
867
- function flushSuppressedSummary(options, state) {
868
- const count = state.suppressedCount;
869
- if (count <= 0) return;
870
- state.suppressedCount = 0;
871
- void options.audit?.({
872
- action: "relay.request.suppressed_summary",
873
- result: "allow",
874
- method: "INTERNAL",
875
- path: "/",
876
- suppressedCount: count
877
- });
878
- }
879
- function disposeAuditSampler(options) {
880
- const state = auditSamplers.get(options);
881
- if (!state) return;
882
- if (state.timer) {
883
- clearInterval(state.timer);
884
- state.timer = void 0;
885
- }
886
- flushSuppressedSummary(options, state);
887
- auditSamplers.delete(options);
888
- }
889
- function createWorkspaceRelayTrace() {
890
- const phases = [];
891
- return {
892
- phases,
893
- async span(name, run) {
894
- const startedAt = performance.now();
895
- try {
896
- return await run();
897
- } finally {
898
- phases.push({ name, ms: performance.now() - startedAt });
899
- }
900
- }
901
- };
902
- }
903
- function roundedMs(value) {
904
- return Math.round(value * 100) / 100;
905
- }
906
- function workspaceRelayServerTiming(trace) {
907
- return trace.phases.map((phase) => `${phase.name};dur=${roundedMs(phase.ms)}`).join(", ");
908
- }
909
- function workspaceRelayTimingResponse(response, trace) {
910
- const value = workspaceRelayServerTiming(trace);
911
- if (!value) return response;
912
- const headers = new Headers(response.headers);
913
- const prev = headers.get("server-timing");
914
- headers.set("server-timing", prev ? `${prev}, ${value}` : value);
915
- return new Response(response.body, {
916
- status: response.status,
917
- statusText: response.statusText,
918
- headers
919
- });
920
- }
921
- async function audit(options, event) {
922
- if (event.action === "relay.request.accepted") {
923
- const state = getOrCreateAuditSampler(options);
924
- if (state.sampleRate < 1) {
925
- ensureFlushTimer(options, state);
926
- if (state.random() >= state.sampleRate) {
927
- state.suppressedCount += 1;
928
- return;
929
- }
930
- }
931
- }
932
- await options.audit?.(event);
933
- }
934
- var defaultOriginMatcher = createOriginMatcher(DEFAULT_RELAY_APP_ORIGINS);
935
- var originMatchers = /* @__PURE__ */ new WeakMap();
936
- function originMatcherFor(options) {
937
- if (!options.allowedOrigins) return defaultOriginMatcher;
938
- let matcher = originMatchers.get(options);
939
- if (!matcher) {
940
- matcher = createOriginMatcher(options.allowedOrigins);
941
- originMatchers.set(options, matcher);
942
- }
943
- return matcher;
944
- }
945
- var RELAY_ALLOWED_REQUEST_HEADER_LIST = [
946
- "Accept",
947
- "Authorization",
948
- "Content-Type",
949
- "Last-Event-ID",
950
- "Traceparent",
951
- "Tracestate",
952
- "X-Fetch-Bypass-Throttle",
953
- "X-Daytona-Skip-Preview-Warning",
954
- "X-Workspace-Id",
955
- "X-OpenCode-Directory",
956
- "X-Claxedo-Runner",
957
- "X-Claxedo-Model",
958
- "X-Claxedo-Draft-Id",
959
- "X-Claxedo-Binary"
960
- ];
961
- var RELAY_ALLOWED_REQUEST_HEADERS = RELAY_ALLOWED_REQUEST_HEADER_LIST.join(", ");
962
- function denyCorsHeaders(request, originAllowed = defaultOriginMatcher) {
963
- const origin = request.headers.get("origin");
964
- if (!origin) return void 0;
965
- if (!originAllowed(origin)) return void 0;
966
- return {
967
- "access-control-allow-origin": origin,
968
- "access-control-allow-headers": RELAY_ALLOWED_REQUEST_HEADERS,
969
- "access-control-allow-methods": "GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS"
970
- };
971
- }
972
949
  async function deny(options, input) {
973
950
  const url = new URL(input.request.url);
974
951
  await audit(options, {
@@ -984,10 +961,7 @@ async function deny(options, input) {
984
961
  method: input.request.method,
985
962
  path: url.pathname
986
963
  });
987
- return Response.json(errorBody(input.code, input.message), {
988
- status: input.status,
989
- headers: denyCorsHeaders(input.request, originMatcherFor(options))
990
- });
964
+ return Response.json(errorBody(input.code, input.message), { status: input.status });
991
965
  }
992
966
  async function authorizeWorkspaceRelayRequest(options, request, workspaceId, trace) {
993
967
  const token = runtimeAccessToken(request);
@@ -1009,6 +983,8 @@ async function authorizeWorkspaceRelayRequest(options, request, workspaceId, tra
1009
983
  "rat-verify",
1010
984
  async () => await cachedRuntimeAccessTokenClaims(options, token, workspaceId)
1011
985
  );
986
+ const targetLookup = span("target-resolve", async () => await options.resolveTarget(claims));
987
+ targetLookup.catch(() => void 0);
1012
988
  const active = await span("rat-active", async () => {
1013
989
  return await options.isRuntimeAccessTokenActive?.(claims) ?? { active: true };
1014
990
  });
@@ -1026,21 +1002,21 @@ async function authorizeWorkspaceRelayRequest(options, request, workspaceId, tra
1026
1002
  };
1027
1003
  }
1028
1004
  const path = relayRuntimePath(request);
1029
- if (!path || !roleAllowsRelayRequest(claims.role, request.method, path)) {
1005
+ if (!path || !sessionScopeReaches(claims.session_id, path, new URL(request.url).search)) {
1030
1006
  return {
1031
1007
  ok: false,
1032
- code: "relay_role_denied",
1008
+ code: "relay_scope_denied",
1033
1009
  response: await deny(options, {
1034
- code: "relay_role_denied",
1035
- message: "Workspace role does not allow this relay request",
1010
+ code: "relay_scope_denied",
1011
+ message: "Runtime Access Token scope does not reach this relay request",
1036
1012
  status: 403,
1037
1013
  claims,
1038
1014
  request
1039
1015
  })
1040
1016
  };
1041
1017
  }
1042
- const target = await span("target-resolve", async () => await options.resolveTarget(claims));
1043
- if (!target || target.workspaceId !== claims.workspace_id || target.hostId !== claims.host_id) {
1018
+ const target = await targetLookup;
1019
+ if (!target || target.workspaceId !== claims.workspace_id || target.hostId !== claims.host_id || !isAllowedRelayTargetBaseUrl(target)) {
1044
1020
  return {
1045
1021
  ok: false,
1046
1022
  code: "relay_target_unavailable",
@@ -1053,13 +1029,13 @@ async function authorizeWorkspaceRelayRequest(options, request, workspaceId, tra
1053
1029
  })
1054
1030
  };
1055
1031
  }
1056
- if (isUserHostedTarget(target) && !options.directory?.activeHost({ hostId: target.hostId, workspaceId: target.workspaceId })) {
1032
+ if (isHostTunnelTarget(target) && !options.directory?.activeHost({ hostId: target.hostId, workspaceId: target.workspaceId })) {
1057
1033
  return {
1058
1034
  ok: false,
1059
- code: "user_hosted_app_offline",
1035
+ code: "host_tunnel_offline",
1060
1036
  response: await deny(options, {
1061
- code: "user_hosted_app_offline",
1062
- message: "User-hosted workspace is offline",
1037
+ code: "host_tunnel_offline",
1038
+ message: "The machine serving this workspace is offline",
1063
1039
  status: 503,
1064
1040
  claims,
1065
1041
  request
@@ -1102,220 +1078,6 @@ async function authorizeWorkspaceRelayRequest(options, request, workspaceId, tra
1102
1078
  };
1103
1079
  }
1104
1080
  }
1105
- async function forwardWorkspaceRelayRequest(request, target, relayHostToken, path, requestFetch, timeoutMs, trace, originAllowed = defaultOriginMatcher) {
1106
- const controller = new AbortController();
1107
- const timer = setTimeout(() => controller.abort(), timeoutMs);
1108
- const span = (name, run) => trace ? trace.span(name, run) : run();
1109
- try {
1110
- const upstream = await span("upstream-fetch", async () => await requestFetch(
1111
- targetUrl(target, path, new URL(request.url).search),
1112
- workspaceRelayForwardRequestInit(request, relayHostToken, target.workspaceId, {
1113
- signal: controller.signal,
1114
- upstreamHeaders: target.upstreamHeaders
1115
- })
1116
- ));
1117
- const headers = new Headers(upstream.headers);
1118
- headers.delete("access-control-allow-origin");
1119
- headers.delete("access-control-allow-credentials");
1120
- headers.delete("access-control-allow-headers");
1121
- headers.delete("access-control-allow-methods");
1122
- headers.delete("access-control-expose-headers");
1123
- headers.delete("access-control-max-age");
1124
- for (const [key, value] of Object.entries(denyCorsHeaders(request, originAllowed) ?? {})) {
1125
- headers.set(key, value);
1126
- }
1127
- return new Response(upstream.body, {
1128
- status: upstream.status,
1129
- statusText: upstream.statusText,
1130
- headers
1131
- });
1132
- } catch (err) {
1133
- const aborted = err instanceof Error && err.name === "AbortError";
1134
- return Response.json(
1135
- errorBody(
1136
- aborted ? "upstream_timeout" : "upstream_unavailable",
1137
- aborted ? "Workspace upstream timed out" : "Workspace upstream is unavailable"
1138
- ),
1139
- { status: aborted ? 504 : 503, headers: denyCorsHeaders(request, originAllowed) }
1140
- );
1141
- } finally {
1142
- clearTimeout(timer);
1143
- }
1144
- }
1145
- function getAuditSuppressedCount(options) {
1146
- return auditSamplers.get(options)?.suppressedCount ?? 0;
1147
- }
1148
- var LOOPBACK_REMOTES = /* @__PURE__ */ new Set([
1149
- "127.0.0.1",
1150
- "::1",
1151
- "::ffff:127.0.0.1"
1152
- ]);
1153
- function isLoopbackAddress(address) {
1154
- if (!address) return false;
1155
- const trimmed = address.trim();
1156
- if (LOOPBACK_REMOTES.has(trimmed)) return true;
1157
- if (trimmed.startsWith("127.")) return true;
1158
- if (/^::ffff:127\./i.test(trimmed)) return true;
1159
- return false;
1160
- }
1161
- function checkMetricsAuth(options, request) {
1162
- if (options.metricsToken) {
1163
- const presented = bearerToken(request.headers.get("authorization") ?? void 0);
1164
- if (!presented || presented !== options.metricsToken) {
1165
- return {
1166
- ok: false,
1167
- status: 401,
1168
- code: "metrics_unauthorized",
1169
- message: "Metrics endpoint requires a valid bearer token"
1170
- };
1171
- }
1172
- return { ok: true };
1173
- }
1174
- if (!options.metricsRemoteAddress) {
1175
- return {
1176
- ok: false,
1177
- status: 401,
1178
- code: "metrics_unauthorized",
1179
- message: "Metrics endpoint is restricted to loopback or bearer-token clients"
1180
- };
1181
- }
1182
- const address = options.metricsRemoteAddress(request);
1183
- if (isLoopbackAddress(address)) return { ok: true };
1184
- return {
1185
- ok: false,
1186
- status: 401,
1187
- code: "metrics_unauthorized",
1188
- message: "Metrics endpoint is restricted to loopback or bearer-token clients"
1189
- };
1190
- }
1191
- function buildMetricsBody(options) {
1192
- const sources = options.metricsSources ?? {};
1193
- const fragmentation = sources.fragmentation?.() ?? { fragmentsBuffered: 0, oversizedClosed: 0 };
1194
- const slowConsumer = sources.slowConsumer?.() ?? {
1195
- overflowEvents: 0,
1196
- timerFired: 0,
1197
- droppedRequests: 0
1198
- };
1199
- const activeHostCount = options.directory?.size?.() ?? 0;
1200
- const suppressedAccepts = getAuditSuppressedCount(options);
1201
- const body = {
1202
- fragmentation,
1203
- slowConsumer,
1204
- directory: { activeHostCount },
1205
- audit: { suppressedAccepts }
1206
- };
1207
- if (sources.drainPending) {
1208
- body.drain = { pendingCount: sources.drainPending() };
1209
- }
1210
- return body;
1211
- }
1212
- function createWorkspaceRelay(options) {
1213
- const app = Object.assign(new Hono(), {
1214
- disposeAuditSampler: () => disposeAuditSampler(options)
1215
- });
1216
- const originAllowed = originMatcherFor(options);
1217
- app.use("*", async (c, next) => {
1218
- await next();
1219
- const origin = c.res.headers.get("access-control-allow-origin");
1220
- if (origin && origin !== "*" && !c.res.headers.has("timing-allow-origin")) {
1221
- c.res.headers.set("timing-allow-origin", origin);
1222
- }
1223
- });
1224
- app.use("*", cors({
1225
- origin: (origin) => {
1226
- if (!origin) return void 0;
1227
- return originAllowed(origin) ? origin : void 0;
1228
- },
1229
- // Spread from the shared list, not hand-maintained here: a duplicate
1230
- // array drifts out of sync and silently drops headers like
1231
- // `Last-Event-ID` or `X-Fetch-Bypass-Throttle`.
1232
- allowHeaders: [...RELAY_ALLOWED_REQUEST_HEADER_LIST],
1233
- allowMethods: ["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"]
1234
- }));
1235
- app.get("/health", (c) => {
1236
- if (options.isDraining?.()) {
1237
- return c.json(
1238
- { ok: false, service: "workspace-relay", draining: true },
1239
- 503
1240
- );
1241
- }
1242
- return c.json({ ok: true, service: "workspace-relay" });
1243
- });
1244
- app.get("/.well-known/jwks.json", async (c) => {
1245
- const sources = options.relayHostPublicKeys ?? [];
1246
- if (sources.length === 0) {
1247
- return c.json(
1248
- {
1249
- error: {
1250
- code: "jwks_no_keys_configured",
1251
- message: "No Relay Host Token public keys are configured for the JWKS endpoint"
1252
- }
1253
- },
1254
- 503
1255
- );
1256
- }
1257
- let keys;
1258
- try {
1259
- keys = await Promise.all(sources.map(async (source) => {
1260
- const jwk = await exportJWK2(source.publicKey);
1261
- return {
1262
- ...jwk,
1263
- kid: source.kid,
1264
- alg: "EdDSA",
1265
- use: "sig"
1266
- };
1267
- }));
1268
- } catch (err) {
1269
- return c.json(
1270
- {
1271
- error: {
1272
- code: "jwks_invalid_key",
1273
- message: "A configured RHT public key could not be exported as a JWK",
1274
- detail: err instanceof Error ? err.message : String(err)
1275
- }
1276
- },
1277
- 500
1278
- );
1279
- }
1280
- c.header("cache-control", "public, max-age=300");
1281
- return c.json({ keys });
1282
- });
1283
- app.get("/metrics", (c) => {
1284
- const authResult = checkMetricsAuth(options, c.req.raw);
1285
- if (!authResult.ok) {
1286
- return c.json(
1287
- errorBody(authResult.code, authResult.message),
1288
- authResult.status
1289
- );
1290
- }
1291
- return c.json(buildMetricsBody(options));
1292
- });
1293
- app.all("/workspaces/:workspaceId/*", async (c) => {
1294
- const trace = createWorkspaceRelayTrace();
1295
- if (options.isDraining?.()) {
1296
- return Response.json(
1297
- errorBody("relay_draining", "Workspace relay is shutting down; try another instance"),
1298
- { status: 503, headers: denyCorsHeaders(c.req.raw, originAllowed) }
1299
- );
1300
- }
1301
- const response = await trace.span("relay-total", async () => {
1302
- const relay = await authorizeWorkspaceRelayRequest(options, c.req.raw, c.req.param("workspaceId"), trace);
1303
- if (!relay.ok) return relay.response;
1304
- return await forwardWorkspaceRelayRequest(
1305
- new Request(c.req.raw),
1306
- relay.request.target,
1307
- relay.request.relayHostToken,
1308
- relay.request.path,
1309
- options.fetch ?? fetch,
1310
- options.forwardTimeoutMs ?? 3e4,
1311
- trace,
1312
- originAllowed
1313
- );
1314
- });
1315
- return workspaceRelayTimingResponse(response, trace);
1316
- });
1317
- return app;
1318
- }
1319
1081
 
1320
1082
  // src/directory.ts
1321
1083
  function presenceKey(hostId, workspaceId) {
@@ -1324,7 +1086,6 @@ function presenceKey(hostId, workspaceId) {
1324
1086
  function createWorkspaceRelayDirectory(options = {}) {
1325
1087
  const ttlMs = Math.max(1, options.ttlMs ?? 45e3);
1326
1088
  const now = options.now ?? Date.now;
1327
- const sweepIntervalMs = options.sweepIntervalMs ?? 3e4;
1328
1089
  const entries = /* @__PURE__ */ new Map();
1329
1090
  const alive = (key) => {
1330
1091
  const presence = entries.get(key);
@@ -1335,22 +1096,7 @@ function createWorkspaceRelayDirectory(options = {}) {
1335
1096
  }
1336
1097
  return presence;
1337
1098
  };
1338
- const keysOf = (hostId, workspaceIds) => {
1339
- if (workspaceIds) return [...new Set(workspaceIds)].map((workspaceId) => presenceKey(hostId, workspaceId));
1340
- return [...entries].filter(([, presence]) => presence.hostId === hostId).map(([key]) => key);
1341
- };
1342
- const sweep = () => {
1343
- const at = now();
1344
- for (const [key, presence] of entries) {
1345
- if (presence.expiresAt <= at) {
1346
- entries.delete(key);
1347
- }
1348
- }
1349
- };
1350
- let intervalHandle;
1351
- if (sweepIntervalMs > 0) {
1352
- intervalHandle = setInterval(sweep, sweepIntervalMs);
1353
- }
1099
+ const keysOf = (hostId) => [...entries].filter(([, presence]) => presence.hostId === hostId).map(([key]) => key);
1354
1100
  return {
1355
1101
  registerHostTunnel(input) {
1356
1102
  const timestamp = now();
@@ -1364,10 +1110,10 @@ function createWorkspaceRelayDirectory(options = {}) {
1364
1110
  for (const workspaceId of next.workspaceIds) entries.set(presenceKey(next.hostId, workspaceId), next);
1365
1111
  return next;
1366
1112
  },
1367
- recordPong(hostId, workspaceIds) {
1113
+ recordPong(hostId) {
1368
1114
  const timestamp = now();
1369
1115
  let touched;
1370
- for (const key of keysOf(hostId, workspaceIds)) {
1116
+ for (const key of keysOf(hostId)) {
1371
1117
  const presence = alive(key);
1372
1118
  if (!presence) continue;
1373
1119
  const next = { ...presence, lastPongAt: timestamp, expiresAt: timestamp + ttlMs };
@@ -1376,35 +1122,140 @@ function createWorkspaceRelayDirectory(options = {}) {
1376
1122
  }
1377
1123
  return touched;
1378
1124
  },
1379
- disconnectHost(hostId, workspaceIds) {
1380
- for (const key of keysOf(hostId, workspaceIds)) entries.delete(key);
1125
+ disconnectHost(hostId) {
1126
+ for (const key of keysOf(hostId)) entries.delete(key);
1381
1127
  },
1382
1128
  activeHost(input) {
1383
1129
  return alive(presenceKey(input.hostId, input.workspaceId));
1384
- },
1385
- sweep,
1386
- size() {
1387
- const at = now();
1388
- const hosts = /* @__PURE__ */ new Set();
1389
- for (const presence of entries.values()) {
1390
- if (presence.expiresAt > at) hosts.add(presence.hostId);
1391
- }
1392
- return hosts.size;
1393
- },
1394
- dispose() {
1395
- if (intervalHandle !== void 0) {
1396
- clearInterval(intervalHandle);
1397
- intervalHandle = void 0;
1398
- }
1399
1130
  }
1400
1131
  };
1401
1132
  }
1402
- function disposeWorkspaceRelayDirectory(directory) {
1403
- directory.dispose();
1133
+
1134
+ // src/cors-origins.ts
1135
+ var DEFAULT_RELAY_APP_ORIGINS = [
1136
+ "https://claxedo.com",
1137
+ "https://*.claxedo.com"
1138
+ ];
1139
+ var RELAY_ALLOWED_REQUEST_HEADERS = [
1140
+ "Accept",
1141
+ "Authorization",
1142
+ "Content-Type",
1143
+ "Last-Event-ID",
1144
+ "Traceparent",
1145
+ "Tracestate",
1146
+ "X-Fetch-Bypass-Throttle",
1147
+ "X-Workspace-Id",
1148
+ "X-OpenCode-Directory",
1149
+ "X-Claxedo-Runner",
1150
+ "X-Claxedo-Model",
1151
+ "X-Claxedo-Draft-Id",
1152
+ "X-Claxedo-Session-Registration-Operation",
1153
+ "X-Claxedo-Binary"
1154
+ ].join(", ");
1155
+ function parseAllowedOrigins(raw) {
1156
+ const entries = (raw ?? "").split(",").map((item) => item.trim()).filter(Boolean);
1157
+ return entries.length ? entries : void 0;
1158
+ }
1159
+ function createOriginMatcher(patterns) {
1160
+ const exact = /* @__PURE__ */ new Set();
1161
+ const httpsSuffixes = [];
1162
+ const httpPortPrefixes = [];
1163
+ for (const pattern of patterns) {
1164
+ if (pattern.startsWith("https://*.")) {
1165
+ httpsSuffixes.push(pattern.slice("https://*".length));
1166
+ } else if (pattern.startsWith("http://") && pattern.endsWith(":*")) {
1167
+ httpPortPrefixes.push(pattern.slice(0, -1));
1168
+ } else {
1169
+ exact.add(pattern);
1170
+ }
1171
+ }
1172
+ return (origin) => {
1173
+ if (exact.has(origin)) return true;
1174
+ if (origin.startsWith("https://")) {
1175
+ return httpsSuffixes.some(
1176
+ (suffix) => origin.endsWith(suffix) && origin.length > "https://".length + suffix.length
1177
+ );
1178
+ }
1179
+ if (origin.startsWith("http://")) {
1180
+ return httpPortPrefixes.some(
1181
+ (prefix) => origin.startsWith(prefix) && /^\d+$/.test(origin.slice(prefix.length))
1182
+ );
1183
+ }
1184
+ return false;
1185
+ };
1186
+ }
1187
+
1188
+ // src/relay-cors.ts
1189
+ var RELAY_EXPOSED_RESPONSE_HEADERS = "X-Event-Cursor, X-Next-Cursor, X-Max-Event-Ordinal, Server-Timing, X-Claxedo-Trace-Id, X-Claxedo-Relay-Location-Hint";
1190
+ var configuredAppOriginAllowed = () => false;
1191
+ function setWorkspaceRelayAppOrigins(raw) {
1192
+ configuredAppOriginAllowed = raw?.trim() ? createOriginMatcher(parseAllowedOrigins(raw) ?? []) : () => false;
1193
+ }
1194
+ var defaultOriginAllowed = createOriginMatcher(DEFAULT_RELAY_APP_ORIGINS);
1195
+ var baseOriginAllowed = defaultOriginAllowed;
1196
+ function setWorkspaceRelayAllowedOrigins(raw) {
1197
+ const patterns = parseAllowedOrigins(raw);
1198
+ baseOriginAllowed = patterns ? createOriginMatcher(patterns) : defaultOriginAllowed;
1199
+ }
1200
+ function allowedCorsOrigin(origin) {
1201
+ if (!origin) return void 0;
1202
+ return baseOriginAllowed(origin) || configuredAppOriginAllowed(origin) ? origin : void 0;
1203
+ }
1204
+ function workspaceUpgradeOriginDenied(request) {
1205
+ const origin = request.headers.get("origin");
1206
+ if (origin === null || allowedCorsOrigin(origin)) return null;
1207
+ return Response.json(errorBody("origin_not_allowed", "Origin is not in the allowlist"), { status: 403 });
1208
+ }
1209
+ function corsHeaders(request) {
1210
+ const origin = allowedCorsOrigin(request.headers.get("origin"));
1211
+ if (!origin) return void 0;
1212
+ return {
1213
+ "access-control-allow-origin": origin,
1214
+ "access-control-allow-methods": "GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS",
1215
+ "access-control-allow-headers": RELAY_ALLOWED_REQUEST_HEADERS,
1216
+ "access-control-expose-headers": RELAY_EXPOSED_RESPONSE_HEADERS,
1217
+ "access-control-max-age": "86400",
1218
+ "timing-allow-origin": origin,
1219
+ vary: "Origin"
1220
+ };
1221
+ }
1222
+ function corsPreflight(request) {
1223
+ const headers = corsHeaders(request);
1224
+ return new Response(null, { status: headers ? 204 : 403, ...headers ? { headers } : {} });
1225
+ }
1226
+ var UPSTREAM_OWNED_RESPONSE_HEADERS = [
1227
+ "set-cookie",
1228
+ "access-control-allow-origin",
1229
+ "access-control-allow-credentials",
1230
+ "access-control-allow-headers",
1231
+ "access-control-allow-methods",
1232
+ "access-control-expose-headers",
1233
+ "access-control-max-age"
1234
+ ];
1235
+ var RELAY_DOCUMENT_LOCKDOWN_HEADERS = {
1236
+ "x-content-type-options": "nosniff",
1237
+ "content-security-policy": "sandbox"
1238
+ };
1239
+ function withRelayResponseHeaders(request, response) {
1240
+ if (response.status === 101 || "webSocket" in response && response.webSocket) return response;
1241
+ const next = new Headers(response.headers);
1242
+ for (const name of UPSTREAM_OWNED_RESPONSE_HEADERS) next.delete(name);
1243
+ for (const [key, value] of Object.entries({ ...RELAY_DOCUMENT_LOCKDOWN_HEADERS, ...corsHeaders(request) })) next.set(key, value);
1244
+ return new Response(response.body, {
1245
+ status: response.status,
1246
+ statusText: response.statusText,
1247
+ headers: next
1248
+ });
1404
1249
  }
1405
1250
 
1406
- // src/cloudflare.ts
1251
+ // src/session-host-forwarding.ts
1252
+ import { sessionHostRootOf as sessionHostRootOf2 } from "@claxedo/workspace-relay-protocol";
1253
+
1254
+ // src/relay-location.ts
1255
+ import { trimToUndefined as trimToUndefined2 } from "@claxedo/helpers/string";
1256
+ import { sessionHostRootOf } from "@claxedo/workspace-relay-protocol";
1407
1257
  var DEFAULT_RELAY_LOCATION_HINT = "apac";
1258
+ var SESSION_HOST_LOCATION_HINT = "enam";
1408
1259
  var RELAY_LOCATION_HINTS = ["wnam", "enam", "sam", "weur", "eeur", "apac", "oc", "afr", "me"];
1409
1260
  var relayLocationHintSet = new Set(RELAY_LOCATION_HINTS);
1410
1261
  function isRelayLocationHint(input) {
@@ -1446,29 +1297,135 @@ function locationHintForCountry(country) {
1446
1297
  }
1447
1298
  var RELAY_REGION_HEADER = "x-claxedo-workspace-region";
1448
1299
  function relayLocationHint(input) {
1449
- const configured = trimToUndefined(input.configured) ?? DEFAULT_RELAY_LOCATION_HINT;
1450
- const region = trimToUndefined(input.region);
1300
+ const configured = trimToUndefined2(input.configured) ?? DEFAULT_RELAY_LOCATION_HINT;
1301
+ const region = trimToUndefined2(input.region);
1451
1302
  if (region) {
1452
1303
  const lowered = region.toLowerCase();
1453
1304
  const direct = isRelayLocationHint(lowered) ? lowered : void 0;
1454
1305
  const mapped = direct ?? RELAY_REGION_TO_LOCATION_HINT[lowered];
1455
1306
  if (mapped) return mapped;
1456
1307
  }
1457
- const country = trimToUndefined(input.country);
1308
+ const country = trimToUndefined2(input.country);
1458
1309
  if (country) {
1459
1310
  const mapped = locationHintForCountry(country);
1460
1311
  if (mapped) return mapped;
1461
1312
  }
1462
1313
  return configured;
1463
1314
  }
1315
+ var RELAY_REGION_QUERY_PARAMS = ["region", "homeRegion"];
1464
1316
  function relayRequestRegion(request) {
1465
1317
  const url = new URL(request.url);
1466
- return trimToUndefined(request.headers.get(RELAY_REGION_HEADER)) ?? trimToUndefined(url.searchParams.get("region")) ?? trimToUndefined(url.searchParams.get("homeRegion"));
1318
+ return trimToUndefined2(request.headers.get(RELAY_REGION_HEADER)) ?? trimToUndefined2(url.searchParams.get(RELAY_REGION_QUERY_PARAMS[0])) ?? trimToUndefined2(url.searchParams.get(RELAY_REGION_QUERY_PARAMS[1]));
1319
+ }
1320
+ async function relayRoomPlacement(request, input) {
1321
+ const configured = trimToUndefined2(input.configured);
1322
+ const region = relayRequestRegion(request);
1323
+ const country = relayRequestCountry(request);
1324
+ const floor = relayLocationHint(configured ? { configured } : {});
1325
+ const declared = relayLocationHint({
1326
+ ...configured ? { configured } : {},
1327
+ ...region ? { region } : {},
1328
+ ...country ? { country } : {}
1329
+ });
1330
+ const headers = new Headers(request.headers);
1331
+ headers.delete(RELAY_REGION_HEADER);
1332
+ const credential = await input.credential();
1333
+ const sessionHost = credential !== void 0 && sessionHostRootOf(credential.hostId) !== void 0;
1334
+ return {
1335
+ locationHint: sessionHost ? SESSION_HOST_LOCATION_HINT : credential || declared === floor ? declared : floor,
1336
+ url: withoutQueryParams(request.url, RELAY_REGION_QUERY_PARAMS),
1337
+ headers
1338
+ };
1339
+ }
1340
+ function withoutQueryParams(url, names) {
1341
+ const target = new URL(url);
1342
+ if (!names.some((name) => target.searchParams.has(name))) return url;
1343
+ const kept = target.search.slice(1).split("&").filter((pair) => !names.includes(pair.split("=")[0] ?? ""));
1344
+ target.search = kept.length ? `?${kept.join("&")}` : "";
1345
+ return target.toString();
1467
1346
  }
1468
1347
  function relayRequestCountry(request) {
1469
1348
  const cf = request.cf;
1470
1349
  return typeof cf?.country === "string" ? cf.country : void 0;
1471
1350
  }
1351
+
1352
+ // src/session-host-forwarding.ts
1353
+ var SESSION_HOST_LOCATION = { locationHint: SESSION_HOST_LOCATION_HINT };
1354
+ var SESSION_HOST_ORIGIN = "https://session-host.invalid";
1355
+ function unavailable() {
1356
+ return Response.json(errorBody("session_host_unavailable", "The session host is unavailable"), { status: 503 });
1357
+ }
1358
+ function isSessionHostTarget(target) {
1359
+ return target.backing === "durable-object";
1360
+ }
1361
+ async function forwardSessionHostHttp(namespace, request, authorized) {
1362
+ const root = sessionHostRootOf2(authorized.target.hostId);
1363
+ if (!namespace || !root) return unavailable();
1364
+ const url = workspaceRelayTargetUrl({ ...authorized.target, baseUrl: SESSION_HOST_ORIGIN }, authorized.path, new URL(request.url).search);
1365
+ const init = workspaceRelayForwardRequestInit(request, authorized.relayHostToken, authorized.target.workspaceId, { hostTunnel: true });
1366
+ let upstream;
1367
+ try {
1368
+ upstream = await namespace.getByName(root, SESSION_HOST_LOCATION).fetch(url.toString(), init);
1369
+ } catch {
1370
+ return unavailable();
1371
+ }
1372
+ return new Response(upstream.body, { status: upstream.status, statusText: upstream.statusText, headers: upstream.headers });
1373
+ }
1374
+
1375
+ // src/gateway-placement.ts
1376
+ import { createRemoteJWKSet, importSPKI } from "jose";
1377
+ import { trimToUndefined as trimToUndefined3 } from "@claxedo/helpers/string";
1378
+ var placementAuthKeys = /* @__PURE__ */ new Map();
1379
+ function placementAuthKey(env) {
1380
+ const jwksUrl = typeof env.CLAXEDO_CONTROL_PLANE_JWKS_URL === "string" ? trimToUndefined3(env.CLAXEDO_CONTROL_PLANE_JWKS_URL) : void 0;
1381
+ const pem = (typeof env.CLAXEDO_RUNTIME_ACCESS_TOKEN_PUBLIC_KEY_PEM === "string" ? trimToUndefined3(env.CLAXEDO_RUNTIME_ACCESS_TOKEN_PUBLIC_KEY_PEM) : void 0)?.replaceAll("\\n", "\n");
1382
+ const id = jwksUrl ? `jwks:${jwksUrl}` : pem ? `pem:${pem}` : void 0;
1383
+ if (!id) return void 0;
1384
+ const cached = placementAuthKeys.get(id);
1385
+ if (cached) return cached;
1386
+ if (placementAuthKeys.size >= 32) placementAuthKeys.clear();
1387
+ const loaded = (async () => {
1388
+ try {
1389
+ if (jwksUrl) return createRemoteJWKSet(new URL(jwksUrl));
1390
+ if (!pem) return void 0;
1391
+ return await importSPKI(pem, "EdDSA");
1392
+ } catch {
1393
+ return void 0;
1394
+ }
1395
+ })();
1396
+ placementAuthKeys.set(id, loaded);
1397
+ return loaded;
1398
+ }
1399
+ function runtimeAccessTokenFrom(request) {
1400
+ return bearerToken(request.headers.get("authorization") ?? void 0) ?? request.headers.get("sec-websocket-protocol")?.split(",").map((item) => item.trim()).find((item) => item.startsWith("claxedo-rat."))?.slice("claxedo-rat.".length);
1401
+ }
1402
+ async function relayPlacementCredential(request, env, url, workspaceId) {
1403
+ const key = await placementAuthKey(env);
1404
+ if (!key) return void 0;
1405
+ try {
1406
+ if (url.pathname.startsWith("/host-tunnels/")) {
1407
+ const hostId = trimToUndefined3(hostIdFromTunnelPath(url.pathname));
1408
+ const token2 = bearerToken(request.headers.get("authorization"));
1409
+ if (!hostId || !token2) return void 0;
1410
+ await verifyHostTunnelToken(token2, key, { hostId, workspaceIds: workspaceIdsFromSearch(url) });
1411
+ return { hostId };
1412
+ }
1413
+ const token = runtimeAccessTokenFrom(request);
1414
+ if (!token) return void 0;
1415
+ const claims = await verifyRuntimeAccessToken(token, key, { workspaceId });
1416
+ return { hostId: claims.host_id };
1417
+ } catch {
1418
+ return void 0;
1419
+ }
1420
+ }
1421
+ function workspaceRelayRoomPlacement(request, env, workspaceId) {
1422
+ return relayRoomPlacement(request, {
1423
+ ...typeof env.CLAXEDO_RELAY_LOCATION_HINT === "string" ? { configured: env.CLAXEDO_RELAY_LOCATION_HINT } : {},
1424
+ credential: () => relayPlacementCredential(request, env, new URL(request.url), workspaceId)
1425
+ });
1426
+ }
1427
+
1428
+ // src/cloudflare.ts
1472
1429
  var TUNNEL_PENDING_HTTP_CAP_DEFAULT = 32;
1473
1430
  var TUNNEL_STARTED_STREAM_CAP_DEFAULT = 16;
1474
1431
  var TUNNEL_CHANNEL_CAP_DEFAULT = 16;
@@ -1476,19 +1433,20 @@ var TUNNEL_REQUEST_BODY_MAX_BYTES_DEFAULT = 16 * 1024 * 1024;
1476
1433
  var TUNNEL_RESPONSE_BODY_MAX_BYTES_DEFAULT = 16 * 1024 * 1024;
1477
1434
  var SLOW_CONSUMER_HIGH_WATER_MARK_BYTES_DEFAULT = 8 * 1024 * 1024;
1478
1435
  var SLOW_CONSUMER_TIMEOUT_MS_DEFAULT = 3e4;
1479
- var UPSTREAM_WS_PRE_OPEN_QUEUE_MAX_FRAMES_DEFAULT = 64;
1436
+ var UPSTREAM_WS_PRE_OPEN_QUEUE_MAX_FRAMES_DEFAULT = 8192;
1480
1437
  var UPSTREAM_WS_PRE_OPEN_QUEUE_MAX_BYTES_DEFAULT = 8 * 1024 * 1024;
1481
- var RUNTIME_ACCESS_TOKEN_ACTIVE_CHECK_INTERVAL_MS_DEFAULT = 3e4;
1438
+ var UPSTREAM_WS_PRE_OPEN_QUEUE_FRAME_OVERHEAD_BYTES = 64;
1482
1439
  var WORKSPACE_TARGET_ACTIVE_CHECK_INTERVAL_MS_DEFAULT = 3e4;
1483
1440
  var RESOLVER_OUTAGE_GRACE_ATTEMPTS_DEFAULT = 3;
1484
1441
  var HIBERNATED_REVOCATION_ALARM_INTERVAL_MS_DEFAULT = 3e4;
1485
1442
  var HOST_GENERATION_CHECK_INTERVAL_MS_DEFAULT = 3e4;
1486
1443
  var TRACE_ID_HEADER = "x-claxedo-trace-id";
1487
1444
  var TRACE_FORCE_HEADER = "x-claxedo-relay-trace";
1445
+ var TRACE_SAMPLED_RESPONSE_HEADER = "x-claxedo-relay-sampled";
1488
1446
  function unrefTimer(timer) {
1489
1447
  if (typeof timer.unref === "function") timer.unref();
1490
1448
  }
1491
- function isUserHostedClient(client) {
1449
+ function isHostTunnelClient(client) {
1492
1450
  return "channelId" in client && typeof client.channelId === "string";
1493
1451
  }
1494
1452
  function clampRate(input) {
@@ -1498,8 +1456,8 @@ function clampRate(input) {
1498
1456
  return input;
1499
1457
  }
1500
1458
  function traceRequest(request, routeKind, options) {
1501
- const id = trimToUndefined(request.headers.get(TRACE_ID_HEADER)) ?? crypto.randomUUID();
1502
- const forceSecret = trimToUndefined(options.traceForceHeaderSecret);
1459
+ const id = trimToUndefined4(request.headers.get(TRACE_ID_HEADER)) ?? crypto.randomUUID();
1460
+ const forceSecret = trimToUndefined4(options.traceForceHeaderSecret);
1503
1461
  const forced = !!forceSecret && request.headers.get(TRACE_FORCE_HEADER) === forceSecret;
1504
1462
  const rate = clampRate(options.traceSampleRate);
1505
1463
  return {
@@ -1518,12 +1476,12 @@ async function traceSpan(trace, name, fn) {
1518
1476
  trace?.phases.push({ name, ms: performance.now() - startedAt });
1519
1477
  }
1520
1478
  }
1521
- function tracedRequest(request, trace) {
1522
- const headers = new Headers(request.headers);
1479
+ function tracedRequest(request, trace, forwarded = request) {
1480
+ const headers = new Headers(forwarded.headers);
1523
1481
  headers.set(TRACE_ID_HEADER, trace.id);
1524
1482
  if (trace.sampled) headers.set("x-claxedo-trace-sampled", "1");
1525
1483
  else headers.delete("x-claxedo-trace-sampled");
1526
- return new Request(request.url, {
1484
+ return new Request(forwarded.url, {
1527
1485
  method: request.method,
1528
1486
  headers,
1529
1487
  body: request.method === "GET" || request.method === "HEAD" ? void 0 : request.body,
@@ -1531,11 +1489,11 @@ function tracedRequest(request, trace) {
1531
1489
  duplex: "half"
1532
1490
  });
1533
1491
  }
1534
- function roundedMs2(value) {
1492
+ function roundedMs(value) {
1535
1493
  return Math.round(value * 100) / 100;
1536
1494
  }
1537
1495
  function serverTiming(trace) {
1538
- return trace.phases.map((phase) => `${phase.name};dur=${roundedMs2(phase.ms)}`).join(", ");
1496
+ return trace.phases.map((phase) => `${phase.name};dur=${roundedMs(phase.ms)}`).join(", ");
1539
1497
  }
1540
1498
  function withTraceHeaders(response, trace) {
1541
1499
  const headers = new Headers(response.headers);
@@ -1544,6 +1502,7 @@ function withTraceHeaders(response, trace) {
1544
1502
  if (timing) {
1545
1503
  const existing = headers.get("server-timing");
1546
1504
  headers.set("server-timing", existing ? `${existing}, ${timing}` : timing);
1505
+ headers.set(TRACE_SAMPLED_RESPONSE_HEADER, "1");
1547
1506
  }
1548
1507
  return new Response(response.body, {
1549
1508
  status: response.status,
@@ -1563,8 +1522,8 @@ function logTrace(options, request, workspaceId, trace, response) {
1563
1522
  method: request.method,
1564
1523
  path: url.pathname,
1565
1524
  status: response.status,
1566
- totalMs: roundedMs2(totalMs),
1567
- phases: Object.fromEntries(trace.phases.map((phase) => [phase.name, roundedMs2(phase.ms)]))
1525
+ totalMs: roundedMs(totalMs),
1526
+ phases: Object.fromEntries(trace.phases.map((phase) => [phase.name, roundedMs(phase.ms)]))
1568
1527
  };
1569
1528
  if (options.traceLog) {
1570
1529
  options.traceLog(event);
@@ -1575,26 +1534,17 @@ function logTrace(options, request, workspaceId, trace, response) {
1575
1534
  function workspaceIdFromRelayPath(pathname) {
1576
1535
  return /^\/workspaces\/([^/]+)(?:\/|$)/.exec(pathname)?.[1];
1577
1536
  }
1578
- function hostIdFromTunnelPath(pathname) {
1579
- return /^\/host-tunnels\/([^/]+)(?:\/|$)/.exec(pathname)?.[1];
1580
- }
1581
1537
  function websocketRequest(request) {
1582
1538
  return request.headers.get("upgrade")?.toLowerCase() === "websocket";
1583
1539
  }
1584
1540
  function relayWebSocketTraceEnabled(request) {
1585
1541
  return request.headers.get("x-claxedo-relay-ws-trace") === "1";
1586
1542
  }
1587
- function workspaceIdsFromSearch(url) {
1588
- return [...new Set([
1589
- ...url.searchParams.getAll("workspaceId"),
1590
- ...url.searchParams.getAll("workspace_id")
1591
- ].map(trimToUndefined).filter((item) => !!item))];
1592
- }
1593
1543
  function randomToken() {
1594
1544
  return crypto.randomUUID();
1595
1545
  }
1596
1546
  function headerRecord(headers) {
1597
- const result = {};
1547
+ const result = /* @__PURE__ */ Object.create(null);
1598
1548
  headers.forEach((value, key) => {
1599
1549
  result[key] = value;
1600
1550
  });
@@ -1617,8 +1567,8 @@ function base64ToBytes(input) {
1617
1567
  return Uint8Array.from(atob(input), (char) => char.charCodeAt(0));
1618
1568
  }
1619
1569
  function preOpenFrameBytes(input) {
1620
- if (typeof input === "string") return new TextEncoder().encode(input).byteLength;
1621
- return input.byteLength;
1570
+ const payload = typeof input === "string" ? new TextEncoder().encode(input).byteLength : input.byteLength;
1571
+ return payload + UPSTREAM_WS_PRE_OPEN_QUEUE_FRAME_OVERHEAD_BYTES;
1622
1572
  }
1623
1573
  function isBlobLike(input) {
1624
1574
  if (!input || typeof input !== "object") return false;
@@ -1740,10 +1690,10 @@ function workspaceRelayDurableObjectRoomName(workspaceId) {
1740
1690
  }
1741
1691
  function workspaceRelayDurableObjectWorkspaceId(request) {
1742
1692
  const url = new URL(request.url);
1743
- const workspaceId = trimToUndefined(workspaceIdFromRelayPath(url.pathname));
1693
+ const workspaceId = trimToUndefined4(workspaceIdFromRelayPath(url.pathname));
1744
1694
  if (workspaceId) return decodeURIComponent(workspaceId);
1745
1695
  if (!hostIdFromTunnelPath(url.pathname)) return void 0;
1746
- return trimToUndefined(url.searchParams.get("workspaceId")) ?? trimToUndefined(url.searchParams.get("workspace_id"));
1696
+ return trimToUndefined4(url.searchParams.get("workspaceId")) ?? trimToUndefined4(url.searchParams.get("workspace_id"));
1747
1697
  }
1748
1698
  function namespaceFromEnv(env, options) {
1749
1699
  if (options.namespace) return options.namespace;
@@ -1757,47 +1707,6 @@ function isDurableObjectNamespace(value) {
1757
1707
  function json(code, message, status) {
1758
1708
  return Response.json(errorBody(code, message), { status });
1759
1709
  }
1760
- var configuredAppOriginAllowed = () => false;
1761
- function setWorkspaceRelayAppOrigins(raw) {
1762
- configuredAppOriginAllowed = raw?.trim() ? createOriginMatcher(parseAllowedOrigins(raw) ?? []) : () => false;
1763
- }
1764
- var defaultOriginAllowed = createOriginMatcher(DEFAULT_RELAY_APP_ORIGINS);
1765
- var baseOriginAllowed = defaultOriginAllowed;
1766
- function setWorkspaceRelayAllowedOrigins(raw) {
1767
- const patterns = parseAllowedOrigins(raw);
1768
- baseOriginAllowed = patterns ? createOriginMatcher(patterns) : defaultOriginAllowed;
1769
- }
1770
- function allowedCorsOrigin(origin) {
1771
- if (!origin) return void 0;
1772
- return baseOriginAllowed(origin) || configuredAppOriginAllowed(origin) ? origin : void 0;
1773
- }
1774
- function corsHeaders(request) {
1775
- const origin = allowedCorsOrigin(request.headers.get("origin"));
1776
- if (!origin) return void 0;
1777
- return {
1778
- "access-control-allow-origin": origin,
1779
- "access-control-allow-methods": "GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS",
1780
- "access-control-allow-headers": RELAY_ALLOWED_REQUEST_HEADERS,
1781
- "access-control-max-age": "86400",
1782
- vary: "Origin"
1783
- };
1784
- }
1785
- function corsPreflight(request) {
1786
- const headers = corsHeaders(request);
1787
- return new Response(null, { status: headers ? 204 : 403, ...headers ? { headers } : {} });
1788
- }
1789
- function withCors(request, response) {
1790
- const headers = corsHeaders(request);
1791
- if (!headers) return response;
1792
- if (response.status === 101 || "webSocket" in response && response.webSocket) return response;
1793
- const next = new Headers(response.headers);
1794
- for (const [key, value] of Object.entries(headers)) next.set(key, value);
1795
- return new Response(response.body, {
1796
- status: response.status,
1797
- statusText: response.statusText,
1798
- headers: next
1799
- });
1800
- }
1801
1710
  function defaultWebSocketPair() {
1802
1711
  const pair = new WebSocketPair();
1803
1712
  return {
@@ -1834,7 +1743,7 @@ function createWorkspaceRelayDurableObjectGateway(options = {}) {
1834
1743
  return {
1835
1744
  async fetch(request, env = {}) {
1836
1745
  const gatewayStartedAt = performance.now();
1837
- const gatewayTraceId = trimToUndefined(request.headers.get(TRACE_ID_HEADER)) ?? crypto.randomUUID();
1746
+ const gatewayTraceId = trimToUndefined4(request.headers.get(TRACE_ID_HEADER)) ?? crypto.randomUUID();
1838
1747
  const url = new URL(request.url);
1839
1748
  if (request.method === "OPTIONS" && (url.pathname.startsWith("/workspaces/") || url.pathname.startsWith("/host-tunnels/"))) {
1840
1749
  return corsPreflight(request);
@@ -1842,13 +1751,6 @@ function createWorkspaceRelayDurableObjectGateway(options = {}) {
1842
1751
  if (url.pathname === "/health") {
1843
1752
  return Response.json({ ok: true, service: "workspace-relay", mode: "cloudflare-durable-object" });
1844
1753
  }
1845
- const requestRegion = relayRequestRegion(request);
1846
- const requestCountry = relayRequestCountry(request);
1847
- const locationHint = relayLocationHint({
1848
- ...typeof env.CLAXEDO_RELAY_LOCATION_HINT === "string" ? { configured: env.CLAXEDO_RELAY_LOCATION_HINT } : {},
1849
- ...requestRegion ? { region: requestRegion } : {},
1850
- ...requestCountry ? { country: requestCountry } : {}
1851
- });
1852
1754
  const namespace = namespaceFromEnv(env, options);
1853
1755
  if (!namespace) {
1854
1756
  return json(
@@ -1872,6 +1774,8 @@ function createWorkspaceRelayDurableObjectGateway(options = {}) {
1872
1774
  url.pathname.startsWith("/workspaces/") || url.pathname.startsWith("/host-tunnels/") ? 400 : 404
1873
1775
  );
1874
1776
  }
1777
+ const placement = await workspaceRelayRoomPlacement(request, env, workspaceId);
1778
+ const locationHint = placement.locationHint;
1875
1779
  const routeFinishedAt = performance.now();
1876
1780
  const response = await namespace.get(namespace.idFromName(workspaceRelayDurableObjectRoomName(workspaceId)), {
1877
1781
  locationHint
@@ -1881,10 +1785,10 @@ function createWorkspaceRelayDurableObjectGateway(options = {}) {
1881
1785
  startedAt: gatewayStartedAt,
1882
1786
  routeKind: "gateway",
1883
1787
  phases: []
1884
- }));
1788
+ }, placement));
1885
1789
  const fetchFinishedAt = performance.now();
1886
1790
  if (response.status === 101 || "webSocket" in response && response.webSocket) {
1887
- return url.pathname.startsWith("/workspaces/") ? withCors(request, response) : response;
1791
+ return url.pathname.startsWith("/workspaces/") ? withRelayResponseHeaders(request, response) : response;
1888
1792
  }
1889
1793
  const headers = new Headers(response.headers);
1890
1794
  headers.set(TRACE_ID_HEADER, headers.get(TRACE_ID_HEADER) ?? gatewayTraceId);
@@ -1896,22 +1800,24 @@ function createWorkspaceRelayDurableObjectGateway(options = {}) {
1896
1800
  headers
1897
1801
  });
1898
1802
  const gatewayFinishedAt = performance.now();
1899
- const roomTiming = tracedResponse.headers.get("server-timing");
1900
- if (roomTiming) {
1803
+ const sampled = tracedResponse.headers.get(TRACE_SAMPLED_RESPONSE_HEADER) === "1";
1804
+ tracedResponse.headers.delete(TRACE_SAMPLED_RESPONSE_HEADER);
1805
+ const roomTiming = tracedResponse.headers.get("server-timing") ?? "";
1806
+ if (sampled) {
1901
1807
  const gatewayTiming = [
1902
- `relay-gateway-route;dur=${roundedMs2(routeFinishedAt - gatewayStartedAt)}`,
1903
- `relay-gateway-do-fetch;dur=${roundedMs2(fetchFinishedAt - routeFinishedAt)}`,
1904
- `relay-gateway-response;dur=${roundedMs2(gatewayFinishedAt - gatewayResponseStartedAt)}`,
1905
- `relay-gateway;dur=${roundedMs2(gatewayFinishedAt - gatewayStartedAt)}`
1808
+ `relay-gateway-route;dur=${roundedMs(routeFinishedAt - gatewayStartedAt)}`,
1809
+ `relay-gateway-do-fetch;dur=${roundedMs(fetchFinishedAt - routeFinishedAt)}`,
1810
+ `relay-gateway-response;dur=${roundedMs(gatewayFinishedAt - gatewayResponseStartedAt)}`,
1811
+ `relay-gateway;dur=${roundedMs(gatewayFinishedAt - gatewayStartedAt)}`
1906
1812
  ].join(", ");
1907
1813
  tracedResponse.headers.set("server-timing", `${roomTiming}, ${gatewayTiming}`);
1908
1814
  }
1909
- return url.pathname.startsWith("/workspaces/") ? withCors(request, tracedResponse) : tracedResponse;
1815
+ return url.pathname.startsWith("/workspaces/") ? withRelayResponseHeaders(request, tracedResponse) : tracedResponse;
1910
1816
  }
1911
1817
  };
1912
1818
  }
1913
1819
  function createWorkspaceRelayDurableObjectRoom(options) {
1914
- const directory = options.directory ?? createWorkspaceRelayDirectory({ sweepIntervalMs: 0 });
1820
+ const directory = options.directory ?? createWorkspaceRelayDirectory();
1915
1821
  const relayOptions = { ...options, directory };
1916
1822
  const hostTunnels = /* @__PURE__ */ new Map();
1917
1823
  const clients = /* @__PURE__ */ new Set();
@@ -1986,12 +1892,14 @@ function createWorkspaceRelayDurableObjectRoom(options) {
1986
1892
  const attachment = socketAttachment(socket);
1987
1893
  if (!attachment) continue;
1988
1894
  if (attachment.kind === "host-tunnel") {
1895
+ const incumbent = hostTunnels.get(attachment.hostId);
1896
+ if (incumbent && incumbent.connectedAt > attachment.connectedAt) continue;
1989
1897
  hostTunnels.set(attachment.hostId, {
1990
1898
  hostId: attachment.hostId,
1991
1899
  workspaceIds: attachment.workspaceIds,
1992
1900
  connectedAt: attachment.connectedAt,
1993
- ...attachment.enrollmentId ? { enrollmentId: attachment.enrollmentId } : {},
1994
- ...attachment.generation !== void 0 ? { generation: attachment.generation } : {},
1901
+ enrollmentId: attachment.enrollmentId,
1902
+ generation: attachment.generation,
1995
1903
  generationCheckFailures: attachment.generationCheckFailures ?? 0,
1996
1904
  socket,
1997
1905
  pending: /* @__PURE__ */ new Map(),
@@ -2008,7 +1916,7 @@ function createWorkspaceRelayDurableObjectRoom(options) {
2008
1916
  for (const row of clientRows) {
2009
1917
  const tunnel = hostTunnels.get(row.attachment.target.hostId);
2010
1918
  if (!tunnel) {
2011
- closeSocket(row.socket, 1011, "User-hosted tunnel unavailable");
1919
+ closeSocket(row.socket, 1011, "Host tunnel unavailable");
2012
1920
  continue;
2013
1921
  }
2014
1922
  const client = {
@@ -2059,7 +1967,7 @@ function createWorkspaceRelayDurableObjectRoom(options) {
2059
1967
  clearInterval(timer);
2060
1968
  onInactive(reason);
2061
1969
  };
2062
- void Promise.resolve(options.isRuntimeAccessTokenActive(client.request.claims)).then((active) => {
1970
+ void Promise.resolve().then(() => options.isRuntimeAccessTokenActive(client.request.claims)).then((active) => {
2063
1971
  if (active.active) {
2064
1972
  consecutiveUnreachable = 0;
2065
1973
  return;
@@ -2076,20 +1984,20 @@ function createWorkspaceRelayDurableObjectRoom(options) {
2076
1984
  unrefTimer(timer);
2077
1985
  return timer;
2078
1986
  };
2079
- const watchUserHostedTarget = (client, onInactive) => {
1987
+ const watchHostTunnelTarget = (client, onInactive) => {
2080
1988
  const intervalMs = options.workspaceTargetActiveCheckIntervalMs ?? WORKSPACE_TARGET_ACTIVE_CHECK_INTERVAL_MS_DEFAULT;
2081
1989
  if (intervalMs <= 0) return void 0;
2082
1990
  const target = client.request.target;
2083
1991
  let consecutiveUnreachable = 0;
2084
1992
  const timer = setInterval(() => {
2085
1993
  void Promise.resolve(options.resolveTarget(client.request.claims)).then((activeTarget) => {
2086
- if (activeTarget?.access === "user-hosted" && activeTarget.workspaceId === target.workspaceId && activeTarget.hostId === target.hostId && directory.activeHost({ hostId: target.hostId, workspaceId: target.workspaceId })) {
1994
+ if (activeTarget?.backing === "local-worktree" && activeTarget.workspaceId === target.workspaceId && activeTarget.hostId === target.hostId && directory.activeHost({ hostId: target.hostId, workspaceId: target.workspaceId })) {
2087
1995
  consecutiveUnreachable = 0;
2088
1996
  return;
2089
1997
  }
2090
1998
  consecutiveUnreachable = 0;
2091
1999
  clearInterval(timer);
2092
- onInactive("User-hosted workspace is offline");
2000
+ onInactive("The machine serving this workspace is offline");
2093
2001
  }).catch(() => {
2094
2002
  consecutiveUnreachable += 1;
2095
2003
  if (consecutiveUnreachable < resolverGraceAttempts) return;
@@ -2105,13 +2013,12 @@ function createWorkspaceRelayDurableObjectRoom(options) {
2105
2013
  hostId: tunnel.hostId,
2106
2014
  workspaceIds: tunnel.workspaceIds,
2107
2015
  connectedAt: tunnel.connectedAt,
2108
- ...tunnel.enrollmentId ? { enrollmentId: tunnel.enrollmentId } : {},
2109
- ...tunnel.generation !== void 0 ? { generation: tunnel.generation } : {},
2016
+ enrollmentId: tunnel.enrollmentId,
2017
+ generation: tunnel.generation,
2110
2018
  ...tunnel.generationCheckFailures ? { generationCheckFailures: tunnel.generationCheckFailures } : {}
2111
2019
  });
2112
- const fenced = (tunnel) => Boolean(options.resolveHostGeneration) && tunnel.generation !== void 0;
2113
2020
  const recheckHostGeneration = async (tunnel) => {
2114
- if (!fenced(tunnel) || hostTunnels.get(tunnel.hostId) !== tunnel) return;
2021
+ if (hostTunnels.get(tunnel.hostId) !== tunnel) return;
2115
2022
  const decision = await checkHostTunnelGeneration(options.resolveHostGeneration, {
2116
2023
  enrollment_id: tunnel.enrollmentId,
2117
2024
  generation: tunnel.generation
@@ -2138,7 +2045,7 @@ function createWorkspaceRelayDurableObjectRoom(options) {
2138
2045
  };
2139
2046
  const watchHostGeneration = (tunnel) => {
2140
2047
  const intervalMs = options.hostGenerationCheckIntervalMs ?? HOST_GENERATION_CHECK_INTERVAL_MS_DEFAULT;
2141
- if (tunnel.generationWatcher || hibernation || !fenced(tunnel) || intervalMs <= 0) return;
2048
+ if (tunnel.generationWatcher || hibernation || intervalMs <= 0) return;
2142
2049
  tunnel.generationWatcher = setInterval(() => {
2143
2050
  void recheckHostGeneration(tunnel).catch(() => {
2144
2051
  });
@@ -2315,12 +2222,12 @@ function createWorkspaceRelayDurableObjectRoom(options) {
2315
2222
  const active = await options.isRuntimeAccessTokenActive(client.request.claims);
2316
2223
  return active.active ? { active: true } : { active: false, reason: active.reason };
2317
2224
  };
2318
- const userHostedTargetActive = async (client) => {
2225
+ const hostTunnelTargetActive = async (client) => {
2319
2226
  const target = client.request.target;
2320
2227
  const activeTarget = await options.resolveTarget(client.request.claims);
2321
- return !!(activeTarget?.access === "user-hosted" && activeTarget.workspaceId === target.workspaceId && activeTarget.hostId === target.hostId && directory.activeHost({ hostId: target.hostId, workspaceId: target.workspaceId }));
2228
+ return !!(activeTarget?.backing === "local-worktree" && activeTarget.workspaceId === target.workspaceId && activeTarget.hostId === target.hostId && directory.activeHost({ hostId: target.hostId, workspaceId: target.workspaceId }));
2322
2229
  };
2323
- const dropUserHostedChannel = (tunnel, channelId, client, code, reason) => {
2230
+ const dropHostTunnelChannel = (tunnel, channelId, client, code, reason) => {
2324
2231
  tunnel.channels.delete(channelId);
2325
2232
  clients.delete(client);
2326
2233
  closeSocket(client.socket, code, reason);
@@ -2341,7 +2248,7 @@ function createWorkspaceRelayDurableObjectRoom(options) {
2341
2248
  const scheduleHibernatedRevocationCheck = async () => {
2342
2249
  const intervalMs = hibernatedRevocationIntervalMs();
2343
2250
  if (!hibernation || !options.alarms || intervalMs <= 0) return;
2344
- if (clients.size === 0 && ![...hostTunnels.values()].some(fenced)) return;
2251
+ if (clients.size === 0 && hostTunnels.size === 0) return;
2345
2252
  const now = options.now ?? Date.now;
2346
2253
  const at = now() + intervalMs;
2347
2254
  try {
@@ -2355,10 +2262,10 @@ function createWorkspaceRelayDurableObjectRoom(options) {
2355
2262
  ensureHibernatedSocketsRebuilt();
2356
2263
  const now = options.now ?? Date.now;
2357
2264
  const revoke = (client, reason) => {
2358
- if (isUserHostedClient(client)) {
2265
+ if (isHostTunnelClient(client)) {
2359
2266
  for (const tunnel of hostTunnels.values()) {
2360
2267
  if (tunnel.channels.get(client.channelId) !== client) continue;
2361
- dropUserHostedChannel(tunnel, client.channelId, client, 1008, reason);
2268
+ dropHostTunnelChannel(tunnel, client.channelId, client, 1008, reason);
2362
2269
  return;
2363
2270
  }
2364
2271
  }
@@ -2385,23 +2292,23 @@ function createWorkspaceRelayDurableObjectRoom(options) {
2385
2292
  }
2386
2293
  await scheduleHibernatedRevocationCheck();
2387
2294
  };
2388
- const activeUserHostedClient = (channelId, socket) => {
2295
+ const activeHostTunnelClient = (channelId, socket) => {
2389
2296
  for (const tunnel of hostTunnels.values()) {
2390
2297
  const client = tunnel.channels.get(channelId);
2391
2298
  if (client?.socket === socket) return { tunnel, client };
2392
2299
  }
2393
2300
  return void 0;
2394
2301
  };
2395
- const handleUserHostedClientMessage = async (channelId, socket, data) => {
2396
- const active = activeUserHostedClient(channelId, socket);
2302
+ const handleHostTunnelClientMessage = async (channelId, socket, data) => {
2303
+ const active = activeHostTunnelClient(channelId, socket);
2397
2304
  if (!active) return;
2398
2305
  const token = await runtimeAccessTokenActive(active.client);
2399
2306
  if (!token.active) {
2400
2307
  closeSocket(socket, 1008, token.reason);
2401
2308
  return;
2402
2309
  }
2403
- if (!await userHostedTargetActive(active.client)) {
2404
- closeSocket(socket, 1011, "User-hosted workspace is offline");
2310
+ if (!await hostTunnelTargetActive(active.client)) {
2311
+ closeSocket(socket, 1011, "The machine serving this workspace is offline");
2405
2312
  return;
2406
2313
  }
2407
2314
  const frame = await socketFrame(data);
@@ -2412,7 +2319,7 @@ function createWorkspaceRelayDurableObjectRoom(options) {
2412
2319
  channel_id: channelId,
2413
2320
  ...frame
2414
2321
  }));
2415
- if (!sent) closeSocket(socket, 1011, "User-hosted tunnel unavailable");
2322
+ if (!sent) closeSocket(socket, 1011, "Host tunnel unavailable");
2416
2323
  };
2417
2324
  const applyRegistrationUpdate = async (tunnel, workspaceIds, token) => {
2418
2325
  const hostId = tunnel.hostId;
@@ -2446,12 +2353,12 @@ function createWorkspaceRelayDurableObjectRoom(options) {
2446
2353
  watchHostGeneration(tunnel);
2447
2354
  void scheduleHibernatedRevocationCheck();
2448
2355
  };
2449
- const handleTunnelMessage = async (hostId, event) => {
2356
+ const handleTunnelMessage = async (hostId, socket, event) => {
2450
2357
  const row = parseTunnelMessageData(event.data);
2451
2358
  if (!row.ok) return;
2452
2359
  const parsed = validateTunnelMessage(row.value);
2453
2360
  const tunnel = hostTunnels.get(hostId);
2454
- if (!parsed.ok || !tunnel) return;
2361
+ if (!parsed.ok || !tunnel || tunnel.socket !== socket) return;
2455
2362
  directory.recordPong(tunnel.hostId);
2456
2363
  if (parsed.message.type === "ping") {
2457
2364
  tunnel.socket.send?.(JSON.stringify(makeTunnelPong(parsed.message)));
@@ -2483,7 +2390,7 @@ function createWorkspaceRelayDurableObjectRoom(options) {
2483
2390
  if (!pending) return;
2484
2391
  const chunk = base64ToBytes(message.body_base64);
2485
2392
  if (pending.bytes + chunk.byteLength > (options.tunnelResponseBodyMaxBytes ?? TUNNEL_RESPONSE_BODY_MAX_BYTES_DEFAULT)) {
2486
- failPendingTunnelResponse(tunnel, message.request_id, pending, new Error("User-hosted response body exceeds the relay limit"));
2393
+ failPendingTunnelResponse(tunnel, message.request_id, pending, hostTunnelFailure("host_tunnel_response_body_too_large"));
2487
2394
  return;
2488
2395
  }
2489
2396
  pending.bytes += chunk.byteLength;
@@ -2529,7 +2436,7 @@ function createWorkspaceRelayDurableObjectRoom(options) {
2529
2436
  return;
2530
2437
  }
2531
2438
  if (!sendSocket(channel.socket, tunnelFrame(message))) {
2532
- dropUserHostedChannel(tunnel, message.channel_id, channel, 1013, "Client WebSocket delivery failed");
2439
+ dropHostTunnelChannel(tunnel, message.channel_id, channel, 1013, "Client WebSocket delivery failed");
2533
2440
  }
2534
2441
  return;
2535
2442
  }
@@ -2567,7 +2474,7 @@ function createWorkspaceRelayDurableObjectRoom(options) {
2567
2474
  hostTunnels.delete(hostId);
2568
2475
  directory.disconnectHost(hostId);
2569
2476
  };
2570
- const cleanupUserHostedClient = (channelId, socket, event) => {
2477
+ const cleanupHostTunnelClient = (channelId, socket, event) => {
2571
2478
  for (const tunnel of hostTunnels.values()) {
2572
2479
  const active = tunnel.channels.get(channelId);
2573
2480
  if (!active || active.socket !== socket) continue;
@@ -2585,7 +2492,7 @@ function createWorkspaceRelayDurableObjectRoom(options) {
2585
2492
  };
2586
2493
  const admitHostTunnel = async (request, roomWorkspaceId, url) => {
2587
2494
  if (!websocketRequest(request)) return json("websocket_upgrade_required", "Workspace Relay requires a WebSocket upgrade", 426);
2588
- const hostId = trimToUndefined(hostIdFromTunnelPath(url.pathname));
2495
+ const hostId = trimToUndefined4(hostIdFromTunnelPath(url.pathname));
2589
2496
  if (!hostId) return json("host_tunnel_host_required", "Host tunnel id is required", 400);
2590
2497
  const workspaceIds = workspaceIdsFromSearch(url);
2591
2498
  if (!workspaceIds.includes(roomWorkspaceId)) {
@@ -2610,8 +2517,8 @@ function createWorkspaceRelayDurableObjectRoom(options) {
2610
2517
  }
2611
2518
  const connectedAt = options.now?.() ?? Date.now();
2612
2519
  const fence = {
2613
- ...claims.enrollment_id ? { enrollmentId: claims.enrollment_id } : {},
2614
- ...claims.generation !== void 0 ? { generation: claims.generation } : {}
2520
+ enrollmentId: claims.enrollment_id,
2521
+ generation: claims.generation
2615
2522
  };
2616
2523
  const pair = acceptSocket({ kind: "host-tunnel", hostId, workspaceIds, connectedAt, ...fence });
2617
2524
  const tunnel = {
@@ -2635,10 +2542,10 @@ function createWorkspaceRelayDurableObjectRoom(options) {
2635
2542
  if (!hibernation) {
2636
2543
  pair.server.addEventListener?.("message", (event) => {
2637
2544
  if (!event || event.data === void 0) return;
2638
- void handleTunnelMessage(hostId, { data: event.data }).catch((err) => reportFrameHandlerFailure(pair.server, err));
2545
+ void handleTunnelMessage(hostId, pair.server, { data: event.data }).catch((err) => reportFrameHandlerFailure(pair.server, err));
2639
2546
  });
2640
- pair.server.addEventListener?.("close", () => cleanupHostTunnel(hostId, pair.server, "User-hosted tunnel disconnected"));
2641
- pair.server.addEventListener?.("error", () => cleanupHostTunnel(hostId, pair.server, "User-hosted tunnel disconnected"));
2547
+ pair.server.addEventListener?.("close", () => cleanupHostTunnel(hostId, pair.server, "Host tunnel disconnected"));
2548
+ pair.server.addEventListener?.("error", () => cleanupHostTunnel(hostId, pair.server, "Host tunnel disconnected"));
2642
2549
  }
2643
2550
  return upgradeResponse(pair.client, request);
2644
2551
  };
@@ -2646,7 +2553,12 @@ function createWorkspaceRelayDurableObjectRoom(options) {
2646
2553
  if (!websocketRequest(request)) return json("websocket_upgrade_required", "Workspace Relay requires a WebSocket upgrade", 426);
2647
2554
  const authorized = await authorizeWorkspaceRelayRequest(relayOptions, request, workspaceId);
2648
2555
  if (!authorized.ok) return authorized.response;
2649
- if (isUserHostedTarget(authorized.request.target)) return admitUserHostedClient(request, authorized.request);
2556
+ const originDenied = workspaceUpgradeOriginDenied(request);
2557
+ if (originDenied) return originDenied;
2558
+ if (isHostTunnelTarget(authorized.request.target)) return admitHostTunnelClient(request, authorized.request);
2559
+ if (isSessionHostTarget(authorized.request.target)) {
2560
+ return json("durable_object_websocket_unsupported", "A session host serves its events over HTTP streams, not WebSockets", 400);
2561
+ }
2650
2562
  return await admitCloudClient(request, authorized.request);
2651
2563
  };
2652
2564
  const admitCloudClient = async (request, authorized) => {
@@ -2664,7 +2576,7 @@ function createWorkspaceRelayDurableObjectRoom(options) {
2664
2576
  new Headers(),
2665
2577
  authorized.relayHostToken,
2666
2578
  authorized.target.workspaceId,
2667
- { userHosted: false, upstreamHeaders: authorized.target.upstreamHeaders }
2579
+ { hostTunnel: false }
2668
2580
  ))
2669
2581
  }
2670
2582
  );
@@ -2684,9 +2596,9 @@ function createWorkspaceRelayDurableObjectRoom(options) {
2684
2596
  trace.emitted = true;
2685
2597
  sendSocket(pair.server, JSON.stringify({
2686
2598
  type: "relay.trace",
2687
- wsUpstreamOpenMs: roundedMs2(openedAt - trace.upstreamStartedAt),
2599
+ wsUpstreamOpenMs: roundedMs(openedAt - trace.upstreamStartedAt),
2688
2600
  queuedFrames: trace.queueTimes.length,
2689
- maxQueuedDelayMs: roundedMs2(trace.queueTimes.reduce((max, at) => Math.max(max, openedAt - at), 0))
2601
+ maxQueuedDelayMs: roundedMs(trace.queueTimes.reduce((max, at) => Math.max(max, openedAt - at), 0))
2690
2602
  }));
2691
2603
  };
2692
2604
  const activeTokenTimer = watchRuntimeAccessToken(client, (reason) => {
@@ -2720,7 +2632,7 @@ function createWorkspaceRelayDurableObjectRoom(options) {
2720
2632
  const deliverClientFrame = orderedFrameDelivery((payload) => {
2721
2633
  if (!upstreamOpen) {
2722
2634
  const frameBytes = preOpenFrameBytes(payload);
2723
- if (queue.length >= (options.upstreamWebSocketPreOpenQueueMaxFrames ?? UPSTREAM_WS_PRE_OPEN_QUEUE_MAX_FRAMES_DEFAULT) && queuedBytes + frameBytes > (options.upstreamWebSocketPreOpenQueueMaxBytes ?? UPSTREAM_WS_PRE_OPEN_QUEUE_MAX_BYTES_DEFAULT)) {
2635
+ if (queue.length >= (options.upstreamWebSocketPreOpenQueueMaxFrames ?? UPSTREAM_WS_PRE_OPEN_QUEUE_MAX_FRAMES_DEFAULT) || queuedBytes + frameBytes > (options.upstreamWebSocketPreOpenQueueMaxBytes ?? UPSTREAM_WS_PRE_OPEN_QUEUE_MAX_BYTES_DEFAULT)) {
2724
2636
  closeSocket(pair.server, 1011, "Upstream WebSocket queue limit exceeded");
2725
2637
  cleanupClient({ code: 1011, reason: "Upstream WebSocket queue limit exceeded" });
2726
2638
  return;
@@ -2747,17 +2659,17 @@ function createWorkspaceRelayDurableObjectRoom(options) {
2747
2659
  if (trace && upstreamOpen) emitTrace(upstreamConnectedAt);
2748
2660
  return upgradeResponse(pair.client, request);
2749
2661
  };
2750
- const admitUserHostedClient = (request, authorized) => {
2662
+ const admitHostTunnelClient = (request, authorized) => {
2751
2663
  const tunnel = hostTunnels.get(authorized.target.hostId);
2752
2664
  if (!tunnel || !directory.activeHost({ hostId: authorized.target.hostId, workspaceId: authorized.target.workspaceId })) {
2753
- return json("user_hosted_app_offline", "User-hosted workspace is offline", 503);
2665
+ return json("host_tunnel_offline", "The machine serving this workspace is offline", 503);
2754
2666
  }
2755
2667
  if (tunnel.channels.size >= (options.tunnelChannelCap ?? TUNNEL_CHANNEL_CAP_DEFAULT)) {
2756
- return json("too_many_channels", "User-hosted tunnel has too many active WebSocket channels", 503);
2668
+ return json("too_many_channels", "Host tunnel has too many active WebSocket channels", 503);
2757
2669
  }
2758
2670
  const channelId = randomToken();
2759
2671
  const pair = acceptSocket({
2760
- kind: "user-hosted-client",
2672
+ kind: "host-tunnel-client",
2761
2673
  channelId,
2762
2674
  claims: authorized.claims,
2763
2675
  target: authorized.target,
@@ -2774,7 +2686,7 @@ function createWorkspaceRelayDurableObjectRoom(options) {
2774
2686
  const activeTokenTimer = hibernation ? void 0 : watchRuntimeAccessToken(client, (reason) => {
2775
2687
  closeSocket(pair.server, 1008, reason);
2776
2688
  });
2777
- const activeTargetTimer = hibernation ? void 0 : watchUserHostedTarget(client, (reason) => {
2689
+ const activeTargetTimer = hibernation ? void 0 : watchHostTunnelTarget(client, (reason) => {
2778
2690
  closeSocket(pair.server, 1011, reason);
2779
2691
  });
2780
2692
  if (hibernation) void scheduleHibernatedRevocationCheck();
@@ -2782,11 +2694,11 @@ function createWorkspaceRelayDurableObjectRoom(options) {
2782
2694
  const cleanup = (event) => {
2783
2695
  if (activeTokenTimer) clearInterval(activeTokenTimer);
2784
2696
  if (activeTargetTimer) clearInterval(activeTargetTimer);
2785
- cleanupUserHostedClient(channelId, pair.server, event);
2697
+ cleanupHostTunnelClient(channelId, pair.server, event);
2786
2698
  };
2787
2699
  pair.server.addEventListener?.("message", (event) => {
2788
2700
  if (!event) return;
2789
- void handleUserHostedClientMessage(channelId, pair.server, event.data).catch((err) => reportFrameHandlerFailure(pair.server, err));
2701
+ void handleHostTunnelClientMessage(channelId, pair.server, event.data).catch((err) => reportFrameHandlerFailure(pair.server, err));
2790
2702
  });
2791
2703
  pair.server.addEventListener?.("close", cleanup);
2792
2704
  pair.server.addEventListener?.("error", cleanup);
@@ -2801,14 +2713,14 @@ function createWorkspaceRelayDurableObjectRoom(options) {
2801
2713
  new Headers(),
2802
2714
  authorized.relayHostToken,
2803
2715
  authorized.target.workspaceId,
2804
- { userHosted: true }
2716
+ { hostTunnel: true }
2805
2717
  ))
2806
2718
  }));
2807
2719
  if (!opened) {
2808
2720
  tunnel.channels.delete(channelId);
2809
2721
  clients.delete(client);
2810
- closeSocket(pair.server, 1011, "User-hosted tunnel unavailable");
2811
- return json("user_hosted_app_offline", "User-hosted workspace is offline", 503);
2722
+ closeSocket(pair.server, 1011, "Host tunnel unavailable");
2723
+ return json("host_tunnel_offline", "The machine serving this workspace is offline", 503);
2812
2724
  }
2813
2725
  return upgradeResponse(pair.client, request);
2814
2726
  };
@@ -2821,9 +2733,13 @@ function createWorkspaceRelayDurableObjectRoom(options) {
2821
2733
  })
2822
2734
  );
2823
2735
  if (!authorized.ok) return authorized.response;
2824
- if (isUserHostedTarget(authorized.request.target)) {
2825
- trace && (trace.routeKind = "user-hosted-http");
2826
- return forwardUserHostedHttp(request, authorized.request, trace);
2736
+ if (isHostTunnelTarget(authorized.request.target)) {
2737
+ trace && (trace.routeKind = "host-tunnel-http");
2738
+ return forwardHostTunnelHttp(request, authorized.request, trace);
2739
+ }
2740
+ if (isSessionHostTarget(authorized.request.target)) {
2741
+ trace && (trace.routeKind = "session-host-http");
2742
+ return traceSpan(trace, "session-host-fetch", () => forwardSessionHostHttp(options.sessionHost, request, authorized.request));
2827
2743
  }
2828
2744
  trace && (trace.routeKind = "cloud-http");
2829
2745
  const controller = new AbortController();
@@ -2836,8 +2752,7 @@ function createWorkspaceRelayDurableObjectRoom(options) {
2836
2752
  new URL(request.url).search
2837
2753
  ),
2838
2754
  workspaceRelayForwardRequestInit(request, authorized.request.relayHostToken, workspaceId, {
2839
- signal: controller.signal,
2840
- upstreamHeaders: authorized.request.target.upstreamHeaders
2755
+ signal: controller.signal
2841
2756
  })
2842
2757
  ));
2843
2758
  return new Response(upstream.body, {
@@ -2858,27 +2773,27 @@ function createWorkspaceRelayDurableObjectRoom(options) {
2858
2773
  clearTimeout(timer);
2859
2774
  }
2860
2775
  };
2861
- const forwardUserHostedHttp = async (request, authorized, trace) => {
2776
+ const forwardHostTunnelHttp = async (request, authorized, trace) => {
2862
2777
  const tunnel = hostTunnels.get(authorized.target.hostId);
2863
2778
  if (!tunnel || !directory.activeHost({ hostId: authorized.target.hostId, workspaceId: authorized.target.workspaceId })) {
2864
- return json("user_hosted_app_offline", "User-hosted workspace is offline", 503);
2779
+ return json("host_tunnel_offline", "The machine serving this workspace is offline", 503);
2865
2780
  }
2866
2781
  const awaitingStart = [...tunnel.pending.values()].filter((entry) => entry.startedSeq === void 0).length;
2867
2782
  if (awaitingStart >= (options.tunnelPendingHttpCap ?? TUNNEL_PENDING_HTTP_CAP_DEFAULT)) {
2868
- return json("too_many_pending_requests", "User-hosted tunnel has too many pending HTTP requests", 503);
2783
+ return json("too_many_pending_requests", "Host tunnel has too many pending HTTP requests", 503);
2869
2784
  }
2870
2785
  const requestId = randomToken();
2871
2786
  const init = workspaceRelayForwardRequestInit(request, authorized.relayHostToken, authorized.target.workspaceId, {
2872
- userHosted: true
2787
+ hostTunnel: true
2873
2788
  });
2874
2789
  const bodyMaxBytes = options.tunnelRequestBodyMaxBytes ?? TUNNEL_REQUEST_BODY_MAX_BYTES_DEFAULT;
2875
2790
  const contentLength = Number(request.headers.get("content-length") ?? Number.NaN);
2876
2791
  if (Number.isFinite(contentLength) && contentLength > bodyMaxBytes) {
2877
- return json("request_body_too_large", "User-hosted request body exceeds the relay limit", 413);
2792
+ return json("request_body_too_large", "Host tunnel request body exceeds the relay limit", 413);
2878
2793
  }
2879
2794
  const bodyBytes = request.method === "GET" || request.method === "HEAD" ? void 0 : await traceSpan(trace, "request-body", () => readRequestBodyCapped(request, bodyMaxBytes));
2880
2795
  if (bodyBytes === "too_large") {
2881
- return json("request_body_too_large", "User-hosted request body exceeds the relay limit", 413);
2796
+ return json("request_body_too_large", "Host tunnel request body exceeds the relay limit", 413);
2882
2797
  }
2883
2798
  const body = bodyBytes ? bytesToBase64(bodyBytes) : void 0;
2884
2799
  const pending = new Promise((resolve, reject) => {
@@ -2891,7 +2806,7 @@ function createWorkspaceRelayDurableObjectRoom(options) {
2891
2806
  paused: false,
2892
2807
  reason: "closed"
2893
2808
  }));
2894
- reject(new Error("User-hosted tunnel timed out"));
2809
+ reject(hostTunnelFailure("host_tunnel_timeout"));
2895
2810
  }, options.forwardTimeoutMs ?? 3e4);
2896
2811
  tunnel.pending.set(requestId, {
2897
2812
  chunks: [],
@@ -2915,15 +2830,7 @@ function createWorkspaceRelayDurableObjectRoom(options) {
2915
2830
  try {
2916
2831
  return await traceSpan(trace, "tunnel-wait", () => pending);
2917
2832
  } catch (err) {
2918
- const timeout = err instanceof Error && err.message.includes("timed out");
2919
- const responseTooLarge = err instanceof Error && err.message.includes("response body exceeds");
2920
- return Response.json(
2921
- errorBody(
2922
- timeout ? "user_hosted_tunnel_timeout" : responseTooLarge ? "user_hosted_response_body_too_large" : "user_hosted_tunnel_unavailable",
2923
- timeout ? "User-hosted tunnel timed out" : responseTooLarge ? "User-hosted response body exceeds the relay limit" : "User-hosted tunnel is unavailable"
2924
- ),
2925
- { status: responseTooLarge ? 413 : 503 }
2926
- );
2833
+ return hostTunnelFailureResponse(err);
2927
2834
  }
2928
2835
  };
2929
2836
  return {
@@ -2958,7 +2865,7 @@ function createWorkspaceRelayDurableObjectRoom(options) {
2958
2865
  trace.phases.push({ name: "relay-room-total", ms: performance.now() - roomTotalStartedAt });
2959
2866
  const tracedResponse = withTraceHeaders(response, trace);
2960
2867
  logTrace(options, nextRequest, roomWorkspaceId, trace, tracedResponse);
2961
- return withCors(request, tracedResponse);
2868
+ return withRelayResponseHeaders(request, tracedResponse);
2962
2869
  }
2963
2870
  return json("relay_route_not_found", "Workspace Relay route not found", 404);
2964
2871
  },
@@ -2968,10 +2875,10 @@ function createWorkspaceRelayDurableObjectRoom(options) {
2968
2875
  const attachment = socketAttachment(socket);
2969
2876
  if (!attachment) return;
2970
2877
  if (attachment.kind === "host-tunnel") {
2971
- await handleTunnelMessage(attachment.hostId, { data: message });
2878
+ await handleTunnelMessage(attachment.hostId, socket, { data: message });
2972
2879
  return;
2973
2880
  }
2974
- await handleUserHostedClientMessage(attachment.channelId, socket, message);
2881
+ await handleHostTunnelClientMessage(attachment.channelId, socket, message);
2975
2882
  } catch (err) {
2976
2883
  reportFrameHandlerFailure(socket, err);
2977
2884
  }
@@ -2992,20 +2899,20 @@ function createWorkspaceRelayDurableObjectRoom(options) {
2992
2899
  const attachment = socketAttachment(socket);
2993
2900
  if (!attachment) return;
2994
2901
  if (attachment.kind === "host-tunnel") {
2995
- cleanupHostTunnel(attachment.hostId, socket, "User-hosted tunnel disconnected");
2902
+ cleanupHostTunnel(attachment.hostId, socket, "Host tunnel disconnected");
2996
2903
  return;
2997
2904
  }
2998
- cleanupUserHostedClient(attachment.channelId, socket, { code, reason });
2905
+ cleanupHostTunnelClient(attachment.channelId, socket, { code, reason });
2999
2906
  },
3000
2907
  webSocketError(socket) {
3001
2908
  ensureHibernatedSocketsRebuilt();
3002
2909
  const attachment = socketAttachment(socket);
3003
2910
  if (!attachment) return;
3004
2911
  if (attachment.kind === "host-tunnel") {
3005
- cleanupHostTunnel(attachment.hostId, socket, "User-hosted tunnel disconnected");
2912
+ cleanupHostTunnel(attachment.hostId, socket, "Host tunnel disconnected");
3006
2913
  return;
3007
2914
  }
3008
- cleanupUserHostedClient(attachment.channelId, socket, { code: 1011, reason: "WebSocket error" });
2915
+ cleanupHostTunnelClient(attachment.channelId, socket, { code: 1011, reason: "WebSocket error" });
3009
2916
  },
3010
2917
  state() {
3011
2918
  ensureHibernatedSocketsRebuilt();
@@ -3019,63 +2926,222 @@ function createWorkspaceRelayDurableObjectRoom(options) {
3019
2926
  drain: drainController
3020
2927
  };
3021
2928
  }
2929
+
2930
+ // ../claxedo-helpers/dist/deadline.mjs
2931
+ async function withDeadlineSignal(timeoutMs, work, parent) {
2932
+ const deadline = new AbortController();
2933
+ const timer = setTimeout(() => deadline.abort(new DOMException("The operation timed out.", "TimeoutError")), timeoutMs);
2934
+ try {
2935
+ return await work(parent ? AbortSignal.any([parent, deadline.signal]) : deadline.signal);
2936
+ } finally {
2937
+ clearTimeout(timer);
2938
+ }
2939
+ }
2940
+
2941
+ // src/resolver-clients.ts
2942
+ function parseRuntimeAccessTokenActiveResult(input) {
2943
+ if (!isRecord(input)) return void 0;
2944
+ const row = input;
2945
+ if (row.active === true) return { active: true };
2946
+ if (row.active !== false) return void 0;
2947
+ if (typeof row.code !== "string" || typeof row.reason !== "string") return void 0;
2948
+ return { active: false, code: row.code, reason: row.reason };
2949
+ }
2950
+ function parseHostGenerationResult(input) {
2951
+ if (!isRecord(input)) return void 0;
2952
+ const row = input;
2953
+ if (typeof row.enrollmentId !== "string" || !row.enrollmentId.trim()) return void 0;
2954
+ if (!isHostGeneration(row.generation) || typeof row.revoked !== "boolean") return void 0;
2955
+ return { enrollmentId: row.enrollmentId, generation: row.generation, revoked: row.revoked };
2956
+ }
2957
+ var HOST_GENERATION_LOOKUP_TIMEOUT_MS_DEFAULT = 5e3;
2958
+ var HOST_GENERATION_ENROLLMENT_NOT_FOUND_CODE = "relay_resolver_enrollment_not_found";
2959
+ function createHostGenerationResolverLookup(url, options) {
2960
+ const fetcher = options.fetch ?? ((target, init) => fetch(target, init));
2961
+ const timeoutMs = options.timeoutMs ?? HOST_GENERATION_LOOKUP_TIMEOUT_MS_DEFAULT;
2962
+ return async ({ enrollmentId }) => {
2963
+ const target = new URL(url);
2964
+ target.searchParams.set("enrollmentId", enrollmentId);
2965
+ return withDeadlineSignal(timeoutMs, async (signal) => {
2966
+ const res = await fetcher(target, { headers: options.headers, signal });
2967
+ if (res.status === 404) {
2968
+ const body = await res.json().catch(() => void 0);
2969
+ const error = isRecord(body) && isRecord(body.error) ? body.error : void 0;
2970
+ if (error?.code === HOST_GENERATION_ENROLLMENT_NOT_FOUND_CODE) return void 0;
2971
+ throw new Error(`relay host-generation resolver failed: 404 (no host-generation route at ${url})`);
2972
+ }
2973
+ if (!res.ok) throw new Error(`relay host-generation resolver failed: ${res.status} ${await res.text()}`);
2974
+ const result = parseHostGenerationResult(await res.json());
2975
+ if (!result) throw new Error("relay host-generation resolver returned a malformed result");
2976
+ return result;
2977
+ });
2978
+ };
2979
+ }
2980
+ var RESOLVER_CACHE_MAX_ENTRIES = 8192;
2981
+ var REVOCATION_CACHE_TTL_MS_DEFAULT = 1e4;
2982
+ var TARGET_CACHE_TTL_MS_DEFAULT = 1e4;
2983
+ var DURABLE_OBJECT_TARGET_CACHE_TTL_MS_DEFAULT = 30 * 6e4;
2984
+ function pruneExpiringCache(cache, now, maxEntries) {
2985
+ for (const [key, entry] of cache) {
2986
+ if (entry.expiresAt <= now) cache.delete(key);
2987
+ }
2988
+ while (cache.size >= maxEntries) {
2989
+ const key = cache.keys().next().value;
2990
+ if (!key) return;
2991
+ cache.delete(key);
2992
+ }
2993
+ }
2994
+ function createCachedRevocationClient(inner, options = {}) {
2995
+ const ttlMs = options.ttlMs ?? REVOCATION_CACHE_TTL_MS_DEFAULT;
2996
+ const now = options.now ?? Date.now;
2997
+ const cache = /* @__PURE__ */ new Map();
2998
+ return async (args) => {
2999
+ const at = now();
3000
+ const entry = cache.get(args.jti);
3001
+ if (entry && entry.expiresAt > at) {
3002
+ if (entry.result) return entry.result;
3003
+ if (entry.promise) return await entry.promise;
3004
+ }
3005
+ const promise = inner(args).then((result) => {
3006
+ pruneExpiringCache(cache, now(), RESOLVER_CACHE_MAX_ENTRIES);
3007
+ cache.set(args.jti, { result, expiresAt: now() + ttlMs });
3008
+ return result;
3009
+ }).catch((err) => {
3010
+ cache.delete(args.jti);
3011
+ throw err;
3012
+ });
3013
+ cache.set(args.jti, { promise, expiresAt: at + ttlMs });
3014
+ return await promise;
3015
+ };
3016
+ }
3017
+ function runtimeAccessTokenRevocationDelayMs(input = {}) {
3018
+ const configuredTtl = input.revocationCacheTtlMs ?? REVOCATION_CACHE_TTL_MS_DEFAULT;
3019
+ const ttlMs = Number.isFinite(configuredTtl) && configuredTtl > 0 ? configuredTtl : 0;
3020
+ if (input.activeCheckIntervalMs === void 0) return ttlMs;
3021
+ if (!Number.isFinite(input.activeCheckIntervalMs) || input.activeCheckIntervalMs <= 0) {
3022
+ return Number.POSITIVE_INFINITY;
3023
+ }
3024
+ return ttlMs + input.activeCheckIntervalMs;
3025
+ }
3026
+ function createCachedHostGenerationClient(inner, options = {}) {
3027
+ const ttlMs = options.ttlMs ?? 1e4;
3028
+ const now = options.now ?? Date.now;
3029
+ const cache = /* @__PURE__ */ new Map();
3030
+ return async (args) => {
3031
+ for (; ; ) {
3032
+ const at = now();
3033
+ const entry = cache.get(args.enrollmentId);
3034
+ if (entry && entry.expiresAt > at) {
3035
+ if (entry.result && entry.result.generation >= args.generation) return entry.result;
3036
+ if (!entry.result && entry.promise) {
3037
+ const shared = await entry.promise;
3038
+ if (!shared || shared.generation >= args.generation) return shared;
3039
+ continue;
3040
+ }
3041
+ }
3042
+ const promise = inner(args).then((result) => {
3043
+ pruneExpiringCache(cache, now(), RESOLVER_CACHE_MAX_ENTRIES);
3044
+ if (result) cache.set(args.enrollmentId, { result, expiresAt: now() + ttlMs });
3045
+ else cache.delete(args.enrollmentId);
3046
+ return result;
3047
+ }).catch((err) => {
3048
+ cache.delete(args.enrollmentId);
3049
+ throw err;
3050
+ });
3051
+ cache.set(args.enrollmentId, { promise, expiresAt: at + ttlMs });
3052
+ return await promise;
3053
+ }
3054
+ };
3055
+ }
3056
+ function createCachedTargetClient(inner, options = {}) {
3057
+ const ttlMs = options.ttlMs ?? TARGET_CACHE_TTL_MS_DEFAULT;
3058
+ const durableObjectTtlMs = options.durableObjectTtlMs ?? DURABLE_OBJECT_TARGET_CACHE_TTL_MS_DEFAULT;
3059
+ const now = options.now ?? Date.now;
3060
+ const cache = /* @__PURE__ */ new Map();
3061
+ return async (args) => {
3062
+ const key = `${args.workspaceId}\0${args.hostId}\0${args.routingId ?? ""}`;
3063
+ const at = now();
3064
+ const entry = cache.get(key);
3065
+ if (entry && entry.expiresAt > at) {
3066
+ if (entry.target) return entry.target;
3067
+ if (entry.promise) return await entry.promise;
3068
+ }
3069
+ const promise = inner(args).then((target) => {
3070
+ pruneExpiringCache(cache, now(), RESOLVER_CACHE_MAX_ENTRIES);
3071
+ if (target) cache.set(key, { target, expiresAt: now() + (target.backing === "durable-object" ? durableObjectTtlMs : ttlMs) });
3072
+ else cache.delete(key);
3073
+ return target;
3074
+ }).catch((err) => {
3075
+ cache.delete(key);
3076
+ throw err;
3077
+ });
3078
+ cache.set(key, { promise, expiresAt: at + ttlMs });
3079
+ return await promise;
3080
+ };
3081
+ }
3022
3082
  export {
3023
3083
  DEFAULT_RELAY_APP_ORIGINS,
3024
3084
  DEFAULT_RELAY_LOCATION_HINT,
3025
3085
  RELAY_ALLOWED_REQUEST_HEADERS,
3026
- RELAY_ALLOWED_REQUEST_HEADER_LIST,
3027
3086
  RELAY_LOCATION_HINTS,
3028
3087
  RELAY_REGION_HEADER,
3088
+ RUNTIME_ACCESS_TOKEN_ACTIVE_CHECK_INTERVAL_MS_DEFAULT,
3089
+ RUNTIME_ACCESS_TOKEN_ISSUED_AT_FLOOR_SECONDS,
3090
+ SESSION_HOST_LOCATION_HINT,
3029
3091
  WorkspaceRelayAuthError,
3030
3092
  authorizeWorkspaceRelayRequest,
3031
3093
  bytesToBase64,
3032
3094
  checkHostTunnelGeneration,
3095
+ corsPreflight,
3033
3096
  createCachedHostGenerationClient,
3034
3097
  createCachedRevocationClient,
3035
3098
  createCachedTargetClient,
3036
3099
  createHostGenerationResolverLookup,
3037
3100
  createOriginMatcher,
3038
- createWorkspaceRelay,
3039
3101
  createWorkspaceRelayDirectory,
3040
3102
  createWorkspaceRelayDurableObjectGateway,
3041
3103
  createWorkspaceRelayDurableObjectRoom,
3042
- createWorkspaceRelayTrace,
3043
3104
  deriveRelayHostKid,
3044
3105
  deriveRelayHostPublicKey,
3045
- disposeAuditSampler,
3046
- disposeWorkspaceRelayDirectory,
3047
- forwardWorkspaceRelayRequest,
3048
- getAuditSuppressedCount,
3049
3106
  hostTunnelIncumbentOutranks,
3050
3107
  hostTunnelTokenAudience,
3108
+ isAllowedRelayTargetBaseUrl,
3051
3109
  isHostGeneration,
3052
- isRelayClaimPair,
3110
+ isRelayBacking,
3053
3111
  mintHostTunnelToken,
3054
3112
  mintRelayHostToken,
3055
3113
  mintRuntimeAccessToken,
3056
3114
  parseAllowedOrigins,
3057
- parseHostGenerationResult,
3058
3115
  parseRuntimeAccessTokenActiveResult,
3059
3116
  parseWorkspaceRelayTarget,
3117
+ pruneExpiringCache,
3060
3118
  relayFrameDiagnostics,
3119
+ relayHostInputFromRuntimeClaims,
3061
3120
  relayHostTokenAudience,
3062
3121
  relayHostTokenIssuer,
3063
3122
  relayLocationHint,
3064
3123
  relayRequestCountry,
3065
3124
  relayRequestRegion,
3125
+ relayRoomPlacement,
3066
3126
  runtimeAccessTokenAudience,
3067
3127
  runtimeAccessTokenIssuer,
3128
+ runtimeAccessTokenRevocationDelayMs,
3068
3129
  setWorkspaceRelayAllowedOrigins,
3069
3130
  setWorkspaceRelayAppOrigins,
3131
+ toChannelIdentityClaim,
3132
+ tokenPurposeClaims,
3133
+ tokenScopeClaims,
3134
+ tokenScopePayload,
3070
3135
  validateRuntimeAccessTokenClaims,
3071
3136
  verifyHostTunnelToken,
3072
3137
  verifyRelayHostToken,
3073
3138
  verifyRuntimeAccessToken,
3139
+ withRelayResponseHeaders,
3074
3140
  workspaceRelayDurableObjectRoomName,
3075
3141
  workspaceRelayDurableObjectWorkspaceId,
3076
3142
  workspaceRelayForwardHeaders,
3077
3143
  workspaceRelayForwardRequestInit,
3078
- workspaceRelayServerTiming,
3144
+ workspaceRelayRoomPlacement,
3079
3145
  workspaceRelayTargetUrl,
3080
- workspaceRelayTimingResponse
3146
+ workspaceUpgradeOriginDenied
3081
3147
  };