diff --git a/apps/backend/src/server/app.ts b/apps/backend/src/server/app.ts
index 2bc0c5dba..f126b4baa 100644
--- a/apps/backend/src/server/app.ts
+++ b/apps/backend/src/server/app.ts
@@ -7,7 +7,7 @@ import { getEnvVariable, getNodeEnvironment } from "@hexclave/shared/dist/utils/
import { Elysia } from "elysia";
import { createBackendRequest } from "./backend-request";
import { handleUncaughtBackendError } from "./error-handler";
-import { runRequestPipeline } from "./middleware";
+import { getCorsHeadersInit, runRequestPipeline } from "./middleware";
import { MalformedRouteParamError, matchRoute } from "./registry";
const globalSecurityHeaders = {
@@ -30,7 +30,7 @@ export const app = new Elysia({
developmentRequestStartTimes.set(request, performance.now());
}
})
- .onAfterResponse(({ request, set }) => {
+ .onAfterResponse(({ request, response, set }) => {
if (!shouldLogDevelopmentRequests) {
return;
}
@@ -38,9 +38,9 @@ export const app = new Elysia({
const startTime = developmentRequestStartTimes.get(request);
const elapsedMilliseconds = startTime == null ? "unknown" : (performance.now() - startTime).toFixed(1);
const pathname = new URL(request.url).pathname;
- console.log(`[Elysia] ${request.method} ${pathname} ${set.status} ${elapsedMilliseconds}ms`);
+ console.log(`[Elysia] ${request.method} ${pathname} ${getLoggedResponseStatus(response, set.status)} ${elapsedMilliseconds}ms`);
})
- .onError(({ error }) => withGlobalHeaders(handleUncaughtBackendError(error)))
+ .onError(({ error, request }) => withResponseHeaders(handleUncaughtBackendError(error), getCorsHeadersInit(request)))
.get("/", () => htmlResponse(homeHtml()))
.get("/dev-stats", () => htmlResponse(devStatsHtml()))
.get("/health/error-handler-debug", () => htmlResponse(errorHandlerDebugHtml()))
@@ -54,7 +54,7 @@ export const app = new Elysia({
export async function dispatch(request: Request) {
const pipeline = await runRequestPipeline(request);
if (pipeline.shortCircuitResponse != null) {
- return withGlobalHeaders(pipeline.shortCircuitResponse);
+ return withResponseHeaders(pipeline.shortCircuitResponse, pipeline.corsHeadersInit);
}
let match;
@@ -62,30 +62,30 @@ export async function dispatch(request: Request) {
match = matchRoute(pipeline.dispatchPath);
} catch (error) {
if (error instanceof MalformedRouteParamError) {
- return withGlobalHeaders(new Response("Bad Request", { status: 400 }));
+ return withResponseHeaders(new Response("Bad Request", { status: 400 }), pipeline.corsHeadersInit);
}
throw error;
}
if (match == null) {
- return withGlobalHeaders(new Response("
404 Not Found
", {
+ return withResponseHeaders(new Response("404 Not Found
", {
status: 404,
headers: {
"content-type": "text/html; charset=utf-8",
},
- }));
+ }), pipeline.corsHeadersInit);
}
const method = request.method.toUpperCase();
if (!isHttpMethod(method)) {
- return withGlobalHeaders(new Response(null, {
+ return withResponseHeaders(new Response(null, {
status: 405,
- }));
+ }), pipeline.corsHeadersInit);
}
const handler = match.methods.get(method) ?? (method === "HEAD" ? match.methods.get("GET") : undefined);
if (handler == null) {
- return withGlobalHeaders(new Response(null, {
+ return withResponseHeaders(new Response(null, {
status: 405,
- }));
+ }), pipeline.corsHeadersInit);
}
const backendRequest = createBackendRequest(request, pipeline.mergedHeaders, pipeline.originalUrl);
@@ -129,14 +129,18 @@ export async function dispatch(request: Request) {
for (const cookie of context.deletedCookies) {
finalResponse.headers.append("Set-Cookie", serializeSetCookie(cookie.name, cookie.value, cookie.options));
}
- if (pipeline.corsHeadersInit != null) {
- for (const [key, value] of Object.entries(pipeline.corsHeadersInit)) {
- finalResponse.headers.set(key, value);
- }
- }
- return withGlobalHeaders(finalResponse);
+ return withResponseHeaders(finalResponse, pipeline.corsHeadersInit);
}
+function getLoggedResponseStatus(response: unknown, fallbackStatus: number | string | undefined) {
+ return response instanceof Response ? response.status : fallbackStatus;
+}
+
+import.meta.vitest?.test("development logging uses the returned Response status", ({ expect }) => {
+ expect(getLoggedResponseStatus(new Response(null, { status: 404 }), 200)).toBe(404);
+ expect(getLoggedResponseStatus("response body", 201)).toBe(201);
+});
+
function isRedirectError(error: unknown): error is { redirectStatus: 307 | 308, redirectUrl: string } {
if (!(error instanceof Error) || !("digest" in error) || !("redirectUrl" in error) || !("redirectStatus" in error)) {
return false;
@@ -154,6 +158,15 @@ function withGlobalHeaders(response: Response) {
return response;
}
+function withResponseHeaders(response: Response, corsHeadersInit?: HeadersInit) {
+ if (corsHeadersInit != null) {
+ for (const [key, value] of new Headers(corsHeadersInit)) {
+ response.headers.set(key, value);
+ }
+ }
+ return withGlobalHeaders(response);
+}
+
function htmlResponse(body: string, status = 200) {
return withGlobalHeaders(new Response(body, {
status,
@@ -178,6 +191,39 @@ import.meta.vitest?.test("API version migrations do not expose their internal re
}
});
+import.meta.vitest?.test("dispatcher-generated API errors retain CORS headers", async ({ expect }) => {
+ const { vi } = import.meta.vitest!;
+ vi.stubEnv("HEXCLAVE_ARTIFICIAL_DEVELOPMENT_DELAY_MS", "0");
+ vi.stubEnv("STACK_ARTIFICIAL_DEVELOPMENT_DELAY_MS", "0");
+
+ try {
+ const [notFound, methodNotAllowed] = await Promise.all([
+ app.handle(new Request("http://localhost/api/latest/this-route-does-not-exist")),
+ app.handle(new Request("http://localhost/api/v2beta1/migration-tests/smart-route-handler", {
+ method: "POST",
+ })),
+ ]);
+
+ expect([
+ { status: notFound.status, allowOrigin: notFound.headers.get("access-control-allow-origin") },
+ { status: methodNotAllowed.status, allowOrigin: methodNotAllowed.headers.get("access-control-allow-origin") },
+ ]).toMatchInlineSnapshot(`
+ [
+ {
+ "allowOrigin": "*",
+ "status": 404,
+ },
+ {
+ "allowOrigin": "*",
+ "status": 405,
+ },
+ ]
+ `);
+ } finally {
+ vi.unstubAllEnvs();
+ }
+});
+
import.meta.vitest?.test("uncaught dispatch errors use the global sanitized error boundary", async ({ expect }) => {
const { vi } = import.meta.vitest!;
vi.stubEnv("NODE_ENV", "production");
@@ -190,10 +236,12 @@ import.meta.vitest?.test("uncaught dispatch errors use the global sanitized erro
expect({
status: response.status,
body: await response.text(),
+ accessControlAllowOrigin: response.headers.get("access-control-allow-origin"),
contentType: response.headers.get("content-type"),
contentTypeOptions: response.headers.get("x-content-type-options"),
}).toMatchInlineSnapshot(`
{
+ "accessControlAllowOrigin": "*",
"body": "Internal Server Error",
"contentType": "text/plain; charset=utf-8",
"contentTypeOptions": "nosniff",
diff --git a/apps/backend/src/server/middleware.ts b/apps/backend/src/server/middleware.ts
index 93ec12903..178f9c376 100644
--- a/apps/backend/src/server/middleware.ts
+++ b/apps/backend/src/server/middleware.ts
@@ -47,6 +47,17 @@ function withHexclaveHeaderAliases(headers: string[]): string[] {
const corsAllowedRequestHeadersWithAliases = withHexclaveHeaderAliases(corsAllowedRequestHeaders);
const corsAllowedResponseHeadersWithAliases = withHexclaveHeaderAliases(corsAllowedResponseHeaders);
+export function getCorsHeadersInit(request: Request): HeadersInit | undefined {
+ return new URL(request.url).pathname.startsWith("/api/") ? {
+ "Access-Control-Allow-Origin": "*",
+ "Access-Control-Allow-Methods": "GET, POST, PUT, PATCH, DELETE, OPTIONS",
+ "Access-Control-Max-Age": "86400",
+ "Access-Control-Allow-Headers": corsAllowedRequestHeadersWithAliases.join(", "),
+ "Access-Control-Expose-Headers": corsAllowedResponseHeadersWithAliases.join(", "),
+ "Vary": corsAllowedRequestHeadersWithAliases.join(", "),
+ } : undefined;
+}
+
export type PipelineResult = {
corsHeadersInit?: HeadersInit,
dispatchPath: string,
@@ -59,27 +70,21 @@ export async function runRequestPipeline(request: Request): Promise 0 && now - devRateLimitMarks[0] > DEV_RATE_LIMIT_WINDOW_MS) {
devRateLimitMarks.shift();
@@ -146,6 +151,18 @@ function mergeHexclaveHeaderAliases(headers: Headers) {
return newRequestHeaders;
}
+function isArtificialDevelopmentBehaviorDisabled(headers: Headers) {
+ return Boolean(headers.get("x-stack-disable-artificial-development-delay"));
+}
+
+import.meta.vitest?.test("Hexclave header aliases control artificial development behavior", ({ expect }) => {
+ const mergedHeaders = mergeHexclaveHeaderAliases(new Headers({
+ "x-hexclave-disable-artificial-development-delay": "true",
+ }));
+
+ expect(isArtificialDevelopmentBehaviorDisabled(mergedHeaders)).toBe(true);
+});
+
const clientIpForwardingHeaders = ["x-forwarded-for", "x-real-ip", "x-vercel-forwarded-for", "cf-connecting-ip"];
function ensureForwardedForHeader(headers: Headers, request: Request) {
diff --git a/apps/e2e/tests/backend/endpoints/api/migration-tests.test.ts b/apps/e2e/tests/backend/endpoints/api/migration-tests.test.ts
index c7a36b778..49e559dc6 100644
--- a/apps/e2e/tests/backend/endpoints/api/migration-tests.test.ts
+++ b/apps/e2e/tests/backend/endpoints/api/migration-tests.test.ts
@@ -27,6 +27,7 @@ describe("SmartRouteHandler", () => {
"headers": Headers { },
}
`);
+ expect(response.headers.get("access-control-allow-origin")).toMatchInlineSnapshot(`"*"`);
});
});