From d6ec115348d0581fc2e6729298db7f31c776d1d6 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Tue, 7 Apr 2026 16:11:31 -0700 Subject: [PATCH 1/5] v0.6.29: login improvements, posthog telemetry (#4026) * feat(posthog): Add tracking on mothership abort (#4023) Co-authored-by: Theodore Li * fix(login): fix captcha headers for manual login (#4025) * fix(signup): fix turnstile key loading * fix(login): fix captcha header passing * Catch user already exists, remove login form captcha --- apps/sim/app/(auth)/signup/signup-form.tsx | 11 +++-------- .../app/workspace/[workspaceId]/home/home.tsx | 12 ++++++++++-- .../w/[workflowId]/components/panel/panel.tsx | 19 ++++++++++++++++++- apps/sim/lib/posthog/events.ts | 5 +++++ 4 files changed, 36 insertions(+), 11 deletions(-) diff --git a/apps/sim/app/(auth)/signup/signup-form.tsx b/apps/sim/app/(auth)/signup/signup-form.tsx index 55a0508ec1b..afb27cd729a 100644 --- a/apps/sim/app/(auth)/signup/signup-form.tsx +++ b/apps/sim/app/(auth)/signup/signup-form.tsx @@ -270,10 +270,8 @@ function SignupFormContent({ name: sanitizedName, }, { - fetchOptions: { - headers: { - ...(token ? { 'x-captcha-response': token } : {}), - }, + headers: { + ...(token ? { 'x-captcha-response': token } : {}), }, onError: (ctx) => { logger.error('Signup error:', ctx.error) @@ -282,10 +280,7 @@ function SignupFormContent({ let errorCode = 'unknown' if (ctx.error.code?.includes('USER_ALREADY_EXISTS')) { errorCode = 'user_already_exists' - errorMessage.push( - 'An account with this email already exists. Please sign in instead.' - ) - setEmailError(errorMessage[0]) + setEmailError('An account with this email already exists. Please sign in instead.') } else if ( ctx.error.code?.includes('BAD_REQUEST') || ctx.error.message?.includes('Email and password sign up is not enabled') diff --git a/apps/sim/app/workspace/[workspaceId]/home/home.tsx b/apps/sim/app/workspace/[workspaceId]/home/home.tsx index d76f17ff454..38367339197 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/home.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/home.tsx @@ -223,6 +223,14 @@ export function Home({ chatId }: HomeProps = {}) { posthogRef.current = posthog }, [posthog]) + const handleStopGeneration = useCallback(() => { + captureEvent(posthogRef.current, 'task_generation_aborted', { + workspace_id: workspaceId, + view: 'mothership', + }) + stopGeneration() + }, [stopGeneration, workspaceId]) + const handleSubmit = useCallback( (text: string, fileAttachments?: FileAttachmentForApi[], contexts?: ChatContext[]) => { const trimmed = text.trim() @@ -334,7 +342,7 @@ export function Home({ chatId }: HomeProps = {}) { defaultValue={initialPrompt} onSubmit={handleSubmit} isSending={isSending} - onStopGeneration={stopGeneration} + onStopGeneration={handleStopGeneration} userId={session?.user?.id} onContextAdd={handleContextAdd} /> @@ -359,7 +367,7 @@ export function Home({ chatId }: HomeProps = {}) { isSending={isSending} isReconnecting={isReconnecting} onSubmit={handleSubmit} - onStopGeneration={stopGeneration} + onStopGeneration={handleStopGeneration} messageQueue={messageQueue} onRemoveQueuedMessage={removeFromQueue} onSendQueuedMessage={sendNow} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx index 4d485c763ce..da51910789b 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx @@ -4,6 +4,7 @@ import { memo, useCallback, useEffect, useRef, useState } from 'react' import { createLogger } from '@sim/logger' import { History, Plus, Square } from 'lucide-react' import { useParams, useRouter } from 'next/navigation' +import { usePostHog } from 'posthog-js/react' import { useShallow } from 'zustand/react/shallow' import { BubbleChatClose, @@ -33,6 +34,7 @@ import { import { Lock, Unlock, Upload } from '@/components/emcn/icons' import { VariableIcon } from '@/components/icons' import { useSession } from '@/lib/auth/auth-client' +import { captureEvent } from '@/lib/posthog/client' import { generateWorkflowJson } from '@/lib/workflows/operations/import-export' import { ConversationListItem } from '@/app/workspace/[workspaceId]/components' import { MothershipChat } from '@/app/workspace/[workspaceId]/home/components' @@ -101,6 +103,9 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel const params = useParams() const workspaceId = propWorkspaceId ?? (params.workspaceId as string) + const posthog = usePostHog() + const posthogRef = useRef(posthog) + const panelRef = useRef(null) const fileInputRef = useRef(null) const { activeTab, setActiveTab, panelWidth, _hasHydrated, setHasHydrated } = usePanelStore( @@ -264,6 +269,10 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel loadCopilotChats() }, [loadCopilotChats]) + useEffect(() => { + posthogRef.current = posthog + }, [posthog]) + const handleCopilotSelectChat = useCallback((chat: { id: string; title: string | null }) => { setCopilotChatId(chat.id) setCopilotChatTitle(chat.title) @@ -394,6 +403,14 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel [copilotEditQueuedMessage] ) + const handleCopilotStopGeneration = useCallback(() => { + captureEvent(posthogRef.current, 'task_generation_aborted', { + workspace_id: workspaceId, + view: 'copilot', + }) + copilotStopGeneration() + }, [copilotStopGeneration, workspaceId]) + const handleCopilotSubmit = useCallback( (text: string, fileAttachments?: FileAttachmentForApi[], contexts?: ChatContext[]) => { const trimmed = text.trim() @@ -833,7 +850,7 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel isSending={copilotIsSending} isReconnecting={copilotIsReconnecting} onSubmit={handleCopilotSubmit} - onStopGeneration={copilotStopGeneration} + onStopGeneration={handleCopilotStopGeneration} messageQueue={copilotMessageQueue} onRemoveQueuedMessage={copilotRemoveFromQueue} onSendQueuedMessage={copilotSendNow} diff --git a/apps/sim/lib/posthog/events.ts b/apps/sim/lib/posthog/events.ts index 537a9864282..faf9895bf62 100644 --- a/apps/sim/lib/posthog/events.ts +++ b/apps/sim/lib/posthog/events.ts @@ -378,6 +378,11 @@ export interface PostHogEventMap { workspace_id: string } + task_generation_aborted: { + workspace_id: string + view: 'mothership' | 'copilot' + } + task_message_sent: { workspace_id: string has_attachments: boolean From 3cc2692f57cd11059c8af1e24372a3750c6b91fb Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Tue, 30 Jun 2026 19:43:25 -0700 Subject: [PATCH 2/5] fix(attachments): cross tenant security hardening --- apps/sim/app/api/files/parse/route.ts | 9 +++------ apps/sim/lib/uploads/utils/file-utils.server.ts | 9 +++------ apps/sim/providers/file-attachments.server.ts | 5 ++--- 3 files changed, 8 insertions(+), 15 deletions(-) diff --git a/apps/sim/app/api/files/parse/route.ts b/apps/sim/app/api/files/parse/route.ts index b925a366033..033f180818a 100644 --- a/apps/sim/app/api/files/parse/route.ts +++ b/apps/sim/app/api/files/parse/route.ts @@ -13,7 +13,7 @@ import { checkInternalAuth } from '@/lib/auth/hybrid' import { sanitizeUrlForLog } from '@/lib/core/utils/logging' import { assertKnownSizeWithinLimit, isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits' import { isSupportedFileType, parseFile } from '@/lib/file-parsers' -import { isUsingCloudStorage, type StorageContext, StorageService } from '@/lib/uploads' +import { isUsingCloudStorage, StorageService } from '@/lib/uploads' import { uploadExecutionFile } from '@/lib/uploads/contexts/execution' import { ExternalUrlValidationError, @@ -303,7 +303,6 @@ async function parseFileSingle( return handleCloudFile( filePath, fileType, - undefined, userId, executionContext, maxDownloadBytes, @@ -329,7 +328,6 @@ async function parseFileSingle( return handleCloudFile( filePath, fileType, - undefined, userId, executionContext, maxDownloadBytes, @@ -608,7 +606,6 @@ async function handleExternalUrl( async function handleCloudFile( filePath: string, fileType: string, - explicitContext: string | undefined, userId: string, executionContext?: ExecutionContext, maxDownloadBytes = MAX_DOWNLOAD_SIZE_BYTES, @@ -619,7 +616,7 @@ async function handleCloudFile( logger.info('Extracted cloud key:', cloudKey) - const context = (explicitContext as StorageContext) || inferContextFromKey(cloudKey) + const context = inferContextFromKey(cloudKey) const hasAccess = await verifyFileAccess( cloudKey, @@ -658,7 +655,7 @@ async function handleCloudFile( maxBytes: maxDownloadBytes, }) logger.info( - `Downloaded file from ${context} storage (${explicitContext ? 'explicit' : 'inferred'}): ${cloudKey}, size: ${fileBuffer.length} bytes` + `Downloaded file from ${context} storage: ${cloudKey}, size: ${fileBuffer.length} bytes` ) const filename = originalFilename || cloudKey.split('/').pop() || cloudKey diff --git a/apps/sim/lib/uploads/utils/file-utils.server.ts b/apps/sim/lib/uploads/utils/file-utils.server.ts index e495b94274a..3b4d39d3ae6 100644 --- a/apps/sim/lib/uploads/utils/file-utils.server.ts +++ b/apps/sim/lib/uploads/utils/file-utils.server.ts @@ -12,7 +12,6 @@ import { consumeOrCancelBody, readResponseToBufferWithLimit, } from '@/lib/core/utils/stream-limits' -import type { StorageContext } from '@/lib/uploads' import { StorageService } from '@/lib/uploads' import { isExecutionFile } from '@/lib/uploads/contexts/execution/utils' import { @@ -90,7 +89,7 @@ export async function resolveFileInputToUrl( // Generate presigned URL if we have a key but no URL if (!fileUrl && userFile.key) { - const context = (userFile.context as StorageContext) || inferContextFromKey(userFile.key) + const context = inferContextFromKey(userFile.key) const hasAccess = await verifyFileAccess(userFile.key, userId, undefined, context, false) if (!hasAccess) { @@ -281,10 +280,8 @@ export async function downloadFileFromStorage( ) buffer = await downloadExecutionFile(userFile, { maxBytes: options.maxBytes }) } else if (userFile.key) { - const context = (userFile.context as StorageContext) || inferContextFromKey(userFile.key) - logger.info( - `[${requestId}] Downloading from ${context} storage (${userFile.context ? 'explicit' : 'inferred'}): ${userFile.key}` - ) + const context = inferContextFromKey(userFile.key) + logger.info(`[${requestId}] Downloading from ${context} storage: ${userFile.key}`) const { downloadFile } = await import('@/lib/uploads/core/storage-service') buffer = await downloadFile({ diff --git a/apps/sim/providers/file-attachments.server.ts b/apps/sim/providers/file-attachments.server.ts index 8f2e2dfac5e..88bf93beaa3 100644 --- a/apps/sim/providers/file-attachments.server.ts +++ b/apps/sim/providers/file-attachments.server.ts @@ -2,7 +2,6 @@ import { FileState, GoogleGenAI } from '@google/genai' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { sleep } from '@sim/utils/helpers' -import type { StorageContext } from '@/lib/uploads' import { StorageService } from '@/lib/uploads' import { inferContextFromKey } from '@/lib/uploads/utils/file-utils' import { downloadServableFileFromStorage } from '@/lib/uploads/utils/file-utils.server' @@ -85,7 +84,7 @@ export async function attachLargeFileRemoteUrls( ) } - const context = (file.context as StorageContext) || inferContextFromKey(file.key) + const context = inferContextFromKey(file.key) const hasAccess = await verifyFileAccess(file.key, request.userId, undefined, context, false) if (!hasAccess) { throw new Error(`File "${file.name}" is not accessible for provider "${providerId}"`) @@ -147,7 +146,7 @@ async function assertFileAccessForUpload( if (!userId) { throw new Error(`File "${file.name}" requires an authenticated user to upload`) } - const context = (file.context as StorageContext) || inferContextFromKey(file.key) + const context = inferContextFromKey(file.key) const hasAccess = await verifyFileAccess(file.key, userId, undefined, context, false) if (!hasAccess) { throw new Error(`File "${file.name}" is not accessible`) From 135512e886058fde863ea84ee9351ea709154e46 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Tue, 30 Jun 2026 20:19:16 -0700 Subject: [PATCH 3/5] address comments --- .../lib/uploads/utils/file-utils.server.ts | 5 ++- apps/sim/lib/uploads/utils/file-utils.test.ts | 21 +++++++++++ apps/sim/lib/uploads/utils/file-utils.ts | 37 +++++++++++++++++++ apps/sim/providers/file-attachments.server.ts | 6 +-- 4 files changed, 64 insertions(+), 5 deletions(-) diff --git a/apps/sim/lib/uploads/utils/file-utils.server.ts b/apps/sim/lib/uploads/utils/file-utils.server.ts index 3b4d39d3ae6..1077512cc97 100644 --- a/apps/sim/lib/uploads/utils/file-utils.server.ts +++ b/apps/sim/lib/uploads/utils/file-utils.server.ts @@ -22,6 +22,7 @@ import { isInternalFileUrl, processSingleFileToUserFile, type RawFileInput, + resolveTrustedFileContext, } from '@/lib/uploads/utils/file-utils' import { verifyFileAccess } from '@/app/api/files/authorization' import type { UserFile } from '@/executor/types' @@ -89,7 +90,7 @@ export async function resolveFileInputToUrl( // Generate presigned URL if we have a key but no URL if (!fileUrl && userFile.key) { - const context = inferContextFromKey(userFile.key) + const context = resolveTrustedFileContext(userFile.key, userFile.context) const hasAccess = await verifyFileAccess(userFile.key, userId, undefined, context, false) if (!hasAccess) { @@ -280,7 +281,7 @@ export async function downloadFileFromStorage( ) buffer = await downloadExecutionFile(userFile, { maxBytes: options.maxBytes }) } else if (userFile.key) { - const context = inferContextFromKey(userFile.key) + const context = resolveTrustedFileContext(userFile.key, userFile.context) logger.info(`[${requestId}] Downloading from ${context} storage: ${userFile.key}`) const { downloadFile } = await import('@/lib/uploads/core/storage-service') diff --git a/apps/sim/lib/uploads/utils/file-utils.test.ts b/apps/sim/lib/uploads/utils/file-utils.test.ts index d982f33e2a4..0e7581bb454 100644 --- a/apps/sim/lib/uploads/utils/file-utils.test.ts +++ b/apps/sim/lib/uploads/utils/file-utils.test.ts @@ -9,6 +9,7 @@ import { isInternalFileUrl, isNetworkError, processSingleFileToUserFile, + resolveTrustedFileContext, } from '@/lib/uploads/utils/file-utils' const logger = createLogger('FileUtilsTest') @@ -74,6 +75,26 @@ describe('inferContextFromKey', () => { }) }) +describe('resolveTrustedFileContext', () => { + it('derives from the key prefix and ignores a mismatched caller context', () => { + expect(resolveTrustedFileContext('workspace/ws/1700000000000-abc-x.pdf', 'og-images')).toBe( + 'workspace' + ) + expect(resolveTrustedFileContext('chat/x', 'workspace-logos')).toBe('chat') + expect(resolveTrustedFileContext('workspace/ws/x', 'mothership')).toBe('workspace') + }) + + it('honors the caller context for legacy keys with no inferrable prefix', () => { + expect(resolveTrustedFileContext('legacy/ws/wf/ex/report.pdf', 'execution')).toBe('execution') + }) + + it('never resolves an un-inferrable key to a world-readable context', () => { + expect(() => resolveTrustedFileContext('legacy/report.pdf', 'og-images')).toThrow() + expect(() => resolveTrustedFileContext('legacy/report.pdf', 'profile-pictures')).toThrow() + expect(() => resolveTrustedFileContext('legacy/report.pdf')).toThrow() + }) +}) + describe('isAbortError', () => { it('returns true for AbortError-named errors', () => { const err = new Error('aborted') diff --git a/apps/sim/lib/uploads/utils/file-utils.ts b/apps/sim/lib/uploads/utils/file-utils.ts index 0fd254f2e25..642e48aafd2 100644 --- a/apps/sim/lib/uploads/utils/file-utils.ts +++ b/apps/sim/lib/uploads/utils/file-utils.ts @@ -596,6 +596,43 @@ export function inferContextFromKey(key: string): StorageContext { ) } +/** + * World-readable storage contexts. Reads for these short-circuit file + * authorization and can resolve to the shared bucket, so a caller-supplied + * context must never select one for a key that does not carry the matching + * prefix. + */ +const PUBLIC_STORAGE_CONTEXTS = new Set([ + 'profile-pictures', + 'og-images', + 'workspace-logos', +]) + +/** + * Resolve the storage context for a stored file from its trusted key prefix. + * + * The storage key is written server-side at upload time and cannot be forged to + * change tenant, whereas a file's `context` field is attacker-authorable in a + * workflow. When the key carries a recognized prefix that prefix is + * authoritative and the caller-supplied `context` is ignored — this prevents a + * private `workspace/…` key from being relabeled with a world-readable context + * to bypass authorization and read the shared bucket. + * + * Legacy keys predating context-prefixed keys cannot be inferred; for those the + * persisted `context` is honored so existing files stay resolvable — except a + * world-readable context, which would reopen the bypass on an un-inferrable key. + */ +export function resolveTrustedFileContext(key: string, context?: string): StorageContext { + try { + return inferContextFromKey(key) + } catch (error) { + if (context && !PUBLIC_STORAGE_CONTEXTS.has(context as StorageContext)) { + return context as StorageContext + } + throw error + } +} + /** * Extract storage key and context from an internal file URL * @param fileUrl - Internal file URL (e.g., /api/files/serve/key?context=workspace) diff --git a/apps/sim/providers/file-attachments.server.ts b/apps/sim/providers/file-attachments.server.ts index 88bf93beaa3..4ca03ef07aa 100644 --- a/apps/sim/providers/file-attachments.server.ts +++ b/apps/sim/providers/file-attachments.server.ts @@ -3,7 +3,7 @@ import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { sleep } from '@sim/utils/helpers' import { StorageService } from '@/lib/uploads' -import { inferContextFromKey } from '@/lib/uploads/utils/file-utils' +import { resolveTrustedFileContext } from '@/lib/uploads/utils/file-utils' import { downloadServableFileFromStorage } from '@/lib/uploads/utils/file-utils.server' import { verifyFileAccess } from '@/app/api/files/authorization' import type { UserFile } from '@/executor/types' @@ -84,7 +84,7 @@ export async function attachLargeFileRemoteUrls( ) } - const context = inferContextFromKey(file.key) + const context = resolveTrustedFileContext(file.key, file.context) const hasAccess = await verifyFileAccess(file.key, request.userId, undefined, context, false) if (!hasAccess) { throw new Error(`File "${file.name}" is not accessible for provider "${providerId}"`) @@ -146,7 +146,7 @@ async function assertFileAccessForUpload( if (!userId) { throw new Error(`File "${file.name}" requires an authenticated user to upload`) } - const context = inferContextFromKey(file.key) + const context = resolveTrustedFileContext(file.key, file.context) const hasAccess = await verifyFileAccess(file.key, userId, undefined, context, false) if (!hasAccess) { throw new Error(`File "${file.name}" is not accessible`) From df8b98f42b6aaddb426d4a72db7681655b89ce42 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Tue, 30 Jun 2026 20:35:38 -0700 Subject: [PATCH 4/5] fix --- apps/sim/app/api/files/download/route.ts | 17 +++---- .../uploads/utils/file-utils.server.test.ts | 47 +++++++++++++++++++ 2 files changed, 54 insertions(+), 10 deletions(-) create mode 100644 apps/sim/lib/uploads/utils/file-utils.server.test.ts diff --git a/apps/sim/app/api/files/download/route.ts b/apps/sim/app/api/files/download/route.ts index 33f1ce61146..bcd9043009c 100644 --- a/apps/sim/app/api/files/download/route.ts +++ b/apps/sim/app/api/files/download/route.ts @@ -4,8 +4,8 @@ import { fileDownloadContract } from '@/lib/api/contracts/storage-transfer' import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import type { StorageContext } from '@/lib/uploads/config' import { hasCloudStorage } from '@/lib/uploads/core/storage-service' +import { resolveTrustedFileContext } from '@/lib/uploads/utils/file-utils' import { verifyFileAccess } from '@/app/api/files/authorization' import { createErrorResponse, FileNotFoundError } from '@/app/api/files/utils' @@ -58,12 +58,10 @@ export const POST = withRouteHandler(async (request: NextRequest) => { }) } - let storageContext: StorageContext | 'general' | undefined = context - - if (isExecutionFile && !context) { - storageContext = 'execution' - logger.info(`Using execution context for file: ${key}`) - } + const storageContext = resolveTrustedFileContext( + key, + isExecutionFile && !context ? 'execution' : context + ) const hasAccess = await verifyFileAccess( key, @@ -79,10 +77,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } const { getBaseUrl } = await import('@/lib/core/utils/urls') - const contextQuery = storageContext ? `?context=${storageContext}` : '' - const downloadUrl = `${getBaseUrl()}/api/files/serve/${encodeURIComponent(key)}${contextQuery}` + const downloadUrl = `${getBaseUrl()}/api/files/serve/${encodeURIComponent(key)}?context=${storageContext}` - logger.info(`Generated download URL for ${storageContext ?? 'inferred'} file: ${key}`) + logger.info(`Generated download URL for ${storageContext} file: ${key}`) return NextResponse.json({ downloadUrl, diff --git a/apps/sim/lib/uploads/utils/file-utils.server.test.ts b/apps/sim/lib/uploads/utils/file-utils.server.test.ts new file mode 100644 index 00000000000..40e9ec97044 --- /dev/null +++ b/apps/sim/lib/uploads/utils/file-utils.server.test.ts @@ -0,0 +1,47 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockDownloadFile } = vi.hoisted(() => ({ + mockDownloadFile: vi.fn(), +})) + +vi.mock('@/lib/uploads/core/storage-service', () => ({ + downloadFile: mockDownloadFile, + hasCloudStorage: vi.fn(() => true), +})) + +vi.mock('@/app/api/files/authorization', () => ({ + verifyFileAccess: vi.fn(), +})) + +import { createLogger } from '@sim/logger' +import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' +import type { UserFile } from '@/executor/types' + +describe('downloadFileFromStorage context derivation', () => { + beforeEach(() => { + vi.clearAllMocks() + mockDownloadFile.mockResolvedValue(Buffer.from('bytes')) + }) + + it('downloads with the key-derived context, ignoring a caller-supplied public context', async () => { + const userFile: UserFile = { + id: 'f1', + name: 'report.pdf', + url: '', + size: 5, + type: 'application/pdf', + key: 'workspace/ws-1/1700000000000-abc1234-report.pdf', + context: 'og-images', + } + + await downloadFileFromStorage(userFile, 'req-1', createLogger('test')) + + expect(mockDownloadFile).toHaveBeenCalledTimes(1) + expect(mockDownloadFile).toHaveBeenCalledWith( + expect.objectContaining({ key: userFile.key, context: 'workspace' }) + ) + }) +}) From 70c339516fb3829f4abd285fa45a7670c2ea213c Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Tue, 30 Jun 2026 20:50:43 -0700 Subject: [PATCH 5/5] fix --- apps/sim/app/api/files/download/route.ts | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/apps/sim/app/api/files/download/route.ts b/apps/sim/app/api/files/download/route.ts index bcd9043009c..62bdbfe6e6a 100644 --- a/apps/sim/app/api/files/download/route.ts +++ b/apps/sim/app/api/files/download/route.ts @@ -5,7 +5,7 @@ import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { hasCloudStorage } from '@/lib/uploads/core/storage-service' -import { resolveTrustedFileContext } from '@/lib/uploads/utils/file-utils' +import { inferContextFromKey } from '@/lib/uploads/utils/file-utils' import { verifyFileAccess } from '@/app/api/files/authorization' import { createErrorResponse, FileNotFoundError } from '@/app/api/files/utils' @@ -40,7 +40,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) if (!parsed.success) return parsed.response - const { key, name, isExecutionFile, context, url } = parsed.data.body + const { key, name, url } = parsed.data.body if (!key) { return createErrorResponse(new Error('File key is required'), 400) @@ -58,10 +58,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { }) } - const storageContext = resolveTrustedFileContext( - key, - isExecutionFile && !context ? 'execution' : context - ) + // Derive context from the trusted key prefix, mirroring the serve route this URL + // delegates to, which re-derives context from the key and ignores any client-supplied value. + const storageContext = inferContextFromKey(key) const hasAccess = await verifyFileAccess( key,