@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/README.md +92 -223
- package/dist/auth.d.ts +94 -35
- package/dist/cloudflare.d.ts +15 -45
- package/dist/cors-origins.d.ts +2 -0
- package/dist/directory.d.ts +5 -38
- package/dist/gateway-placement.d.ts +7 -0
- package/dist/host-tunnel-failure.d.ts +7 -0
- package/dist/host-tunnel-forwarding.d.ts +29 -0
- package/dist/http.d.ts +4 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.mjs +901 -835
- package/dist/relay-audit.d.ts +4 -0
- package/dist/relay-cors.d.ts +12 -0
- package/dist/relay-location.d.ts +93 -0
- package/dist/relay-target.d.ts +25 -0
- package/dist/resolver-clients.d.ts +116 -0
- package/dist/server.d.ts +27 -286
- package/dist/session-host-forwarding.d.ts +22 -0
- package/dist/test-support/unfenced-host-token.d.ts +6 -0
- package/dist/worker.d.ts +3 -3
- package/package.json +6 -25
- package/dist/bun.d.ts +0 -225
- package/dist/bun.mjs +0 -2492
- package/dist/main.d.ts +0 -255
- package/dist/main.mjs +0 -23713
- package/dist/synthetic.d.ts +0 -60
- package/dist/upstream-websocket.d.ts +0 -34
- package/dist/user-hosted-forwarding.d.ts +0 -40
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
|
|
84
|
-
return input.
|
|
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 (
|
|
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
|
-
|
|
186
|
-
|
|
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 (!
|
|
203
|
-
throw new WorkspaceRelayAuthError("relay_token_claims_invalid", "Relay Host Token
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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/
|
|
372
|
-
function
|
|
373
|
-
return target.
|
|
560
|
+
// src/host-tunnel-forwarding.ts
|
|
561
|
+
function isHostTunnelTarget(target) {
|
|
562
|
+
return target.backing === "local-worktree";
|
|
374
563
|
}
|
|
375
564
|
|
|
376
|
-
// src/
|
|
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
|
|
569
|
+
const { workspaceId, hostId, baseUrl } = row;
|
|
381
570
|
if (typeof workspaceId !== "string" || typeof hostId !== "string" || typeof baseUrl !== "string") return void 0;
|
|
382
|
-
if (
|
|
383
|
-
|
|
384
|
-
if (!
|
|
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
|
-
|
|
390
|
-
...pair
|
|
578
|
+
backing: row.backing
|
|
391
579
|
};
|
|
392
580
|
}
|
|
393
|
-
function
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
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
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
const
|
|
412
|
-
|
|
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
|
|
430
|
-
if (
|
|
431
|
-
|
|
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
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
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
|
|
439
|
-
|
|
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
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
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
|
|
458
|
-
const
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
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
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
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
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
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 || !
|
|
1005
|
+
if (!path || !sessionScopeReaches(claims.session_id, path, new URL(request.url).search)) {
|
|
1030
1006
|
return {
|
|
1031
1007
|
ok: false,
|
|
1032
|
-
code: "
|
|
1008
|
+
code: "relay_scope_denied",
|
|
1033
1009
|
response: await deny(options, {
|
|
1034
|
-
code: "
|
|
1035
|
-
message: "
|
|
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
|
|
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 (
|
|
1032
|
+
if (isHostTunnelTarget(target) && !options.directory?.activeHost({ hostId: target.hostId, workspaceId: target.workspaceId })) {
|
|
1057
1033
|
return {
|
|
1058
1034
|
ok: false,
|
|
1059
|
-
code: "
|
|
1035
|
+
code: "host_tunnel_offline",
|
|
1060
1036
|
response: await deny(options, {
|
|
1061
|
-
code: "
|
|
1062
|
-
message: "
|
|
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,
|
|
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
|
|
1113
|
+
recordPong(hostId) {
|
|
1368
1114
|
const timestamp = now();
|
|
1369
1115
|
let touched;
|
|
1370
|
-
for (const key of keysOf(hostId
|
|
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
|
|
1380
|
-
for (const key of keysOf(hostId
|
|
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
|
-
|
|
1403
|
-
|
|
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/
|
|
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 =
|
|
1450
|
-
const 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 =
|
|
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
|
|
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 =
|
|
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
|
|
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
|
|
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 =
|
|
1502
|
-
const forceSecret =
|
|
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(
|
|
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(
|
|
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
|
|
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=${
|
|
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:
|
|
1567
|
-
phases: Object.fromEntries(trace.phases.map((phase) => [phase.name,
|
|
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
|
-
|
|
1621
|
-
return
|
|
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 =
|
|
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
|
|
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 =
|
|
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/") ?
|
|
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
|
|
1900
|
-
|
|
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=${
|
|
1903
|
-
`relay-gateway-do-fetch;dur=${
|
|
1904
|
-
`relay-gateway-response;dur=${
|
|
1905
|
-
`relay-gateway;dur=${
|
|
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/") ?
|
|
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(
|
|
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
|
-
|
|
1994
|
-
|
|
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, "
|
|
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
|
|
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?.
|
|
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("
|
|
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
|
-
|
|
2109
|
-
|
|
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 (
|
|
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 ||
|
|
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
|
|
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?.
|
|
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
|
|
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 &&
|
|
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 (
|
|
2265
|
+
if (isHostTunnelClient(client)) {
|
|
2359
2266
|
for (const tunnel of hostTunnels.values()) {
|
|
2360
2267
|
if (tunnel.channels.get(client.channelId) !== client) continue;
|
|
2361
|
-
|
|
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
|
|
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
|
|
2396
|
-
const active =
|
|
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
|
|
2404
|
-
closeSocket(socket, 1011, "
|
|
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, "
|
|
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,
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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
|
-
|
|
2614
|
-
|
|
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, "
|
|
2641
|
-
pair.server.addEventListener?.("error", () => cleanupHostTunnel(hostId, pair.server, "
|
|
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
|
-
|
|
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
|
-
{
|
|
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:
|
|
2599
|
+
wsUpstreamOpenMs: roundedMs(openedAt - trace.upstreamStartedAt),
|
|
2688
2600
|
queuedFrames: trace.queueTimes.length,
|
|
2689
|
-
maxQueuedDelayMs:
|
|
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)
|
|
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
|
|
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("
|
|
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", "
|
|
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: "
|
|
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 :
|
|
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
|
-
|
|
2697
|
+
cleanupHostTunnelClient(channelId, pair.server, event);
|
|
2786
2698
|
};
|
|
2787
2699
|
pair.server.addEventListener?.("message", (event) => {
|
|
2788
2700
|
if (!event) return;
|
|
2789
|
-
void
|
|
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
|
-
{
|
|
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, "
|
|
2811
|
-
return json("
|
|
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 (
|
|
2825
|
-
trace && (trace.routeKind = "
|
|
2826
|
-
return
|
|
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
|
|
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("
|
|
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", "
|
|
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
|
-
|
|
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", "
|
|
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", "
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|
|
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, "
|
|
2902
|
+
cleanupHostTunnel(attachment.hostId, socket, "Host tunnel disconnected");
|
|
2996
2903
|
return;
|
|
2997
2904
|
}
|
|
2998
|
-
|
|
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, "
|
|
2912
|
+
cleanupHostTunnel(attachment.hostId, socket, "Host tunnel disconnected");
|
|
3006
2913
|
return;
|
|
3007
2914
|
}
|
|
3008
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3144
|
+
workspaceRelayRoomPlacement,
|
|
3079
3145
|
workspaceRelayTargetUrl,
|
|
3080
|
-
|
|
3146
|
+
workspaceUpgradeOriginDenied
|
|
3081
3147
|
};
|