From 7e1709261cafcbe67846c8e293539256a07505eb Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 26 May 2026 10:23:45 -0700 Subject: [PATCH 1/3] fix(api): classify access-denied and sandbox user-code errors with correct HTTP status --- apps/sim/app/api/copilot/chat/queries.ts | 9 +++++- apps/sim/app/api/copilot/chats/route.ts | 9 +++++- apps/sim/app/api/function/execute/route.ts | 6 ++-- .../mothership/chats/[chatId]/fork/route.ts | 9 +++++- apps/sim/app/api/mothership/chats/route.ts | 12 +++++++- apps/sim/app/api/mothership/execute/route.ts | 5 ++++ apps/sim/app/api/tools/file/manage/route.ts | 11 ++++++- apps/sim/lib/copilot/chat/post.ts | 9 +++++- apps/sim/lib/copilot/request/http.ts | 4 +++ apps/sim/lib/core/utils/with-route-handler.ts | 30 +++++++++++++++++-- apps/sim/lib/workspaces/permissions/utils.ts | 23 +++++++++++++- 11 files changed, 115 insertions(+), 12 deletions(-) diff --git a/apps/sim/app/api/copilot/chat/queries.ts b/apps/sim/app/api/copilot/chat/queries.ts index 41ff9ec4bbf..55d8f5acad0 100644 --- a/apps/sim/app/api/copilot/chat/queries.ts +++ b/apps/sim/app/api/copilot/chat/queries.ts @@ -12,13 +12,17 @@ import { normalizeMessage } from '@/lib/copilot/chat/persisted-message' import { authenticateCopilotRequestSessionOnly, createBadRequestResponse, + createForbiddenResponse, createInternalServerErrorResponse, createUnauthorizedResponse, } from '@/lib/copilot/request/http' import { readFilePreviewSessions } from '@/lib/copilot/request/session' import { readEvents } from '@/lib/copilot/request/session/buffer' import { toStreamBatchEvent } from '@/lib/copilot/request/session/types' -import { assertActiveWorkspaceAccess } from '@/lib/workspaces/permissions/utils' +import { + assertActiveWorkspaceAccess, + isWorkspaceAccessDeniedError, +} from '@/lib/workspaces/permissions/utils' const logger = createLogger('CopilotChatAPI') @@ -196,6 +200,9 @@ export async function GET(req: NextRequest) { chats: chats.map(transformChatListItem), }) } catch (error) { + if (isWorkspaceAccessDeniedError(error)) { + return createForbiddenResponse('Workspace access denied') + } logger.error('Error fetching copilot chats:', error) return createInternalServerErrorResponse('Failed to fetch chats') } diff --git a/apps/sim/app/api/copilot/chats/route.ts b/apps/sim/app/api/copilot/chats/route.ts index 0aecdb462b9..05e4c7773db 100644 --- a/apps/sim/app/api/copilot/chats/route.ts +++ b/apps/sim/app/api/copilot/chats/route.ts @@ -10,12 +10,16 @@ import { resolveOrCreateChat } from '@/lib/copilot/chat/lifecycle' import { authenticateCopilotRequestSessionOnly, createBadRequestResponse, + createForbiddenResponse, createInternalServerErrorResponse, createUnauthorizedResponse, } from '@/lib/copilot/request/http' import { taskPubSub } from '@/lib/copilot/tasks' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { assertActiveWorkspaceAccess } from '@/lib/workspaces/permissions/utils' +import { + assertActiveWorkspaceAccess, + isWorkspaceAccessDeniedError, +} from '@/lib/workspaces/permissions/utils' const logger = createLogger('CopilotChatsListAPI') @@ -138,6 +142,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ success: true, id: result.chatId }) } catch (error) { + if (isWorkspaceAccessDeniedError(error)) { + return createForbiddenResponse('Workspace access denied') + } logger.error('Error creating workflow copilot chat:', error) return createInternalServerErrorResponse('Failed to create chat') } diff --git a/apps/sim/app/api/function/execute/route.ts b/apps/sim/app/api/function/execute/route.ts index 5fa058736d2..92dbaa87ef2 100644 --- a/apps/sim/app/api/function/execute/route.ts +++ b/apps/sim/app/api/function/execute/route.ts @@ -1132,7 +1132,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => { output: { result: null, stdout: cleanStdout(shellStdout), executionTime }, }, routeContext, - { status: 500 } + { status: 422 } ) } @@ -1269,7 +1269,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => { output: { result: null, stdout: cleanedOutput, executionTime }, }, routeContext, - { status: 500 } + { status: 422 } ) } @@ -1356,7 +1356,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => { output: { result: null, stdout: cleanedOutput, executionTime }, }, routeContext, - { status: 500 } + { status: 422 } ) } diff --git a/apps/sim/app/api/mothership/chats/[chatId]/fork/route.ts b/apps/sim/app/api/mothership/chats/[chatId]/fork/route.ts index 8cea0668228..1509f68eb55 100644 --- a/apps/sim/app/api/mothership/chats/[chatId]/fork/route.ts +++ b/apps/sim/app/api/mothership/chats/[chatId]/fork/route.ts @@ -11,6 +11,7 @@ import { fetchGo } from '@/lib/copilot/request/go/fetch' import { authenticateCopilotRequestSessionOnly, createBadRequestResponse, + createForbiddenResponse, createInternalServerErrorResponse, createNotFoundResponse, createUnauthorizedResponse, @@ -21,7 +22,10 @@ import { taskPubSub } from '@/lib/copilot/tasks' import { env } from '@/lib/core/config/env' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { captureServerEvent } from '@/lib/posthog/server' -import { assertActiveWorkspaceAccess } from '@/lib/workspaces/permissions/utils' +import { + assertActiveWorkspaceAccess, + isWorkspaceAccessDeniedError, +} from '@/lib/workspaces/permissions/utils' const logger = createLogger('ForkChatAPI') @@ -150,6 +154,9 @@ export const POST = withRouteHandler( return NextResponse.json({ success: true, id: newId }) } catch (error) { + if (isWorkspaceAccessDeniedError(error)) { + return createForbiddenResponse('Workspace access denied') + } logger.error('Error forking chat:', error) return createInternalServerErrorResponse('Failed to fork chat') } diff --git a/apps/sim/app/api/mothership/chats/route.ts b/apps/sim/app/api/mothership/chats/route.ts index f6d2d9eae35..1b7157fdde5 100644 --- a/apps/sim/app/api/mothership/chats/route.ts +++ b/apps/sim/app/api/mothership/chats/route.ts @@ -11,13 +11,17 @@ import { parseRequest } from '@/lib/api/server' import { reconcileChatStreamMarkers } from '@/lib/copilot/chat/stream-liveness' import { authenticateCopilotRequestSessionOnly, + createForbiddenResponse, createInternalServerErrorResponse, createUnauthorizedResponse, } from '@/lib/copilot/request/http' import { taskPubSub } from '@/lib/copilot/tasks' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { captureServerEvent } from '@/lib/posthog/server' -import { assertActiveWorkspaceAccess } from '@/lib/workspaces/permissions/utils' +import { + assertActiveWorkspaceAccess, + isWorkspaceAccessDeniedError, +} from '@/lib/workspaces/permissions/utils' const logger = createLogger('MothershipChatsAPI') @@ -68,6 +72,9 @@ export const GET = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ success: true, data: reconciled }) } catch (error) { + if (isWorkspaceAccessDeniedError(error)) { + return createForbiddenResponse('Workspace access denied') + } logger.error('Error fetching mothership chats:', error) return createInternalServerErrorResponse('Failed to fetch chats') } @@ -118,6 +125,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ success: true, id: chat.id }) } catch (error) { + if (isWorkspaceAccessDeniedError(error)) { + return createForbiddenResponse('Workspace access denied') + } logger.error('Error creating mothership chat:', error) return createInternalServerErrorResponse('Failed to create chat') } diff --git a/apps/sim/app/api/mothership/execute/route.ts b/apps/sim/app/api/mothership/execute/route.ts index ab53f413baf..a3550718b92 100644 --- a/apps/sim/app/api/mothership/execute/route.ts +++ b/apps/sim/app/api/mothership/execute/route.ts @@ -19,6 +19,7 @@ import { buildMothershipToolsForRequest } from '@/lib/mothership/settings/runtim import { assertActiveWorkspaceAccess, getUserEntityPermissions, + isWorkspaceAccessDeniedError, } from '@/lib/workspaces/permissions/utils' export const maxDuration = 3600 @@ -378,6 +379,10 @@ export const POST = withRouteHandler(async (req: NextRequest) => { return NextResponse.json({ error: 'Mothership execution aborted' }, { status: 499 }) } + if (isWorkspaceAccessDeniedError(error)) { + return NextResponse.json({ error: 'Workspace access denied' }, { status: 403 }) + } + logger.error( messageId ? `Mothership execute error [messageId:${messageId}]` : 'Mothership execute error', { diff --git a/apps/sim/app/api/tools/file/manage/route.ts b/apps/sim/app/api/tools/file/manage/route.ts index f95b4a9941d..61648a2a4d9 100644 --- a/apps/sim/app/api/tools/file/manage/route.ts +++ b/apps/sim/app/api/tools/file/manage/route.ts @@ -19,7 +19,10 @@ import { } from '@/lib/uploads/contexts/workspace/workspace-file-manager' import { getFileExtension, getMimeTypeFromExtension } from '@/lib/uploads/utils/file-utils' import { performMoveWorkspaceFileItems } from '@/lib/workspace-files/orchestration' -import { assertActiveWorkspaceAccess } from '@/lib/workspaces/permissions/utils' +import { + assertActiveWorkspaceAccess, + isWorkspaceAccessDeniedError, +} from '@/lib/workspaces/permissions/utils' export const dynamic = 'force-dynamic' @@ -352,6 +355,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } } } catch (error) { + if (isWorkspaceAccessDeniedError(error)) { + return NextResponse.json( + { success: false, error: 'Workspace access denied' }, + { status: 403 } + ) + } const message = getErrorMessage(error, 'Unknown error') logger.error('File operation failed', { operation: body.operation, error: message }) return NextResponse.json({ success: false, error: message }, { status: 500 }) diff --git a/apps/sim/lib/copilot/chat/post.ts b/apps/sim/lib/copilot/chat/post.ts index a7ba4573879..f13f4a85046 100644 --- a/apps/sim/lib/copilot/chat/post.ts +++ b/apps/sim/lib/copilot/chat/post.ts @@ -44,7 +44,10 @@ import { taskPubSub } from '@/lib/copilot/tasks' import { prepareExecutionContext } from '@/lib/copilot/tools/handlers/context' import { getEffectiveDecryptedEnv } from '@/lib/environment/utils' import { getWorkflowById, resolveWorkflowIdForUser } from '@/lib/workflows/utils' -import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' +import { + getUserEntityPermissions, + isWorkspaceAccessDeniedError, +} from '@/lib/workspaces/permissions/utils' import type { ChatContext } from '@/stores/panel' export const maxDuration = 3600 @@ -1039,6 +1042,10 @@ export async function handleUnifiedChatPost(req: NextRequest) { return validationErrorResponse(error, 'Invalid request data') } + if (isWorkspaceAccessDeniedError(error)) { + return NextResponse.json({ error: 'Workspace access denied' }, { status: 403 }) + } + logger.error(`[${requestId}] Error handling unified chat request`, { error: getErrorMessage(error, 'Unknown error'), stack: error instanceof Error ? error.stack : undefined, diff --git a/apps/sim/lib/copilot/request/http.ts b/apps/sim/lib/copilot/request/http.ts index 19c33fc7545..56614265ff7 100644 --- a/apps/sim/lib/copilot/request/http.ts +++ b/apps/sim/lib/copilot/request/http.ts @@ -35,6 +35,10 @@ export function createBadRequestResponse(message: string): NextResponse { return NextResponse.json({ error: message }, { status: 400 }) } +export function createForbiddenResponse(message: string): NextResponse { + return NextResponse.json({ error: message }, { status: 403 }) +} + export function createNotFoundResponse(message: string): NextResponse { return NextResponse.json({ error: message }, { status: 404 }) } diff --git a/apps/sim/lib/core/utils/with-route-handler.ts b/apps/sim/lib/core/utils/with-route-handler.ts index 5b3212f23e5..806eec54a5b 100644 --- a/apps/sim/lib/core/utils/with-route-handler.ts +++ b/apps/sim/lib/core/utils/with-route-handler.ts @@ -11,6 +11,19 @@ type RouteHandler = ( context: T ) => Promise | NextResponse | Response +/** + * Reads a numeric `statusCode` (4xx or 5xx) off an Error so typed domain errors + * (e.g. `WorkspaceAccessDeniedError`) can map to the correct HTTP status when + * they bubble up unhandled instead of defaulting to 500. + */ +function readTypedErrorStatus(error: unknown): number | undefined { + if (!(error instanceof Error)) return undefined + const status = (error as { statusCode?: unknown }).statusCode + if (typeof status !== 'number') return undefined + if (status < 400 || status >= 600) return undefined + return status +} + /** * Wraps a Next.js API route handler with centralized error reporting. * @@ -35,8 +48,21 @@ export function withRouteHandler(handler: RouteHandler): RouteHandler { } catch (error) { const duration = Date.now() - startTime const message = getErrorMessage(error, 'Unknown error') - logger.error('Unhandled route error', { duration, error: message }) - response = NextResponse.json({ error: 'Internal server error', requestId }, { status: 500 }) + const typedStatus = readTypedErrorStatus(error) + if (typedStatus !== undefined) { + if (typedStatus >= 500) { + logger.error('Unhandled route error', { duration, status: typedStatus, error: message }) + } else { + logger.warn('Typed route error', { duration, status: typedStatus, error: message }) + } + response = NextResponse.json({ error: message, requestId }, { status: typedStatus }) + } else { + logger.error('Unhandled route error', { duration, error: message }) + response = NextResponse.json( + { error: 'Internal server error', requestId }, + { status: 500 } + ) + } response?.headers?.set('x-request-id', requestId) return response } diff --git a/apps/sim/lib/workspaces/permissions/utils.ts b/apps/sim/lib/workspaces/permissions/utils.ts index bef0da42e42..e31192ada53 100644 --- a/apps/sim/lib/workspaces/permissions/utils.ts +++ b/apps/sim/lib/workspaces/permissions/utils.ts @@ -147,13 +147,34 @@ export async function checkWorkspaceAccess( return { exists: true, hasAccess: true, canWrite, workspace: ws } } +/** + * Thrown when a user attempts to access a workspace they don't have access to, + * or that doesn't exist / has been archived. Carries `statusCode = 403` so route + * handlers and the centralized route wrapper can map it to HTTP 403 instead of + * defaulting to 500. + */ +export class WorkspaceAccessDeniedError extends Error { + readonly statusCode = 403 + readonly workspaceId: string + + constructor(workspaceId: string) { + super(`Active workspace access denied: ${workspaceId}`) + this.name = 'WorkspaceAccessDeniedError' + this.workspaceId = workspaceId + } +} + +export function isWorkspaceAccessDeniedError(error: unknown): error is WorkspaceAccessDeniedError { + return error instanceof WorkspaceAccessDeniedError +} + export async function assertActiveWorkspaceAccess( workspaceId: string, userId: string ): Promise { const access = await checkWorkspaceAccess(workspaceId, userId) if (!access.exists || !access.hasAccess) { - throw new Error(`Active workspace access denied: ${workspaceId}`) + throw new WorkspaceAccessDeniedError(workspaceId) } return access } From d5683f52be925051e12fcc4ac315320861ef15f4 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 26 May 2026 10:35:08 -0700 Subject: [PATCH 2/3] fix(api): gate typed-error message exposure behind publicMessage opt-in --- apps/sim/executor/utils/block-reference.ts | 8 +-- apps/sim/lib/core/utils/with-route-handler.ts | 54 ++++++++++++++----- apps/sim/lib/workspaces/permissions/utils.ts | 5 +- 3 files changed, 51 insertions(+), 16 deletions(-) diff --git a/apps/sim/executor/utils/block-reference.ts b/apps/sim/executor/utils/block-reference.ts index 082a9339782..5501fefd644 100644 --- a/apps/sim/executor/utils/block-reference.ts +++ b/apps/sim/executor/utils/block-reference.ts @@ -32,17 +32,19 @@ export interface BlockReferenceResult { export class InvalidFieldError extends Error { readonly statusCode = 400 + readonly publicMessage: string constructor( public readonly blockName: string, public readonly fieldPath: string, public readonly availableFields: string[] ) { - super( + const message = `"${fieldPath}" doesn't exist on block "${blockName}". ` + - `Available fields: ${availableFields.length > 0 ? availableFields.join(', ') : 'none'}` - ) + `Available fields: ${availableFields.length > 0 ? availableFields.join(', ') : 'none'}` + super(message) this.name = 'InvalidFieldError' + this.publicMessage = message } } diff --git a/apps/sim/lib/core/utils/with-route-handler.ts b/apps/sim/lib/core/utils/with-route-handler.ts index 806eec54a5b..9fb74d4396a 100644 --- a/apps/sim/lib/core/utils/with-route-handler.ts +++ b/apps/sim/lib/core/utils/with-route-handler.ts @@ -11,17 +11,36 @@ type RouteHandler = ( context: T ) => Promise | NextResponse | Response +function defaultMessageForStatus(status: number): string { + if (status >= 500) return 'Internal server error' + if (status === 401) return 'Unauthorized' + if (status === 403) return 'Forbidden' + if (status === 404) return 'Not Found' + if (status === 409) return 'Conflict' + return 'Request failed' +} + /** * Reads a numeric `statusCode` (4xx or 5xx) off an Error so typed domain errors * (e.g. `WorkspaceAccessDeniedError`) can map to the correct HTTP status when - * they bubble up unhandled instead of defaulting to 500. + * they bubble up unhandled instead of defaulting to 500. Returns both the + * status and a client-safe message: the error's own `publicMessage` if it + * opted in, otherwise a generic per-status string. The raw `error.message` is + * never exposed by this fallback — domain errors must explicitly mark their + * message as safe to expose, preventing accidental leakage of internal details + * from typed errors that didn't intend to be user-facing. */ -function readTypedErrorStatus(error: unknown): number | undefined { +function readTypedErrorResponse(error: unknown): { status: number; message: string } | undefined { if (!(error instanceof Error)) return undefined - const status = (error as { statusCode?: unknown }).statusCode + const typed = error as { statusCode?: unknown; publicMessage?: unknown } + const status = typed.statusCode if (typeof status !== 'number') return undefined if (status < 400 || status >= 600) return undefined - return status + const message = + typeof typed.publicMessage === 'string' && typed.publicMessage.length > 0 + ? typed.publicMessage + : defaultMessageForStatus(status) + return { status, message } } /** @@ -47,17 +66,28 @@ export function withRouteHandler(handler: RouteHandler): RouteHandler { response = await handler(request, context) } catch (error) { const duration = Date.now() - startTime - const message = getErrorMessage(error, 'Unknown error') - const typedStatus = readTypedErrorStatus(error) - if (typedStatus !== undefined) { - if (typedStatus >= 500) { - logger.error('Unhandled route error', { duration, status: typedStatus, error: message }) + const rawMessage = getErrorMessage(error, 'Unknown error') + const typed = readTypedErrorResponse(error) + if (typed !== undefined) { + if (typed.status >= 500) { + logger.error('Unhandled route error', { + duration, + status: typed.status, + error: rawMessage, + }) } else { - logger.warn('Typed route error', { duration, status: typedStatus, error: message }) + logger.warn('Typed route error', { + duration, + status: typed.status, + error: rawMessage, + }) } - response = NextResponse.json({ error: message, requestId }, { status: typedStatus }) + response = NextResponse.json( + { error: typed.message, requestId }, + { status: typed.status } + ) } else { - logger.error('Unhandled route error', { duration, error: message }) + logger.error('Unhandled route error', { duration, error: rawMessage }) response = NextResponse.json( { error: 'Internal server error', requestId }, { status: 500 } diff --git a/apps/sim/lib/workspaces/permissions/utils.ts b/apps/sim/lib/workspaces/permissions/utils.ts index e31192ada53..1437f2696f7 100644 --- a/apps/sim/lib/workspaces/permissions/utils.ts +++ b/apps/sim/lib/workspaces/permissions/utils.ts @@ -151,10 +151,13 @@ export async function checkWorkspaceAccess( * Thrown when a user attempts to access a workspace they don't have access to, * or that doesn't exist / has been archived. Carries `statusCode = 403` so route * handlers and the centralized route wrapper can map it to HTTP 403 instead of - * defaulting to 500. + * defaulting to 500. `publicMessage` is the client-safe string the centralized + * wrapper exposes — the `message` (which includes the workspaceId) is kept for + * server-side logs only. */ export class WorkspaceAccessDeniedError extends Error { readonly statusCode = 403 + readonly publicMessage = 'Workspace access denied' readonly workspaceId: string constructor(workspaceId: string) { From 2e2d10b6bcd4f612e4255f2431bc9775983f6ad8 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 26 May 2026 10:50:33 -0700 Subject: [PATCH 3/3] refactor(api): match NestJS/Spring convention for typed-error message exposure --- apps/sim/executor/utils/block-reference.ts | 8 +-- apps/sim/lib/core/utils/with-route-handler.ts | 63 ++++++------------- apps/sim/lib/workspaces/permissions/utils.ts | 11 ++-- 3 files changed, 27 insertions(+), 55 deletions(-) diff --git a/apps/sim/executor/utils/block-reference.ts b/apps/sim/executor/utils/block-reference.ts index 5501fefd644..082a9339782 100644 --- a/apps/sim/executor/utils/block-reference.ts +++ b/apps/sim/executor/utils/block-reference.ts @@ -32,19 +32,17 @@ export interface BlockReferenceResult { export class InvalidFieldError extends Error { readonly statusCode = 400 - readonly publicMessage: string constructor( public readonly blockName: string, public readonly fieldPath: string, public readonly availableFields: string[] ) { - const message = + super( `"${fieldPath}" doesn't exist on block "${blockName}". ` + - `Available fields: ${availableFields.length > 0 ? availableFields.join(', ') : 'none'}` - super(message) + `Available fields: ${availableFields.length > 0 ? availableFields.join(', ') : 'none'}` + ) this.name = 'InvalidFieldError' - this.publicMessage = message } } diff --git a/apps/sim/lib/core/utils/with-route-handler.ts b/apps/sim/lib/core/utils/with-route-handler.ts index 9fb74d4396a..44857eba011 100644 --- a/apps/sim/lib/core/utils/with-route-handler.ts +++ b/apps/sim/lib/core/utils/with-route-handler.ts @@ -11,36 +11,24 @@ type RouteHandler = ( context: T ) => Promise | NextResponse | Response -function defaultMessageForStatus(status: number): string { - if (status >= 500) return 'Internal server error' - if (status === 401) return 'Unauthorized' - if (status === 403) return 'Forbidden' - if (status === 404) return 'Not Found' - if (status === 409) return 'Conflict' - return 'Request failed' -} - /** * Reads a numeric `statusCode` (4xx or 5xx) off an Error so typed domain errors - * (e.g. `WorkspaceAccessDeniedError`) can map to the correct HTTP status when - * they bubble up unhandled instead of defaulting to 500. Returns both the - * status and a client-safe message: the error's own `publicMessage` if it - * opted in, otherwise a generic per-status string. The raw `error.message` is - * never exposed by this fallback — domain errors must explicitly mark their - * message as safe to expose, preventing accidental leakage of internal details - * from typed errors that didn't intend to be user-facing. + * (e.g. `WorkspaceAccessDeniedError`, `InvalidFieldError`) map to the correct + * HTTP status when they bubble up unhandled instead of defaulting to 500. + * + * 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. */ -function readTypedErrorResponse(error: unknown): { status: number; message: string } | undefined { +function readTypedErrorStatus(error: unknown): number | undefined { if (!(error instanceof Error)) return undefined - const typed = error as { statusCode?: unknown; publicMessage?: unknown } - const status = typed.statusCode + const status = (error as { statusCode?: unknown }).statusCode if (typeof status !== 'number') return undefined if (status < 400 || status >= 600) return undefined - const message = - typeof typed.publicMessage === 'string' && typed.publicMessage.length > 0 - ? typed.publicMessage - : defaultMessageForStatus(status) - return { status, message } + return status } /** @@ -66,28 +54,17 @@ export function withRouteHandler(handler: RouteHandler): RouteHandler { response = await handler(request, context) } catch (error) { const duration = Date.now() - startTime - const rawMessage = getErrorMessage(error, 'Unknown error') - const typed = readTypedErrorResponse(error) - if (typed !== undefined) { - if (typed.status >= 500) { - logger.error('Unhandled route error', { - duration, - status: typed.status, - error: rawMessage, - }) + const message = getErrorMessage(error, 'Unknown error') + const typedStatus = readTypedErrorStatus(error) + if (typedStatus !== undefined) { + if (typedStatus >= 500) { + logger.error('Unhandled route error', { duration, status: typedStatus, error: message }) } else { - logger.warn('Typed route error', { - duration, - status: typed.status, - error: rawMessage, - }) + logger.warn('Typed route error', { duration, status: typedStatus, error: message }) } - response = NextResponse.json( - { error: typed.message, requestId }, - { status: typed.status } - ) + response = NextResponse.json({ error: message, requestId }, { status: typedStatus }) } else { - logger.error('Unhandled route error', { duration, error: rawMessage }) + logger.error('Unhandled route error', { duration, error: message }) response = NextResponse.json( { error: 'Internal server error', requestId }, { status: 500 } diff --git a/apps/sim/lib/workspaces/permissions/utils.ts b/apps/sim/lib/workspaces/permissions/utils.ts index 1437f2696f7..15a5f2b7e7d 100644 --- a/apps/sim/lib/workspaces/permissions/utils.ts +++ b/apps/sim/lib/workspaces/permissions/utils.ts @@ -149,19 +149,16 @@ export async function checkWorkspaceAccess( /** * Thrown when a user attempts to access a workspace they don't have access to, - * or that doesn't exist / has been archived. Carries `statusCode = 403` so route - * handlers and the centralized route wrapper can map it to HTTP 403 instead of - * defaulting to 500. `publicMessage` is the client-safe string the centralized - * wrapper exposes — the `message` (which includes the workspaceId) is kept for - * server-side logs only. + * or that doesn't exist / has been archived. Carries `statusCode = 403` so the + * 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 { readonly statusCode = 403 - readonly publicMessage = 'Workspace access denied' readonly workspaceId: string constructor(workspaceId: string) { - super(`Active workspace access denied: ${workspaceId}`) + super(`Workspace access denied: ${workspaceId}`) this.name = 'WorkspaceAccessDeniedError' this.workspaceId = workspaceId }