- Compare
-
+
+ Compare
+
+
{sim.name} vs {competitor.name}
@@ -89,6 +130,8 @@ export function ComparisonTable({ sim, competitor }: ComparisonTableProps) {
role='columnheader'
className={cn(
'border-[var(--border)] border-r bg-[var(--surface-1)] px-4 py-2',
+ STICKY_LABEL_COL,
+ 'max-lg:border-r-0',
sectionIdx > 0 && 'border-[var(--border-1)] border-t'
)}
>
@@ -99,7 +142,7 @@ export function ComparisonTable({ sim, competitor }: ComparisonTableProps) {
0 && 'border-[var(--border-1)] border-t'
)}
/>
@@ -115,28 +158,42 @@ export function ComparisonTable({ sim, competitor }: ComparisonTableProps) {
- {row.label}
+
+ {row.label}
+
+
+ {sim.name}
+
+
+ {competitor.name}
+
diff --git a/apps/sim/app/(landing)/comparison/components/source-info/source-info.tsx b/apps/sim/app/(landing)/comparison/components/source-info/source-info.tsx
index fba0e4debd8..200d1c35aaf 100644
--- a/apps/sim/app/(landing)/comparison/components/source-info/source-info.tsx
+++ b/apps/sim/app/(landing)/comparison/components/source-info/source-info.tsx
@@ -1,7 +1,7 @@
'use client'
import type { ReactNode } from 'react'
-import { Tooltip } from '@sim/emcn'
+import { cn, Tooltip } from '@sim/emcn'
import type { FactSource } from '@/lib/compare/data'
export interface SourceLinkProps {
@@ -29,7 +29,7 @@ export function SourceLink({ source, children, className }: SourceLinkProps) {
target='_blank'
rel='noopener noreferrer'
aria-label={`${source.label} (opens source)`}
- className={className}
+ className={cn('block min-w-0', className)}
>
{children}
diff --git a/apps/sim/app/_styles/globals.css b/apps/sim/app/_styles/globals.css
index ecdff6f7b2b..bbb8eca1745 100644
--- a/apps/sim/app/_styles/globals.css
+++ b/apps/sim/app/_styles/globals.css
@@ -225,6 +225,7 @@ html.sidebar-booting .sidebar-shell-inner {
--brand-accent: #33c482;
--brand-accent-hover: #2dac72;
--selection: #1a5cf6;
+ --selection-muted: #1a5cf647;
--warning: #ea580c;
/* Inverted surface (dark chrome on light page, e.g. tag dropdown) */
@@ -382,6 +383,7 @@ html.sidebar-booting .sidebar-shell-inner {
--brand-accent: #33c482;
--brand-accent-hover: #2dac72;
--selection: #4b83f7;
+ --selection-muted: #4b83f759;
--warning: #ff6600;
/* Inverted surface (in dark mode, falls back to standard surfaces) */
@@ -492,10 +494,14 @@ html.sidebar-booting .sidebar-shell-inner {
}
/* Consistent branded text-selection color everywhere, independent of the
- browser/OS default (which dims when the window loses focus). */
+ browser/OS default (which dims when the window loses focus). Translucent,
+ with no color override, so selected text keeps its own paint on every
+ surface — solid-brand + forced white broke wherever glyphs aren't the
+ selected element's own (Chromium ignores ::selection color in form
+ controls, and the chat/workflow inputs render transparent textareas over
+ styled mirrors, which left black text on solid brand blue). */
::selection {
- background-color: var(--selection);
- color: var(--white);
+ background-color: var(--selection-muted);
}
body {
diff --git a/apps/sim/app/api/custom-blocks/[id]/route.ts b/apps/sim/app/api/custom-blocks/[id]/route.ts
new file mode 100644
index 00000000000..f7a59bed7f7
--- /dev/null
+++ b/apps/sim/app/api/custom-blocks/[id]/route.ts
@@ -0,0 +1,94 @@
+import { createLogger } from '@sim/logger'
+import { getErrorMessage } from '@sim/utils/errors'
+import type { NextRequest } from 'next/server'
+import { NextResponse } from 'next/server'
+import {
+ deleteCustomBlockContract,
+ updateCustomBlockContract,
+} from '@/lib/api/contracts/custom-blocks'
+import { parseRequest } from '@/lib/api/server'
+import { getSession } from '@/lib/auth'
+import { isFeatureEnabled } from '@/lib/core/config/feature-flags'
+import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import {
+ CustomBlockValidationError,
+ deleteCustomBlock,
+ getCustomBlockManageContext,
+ updateCustomBlock,
+} from '@/lib/workflows/custom-blocks/operations'
+import { hasWorkspaceAdminAccess } from '@/lib/workspaces/permissions/utils'
+
+const logger = createLogger('CustomBlockAPI')
+
+type RouteContext = { params: Promise<{ id: string }> }
+
+/**
+ * Confirm the caller can manage (edit/delete) the block: admin of the block's
+ * SOURCE workflow's workspace — matching who could publish it. Org admins/owners
+ * hold admin on every org workspace, so they pass too; a workspace admin from a
+ * different workspace does not, so they cannot alter another workspace's block or
+ * its exposed outputs.
+ */
+async function authorizeManage(userId: string, id: string) {
+ const ctx = await getCustomBlockManageContext(id)
+ if (!ctx) return { error: NextResponse.json({ error: 'Not found' }, { status: 404 }) }
+
+ if (!(await isFeatureEnabled('deploy-as-block', { userId, orgId: ctx.organizationId }))) {
+ return {
+ error: NextResponse.json({ error: 'Deploy as block is not enabled' }, { status: 403 }),
+ }
+ }
+ if (!ctx.sourceWorkspaceId || !(await hasWorkspaceAdminAccess(userId, ctx.sourceWorkspaceId))) {
+ return { error: NextResponse.json({ error: 'Admin permissions required' }, { status: 403 }) }
+ }
+ return { error: null }
+}
+
+export const PATCH = withRouteHandler(async (request: NextRequest, context: RouteContext) => {
+ const session = await getSession()
+ if (!session?.user?.id) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
+ }
+
+ const parsed = await parseRequest(updateCustomBlockContract, request, context)
+ if (!parsed.success) return parsed.response
+
+ const { id } = parsed.data.params
+ const authz = await authorizeManage(session.user.id, id)
+ if (authz.error) return authz.error
+
+ const { name, description, enabled, iconUrl, exposedOutputs } = parsed.data.body
+ try {
+ await updateCustomBlock(id, {
+ name,
+ description,
+ enabled,
+ iconUrl,
+ exposedOutputs,
+ })
+ return NextResponse.json({ success: true as const })
+ } catch (error) {
+ if (error instanceof CustomBlockValidationError) {
+ return NextResponse.json({ error: error.message }, { status: 400 })
+ }
+ logger.error('Failed to update custom block', { id, error: getErrorMessage(error) })
+ throw error
+ }
+})
+
+export const DELETE = withRouteHandler(async (request: NextRequest, context: RouteContext) => {
+ const session = await getSession()
+ if (!session?.user?.id) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
+ }
+
+ const parsed = await parseRequest(deleteCustomBlockContract, request, context)
+ if (!parsed.success) return parsed.response
+
+ const { id } = parsed.data.params
+ const authz = await authorizeManage(session.user.id, id)
+ if (authz.error) return authz.error
+
+ await deleteCustomBlock(id)
+ return NextResponse.json({ success: true as const })
+})
diff --git a/apps/sim/app/api/custom-blocks/route.ts b/apps/sim/app/api/custom-blocks/route.ts
new file mode 100644
index 00000000000..c244ae88ad4
--- /dev/null
+++ b/apps/sim/app/api/custom-blocks/route.ts
@@ -0,0 +1,130 @@
+import { createLogger } from '@sim/logger'
+import { getErrorMessage } from '@sim/utils/errors'
+import type { NextRequest } from 'next/server'
+import { NextResponse } from 'next/server'
+import {
+ listCustomBlocksContract,
+ publishCustomBlockContract,
+} from '@/lib/api/contracts/custom-blocks'
+import { parseRequest } from '@/lib/api/server'
+import { getSession } from '@/lib/auth'
+import { isOrganizationOnEnterprisePlan } from '@/lib/billing'
+import { isFeatureEnabled } from '@/lib/core/config/feature-flags'
+import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import {
+ CustomBlockValidationError,
+ type CustomBlockWithInputs,
+ listCustomBlocksWithInputs,
+ publishCustomBlock,
+} from '@/lib/workflows/custom-blocks/operations'
+import {
+ checkWorkspaceAccess,
+ getWorkspaceWithOwner,
+ hasWorkspaceAdminAccess,
+} from '@/lib/workspaces/permissions/utils'
+
+const logger = createLogger('CustomBlocksAPI')
+
+/** Wire shape for a custom block. Keeps the icon field name explicit for the client. */
+function toWire(block: CustomBlockWithInputs) {
+ return {
+ id: block.id,
+ organizationId: block.organizationId,
+ workflowId: block.workflowId,
+ type: block.type,
+ name: block.name,
+ description: block.description,
+ iconUrl: block.iconUrl,
+ enabled: block.enabled,
+ inputFields: block.inputFields,
+ exposedOutputs: block.exposedOutputs,
+ }
+}
+
+export const GET = withRouteHandler(async (request: NextRequest) => {
+ const session = await getSession()
+ if (!session?.user?.id) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
+ }
+
+ const parsed = await parseRequest(listCustomBlocksContract, request, {})
+ if (!parsed.success) return parsed.response
+
+ const userId = session.user.id
+ const { workspaceId } = parsed.data.query
+
+ const access = await checkWorkspaceAccess(workspaceId, userId)
+ if (!access.hasAccess) {
+ return NextResponse.json({ error: 'Access denied' }, { status: 403 })
+ }
+
+ const organizationId = access.workspace?.organizationId
+ if (!organizationId) {
+ return NextResponse.json({ enabled: false, customBlocks: [] })
+ }
+
+ if (!(await isFeatureEnabled('deploy-as-block', { userId, orgId: organizationId }))) {
+ return NextResponse.json({ enabled: false, customBlocks: [] })
+ }
+
+ const enabled = await isOrganizationOnEnterprisePlan(organizationId)
+ const blocks = enabled ? await listCustomBlocksWithInputs(organizationId) : []
+ return NextResponse.json({ enabled, customBlocks: blocks.map(toWire) })
+})
+
+export const POST = withRouteHandler(async (request: NextRequest) => {
+ const session = await getSession()
+ if (!session?.user?.id) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
+ }
+
+ const parsed = await parseRequest(publishCustomBlockContract, request, {})
+ if (!parsed.success) return parsed.response
+
+ const userId = session.user.id
+ const { workspaceId, workflowId, name, description, iconUrl, exposedOutputs } = parsed.data.body
+
+ if (!(await hasWorkspaceAdminAccess(userId, workspaceId))) {
+ return NextResponse.json({ error: 'Admin permissions required' }, { status: 403 })
+ }
+
+ const ws = await getWorkspaceWithOwner(workspaceId)
+ if (!ws?.organizationId) {
+ return NextResponse.json(
+ { error: 'Publishing a block requires the workspace to belong to an organization' },
+ { status: 400 }
+ )
+ }
+ const organizationId = ws.organizationId
+
+ if (!(await isFeatureEnabled('deploy-as-block', { userId, orgId: organizationId }))) {
+ return NextResponse.json({ error: 'Deploy as block is not enabled' }, { status: 403 })
+ }
+
+ if (!(await isOrganizationOnEnterprisePlan(organizationId))) {
+ return NextResponse.json(
+ { error: 'Deploy as block requires an enterprise plan' },
+ { status: 403 }
+ )
+ }
+
+ try {
+ const block = await publishCustomBlock({
+ organizationId,
+ workspaceId,
+ workflowId,
+ userId,
+ name,
+ description,
+ iconUrl,
+ exposedOutputs,
+ })
+ return NextResponse.json({ customBlock: toWire(block) })
+ } catch (error) {
+ if (error instanceof CustomBlockValidationError) {
+ return NextResponse.json({ error: error.message }, { status: 400 })
+ }
+ logger.error('Failed to publish custom block', { error: getErrorMessage(error) })
+ throw error
+ }
+})
diff --git a/apps/sim/app/api/files/upload/route.test.ts b/apps/sim/app/api/files/upload/route.test.ts
index bbc42c5e743..f6df57eb59d 100644
--- a/apps/sim/app/api/files/upload/route.test.ts
+++ b/apps/sim/app/api/files/upload/route.test.ts
@@ -562,16 +562,9 @@ describe('File Upload Security Tests', () => {
return formData
}
- beforeEach(() => {
- setupFileApiMocks({
- cloudEnabled: false,
- storageProvider: 'local',
- })
- })
-
- it('rejects execution uploads without workspaceId', async () => {
+ const postExecutionUpload = async (workspaceId: string | null = 'test-workspace-id') => {
const file = new File(['test content'], 'test.pdf', { type: 'application/pdf' })
- const formData = createExecutionFormData(file, null)
+ const formData = createExecutionFormData(file, workspaceId)
const req = new Request('http://localhost/api/files/upload', {
method: 'POST',
@@ -579,7 +572,18 @@ describe('File Upload Security Tests', () => {
body: formData,
})
- const response = await POST(req as unknown as NextRequest)
+ return POST(req as unknown as NextRequest)
+ }
+
+ beforeEach(() => {
+ setupFileApiMocks({
+ cloudEnabled: false,
+ storageProvider: 'local',
+ })
+ })
+
+ it('rejects execution uploads without workspaceId', async () => {
+ const response = await postExecutionUpload(null)
expect(response.status).toBe(400)
const data = await response.json()
@@ -590,16 +594,7 @@ describe('File Upload Security Tests', () => {
it('rejects execution uploads for a read-only workspace member', async () => {
permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue('read')
- const file = new File(['test content'], 'test.pdf', { type: 'application/pdf' })
- const formData = createExecutionFormData(file)
-
- const req = new Request('http://localhost/api/files/upload', {
- method: 'POST',
- headers: { 'content-length': '1024' },
- body: formData,
- })
-
- const response = await POST(req as unknown as NextRequest)
+ const response = await postExecutionUpload()
expect(response.status).toBe(403)
const data = await response.json()
@@ -610,16 +605,7 @@ describe('File Upload Security Tests', () => {
it('rejects execution uploads for a member with no workspace permission', async () => {
permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue(null)
- const file = new File(['test content'], 'test.pdf', { type: 'application/pdf' })
- const formData = createExecutionFormData(file)
-
- const req = new Request('http://localhost/api/files/upload', {
- method: 'POST',
- headers: { 'content-length': '1024' },
- body: formData,
- })
-
- const response = await POST(req as unknown as NextRequest)
+ const response = await postExecutionUpload()
expect(response.status).toBe(403)
expect(mocks.mockUploadExecutionFile).not.toHaveBeenCalled()
@@ -628,16 +614,7 @@ describe('File Upload Security Tests', () => {
it('allows execution uploads for a write-permission workspace member', async () => {
permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue('write')
- const file = new File(['test content'], 'test.pdf', { type: 'application/pdf' })
- const formData = createExecutionFormData(file)
-
- const req = new Request('http://localhost/api/files/upload', {
- method: 'POST',
- headers: { 'content-length': '1024' },
- body: formData,
- })
-
- const response = await POST(req as unknown as NextRequest)
+ const response = await postExecutionUpload()
expect(response.status).toBe(200)
expect(mocks.mockUploadExecutionFile).toHaveBeenCalledWith(
@@ -656,16 +633,7 @@ describe('File Upload Security Tests', () => {
it('allows execution uploads for an admin-permission workspace member', async () => {
permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue('admin')
- const file = new File(['test content'], 'test.pdf', { type: 'application/pdf' })
- const formData = createExecutionFormData(file)
-
- const req = new Request('http://localhost/api/files/upload', {
- method: 'POST',
- headers: { 'content-length': '1024' },
- body: formData,
- })
-
- const response = await POST(req as unknown as NextRequest)
+ const response = await postExecutionUpload()
expect(response.status).toBe(200)
expect(mocks.mockUploadExecutionFile).toHaveBeenCalled()
diff --git a/apps/sim/app/api/files/upload/route.ts b/apps/sim/app/api/files/upload/route.ts
index a68fb9da045..64914af8970 100644
--- a/apps/sim/app/api/files/upload/route.ts
+++ b/apps/sim/app/api/files/upload/route.ts
@@ -13,6 +13,7 @@ import { getSession } from '@/lib/auth'
import {
assertKnownSizeWithinLimit,
isPayloadSizeLimitError,
+ MAX_MULTIPART_OVERHEAD_BYTES,
readFileToBufferWithLimit,
readFormDataWithLimit,
} from '@/lib/core/utils/stream-limits'
@@ -32,7 +33,6 @@ import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
import { createErrorResponse, InvalidRequestError } from '@/app/api/files/utils'
const ALLOWED_EXTENSIONS = new Set
(SUPPORTED_ATTACHMENT_EXTENSIONS)
-const MAX_MULTIPART_OVERHEAD_BYTES = 1024 * 1024
function validateFileExtension(filename: string): boolean {
const extension = filename.split('.').pop()?.toLowerCase()
@@ -92,6 +92,29 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
const usingCloudStorage = storageService.hasCloudStorage()
logger.info(`Using storage mode: ${usingCloudStorage ? 'Cloud' : 'Local'} for file upload`)
+ // Execution context requires a workspace write/admin permission check. Resolve it once per
+ // request (not per file) since workspaceId is invariant across all files in the upload.
+ let executionUploadContext:
+ | { workspaceId: string; workflowId: string; executionId: string }
+ | undefined
+ if (context === 'execution') {
+ if (!workflowId || !executionId || !workspaceId) {
+ throw new InvalidRequestError(
+ 'Execution context requires workflowId, executionId, and workspaceId parameters'
+ )
+ }
+
+ const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId)
+ if (permission !== 'write' && permission !== 'admin') {
+ return NextResponse.json(
+ { error: 'Write or Admin access required for execution uploads' },
+ { status: 403 }
+ )
+ }
+
+ executionUploadContext = { workspaceId, workflowId, executionId }
+ }
+
const uploadResults = []
for (const file of files) {
@@ -110,28 +133,10 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
})
// Handle execution context
- if (context === 'execution') {
- if (!workflowId || !executionId || !workspaceId) {
- throw new InvalidRequestError(
- 'Execution context requires workflowId, executionId, and workspaceId parameters'
- )
- }
-
- const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId)
- if (permission !== 'write' && permission !== 'admin') {
- return NextResponse.json(
- { error: 'Write or Admin access required for execution uploads' },
- { status: 403 }
- )
- }
-
+ if (context === 'execution' && executionUploadContext) {
const { uploadExecutionFile } = await import('@/lib/uploads/contexts/execution')
const userFile = await uploadExecutionFile(
- {
- workspaceId,
- workflowId,
- executionId,
- },
+ executionUploadContext,
buffer,
originalName,
file.type,
diff --git a/apps/sim/app/api/mcp/oauth/callback/route.test.ts b/apps/sim/app/api/mcp/oauth/callback/route.test.ts
index d79c43e4a76..1a1f5bcb8e8 100644
--- a/apps/sim/app/api/mcp/oauth/callback/route.test.ts
+++ b/apps/sim/app/api/mcp/oauth/callback/route.test.ts
@@ -13,13 +13,9 @@ import {
import { NextRequest } from 'next/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'
-const { mockMcpAuth, mockCreateSsrfGuardedMcpFetch, mockGuardedFetch, mockDiscoverServerTools } =
- vi.hoisted(() => ({
- mockMcpAuth: vi.fn(),
- mockCreateSsrfGuardedMcpFetch: vi.fn(),
- mockGuardedFetch: vi.fn(),
- mockDiscoverServerTools: vi.fn(),
- }))
+const { mockDiscoverServerTools } = vi.hoisted(() => ({
+ mockDiscoverServerTools: vi.fn(),
+}))
vi.mock('@sim/db', () => dbChainMock)
vi.mock('@sim/db/schema', () => schemaMock)
@@ -28,13 +24,7 @@ vi.mock('drizzle-orm', () => ({
eq: vi.fn(),
isNull: vi.fn(),
}))
-vi.mock('@modelcontextprotocol/sdk/client/auth.js', () => ({
- auth: mockMcpAuth,
-}))
vi.mock('@/lib/mcp/oauth', () => mcpOauthMock)
-vi.mock('@/lib/mcp/pinned-fetch', () => ({
- createSsrfGuardedMcpFetch: mockCreateSsrfGuardedMcpFetch,
-}))
vi.mock('@/lib/mcp/service', () => ({
mcpService: { discoverServerTools: mockDiscoverServerTools },
}))
@@ -45,7 +35,6 @@ describe('MCP OAuth callback route', () => {
beforeEach(() => {
vi.clearAllMocks()
resetDbChainMock()
- mockCreateSsrfGuardedMcpFetch.mockReturnValue(mockGuardedFetch)
authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-1' } })
mcpOauthMockFns.mockLoadOauthRowByState.mockResolvedValue({
id: 'oauth-row-1',
@@ -61,24 +50,25 @@ describe('MCP OAuth callback route', () => {
},
])
mcpOauthMockFns.mockLoadPreregisteredClient.mockResolvedValue(undefined)
- mockMcpAuth.mockResolvedValue('AUTHORIZED')
+ mcpOauthMockFns.mockMcpAuthGuarded.mockResolvedValue('AUTHORIZED')
mockDiscoverServerTools.mockResolvedValue(undefined)
})
- it('performs the token exchange through the SSRF-guarded fetch', async () => {
+ it('performs the token exchange through the SSRF-guarded mcpAuthGuarded wrapper', async () => {
const request = new NextRequest(
'http://localhost:3000/api/mcp/oauth/callback?state=state-1&code=auth-code-1'
)
await GET(request)
- expect(mockCreateSsrfGuardedMcpFetch).toHaveBeenCalledTimes(1)
- expect(mockMcpAuth).toHaveBeenCalledWith(
+ // The route must call the guarded wrapper (which defaults fetchFn to the
+ // SSRF-guarded fetch internally) rather than the raw SDK `auth()` — see
+ // apps/sim/lib/mcp/oauth/auth.test.ts for the wrapper's own fetchFn coverage.
+ expect(mcpOauthMockFns.mockMcpAuthGuarded).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
serverUrl: 'https://mcp.example.com/mcp',
authorizationCode: 'auth-code-1',
- fetchFn: mockGuardedFetch,
})
)
})
diff --git a/apps/sim/app/api/mcp/oauth/callback/route.ts b/apps/sim/app/api/mcp/oauth/callback/route.ts
index 0acfff85778..5c75416a3bc 100644
--- a/apps/sim/app/api/mcp/oauth/callback/route.ts
+++ b/apps/sim/app/api/mcp/oauth/callback/route.ts
@@ -1,4 +1,3 @@
-import { auth as mcpAuth } from '@modelcontextprotocol/sdk/client/auth.js'
import { db } from '@sim/db'
import { mcpServers } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
@@ -17,9 +16,9 @@ import {
loadOauthRowByState,
loadPreregisteredClient,
type McpOauthCallbackReason,
+ mcpAuthGuarded,
SimMcpOauthProvider,
} from '@/lib/mcp/oauth'
-import { createSsrfGuardedMcpFetch } from '@/lib/mcp/pinned-fetch'
import { mcpService } from '@/lib/mcp/service'
const logger = createLogger('McpOauthCallbackAPI')
@@ -145,12 +144,11 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
const preregistered = await loadPreregisteredClient(server.id)
const provider = new SimMcpOauthProvider({ row, preregistered })
- let result: Awaited>
+ let result: Awaited>
try {
- result = await mcpAuth(provider, {
+ result = await mcpAuthGuarded(provider, {
serverUrl: server.url,
authorizationCode: code,
- fetchFn: createSsrfGuardedMcpFetch(),
})
} catch (e) {
logger.error('Token exchange failed during MCP OAuth callback', e)
diff --git a/apps/sim/app/api/mcp/oauth/start/route.test.ts b/apps/sim/app/api/mcp/oauth/start/route.test.ts
index e4a5132a4fa..68dd96b2bf3 100644
--- a/apps/sim/app/api/mcp/oauth/start/route.test.ts
+++ b/apps/sim/app/api/mcp/oauth/start/route.test.ts
@@ -17,12 +17,6 @@ import {
import { NextRequest } from 'next/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'
-const { mockMcpAuth, mockCreateSsrfGuardedMcpFetch, mockGuardedFetch } = vi.hoisted(() => ({
- mockMcpAuth: vi.fn(),
- mockCreateSsrfGuardedMcpFetch: vi.fn(),
- mockGuardedFetch: vi.fn(),
-}))
-
vi.mock('@sim/db', () => dbChainMock)
vi.mock('@sim/db/schema', () => schemaMock)
vi.mock('drizzle-orm', () => ({
@@ -30,12 +24,6 @@ vi.mock('drizzle-orm', () => ({
eq: vi.fn(),
isNull: vi.fn(),
}))
-vi.mock('@modelcontextprotocol/sdk/client/auth.js', () => ({
- auth: mockMcpAuth,
-}))
-vi.mock('@/lib/mcp/pinned-fetch', () => ({
- createSsrfGuardedMcpFetch: mockCreateSsrfGuardedMcpFetch,
-}))
vi.mock('@/lib/auth/hybrid', () => hybridAuthMock)
vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock)
vi.mock('@/lib/mcp/oauth', () => mcpOauthMock)
@@ -77,21 +65,24 @@ describe('MCP OAuth start route', () => {
updatedAt: new Date(),
})
mcpOauthMockFns.mockLoadPreregisteredClient.mockResolvedValue(undefined)
- mockMcpAuth.mockRejectedValue(new McpOauthRedirectRequiredMock('https://mcp.exa.ai/authorize'))
- mockCreateSsrfGuardedMcpFetch.mockReturnValue(mockGuardedFetch)
+ mcpOauthMockFns.mockMcpAuthGuarded.mockRejectedValue(
+ new McpOauthRedirectRequiredMock('https://mcp.exa.ai/authorize')
+ )
})
- it('routes OAuth discovery through the SSRF-guarded fetch', async () => {
+ it('routes OAuth discovery through the SSRF-guarded mcpAuthGuarded wrapper', async () => {
const request = new NextRequest(
'http://localhost:3000/api/mcp/oauth/start?workspaceId=workspace-1&serverId=server-1'
)
await GET(request)
- expect(mockCreateSsrfGuardedMcpFetch).toHaveBeenCalledTimes(1)
- expect(mockMcpAuth).toHaveBeenCalledWith(
+ // The route must call the guarded wrapper (which defaults fetchFn to the
+ // SSRF-guarded fetch internally) rather than the raw SDK `auth()` — see
+ // apps/sim/lib/mcp/oauth/auth.test.ts for the wrapper's own fetchFn coverage.
+ expect(mcpOauthMockFns.mockMcpAuthGuarded).toHaveBeenCalledWith(
expect.anything(),
- expect.objectContaining({ serverUrl: 'https://mcp.exa.ai/mcp', fetchFn: mockGuardedFetch })
+ expect.objectContaining({ serverUrl: 'https://mcp.exa.ai/mcp' })
)
})
@@ -152,7 +143,7 @@ describe('MCP OAuth start route', () => {
expect(response.status).toBe(409)
expect(body.error).toBe('OAuth authorization already in progress for this server')
- expect(mockMcpAuth).not.toHaveBeenCalled()
+ expect(mcpOauthMockFns.mockMcpAuthGuarded).not.toHaveBeenCalled()
})
it('does not leak non-OAuth internal error details to the client', async () => {
diff --git a/apps/sim/app/api/mcp/oauth/start/route.ts b/apps/sim/app/api/mcp/oauth/start/route.ts
index 3b228bd95c0..edb17a0f1c9 100644
--- a/apps/sim/app/api/mcp/oauth/start/route.ts
+++ b/apps/sim/app/api/mcp/oauth/start/route.ts
@@ -1,4 +1,3 @@
-import { auth as mcpAuth } from '@modelcontextprotocol/sdk/client/auth.js'
import { OAuthError, ServerError } from '@modelcontextprotocol/sdk/server/auth/errors.js'
import { db } from '@sim/db'
import { mcpServers } from '@sim/db/schema'
@@ -17,10 +16,10 @@ import {
loadPreregisteredClient,
McpOauthInsecureUrlError,
McpOauthRedirectRequired,
+ mcpAuthGuarded,
SimMcpOauthProvider,
setOauthRowUser,
} from '@/lib/mcp/oauth'
-import { createSsrfGuardedMcpFetch } from '@/lib/mcp/pinned-fetch'
import { createMcpErrorResponse } from '@/lib/mcp/utils'
const logger = createLogger('McpOauthStartAPI')
@@ -130,9 +129,8 @@ export const GET = withRouteHandler(
const provider = new SimMcpOauthProvider({ row, preregistered })
try {
- const result = await mcpAuth(provider, {
+ const result = await mcpAuthGuarded(provider, {
serverUrl: server.url,
- fetchFn: createSsrfGuardedMcpFetch(),
})
if (result === 'AUTHORIZED') {
return NextResponse.json({ status: 'already_authorized' })
diff --git a/apps/sim/app/api/tools/google_drive/download/route.test.ts b/apps/sim/app/api/tools/google_drive/download/route.test.ts
new file mode 100644
index 00000000000..a9ec28346de
--- /dev/null
+++ b/apps/sim/app/api/tools/google_drive/download/route.test.ts
@@ -0,0 +1,178 @@
+/**
+ * @vitest-environment node
+ */
+import {
+ createMockRequest,
+ hybridAuthMockFns,
+ inputValidationMock,
+ inputValidationMockFns,
+} from '@sim/testing'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { MAX_FILE_SIZE } from '@/lib/uploads/utils/validation'
+
+vi.mock('@/lib/core/security/input-validation.server', () => inputValidationMock)
+
+import { PayloadSizeLimitError } from '@/lib/core/utils/stream-limits'
+import { POST } from '@/app/api/tools/google_drive/download/route'
+
+const { mockValidateUrlWithDNS, mockSecureFetchWithPinnedIP } = inputValidationMockFns
+
+const PINNED_IP = '93.184.216.34'
+
+const baseBody = {
+ accessToken: 'token-123',
+ fileId: 'file-abc',
+}
+
+function jsonResponse(body: unknown, ok = true) {
+ return {
+ ok,
+ status: ok ? 200 : 400,
+ statusText: '',
+ headers: new Headers(),
+ body: null,
+ text: async () => JSON.stringify(body),
+ json: async () => body,
+ arrayBuffer: async () => new ArrayBuffer(0),
+ }
+}
+
+function fileResponse(bytes: number, ok = true) {
+ return {
+ ok,
+ status: ok ? 200 : 400,
+ statusText: '',
+ headers: new Headers(),
+ body: null,
+ text: async () => '',
+ json: async () => ({}),
+ arrayBuffer: async () => new ArrayBuffer(bytes),
+ }
+}
+
+beforeEach(() => {
+ vi.clearAllMocks()
+ hybridAuthMockFns.mockCheckInternalAuth.mockResolvedValue({
+ success: true,
+ userId: 'user-1',
+ authType: 'internal_jwt',
+ })
+ mockValidateUrlWithDNS.mockResolvedValue({
+ isValid: true,
+ resolvedIP: PINNED_IP,
+ originalHostname: 'www.googleapis.com',
+ })
+})
+
+describe('POST /api/tools/google_drive/download', () => {
+ it('downloads a normal file under the size cap', async () => {
+ mockSecureFetchWithPinnedIP
+ .mockResolvedValueOnce(
+ jsonResponse({
+ id: 'file-abc',
+ name: 'report.pdf',
+ mimeType: 'application/pdf',
+ size: '1024',
+ capabilities: { canReadRevisions: false },
+ })
+ )
+ .mockResolvedValueOnce(fileResponse(1024))
+
+ const response = await POST(createMockRequest('POST', baseBody))
+ expect(response.status).toBe(200)
+ const data = (await response.json()) as { success: boolean; output: { file: { size: number } } }
+ expect(data.success).toBe(true)
+ expect(data.output.file.size).toBe(1024)
+
+ const downloadCall = mockSecureFetchWithPinnedIP.mock.calls[1]
+ expect(downloadCall[2]).toMatchObject({ maxResponseBytes: MAX_FILE_SIZE })
+ })
+
+ it('rejects the download before fetching content when metadata size exceeds the cap', async () => {
+ mockSecureFetchWithPinnedIP.mockResolvedValueOnce(
+ jsonResponse({
+ id: 'file-abc',
+ name: 'huge.bin',
+ mimeType: 'application/octet-stream',
+ size: String(MAX_FILE_SIZE + 1),
+ capabilities: { canReadRevisions: false },
+ })
+ )
+
+ const response = await POST(createMockRequest('POST', baseBody))
+ expect(response.status).toBe(413)
+ const data = (await response.json()) as { success: boolean; error: string }
+ expect(data.success).toBe(false)
+ expect(data.error).toContain('exceeds maximum size')
+
+ // Content download must never be initiated once metadata size trips the check.
+ expect(mockSecureFetchWithPinnedIP).toHaveBeenCalledTimes(1)
+ })
+
+ it('surfaces a clean 413 when the streamed content exceeds the cap', async () => {
+ mockSecureFetchWithPinnedIP
+ .mockResolvedValueOnce(
+ jsonResponse({
+ id: 'file-abc',
+ name: 'report.pdf',
+ mimeType: 'application/pdf',
+ capabilities: { canReadRevisions: false },
+ })
+ )
+ .mockRejectedValueOnce(
+ new PayloadSizeLimitError({
+ label: 'response body',
+ maxBytes: MAX_FILE_SIZE,
+ observedBytes: MAX_FILE_SIZE + 1,
+ })
+ )
+
+ const response = await POST(createMockRequest('POST', baseBody))
+ expect(response.status).toBe(413)
+ const data = (await response.json()) as { success: boolean }
+ expect(data.success).toBe(false)
+ })
+
+ it('proceeds to the streamed download when metadata size is malformed', async () => {
+ mockSecureFetchWithPinnedIP
+ .mockResolvedValueOnce(
+ jsonResponse({
+ id: 'file-abc',
+ name: 'report.pdf',
+ mimeType: 'application/pdf',
+ size: 'not-a-number',
+ capabilities: { canReadRevisions: false },
+ })
+ )
+ .mockResolvedValueOnce(fileResponse(1024))
+
+ const response = await POST(createMockRequest('POST', baseBody))
+ expect(response.status).toBe(200)
+ const data = (await response.json()) as { success: boolean; output: { file: { size: number } } }
+ expect(data.success).toBe(true)
+ expect(data.output.file.size).toBe(1024)
+
+ // The early size check should be skipped, but the streaming cap must still apply.
+ const downloadCall = mockSecureFetchWithPinnedIP.mock.calls[1]
+ expect(downloadCall[2]).toMatchObject({ maxResponseBytes: MAX_FILE_SIZE })
+ })
+
+ it('does not require a metadata size for Google Workspace exports', async () => {
+ mockSecureFetchWithPinnedIP
+ .mockResolvedValueOnce(
+ jsonResponse({
+ id: 'doc-1',
+ name: 'My Doc',
+ mimeType: 'application/vnd.google-apps.document',
+ capabilities: { canReadRevisions: false },
+ })
+ )
+ .mockResolvedValueOnce(fileResponse(2048))
+
+ const response = await POST(createMockRequest('POST', baseBody))
+ expect(response.status).toBe(200)
+
+ const exportCall = mockSecureFetchWithPinnedIP.mock.calls[1]
+ expect(exportCall[2]).toMatchObject({ maxResponseBytes: MAX_FILE_SIZE })
+ })
+})
diff --git a/apps/sim/app/api/tools/google_drive/download/route.ts b/apps/sim/app/api/tools/google_drive/download/route.ts
index 8d063b8058c..788a7de418b 100644
--- a/apps/sim/app/api/tools/google_drive/download/route.ts
+++ b/apps/sim/app/api/tools/google_drive/download/route.ts
@@ -9,7 +9,9 @@ import {
validateUrlWithDNS,
} from '@/lib/core/security/input-validation.server'
import { generateRequestId } from '@/lib/core/utils/request'
+import { assertKnownSizeWithinLimit, isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import { MAX_FILE_SIZE } from '@/lib/uploads/utils/validation'
import type { GoogleDriveFile, GoogleDriveRevision } from '@/tools/google_drive/types'
import {
ALL_FILE_FIELDS,
@@ -160,7 +162,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
const exportResponse = await secureFetchWithPinnedIP(
exportUrl,
exportUrlValidation.resolvedIP!,
- { headers: { Authorization: authHeader } }
+ { headers: { Authorization: authHeader }, maxResponseBytes: MAX_FILE_SIZE }
)
if (!exportResponse.ok) {
@@ -185,6 +187,13 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
} else {
logger.info(`[${requestId}] Downloading regular file`, { fileId, mimeType: fileMimeType })
+ if (metadata.size) {
+ const parsedSize = Number.parseInt(metadata.size, 10)
+ if (Number.isFinite(parsedSize)) {
+ assertKnownSizeWithinLimit(parsedSize, MAX_FILE_SIZE, `Google Drive file ${fileId}`)
+ }
+ }
+
const downloadUrl = `https://www.googleapis.com/drive/v3/files/${fileId}?alt=media&supportsAllDrives=true`
const downloadUrlValidation = await validateUrlWithDNS(downloadUrl, 'downloadUrl')
if (!downloadUrlValidation.isValid) {
@@ -197,7 +206,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
const downloadResponse = await secureFetchWithPinnedIP(
downloadUrl,
downloadUrlValidation.resolvedIP!,
- { headers: { Authorization: authHeader } }
+ { headers: { Authorization: authHeader }, maxResponseBytes: MAX_FILE_SIZE }
)
if (!downloadResponse.ok) {
@@ -274,7 +283,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
success: false,
error: getErrorMessage(error, 'Unknown error occurred'),
},
- { status: 500 }
+ { status: isPayloadSizeLimitError(error) ? 413 : 500 }
)
}
})
diff --git a/apps/sim/app/api/tools/onedrive/download/route.test.ts b/apps/sim/app/api/tools/onedrive/download/route.test.ts
new file mode 100644
index 00000000000..ea93ea79eba
--- /dev/null
+++ b/apps/sim/app/api/tools/onedrive/download/route.test.ts
@@ -0,0 +1,107 @@
+/**
+ * @vitest-environment node
+ */
+import {
+ createMockRequest,
+ hybridAuthMockFns,
+ inputValidationMock,
+ inputValidationMockFns,
+} from '@sim/testing'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { MAX_FILE_SIZE } from '@/lib/uploads/utils/validation'
+
+vi.mock('@/lib/core/security/input-validation.server', () => inputValidationMock)
+
+import { PayloadSizeLimitError } from '@/lib/core/utils/stream-limits'
+import { POST } from '@/app/api/tools/onedrive/download/route'
+
+const { mockValidateUrlWithDNS, mockSecureFetchWithPinnedIP } = inputValidationMockFns
+
+const PINNED_IP = '93.184.216.34'
+
+const baseBody = {
+ accessToken: 'token-123',
+ fileId: 'file-abc',
+}
+
+function jsonResponse(body: unknown, ok = true) {
+ return {
+ ok,
+ status: ok ? 200 : 400,
+ statusText: '',
+ headers: new Headers(),
+ body: null,
+ text: async () => JSON.stringify(body),
+ json: async () => body,
+ arrayBuffer: async () => new ArrayBuffer(0),
+ }
+}
+
+function fileResponse(bytes: number) {
+ return {
+ ok: true,
+ status: 200,
+ statusText: '',
+ headers: new Headers(),
+ body: null,
+ text: async () => '',
+ json: async () => ({}),
+ arrayBuffer: async () => new ArrayBuffer(bytes),
+ }
+}
+
+beforeEach(() => {
+ vi.clearAllMocks()
+ hybridAuthMockFns.mockCheckInternalAuth.mockResolvedValue({
+ success: true,
+ userId: 'user-1',
+ authType: 'internal_jwt',
+ })
+ mockValidateUrlWithDNS.mockResolvedValue({
+ isValid: true,
+ resolvedIP: PINNED_IP,
+ originalHostname: 'graph.microsoft.com',
+ })
+})
+
+describe('POST /api/tools/onedrive/download', () => {
+ it('downloads a normal file under the size cap', async () => {
+ mockSecureFetchWithPinnedIP
+ .mockResolvedValueOnce(
+ jsonResponse({ id: 'file-abc', name: 'report.pdf', file: { mimeType: 'application/pdf' } })
+ )
+ .mockResolvedValueOnce(fileResponse(1024))
+
+ const response = await POST(createMockRequest('POST', baseBody))
+ expect(response.status).toBe(200)
+ const data = (await response.json()) as { success: boolean; output: { file: { size: number } } }
+ expect(data.success).toBe(true)
+ expect(data.output.file.size).toBe(1024)
+
+ const downloadCall = mockSecureFetchWithPinnedIP.mock.calls[1]
+ expect(downloadCall[2]).toMatchObject({ maxResponseBytes: MAX_FILE_SIZE })
+ })
+
+ it('surfaces a clean 413 when the streamed content exceeds the cap', async () => {
+ mockSecureFetchWithPinnedIP
+ .mockResolvedValueOnce(
+ jsonResponse({
+ id: 'file-abc',
+ name: 'huge.bin',
+ file: { mimeType: 'application/octet-stream' },
+ })
+ )
+ .mockRejectedValueOnce(
+ new PayloadSizeLimitError({
+ label: 'response body',
+ maxBytes: MAX_FILE_SIZE,
+ observedBytes: MAX_FILE_SIZE + 1,
+ })
+ )
+
+ const response = await POST(createMockRequest('POST', baseBody))
+ expect(response.status).toBe(413)
+ const data = (await response.json()) as { success: boolean }
+ expect(data.success).toBe(false)
+ })
+})
diff --git a/apps/sim/app/api/tools/onedrive/download/route.ts b/apps/sim/app/api/tools/onedrive/download/route.ts
index d713208c494..d1c314397c3 100644
--- a/apps/sim/app/api/tools/onedrive/download/route.ts
+++ b/apps/sim/app/api/tools/onedrive/download/route.ts
@@ -9,7 +9,9 @@ import {
validateUrlWithDNS,
} from '@/lib/core/security/input-validation.server'
import { generateRequestId } from '@/lib/core/utils/request'
+import { isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import { MAX_FILE_SIZE } from '@/lib/uploads/utils/validation'
export const dynamic = 'force-dynamic'
@@ -120,6 +122,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
downloadUrlValidation.resolvedIP!,
{
headers: { Authorization: authHeader },
+ maxResponseBytes: MAX_FILE_SIZE,
}
)
@@ -167,7 +170,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
success: false,
error: getErrorMessage(error, 'Unknown error occurred'),
},
- { status: 500 }
+ { status: isPayloadSizeLimitError(error) ? 413 : 500 }
)
}
})
diff --git a/apps/sim/app/api/tools/slack/download/route.test.ts b/apps/sim/app/api/tools/slack/download/route.test.ts
new file mode 100644
index 00000000000..8e31d5fcc53
--- /dev/null
+++ b/apps/sim/app/api/tools/slack/download/route.test.ts
@@ -0,0 +1,99 @@
+/**
+ * @vitest-environment node
+ */
+import {
+ createMockRequest,
+ hybridAuthMockFns,
+ inputValidationMock,
+ inputValidationMockFns,
+} from '@sim/testing'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { MAX_FILE_SIZE } from '@/lib/uploads/utils/validation'
+
+vi.mock('@/lib/core/security/input-validation.server', () => inputValidationMock)
+
+import { PayloadSizeLimitError } from '@/lib/core/utils/stream-limits'
+import { POST } from '@/app/api/tools/slack/download/route'
+
+const { mockValidateUrlWithDNS, mockSecureFetchWithPinnedIP } = inputValidationMockFns
+
+const PINNED_IP = '93.184.216.34'
+
+const baseBody = {
+ accessToken: 'token-123',
+ fileId: 'file-abc',
+}
+
+function fileResponse(bytes: number) {
+ return {
+ ok: true,
+ status: 200,
+ statusText: '',
+ headers: new Headers(),
+ body: null,
+ text: async () => '',
+ json: async () => ({}),
+ arrayBuffer: async () => new ArrayBuffer(bytes),
+ }
+}
+
+const originalFetch = global.fetch
+
+beforeEach(() => {
+ vi.clearAllMocks()
+ hybridAuthMockFns.mockCheckInternalAuth.mockResolvedValue({
+ success: true,
+ userId: 'user-1',
+ authType: 'internal_jwt',
+ })
+ mockValidateUrlWithDNS.mockResolvedValue({
+ isValid: true,
+ resolvedIP: PINNED_IP,
+ originalHostname: 'files.slack.com',
+ })
+ global.fetch = vi.fn().mockResolvedValue({
+ ok: true,
+ json: async () => ({
+ ok: true,
+ file: {
+ name: 'report.pdf',
+ mimetype: 'application/pdf',
+ url_private: 'https://files.slack.com/files-pri/T000-F000/report.pdf',
+ },
+ }),
+ }) as unknown as typeof fetch
+})
+
+afterEach(() => {
+ global.fetch = originalFetch
+})
+
+describe('POST /api/tools/slack/download', () => {
+ it('downloads a normal file under the size cap', async () => {
+ mockSecureFetchWithPinnedIP.mockResolvedValueOnce(fileResponse(1024))
+
+ const response = await POST(createMockRequest('POST', baseBody))
+ expect(response.status).toBe(200)
+ const data = (await response.json()) as { success: boolean; output: { file: { size: number } } }
+ expect(data.success).toBe(true)
+ expect(data.output.file.size).toBe(1024)
+
+ const downloadCall = mockSecureFetchWithPinnedIP.mock.calls[0]
+ expect(downloadCall[2]).toMatchObject({ maxResponseBytes: MAX_FILE_SIZE })
+ })
+
+ it('surfaces a clean 413 when the streamed content exceeds the cap', async () => {
+ mockSecureFetchWithPinnedIP.mockRejectedValueOnce(
+ new PayloadSizeLimitError({
+ label: 'response body',
+ maxBytes: MAX_FILE_SIZE,
+ observedBytes: MAX_FILE_SIZE + 1,
+ })
+ )
+
+ const response = await POST(createMockRequest('POST', baseBody))
+ expect(response.status).toBe(413)
+ const data = (await response.json()) as { success: boolean }
+ expect(data.success).toBe(false)
+ })
+})
diff --git a/apps/sim/app/api/tools/slack/download/route.ts b/apps/sim/app/api/tools/slack/download/route.ts
index 2cc356bef6d..68eef0e7048 100644
--- a/apps/sim/app/api/tools/slack/download/route.ts
+++ b/apps/sim/app/api/tools/slack/download/route.ts
@@ -9,7 +9,9 @@ import {
validateUrlWithDNS,
} from '@/lib/core/security/input-validation.server'
import { generateRequestId } from '@/lib/core/utils/request'
+import { isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import { MAX_FILE_SIZE } from '@/lib/uploads/utils/validation'
export const dynamic = 'force-dynamic'
@@ -114,6 +116,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
headers: {
Authorization: `Bearer ${accessToken}`,
},
+ maxResponseBytes: MAX_FILE_SIZE,
})
if (!downloadResponse.ok) {
@@ -160,7 +163,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
success: false,
error: getErrorMessage(error, 'Unknown error occurred'),
},
- { status: 500 }
+ { status: isPayloadSizeLimitError(error) ? 413 : 500 }
)
}
})
diff --git a/apps/sim/app/api/tools/stt/route.test.ts b/apps/sim/app/api/tools/stt/route.test.ts
new file mode 100644
index 00000000000..3413350e8e5
--- /dev/null
+++ b/apps/sim/app/api/tools/stt/route.test.ts
@@ -0,0 +1,115 @@
+/**
+ * @vitest-environment node
+ */
+import {
+ createMockRequest,
+ hybridAuthMockFns,
+ inputValidationMock,
+ inputValidationMockFns,
+} from '@sim/testing'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { PayloadSizeLimitError } from '@/lib/core/utils/stream-limits'
+
+const { mockIsInternalFileUrl, mockDownloadFileFromStorage, mockResolveInternalFileUrl } =
+ vi.hoisted(() => ({
+ mockIsInternalFileUrl: vi.fn(),
+ mockDownloadFileFromStorage: vi.fn(),
+ mockResolveInternalFileUrl: vi.fn(),
+ }))
+
+vi.mock('@/lib/core/security/input-validation.server', () => inputValidationMock)
+vi.mock('@/lib/uploads/utils/file-utils', () => ({
+ isInternalFileUrl: mockIsInternalFileUrl,
+ getMimeTypeFromExtension: vi.fn(() => 'application/octet-stream'),
+}))
+vi.mock('@/lib/uploads/utils/file-utils.server', () => ({
+ downloadFileFromStorage: mockDownloadFileFromStorage,
+ resolveInternalFileUrl: mockResolveInternalFileUrl,
+}))
+vi.mock('@/app/api/files/authorization', () => ({
+ assertToolFileAccess: vi.fn().mockResolvedValue(null),
+}))
+vi.mock('@/lib/audio/extractor', () => ({
+ isVideoFile: vi.fn(() => false),
+ extractAudioFromVideo: vi.fn(),
+}))
+
+import { POST } from '@/app/api/tools/stt/route'
+
+const PINNED_IP = '93.184.216.34'
+
+const baseBody = {
+ provider: 'whisper',
+ apiKey: 'test-api-key',
+ audioUrl: 'https://example.com/audio.mp3',
+}
+
+function mockSecureFetchResponse(body: { ok?: boolean; contentType?: string }) {
+ return {
+ ok: body.ok ?? true,
+ status: 200,
+ statusText: '',
+ headers: new Headers({ 'content-type': body.contentType ?? 'audio/mpeg' }),
+ body: null,
+ text: async () => '',
+ json: async () => ({}),
+ arrayBuffer: async () => new ArrayBuffer(8),
+ }
+}
+
+describe('POST /api/tools/stt', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ hybridAuthMockFns.mockCheckInternalAuth.mockResolvedValue({
+ success: true,
+ userId: 'user-1',
+ authType: 'internal_jwt',
+ })
+ inputValidationMockFns.mockValidateUrlWithDNS.mockResolvedValue({
+ isValid: true,
+ resolvedIP: PINNED_IP,
+ originalHostname: 'example.com',
+ })
+ mockIsInternalFileUrl.mockReturnValue(false)
+
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn().mockResolvedValue({
+ ok: true,
+ json: async () => ({ text: 'hello world', language: 'en', duration: 1.2 }),
+ })
+ )
+ })
+
+ it('bounds the audioUrl download and rejects oversized responses cleanly', async () => {
+ inputValidationMockFns.mockSecureFetchWithPinnedIP.mockRejectedValueOnce(
+ new PayloadSizeLimitError({
+ label: 'response body',
+ maxBytes: 100 * 1024 * 1024,
+ observedBytes: 200 * 1024 * 1024,
+ })
+ )
+
+ const response = await POST(createMockRequest('POST', baseBody))
+
+ expect(response.status).toBe(413)
+ const data = (await response.json()) as { error: string }
+ expect(data.error).toMatch(/exceeds the maximum supported size/i)
+
+ const call = inputValidationMockFns.mockSecureFetchWithPinnedIP.mock.calls[0]
+ expect(call[1]).toBe(PINNED_IP)
+ expect(call[2]).toMatchObject({ maxResponseBytes: 100 * 1024 * 1024 })
+ })
+
+ it('transcribes a normal, well-under-cap audio download successfully', async () => {
+ inputValidationMockFns.mockSecureFetchWithPinnedIP.mockResolvedValueOnce(
+ mockSecureFetchResponse({})
+ )
+
+ const response = await POST(createMockRequest('POST', baseBody))
+
+ expect(response.status).toBe(200)
+ const data = (await response.json()) as { transcript: string }
+ expect(data.transcript).toBe('hello world')
+ })
+})
diff --git a/apps/sim/app/api/tools/stt/route.ts b/apps/sim/app/api/tools/stt/route.ts
index fd9a6dc12d7..e245d498a91 100644
--- a/apps/sim/app/api/tools/stt/route.ts
+++ b/apps/sim/app/api/tools/stt/route.ts
@@ -12,12 +12,14 @@ import {
secureFetchWithPinnedIP,
validateUrlWithDNS,
} from '@/lib/core/security/input-validation.server'
+import { isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { getMimeTypeFromExtension, isInternalFileUrl } from '@/lib/uploads/utils/file-utils'
import {
downloadFileFromStorage,
resolveInternalFileUrl,
} from '@/lib/uploads/utils/file-utils.server'
+import { MAX_FILE_SIZE } from '@/lib/uploads/utils/validation'
import { assertToolFileAccess } from '@/app/api/files/authorization'
import type { TranscriptSegment } from '@/tools/stt/types'
@@ -150,6 +152,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
const response = await secureFetchWithPinnedIP(audioUrl, urlValidation.resolvedIP!, {
method: 'GET',
+ maxResponseBytes: MAX_FILE_SIZE,
})
if (!response.ok) {
await response.text().catch(() => {})
@@ -297,8 +300,11 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
return NextResponse.json(response)
} catch (error) {
logger.error(`[${requestId}] STT proxy error:`, error)
- const errorMessage = getErrorMessage(error, 'Unknown error')
- return NextResponse.json({ error: errorMessage }, { status: 500 })
+ const isSizeLimit = isPayloadSizeLimitError(error)
+ const errorMessage = isSizeLimit
+ ? 'Audio file exceeds the maximum supported size'
+ : getErrorMessage(error, 'Unknown error')
+ return NextResponse.json({ error: errorMessage }, { status: isSizeLimit ? 413 : 500 })
}
})
diff --git a/apps/sim/app/api/v1/files/route.ts b/apps/sim/app/api/v1/files/route.ts
index 9e573a126f8..350a2080c72 100644
--- a/apps/sim/app/api/v1/files/route.ts
+++ b/apps/sim/app/api/v1/files/route.ts
@@ -7,6 +7,7 @@ import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
import { generateRequestId } from '@/lib/core/utils/request'
import {
isPayloadSizeLimitError,
+ MAX_MULTIPART_OVERHEAD_BYTES,
readFileToBufferWithLimit,
readFormDataWithLimit,
} from '@/lib/core/utils/stream-limits'
@@ -30,7 +31,6 @@ export const dynamic = 'force-dynamic'
export const revalidate = 0
const MAX_FILE_SIZE = 100 * 1024 * 1024
-const MAX_MULTIPART_OVERHEAD_BYTES = 1024 * 1024
/** GET /api/v1/files — List all files in a workspace. */
export const GET = withRouteHandler(async (request: NextRequest) => {
diff --git a/apps/sim/app/api/v1/knowledge/[id]/documents/route.test.ts b/apps/sim/app/api/v1/knowledge/[id]/documents/route.test.ts
new file mode 100644
index 00000000000..002204f1425
--- /dev/null
+++ b/apps/sim/app/api/v1/knowledge/[id]/documents/route.test.ts
@@ -0,0 +1,167 @@
+/**
+ * Tests for the v1 knowledge document upload route's bounded multipart read.
+ *
+ * @vitest-environment node
+ */
+import { NextRequest } from 'next/server'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const {
+ mockAuthenticateRequest,
+ mockResolveKnowledgeBase,
+ mockCheckActorUsageLimits,
+ mockUploadWorkspaceFile,
+ mockCreateSingleDocument,
+ mockProcessDocumentsWithQueue,
+ mockValidateFileType,
+} = vi.hoisted(() => ({
+ mockAuthenticateRequest: vi.fn(),
+ mockResolveKnowledgeBase: vi.fn(),
+ mockCheckActorUsageLimits: vi.fn(),
+ mockUploadWorkspaceFile: vi.fn(),
+ mockCreateSingleDocument: vi.fn(),
+ mockProcessDocumentsWithQueue: vi.fn(),
+ mockValidateFileType: vi.fn(),
+}))
+
+vi.mock('@/app/api/v1/middleware', () => ({
+ authenticateRequest: mockAuthenticateRequest,
+}))
+
+vi.mock('@/app/api/v1/knowledge/utils', () => ({
+ resolveKnowledgeBase: mockResolveKnowledgeBase,
+ serializeDate: (date: unknown) => (date instanceof Date ? date.toISOString() : date),
+ handleError: (_requestId: string, error: unknown) =>
+ new Response(JSON.stringify({ error: error instanceof Error ? error.message : 'error' }), {
+ status: 500,
+ }),
+}))
+
+vi.mock('@/lib/billing/calculations/usage-monitor', () => ({
+ checkActorUsageLimits: mockCheckActorUsageLimits,
+}))
+
+vi.mock('@/lib/uploads/contexts/workspace', () => ({
+ uploadWorkspaceFile: mockUploadWorkspaceFile,
+}))
+
+vi.mock('@/lib/uploads/utils/validation', () => ({
+ validateFileType: mockValidateFileType,
+}))
+
+vi.mock('@/lib/knowledge/documents/service', () => ({
+ createSingleDocument: mockCreateSingleDocument,
+ getDocuments: vi.fn(),
+ processDocumentsWithQueue: mockProcessDocumentsWithQueue,
+}))
+
+import { POST } from '@/app/api/v1/knowledge/[id]/documents/route'
+
+const routeContext = { params: Promise.resolve({ id: 'kb-1' }) }
+
+function buildFormData(file: File, workspaceId = 'ws-1'): FormData {
+ const formData = new FormData()
+ formData.append('workspaceId', workspaceId)
+ formData.append('file', file)
+ return formData
+}
+
+/**
+ * Builds a pull-based stream that emits fixed-size chunks on demand, so the
+ * size-capped reader's `reader.cancel()` simply stops future `pull` calls
+ * instead of racing an external (e.g. undici FormData) chunk producer.
+ */
+function makeChunkedOverLimitBody(
+ chunkBytes: number,
+ chunkCount: number
+): ReadableStream {
+ let emitted = 0
+ return new ReadableStream({
+ pull(controller) {
+ if (emitted >= chunkCount) {
+ controller.close()
+ return
+ }
+ emitted++
+ controller.enqueue(new Uint8Array(chunkBytes))
+ },
+ })
+}
+
+describe('v1 knowledge document upload route', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockAuthenticateRequest.mockResolvedValue({
+ requestId: 'req-1',
+ userId: 'user-1',
+ rateLimit: {},
+ })
+ mockResolveKnowledgeBase.mockResolvedValue({ kb: { id: 'kb-1', workspaceId: 'ws-1' } })
+ mockCheckActorUsageLimits.mockResolvedValue({ isExceeded: false })
+ mockValidateFileType.mockReturnValue(null)
+ mockUploadWorkspaceFile.mockResolvedValue({
+ url: 'https://example.com/file.txt',
+ })
+ mockCreateSingleDocument.mockResolvedValue({
+ id: 'doc-1',
+ filename: 'file.txt',
+ fileSize: 100,
+ mimeType: 'text/plain',
+ enabled: true,
+ uploadedAt: new Date('2026-01-01T00:00:00.000Z'),
+ })
+ mockProcessDocumentsWithQueue.mockResolvedValue(undefined)
+ })
+
+ it('rejects a declared content-length above the limit before reading the body', async () => {
+ const formData = buildFormData(new File(['x'.repeat(10)], 'file.txt', { type: 'text/plain' }))
+ const req = new NextRequest('http://localhost:3000/api/v1/knowledge/kb-1/documents', {
+ method: 'POST',
+ headers: { 'content-length': String(200 * 1024 * 1024) },
+ body: formData,
+ })
+
+ const response = await POST(req, routeContext)
+ const data = await response.json()
+
+ expect(response.status).toBe(413)
+ expect(data.error).toContain('exceeds maximum size')
+ expect(mockUploadWorkspaceFile).not.toHaveBeenCalled()
+ })
+
+ it('rejects a chunked body without content-length once the streamed size trips the cap', async () => {
+ const body = makeChunkedOverLimitBody(1024 * 1024, 200)
+ const req = new NextRequest('http://localhost:3000/api/v1/knowledge/kb-1/documents', {
+ method: 'POST',
+ body,
+ // @ts-expect-error - duplex is required by undici for streamed bodies but missing from NextRequestInit types
+ duplex: 'half',
+ })
+ expect(req.headers.get('content-length')).toBeNull()
+
+ const response = await POST(req, routeContext)
+ const data = await response.json()
+
+ expect(response.status).toBe(413)
+ expect(data.error).toContain('exceeds maximum size')
+ expect(mockUploadWorkspaceFile).not.toHaveBeenCalled()
+ })
+
+ it('uploads a normal, well-under-limit document successfully', async () => {
+ const file = new File(['hello world'], 'file.txt', { type: 'text/plain' })
+ const formData = buildFormData(file)
+ const req = new NextRequest('http://localhost:3000/api/v1/knowledge/kb-1/documents', {
+ method: 'POST',
+ headers: { 'content-length': '1024' },
+ body: formData,
+ })
+
+ const response = await POST(req, routeContext)
+ const data = await response.json()
+
+ expect(response.status).toBe(200)
+ expect(data.success).toBe(true)
+ expect(mockUploadWorkspaceFile).toHaveBeenCalledTimes(1)
+ expect(mockCreateSingleDocument).toHaveBeenCalledTimes(1)
+ })
+})
diff --git a/apps/sim/app/api/v1/knowledge/[id]/documents/route.ts b/apps/sim/app/api/v1/knowledge/[id]/documents/route.ts
index dafed80b7d2..d0d80070dcd 100644
--- a/apps/sim/app/api/v1/knowledge/[id]/documents/route.ts
+++ b/apps/sim/app/api/v1/knowledge/[id]/documents/route.ts
@@ -6,6 +6,11 @@ import {
} from '@/lib/api/contracts/v1/knowledge'
import { parseRequest } from '@/lib/api/server'
import { checkActorUsageLimits } from '@/lib/billing/calculations/usage-monitor'
+import {
+ isPayloadSizeLimitError,
+ MAX_MULTIPART_OVERHEAD_BYTES,
+ readFormDataWithLimit,
+} from '@/lib/core/utils/stream-limits'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import {
createSingleDocument,
@@ -96,8 +101,14 @@ export const POST = withRouteHandler(
let formData: FormData
try {
- formData = await request.formData()
- } catch {
+ formData = await readFormDataWithLimit(request, {
+ maxBytes: MAX_FILE_SIZE + MAX_MULTIPART_OVERHEAD_BYTES,
+ label: 'knowledge document upload body',
+ })
+ } catch (error) {
+ if (isPayloadSizeLimitError(error)) {
+ return NextResponse.json({ error: error.message }, { status: 413 })
+ }
return NextResponse.json(
{ error: 'Request body must be valid multipart form data' },
{ status: 400 }
diff --git a/apps/sim/app/api/workflows/[id]/execute/route.ts b/apps/sim/app/api/workflows/[id]/execute/route.ts
index 4cd544c27bb..16bc63964a5 100644
--- a/apps/sim/app/api/workflows/[id]/execute/route.ts
+++ b/apps/sim/app/api/workflows/[id]/execute/route.ts
@@ -61,6 +61,7 @@ import {
cleanupExecutionBase64Cache,
hydrateUserFilesWithBase64,
} from '@/lib/uploads/utils/user-file-base64.server'
+import { getCustomBlockRowsForWorkspace } from '@/lib/workflows/custom-blocks/operations'
import { executeWorkflow } from '@/lib/workflows/executor/execute-workflow'
import { executeWorkflowCore } from '@/lib/workflows/executor/execution-core'
import { type ExecutionEvent, encodeSSEEvent } from '@/lib/workflows/executor/execution-events'
@@ -72,6 +73,7 @@ import {
import { createStreamingResponse } from '@/lib/workflows/streaming/streaming'
import { createHttpResponseFromBlock, workflowHasResponseBlock } from '@/lib/workflows/utils'
import { executeWorkflowJob, type WorkflowExecutionPayload } from '@/background/workflow-execution'
+import { withCustomBlockOverlay } from '@/blocks/custom/server-overlay'
import {
PublicApiNotAllowedError,
validatePublicApiAllowed,
@@ -859,12 +861,17 @@ async function handleExecutePost(
variables: deployedVariables,
}
- const serializedWorkflow = new Serializer().serializeWorkflow(
- workflowData.blocks,
- workflowData.edges,
- workflowData.loops,
- workflowData.parallels,
- false
+ // Custom blocks resolve only inside the org overlay; wrap this pre-execution
+ // serialize (used for input file-field discovery) the same way the core does.
+ const customBlockRows = await getCustomBlockRowsForWorkspace(workspaceId)
+ const serializedWorkflow = await withCustomBlockOverlay(customBlockRows, async () =>
+ new Serializer().serializeWorkflow(
+ workflowData.blocks,
+ workflowData.edges,
+ workflowData.loops,
+ workflowData.parallels,
+ false
+ )
)
const executionContext = {
diff --git a/apps/sim/app/api/workspaces/[id]/files/route.test.ts b/apps/sim/app/api/workspaces/[id]/files/route.test.ts
new file mode 100644
index 00000000000..3ce4bc9898e
--- /dev/null
+++ b/apps/sim/app/api/workspaces/[id]/files/route.test.ts
@@ -0,0 +1,143 @@
+/**
+ * Tests for the workspace files upload route's bounded multipart read.
+ *
+ * @vitest-environment node
+ */
+import { authMockFns, permissionsMock, permissionsMockFns, posthogServerMock } from '@sim/testing'
+import { NextRequest } from 'next/server'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const { mockUploadWorkspaceFile, mockGetSharesForResources, mockRecordAudit } = vi.hoisted(() => ({
+ mockUploadWorkspaceFile: vi.fn(),
+ mockGetSharesForResources: vi.fn(),
+ mockRecordAudit: vi.fn(),
+}))
+
+vi.mock('@/lib/uploads/contexts/workspace', () => ({
+ uploadWorkspaceFile: mockUploadWorkspaceFile,
+ FileConflictError: class FileConflictError extends Error {},
+}))
+
+vi.mock('@/lib/uploads/shared/types', async (importOriginal) => {
+ const actual = await importOriginal()
+ return {
+ ...actual,
+ MAX_WORKSPACE_FORMDATA_FILE_SIZE: 1024,
+ }
+})
+
+vi.mock('@/lib/public-shares/share-manager', () => ({
+ getSharesForResources: mockGetSharesForResources,
+}))
+
+vi.mock('@/lib/posthog/server', () => posthogServerMock)
+vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock)
+vi.mock('@/app/api/workflows/utils', () => ({
+ verifyWorkspaceMembership: vi.fn().mockResolvedValue('write'),
+}))
+vi.mock('@sim/audit', () => ({
+ recordAudit: mockRecordAudit,
+ AuditAction: { FILE_UPLOADED: 'file_uploaded' },
+ AuditResourceType: { FILE: 'file' },
+}))
+
+const WS = '7727ef3f-8cf6-4686-b063-2bb006a10785'
+
+import { POST } from '@/app/api/workspaces/[id]/files/route'
+
+const routeContext = { params: Promise.resolve({ id: WS }) }
+
+function buildFormData(file: File): FormData {
+ const formData = new FormData()
+ formData.append('file', file)
+ return formData
+}
+
+/**
+ * Builds a pull-based stream that emits fixed-size chunks on demand, so the
+ * size-capped reader's `reader.cancel()` simply stops future `pull` calls
+ * instead of racing an external (e.g. undici FormData) chunk producer.
+ */
+function makeChunkedOverLimitBody(
+ chunkBytes: number,
+ chunkCount: number
+): ReadableStream {
+ let emitted = 0
+ return new ReadableStream({
+ pull(controller) {
+ if (emitted >= chunkCount) {
+ controller.close()
+ return
+ }
+ emitted++
+ controller.enqueue(new Uint8Array(chunkBytes))
+ },
+ })
+}
+
+describe('workspace files upload route', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-1' } })
+ permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue('write')
+ mockGetSharesForResources.mockResolvedValue(new Map())
+ mockUploadWorkspaceFile.mockResolvedValue({
+ id: 'file-1',
+ name: 'file.txt',
+ url: 'https://example.com/file.txt',
+ size: 11,
+ type: 'text/plain',
+ })
+ })
+
+ it('rejects a declared content-length above the limit before reading the body', async () => {
+ const formData = buildFormData(new File(['x'.repeat(10)], 'file.txt', { type: 'text/plain' }))
+ const req = new NextRequest(`http://localhost:3000/api/workspaces/${WS}/files`, {
+ method: 'POST',
+ headers: { 'content-length': String(10 * 1024 * 1024) },
+ body: formData,
+ })
+
+ const response = await POST(req, routeContext)
+ const data = await response.json()
+
+ expect(response.status).toBe(413)
+ expect(data.error).toContain('exceeds maximum size')
+ expect(mockUploadWorkspaceFile).not.toHaveBeenCalled()
+ })
+
+ it('rejects a chunked body without content-length once the streamed size trips the cap', async () => {
+ const body = makeChunkedOverLimitBody(64 * 1024, 32)
+ const req = new NextRequest(`http://localhost:3000/api/workspaces/${WS}/files`, {
+ method: 'POST',
+ body,
+ // @ts-expect-error - duplex is required by undici for streamed bodies but missing from NextRequestInit types
+ duplex: 'half',
+ })
+ expect(req.headers.get('content-length')).toBeNull()
+
+ const response = await POST(req, routeContext)
+ const data = await response.json()
+
+ expect(response.status).toBe(413)
+ expect(data.error).toContain('exceeds maximum size')
+ expect(mockUploadWorkspaceFile).not.toHaveBeenCalled()
+ })
+
+ it('uploads a normal, well-under-limit file successfully', async () => {
+ const file = new File(['hello world'], 'file.txt', { type: 'text/plain' })
+ const formData = buildFormData(file)
+ const req = new NextRequest(`http://localhost:3000/api/workspaces/${WS}/files`, {
+ method: 'POST',
+ headers: { 'content-length': '512' },
+ body: formData,
+ })
+
+ const response = await POST(req, routeContext)
+ const data = await response.json()
+
+ expect(response.status).toBe(200)
+ expect(data.success).toBe(true)
+ expect(mockUploadWorkspaceFile).toHaveBeenCalledTimes(1)
+ })
+})
diff --git a/apps/sim/app/api/workspaces/[id]/files/route.ts b/apps/sim/app/api/workspaces/[id]/files/route.ts
index b13a8d08b6f..6f20d15a213 100644
--- a/apps/sim/app/api/workspaces/[id]/files/route.ts
+++ b/apps/sim/app/api/workspaces/[id]/files/route.ts
@@ -9,6 +9,11 @@ import {
import { getValidationErrorMessage } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
import { generateRequestId } from '@/lib/core/utils/request'
+import {
+ isPayloadSizeLimitError,
+ MAX_MULTIPART_OVERHEAD_BYTES,
+ readFormDataWithLimit,
+} from '@/lib/core/utils/stream-limits'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { captureServerEvent } from '@/lib/posthog/server'
import { getSharesForResources } from '@/lib/public-shares/share-manager'
@@ -132,7 +137,21 @@ export const POST = withRouteHandler(
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 })
}
- const formData = await request.formData()
+ let formData: FormData
+ try {
+ formData = await readFormDataWithLimit(request, {
+ maxBytes: MAX_WORKSPACE_FORMDATA_FILE_SIZE + MAX_MULTIPART_OVERHEAD_BYTES,
+ label: 'workspace file upload body',
+ })
+ } catch (error) {
+ if (isPayloadSizeLimitError(error)) {
+ return NextResponse.json({ error: error.message }, { status: 413 })
+ }
+ return NextResponse.json(
+ { error: 'Request body must be valid multipart form data' },
+ { status: 400 }
+ )
+ }
const rawFile = formData.get('file')
const rawFolderId = formData.get('folderId')
const folderId =
diff --git a/apps/sim/app/not-found.tsx b/apps/sim/app/not-found.tsx
index 76af4c16271..82c39e62ab3 100644
--- a/apps/sim/app/not-found.tsx
+++ b/apps/sim/app/not-found.tsx
@@ -1,5 +1,5 @@
+import { ChipLink } from '@sim/emcn'
import type { Metadata } from 'next'
-import Link from 'next/link'
import { LogoShell } from '@/app/(landing)/components'
export const metadata: Metadata = {
@@ -17,12 +17,9 @@ export default function NotFound() {
The page you're looking for doesn't exist or has been moved.
-
+
Return home
-
+
)
diff --git a/apps/sim/app/workspace/[workspaceId]/components/drop-zone.tsx b/apps/sim/app/workspace/[workspaceId]/components/drop-zone.tsx
new file mode 100644
index 00000000000..d9845d42012
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/components/drop-zone.tsx
@@ -0,0 +1,42 @@
+'use client'
+
+import { useState } from 'react'
+import { cn } from '@sim/emcn'
+
+interface DropZoneProps {
+ onDrop: (e: React.DragEvent) => void
+ children: React.ReactNode
+ className?: string
+}
+
+/** File drop target with a dashed accent overlay while dragging. Shared by the
+ * whitelabeling settings and the deploy-as-block icon upload. */
+export function DropZone({ onDrop, children, className }: DropZoneProps) {
+ const [isDragging, setIsDragging] = useState(false)
+
+ return (
+
+ )
+}
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx
index afeac25cc3b..1283dc8617e 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx
@@ -1,6 +1,6 @@
'use client'
-import { type ComponentPropsWithoutRef, memo, useEffect, useMemo, useRef } from 'react'
+import { type ComponentPropsWithoutRef, memo, useEffect, useMemo, useRef, useState } from 'react'
import { Streamdown } from 'streamdown'
import 'streamdown/styles.css'
// prismjs core must load before its language components — they register on the
@@ -53,17 +53,42 @@ const PROSE_CLASSES = cn(
/**
* Soft fade for newly revealed text. Paired with {@link useSmoothText}, which
- * paces the reveal: `sep: 'char'` fades each character as the pacer exposes it
- * (so a growing trailing word never re-animates), and `stagger: 0` keeps the
- * cadence driven by the pacer rather than an overlapping per-token delay ramp.
+ * paces the reveal; `stagger: 0` keeps the cadence driven by the pacer rather
+ * than an overlapping per-token delay ramp — every span revealed in one tick
+ * fades as a unit, so `sep: 'word'` looks identical to `sep: 'char'` while
+ * creating ~5x fewer spans. That span count is the dominant mid-stream cost:
+ * the animate plugin rebuilds a span per token for the WHOLE trailing block on
+ * every reveal tick, so per-char wrapping of a long paragraph meant thousands
+ * of hast nodes + React elements reconciled ~40x/sec. Streamdown's
+ * prev-content tracking keeps a word that grows across two ticks from
+ * re-fading (its continuation renders unfaded), and the pacer's word-boundary
+ * snapping makes such splits rare to begin with.
*/
const STREAM_ANIMATION = {
animation: 'fadeIn',
duration: 220,
stagger: 0,
- sep: 'char',
+ sep: 'word',
} as const
+/**
+ * How long after the reveal fully settles before the animated tree is dropped.
+ * Must exceed {@link STREAM_ANIMATION}'s 220ms duration so the last characters
+ * finish fading at full opacity before their spans are swapped for plain text.
+ */
+const ANIMATION_DRAIN_MS = 300
+
+/**
+ * Once a segment has revealed this many characters, new text stops fading in;
+ * the word-paced reveal itself is unchanged. Fade cost scales with segment
+ * length — every reveal tick rebuilds a span per word for the WHOLE trailing
+ * markdown block — so on an unbroken wall of text it eventually swamps the
+ * frame budget (measured: ~9k-char single paragraphs spent ~30% of main-thread
+ * time in long tasks) while the fade itself is imperceptible detail that deep
+ * into a reply.
+ */
+const FADE_MAX_REVEALED_CHARS = 6000
+
function startsInlineWord(value: string): boolean {
return /^[A-Za-z0-9_(]/.test(value)
}
@@ -306,19 +331,91 @@ function ChatContentInner({
}, [isRevealing])
/**
- * One-way latch: once a message has streamed in this mount, keep rendering it
- * through Streamdown's streaming/animation pipeline for the rest of its life.
- * Drives `mode`, `animated`, AND `isAnimating` together — all three must stay
- * constant across the completion boundary. Streamdown removes the per-word
- * `