| 1 | import * as NodeHttpPlatform from "@effect/platform-node/NodeHttpPlatform"; |
| 2 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 3 | // @effect-diagnostics-next-line nodeBuiltinImport:off -- Effect's Crypto has no generateKeyPairSync. |
| 4 | import * as NodeCrypto from "node:crypto"; |
| 5 | import * as EffectNodeCrypto from "@effect/platform-node/NodeCrypto"; |
| 6 | import { describe, expect, it } from "@effect/vitest"; |
| 7 | import { RelayApi } from "@t3tools/contracts/relay"; |
| 8 | import * as DateTime from "effect/DateTime"; |
| 9 | import * as Deferred from "effect/Deferred"; |
| 10 | import * as Duration from "effect/Duration"; |
| 11 | import * as Clock from "effect/Clock"; |
| 12 | import * as Effect from "effect/Effect"; |
| 13 | import * as Fiber from "effect/Fiber"; |
| 14 | import * as Layer from "effect/Layer"; |
| 15 | import * as Redacted from "effect/Redacted"; |
| 16 | import * as TestClock from "effect/testing/TestClock"; |
| 17 | import * as Tracer from "effect/Tracer"; |
| 18 | import * as HttpClient from "effect/http/HttpClient"; |
| 19 | import * as HttpClientError from "effect/http/HttpClientError"; |
| 20 | import type * as HttpClientRequest from "effect/http/HttpClientRequest"; |
| 21 | import * as HttpClientResponse from "effect/http/HttpClientResponse"; |
| 22 | import * as Etag from "effect/http/Etag"; |
| 23 | import * as HttpEffect from "effect/http/HttpEffect"; |
| 24 | import * as HttpRouter from "effect/http/HttpRouter"; |
| 25 | import * as HttpApi from "effect/http-api/HttpApi"; |
| 26 | import * as HttpApiBuilder from "effect/http-api/HttpApiBuilder"; |
| 27 | import * as HttpMiddleware from "effect/http/HttpMiddleware"; |
| 28 | import * as HttpServerRequest from "effect/http/HttpServerRequest"; |
| 29 | import * as HttpServerResponse from "effect/http/HttpServerResponse"; |
| 30 | |
| 31 | import * as RelayConfiguration from "../Config.ts"; |
| 32 | import * as EnvironmentLinks from "../environments/EnvironmentLinks.ts"; |
| 33 | import * as ManagedEndpointAllocations from "../environments/ManagedEndpointAllocations.ts"; |
| 34 | import { RELAY_HTTP_ROUTER_CONFIG, traceRelayHttpRequestWith } from "../http/Api.ts"; |
| 35 | import * as RelayHttpApi from "../http/Api.ts"; |
| 36 | import * as HookForwarder from "./HookForwarder.ts"; |
| 37 | import { RELAY_HOOK_DELIVERY_TYP, verifyRelayJwt } from "@t3tools/shared/relayJwt"; |
| 38 | import * as HeldHooks from "./HeldHooks.ts"; |
| 39 | import * as HookInbox from "./HookInbox.ts"; |
| 40 | import type { HeldHook } from "./HookInboxStore.ts"; |
| 41 | import { RELAY_HOOK_UPSTREAM_TIMEOUT_MS } from "./upstream.ts"; |
| 42 | |
| 43 | const mintKeys = NodeCrypto.generateKeyPairSync("ed25519", { |
| 44 | privateKeyEncoding: { format: "pem", type: "pkcs8" }, |
| 45 | publicKeyEncoding: { format: "pem", type: "spki" }, |
| 46 | }); |
| 47 | |
| 48 | const 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 | |
| 61 | const environmentId = "env-hook"; |
| 62 | const endpointKey = "0123456789abcdef"; |
| 63 | const 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 | |
| 77 | const 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 | |
| 91 | interface 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 | |
| 103 | function 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 | |
| 195 | const hookUrl = (path = "hook-1/secret-token", query = "") => |
| 196 | `https://relay.test/v1/hooks/${endpointKey}/${path}${query}`; |
| 197 | |
| 198 | const readBody = (response: HttpServerResponse.HttpServerResponse) => |
| 199 | Effect.promise(() => HttpServerResponse.toWeb(response).arrayBuffer()).pipe( |
| 200 | Effect.map((buffer) => new Uint8Array(buffer)), |
| 201 | ); |
| 202 | const readJson = (response: HttpServerResponse.HttpServerResponse) => |
| 203 | Effect.promise(() => HttpServerResponse.toWeb(response).json()); |
| 204 | |
| 205 | const requestBytes = (request: HttpClientRequest.HttpClientRequest) => |
| 206 | request.body._tag === "Uint8Array" ? request.body.body : new Uint8Array(0); |
| 207 | |
| 208 | describe("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 | }); |