diff --git a/apps/backend/src/server/app.ts b/apps/backend/src/server/app.ts index 40631e44c..047278972 100644 --- a/apps/backend/src/server/app.ts +++ b/apps/backend/src/server/app.ts @@ -4,10 +4,10 @@ import { NextNotFoundError } from "@/lib/runtime/navigation"; import { parseCookieHeader, requestContextALS, type RequestContext } from "@/lib/runtime/request-context"; import { node } from "@elysiajs/node"; import { getEnvVariable, getNodeEnvironment } from "@hexclave/shared/dist/utils/env"; -import { captureError } from "@hexclave/shared/dist/utils/errors"; import { Elysia } from "elysia"; -import { runRequestPipeline } from "./middleware"; import { createBackendRequest } from "./backend-request"; +import { handleUncaughtBackendError } from "./error-handler"; +import { runRequestPipeline } from "./middleware"; import { MalformedRouteParamError, matchRoute } from "./registry"; const globalSecurityHeaders = { @@ -40,13 +40,7 @@ export const app = new Elysia({ const pathname = new URL(request.url).pathname; console.log(`[Elysia] ${request.method} ${pathname} ${set.status} ${elapsedMilliseconds}ms`); }) - .onError(({ error }) => { - // Smart route handlers sanitize their own errors. This is the final boundary - // for errors from raw routes and framework code, which Elysia would otherwise - // return to the client verbatim. - captureError("elysia-request-handler", error); - return internalServerErrorResponse(); - }) + .onError(({ error }) => withGlobalHeaders(handleUncaughtBackendError(error))) .get("/", () => htmlResponse(homeHtml())) .get("/dev-stats", () => htmlResponse(devStatsHtml())) .get("/health/error-handler-debug", () => htmlResponse(errorHandlerDebugHtml())) @@ -169,23 +163,6 @@ function htmlResponse(body: string, status = 200) { })); } -function internalServerErrorResponse() { - return withGlobalHeaders(new Response("Internal Server Error", { - status: 500, - headers: { - "content-type": "text/plain; charset=utf-8", - }, - })); -} - -import.meta.vitest?.test("unhandled errors do not expose their message", async ({ expect }) => { - const response = internalServerErrorResponse(); - - expect(response.status).toBe(500); - expect(await response.text()).toBe("Internal Server Error"); - expect(response.headers.get("x-content-type-options")).toBe("nosniff"); -}); - import.meta.vitest?.test("API version migrations do not expose their internal rewrite", async ({ expect }) => { const { vi } = import.meta.vitest!; vi.stubEnv("HEXCLAVE_ARTIFICIAL_DEVELOPMENT_DELAY_MS", "0"); diff --git a/apps/backend/src/server/error-handler.ts b/apps/backend/src/server/error-handler.ts new file mode 100644 index 000000000..fa7caf051 --- /dev/null +++ b/apps/backend/src/server/error-handler.ts @@ -0,0 +1,31 @@ +import { captureError } from "@hexclave/shared/dist/utils/errors"; + +function createUncaughtErrorResponse() { + return new Response("Internal Server Error", { + status: 500, + headers: { + "content-type": "text/plain; charset=utf-8", + }, + }); +} + +export function handleUncaughtBackendError(error: unknown) { + captureError("backend-global-error", error); + return createUncaughtErrorResponse(); +} + +import.meta.vitest?.test("uncaught backend errors do not expose internal details", async ({ expect }) => { + const response = createUncaughtErrorResponse(); + + expect({ + status: response.status, + contentType: response.headers.get("content-type"), + body: await response.text(), + }).toMatchInlineSnapshot(` + { + "body": "Internal Server Error", + "contentType": "text/plain; charset=utf-8", + "status": 500, + } + `); +}); diff --git a/apps/backend/src/smart-router.tsx b/apps/backend/src/smart-router.tsx index ceca8831b..2dfa3e992 100644 --- a/apps/backend/src/smart-router.tsx +++ b/apps/backend/src/smart-router.tsx @@ -103,9 +103,11 @@ function parseApiVersionStringToArray(version: string): [number, number] { function matchPath(path: string, toMatchWith: string): Record | false { - // get the relative part, and modify it to have a leading slash, without a trailing slash, without ./.., etc. - const url = new URL(path + "/", "http://example.com"); - const modifiedPath = url.pathname.slice(1, -1); + // Normalize to relative path segments without a trailing slash. Appending "/" before URL parsing + // turns an already-trailing path into "//", which URL interprets as a protocol-relative URL during + // the final recursive match and rejects because it has no host. + const url = new URL(path === "" ? "/" : path, "http://example.com"); + const modifiedPath = url.pathname.slice(1).replace(/\/+$/, ""); const modifiedToMatchWith = toMatchWith.slice(1); if (modifiedPath === "" && modifiedToMatchWith === "") { @@ -146,6 +148,20 @@ function matchPath(path: string, toMatchWith: string): Record { + expect({ + staticRoute: SmartRouter.matchNormalizedPath("/api/v1/", "/api/v1"), + dynamicRoute: SmartRouter.matchNormalizedPath("/users/user-123/", "/users/[user_id]"), + }).toMatchInlineSnapshot(` + { + "dynamicRoute": { + "user_id": "user-123", + }, + "staticRoute": {}, + } + `); +}); + /** * Modified from: https://github.com/vercel/next.js/blob/6ff13369bb18045657d0f84ddc86b540340603a1/packages/next/src/shared/lib/router/utils/app-paths.ts#L23 */ diff --git a/apps/e2e/tests/backend/endpoints/api/v1/index.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/index.test.ts index 9f636f92f..74ae165d4 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/index.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/index.test.ts @@ -10,6 +10,21 @@ describe("without project ID", () => { it("should load", async ({ expect }) => { const response = await niceBackendFetch("/api/v1"); + expect(response).toMatchInlineSnapshot(` + NiceResponse { + "status": 200, + "body": deindent\` + Welcome to the Hexclave API endpoint! Please refer to the documentation at https://docs.hexclave.com. + + Authentication: None + \`, + "headers": Headers {