1import * as NodeHttpPlatform from "@effect/platform-node/NodeHttpPlatform";
2import * as NodeServices from "@effect/platform-node/NodeServices";
3// @effect-diagnostics-next-line nodeBuiltinImport:off -- Effect's Crypto has no generateKeyPairSync.
4import * as NodeCrypto from "node:crypto";
5import * as EffectNodeCrypto from "@effect/platform-node/NodeCrypto";
6import { describe, expect, it } from "@effect/vitest";
7import { RelayApi } from "@t3tools/contracts/relay";
8import * as DateTime from "effect/DateTime";
9import * as Deferred from "effect/Deferred";
10import * as Duration from "effect/Duration";
11import * as Clock from "effect/Clock";
12import * as Effect from "effect/Effect";
13import * as Fiber from "effect/Fiber";
14import * as Layer from "effect/Layer";
15import * as Redacted from "effect/Redacted";
16import * as TestClock from "effect/testing/TestClock";
17import * as Tracer from "effect/Tracer";
18import * as HttpClient from "effect/http/HttpClient";
19import * as HttpClientError from "effect/http/HttpClientError";
20import type * as HttpClientRequest from "effect/http/HttpClientRequest";
21import * as HttpClientResponse from "effect/http/HttpClientResponse";
22import * as Etag from "effect/http/Etag";
23import * as HttpEffect from "effect/http/HttpEffect";
24import * as HttpRouter from "effect/http/HttpRouter";
25import * as HttpApi from "effect/http-api/HttpApi";
26import * as HttpApiBuilder from "effect/http-api/HttpApiBuilder";
27import * as HttpMiddleware from "effect/http/HttpMiddleware";
28import * as HttpServerRequest from "effect/http/HttpServerRequest";
29import * as HttpServerResponse from "effect/http/HttpServerResponse";
30
31import * as RelayConfiguration from "../Config.ts";
32import * as EnvironmentLinks from "../environments/EnvironmentLinks.ts";
33import * as ManagedEndpointAllocations from "../environments/ManagedEndpointAllocations.ts";
34import { RELAY_HTTP_ROUTER_CONFIG, traceRelayHttpRequestWith } from "../http/Api.ts";
35import * as RelayHttpApi from "../http/Api.ts";
36import * as HookForwarder from "./HookForwarder.ts";
37import { RELAY_HOOK_DELIVERY_TYP, verifyRelayJwt } from "@t3tools/shared/relayJwt";
38import * as HeldHooks from "./HeldHooks.ts";
39import * as HookInbox from "./HookInbox.ts";
40import type { HeldHook } from "./HookInboxStore.ts";
41import { RELAY_HOOK_UPSTREAM_TIMEOUT_MS } from "./upstream.ts";
42
43const mintKeys = NodeCrypto.generateKeyPairSync("ed25519", {
44 privateKeyEncoding: { format: "pem", type: "pkcs8" },
45 publicKeyEncoding: { format: "pem", type: "spki" },
46});
47
48const settings: RelayConfiguration.RelayConfiguration["Service"] = {
49 relayIssuer: "https://relay.example.test",
50 apns: null,
51 clerkSecretKey: Redacted.make("clerk-secret-key"),
52 clerkPublishableKey: "pk_test_test",
53 clerkJwtAudience: "t3-code-relay",
54 apnsDeliveryJobSigningSecret: Redacted.make("apns-delivery-secret"),
55 cloudMintPrivateKey: Redacted.make(mintKeys.privateKey),
56 cloudMintPublicKey: mintKeys.publicKey,
57 managedEndpointBaseDomain: "example.test",
58 managedEndpointNamespace: "dev",
59};
60
61const environmentId = "env-hook";
62const endpointKey = "0123456789abcdef";
63const readyAllocation: ManagedEndpointAllocations.ManagedEndpointAllocation = {
64 userId: "user_1",
65 environmentId,
66 hostname: "env.example.test",
67 tunnelId: "tunnel-id",
68 tunnelName: `t3coderelay-managedendpoint-dev-${endpointKey}`,
69 dnsRecordId: "dns-record-id",
70 readyAt: "2026-05-25T00:00:00.000Z",
71 tunnelReleasedAt: null,
72 origin: { localHttpHost: "127.0.0.1", localHttpPort: 3773 },
73 updatedAt: "2026-05-25T00:00:00.000Z",
74 generation: 1,
75};
76
77const managedLink = {
78 userId: "user_1",
79 environmentId: environmentId as never,
80 label: "Hook env",
81 endpoint: {
82 httpBaseUrl: "https://env.example.test/",
83 wsBaseUrl: "wss://env.example.test/ws",
84 providerKind: "cloudflare_tunnel" as const,
85 },
86 environmentPublicKey: "public-key",
87 linkedAt: "2026-05-25T00:00:00.000Z",
88 holdWebhooksWhileOffline: false,
89};
90
91interface Harness {
92 readonly execute?: (
93 request: HttpClientRequest.HttpClientRequest,
94 ) => Effect.Effect<HttpClientResponse.HttpClientResponse, HttpClientError.HttpClientError>;
95 readonly links?: ReadonlyArray<typeof managedLink>;
96 readonly allocation?: ManagedEndpointAllocations.ManagedEndpointAllocation | null;
97 readonly allow?: (key: string) => boolean;
98 readonly allowEndpoint?: (endpointKey: string) => boolean;
99 /** Inbox capacity; hold reports full once this many requests are held. */
100 readonly inboxCapacity?: number;
101}
102
103function makeHarness(options: Harness = {}) {
104 const sent: Array<HttpClientRequest.HttpClientRequest> = [];
105 const rateLimitKeys: Array<string> = [];
106 const held: Array<HeldHook & { readonly baseUrl: string }> = [];
107 const execute =
108 options.execute ??
109 ((request: HttpClientRequest.HttpClientRequest) =>
110 Effect.succeed(
111 HttpClientResponse.fromWeb(
112 request,
113 new Response("ok", { status: 200, headers: { "content-type": "text/plain" } }),
114 ),
115 ));
116 const layerForwarder = HookForwarder.layer.pipe(
117 Layer.provideMerge(HeldHooks.layer),
118 Layer.provide(
119 Layer.mergeAll(
120 Layer.succeed(RelayConfiguration.RelayConfiguration, settings),
121 Layer.mock(EnvironmentLinks.EnvironmentLinks, {
122 findActiveManagedForEnvironment: (input) =>
123 Effect.succeed(
124 (options.links ?? [managedLink]).filter(
125 (link) =>
126 link.environmentId === input.environmentId &&
127 (input.userId === undefined || link.userId === input.userId),
128 ),
129 ),
130 }),
131 Layer.mock(ManagedEndpointAllocations.ManagedEndpointAllocations, {
132 getByTunnelName: (tunnelName) => {
133 const allocation =
134 options.allocation === undefined ? readyAllocation : options.allocation;
135 return Effect.succeed(allocation?.tunnelName === tunnelName ? allocation : null);
136 },
137 }),
138 Layer.succeed(
139 HttpClient.HttpClient,
140 HttpClient.make((request) => {
141 sent.push(request);
142 return execute(request);
143 }),
144 ),
145 Layer.mock(HookInbox.HookInbox, {
146 hold: ({ hook, baseUrl }) =>
147 Effect.sync(() => {
148 if (held.length >= (options.inboxCapacity ?? Infinity)) return false;
149 held.push({ ...hook, baseUrl });
150 return true;
151 }),
152 }),
153 EffectNodeCrypto.layer,
154 Layer.succeed(HookForwarder.HookRateLimiter, {
155 allowHook: (key) =>
156 Effect.sync(() => {
157 rateLimitKeys.push(key);
158 return options.allow ? options.allow(key) : true;
159 }),
160 allowEndpoint: (key) =>
161 Effect.sync(() => (options.allowEndpoint ? options.allowEndpoint(key) : true)),
162 }),
163 ),
164 ),
165 );
166 const httpEffect = HttpRouter.toHttpEffect(
167 Layer.mergeAll(
168 HttpApiBuilder.layer(HttpApi.make("RelayApi").add(RelayApi.groups.hooks)).pipe(
169 Layer.provide(HookForwarder.layerApi.pipe(Layer.provide(layerForwarder))),
170 Layer.provide([NodeServices.layer, NodeHttpPlatform.layer, Etag.layerWeak]),
171 ),
172 RelayHttpApi.layerNotFoundRoute,
173 RelayHttpApi.layerCors,
174 ),
175 ).pipe(Effect.provideService(HttpRouter.RouterConfig, RELAY_HTTP_ROUTER_CONFIG));
176 // Goes through Effect's request handler, which applies pre-response handlers
177 // (such as CORS) to the response it sends, as the Workers runtime does.
178 const send = (request: Request) =>
179 Effect.gen(function* () {
180 const handler = yield* httpEffect;
181 const sent = yield* Deferred.make<HttpServerResponse.HttpServerResponse>();
182 yield* HttpEffect.toHandled(handler, (_request, response) =>
183 Deferred.succeed(sent, response),
184 ).pipe(
185 Effect.provideService(
186 HttpServerRequest.HttpServerRequest,
187 HttpServerRequest.fromWeb(request),
188 ),
189 );
190 return yield* Deferred.await(sent);
191 });
192 return { sent, rateLimitKeys, held, send, httpEffect };
193}
194
195const hookUrl = (path = "hook-1/secret-token", query = "") =>
196 `https://relay.test/v1/hooks/${endpointKey}/${path}${query}`;
197
198const readBody = (response: HttpServerResponse.HttpServerResponse) =>
199 Effect.promise(() => HttpServerResponse.toWeb(response).arrayBuffer()).pipe(
200 Effect.map((buffer) => new Uint8Array(buffer)),
201 );
202const readJson = (response: HttpServerResponse.HttpServerResponse) =>
203 Effect.promise(() => HttpServerResponse.toWeb(response).json());
204
205const requestBytes = (request: HttpClientRequest.HttpClientRequest) =>
206 request.body._tag === "Uint8Array" ? request.body.body : new Uint8Array(0);
207
208describe("HookForwarder", () => {
209 it.effect("forwards the exact body bytes, query and filtered headers", () =>
210 Effect.gen(function* () {
211 const harness = makeHarness();
212 const body = new Uint8Array([0, 255, 10, 13, 0x7b, 0x22, 0xc3, 0x28]);
213 const response = yield* harness.send(
214 new Request(hookUrl("hook-1/tok%2Fen", "?a=1&b=two%20words"), {
215 method: "POST",
216 headers: {
217 "content-type": "application/octet-stream",
218 authorization: "Bearer sender-secret",
219 "x-hub-signature-256": "sha256=abc",
220 cookie: "session=1",
221 "cf-connecting-ip": "1.2.3.4",
222 "x-forwarded-for": "1.2.3.4",
223 "x-real-ip": "1.2.3.4",
224 "proxy-authorization": "Basic x",
225 connection: "keep-alive",
226 },
227 body,
228 }),
229 );
230 expect(response.status).toBe(200);
231 expect(harness.sent).toHaveLength(1);
232 const sent = harness.sent[0]!;
233 expect(sent.method).toBe("POST");
234 expect(sent.url).toBe("https://env.example.test/api/hooks/hook-1/tok%2Fen?a=1&b=two%20words");
235 expect(Array.from(requestBytes(sent))).toEqual(Array.from(body));
236 expect(sent.headers.authorization).toBe("Bearer sender-secret");
237 expect(sent.headers["x-hub-signature-256"]).toBe("sha256=abc");
238 expect(sent.headers["content-type"]).toBe("application/octet-stream");
239 for (const dropped of [
240 "cookie",
241 "cf-connecting-ip",
242 "x-forwarded-for",
243 "x-real-ip",
244 "proxy-authorization",
245 "connection",
246 "host",
247 ]) {
248 expect(sent.headers[dropped]).toBeUndefined();
249 }
250 // Recomputed from the forwarded bytes, not copied from the sender.
251 expect(sent.headers["content-length"]).toBe(String(body.length));
252 // The budget key is a hash, so the token never reaches the limiter.
253 expect(harness.rateLimitKeys).toHaveLength(1);
254 expect(harness.rateLimitKeys[0]).toMatch(/^[0-9a-f]{64}$/);
255 }),
256 );
257
258 it.effect("signs each forward for the environment, replacing any copy a sender sent", () =>
259 Effect.gen(function* () {
260 const harness = makeHarness();
261 yield* harness.send(
262 new Request(hookUrl("hook-1/tok"), {
263 method: "POST",
264 headers: { "x-t3-relay-delivery": "forged", "x-t3-relay-delivery-id": "forged" },
265 body: "{}",
266 }),
267 );
268 const sent = harness.sent[0]!;
269 const payload = yield* verifyRelayJwt({
270 publicKey: mintKeys.publicKey,
271 token: sent.headers["x-t3-relay-delivery"]!,
272 typ: RELAY_HOOK_DELIVERY_TYP,
273 issuer: settings.relayIssuer,
274 audience: `t3-env:${environmentId}`,
275 nowEpochSeconds: Math.floor((yield* Clock.currentTimeMillis) / 1_000),
276 });
277 // The proof names exactly the delivery the environment receives.
278 expect(payload).toMatchObject({
279 environmentId,
280 hookId: "hook-1",
281 deliveryId: sent.headers["x-t3-relay-delivery-id"],
282 receivedAt: sent.headers["x-t3-relay-received-at"],
283 });
284 expect(sent.headers["x-t3-relay-delivery-id"]).not.toBe("forged");
285 }),
286 );
287
288 it.effect("passes upstream status, body and content-type through", () =>
289 Effect.gen(function* () {
290 const harness = makeHarness({
291 execute: (request) =>
292 Effect.succeed(
293 HttpClientResponse.fromWeb(
294 request,
295 new Response('{"error":"bad_signature"}', {
296 status: 401,
297 headers: { "content-type": "application/json", "set-cookie": "x=1" },
298 }),
299 ),
300 ),
301 });
302 const response = yield* harness.send(new Request(hookUrl(), { method: "GET" }));
303 expect(response.status).toBe(401);
304 expect(response.headers["content-type"]).toBe("application/json");
305 expect(response.headers["set-cookie"]).toBeUndefined();
306 expect(response.headers["access-control-allow-origin"]).toBeUndefined();
307 // Served from the relay's origin, so it may never render or run there.
308 expect(response.headers["x-content-type-options"]).toBe("nosniff");
309 expect(response.headers["content-security-policy"]).toBe("sandbox; default-src 'none'");
310 expect(new TextDecoder().decode(yield* readBody(response))).toBe('{"error":"bad_signature"}');
311 expect(harness.sent[0]?.method).toBe("GET");
312 }),
313 );
314
315 it.effect("never forwards or holds HEAD, which can carry no body", () =>
316 Effect.gen(function* () {
317 const harness = makeHarness({ execute: () => Effect.die("must not be sent") });
318 const response = yield* harness.send(new Request(hookUrl(), { method: "HEAD" }));
319 expect(response.status).toBeGreaterThanOrEqual(400);
320 expect(response.status).toBeLessThan(500);
321 expect(harness.held).toHaveLength(0);
322 }),
323 );
324
325 it.effect("does not follow or relay upstream redirects", () =>
326 Effect.gen(function* () {
327 const harness = makeHarness({
328 execute: (request) =>
329 Effect.succeed(
330 HttpClientResponse.fromWeb(
331 request,
332 new Response(null, {
333 status: 302,
334 headers: { location: "https://internal.example/" },
335 }),
336 ),
337 ),
338 });
339 const response = yield* harness.send(new Request(hookUrl(), { method: "POST", body: "{}" }));
340 expect(response.status).toBe(302);
341 expect(response.headers.location).toBeUndefined();
342 expect(harness.sent).toHaveLength(1);
343 }),
344 );
345
346 it.effect("rejects bodies over 1 MiB by content-length and while reading", () =>
347 Effect.gen(function* () {
348 const harness = makeHarness();
349 const declared = yield* harness.send(
350 new Request(hookUrl(), {
351 method: "POST",
352 headers: { "content-length": String(HookForwarder.RELAY_HOOK_MAX_BODY_BYTES + 1) },
353 body: "x",
354 }),
355 );
356 expect(declared.status).toBe(413);
357
358 const oversized = new Uint8Array(HookForwarder.RELAY_HOOK_MAX_BODY_BYTES + 1);
359 const stream = new ReadableStream<Uint8Array>({
360 start(controller) {
361 controller.enqueue(oversized.subarray(0, 600_000));
362 controller.enqueue(oversized.subarray(600_000));
363 controller.close();
364 },
365 });
366 const streamed = yield* harness.send(
367 new Request(hookUrl(), { method: "POST", body: stream, duplex: "half" } as RequestInit),
368 );
369 expect(streamed.status).toBe(413);
370 expect(yield* readJson(streamed)).toEqual({ error: "payload_too_large" });
371 expect(harness.sent).toHaveLength(0);
372 }),
373 );
374
375 it.effect("returns 404 for unknown endpoints and unready endpoints", () =>
376 Effect.gen(function* () {
377 const unknown = makeHarness();
378 for (const key of ["fedcba9876543210", environmentId]) {
379 const response = yield* unknown.send(
380 new Request(`https://relay.test/v1/hooks/${key}/hook-1/token`, { method: "POST" }),
381 );
382 expect(response.status).toBe(404);
383 expect(yield* readJson(response)).toEqual({ error: "hook_not_found" });
384 }
385 expect(unknown.sent).toHaveLength(0);
386
387 const unready = makeHarness({ allocation: { ...readyAllocation, readyAt: null } });
388 const unreadyResponse = yield* unready.send(new Request(hookUrl(), { method: "POST" }));
389 expect(unreadyResponse.status).toBe(404);
390 expect(unready.sent).toHaveLength(0);
391 }),
392 );
393
394 it.effect("forwards only to the link that owns the endpoint key", () =>
395 Effect.gen(function* () {
396 // Another account linked the same environment id under its own key; its
397 // link must not receive this endpoint's hooks, whatever order rows come in.
398 const intruder = {
399 ...managedLink,
400 userId: "user_attacker",
401 environmentPublicKey: "attacker-key",
402 endpoint: { ...managedLink.endpoint, httpBaseUrl: "https://attacker.example.test/" },
403 };
404 const harness = makeHarness({ links: [intruder, managedLink] });
405 const response = yield* harness.send(new Request(hookUrl(), { method: "POST", body: "{}" }));
406 expect(response.status).toBe(200);
407 expect(harness.sent.map((request) => new URL(request.url).host)).toEqual([
408 "env.example.test",
409 ]);
410
411 // Without the owner's link, the key resolves to nothing at all.
412 const orphaned = makeHarness({ links: [intruder] });
413 const orphanedResponse = yield* orphaned.send(
414 new Request(hookUrl(), { method: "POST", body: "{}" }),
415 );
416 expect(orphanedResponse.status).toBe(404);
417 expect(orphaned.sent).toHaveLength(0);
418 }),
419 );
420
421 it.effect("returns 429 when the endpoint's overall budget is spent", () =>
422 Effect.gen(function* () {
423 const harness = makeHarness({ allowEndpoint: () => false });
424 const response = yield* harness.send(new Request(hookUrl(), { method: "POST" }));
425 expect(response.status).toBe(429);
426 expect(harness.sent).toHaveLength(0);
427 }),
428 );
429
430 it.effect("maps tunnel-offline and network failures to 503", () =>
431 Effect.gen(function* () {
432 const offline = makeHarness({
433 execute: (request) =>
434 Effect.succeed(HttpClientResponse.fromWeb(request, new Response("", { status: 530 }))),
435 });
436 const offlineResponse = yield* offline.send(new Request(hookUrl(), { method: "POST" }));
437 expect(offlineResponse.status).toBe(503);
438 expect(yield* readJson(offlineResponse)).toEqual({ error: "environment_unavailable" });
439
440 const network = makeHarness({
441 execute: (request) =>
442 Effect.fail(
443 new HttpClientError.HttpClientError({
444 reason: new HttpClientError.TransportError({ request, cause: new Error("reset") }),
445 }),
446 ),
447 });
448 const networkResponse = yield* network.send(new Request(hookUrl(), { method: "POST" }));
449 expect(networkResponse.status).toBe(503);
450 }),
451 );
452
453 it.effect("does not relay an upstream response larger than the cap", () =>
454 Effect.gen(function* () {
455 const harness = makeHarness({
456 execute: (request) =>
457 Effect.succeed(
458 HttpClientResponse.fromWeb(
459 request,
460 new Response(new Uint8Array(64 * 1024 + 1), { status: 200 }),
461 ),
462 ),
463 });
464 const response = yield* harness.send(new Request(hookUrl(), { method: "POST" }));
465 expect(response.status).toBe(503);
466 expect(yield* readJson(response)).toEqual({ error: "environment_unavailable" });
467 }),
468 );
469
470 it.effect("maps an upstream timeout to 504", () =>
471 Effect.gen(function* () {
472 // Advance the clock only once the request is waiting on the environment;
473 // hashing the budget key before that step is asynchronous.
474 const reachedUpstream = yield* Deferred.make<void>();
475 const harness = makeHarness({
476 execute: () =>
477 Deferred.succeed(reachedUpstream, undefined).pipe(Effect.andThen(Effect.never)),
478 });
479 const fiber = yield* harness
480 .send(new Request(hookUrl(), { method: "POST", body: "{}" }))
481 .pipe(Effect.forkChild);
482 yield* Deferred.await(reachedUpstream);
483 yield* TestClock.adjust(Duration.millis(RELAY_HOOK_UPSTREAM_TIMEOUT_MS));
484 const response = yield* Fiber.join(fiber);
485 expect(response.status).toBe(504);
486 expect(yield* readJson(response)).toEqual({ error: "environment_timeout" });
487 }),
488 );
489
490 it.effect("holds a timed-out request with the time it arrived", () =>
491 Effect.gen(function* () {
492 const reachedUpstream = yield* Deferred.make<void>();
493 const harness = makeHarness({
494 links: [{ ...managedLink, holdWebhooksWhileOffline: true }],
495 execute: () =>
496 Deferred.succeed(reachedUpstream, undefined).pipe(Effect.andThen(Effect.never)),
497 });
498 const arrivedAt = DateTime.formatIso(yield* DateTime.now);
499 const fiber = yield* harness
500 .send(new Request(hookUrl(), { method: "POST", body: "{}" }))
501 .pipe(Effect.forkChild);
502 yield* Deferred.await(reachedUpstream);
503 yield* TestClock.adjust(Duration.millis(RELAY_HOOK_UPSTREAM_TIMEOUT_MS));
504 expect((yield* Fiber.join(fiber)).status).toBe(202);
505 expect(harness.held[0]?.receivedAt).toBe(arrivedAt);
506 }),
507 );
508
509 it.effect("answers OPTIONS without a CORS preflight or forwarding", () =>
510 Effect.gen(function* () {
511 const harness = makeHarness();
512 const response = yield* harness.send(new Request(hookUrl(), { method: "OPTIONS" }));
513 // No hook endpoint accepts OPTIONS, so it falls through to the 404 route.
514 expect(response.status).toBe(404);
515 expect(response.headers["access-control-allow-origin"]).toBeUndefined();
516 expect(response.headers["access-control-allow-methods"]).toBeUndefined();
517 expect(harness.sent).toHaveLength(0);
518 }),
519 );
520
521 it.effect("drops headers the sender names in Connection", () =>
522 Effect.gen(function* () {
523 const harness = makeHarness();
524 yield* harness.send(
525 new Request(hookUrl(), {
526 method: "POST",
527 body: "{}",
528 headers: { connection: "x-hop, keep-alive", "x-hop": "1", "x-keep": "2" },
529 }),
530 );
531 const sent: Readonly<Record<string, string>> = harness.sent[0]?.headers ?? {};
532 expect(sent["x-hop"]).toBeUndefined();
533 expect(sent["x-keep"]).toBe("2");
534 }),
535 );
536
537 it.effect("gives requests with a wrong token their own budget", () =>
538 Effect.gen(function* () {
539 const harness = makeHarness();
540 yield* harness.send(
541 new Request(hookUrl("hook-1/real-token"), { method: "POST", body: "{}" }),
542 );
543 yield* harness.send(new Request(hookUrl("hook-1/guessed"), { method: "POST", body: "{}" }));
544 expect(new Set(harness.rateLimitKeys).size).toBe(2);
545 }),
546 );
547
548 it.effect("gives two spellings of one token a single budget", () =>
549 Effect.gen(function* () {
550 const harness = makeHarness();
551 yield* harness.send(new Request(hookUrl("hook-1/token"), { method: "POST", body: "{}" }));
552 yield* harness.send(new Request(hookUrl("hook-1/%74oken"), { method: "POST", body: "{}" }));
553 expect(new Set(harness.rateLimitKeys).size).toBe(1);
554 }),
555 );
556
557 it.effect("returns 429 when the hook's budget is spent", () =>
558 Effect.gen(function* () {
559 const harness = makeHarness({ allow: () => false });
560 const response = yield* harness.send(new Request(hookUrl(), { method: "POST" }));
561 expect(response.status).toBe(429);
562 expect(harness.sent).toHaveLength(0);
563 }),
564 );
565
566 it.effect("never records the token in the server span", () =>
567 Effect.gen(function* () {
568 const spans: Array<Tracer.NativeSpan> = [];
569 const tracer = Tracer.make({
570 span: (options) => {
571 const span = new Tracer.NativeSpan(options);
572 spans.push(span);
573 return span;
574 },
575 });
576 const harness = makeHarness();
577 const handler = yield* harness.httpEffect;
578 const response = yield* traceRelayHttpRequestWith(
579 handler,
580 Layer.succeed(Tracer.Tracer, tracer),
581 ).pipe(
582 Effect.provideService(
583 HttpServerRequest.HttpServerRequest,
584 HttpServerRequest.fromWeb(
585 new Request(hookUrl("hook-1/super-secret-token", "?sig=also-secret"), {
586 method: "POST",
587 headers: { "x-gitlab-token": "header-secret" },
588 body: "{}",
589 }),
590 ),
591 ),
592 );
593 expect(response.status).toBe(200);
594 expect(harness.sent[0]?.url).toContain("super-secret-token");
595 yield* Effect.yieldNow;
596 const serialized = spans
597 .flatMap((span) => [span.name, ...Array.from(span.attributes.values(), String)])
598 .join("\n");
599 expect(serialized).not.toContain("super-secret-token");
600 expect(serialized).not.toContain("also-secret");
601 expect(serialized).not.toContain("header-secret");
602 const server = spans.find((span) => span.kind === "server");
603 expect(server?.attributes.get("url.path")).toBe(`/v1/hooks/${endpointKey}/hook-1/redacted`);
604 }),
605 );
606
607 it.effect("records one redacted server span even inside the worker's own HTTP tracer", () =>
608 Effect.gen(function* () {
609 const spans: Array<Tracer.NativeSpan> = [];
610 const tracer = Tracer.make({
611 span: (options) => {
612 const span = new Tracer.NativeSpan(options);
613 spans.push(span);
614 return span;
615 },
616 });
617 const harness = makeHarness();
618 const handler = yield* harness.httpEffect;
619 // As the worker runtime runs it: its own tracer around ours, turned off
620 // (see worker.ts). Whether alchemy applies the predicate per event is
621 // only visible on a deployed worker.
622 yield* HttpMiddleware.tracer(
623 traceRelayHttpRequestWith(handler, Layer.succeed(Tracer.Tracer, tracer)),
624 ).pipe(
625 Effect.provideService(HttpMiddleware.TracerDisabledWhen, () => true),
626 Effect.withTracer(tracer),
627 Effect.provideService(
628 HttpServerRequest.HttpServerRequest,
629 HttpServerRequest.fromWeb(
630 new Request(hookUrl("hook-1/super-secret-token"), {
631 method: "POST",
632 headers: { traceparent: "00-11111111111111111111111111111111-2222222222222222-01" },
633 body: "{}",
634 }),
635 ),
636 ),
637 );
638 yield* Effect.yieldNow;
639 const servers = spans.filter((span) => span.kind === "server");
640 expect(servers).toHaveLength(1);
641 expect(servers[0]?.attributes.get("url.path")).toBe(
642 `/v1/hooks/${endpointKey}/hook-1/redacted`,
643 );
644 expect(spans.every((span) => span.traceId !== "11111111111111111111111111111111")).toBe(true);
645 }),
646 );
647
648 it.effect("joins the environment to its trace and records what the environment did", () =>
649 Effect.gen(function* () {
650 const spans: Array<Tracer.NativeSpan> = [];
651 const tracer = Tracer.make({
652 span: (options) => {
653 const span = new Tracer.NativeSpan(options);
654 spans.push(span);
655 return span;
656 },
657 });
658 const respondWith = (outcome: string) =>
659 makeHarness({
660 execute: (request) =>
661 Effect.succeed(
662 HttpClientResponse.fromWeb(
663 request,
664 new Response('{"deliveryId":"d"}', {
665 status: 202,
666 headers: { "x-t3-hook-outcome": outcome },
667 }),
668 ),
669 ),
670 });
671 const forward = (harness: ReturnType<typeof makeHarness>) =>
672 Effect.gen(function* () {
673 const handler = yield* harness.httpEffect;
674 return yield* traceRelayHttpRequestWith(
675 handler,
676 Layer.succeed(Tracer.Tracer, tracer),
677 ).pipe(
678 Effect.provideService(
679 HttpServerRequest.HttpServerRequest,
680 HttpServerRequest.fromWeb(
681 new Request(hookUrl(), {
682 method: "POST",
683 // A sender's own trace context never reaches the environment.
684 headers: {
685 traceparent: "00-11111111111111111111111111111111-2222222222222222-01",
686 "x-b3-traceid": "33333333333333333333333333333333",
687 },
688 body: "{}",
689 }),
690 ),
691 ),
692 );
693 });
694
695 const duplicate = respondWith("duplicate");
696 expect((yield* forward(duplicate)).status).toBe(202);
697 yield* Effect.yieldNow;
698 const forwardSpan = spans.find((span) => span.name === "relay.hooks.forward");
699 expect(forwardSpan?.attributes.get("relay.hook.upstream_outcome")).toBe("duplicate");
700 const sent = duplicate.sent[0]!;
701 expect(sent.headers.traceparent).toContain(forwardSpan!.traceId);
702 expect(sent.headers.traceparent).not.toContain("1111111111");
703 expect(sent.headers["x-b3-traceid"]).toBeUndefined();
704
705 // Only a plain outcome name is recorded.
706 spans.length = 0;
707 yield* forward(respondWith("<script>alert(1)</script>"));
708 yield* Effect.yieldNow;
709 expect(
710 spans
711 .find((span) => span.name === "relay.hooks.forward")
712 ?.attributes.has("relay.hook.upstream_outcome"),
713 ).toBe(false);
714 }),
715 );
716
717 describe("holding requests while the environment is offline", () => {
718 const offline = (request: HttpClientRequest.HttpClientRequest) =>
719 Effect.fail(
720 new HttpClientError.HttpClientError({
721 reason: new HttpClientError.TransportError({ request, cause: new Error("offline") }),
722 }),
723 );
724
725 it.effect("holds when cloudflared answers that the local server is down", () =>
726 Effect.gen(function* () {
727 for (const status of [502, 503, 504, 530]) {
728 const harness = makeHarness({
729 links: [{ ...managedLink, holdWebhooksWhileOffline: true }],
730 execute: (request) =>
731 Effect.succeed(HttpClientResponse.fromWeb(request, new Response("", { status }))),
732 });
733 const response = yield* harness.send(
734 new Request(hookUrl(), { method: "POST", body: "{}" }),
735 );
736 expect(response.status).toBe(202);
737 expect(harness.held).toHaveLength(1);
738 }
739 }),
740 );
741
742 it.effect("stays a plain proxy when the environment has not opted in", () =>
743 Effect.gen(function* () {
744 const harness = makeHarness({ execute: offline });
745 const response = yield* harness.send(
746 new Request(hookUrl(), { method: "POST", body: "{}" }),
747 );
748 expect(response.status).toBe(503);
749 expect(harness.held).toHaveLength(0);
750 }),
751 );
752
753 it.effect("holds the exact request and answers 202 once opted in", () =>
754 Effect.gen(function* () {
755 const harness = makeHarness({
756 execute: offline,
757 links: [{ ...managedLink, holdWebhooksWhileOffline: true }],
758 });
759 const body = new Uint8Array([0, 255, 10]);
760 const response = yield* harness.send(
761 new Request(hookUrl("%68ook-1/tok%2Fen", "?a=1"), {
762 method: "POST",
763 body,
764 headers: { "x-t3-relay-delivery-id": "forged", "x-sig": "s" },
765 }),
766 );
767 expect(response.status).toBe(202);
768 const [hook] = harness.held;
769 expect(hook?.baseUrl).toBe("https://env.example.test/");
770 expect(hook?.rawToken).toBe("tok%2Fen");
771 expect(hook?.query).toBe("a=1");
772 expect([...(hook?.body ?? [])]).toEqual([0, 255, 10]);
773 expect(hook?.headers["x-sig"]).toBe("s");
774 // Every spelling of the hook id shares one per-hook cap.
775 expect(hook?.hookKey).toBe("hook-1");
776 // The sender cannot choose the delivery id.
777 expect(hook?.headers["x-t3-relay-delivery-id"]).toBeUndefined();
778 expect(hook?.id).not.toBe("forged");
779 }),
780 );
781
782 it.effect("answers 503 inbox_full when the environment's inbox is full", () =>
783 Effect.gen(function* () {
784 const harness = makeHarness({
785 execute: offline,
786 links: [{ ...managedLink, holdWebhooksWhileOffline: true }],
787 inboxCapacity: 0,
788 });
789 const response = yield* harness.send(
790 new Request(hookUrl(), { method: "POST", body: "{}" }),
791 );
792 expect(response.status).toBe(503);
793 expect(yield* readJson(response)).toEqual({ error: "inbox_full" });
794 }),
795 );
796
797 it.effect("tags every forward with a relay delivery id", () =>
798 Effect.gen(function* () {
799 const harness = makeHarness();
800 yield* harness.send(
801 new Request(hookUrl(), {
802 method: "POST",
803 body: "{}",
804 headers: { "x-t3-relay-delivery-id": "forged" },
805 }),
806 );
807 const id = harness.sent[0]?.headers["x-t3-relay-delivery-id"];
808 expect(id).toMatch(/^[0-9a-f-]{36}$/);
809 }),
810 );
811 });
812});