Skip to content

Commit 17701ea

Browse files
committed
improvement(api): use HttpError base class for typed-error status mapping
1 parent e66daa1 commit 17701ea

4 files changed

Lines changed: 37 additions & 11 deletions

File tree

apps/sim/executor/utils/block-reference.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { HttpError } from '@/lib/core/utils/http-error'
12
import { USER_FILE_ACCESSIBLE_PROPERTIES } from '@/lib/workflows/types'
23
import { normalizeName } from '@/executor/constants'
34
import {
@@ -30,7 +31,7 @@ export interface BlockReferenceResult {
3031
blockId: string
3132
}
3233

33-
export class InvalidFieldError extends Error {
34+
export class InvalidFieldError extends HttpError {
3435
readonly statusCode = 400
3536

3637
constructor(
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
/**
2+
* Base class for domain errors that map to a specific HTTP status when they
3+
* bubble up unhandled through `withRouteHandler`. Modeled after NestJS
4+
* `HttpException` / Spring `ResponseStatusException`: subclasses declare a
5+
* concrete `statusCode`, and the centralized route wrapper uses an
6+
* `instanceof HttpError` check (not duck-typing on a `statusCode` property)
7+
* to decide whether to forward the error's `message` to the client.
8+
*
9+
* Using a class check prevents third-party errors that happen to carry a
10+
* `statusCode`-shaped field from being treated as typed HTTP errors and
11+
* leaking internal details.
12+
*
13+
* Subclasses MUST ensure that `message` is safe to expose to clients — no
14+
* stack traces, secrets, file paths, ORM internals, or upstream provider
15+
* details.
16+
*/
17+
export abstract class HttpError extends Error {
18+
abstract readonly statusCode: number
19+
}

apps/sim/lib/core/utils/with-route-handler.ts

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { createLogger, runWithRequestContext } from '@sim/logger'
22
import { getErrorMessage } from '@sim/utils/errors'
33
import type { NextRequest } from 'next/server'
44
import { NextResponse } from 'next/server'
5+
import { HttpError } from '@/lib/core/utils/http-error'
56
import { generateRequestId } from '@/lib/core/utils/request'
67

78
const logger = createLogger('RouteHandler')
@@ -12,20 +13,24 @@ type RouteHandler<T = unknown> = (
1213
) => Promise<NextResponse | Response> | NextResponse | Response
1314

1415
/**
15-
* Reads a numeric `statusCode` (4xx or 5xx) off an Error so typed domain errors
16-
* (e.g. `WorkspaceAccessDeniedError`, `InvalidFieldError`) map to the correct
17-
* HTTP status when they bubble up unhandled instead of defaulting to 500.
16+
* Reads a numeric `statusCode` (4xx or 5xx) off an `HttpError` so typed domain
17+
* errors (e.g. `WorkspaceAccessDeniedError`, `InvalidFieldError`) map to the
18+
* correct HTTP status when they bubble up unhandled instead of defaulting to
19+
* 500.
20+
*
21+
* Uses an `instanceof HttpError` check (not duck-typing on `statusCode`) so
22+
* third-party errors that happen to carry a `statusCode`-shaped field cannot
23+
* trigger this path and leak their internal `message` to the client.
1824
*
1925
* When a typed status is returned, the error's `message` is sent to the client
2026
* verbatim — matching the NestJS `HttpException` / Spring `ResponseStatusException`
21-
* convention. The safety contract is convention-based: only attach `statusCode`
22-
* to errors whose `message` is safe to expose to clients (no stack traces,
23-
* secrets, file paths, ORM internals). Untyped errors fall back to a generic
24-
* 500 response with no message exposure.
27+
* convention. Subclasses of `HttpError` are responsible for keeping `message`
28+
* safe to expose to clients (no stack traces, secrets, file paths, ORM
29+
* internals).
2530
*/
2631
function readTypedErrorStatus(error: unknown): number | undefined {
27-
if (!(error instanceof Error)) return undefined
28-
const status = (error as { statusCode?: unknown }).statusCode
32+
if (!(error instanceof HttpError)) return undefined
33+
const status = error.statusCode
2934
if (typeof status !== 'number') return undefined
3035
if (status < 400 || status >= 600) return undefined
3136
return status

apps/sim/lib/workspaces/permissions/utils.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
workspace,
99
} from '@sim/db/schema'
1010
import { and, eq, isNull } from 'drizzle-orm'
11+
import { HttpError } from '@/lib/core/utils/http-error'
1112

1213
export type PermissionType = (typeof permissionTypeEnum.enumValues)[number]
1314
export interface WorkspaceBasic {
@@ -153,7 +154,7 @@ export async function checkWorkspaceAccess(
153154
* centralized route wrapper maps it to HTTP 403 instead of defaulting to 500.
154155
* The `message` is intentionally client-safe and is exposed to API responses.
155156
*/
156-
export class WorkspaceAccessDeniedError extends Error {
157+
export class WorkspaceAccessDeniedError extends HttpError {
157158
readonly statusCode = 403
158159
readonly workspaceId: string
159160

0 commit comments

Comments
 (0)