From 17701ea732f7002a1c4306431eef519d62d0c79f Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 26 May 2026 14:40:41 -0700 Subject: [PATCH 1/2] improvement(api): use HttpError base class for typed-error status mapping --- apps/sim/executor/utils/block-reference.ts | 3 ++- apps/sim/lib/core/utils/http-error.ts | 19 +++++++++++++++ apps/sim/lib/core/utils/with-route-handler.ts | 23 +++++++++++-------- apps/sim/lib/workspaces/permissions/utils.ts | 3 ++- 4 files changed, 37 insertions(+), 11 deletions(-) create mode 100644 apps/sim/lib/core/utils/http-error.ts diff --git a/apps/sim/executor/utils/block-reference.ts b/apps/sim/executor/utils/block-reference.ts index 082a9339782..95a49d3d0de 100644 --- a/apps/sim/executor/utils/block-reference.ts +++ b/apps/sim/executor/utils/block-reference.ts @@ -1,3 +1,4 @@ +import { HttpError } from '@/lib/core/utils/http-error' import { USER_FILE_ACCESSIBLE_PROPERTIES } from '@/lib/workflows/types' import { normalizeName } from '@/executor/constants' import { @@ -30,7 +31,7 @@ export interface BlockReferenceResult { blockId: string } -export class InvalidFieldError extends Error { +export class InvalidFieldError extends HttpError { readonly statusCode = 400 constructor( diff --git a/apps/sim/lib/core/utils/http-error.ts b/apps/sim/lib/core/utils/http-error.ts new file mode 100644 index 00000000000..5c77ebe54ba --- /dev/null +++ b/apps/sim/lib/core/utils/http-error.ts @@ -0,0 +1,19 @@ +/** + * Base class for domain errors that map to a specific HTTP status when they + * bubble up unhandled through `withRouteHandler`. Modeled after NestJS + * `HttpException` / Spring `ResponseStatusException`: subclasses declare a + * concrete `statusCode`, and the centralized route wrapper uses an + * `instanceof HttpError` check (not duck-typing on a `statusCode` property) + * to decide whether to forward the error's `message` to the client. + * + * Using a class check prevents third-party errors that happen to carry a + * `statusCode`-shaped field from being treated as typed HTTP errors and + * leaking internal details. + * + * Subclasses MUST ensure that `message` is safe to expose to clients — no + * stack traces, secrets, file paths, ORM internals, or upstream provider + * details. + */ +export abstract class HttpError extends Error { + abstract readonly statusCode: number +} diff --git a/apps/sim/lib/core/utils/with-route-handler.ts b/apps/sim/lib/core/utils/with-route-handler.ts index 44857eba011..11217dd78db 100644 --- a/apps/sim/lib/core/utils/with-route-handler.ts +++ b/apps/sim/lib/core/utils/with-route-handler.ts @@ -2,6 +2,7 @@ import { createLogger, runWithRequestContext } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import type { NextRequest } from 'next/server' import { NextResponse } from 'next/server' +import { HttpError } from '@/lib/core/utils/http-error' import { generateRequestId } from '@/lib/core/utils/request' const logger = createLogger('RouteHandler') @@ -12,20 +13,24 @@ type RouteHandler = ( ) => Promise | NextResponse | Response /** - * Reads a numeric `statusCode` (4xx or 5xx) off an Error so typed domain errors - * (e.g. `WorkspaceAccessDeniedError`, `InvalidFieldError`) map to the correct - * HTTP status when they bubble up unhandled instead of defaulting to 500. + * Reads a numeric `statusCode` (4xx or 5xx) off an `HttpError` so typed domain + * errors (e.g. `WorkspaceAccessDeniedError`, `InvalidFieldError`) map to the + * correct HTTP status when they bubble up unhandled instead of defaulting to + * 500. + * + * Uses an `instanceof HttpError` check (not duck-typing on `statusCode`) so + * third-party errors that happen to carry a `statusCode`-shaped field cannot + * trigger this path and leak their internal `message` to the client. * * When a typed status is returned, the error's `message` is sent to the client * verbatim — matching the NestJS `HttpException` / Spring `ResponseStatusException` - * convention. The safety contract is convention-based: only attach `statusCode` - * to errors whose `message` is safe to expose to clients (no stack traces, - * secrets, file paths, ORM internals). Untyped errors fall back to a generic - * 500 response with no message exposure. + * convention. Subclasses of `HttpError` are responsible for keeping `message` + * safe to expose to clients (no stack traces, secrets, file paths, ORM + * internals). */ function readTypedErrorStatus(error: unknown): number | undefined { - if (!(error instanceof Error)) return undefined - const status = (error as { statusCode?: unknown }).statusCode + if (!(error instanceof HttpError)) return undefined + const status = error.statusCode if (typeof status !== 'number') return undefined if (status < 400 || status >= 600) return undefined return status diff --git a/apps/sim/lib/workspaces/permissions/utils.ts b/apps/sim/lib/workspaces/permissions/utils.ts index 15a5f2b7e7d..dad8ef20d20 100644 --- a/apps/sim/lib/workspaces/permissions/utils.ts +++ b/apps/sim/lib/workspaces/permissions/utils.ts @@ -8,6 +8,7 @@ import { workspace, } from '@sim/db/schema' import { and, eq, isNull } from 'drizzle-orm' +import { HttpError } from '@/lib/core/utils/http-error' export type PermissionType = (typeof permissionTypeEnum.enumValues)[number] export interface WorkspaceBasic { @@ -153,7 +154,7 @@ export async function checkWorkspaceAccess( * centralized route wrapper maps it to HTTP 403 instead of defaulting to 500. * The `message` is intentionally client-safe and is exposed to API responses. */ -export class WorkspaceAccessDeniedError extends Error { +export class WorkspaceAccessDeniedError extends HttpError { readonly statusCode = 403 readonly workspaceId: string From c77b8a361e31eed63255aafd21fde15017499b57 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 26 May 2026 14:46:11 -0700 Subject: [PATCH 2/2] chore(api): drop unreachable runtime check after HttpError instanceof guard --- apps/sim/lib/core/utils/with-route-handler.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/sim/lib/core/utils/with-route-handler.ts b/apps/sim/lib/core/utils/with-route-handler.ts index 11217dd78db..b61cd0d3b30 100644 --- a/apps/sim/lib/core/utils/with-route-handler.ts +++ b/apps/sim/lib/core/utils/with-route-handler.ts @@ -31,7 +31,6 @@ type RouteHandler = ( function readTypedErrorStatus(error: unknown): number | undefined { if (!(error instanceof HttpError)) return undefined const status = error.statusCode - if (typeof status !== 'number') return undefined if (status < 400 || status >= 600) return undefined return status }