Skip to content

Commit d5683f5

Browse files
committed
fix(api): gate typed-error message exposure behind publicMessage opt-in
1 parent 7e17092 commit d5683f5

3 files changed

Lines changed: 51 additions & 16 deletions

File tree

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

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

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

3637
constructor(
3738
public readonly blockName: string,
3839
public readonly fieldPath: string,
3940
public readonly availableFields: string[]
4041
) {
41-
super(
42+
const message =
4243
`"${fieldPath}" doesn't exist on block "${blockName}". ` +
43-
`Available fields: ${availableFields.length > 0 ? availableFields.join(', ') : 'none'}`
44-
)
44+
`Available fields: ${availableFields.length > 0 ? availableFields.join(', ') : 'none'}`
45+
super(message)
4546
this.name = 'InvalidFieldError'
47+
this.publicMessage = message
4648
}
4749
}
4850

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

Lines changed: 42 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -11,17 +11,36 @@ 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+
1423
/**
1524
* Reads a numeric `statusCode` (4xx or 5xx) off an Error so typed domain errors
1625
* (e.g. `WorkspaceAccessDeniedError`) can map to the correct HTTP status when
17-
* they bubble up unhandled instead of defaulting to 500.
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.
1832
*/
19-
function readTypedErrorStatus(error: unknown): number | undefined {
33+
function readTypedErrorResponse(error: unknown): { status: number; message: string } | undefined {
2034
if (!(error instanceof Error)) return undefined
21-
const status = (error as { statusCode?: unknown }).statusCode
35+
const typed = error as { statusCode?: unknown; publicMessage?: unknown }
36+
const status = typed.statusCode
2237
if (typeof status !== 'number') return undefined
2338
if (status < 400 || status >= 600) return undefined
24-
return status
39+
const message =
40+
typeof typed.publicMessage === 'string' && typed.publicMessage.length > 0
41+
? typed.publicMessage
42+
: defaultMessageForStatus(status)
43+
return { status, message }
2544
}
2645

2746
/**
@@ -47,17 +66,28 @@ export function withRouteHandler<T>(handler: RouteHandler<T>): RouteHandler<T> {
4766
response = await handler(request, context)
4867
} catch (error) {
4968
const duration = Date.now() - startTime
50-
const message = getErrorMessage(error, 'Unknown error')
51-
const typedStatus = readTypedErrorStatus(error)
52-
if (typedStatus !== undefined) {
53-
if (typedStatus >= 500) {
54-
logger.error('Unhandled route error', { duration, status: typedStatus, error: message })
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+
})
5578
} else {
56-
logger.warn('Typed route error', { duration, status: typedStatus, error: message })
79+
logger.warn('Typed route error', {
80+
duration,
81+
status: typed.status,
82+
error: rawMessage,
83+
})
5784
}
58-
response = NextResponse.json({ error: message, requestId }, { status: typedStatus })
85+
response = NextResponse.json(
86+
{ error: typed.message, requestId },
87+
{ status: typed.status }
88+
)
5989
} else {
60-
logger.error('Unhandled route error', { duration, error: message })
90+
logger.error('Unhandled route error', { duration, error: rawMessage })
6191
response = NextResponse.json(
6292
{ error: 'Internal server error', requestId },
6393
{ status: 500 }

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -151,10 +151,13 @@ export async function checkWorkspaceAccess(
151151
* Thrown when a user attempts to access a workspace they don't have access to,
152152
* or that doesn't exist / has been archived. Carries `statusCode = 403` so route
153153
* handlers and the centralized route wrapper can map it to HTTP 403 instead of
154-
* defaulting to 500.
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.
155157
*/
156158
export class WorkspaceAccessDeniedError extends Error {
157159
readonly statusCode = 403
160+
readonly publicMessage = 'Workspace access denied'
158161
readonly workspaceId: string
159162

160163
constructor(workspaceId: string) {

0 commit comments

Comments
 (0)