diff --git a/packages/stack/src/ApiProxy.ts b/packages/stack/src/ApiProxy.ts index 12f258235e..78dda3ec3d 100644 --- a/packages/stack/src/ApiProxy.ts +++ b/packages/stack/src/ApiProxy.ts @@ -98,9 +98,23 @@ const CORS_HEADERS: ReadonlyArray = [ function addCorsHeaders( response: HttpServerResponse.HttpServerResponse, + requestedHeaders?: string, ): HttpServerResponse.HttpServerResponse { + // On a preflight, echo the requested headers: browser clients evolve + // faster than any static list (supabase-js added x-supabase-api-version; + // PostgREST reads Prefer and the profile headers), and echoing is what + // Kong's cors plugin — the classic CLI's proxy — does when no header + // list is configured. The static list stays as the fallback for + // preflights that name no headers and for non-preflight responses. return CORS_HEADERS.reduce( - (res, [name, value]) => HttpServerResponse.setHeader(res, name, value), + (res, [name, value]) => + HttpServerResponse.setHeader( + res, + name, + name === "access-control-allow-headers" && requestedHeaders !== undefined + ? requestedHeaders + : value, + ), response, ); } @@ -370,7 +384,10 @@ export class ApiProxy extends Context.Service< const req = yield* HttpServerRequest.HttpServerRequest; if (req.method === "OPTIONS") { - return addCorsHeaders(HttpServerResponse.empty({ status: 204 })); + return addCorsHeaders( + HttpServerResponse.empty({ status: 204 }), + req.headers["access-control-request-headers"], + ); } const response = yield* httpEffect; diff --git a/packages/stack/src/ApiProxy.unit.test.ts b/packages/stack/src/ApiProxy.unit.test.ts index d2b3a00bbc..6a3b3f18f4 100644 --- a/packages/stack/src/ApiProxy.unit.test.ts +++ b/packages/stack/src/ApiProxy.unit.test.ts @@ -205,6 +205,33 @@ describe("ApiProxy", () => { expect(res.headers.get("access-control-allow-origin")).toBe("*"); }); + test("preflight echoes the requested headers", async () => { + // Browser clients evolve faster than any static allow list + // (supabase-js's x-supabase-api-version, PostgREST's Prefer); the + // preflight reflects what was asked for — the same default Kong's cors + // plugin gives the classic CLI's proxy. + const res = await fetch(`${proxyUrl}/rest/v1/users`, { + method: "OPTIONS", + headers: { + "access-control-request-headers": "authorization,prefer,x-supabase-api-version", + }, + }); + expect(res.status).toBe(204); + expect(res.headers.get("access-control-allow-headers")).toBe( + "authorization,prefer,x-supabase-api-version", + ); + }); + + test("the echo is preflight-only: non-OPTIONS requests keep the static list", async () => { + const res = await fetch(`${proxyUrl}/health`, { + headers: { + "access-control-request-headers": "x-arbitrary-header", + }, + }); + expect(res.headers.get("access-control-allow-headers")).toContain("apikey"); + expect(res.headers.get("access-control-allow-headers")).not.toContain("x-arbitrary-header"); + }); + // --------------------------------------------------------------------------- // Auth transformation — publishableKey → anonJwt // ---------------------------------------------------------------------------