diff --git a/infra/relay/src/hooks/HookForwarder.test.ts b/infra/relay/src/hooks/HookForwarder.test.ts index b00b4f9d750d8182d89f0a5378c553cc642ad136..dad1431a133f804a1a4fdec8b20c9936f2e966f1 100644 --- a/infra/relay/src/hooks/HookForwarder.test.ts +++ b/infra/relay/src/hooks/HookForwarder.test.ts @@ -19,6 +19,7 @@ import * as HttpClientError from "effect/http/HttpClientError"; import type * as HttpClientRequest from "effect/http/HttpClientRequest"; import * as HttpClientResponse from "effect/http/HttpClientResponse"; import * as Etag from "effect/http/Etag"; +import * as HttpEffect from "effect/http/HttpEffect"; import * as HttpRouter from "effect/http/HttpRouter"; import * as HttpApi from "effect/http-api/HttpApi"; import * as HttpApiBuilder from "effect/http-api/HttpApiBuilder"; @@ -171,15 +172,21 @@ function makeHarness(options: Harness = {}) { RelayHttpApi.layerCors, ), ).pipe(Effect.provideService(HttpRouter.RouterConfig, RELAY_HTTP_ROUTER_CONFIG)); + // Goes through Effect's request handler, which applies pre-response handlers + // (such as CORS) to the response it sends, as the Workers runtime does. const send = (request: Request) => Effect.gen(function* () { const handler = yield* httpEffect; - return yield* handler.pipe( + const sent = yield* Deferred.make(); + yield* HttpEffect.toHandled(handler, (_request, response) => + Deferred.succeed(sent, response), + ).pipe( Effect.provideService( HttpServerRequest.HttpServerRequest, HttpServerRequest.fromWeb(request), ), ); + return yield* Deferred.await(sent); }); return { sent, rateLimitKeys, held, send, httpEffect }; } diff --git a/infra/relay/src/http/Api.test.ts b/infra/relay/src/http/Api.test.ts index b0e047bd829ccd6426a966998d95e795d54e8cf3..4df0a27181ef0c7f13cd5490f9dac7413ce5b244 100644 --- a/infra/relay/src/http/Api.test.ts +++ b/infra/relay/src/http/Api.test.ts @@ -14,8 +14,10 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { describe, expect, it } from "@effect/vitest"; import { vi } from "vite-plus/test"; import * as Context from "effect/Context"; +import * as Data from "effect/Data"; import * as NodeCryptoLayer from "@effect/platform-node/NodeCrypto"; import * as DateTime from "effect/DateTime"; +import * as Deferred from "effect/Deferred"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; @@ -26,6 +28,7 @@ import * as Redacted from "effect/Redacted"; import * as TestClock from "effect/testing/TestClock"; import * as Tracer from "effect/Tracer"; import * as Etag from "effect/http/Etag"; +import * as HttpEffect from "effect/http/HttpEffect"; import * as HttpRouter from "effect/http/HttpRouter"; import * as HttpServerRequest from "effect/http/HttpServerRequest"; import * as HttpServerResponse from "effect/http/HttpServerResponse"; @@ -1319,7 +1322,6 @@ describe("relay routing fallback", () => { it.effect("redirects the relay root to the API docs", () => Effect.gen(function* () { - const request = HttpServerRequest.fromWeb(new Request("https://relay.test/")); const httpEffect = yield* HttpRouter.toHttpEffect( Layer.mergeAll( RelayHttpApi.layerDocsRedirectRoute, @@ -1327,9 +1329,7 @@ describe("relay routing fallback", () => { RelayHttpApi.layerCors, ), ); - const response = yield* httpEffect.pipe( - Effect.provideService(HttpServerRequest.HttpServerRequest, request), - ); + const response = yield* sendRelayRequest(httpEffect, new Request("https://relay.test/")); expect(response.status).toBe(302); expect(response.headers.location).toBe("/docs"); @@ -1339,18 +1339,118 @@ describe("relay routing fallback", () => { it.effect("returns a CORS-compatible 404 response for unmatched paths", () => Effect.gen(function* () { - const request = HttpServerRequest.fromWeb( + const httpEffect = yield* HttpRouter.toHttpEffect( + Layer.merge(RelayHttpApi.layerNotFoundRoute, RelayHttpApi.layerCors), + ); + const response = yield* sendRelayRequest( + httpEffect, new Request("https://relay.test/v1/environmentsd", { method: "GET" }), ); - const httpEffect = yield* HttpRouter.toHttpEffect( - Layer.merge(RelayHttpApi.layerNotFoundRoute, RelayHttpApi.layerCors), - ); - const response = yield* httpEffect.pipe( - Effect.provideService(HttpServerRequest.HttpServerRequest, request), - ); expect(response.status).toBe(404); expect(response.headers["access-control-allow-origin"]).toBe("*"); }).pipe(Effect.scoped), ); }); + +describe("relay CORS", () => { + const origin = "https://app.t3.codes"; + + class HandlerFailed extends Data.TaggedError("HandlerFailed") {} + + it.effect("answers preflight requests without reaching a route", () => + Effect.gen(function* () { + const httpEffect = yield* HttpRouter.toHttpEffect( + Layer.merge(RelayHttpApi.layerNotFoundRoute, RelayHttpApi.layerCors), + ); + const response = yield* sendRelayRequest( + httpEffect, + new Request("https://relay.test/v1/client/environments", { + method: "OPTIONS", + headers: { + origin, + "access-control-request-method": "POST", + "access-control-request-headers": "authorization,dpop,content-type", + }, + }), + ); + + expect(response.status).toBe(204); + expect( + response.headers["access-control-allow-methods"]?.split(",").map((method) => method.trim()), + ).toEqual(["GET", "POST", "DELETE", "OPTIONS"]); + expect(response.headers).toMatchObject({ + "access-control-allow-origin": "*", + "access-control-allow-headers": "authorization,b3,traceparent,content-type,dpop", + "access-control-expose-headers": "traceparent,www-authenticate", + "access-control-max-age": "86400", + }); + }).pipe(Effect.scoped), + ); + + it.effect("adds CORS headers to handler failures and defects", () => + Effect.gen(function* () { + const httpEffect = yield* HttpRouter.toHttpEffect( + Layer.mergeAll( + HttpRouter.add("GET", "/v1/fail", Effect.fail(new HandlerFailed())), + HttpRouter.add("GET", "/v1/die", Effect.die(new Error("handler defect"))), + RelayHttpApi.layerNotFoundRoute, + RelayHttpApi.layerCors, + ), + ); + for (const path of ["/v1/fail", "/v1/die"]) { + const response = yield* sendRelayRequest( + httpEffect, + new Request(`https://relay.test${path}`, { headers: { origin } }), + ); + + expect(response.status).toBe(500); + expect(response.headers["access-control-allow-origin"]).toBe("*"); + expect(response.headers["access-control-expose-headers"]).toBe( + "traceparent,www-authenticate", + ); + } + }).pipe(Effect.scoped), + ); + + it.effect("adds CORS headers to the request deadline response", () => + Effect.gen(function* () { + const httpEffect = yield* HttpRouter.toHttpEffect( + Layer.mergeAll( + HttpRouter.add("GET", "/v1/hang", Effect.never), + RelayHttpApi.layerNotFoundRoute, + RelayHttpApi.layerCors, + ), + ); + const fiber = yield* sendRelayRequest( + traceRelayHttpRequestWith(httpEffect, Layer.empty), + new Request("https://relay.test/v1/hang", { headers: { origin } }), + ).pipe(Effect.forkChild); + yield* TestClock.adjust(Duration.millis(RELAY_REQUEST_DEADLINE_MS)); + const response = yield* Fiber.join(fiber); + + expect(response.status).toBe(504); + expect(response.headers["access-control-allow-origin"]).toBe("*"); + expect(response.headers["access-control-expose-headers"]).toBe( + "traceparent,www-authenticate", + ); + }).pipe(Effect.scoped), + ); +}); + +// Sends a request through Effect's request handler, which applies pre-response +// handlers to the response it sends, as the Workers runtime does. +const sendRelayRequest = Effect.fnUntraced(function* ( + httpEffect: Effect.Effect, + request: Request, +) { + const sent = yield* Deferred.make(); + yield* HttpEffect.toHandled(httpEffect, (_request, response) => + Deferred.succeed(sent, response), + ).pipe( + Effect.provideService(HttpServerRequest.HttpServerRequest, HttpServerRequest.fromWeb(request)), + // A handler failure still fails this effect after its 500 has been sent. + Effect.exit, + ); + return yield* Deferred.await(sent); +}); diff --git a/infra/relay/src/http/Api.ts b/infra/relay/src/http/Api.ts index ed8601d399eddc151a8f99b58a4108ec71293e6b..7e146022b8c5b238e537f70cb55d797c186e8c3a 100644 --- a/infra/relay/src/http/Api.ts +++ b/infra/relay/src/http/Api.ts @@ -88,28 +88,6 @@ export const RELAY_HTTP_ROUTER_CONFIG = { maxParamLength: 512, } as const; -const relayCorsAllowedMethods = ["GET", "POST", "DELETE", "OPTIONS"] as const; -const relayCorsAllowedHeaders = [ - "authorization", - "b3", - "traceparent", - "content-type", - "dpop", -] as const; -const relayCorsExposedHeaders = ["traceparent", "www-authenticate"] as const; - -const relayCorsHeaders = { - "access-control-allow-origin": "*", - "access-control-expose-headers": relayCorsExposedHeaders.join(","), -} as const; - -const relayCorsPreflightHeaders = { - ...relayCorsHeaders, - "access-control-allow-methods": relayCorsAllowedMethods.join(","), - "access-control-allow-headers": relayCorsAllowedHeaders.join(","), - "access-control-max-age": "86400", -} as const; - const decodeManagedTunnelRecoveryProof = Schema.decodeUnknownEffect( RelayManagedEndpointRecoveryProofPayload, ); @@ -146,6 +124,16 @@ const appendRelayTraceContextResponseHeader = Effect.gen(function* () { ); }).pipe(Effect.ignore); +const relayCorsMiddleware = HttpMiddleware.cors({ + allowedMethods: ["GET", "POST", "DELETE", "OPTIONS"], + allowedHeaders: ["authorization", "b3", "traceparent", "content-type", "dpop"], + exposedHeaders: ["traceparent", "www-authenticate"], + maxAge: 86_400, +}); + +// The CORS headers come from a pre-response handler, so they reach every response +// the request sends: handler failures and defects, and the deadline 504 that +// `traceRelayHttpRequest` produces outside the router. export const layerCors = HttpRouter.middleware( Effect.fnUntraced(function* ( httpEffect: Effect.Effect< @@ -159,14 +147,7 @@ export const layerCors = HttpRouter.middleware( if (isRelayHookPath(request.url)) { return yield* httpEffect; } - if (request.method === "OPTIONS") { - return HttpServerResponse.empty({ - status: 204, - headers: relayCorsPreflightHeaders, - }); - } - const response = yield* httpEffect; - return HttpServerResponse.setHeaders(response, relayCorsHeaders); + return yield* relayCorsMiddleware(httpEffect); }), { global: true }, );