Skip to content

Commit 2e2d10b

Browse files
committed
refactor(api): match NestJS/Spring convention for typed-error message exposure
1 parent d5683f5 commit 2e2d10b

3 files changed

Lines changed: 27 additions & 55 deletions

File tree

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

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -32,19 +32,17 @@ export interface BlockReferenceResult {
3232

3333
export class InvalidFieldError extends Error {
3434
readonly statusCode = 400
35-
readonly publicMessage: string
3635

3736
constructor(
3837
public readonly blockName: string,
3938
public readonly fieldPath: string,
4039
public readonly availableFields: string[]
4140
) {
42-
const message =
41+
super(
4342
`"${fieldPath}" doesn't exist on block "${blockName}". ` +
44-
`Available fields: ${availableFields.length > 0 ? availableFields.join(', ') : 'none'}`
45-
super(message)
43+
`Available fields: ${availableFields.length > 0 ? availableFields.join(', ') : 'none'}`
44+
)
4645
this.name = 'InvalidFieldError'
47-
this.publicMessage = message
4846
}
4947
}
5048

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

Lines changed: 20 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -11,36 +11,24 @@ type RouteHandler<T = unknown> = (
1111
context: T
1212
) => Promise<NextResponse | Response> | NextResponse | Response
1313

14-
function defaultMessageForStatus(status: number): string {
15-
if (status >= 500) return 'Internal server error'
16-
if (status === 401) return 'Unauthorized'
17-
if (status === 403) return 'Forbidden'
18-
if (status === 404) return 'Not Found'
19-
if (status === 409) return 'Conflict'
20-
return 'Request failed'
21-
}
22-
2314
/**
2415
* Reads a numeric `statusCode` (4xx or 5xx) off an Error so typed domain errors
25-
* (e.g. `WorkspaceAccessDeniedError`) can map to the correct HTTP status when
26-
* they bubble up unhandled instead of defaulting to 500. Returns both the
27-
* status and a client-safe message: the error's own `publicMessage` if it
28-
* opted in, otherwise a generic per-status string. The raw `error.message` is
29-
* never exposed by this fallback — domain errors must explicitly mark their
30-
* message as safe to expose, preventing accidental leakage of internal details
31-
* from typed errors that didn't intend to be user-facing.
16+
* (e.g. `WorkspaceAccessDeniedError`, `InvalidFieldError`) map to the correct
17+
* HTTP status when they bubble up unhandled instead of defaulting to 500.
18+
*
19+
* When a typed status is returned, the error's `message` is sent to the client
20+
* 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.
3225
*/
33-
function readTypedErrorResponse(error: unknown): { status: number; message: string } | undefined {
26+
function readTypedErrorStatus(error: unknown): number | undefined {
3427
if (!(error instanceof Error)) return undefined
35-
const typed = error as { statusCode?: unknown; publicMessage?: unknown }
36-
const status = typed.statusCode
28+
const status = (error as { statusCode?: unknown }).statusCode
3729
if (typeof status !== 'number') return undefined
3830
if (status < 400 || status >= 600) return undefined
39-
const message =
40-
typeof typed.publicMessage === 'string' && typed.publicMessage.length > 0
41-
? typed.publicMessage
42-
: defaultMessageForStatus(status)
43-
return { status, message }
31+
return status
4432
}
4533

4634
/**
@@ -66,28 +54,17 @@ export function withRouteHandler<T>(handler: RouteHandler<T>): RouteHandler<T> {
6654
response = await handler(request, context)
6755
} catch (error) {
6856
const duration = Date.now() - startTime
69-
const rawMessage = getErrorMessage(error, 'Unknown error')
70-
const typed = readTypedErrorResponse(error)
71-
if (typed !== undefined) {
72-
if (typed.status >= 500) {
73-
logger.error('Unhandled route error', {
74-
duration,
75-
status: typed.status,
76-
error: rawMessage,
77-
})
57+
const message = getErrorMessage(error, 'Unknown error')
58+
const typedStatus = readTypedErrorStatus(error)
59+
if (typedStatus !== undefined) {
60+
if (typedStatus >= 500) {
61+
logger.error('Unhandled route error', { duration, status: typedStatus, error: message })
7862
} else {
79-
logger.warn('Typed route error', {
80-
duration,
81-
status: typed.status,
82-
error: rawMessage,
83-
})
63+
logger.warn('Typed route error', { duration, status: typedStatus, error: message })
8464
}
85-
response = NextResponse.json(
86-
{ error: typed.message, requestId },
87-
{ status: typed.status }
88-
)
65+
response = NextResponse.json({ error: message, requestId }, { status: typedStatus })
8966
} else {
90-
logger.error('Unhandled route error', { duration, error: rawMessage })
67+
logger.error('Unhandled route error', { duration, error: message })
9168
response = NextResponse.json(
9269
{ error: 'Internal server error', requestId },
9370
{ status: 500 }

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

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -149,19 +149,16 @@ export async function checkWorkspaceAccess(
149149

150150
/**
151151
* Thrown when a user attempts to access a workspace they don't have access to,
152-
* or that doesn't exist / has been archived. Carries `statusCode = 403` so route
153-
* handlers and the centralized route wrapper can map it to HTTP 403 instead of
154-
* defaulting to 500. `publicMessage` is the client-safe string the centralized
155-
* wrapper exposes — the `message` (which includes the workspaceId) is kept for
156-
* server-side logs only.
152+
* or that doesn't exist / has been archived. Carries `statusCode = 403` so the
153+
* centralized route wrapper maps it to HTTP 403 instead of defaulting to 500.
154+
* The `message` is intentionally client-safe and is exposed to API responses.
157155
*/
158156
export class WorkspaceAccessDeniedError extends Error {
159157
readonly statusCode = 403
160-
readonly publicMessage = 'Workspace access denied'
161158
readonly workspaceId: string
162159

163160
constructor(workspaceId: string) {
164-
super(`Active workspace access denied: ${workspaceId}`)
161+
super(`Workspace access denied: ${workspaceId}`)
165162
this.name = 'WorkspaceAccessDeniedError'
166163
this.workspaceId = workspaceId
167164
}

0 commit comments

Comments
 (0)