1import * as HttpServer from "effect/http/HttpServer";
2import {
3 RelayClientAuth,
4 RelayClientPrincipal,
5 type RelayClientDeviceRecord,
6} from "@t3tools/contracts/relay";
7import * as EnvironmentLinker from "../environments/EnvironmentLinker.ts";
8import * as RelayTokens from "../auth/RelayTokens.ts";
9import * as Devices from "../agentActivity/Devices.ts";
10// @effect-diagnostics-next-line nodeBuiltinImport:off -- Effect's Crypto has no generateKeyPairSync.
11import * as NodeCrypto from "node:crypto";
12import { createClerkClient, verifyToken } from "@clerk/backend";
13import * as NodeHttpPlatform from "@effect/platform-node/NodeHttpPlatform";
14import * as NodeServices from "@effect/platform-node/NodeServices";
15import { describe, expect, it } from "@effect/vitest";
16import { vi } from "vite-plus/test";
17import * as Context from "effect/Context";
18import * as Data from "effect/Data";
19import * as NodeCryptoLayer from "@effect/platform-node/NodeCrypto";
20import * as DateTime from "effect/DateTime";
21import * as Deferred from "effect/Deferred";
22import * as Duration from "effect/Duration";
23import * as Effect from "effect/Effect";
24import * as Fiber from "effect/Fiber";
25import * as Layer from "effect/Layer";
26import * as Option from "effect/Option";
27import * as Predicate from "effect/Predicate";
28import * as Redacted from "effect/Redacted";
29import * as TestClock from "effect/testing/TestClock";
30import * as Tracer from "effect/Tracer";
31import * as Etag from "effect/http/Etag";
32import * as HttpEffect from "effect/http/HttpEffect";
33import * as HttpRouter from "effect/http/HttpRouter";
34import * as HttpMiddleware from "effect/http/HttpMiddleware";
35import * as HttpServerRequest from "effect/http/HttpServerRequest";
36import * as HttpServerResponse from "effect/http/HttpServerResponse";
37import * as HttpApi from "effect/http-api/HttpApi";
38import * as HttpApiBuilder from "effect/http-api/HttpApiBuilder";
39import { EnvironmentId } from "@t3tools/contracts";
40import {
41 RelayEnvironmentAuth,
42 RelayEnvironmentPrincipal,
43 RelayApi,
44} from "@t3tools/contracts/relay";
45import { RELAY_MANAGED_TUNNEL_RECOVERY_TYP, signRelayJwt } from "@t3tools/shared/relayJwt";
46
47import {
48 RELAY_HTTP_ROUTER_CONFIG,
49 RELAY_REQUEST_DEADLINE_MS,
50 recoverEnvironmentTunnelRecord,
51 registerEnvironmentTunnelRecovery,
52 relayDpopFailureReason,
53 revokeEnvironmentLinkRecord,
54 traceRelayHttpRequestWith,
55 unlinkEnvironmentRecord,
56 verifyRelayClientBearerToken,
57 verifyEnvironmentTunnelRecoveryProof,
58 withoutCapturedParentSpan,
59} from "./Api.ts";
60import * as RelayHttpApi from "./Api.ts";
61import * as RelayConfiguration from "../Config.ts";
62import * as RelayDb from "../db.ts";
63import * as EnvironmentCredentials from "../environments/EnvironmentCredentials.ts";
64import * as HeldHooks from "../hooks/HeldHooks.ts";
65import * as HookInbox from "../hooks/HookInbox.ts";
66import * as EnvironmentLinks from "../environments/EnvironmentLinks.ts";
67import * as ManagedEndpointAllocations from "../environments/ManagedEndpointAllocations.ts";
68import * as ManagedEndpointProvider from "../environments/ManagedEndpointProvider.ts";
69import * as AgentActivityPublisher from "../agentActivity/AgentActivityPublisher.ts";
70import * as EnvironmentPublishSignatures from "../environments/EnvironmentPublishSignatures.ts";
71
72vi.mock("@clerk/backend", () => ({
73 createClerkClient: vi.fn(),
74 verifyToken: vi.fn(),
75}));
76
77const relaySettings: RelayConfiguration.RelayConfiguration["Service"] = {
78 relayIssuer: "https://relay.example.test",
79 apns: {
80 teamId: "apns-team",
81 keyId: "apns-key",
82 privateKey: Redacted.make("apns-private-key"),
83 bundleId: "com.example.t3",
84 environment: "sandbox",
85 },
86 clerkSecretKey: Redacted.make("clerk-secret-key"),
87 clerkPublishableKey: "pk_test_test",
88 clerkJwtAudience: "t3-code-relay",
89 apnsDeliveryJobSigningSecret: Redacted.make("apns-delivery-secret"),
90 cloudMintPrivateKey: Redacted.make("cloud-mint-private-key"),
91 cloudMintPublicKey: "cloud-mint-public-key",
92 managedEndpointBaseDomain: undefined,
93 managedEndpointNamespace: undefined,
94};
95
96describe("device listing compatibility", () => {
97 it.effect("keeps v1 iOS-only while v2 returns every platform for the same account", () => {
98 const iphone: RelayClientDeviceRecord = {
99 deviceId: "iphone",
100 label: "iPhone",
101 platform: "ios",
102 iosMajorVersion: 18,
103 appVersion: "1.0.0",
104 notifications: {
105 enabled: true,
106 notifyOnApproval: true,
107 notifyOnInput: true,
108 notifyOnCompletion: true,
109 notifyOnFailure: true,
110 },
111 liveActivities: { enabled: true },
112 updatedAt: "2026-09-09T00:00:00.000Z",
113 };
114 const android: RelayClientDeviceRecord = {
115 ...iphone,
116 deviceId: "android",
117 label: "Android",
118 platform: "android",
119 iosMajorVersion: null,
120 androidApiLevel: 36,
121 };
122 const layerHandlers = RelayHttpApi.layerClientApi.pipe(
123 HttpRouter.provideRequest(
124 Layer.mergeAll(
125 Layer.mock(EnvironmentCredentials.EnvironmentCredentials, {}),
126 Layer.mock(EnvironmentLinks.EnvironmentLinks, {}),
127 Layer.mock(ManagedEndpointProvider.ManagedEndpointProvider, {}),
128 Layer.mock(RelayDb.RelayTransactions, {}),
129 Layer.mock(HookInbox.HookInbox, {}),
130 ),
131 ),
132 Layer.provide(
133 Layer.mergeAll(
134 Layer.succeed(RelayConfiguration.RelayConfiguration, relaySettings),
135 NodeCryptoLayer.layer,
136 Layer.mock(RelayTokens.RelayTokens, { resolveDpopAccessTokenScopes: () => null }),
137 Layer.mock(EnvironmentLinker.EnvironmentLinker, {}),
138 Layer.mock(EnvironmentLinks.EnvironmentLinks, {}),
139 Layer.mock(ManagedEndpointProvider.ManagedEndpointProvider, {}),
140 Layer.mock(Devices.Devices, {
141 listForUser: ({ userId }) => {
142 expect(userId).toBe("user-1");
143 return Effect.succeed([iphone, android]);
144 },
145 }),
146 ),
147 ),
148 Layer.provideMerge(
149 Layer.succeed(RelayClientAuth, {
150 clientBearer: (effect) =>
151 Effect.provideService(effect, RelayClientPrincipal, {
152 userId: "user-1",
153 token: "test-token",
154 }),
155 }),
156 ),
157 );
158 return Effect.gen(function* () {
159 const app = yield* Effect.acquireRelease(
160 Effect.sync(() =>
161 HttpRouter.toWebHandler(
162 HttpApiBuilder.layer(HttpApi.make("RelayApi").add(RelayApi.groups.client)).pipe(
163 Layer.provide(layerHandlers),
164 Layer.provide(HttpServer.layerServices),
165 ),
166 { disableLogger: true },
167 ),
168 ),
169 (app) => Effect.promise(() => app.dispose()),
170 );
171 for (const [version, devices] of [
172 ["v1", [iphone]],
173 ["v2", [iphone, android]],
174 ] as const) {
175 const response = yield* Effect.promise(() =>
176 app.handler(
177 new Request(`https://relay.example.test/${version}/client/devices`, {
178 headers: { authorization: "Bearer test-token" },
179 }),
180 ),
181 );
182 expect(response.status).toBe(200);
183 expect(response.headers.get("cache-control")).toBe("no-store");
184 expect(yield* Effect.promise(() => response.json())).toEqual({ devices });
185 }
186 }).pipe(Effect.scoped);
187 });
188});
189
190describe("relay client authentication", () => {
191 it.effect("preserves the existing Clerk session JWT path", () =>
192 Effect.gen(function* () {
193 vi.mocked(verifyToken).mockResolvedValue({
194 sub: "user_session",
195 aud: relaySettings.clerkJwtAudience,
196 } as never);
197
198 expect(yield* verifyRelayClientBearerToken(relaySettings, "session-token")).toEqual({
199 sub: "user_session",
200 mode: "clerk_session_bearer",
201 });
202 expect(verifyToken).toHaveBeenCalledWith("session-token", {
203 secretKey: "clerk-secret-key",
204 audience: relaySettings.clerkJwtAudience,
205 });
206 expect(createClerkClient).not.toHaveBeenCalled();
207 }).pipe(
208 Effect.ensuring(
209 Effect.sync(() => {
210 vi.mocked(verifyToken).mockReset();
211 vi.mocked(createClerkClient).mockReset();
212 }),
213 ),
214 ),
215 );
216
217 it.effect("falls back to Clerk OAuth token verification for the headless CLI", () =>
218 Effect.gen(function* () {
219 vi.mocked(verifyToken).mockRejectedValue(new Error("not a session JWT"));
220 vi.mocked(createClerkClient).mockReturnValue({
221 authenticateRequest: vi.fn().mockResolvedValue({
222 isAuthenticated: true,
223 toAuth: () => ({ userId: "user_oauth" }),
224 }),
225 } as never);
226
227 expect(yield* verifyRelayClientBearerToken(relaySettings, "oauth-token")).toEqual({
228 sub: "user_oauth",
229 mode: "clerk_oauth_bearer",
230 });
231 expect(createClerkClient).toHaveBeenCalledWith({
232 secretKey: "clerk-secret-key",
233 publishableKey: "pk_test_test",
234 });
235 }).pipe(
236 Effect.ensuring(
237 Effect.sync(() => {
238 vi.mocked(verifyToken).mockReset();
239 vi.mocked(createClerkClient).mockReset();
240 }),
241 ),
242 ),
243 );
244});
245
246describe("relay DPoP failure mapping", () => {
247 it("maps verifier failures to safe client-facing categories", () => {
248 const mappings = [
249 ["time_window", "time_window"],
250 ["key_mismatch", "key_mismatch"],
251 ["method_mismatch", "request_mismatch"],
252 ["url_mismatch", "request_mismatch"],
253 ["access_token_hash_mismatch", "token_mismatch"],
254 ["replayed", "replay"],
255 ["missing_proof", "invalid_proof"],
256 ["malformed_proof", "invalid_proof"],
257 ["invalid_signature", "invalid_proof"],
258 ["invalid_proof", "invalid_proof"],
259 ] as const;
260
261 for (const [code, expected] of mappings) {
262 expect(relayDpopFailureReason(code)).toBe(expected);
263 }
264 });
265});
266
267describe("relay environment authentication", () => {
268 it.effect("preserves credential lookup persistence failures as internal errors", () => {
269 const failure = new EnvironmentCredentials.EnvironmentCredentialAuthenticatePersistenceError({
270 stage: "lookup-credential",
271 cause: "database unavailable",
272 });
273 const credentials: EnvironmentCredentials.EnvironmentCredentials["Service"] = {
274 create: () => Effect.die("unused create"),
275 authenticate: () => Effect.fail(failure),
276 revokeForEnvironmentPublicKey: () => Effect.die("unused revoke"),
277 };
278
279 return Effect.gen(function* () {
280 const auth = yield* RelayEnvironmentAuth;
281 const error = yield* Effect.flip(
282 auth.environmentBearer(Effect.succeed(HttpServerResponse.empty()), {
283 credential: Redacted.make("environment-credential"),
284 endpoint: {} as never,
285 group: {} as never,
286 }),
287 );
288
289 expect(Predicate.isTagged(error, "RelayInternalError")).toBe(true);
290 if (Predicate.isTagged(error, "RelayInternalError")) {
291 expect(error.reason).toBe("persistence_failed");
292 }
293 }).pipe(
294 Effect.provideService(
295 HttpServerRequest.HttpServerRequest,
296 HttpServerRequest.fromWeb(new Request("https://relay.test/v1/server/link")),
297 ),
298 Effect.provideService(HttpServerRequest.ParsedSearchParams, {}),
299 Effect.provideService(HttpRouter.RouteContext, {
300 params: {},
301 route: {} as never,
302 }),
303 Effect.provide(
304 RelayHttpApi.layerEnvironmentAuth.pipe(
305 Layer.provide(Layer.succeed(EnvironmentCredentials.EnvironmentCredentials, credentials)),
306 ),
307 ),
308 Effect.scoped,
309 );
310 });
311});
312
313function layerRelayUnlinkTest(input?: {
314 readonly withTransaction?: RelayDb.RelayTransactions["Service"]["withTransaction"];
315 readonly getForUser?: EnvironmentLinks.EnvironmentLinks["Service"]["getForUser"];
316 readonly revokeForUser?: EnvironmentLinks.EnvironmentLinks["Service"]["revokeForUser"];
317 readonly revokeCredential?: EnvironmentCredentials.EnvironmentCredentials["Service"]["revokeForEnvironmentPublicKey"];
318 readonly prepareDeprovision?: ManagedEndpointProvider.ManagedEndpointProvider["Service"]["prepareDeprovision"];
319 readonly deprovision?: ManagedEndpointProvider.ManagedEndpointProvider["Service"]["deprovision"];
320 readonly provision?: ManagedEndpointProvider.ManagedEndpointProvider["Service"]["provision"];
321 readonly reconcileOrigin?: ManagedEndpointProvider.ManagedEndpointProvider["Service"]["reconcileOrigin"];
322 readonly release?: ManagedEndpointProvider.ManagedEndpointProvider["Service"]["release"];
323 readonly clearInbox?: HookInbox.HookInbox["Service"]["clear"];
324}) {
325 return Layer.mergeAll(
326 Layer.mock(HookInbox.HookInbox, {
327 clear: input?.clearInbox ?? (() => Effect.void),
328 }),
329 Layer.succeed(
330 RelayDb.RelayTransactions,
331 RelayDb.RelayTransactions.of({
332 withTransaction: input?.withTransaction ?? ((effect) => effect),
333 }),
334 ),
335 Layer.succeed(
336 EnvironmentLinks.EnvironmentLinks,
337 EnvironmentLinks.EnvironmentLinks.of({
338 upsert: () => Effect.die("unused upsert"),
339 listDeliveryUsersForEnvironment: () => Effect.die("unused listDeliveryUsersForEnvironment"),
340 listForUser: () => Effect.die("unused listForUser"),
341 getForUser: input?.getForUser ?? (() => Effect.succeed(null)),
342 findActiveManagedForEnvironment: () => Effect.die("unused findActiveManagedForEnvironment"),
343 setHoldWebhooksWhileOffline: () => Effect.die("unused setHoldWebhooksWhileOffline"),
344 revokeForUser: input?.revokeForUser ?? (() => Effect.succeed(false)),
345 }),
346 ),
347 Layer.succeed(
348 EnvironmentCredentials.EnvironmentCredentials,
349 EnvironmentCredentials.EnvironmentCredentials.of({
350 create: () => Effect.die("unused create"),
351 authenticate: () => Effect.die("unused authenticate"),
352 revokeForEnvironmentPublicKey: input?.revokeCredential ?? (() => Effect.succeed(false)),
353 }),
354 ),
355 Layer.succeed(
356 ManagedEndpointProvider.ManagedEndpointProvider,
357 ManagedEndpointProvider.ManagedEndpointProvider.of({
358 provision: input?.provision ?? (() => Effect.die("unused provision")),
359 reconcileOrigin: input?.reconcileOrigin ?? (() => Effect.succeed("ready")),
360 prepareDeprovision: input?.prepareDeprovision ?? (() => Effect.succeed(null)),
361 deprovision: input?.deprovision ?? (() => Effect.succeed(true)),
362 release: input?.release ?? (() => Effect.die("unused release")),
363 }),
364 ),
365 );
366}
367
368const linkedEnvironmentRecord = {
369 environmentId: EnvironmentId.make("environment-1"),
370 label: "Environment 1",
371 endpoint: {
372 httpBaseUrl: "https://environment-1.example.test/",
373 wsBaseUrl: "wss://environment-1.example.test/ws",
374 providerKind: "cloudflare_tunnel",
375 },
376 environmentPublicKey: "public-key",
377 linkedAt: "2026-07-28T00:00:00.000Z",
378} as const;
379
380describe("relay managed tunnel recovery", () => {
381 it.effect("binds recovery requests to the host, cloud user, and T3 service origin", () =>
382 Effect.gen(function* () {
383 const keyPair = NodeCrypto.generateKeyPairSync("ed25519", {
384 privateKeyEncoding: { format: "pem", type: "pkcs8" },
385 publicKeyEncoding: { format: "pem", type: "spki" },
386 });
387 const now = yield* DateTime.now;
388 const issuedAt = Math.floor(now.epochMilliseconds / 1_000);
389 const proof = yield* signRelayJwt({
390 privateKey: keyPair.privateKey,
391 typ: RELAY_MANAGED_TUNNEL_RECOVERY_TYP,
392 payload: {
393 iss: "t3-env:environment-1",
394 aud: "https://relay.example.test",
395 sub: "environment-1",
396 jti: "recovery-proof",
397 iat: issuedAt,
398 exp: issuedAt + 60,
399 action: "recover",
400 environmentId: "environment-1",
401 cloudUserId: "user-1",
402 origin: { localHttpHost: "127.0.0.1", localHttpPort: 3773 },
403 },
404 });
405 const request = {
406 action: "recover" as const,
407 proof,
408 userId: "user-1",
409 environmentId: "environment-1",
410 environmentPublicKey: keyPair.publicKey,
411 origin: { localHttpHost: "127.0.0.1", localHttpPort: 3773 },
412 };
413
414 yield* verifyEnvironmentTunnelRecoveryProof(request);
415
416 const wrongOwner = yield* Effect.flip(
417 verifyEnvironmentTunnelRecoveryProof({ ...request, userId: "user-2" }),
418 );
419 expect(wrongOwner).toMatchObject({ _tag: "Unauthorized" });
420
421 const wrongOrigin = yield* Effect.flip(
422 verifyEnvironmentTunnelRecoveryProof({
423 ...request,
424 origin: { localHttpHost: "127.0.0.1", localHttpPort: 5432 },
425 }),
426 );
427 expect(wrongOrigin).toMatchObject({ _tag: "Unauthorized" });
428
429 const wrongAction = yield* Effect.flip(
430 verifyEnvironmentTunnelRecoveryProof({
431 action: "register",
432 proof,
433 userId: "user-1",
434 environmentId: "environment-1",
435 environmentPublicKey: keyPair.publicKey,
436 tunnelId: "existing-tunnel",
437 origin: { localHttpHost: "127.0.0.1", localHttpPort: 3773 },
438 }),
439 );
440 expect(wrongAction).toMatchObject({ _tag: "Unauthorized" });
441 }).pipe(Effect.provideService(RelayConfiguration.RelayConfiguration, relaySettings)),
442 );
443
444 it.effect("rejects a signed registration proof for a different origin", () =>
445 Effect.gen(function* () {
446 const keyPair = NodeCrypto.generateKeyPairSync("ed25519", {
447 privateKeyEncoding: { format: "pem", type: "pkcs8" },
448 publicKeyEncoding: { format: "pem", type: "spki" },
449 });
450 const now = yield* DateTime.now;
451 const issuedAt = Math.floor(now.epochMilliseconds / 1_000);
452 const proof = yield* signRelayJwt({
453 privateKey: keyPair.privateKey,
454 typ: RELAY_MANAGED_TUNNEL_RECOVERY_TYP,
455 payload: {
456 iss: "t3-env:environment-1",
457 aud: "https://relay.example.test",
458 sub: "environment-1",
459 jti: "registration-origin-proof",
460 iat: issuedAt,
461 exp: issuedAt + 60,
462 action: "register",
463 environmentId: "environment-1",
464 cloudUserId: "user-1",
465 tunnelId: "existing-tunnel",
466 origin: { localHttpHost: "127.0.0.1", localHttpPort: 3773 },
467 },
468 });
469
470 const error = yield* Effect.flip(
471 verifyEnvironmentTunnelRecoveryProof({
472 action: "register",
473 proof,
474 userId: "user-1",
475 environmentId: "environment-1",
476 environmentPublicKey: keyPair.publicKey,
477 tunnelId: "existing-tunnel",
478 origin: { localHttpHost: "127.0.0.1", localHttpPort: 5432 },
479 }),
480 );
481
482 expect(error).toMatchObject({ _tag: "Unauthorized" });
483 }).pipe(Effect.provideService(RelayConfiguration.RelayConfiguration, relaySettings)),
484 );
485
486 it.effect("registers recovery for an existing tunnel without provisioning it", () => {
487 let recoveryEnabledFor: {
488 readonly userId: string;
489 readonly environmentId: string;
490 readonly tunnelId: string;
491 readonly environmentPublicKey: string;
492 readonly origin: { readonly localHttpHost: string; readonly localHttpPort: number };
493 } | null = null;
494
495 return Effect.gen(function* () {
496 expect(
497 yield* registerEnvironmentTunnelRecovery({
498 userId: "user-1",
499 environmentId: "environment-1",
500 environmentPublicKey: "public-key",
501 tunnelId: "existing-tunnel",
502 origin: { localHttpHost: "127.0.0.1", localHttpPort: 3773 },
503 }),
504 ).toEqual({ status: "ready" });
505 expect(recoveryEnabledFor).toEqual({
506 userId: "user-1",
507 environmentId: "environment-1",
508 tunnelId: "existing-tunnel",
509 environmentPublicKey: "public-key",
510 origin: { localHttpHost: "127.0.0.1", localHttpPort: 3773 },
511 });
512 }).pipe(
513 Effect.provide(
514 Layer.merge(
515 layerRelayUnlinkTest({
516 getForUser: () => Effect.succeed(linkedEnvironmentRecord),
517 provision: () => Effect.die("registration must not provision a tunnel"),
518 }),
519 Layer.mock(ManagedEndpointAllocations.ManagedEndpointAllocations)({
520 enableRecovery: (input) =>
521 Effect.sync(() => {
522 recoveryEnabledFor = input;
523 return true;
524 }),
525 }),
526 ),
527 ),
528 );
529 });
530
531 it.effect("requests recovery without enabling a stale tunnel", () => {
532 let recoveryEnabled = false;
533
534 return Effect.gen(function* () {
535 expect(
536 yield* registerEnvironmentTunnelRecovery({
537 userId: "user-1",
538 environmentId: "environment-1",
539 environmentPublicKey: "public-key",
540 tunnelId: "deleted-tunnel",
541 origin: { localHttpHost: "127.0.0.1", localHttpPort: 3773 },
542 }),
543 ).toEqual({ status: "recovery_required" });
544 expect(recoveryEnabled).toBe(false);
545 }).pipe(
546 Effect.provide(
547 Layer.merge(
548 layerRelayUnlinkTest({
549 getForUser: () => Effect.succeed(linkedEnvironmentRecord),
550 reconcileOrigin: () => Effect.succeed("recovery_required"),
551 }),
552 Layer.mock(ManagedEndpointAllocations.ManagedEndpointAllocations)({
553 enableRecovery: () =>
554 Effect.sync(() => {
555 recoveryEnabled = true;
556 return true;
557 }),
558 }),
559 ),
560 ),
561 );
562 });
563
564 it.effect("rejects recovery registration for a different environment key", () => {
565 let recoveryEnabled = false;
566
567 return Effect.gen(function* () {
568 const error = yield* Effect.flip(
569 registerEnvironmentTunnelRecovery({
570 userId: "user-1",
571 environmentId: "environment-1",
572 environmentPublicKey: "different-public-key",
573 tunnelId: "existing-tunnel",
574 origin: { localHttpHost: "127.0.0.1", localHttpPort: 3773 },
575 }),
576 );
577
578 expect(error).toMatchObject({ _tag: "Unauthorized" });
579 expect(recoveryEnabled).toBe(false);
580 }).pipe(
581 Effect.provide(
582 Layer.merge(
583 layerRelayUnlinkTest({
584 getForUser: () => Effect.succeed(linkedEnvironmentRecord),
585 }),
586 Layer.mock(ManagedEndpointAllocations.ManagedEndpointAllocations)({
587 enableRecovery: () =>
588 Effect.sync(() => {
589 recoveryEnabled = true;
590 return true;
591 }),
592 }),
593 ),
594 ),
595 );
596 });
597
598 it.effect("rejects recovery registration when the recorded tunnel changed", () =>
599 Effect.gen(function* () {
600 const error = yield* Effect.flip(
601 registerEnvironmentTunnelRecovery({
602 userId: "user-1",
603 environmentId: "environment-1",
604 environmentPublicKey: "public-key",
605 tunnelId: "stale-tunnel",
606 origin: { localHttpHost: "127.0.0.1", localHttpPort: 3773 },
607 }),
608 );
609
610 expect(error).toMatchObject({ _tag: "Unauthorized" });
611 }).pipe(
612 Effect.provide(
613 Layer.merge(
614 layerRelayUnlinkTest({
615 getForUser: () => Effect.succeed(linkedEnvironmentRecord),
616 }),
617 Layer.mock(ManagedEndpointAllocations.ManagedEndpointAllocations)({
618 enableRecovery: () => Effect.succeed(false),
619 }),
620 ),
621 ),
622 ),
623 );
624
625 it.effect("recovers a linked environment and marks its tunnel as recoverable", () => {
626 let recoveryEnabledFor: {
627 readonly userId: string;
628 readonly environmentId: string;
629 readonly tunnelId: string;
630 readonly environmentPublicKey: string;
631 } | null = null;
632 const runtime = {
633 providerKind: "cloudflare_tunnel" as const,
634 connectorToken: "replacement-token",
635 tunnelId: "replacement-tunnel",
636 };
637
638 return Effect.gen(function* () {
639 expect(
640 yield* recoverEnvironmentTunnelRecord({
641 userId: "user-1",
642 environmentId: "environment-1",
643 environmentPublicKey: "public-key",
644 origin: { localHttpHost: "127.0.0.1", localHttpPort: 3773 },
645 }),
646 ).toEqual({
647 endpoint: linkedEnvironmentRecord.endpoint,
648 endpointRuntime: runtime,
649 });
650 expect(recoveryEnabledFor).toEqual({
651 userId: "user-1",
652 environmentId: "environment-1",
653 tunnelId: "replacement-tunnel",
654 environmentPublicKey: "public-key",
655 origin: { localHttpHost: "127.0.0.1", localHttpPort: 3773 },
656 });
657 }).pipe(
658 Effect.provide(
659 Layer.merge(
660 layerRelayUnlinkTest({
661 getForUser: () => Effect.succeed(linkedEnvironmentRecord),
662 provision: () =>
663 Effect.succeed({
664 endpoint: linkedEnvironmentRecord.endpoint,
665 runtime,
666 }),
667 }),
668 Layer.mock(ManagedEndpointAllocations.ManagedEndpointAllocations)({
669 enableRecovery: (input) =>
670 Effect.sync(() => {
671 recoveryEnabledFor = input;
672 return true;
673 }),
674 }),
675 ),
676 ),
677 );
678 });
679
680 it.effect("rejects a credential from a different environment owner", () => {
681 let provisioned = false;
682
683 return Effect.gen(function* () {
684 const error = yield* Effect.flip(
685 recoverEnvironmentTunnelRecord({
686 userId: "user-1",
687 environmentId: "environment-1",
688 environmentPublicKey: "different-public-key",
689 origin: { localHttpHost: "127.0.0.1", localHttpPort: 3773 },
690 }),
691 );
692 expect(error).toMatchObject({ _tag: "Unauthorized" });
693 expect(provisioned).toBe(false);
694 }).pipe(
695 Effect.provide(
696 Layer.merge(
697 layerRelayUnlinkTest({
698 getForUser: () => Effect.succeed(linkedEnvironmentRecord),
699 provision: () =>
700 Effect.sync(() => {
701 provisioned = true;
702 return {
703 endpoint: linkedEnvironmentRecord.endpoint,
704 runtime: { providerKind: "cloudflare_tunnel", connectorToken: "token" },
705 };
706 }),
707 }),
708 Layer.mock(ManagedEndpointAllocations.ManagedEndpointAllocations)({
709 enableRecovery: () => Effect.die("unused"),
710 }),
711 ),
712 ),
713 );
714 });
715
716 it.effect("does not recover a publish-only environment", () =>
717 Effect.gen(function* () {
718 const error = yield* Effect.flip(
719 recoverEnvironmentTunnelRecord({
720 userId: "user-1",
721 environmentId: "environment-1",
722 environmentPublicKey: "public-key",
723 origin: { localHttpHost: "127.0.0.1", localHttpPort: 3773 },
724 }),
725 );
726 expect(error).toMatchObject({ _tag: "Unauthorized" });
727 }).pipe(
728 Effect.provide(
729 Layer.merge(
730 layerRelayUnlinkTest({
731 getForUser: () =>
732 Effect.succeed({
733 ...linkedEnvironmentRecord,
734 endpoint: {
735 ...linkedEnvironmentRecord.endpoint,
736 providerKind: "manual" as const,
737 },
738 }),
739 }),
740 Layer.mock(ManagedEndpointAllocations.ManagedEndpointAllocations)({
741 enableRecovery: () => Effect.die("unused"),
742 }),
743 ),
744 ),
745 ),
746 );
747
748 it.effect("rejects a recovered tunnel that changes the linked endpoint", () => {
749 let recoveryEnabled = false;
750 const cleaned: Array<string> = [];
751
752 return Effect.gen(function* () {
753 const error = yield* Effect.flip(
754 recoverEnvironmentTunnelRecord({
755 userId: "user-1",
756 environmentId: "environment-1",
757 environmentPublicKey: "public-key",
758 origin: { localHttpHost: "127.0.0.1", localHttpPort: 3773 },
759 }),
760 );
761 expect(error).toMatchObject({ _tag: "Unauthorized" });
762 expect(recoveryEnabled).toBe(false);
763 expect(cleaned).toEqual(["replacement-tunnel"]);
764 }).pipe(
765 Effect.provide(
766 Layer.merge(
767 layerRelayUnlinkTest({
768 getForUser: () => Effect.succeed(linkedEnvironmentRecord),
769 provision: () =>
770 Effect.succeed({
771 endpoint: {
772 httpBaseUrl: "https://different.example.test/",
773 wsBaseUrl: "wss://different.example.test/ws",
774 providerKind: "cloudflare_tunnel",
775 },
776 runtime: {
777 providerKind: "cloudflare_tunnel",
778 connectorToken: "token",
779 tunnelId: "replacement-tunnel",
780 },
781 }),
782 prepareDeprovision: () => Effect.die("must keep the active allocation"),
783 deprovision: () => Effect.die("must keep the active link DNS"),
784 release: ({ expectedTunnelId }) =>
785 Effect.sync(() => {
786 if (expectedTunnelId) {
787 cleaned.push(expectedTunnelId);
788 }
789 return true;
790 }),
791 }),
792 Layer.mock(ManagedEndpointAllocations.ManagedEndpointAllocations)({
793 enableRecovery: () =>
794 Effect.sync(() => {
795 recoveryEnabled = true;
796 return true;
797 }),
798 }),
799 ),
800 ),
801 );
802 });
803
804 it.effect.each([
805 { state: "removed", currentLink: null },
806 {
807 state: "publish-only",
808 currentLink: {
809 ...linkedEnvironmentRecord,
810 endpoint: {
811 ...linkedEnvironmentRecord.endpoint,
812 providerKind: "manual" as const,
813 },
814 },
815 },
816 ])("removes a recovered tunnel when its link becomes $state", ({ currentLink }) => {
817 let lookups = 0;
818 const cleaned: Array<string> = [];
819 const target = {
820 userId: "user-1",
821 environmentId: "environment-1",
822 hostname: "environment-1.example.test",
823 tunnelId: "replacement-tunnel",
824 tunnelName: "environment-1-tunnel",
825 dnsRecordId: "dns-1",
826 readyAt: "2026-07-28T00:00:00.000Z",
827 origin: { localHttpHost: "127.0.0.1", localHttpPort: 3773 },
828 updatedAt: "replacement-generation",
829 generation: 3,
830 tunnelReleasedAt: null,
831 } satisfies ManagedEndpointProvider.ManagedEndpointDeprovisionTarget;
832
833 return Effect.gen(function* () {
834 const error = yield* Effect.flip(
835 recoverEnvironmentTunnelRecord({
836 userId: "user-1",
837 environmentId: "environment-1",
838 environmentPublicKey: "public-key",
839 origin: { localHttpHost: "127.0.0.1", localHttpPort: 3773 },
840 }),
841 );
842 expect(error).toMatchObject({ _tag: "Unauthorized" });
843 expect(cleaned).toEqual(["replacement-tunnel"]);
844 }).pipe(
845 Effect.provide(
846 Layer.merge(
847 layerRelayUnlinkTest({
848 getForUser: () =>
849 Effect.sync(() => (++lookups === 1 ? linkedEnvironmentRecord : currentLink)),
850 provision: () =>
851 Effect.succeed({
852 endpoint: linkedEnvironmentRecord.endpoint,
853 runtime: {
854 providerKind: "cloudflare_tunnel",
855 connectorToken: "replacement-token",
856 tunnelId: "replacement-tunnel",
857 },
858 }),
859 prepareDeprovision: () => Effect.succeed(target),
860 deprovision: ({ target: captured }) =>
861 Effect.sync(() => {
862 if (captured?.tunnelId) {
863 cleaned.push(captured.tunnelId);
864 }
865 return true;
866 }),
867 }),
868 Layer.mock(ManagedEndpointAllocations.ManagedEndpointAllocations)({
869 enableRecovery: () => Effect.succeed(false),
870 }),
871 ),
872 ),
873 );
874 });
875});
876
877describe("relay environment unlink", () => {
878 it.effect("revokes the link and its credentials in one database transaction", () => {
879 const calls: Array<string> = [];
880 return Effect.gen(function* () {
881 expect(
882 yield* revokeEnvironmentLinkRecord({
883 userId: "user-1",
884 environmentId: "environment-1",
885 environmentPublicKey: "public-key",
886 }),
887 ).toBe(true);
888 expect(calls).toEqual(["transaction", "link", "credential"]);
889 }).pipe(
890 Effect.provide(
891 layerRelayUnlinkTest({
892 withTransaction: (effect) => {
893 calls.push("transaction");
894 return effect;
895 },
896 revokeForUser: () =>
897 Effect.sync(() => {
898 calls.push("link");
899 return true;
900 }),
901 revokeCredential: () =>
902 Effect.sync(() => {
903 calls.push("credential");
904 return true;
905 }),
906 }),
907 ),
908 );
909 });
910
911 it.effect("drops the unlinked endpoint's held webhooks, even if clearing fails", () => {
912 const cleared: Array<string> = [];
913 const endpointKey = "0123456789abcdef";
914 const unlink = (clearFails: boolean) =>
915 unlinkEnvironmentRecord({
916 userId: "user-1",
917 environmentId: "environment-1",
918 managedEndpointNamespace: "dev",
919 }).pipe(
920 Effect.provide(
921 layerRelayUnlinkTest({
922 getForUser: () => Effect.succeed(linkedEnvironmentRecord),
923 revokeForUser: () => Effect.succeed(true),
924 prepareDeprovision: () =>
925 Effect.succeed({
926 userId: "user-1",
927 environmentId: "environment-1",
928 hostname: "dev-0123456789abcdef.example.test",
929 tunnelId: "tunnel-1",
930 tunnelName: `t3coderelay-managedendpoint-dev-${endpointKey}`,
931 dnsRecordId: "dns-1",
932 readyAt: "2026-07-28T00:00:00.000Z",
933 tunnelReleasedAt: null,
934 origin: null,
935 updatedAt: "2026-07-28T00:00:00.000Z",
936 generation: 1,
937 }),
938 clearInbox: (input) =>
939 clearFails
940 ? Effect.fail(
941 new HookInbox.HookInboxError({
942 operation: "clear",
943 endpointKey: input.endpointKey,
944 cause: new Error("unavailable"),
945 }),
946 )
947 : Effect.sync(() => void cleared.push(input.endpointKey)),
948 }),
949 ),
950 );
951 return Effect.gen(function* () {
952 expect(yield* unlink(false)).toBe(true);
953 expect(cleared).toEqual([endpointKey]);
954 // The link is already revoked; a failed clear must not fail the unlink.
955 expect(yield* unlink(true)).toBe(true);
956 });
957 });
958
959 it.effect("commits database revocation before deprovisioning the managed endpoint", () => {
960 const calls: Array<string> = [];
961 const deprovisionTarget = {
962 userId: "user-1",
963 environmentId: "environment-1",
964 hostname: "environment-1.example.test",
965 tunnelId: "tunnel-1",
966 tunnelName: "environment-1-tunnel",
967 dnsRecordId: "dns-1",
968 readyAt: "2026-07-28T00:00:00.000Z",
969 origin: { localHttpHost: "127.0.0.1", localHttpPort: 3773 },
970 updatedAt: "generation-before-unlink",
971 generation: 1,
972 tunnelReleasedAt: null,
973 } satisfies ManagedEndpointProvider.ManagedEndpointDeprovisionTarget;
974
975 return Effect.gen(function* () {
976 expect(
977 yield* unlinkEnvironmentRecord({
978 userId: "user-1",
979 environmentId: "environment-1",
980 }),
981 ).toBe(true);
982 expect(calls).toEqual([
983 "prepare",
984 "lookup",
985 "transaction",
986 "link",
987 "credential",
988 "deprovision",
989 ]);
990 }).pipe(
991 Effect.provide(
992 layerRelayUnlinkTest({
993 withTransaction: (effect) => {
994 calls.push("transaction");
995 return effect;
996 },
997 getForUser: () =>
998 Effect.sync(() => {
999 calls.push("lookup");
1000 return linkedEnvironmentRecord;
1001 }),
1002 revokeForUser: () =>
1003 Effect.sync(() => {
1004 calls.push("link");
1005 return true;
1006 }),
1007 revokeCredential: () =>
1008 Effect.sync(() => {
1009 calls.push("credential");
1010 return true;
1011 }),
1012 prepareDeprovision: () =>
1013 Effect.sync(() => {
1014 calls.push("prepare");
1015 return deprovisionTarget;
1016 }),
1017 deprovision: (request) =>
1018 Effect.sync(() => {
1019 expect(request.target).toBe(deprovisionTarget);
1020 calls.push("deprovision");
1021 return true;
1022 }),
1023 }),
1024 ),
1025 );
1026 });
1027
1028 it.effect("does not deprovision when database revocation fails", () => {
1029 const calls: Array<string> = [];
1030 const failure = new EnvironmentCredentials.EnvironmentCredentialRevokePersistenceError({
1031 environmentId: "environment-1",
1032 cause: "database unavailable",
1033 });
1034
1035 return Effect.gen(function* () {
1036 expect(
1037 yield* Effect.flip(
1038 unlinkEnvironmentRecord({
1039 userId: "user-1",
1040 environmentId: "environment-1",
1041 }),
1042 ),
1043 ).toBe(failure);
1044 expect(calls).toEqual(["prepare", "transaction", "link", "credential"]);
1045 }).pipe(
1046 Effect.provide(
1047 layerRelayUnlinkTest({
1048 withTransaction: (effect) => {
1049 calls.push("transaction");
1050 return effect;
1051 },
1052 getForUser: () => Effect.succeed(linkedEnvironmentRecord),
1053 revokeForUser: () =>
1054 Effect.sync(() => {
1055 calls.push("link");
1056 return true;
1057 }),
1058 revokeCredential: () =>
1059 Effect.sync(() => {
1060 calls.push("credential");
1061 }).pipe(Effect.andThen(Effect.fail(failure))),
1062 prepareDeprovision: () =>
1063 Effect.sync(() => {
1064 calls.push("prepare");
1065 return null;
1066 }),
1067 deprovision: () =>
1068 Effect.sync(() => {
1069 calls.push("deprovision");
1070 return true;
1071 }),
1072 }),
1073 ),
1074 );
1075 });
1076
1077 it.effect("retries deprovisioning after the link is already revoked", () => {
1078 const calls: Array<string> = [];
1079 return Effect.gen(function* () {
1080 expect(
1081 yield* unlinkEnvironmentRecord({
1082 userId: "user-1",
1083 environmentId: "environment-1",
1084 }),
1085 ).toBe(false);
1086 expect(calls).toEqual(["prepare", "deprovision"]);
1087 }).pipe(
1088 Effect.provide(
1089 layerRelayUnlinkTest({
1090 prepareDeprovision: () =>
1091 Effect.sync(() => {
1092 calls.push("prepare");
1093 return null;
1094 }),
1095 deprovision: () =>
1096 Effect.sync(() => {
1097 calls.push("deprovision");
1098 return true;
1099 }),
1100 }),
1101 ),
1102 );
1103 });
1104
1105 it.effect("retries unlink cleanup when a concurrent tunnel release wins the first claim", () => {
1106 let lookups = 0;
1107 const targets: Array<ManagedEndpointProvider.ManagedEndpointDeprovisionTarget | undefined> = [];
1108 const target = {
1109 userId: "user-1",
1110 environmentId: "environment-1",
1111 hostname: "environment-1.example.test",
1112 tunnelId: "tunnel-1",
1113 tunnelName: "environment-1-tunnel",
1114 dnsRecordId: "dns-1",
1115 readyAt: "2026-07-28T00:00:00.000Z",
1116 origin: { localHttpHost: "127.0.0.1", localHttpPort: 3773 },
1117 updatedAt: "original-generation",
1118 generation: 1,
1119 tunnelReleasedAt: null,
1120 } satisfies ManagedEndpointProvider.ManagedEndpointDeprovisionTarget;
1121
1122 return Effect.gen(function* () {
1123 expect(
1124 yield* unlinkEnvironmentRecord({
1125 userId: "user-1",
1126 environmentId: "environment-1",
1127 }),
1128 ).toBe(true);
1129 expect(targets).toEqual([target, target]);
1130 }).pipe(
1131 Effect.provide(
1132 layerRelayUnlinkTest({
1133 getForUser: () => Effect.sync(() => (++lookups === 1 ? linkedEnvironmentRecord : null)),
1134 revokeForUser: () => Effect.succeed(true),
1135 prepareDeprovision: () => Effect.succeed(target),
1136 deprovision: ({ target: captured }) =>
1137 Effect.sync(() => {
1138 targets.push(captured ?? undefined);
1139 return targets.length > 1;
1140 }),
1141 }),
1142 ),
1143 );
1144 });
1145});
1146
1147describe("relay request tracing", () => {
1148 it.effect(
1149 "does not parent endpoint spans to an ambient parent captured while building handlers",
1150 () =>
1151 Effect.gen(function* () {
1152 const spans: Array<Tracer.NativeSpan> = [];
1153 const tracer = Tracer.make({
1154 span: (options) => {
1155 const span = new Tracer.NativeSpan(options);
1156 spans.push(span);
1157 return span;
1158 },
1159 });
1160 const ambientParent = Tracer.externalSpan({
1161 traceId: "00000000000000000000000000000001",
1162 spanId: "0000000000000001",
1163 sampled: true,
1164 });
1165 const endpoint = yield* withoutCapturedParentSpan(
1166 Effect.context<never>().pipe(
1167 Effect.map((capturedContext: Context.Context<never>) =>
1168 Effect.succeed(HttpServerResponse.empty({ status: 204 })).pipe(
1169 Effect.withSpan("relay.test.endpoint"),
1170 Effect.provideContext(capturedContext),
1171 ),
1172 ),
1173 ),
1174 ).pipe(Effect.provideService(Tracer.ParentSpan, ambientParent));
1175 const request = HttpServerRequest.fromWeb(
1176 new Request("https://relay.test/v1/mobile/devices?client=mobile", {
1177 method: "POST",
1178 headers: {
1179 authorization: "Bearer secret",
1180 dpop: "signed-proof",
1181 },
1182 }),
1183 );
1184
1185 yield* traceRelayHttpRequestWith(endpoint, Layer.succeed(Tracer.Tracer, tracer)).pipe(
1186 Effect.provideService(HttpServerRequest.HttpServerRequest, request),
1187 );
1188
1189 expect(spans.map((span) => span.name)).toEqual(["POST", "relay.test.endpoint"]);
1190 expect(spans[0]?.kind).toBe("server");
1191 expect(spans[0]?.attributes.get("url.path")).toBe("/v1/mobile/devices");
1192 expect(spans[0]?.attributes.get("http.response.status_code")).toBe(204);
1193 // Headers are not captured at all, so credentials never reach the span.
1194 expect(spans[0]?.attributes.get("http.request.header.authorization")).toBeUndefined();
1195 expect(spans[0]?.attributes.get("http.request.header.dpop")).toBeUndefined();
1196 expect(Option.isNone(spans[0]!.parent)).toBe(true);
1197 expect(Option.getOrUndefined(spans[1]!.parent)?.spanId).toBe(spans[0]?.spanId);
1198 }),
1199 );
1200
1201 it.effect("records one server span inside the worker's disabled HTTP tracer", () =>
1202 Effect.gen(function* () {
1203 const spans: Array<Tracer.NativeSpan> = [];
1204 const tracer = Tracer.make({
1205 span: (options) => {
1206 const span = new Tracer.NativeSpan(options);
1207 spans.push(span);
1208 return span;
1209 },
1210 });
1211 const request = HttpServerRequest.fromWeb(
1212 new Request("https://relay.test/v1/mobile/devices", { method: "POST" }),
1213 );
1214
1215 // As the worker runtime runs it: its own tracer around ours, turned off.
1216 yield* HttpMiddleware.tracer(
1217 traceRelayHttpRequestWith(
1218 Effect.succeed(HttpServerResponse.empty({ status: 204 })),
1219 Layer.empty,
1220 ),
1221 ).pipe(
1222 Effect.provideService(HttpMiddleware.TracerDisabledWhen, () => true),
1223 Effect.provideService(HttpServerRequest.HttpServerRequest, request),
1224 Effect.withTracer(tracer),
1225 );
1226 yield* Effect.yieldNow;
1227
1228 expect(spans.filter((span) => span.kind === "server")).toHaveLength(1);
1229 expect(spans[0]?.attributes.get("url.path")).toBe("/v1/mobile/devices");
1230 }),
1231 );
1232
1233 it.effect("fails hung requests with a 504 before the client's 10s abort", () =>
1234 Effect.gen(function* () {
1235 const spans: Array<Tracer.NativeSpan> = [];
1236 const tracer = Tracer.make({
1237 span: (options) => {
1238 const span = new Tracer.NativeSpan(options);
1239 spans.push(span);
1240 return span;
1241 },
1242 });
1243 const request = HttpServerRequest.fromWeb(
1244 new Request("https://relay.test/v1/mobile/devices", { method: "POST" }),
1245 );
1246
1247 const fiber = yield* traceRelayHttpRequestWith(
1248 Effect.never,
1249 Layer.succeed(Tracer.Tracer, tracer),
1250 ).pipe(Effect.provideService(HttpServerRequest.HttpServerRequest, request), Effect.forkChild);
1251 yield* TestClock.adjust(Duration.millis(RELAY_REQUEST_DEADLINE_MS));
1252 const response = yield* Fiber.join(fiber);
1253
1254 expect(response.status).toBe(504);
1255 expect(spans[0]?.attributes.get("relay.request.deadline_exceeded")).toBe(true);
1256 expect(spans[0]?.attributes.get("http.response.status_code")).toBe(504);
1257 }),
1258 );
1259});
1260
1261describe("relay routing fallback", () => {
1262 it.effect("publishes activity for escaped delegated thread IDs longer than 100 characters", () =>
1263 Effect.gen(function* () {
1264 const environmentId = "environment-1";
1265 const environmentPublicKey = "environment-public-key";
1266 const published: Array<
1267 Parameters<AgentActivityPublisher.AgentActivityPublisher["Service"]["publish"]>[0]
1268 > = [];
1269 const verified: Array<
1270 Parameters<
1271 EnvironmentPublishSignatures.EnvironmentPublishSignatures["Service"]["verify"]
1272 >[0]
1273 > = [];
1274 const layerPublisher = Layer.succeed(AgentActivityPublisher.AgentActivityPublisher, {
1275 publish: (input) =>
1276 Effect.sync(() => {
1277 published.push(input);
1278 return { ok: true as const, deliveries: [] };
1279 }),
1280 replayForLiveActivityRegistration: () => Effect.succeed(null),
1281 });
1282 const layerSignatures = Layer.succeed(
1283 EnvironmentPublishSignatures.EnvironmentPublishSignatures,
1284 {
1285 verify: (input) =>
1286 Effect.sync(() => {
1287 verified.push(input);
1288 }),
1289 },
1290 );
1291 const layerAuth = Layer.succeed(RelayEnvironmentAuth, {
1292 environmentBearer: (effect) =>
1293 effect.pipe(
1294 Effect.provideService(RelayEnvironmentPrincipal, {
1295 environmentId,
1296 environmentPublicKey,
1297 }),
1298 ),
1299 });
1300 const layerRoutes = HttpApiBuilder.layer(
1301 HttpApi.make("RelayApi").add(RelayApi.groups.server),
1302 ).pipe(
1303 Layer.provide(
1304 RelayHttpApi.layerServerApi.pipe(
1305 HttpRouter.provideRequest(
1306 Layer.mergeAll(
1307 Layer.succeed(RelayConfiguration.RelayConfiguration, relaySettings),
1308 Layer.mock(EnvironmentLinks.EnvironmentLinks, {}),
1309 Layer.mock(ManagedEndpointAllocations.ManagedEndpointAllocations, {}),
1310 Layer.mock(ManagedEndpointProvider.ManagedEndpointProvider, {}),
1311 ),
1312 ),
1313 Layer.provide([layerPublisher, layerSignatures, Layer.mock(HeldHooks.HeldHooks, {})]),
1314 ),
1315 ),
1316 Layer.provide(layerAuth),
1317 Layer.provide([NodeServices.layer, NodeHttpPlatform.layer, Etag.layerWeak]),
1318 );
1319 const httpEffect = yield* HttpRouter.toHttpEffect(
1320 Layer.mergeAll(layerRoutes, RelayHttpApi.layerNotFoundRoute, RelayHttpApi.layerCors),
1321 ).pipe(Effect.provideService(HttpRouter.RouterConfig, RELAY_HTTP_ROUTER_CONFIG));
1322 const threadIds = [
1323 "b7c8c522-d244-43dc-875f-7224fce79912",
1324 "t".repeat(512),
1325 "thread:delegated-task:command%3Amcp%3Ab7c8c522-d244-43dc-875f-7224fce79912%3Adelegate-task%3Agreet-subagent-20260813",
1326 ];
1327 for (const threadId of threadIds) {
1328 const request = HttpServerRequest.fromWeb(
1329 new Request(
1330 `https://relay.test/v1/environments/${environmentId}/threads/${encodeURIComponent(threadId)}/agent-activity`,
1331 {
1332 method: "POST",
1333 headers: {
1334 authorization: "Bearer environment-credential",
1335 "content-type": "application/json",
1336 },
1337 body: '{"state":null,"proof":"signed-proof"}',
1338 },
1339 ),
1340 );
1341 const response = yield* httpEffect.pipe(
1342 Effect.provideService(HttpServerRequest.HttpServerRequest, request),
1343 );
1344 expect(response.status).toBe(200);
1345 }
1346 expect(published).toEqual(
1347 threadIds.map((threadId) => ({
1348 environmentId,
1349 environmentPublicKey,
1350 threadId,
1351 state: null,
1352 })),
1353 );
1354 expect(verified.map((input) => input.threadId)).toEqual(threadIds);
1355 }).pipe(Effect.scoped),
1356 );
1357
1358 it.effect("redirects the relay root to the API docs", () =>
1359 Effect.gen(function* () {
1360 const httpEffect = yield* HttpRouter.toHttpEffect(
1361 Layer.mergeAll(
1362 RelayHttpApi.layerDocsRedirectRoute,
1363 RelayHttpApi.layerNotFoundRoute,
1364 RelayHttpApi.layerCors,
1365 ),
1366 );
1367 const response = yield* sendRelayRequest(httpEffect, new Request("https://relay.test/"));
1368
1369 expect(response.status).toBe(302);
1370 expect(response.headers.location).toBe("/docs");
1371 expect(response.headers["access-control-allow-origin"]).toBe("*");
1372 }).pipe(Effect.scoped),
1373 );
1374
1375 it.effect("returns a CORS-compatible 404 response for unmatched paths", () =>
1376 Effect.gen(function* () {
1377 const httpEffect = yield* HttpRouter.toHttpEffect(
1378 Layer.merge(RelayHttpApi.layerNotFoundRoute, RelayHttpApi.layerCors),
1379 );
1380 const response = yield* sendRelayRequest(
1381 httpEffect,
1382 new Request("https://relay.test/v1/environmentsd", { method: "GET" }),
1383 );
1384
1385 expect(response.status).toBe(404);
1386 expect(response.headers["access-control-allow-origin"]).toBe("*");
1387 }).pipe(Effect.scoped),
1388 );
1389});
1390
1391describe("relay CORS", () => {
1392 const origin = "https://app.t3.codes";
1393
1394 class HandlerFailed extends Data.TaggedError("HandlerFailed") {}
1395
1396 it.effect("answers preflight requests without reaching a route", () =>
1397 Effect.gen(function* () {
1398 const httpEffect = yield* HttpRouter.toHttpEffect(
1399 Layer.merge(RelayHttpApi.layerNotFoundRoute, RelayHttpApi.layerCors),
1400 );
1401 const response = yield* sendRelayRequest(
1402 httpEffect,
1403 new Request("https://relay.test/v1/client/environments", {
1404 method: "OPTIONS",
1405 headers: {
1406 origin,
1407 "access-control-request-method": "POST",
1408 "access-control-request-headers": "authorization,dpop,content-type",
1409 },
1410 }),
1411 );
1412
1413 expect(response.status).toBe(204);
1414 expect(
1415 response.headers["access-control-allow-methods"]?.split(",").map((method) => method.trim()),
1416 ).toEqual(["GET", "POST", "DELETE", "OPTIONS"]);
1417 expect(response.headers).toMatchObject({
1418 "access-control-allow-origin": "*",
1419 "access-control-allow-headers": "authorization,b3,traceparent,content-type,dpop",
1420 "access-control-expose-headers": "traceparent,www-authenticate",
1421 "access-control-max-age": "86400",
1422 });
1423 }).pipe(Effect.scoped),
1424 );
1425
1426 it.effect("adds CORS headers to handler failures and defects", () =>
1427 Effect.gen(function* () {
1428 const httpEffect = yield* HttpRouter.toHttpEffect(
1429 Layer.mergeAll(
1430 HttpRouter.add("GET", "/v1/fail", Effect.fail(new HandlerFailed())),
1431 HttpRouter.add("GET", "/v1/die", Effect.die(new Error("handler defect"))),
1432 RelayHttpApi.layerNotFoundRoute,
1433 RelayHttpApi.layerCors,
1434 ),
1435 );
1436 for (const path of ["/v1/fail", "/v1/die"]) {
1437 const response = yield* sendRelayRequest(
1438 httpEffect,
1439 new Request(`https://relay.test${path}`, { headers: { origin } }),
1440 );
1441
1442 expect(response.status).toBe(500);
1443 expect(response.headers["access-control-allow-origin"]).toBe("*");
1444 expect(response.headers["access-control-expose-headers"]).toBe(
1445 "traceparent,www-authenticate",
1446 );
1447 }
1448 }).pipe(Effect.scoped),
1449 );
1450
1451 it.effect("adds CORS headers to the request deadline response", () =>
1452 Effect.gen(function* () {
1453 const httpEffect = yield* HttpRouter.toHttpEffect(
1454 Layer.mergeAll(
1455 HttpRouter.add("GET", "/v1/hang", Effect.never),
1456 RelayHttpApi.layerNotFoundRoute,
1457 RelayHttpApi.layerCors,
1458 ),
1459 );
1460 const fiber = yield* sendRelayRequest(
1461 traceRelayHttpRequestWith(httpEffect, Layer.empty),
1462 new Request("https://relay.test/v1/hang", { headers: { origin } }),
1463 ).pipe(Effect.forkChild);
1464 yield* TestClock.adjust(Duration.millis(RELAY_REQUEST_DEADLINE_MS));
1465 const response = yield* Fiber.join(fiber);
1466
1467 expect(response.status).toBe(504);
1468 expect(response.headers["access-control-allow-origin"]).toBe("*");
1469 expect(response.headers["access-control-expose-headers"]).toBe(
1470 "traceparent,www-authenticate",
1471 );
1472 }).pipe(Effect.scoped),
1473 );
1474});
1475
1476// Sends a request through Effect's request handler, which applies pre-response
1477// handlers to the response it sends, as the Workers runtime does.
1478const sendRelayRequest = Effect.fnUntraced(function* <E, R>(
1479 httpEffect: Effect.Effect<HttpServerResponse.HttpServerResponse, E, R>,
1480 request: Request,
1481) {
1482 const sent = yield* Deferred.make<HttpServerResponse.HttpServerResponse>();
1483 yield* HttpEffect.toHandled(httpEffect, (_request, response) =>
1484 Deferred.succeed(sent, response),
1485 ).pipe(
1486 Effect.provideService(HttpServerRequest.HttpServerRequest, HttpServerRequest.fromWeb(request)),
1487 // A handler failure still fails this effect after its 500 has been sent.
1488 Effect.exit,
1489 );
1490 return yield* Deferred.await(sent);
1491});