From 4295a5c85580c3d1fc4336823d6767dd646df23f Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Thu, 14 May 2026 09:54:05 -0700 Subject: [PATCH 01/12] improvement(db): add session statement/lock timeouts; simplify KB doc tx (#4593) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * improvement(db): add session statement/lock timeouts; simplify KB doc tx * fix(knowledge): close soft-delete TOCTOU on KB document insert Fix the race the bots flagged: KB delete is soft (`deletedAt = now`) so the FK can't catch a concurrent KB delete between the existence check and the document insert. - Add `insertDocumentsIfKbAlive` helper that gates the insert on `EXISTS(SELECT 1 FROM knowledge_base WHERE id=$kb AND deleted_at IS NULL)` in the same statement via INSERT...SELECT...WHERE EXISTS. Atomic at the MVCC snapshot — no transaction, no row lock. - Use jsonb_to_recordset to declare column types once, avoiding per-param casts for nullable columns. - Wire into both `createDocumentRecords` (bulk) and `createSingleDocument`. - Keep the upfront KB existence check as a fast-path early-out for the common case; the atomic insert is the race guard. --------- Co-authored-by: Waleed Co-authored-by: Siddharth Ganesan <33737564+Sg312@users.noreply.github.com> Co-authored-by: Vikhyath Mondreti --- apps/sim/lib/knowledge/documents/service.ts | 313 +++++++++++++------- apps/sim/lib/workspaces/lifecycle.test.ts | 1 + apps/sim/lib/workspaces/lifecycle.ts | 7 + packages/db/db.ts | 8 + 4 files changed, 229 insertions(+), 100 deletions(-) diff --git a/apps/sim/lib/knowledge/documents/service.ts b/apps/sim/lib/knowledge/documents/service.ts index 49675608309..52577a2334e 100644 --- a/apps/sim/lib/knowledge/documents/service.ts +++ b/apps/sim/lib/knowledge/documents/service.ts @@ -748,6 +748,126 @@ async function processDocumentsWithTrigger( } } +interface NewDocumentRow { + id: string + knowledgeBaseId: string + filename: string + fileUrl: string + fileSize: number + mimeType: string + chunkCount: number + tokenCount: number + characterCount: number + processingStatus: 'pending' + enabled: boolean + uploadedAt: Date + tag1: string | null + tag2: string | null + tag3: string | null + tag4: string | null + tag5: string | null + tag6: string | null + tag7: string | null + number1: number | null + number2: number | null + number3: number | null + number4: number | null + number5: number | null + date1: Date | null + date2: Date | null + boolean1: boolean | null + boolean2: boolean | null + boolean3: boolean | null +} + +/** + * Insert N document rows IF the parent knowledge base is still alive + * (`deleted_at IS NULL`) at the statement's MVCC snapshot. Returns the + * number of rows actually inserted. + * + * Knowledge bases are soft-deleted, so a normal FK can't catch a concurrent + * delete — the KB row physically remains. We do the existence check and the + * insert in a single statement via INSERT...SELECT...WHERE EXISTS, which + * Postgres evaluates atomically. No transaction or row lock required, no + * race window between check and insert. + * + * Returns 0 if the KB was soft-deleted; caller throws. + */ +async function insertDocumentsIfKbAlive( + rows: NewDocumentRow[], + knowledgeBaseId: string +): Promise { + if (rows.length === 0) return 0 + + // jsonb_to_recordset declares the column types once, so we don't need to + // cast every parameter individually to keep Postgres' type inference happy + // when nullable columns end up all-NULL across the batch. + const jsonRows = rows.map((d) => ({ + id: d.id, + knowledge_base_id: d.knowledgeBaseId, + filename: d.filename, + file_url: d.fileUrl, + file_size: d.fileSize, + mime_type: d.mimeType, + chunk_count: d.chunkCount, + token_count: d.tokenCount, + character_count: d.characterCount, + processing_status: d.processingStatus, + enabled: d.enabled, + uploaded_at: d.uploadedAt.toISOString(), + tag1: d.tag1, + tag2: d.tag2, + tag3: d.tag3, + tag4: d.tag4, + tag5: d.tag5, + tag6: d.tag6, + tag7: d.tag7, + number1: d.number1, + number2: d.number2, + number3: d.number3, + number4: d.number4, + number5: d.number5, + date1: d.date1?.toISOString() ?? null, + date2: d.date2?.toISOString() ?? null, + boolean1: d.boolean1, + boolean2: d.boolean2, + boolean3: d.boolean3, + })) + + const result = await db.execute(sql` + INSERT INTO document ( + id, knowledge_base_id, filename, file_url, file_size, mime_type, + chunk_count, token_count, character_count, processing_status, enabled, uploaded_at, + tag1, tag2, tag3, tag4, tag5, tag6, tag7, + number1, number2, number3, number4, number5, + date1, date2, + boolean1, boolean2, boolean3 + ) + SELECT + id, knowledge_base_id, filename, file_url, file_size, mime_type, + chunk_count, token_count, character_count, processing_status, enabled, uploaded_at, + tag1, tag2, tag3, tag4, tag5, tag6, tag7, + number1, number2, number3, number4, number5, + date1, date2, + boolean1, boolean2, boolean3 + FROM jsonb_to_recordset(${JSON.stringify(jsonRows)}::jsonb) AS x( + id text, knowledge_base_id text, filename text, file_url text, file_size integer, mime_type text, + chunk_count integer, token_count integer, character_count integer, processing_status text, enabled boolean, uploaded_at timestamp, + tag1 text, tag2 text, tag3 text, tag4 text, tag5 text, tag6 text, tag7 text, + number1 double precision, number2 double precision, number3 double precision, number4 double precision, number5 double precision, + date1 timestamp, date2 timestamp, + boolean1 boolean, boolean2 boolean, boolean3 boolean + ) + WHERE EXISTS ( + SELECT 1 FROM knowledge_base + WHERE id = ${knowledgeBaseId} AND deleted_at IS NULL + ) + RETURNING id + `) + + return Array.from(result).length +} + export async function createDocumentRecords( documents: Array<{ filename: string @@ -766,99 +886,102 @@ export async function createDocumentRecords( knowledgeBaseId: string, requestId: string ): Promise { - return await db.transaction(async (tx) => { - await tx.execute(sql`SELECT 1 FROM knowledge_base WHERE id = ${knowledgeBaseId} FOR UPDATE`) - - const kb = await tx - .select({ id: knowledgeBase.id }) - .from(knowledgeBase) - .where(and(eq(knowledgeBase.id, knowledgeBaseId), isNull(knowledgeBase.deletedAt))) - .limit(1) + // Cheap upfront existence check so the common KB-not-found path fails fast + // before we burn CPU on tag processing. The atomic insert below is the + // race-safe guard against a concurrent KB soft-delete in the small window + // between this check and the insert. + const kb = await db + .select({ id: knowledgeBase.id }) + .from(knowledgeBase) + .where(and(eq(knowledgeBase.id, knowledgeBaseId), isNull(knowledgeBase.deletedAt))) + .limit(1) - if (kb.length === 0) { - throw new Error('Knowledge base not found') - } + if (kb.length === 0) { + throw new Error('Knowledge base not found') + } - const now = new Date() - const documentRecords = [] - const returnData: DocumentData[] = [] + const now = new Date() + const documentRecords: NewDocumentRow[] = [] + const returnData: DocumentData[] = [] - for (const docData of documents) { - const documentId = generateId() + for (const docData of documents) { + const documentId = generateId() - let processedTags: Partial = {} + let processedTags: Partial = {} - if (docData.documentTagsData) { - try { - const tagData = JSON.parse(docData.documentTagsData) - if (Array.isArray(tagData)) { - processedTags = await processDocumentTags(knowledgeBaseId, tagData, requestId) - } - } catch (error) { - if (error instanceof SyntaxError) { - logger.warn(`[${requestId}] Failed to parse documentTagsData for bulk document:`, error) - } else { - throw error - } + if (docData.documentTagsData) { + try { + const tagData = JSON.parse(docData.documentTagsData) + if (Array.isArray(tagData)) { + processedTags = await processDocumentTags(knowledgeBaseId, tagData, requestId) + } + } catch (error) { + if (error instanceof SyntaxError) { + logger.warn(`[${requestId}] Failed to parse documentTagsData for bulk document:`, error) + } else { + throw error } } + } - const newDocument = { - id: documentId, - knowledgeBaseId, - filename: docData.filename, - fileUrl: docData.fileUrl, - fileSize: docData.fileSize, - mimeType: docData.mimeType, - chunkCount: 0, - tokenCount: 0, - characterCount: 0, - processingStatus: 'pending' as const, - enabled: true, - uploadedAt: now, - tag1: processedTags.tag1 ?? docData.tag1 ?? null, - tag2: processedTags.tag2 ?? docData.tag2 ?? null, - tag3: processedTags.tag3 ?? docData.tag3 ?? null, - tag4: processedTags.tag4 ?? docData.tag4 ?? null, - tag5: processedTags.tag5 ?? docData.tag5 ?? null, - tag6: processedTags.tag6 ?? docData.tag6 ?? null, - tag7: processedTags.tag7 ?? docData.tag7 ?? null, - number1: processedTags.number1 ?? null, - number2: processedTags.number2 ?? null, - number3: processedTags.number3 ?? null, - number4: processedTags.number4 ?? null, - number5: processedTags.number5 ?? null, - date1: processedTags.date1 ?? null, - date2: processedTags.date2 ?? null, - boolean1: processedTags.boolean1 ?? null, - boolean2: processedTags.boolean2 ?? null, - boolean3: processedTags.boolean3 ?? null, - } - - documentRecords.push(newDocument) - returnData.push({ - documentId, - filename: docData.filename, - fileUrl: docData.fileUrl, - fileSize: docData.fileSize, - mimeType: docData.mimeType, - }) + const newDocument = { + id: documentId, + knowledgeBaseId, + filename: docData.filename, + fileUrl: docData.fileUrl, + fileSize: docData.fileSize, + mimeType: docData.mimeType, + chunkCount: 0, + tokenCount: 0, + characterCount: 0, + processingStatus: 'pending' as const, + enabled: true, + uploadedAt: now, + tag1: processedTags.tag1 ?? docData.tag1 ?? null, + tag2: processedTags.tag2 ?? docData.tag2 ?? null, + tag3: processedTags.tag3 ?? docData.tag3 ?? null, + tag4: processedTags.tag4 ?? docData.tag4 ?? null, + tag5: processedTags.tag5 ?? docData.tag5 ?? null, + tag6: processedTags.tag6 ?? docData.tag6 ?? null, + tag7: processedTags.tag7 ?? docData.tag7 ?? null, + number1: processedTags.number1 ?? null, + number2: processedTags.number2 ?? null, + number3: processedTags.number3 ?? null, + number4: processedTags.number4 ?? null, + number5: processedTags.number5 ?? null, + date1: processedTags.date1 ?? null, + date2: processedTags.date2 ?? null, + boolean1: processedTags.boolean1 ?? null, + boolean2: processedTags.boolean2 ?? null, + boolean3: processedTags.boolean3 ?? null, } - if (documentRecords.length > 0) { - await tx.insert(document).values(documentRecords) - logger.info( - `[${requestId}] Bulk created ${documentRecords.length} document records in knowledge base ${knowledgeBaseId}` - ) + documentRecords.push(newDocument) + returnData.push({ + documentId, + filename: docData.filename, + fileUrl: docData.fileUrl, + fileSize: docData.fileSize, + mimeType: docData.mimeType, + }) + } - await tx - .update(knowledgeBase) - .set({ updatedAt: now }) - .where(eq(knowledgeBase.id, knowledgeBaseId)) + if (documentRecords.length > 0) { + const insertedCount = await insertDocumentsIfKbAlive(documentRecords, knowledgeBaseId) + if (insertedCount === 0) { + throw new Error('Knowledge base not found') } + logger.info( + `[${requestId}] Bulk created ${insertedCount} document records in knowledge base ${knowledgeBaseId}` + ) - return returnData - }) + await db + .update(knowledgeBase) + .set({ updatedAt: now }) + .where(eq(knowledgeBase.id, knowledgeBaseId)) + } + + return returnData } export interface TagFilterCondition { @@ -1297,7 +1420,7 @@ export async function createSingleDocument( } } - const newDocument = { + const newDocument: NewDocumentRow = { id: documentId, knowledgeBaseId, filename: documentData.filename, @@ -1307,31 +1430,21 @@ export async function createSingleDocument( chunkCount: 0, tokenCount: 0, characterCount: 0, + processingStatus: 'pending', enabled: true, uploadedAt: now, ...processedTags, } - await db.transaction(async (tx) => { - await tx.execute(sql`SELECT 1 FROM knowledge_base WHERE id = ${knowledgeBaseId} FOR UPDATE`) - - const kb = await tx - .select({ id: knowledgeBase.id }) - .from(knowledgeBase) - .where(and(eq(knowledgeBase.id, knowledgeBaseId), isNull(knowledgeBase.deletedAt))) - .limit(1) - - if (kb.length === 0) { - throw new Error('Knowledge base not found') - } - - await tx.insert(document).values(newDocument) + const insertedCount = await insertDocumentsIfKbAlive([newDocument], knowledgeBaseId) + if (insertedCount === 0) { + throw new Error('Knowledge base not found') + } - await tx - .update(knowledgeBase) - .set({ updatedAt: now }) - .where(eq(knowledgeBase.id, knowledgeBaseId)) - }) + await db + .update(knowledgeBase) + .set({ updatedAt: now }) + .where(eq(knowledgeBase.id, knowledgeBaseId)) logger.info(`[${requestId}] Document created: ${documentId} in knowledge base ${knowledgeBaseId}`) return newDocument as { diff --git a/apps/sim/lib/workspaces/lifecycle.test.ts b/apps/sim/lib/workspaces/lifecycle.test.ts index 070b9c4ff25..d165472830a 100644 --- a/apps/sim/lib/workspaces/lifecycle.test.ts +++ b/apps/sim/lib/workspaces/lifecycle.test.ts @@ -55,6 +55,7 @@ describe('workspace lifecycle', () => { }) const tx = { + execute: vi.fn().mockResolvedValue([]), select: vi.fn().mockReturnValue({ from: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([{ id: 'kb-1' }]), diff --git a/apps/sim/lib/workspaces/lifecycle.ts b/apps/sim/lib/workspaces/lifecycle.ts index b0a2b0d6161..975529d27dc 100644 --- a/apps/sim/lib/workspaces/lifecycle.ts +++ b/apps/sim/lib/workspaces/lifecycle.ts @@ -49,6 +49,13 @@ export async function archiveWorkspace( .where(eq(workflowMcpServer.workspaceId, workspaceId)) await db.transaction(async (tx) => { + // Workspace archival is a rare admin/cleanup operation that touches every + // child table; on large workspaces it can exceed the 30s session default. + // Override per-tx with a generous ceiling — if it ever runs longer than + // this something is genuinely wrong. + await tx.execute(sql`SET LOCAL statement_timeout = '5min'`) + await tx.execute(sql`SET LOCAL lock_timeout = '30s'`) + await tx .update(knowledgeBase) .set({ diff --git a/packages/db/db.ts b/packages/db/db.ts index 9e5597fb57b..e56eaa004f0 100644 --- a/packages/db/db.ts +++ b/packages/db/db.ts @@ -13,6 +13,14 @@ const postgresClient = postgres(connectionString, { connect_timeout: 30, max: 15, onnotice: () => {}, + // Server-side guards. lock_timeout cancels a query waiting on a row lock for + // >5s (e.g. another tx holding `SELECT ... FOR UPDATE`). statement_timeout + // cancels any query running >30s. Heavy paths that legitimately need longer + // (table service bulk JSONB rewrites) override per-tx with `SET LOCAL`. + connection: { + lock_timeout: 5_000, + statement_timeout: 30_000, + }, }) export const db = drizzle(postgresClient, { schema }) From 8831defd2e4e6f75b1fbd2180e3b2b6544a0896f Mon Sep 17 00:00:00 2001 From: Waleed Date: Thu, 14 May 2026 10:25:17 -0700 Subject: [PATCH 02/12] fix(seo): use canonical SITE_URL for robots and sitemap (#4598) * fix(seo): use canonical SITE_URL for robots and sitemap * fix(seo): drop /templates from sitemap and guard robots/sitemap in seo test --- apps/sim/app/(landing)/seo.test.ts | 27 +++++++++++++++++++++++++++ apps/sim/app/robots.ts | 6 ++---- apps/sim/app/sitemap.ts | 7 +++++-- 3 files changed, 34 insertions(+), 6 deletions(-) diff --git a/apps/sim/app/(landing)/seo.test.ts b/apps/sim/app/(landing)/seo.test.ts index cb7b207af05..9168c896b95 100644 --- a/apps/sim/app/(landing)/seo.test.ts +++ b/apps/sim/app/(landing)/seo.test.ts @@ -26,9 +26,21 @@ const SEO_SCAN_DIRS = [ const SEO_SCAN_INDIVIDUAL_FILES = [ path.resolve(APP_DIR, 'page.tsx'), + path.resolve(APP_DIR, 'robots.ts'), + path.resolve(APP_DIR, 'sitemap.ts'), path.resolve(SIM_ROOT, 'ee', 'whitelabeling', 'metadata.ts'), ] +/** + * Files whose entire URL output is SEO-facing (robots.txt, sitemap.xml). + * Unlike metadata exports, these don't use `metadataBase`, so the existing + * `getBaseUrl()`-in-metadata check would miss a regression here. + */ +const SEO_DEFAULT_EXPORT_FILES = [ + path.resolve(APP_DIR, 'robots.ts'), + path.resolve(APP_DIR, 'sitemap.ts'), +] + function collectFiles(dir: string, exts: string[]): string[] { const results: string[] = [] if (!fs.existsSync(dir)) return results @@ -97,6 +109,21 @@ describe('SEO canonical URLs', () => { ).toHaveLength(0) }) + it('robots.ts and sitemap.ts do not import getBaseUrl', () => { + const violations: string[] = [] + for (const file of SEO_DEFAULT_EXPORT_FILES) { + if (!fs.existsSync(file)) continue + const content = fs.readFileSync(file, 'utf-8') + if (content.includes('getBaseUrl')) { + violations.push(path.relative(SIM_ROOT, file)) + } + } + expect( + violations, + `robots.ts/sitemap.ts must use SITE_URL, not getBaseUrl():\n${violations.join('\n')}` + ).toHaveLength(0) + }) + it('public pages do not use getBaseUrl() for SEO metadata', () => { const files = getAllSeoFiles(['.ts', '.tsx']) const violations: string[] = [] diff --git a/apps/sim/app/robots.ts b/apps/sim/app/robots.ts index 3adce4103f3..7cc090c9b97 100644 --- a/apps/sim/app/robots.ts +++ b/apps/sim/app/robots.ts @@ -1,5 +1,5 @@ import type { MetadataRoute } from 'next' -import { getBaseUrl } from '@/lib/core/utils/urls' +import { SITE_URL } from '@/lib/core/utils/urls' const DISALLOWED_PATHS = [ '/api/', @@ -45,8 +45,6 @@ const LINK_PREVIEW_BOTS = [ ] export default function robots(): MetadataRoute.Robots { - const baseUrl = getBaseUrl() - return { rules: [ { userAgent: '*', allow: '/', disallow: DISALLOWED_PATHS }, @@ -56,6 +54,6 @@ export default function robots(): MetadataRoute.Robots { disallow: LINK_PREVIEW_DISALLOWED_PATHS, }, ], - sitemap: [`${baseUrl}/sitemap.xml`, `${baseUrl}/blog/sitemap-images.xml`], + sitemap: [`${SITE_URL}/sitemap.xml`, `${SITE_URL}/blog/sitemap-images.xml`], } } diff --git a/apps/sim/app/sitemap.ts b/apps/sim/app/sitemap.ts index 7b28646fa05..8107f3010c0 100644 --- a/apps/sim/app/sitemap.ts +++ b/apps/sim/app/sitemap.ts @@ -1,12 +1,12 @@ import type { MetadataRoute } from 'next' import { COURSES } from '@/lib/academy/content' import { getAllPostMeta } from '@/lib/blog/registry' -import { getBaseUrl } from '@/lib/core/utils/urls' +import { SITE_URL } from '@/lib/core/utils/urls' import integrations from '@/app/(landing)/integrations/data/integrations.json' import { ALL_CATALOG_MODELS, MODEL_PROVIDERS_WITH_CATALOGS } from '@/app/(landing)/models/utils' export default async function sitemap(): Promise { - const baseUrl = getBaseUrl() + const baseUrl = SITE_URL const posts = await getAllPostMeta() const latestPostDate = @@ -46,6 +46,9 @@ export default async function sitemap(): Promise { { url: `${baseUrl}/partners`, }, + { + url: `${baseUrl}/contact`, + }, { url: `${baseUrl}/terms`, lastModified: new Date('2024-10-14'), From b1a87d531ce6d7477a0de347675671420f712290 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Thu, 14 May 2026 10:42:15 -0700 Subject: [PATCH 03/12] Revert "improvement(db): add session statement/lock timeouts; simplify KB doc tx (#4593)" (#4599) This reverts commit 4295a5c85580c3d1fc4336823d6767dd646df23f. --- apps/sim/lib/knowledge/documents/service.ts | 313 +++++++------------- apps/sim/lib/workspaces/lifecycle.test.ts | 1 - apps/sim/lib/workspaces/lifecycle.ts | 7 - packages/db/db.ts | 8 - 4 files changed, 100 insertions(+), 229 deletions(-) diff --git a/apps/sim/lib/knowledge/documents/service.ts b/apps/sim/lib/knowledge/documents/service.ts index 52577a2334e..49675608309 100644 --- a/apps/sim/lib/knowledge/documents/service.ts +++ b/apps/sim/lib/knowledge/documents/service.ts @@ -748,126 +748,6 @@ async function processDocumentsWithTrigger( } } -interface NewDocumentRow { - id: string - knowledgeBaseId: string - filename: string - fileUrl: string - fileSize: number - mimeType: string - chunkCount: number - tokenCount: number - characterCount: number - processingStatus: 'pending' - enabled: boolean - uploadedAt: Date - tag1: string | null - tag2: string | null - tag3: string | null - tag4: string | null - tag5: string | null - tag6: string | null - tag7: string | null - number1: number | null - number2: number | null - number3: number | null - number4: number | null - number5: number | null - date1: Date | null - date2: Date | null - boolean1: boolean | null - boolean2: boolean | null - boolean3: boolean | null -} - -/** - * Insert N document rows IF the parent knowledge base is still alive - * (`deleted_at IS NULL`) at the statement's MVCC snapshot. Returns the - * number of rows actually inserted. - * - * Knowledge bases are soft-deleted, so a normal FK can't catch a concurrent - * delete — the KB row physically remains. We do the existence check and the - * insert in a single statement via INSERT...SELECT...WHERE EXISTS, which - * Postgres evaluates atomically. No transaction or row lock required, no - * race window between check and insert. - * - * Returns 0 if the KB was soft-deleted; caller throws. - */ -async function insertDocumentsIfKbAlive( - rows: NewDocumentRow[], - knowledgeBaseId: string -): Promise { - if (rows.length === 0) return 0 - - // jsonb_to_recordset declares the column types once, so we don't need to - // cast every parameter individually to keep Postgres' type inference happy - // when nullable columns end up all-NULL across the batch. - const jsonRows = rows.map((d) => ({ - id: d.id, - knowledge_base_id: d.knowledgeBaseId, - filename: d.filename, - file_url: d.fileUrl, - file_size: d.fileSize, - mime_type: d.mimeType, - chunk_count: d.chunkCount, - token_count: d.tokenCount, - character_count: d.characterCount, - processing_status: d.processingStatus, - enabled: d.enabled, - uploaded_at: d.uploadedAt.toISOString(), - tag1: d.tag1, - tag2: d.tag2, - tag3: d.tag3, - tag4: d.tag4, - tag5: d.tag5, - tag6: d.tag6, - tag7: d.tag7, - number1: d.number1, - number2: d.number2, - number3: d.number3, - number4: d.number4, - number5: d.number5, - date1: d.date1?.toISOString() ?? null, - date2: d.date2?.toISOString() ?? null, - boolean1: d.boolean1, - boolean2: d.boolean2, - boolean3: d.boolean3, - })) - - const result = await db.execute(sql` - INSERT INTO document ( - id, knowledge_base_id, filename, file_url, file_size, mime_type, - chunk_count, token_count, character_count, processing_status, enabled, uploaded_at, - tag1, tag2, tag3, tag4, tag5, tag6, tag7, - number1, number2, number3, number4, number5, - date1, date2, - boolean1, boolean2, boolean3 - ) - SELECT - id, knowledge_base_id, filename, file_url, file_size, mime_type, - chunk_count, token_count, character_count, processing_status, enabled, uploaded_at, - tag1, tag2, tag3, tag4, tag5, tag6, tag7, - number1, number2, number3, number4, number5, - date1, date2, - boolean1, boolean2, boolean3 - FROM jsonb_to_recordset(${JSON.stringify(jsonRows)}::jsonb) AS x( - id text, knowledge_base_id text, filename text, file_url text, file_size integer, mime_type text, - chunk_count integer, token_count integer, character_count integer, processing_status text, enabled boolean, uploaded_at timestamp, - tag1 text, tag2 text, tag3 text, tag4 text, tag5 text, tag6 text, tag7 text, - number1 double precision, number2 double precision, number3 double precision, number4 double precision, number5 double precision, - date1 timestamp, date2 timestamp, - boolean1 boolean, boolean2 boolean, boolean3 boolean - ) - WHERE EXISTS ( - SELECT 1 FROM knowledge_base - WHERE id = ${knowledgeBaseId} AND deleted_at IS NULL - ) - RETURNING id - `) - - return Array.from(result).length -} - export async function createDocumentRecords( documents: Array<{ filename: string @@ -886,102 +766,99 @@ export async function createDocumentRecords( knowledgeBaseId: string, requestId: string ): Promise { - // Cheap upfront existence check so the common KB-not-found path fails fast - // before we burn CPU on tag processing. The atomic insert below is the - // race-safe guard against a concurrent KB soft-delete in the small window - // between this check and the insert. - const kb = await db - .select({ id: knowledgeBase.id }) - .from(knowledgeBase) - .where(and(eq(knowledgeBase.id, knowledgeBaseId), isNull(knowledgeBase.deletedAt))) - .limit(1) + return await db.transaction(async (tx) => { + await tx.execute(sql`SELECT 1 FROM knowledge_base WHERE id = ${knowledgeBaseId} FOR UPDATE`) - if (kb.length === 0) { - throw new Error('Knowledge base not found') - } + const kb = await tx + .select({ id: knowledgeBase.id }) + .from(knowledgeBase) + .where(and(eq(knowledgeBase.id, knowledgeBaseId), isNull(knowledgeBase.deletedAt))) + .limit(1) - const now = new Date() - const documentRecords: NewDocumentRow[] = [] - const returnData: DocumentData[] = [] + if (kb.length === 0) { + throw new Error('Knowledge base not found') + } - for (const docData of documents) { - const documentId = generateId() + const now = new Date() + const documentRecords = [] + const returnData: DocumentData[] = [] - let processedTags: Partial = {} + for (const docData of documents) { + const documentId = generateId() - if (docData.documentTagsData) { - try { - const tagData = JSON.parse(docData.documentTagsData) - if (Array.isArray(tagData)) { - processedTags = await processDocumentTags(knowledgeBaseId, tagData, requestId) - } - } catch (error) { - if (error instanceof SyntaxError) { - logger.warn(`[${requestId}] Failed to parse documentTagsData for bulk document:`, error) - } else { - throw error + let processedTags: Partial = {} + + if (docData.documentTagsData) { + try { + const tagData = JSON.parse(docData.documentTagsData) + if (Array.isArray(tagData)) { + processedTags = await processDocumentTags(knowledgeBaseId, tagData, requestId) + } + } catch (error) { + if (error instanceof SyntaxError) { + logger.warn(`[${requestId}] Failed to parse documentTagsData for bulk document:`, error) + } else { + throw error + } } } - } - const newDocument = { - id: documentId, - knowledgeBaseId, - filename: docData.filename, - fileUrl: docData.fileUrl, - fileSize: docData.fileSize, - mimeType: docData.mimeType, - chunkCount: 0, - tokenCount: 0, - characterCount: 0, - processingStatus: 'pending' as const, - enabled: true, - uploadedAt: now, - tag1: processedTags.tag1 ?? docData.tag1 ?? null, - tag2: processedTags.tag2 ?? docData.tag2 ?? null, - tag3: processedTags.tag3 ?? docData.tag3 ?? null, - tag4: processedTags.tag4 ?? docData.tag4 ?? null, - tag5: processedTags.tag5 ?? docData.tag5 ?? null, - tag6: processedTags.tag6 ?? docData.tag6 ?? null, - tag7: processedTags.tag7 ?? docData.tag7 ?? null, - number1: processedTags.number1 ?? null, - number2: processedTags.number2 ?? null, - number3: processedTags.number3 ?? null, - number4: processedTags.number4 ?? null, - number5: processedTags.number5 ?? null, - date1: processedTags.date1 ?? null, - date2: processedTags.date2 ?? null, - boolean1: processedTags.boolean1 ?? null, - boolean2: processedTags.boolean2 ?? null, - boolean3: processedTags.boolean3 ?? null, + const newDocument = { + id: documentId, + knowledgeBaseId, + filename: docData.filename, + fileUrl: docData.fileUrl, + fileSize: docData.fileSize, + mimeType: docData.mimeType, + chunkCount: 0, + tokenCount: 0, + characterCount: 0, + processingStatus: 'pending' as const, + enabled: true, + uploadedAt: now, + tag1: processedTags.tag1 ?? docData.tag1 ?? null, + tag2: processedTags.tag2 ?? docData.tag2 ?? null, + tag3: processedTags.tag3 ?? docData.tag3 ?? null, + tag4: processedTags.tag4 ?? docData.tag4 ?? null, + tag5: processedTags.tag5 ?? docData.tag5 ?? null, + tag6: processedTags.tag6 ?? docData.tag6 ?? null, + tag7: processedTags.tag7 ?? docData.tag7 ?? null, + number1: processedTags.number1 ?? null, + number2: processedTags.number2 ?? null, + number3: processedTags.number3 ?? null, + number4: processedTags.number4 ?? null, + number5: processedTags.number5 ?? null, + date1: processedTags.date1 ?? null, + date2: processedTags.date2 ?? null, + boolean1: processedTags.boolean1 ?? null, + boolean2: processedTags.boolean2 ?? null, + boolean3: processedTags.boolean3 ?? null, + } + + documentRecords.push(newDocument) + returnData.push({ + documentId, + filename: docData.filename, + fileUrl: docData.fileUrl, + fileSize: docData.fileSize, + mimeType: docData.mimeType, + }) } - documentRecords.push(newDocument) - returnData.push({ - documentId, - filename: docData.filename, - fileUrl: docData.fileUrl, - fileSize: docData.fileSize, - mimeType: docData.mimeType, - }) - } + if (documentRecords.length > 0) { + await tx.insert(document).values(documentRecords) + logger.info( + `[${requestId}] Bulk created ${documentRecords.length} document records in knowledge base ${knowledgeBaseId}` + ) - if (documentRecords.length > 0) { - const insertedCount = await insertDocumentsIfKbAlive(documentRecords, knowledgeBaseId) - if (insertedCount === 0) { - throw new Error('Knowledge base not found') + await tx + .update(knowledgeBase) + .set({ updatedAt: now }) + .where(eq(knowledgeBase.id, knowledgeBaseId)) } - logger.info( - `[${requestId}] Bulk created ${insertedCount} document records in knowledge base ${knowledgeBaseId}` - ) - - await db - .update(knowledgeBase) - .set({ updatedAt: now }) - .where(eq(knowledgeBase.id, knowledgeBaseId)) - } - return returnData + return returnData + }) } export interface TagFilterCondition { @@ -1420,7 +1297,7 @@ export async function createSingleDocument( } } - const newDocument: NewDocumentRow = { + const newDocument = { id: documentId, knowledgeBaseId, filename: documentData.filename, @@ -1430,21 +1307,31 @@ export async function createSingleDocument( chunkCount: 0, tokenCount: 0, characterCount: 0, - processingStatus: 'pending', enabled: true, uploadedAt: now, ...processedTags, } - const insertedCount = await insertDocumentsIfKbAlive([newDocument], knowledgeBaseId) - if (insertedCount === 0) { - throw new Error('Knowledge base not found') - } + await db.transaction(async (tx) => { + await tx.execute(sql`SELECT 1 FROM knowledge_base WHERE id = ${knowledgeBaseId} FOR UPDATE`) - await db - .update(knowledgeBase) - .set({ updatedAt: now }) - .where(eq(knowledgeBase.id, knowledgeBaseId)) + const kb = await tx + .select({ id: knowledgeBase.id }) + .from(knowledgeBase) + .where(and(eq(knowledgeBase.id, knowledgeBaseId), isNull(knowledgeBase.deletedAt))) + .limit(1) + + if (kb.length === 0) { + throw new Error('Knowledge base not found') + } + + await tx.insert(document).values(newDocument) + + await tx + .update(knowledgeBase) + .set({ updatedAt: now }) + .where(eq(knowledgeBase.id, knowledgeBaseId)) + }) logger.info(`[${requestId}] Document created: ${documentId} in knowledge base ${knowledgeBaseId}`) return newDocument as { diff --git a/apps/sim/lib/workspaces/lifecycle.test.ts b/apps/sim/lib/workspaces/lifecycle.test.ts index d165472830a..070b9c4ff25 100644 --- a/apps/sim/lib/workspaces/lifecycle.test.ts +++ b/apps/sim/lib/workspaces/lifecycle.test.ts @@ -55,7 +55,6 @@ describe('workspace lifecycle', () => { }) const tx = { - execute: vi.fn().mockResolvedValue([]), select: vi.fn().mockReturnValue({ from: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([{ id: 'kb-1' }]), diff --git a/apps/sim/lib/workspaces/lifecycle.ts b/apps/sim/lib/workspaces/lifecycle.ts index 975529d27dc..b0a2b0d6161 100644 --- a/apps/sim/lib/workspaces/lifecycle.ts +++ b/apps/sim/lib/workspaces/lifecycle.ts @@ -49,13 +49,6 @@ export async function archiveWorkspace( .where(eq(workflowMcpServer.workspaceId, workspaceId)) await db.transaction(async (tx) => { - // Workspace archival is a rare admin/cleanup operation that touches every - // child table; on large workspaces it can exceed the 30s session default. - // Override per-tx with a generous ceiling — if it ever runs longer than - // this something is genuinely wrong. - await tx.execute(sql`SET LOCAL statement_timeout = '5min'`) - await tx.execute(sql`SET LOCAL lock_timeout = '30s'`) - await tx .update(knowledgeBase) .set({ diff --git a/packages/db/db.ts b/packages/db/db.ts index e56eaa004f0..9e5597fb57b 100644 --- a/packages/db/db.ts +++ b/packages/db/db.ts @@ -13,14 +13,6 @@ const postgresClient = postgres(connectionString, { connect_timeout: 30, max: 15, onnotice: () => {}, - // Server-side guards. lock_timeout cancels a query waiting on a row lock for - // >5s (e.g. another tx holding `SELECT ... FOR UPDATE`). statement_timeout - // cancels any query running >30s. Heavy paths that legitimately need longer - // (table service bulk JSONB rewrites) override per-tx with `SET LOCAL`. - connection: { - lock_timeout: 5_000, - statement_timeout: 30_000, - }, }) export const db = drizzle(postgresClient, { schema }) From 642231f875b8df9f91984b31ad380a722d774bfa Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Thu, 14 May 2026 11:21:41 -0700 Subject: [PATCH 04/12] improvement(scheduler): drain due schedules in chunks (#4578) * improvement(scheduler): drain in chunks instead of a single capped claim Replaces the fixed MAX_CRON_CLAIMS (200) with a chunked drain loop: claim WORKFLOW_CHUNK_SIZE + JOB_CHUNK_SIZE per iteration, process via Promise.allSettled, repeat until both claim queries return empty or MAX_TICK_DURATION_MS elapses. Throughput is no longer bounded by a static per-tick ceiling; it scales until DB or trigger.dev is the limit. Per-iteration chunk size still bounds row-lock set and fan-out concurrency. Extracts processScheduleItem and processJobItem so the loop body stays readable. Existing claim semantics (FOR UPDATE SKIP LOCKED, lastQueuedAt as the claim signal, staleness reclaim) are unchanged. * improvement(scheduler): skip claim once a queue is exhausted and drop workflowUtils non-null assertion Addresses Greptile review on PR #4578: - track per-queue exhaustion when a claim returns fewer than CHUNK_SIZE rows; subsequent iterations skip the claim query for that queue. Saves one DB round-trip per iteration once one queue drains while the other is still working. - narrow workflowUtils to a local const inside the loop body so the schedule processing branch only runs when the import has completed. Removes the misleading non-null assertion. --- apps/sim/app/api/schedules/execute/route.ts | 379 +++++++++++--------- 1 file changed, 208 insertions(+), 171 deletions(-) diff --git a/apps/sim/app/api/schedules/execute/route.ts b/apps/sim/app/api/schedules/execute/route.ts index 9c86fc1c4f6..7891fcd01b1 100644 --- a/apps/sim/app/api/schedules/execute/route.ts +++ b/apps/sim/app/api/schedules/execute/route.ts @@ -21,9 +21,9 @@ export const dynamic = 'force-dynamic' export const maxDuration = 3600 const logger = createLogger('ScheduledExecuteAPI') -const MAX_CRON_CLAIMS = 200 -const RESERVED_WORKFLOW_CLAIMS = 100 -const RESERVED_JOB_CLAIMS = MAX_CRON_CLAIMS - RESERVED_WORKFLOW_CLAIMS +const WORKFLOW_CHUNK_SIZE = 100 +const JOB_CHUNK_SIZE = 100 +const MAX_TICK_DURATION_MS = 3 * 60 * 1000 const STALE_SCHEDULE_CLAIM_MS = getMaxExecutionTimeout() const dueFilter = (queuedAt: Date) => @@ -143,203 +143,240 @@ async function claimJobSchedules(queuedAt: Date, limit: number) { }) } -export const GET = withRouteHandler(async (request: NextRequest) => { - const requestId = generateRequestId() - logger.info(`[${requestId}] Scheduled execution triggered at ${new Date().toISOString()}`) - - const authError = verifyCronAuth(request, 'Schedule execution') - if (authError) { - return authError +type ClaimedSchedule = Awaited>[number] +type ClaimedJob = Awaited>[number] +type WorkflowUtils = typeof import('@/lib/workflows/utils') +type JobQueue = Awaited> + +async function processScheduleItem( + schedule: ClaimedSchedule, + queuedAt: Date, + requestId: string, + jobQueue: JobQueue, + workflowUtils: WorkflowUtils +) { + const queueTime = schedule.lastQueuedAt ?? queuedAt + const executionId = generateId() + const correlation = { + executionId, + requestId, + source: 'schedule' as const, + workflowId: schedule.workflowId!, + scheduleId: schedule.id, + triggerType: 'schedule', + scheduledFor: schedule.nextRunAt?.toISOString(), } - const queuedAt = new Date() + const payload = { + scheduleId: schedule.id, + workflowId: schedule.workflowId!, + executionId, + requestId, + correlation, + blockId: schedule.blockId || undefined, + deploymentVersionId: schedule.deploymentVersionId || undefined, + cronExpression: schedule.cronExpression || undefined, + lastRanAt: schedule.lastRanAt?.toISOString(), + failedCount: schedule.failedCount || 0, + now: queueTime.toISOString(), + scheduledFor: schedule.nextRunAt?.toISOString(), + } try { - const dueSchedules = await claimWorkflowSchedules(queuedAt, RESERVED_WORKFLOW_CLAIMS) - const dueJobs = await claimJobSchedules(queuedAt, RESERVED_JOB_CLAIMS) - const remainingClaimBudget = Math.max(0, MAX_CRON_CLAIMS - dueSchedules.length - dueJobs.length) - - if (remainingClaimBudget > 0 && dueSchedules.length === RESERVED_WORKFLOW_CLAIMS) { - dueSchedules.push(...(await claimWorkflowSchedules(queuedAt, remainingClaimBudget))) - } else if (remainingClaimBudget > 0 && dueJobs.length === RESERVED_JOB_CLAIMS) { - dueJobs.push(...(await claimJobSchedules(queuedAt, remainingClaimBudget))) + const scheduleJobId = buildScheduleExecutionJobId(schedule) + const existingJob = await jobQueue.getJob(scheduleJobId) + if (existingJob && ['pending', 'processing'].includes(existingJob.status)) { + logger.info(`[${requestId}] Schedule execution job already exists`, { + scheduleId: schedule.id, + jobId: scheduleJobId, + status: existingJob.status, + }) + return + } + if (existingJob) { + logger.info(`[${requestId}] Releasing stale schedule claim for finished job`, { + scheduleId: schedule.id, + jobId: scheduleJobId, + status: existingJob.status, + }) + await releaseScheduleLock( + schedule.id, + requestId, + queuedAt, + `Released stale schedule ${schedule.id} for finished job ${scheduleJobId}`, + getNextRunFromCronExpression(schedule.cronExpression) + ) + return } - const totalCount = dueSchedules.length + dueJobs.length + const resolvedWorkflow = schedule.workflowId + ? await workflowUtils.getWorkflowById(schedule.workflowId) + : null + const resolvedWorkspaceId = resolvedWorkflow?.workspaceId + + const jobId = await jobQueue.enqueue('schedule-execution', payload, { + jobId: scheduleJobId, + concurrencyKey: scheduleJobId, + metadata: { + workflowId: schedule.workflowId ?? undefined, + workspaceId: resolvedWorkspaceId ?? undefined, + correlation, + }, + }) logger.info( - `[${requestId}] Processing ${totalCount} due items (${dueSchedules.length} schedules, ${dueJobs.length} jobs)` + `[${requestId}] Queued schedule execution task ${jobId} for workflow ${schedule.workflowId}` ) - const jobQueue = await getJobQueue() - - const workflowUtils = - dueSchedules.length > 0 ? await import('@/lib/workflows/utils') : undefined - - const schedulePromises = dueSchedules.map(async (schedule) => { - const queueTime = schedule.lastQueuedAt ?? queuedAt - const executionId = generateId() - const correlation = { - executionId, - requestId, - source: 'schedule' as const, - workflowId: schedule.workflowId!, - scheduleId: schedule.id, - triggerType: 'schedule', - scheduledFor: schedule.nextRunAt?.toISOString(), - } - - const payload = { + const queuedJob = await jobQueue.getJob(jobId) + if (queuedJob && !['pending', 'processing'].includes(queuedJob.status)) { + logger.info(`[${requestId}] Schedule execution job already finished`, { scheduleId: schedule.id, - workflowId: schedule.workflowId!, - executionId, + jobId, + status: queuedJob.status, + }) + await releaseScheduleLock( + schedule.id, requestId, - correlation, - blockId: schedule.blockId || undefined, - deploymentVersionId: schedule.deploymentVersionId || undefined, - cronExpression: schedule.cronExpression || undefined, - lastRanAt: schedule.lastRanAt?.toISOString(), - failedCount: schedule.failedCount || 0, - now: queueTime.toISOString(), - scheduledFor: schedule.nextRunAt?.toISOString(), - } + queuedAt, + `Released stale schedule ${schedule.id} for finished job ${jobId}`, + getNextRunFromCronExpression(schedule.cronExpression) + ) + return + } + if (shouldExecuteInline()) { try { - const scheduleJobId = buildScheduleExecutionJobId(schedule) - const existingJob = await jobQueue.getJob(scheduleJobId) - if (existingJob && ['pending', 'processing'].includes(existingJob.status)) { - logger.info(`[${requestId}] Schedule execution job already exists`, { - scheduleId: schedule.id, - jobId: scheduleJobId, - status: existingJob.status, - }) - return - } - if (existingJob) { - logger.info(`[${requestId}] Releasing stale schedule claim for finished job`, { - scheduleId: schedule.id, - jobId: scheduleJobId, - status: existingJob.status, - }) - await releaseScheduleLock( - schedule.id, - requestId, - queuedAt, - `Released stale schedule ${schedule.id} for finished job ${scheduleJobId}`, - getNextRunFromCronExpression(schedule.cronExpression) - ) - return - } - - const resolvedWorkflow = schedule.workflowId - ? await workflowUtils?.getWorkflowById(schedule.workflowId) - : null - const resolvedWorkspaceId = resolvedWorkflow?.workspaceId - - const jobId = await jobQueue.enqueue('schedule-execution', payload, { - jobId: scheduleJobId, - concurrencyKey: scheduleJobId, - metadata: { - workflowId: schedule.workflowId ?? undefined, - workspaceId: resolvedWorkspaceId ?? undefined, - correlation, - }, - }) - logger.info( - `[${requestId}] Queued schedule execution task ${jobId} for workflow ${schedule.workflowId}` + await jobQueue.startJob(jobId) + const output = await executeScheduleJob(payload) + await jobQueue.completeJob(jobId, output) + } catch (error) { + const errorMessage = toError(error).message + logger.error( + `[${requestId}] Schedule execution failed for workflow ${schedule.workflowId}`, + { + jobId, + error: errorMessage, + } ) - - const queuedJob = await jobQueue.getJob(jobId) - if (queuedJob && !['pending', 'processing'].includes(queuedJob.status)) { - logger.info(`[${requestId}] Schedule execution job already finished`, { - scheduleId: schedule.id, + try { + await jobQueue.markJobFailed(jobId, errorMessage) + } catch (markFailedError) { + logger.error(`[${requestId}] Failed to mark job as failed`, { jobId, - status: queuedJob.status, + error: toError(markFailedError).message, }) - await releaseScheduleLock( - schedule.id, - requestId, - queuedAt, - `Released stale schedule ${schedule.id} for finished job ${jobId}`, - getNextRunFromCronExpression(schedule.cronExpression) - ) - return } - - if (shouldExecuteInline()) { - try { - await jobQueue.startJob(jobId) - const output = await executeScheduleJob(payload) - await jobQueue.completeJob(jobId, output) - } catch (error) { - const errorMessage = toError(error).message - logger.error( - `[${requestId}] Schedule execution failed for workflow ${schedule.workflowId}`, - { - jobId, - error: errorMessage, - } - ) - try { - await jobQueue.markJobFailed(jobId, errorMessage) - } catch (markFailedError) { - logger.error(`[${requestId}] Failed to mark job as failed`, { - jobId, - error: - markFailedError instanceof Error - ? markFailedError.message - : String(markFailedError), - }) - } - await releaseScheduleLock( - schedule.id, - requestId, - queuedAt, - `Failed to release lock for schedule ${schedule.id} after inline execution failure` - ) - } - } - } catch (error) { - logger.error( - `[${requestId}] Failed to queue schedule execution for workflow ${schedule.workflowId}`, - error - ) await releaseScheduleLock( schedule.id, requestId, queuedAt, - `Failed to release lock for schedule ${schedule.id} after queue failure` + `Failed to release lock for schedule ${schedule.id} after inline execution failure` ) } + } + } catch (error) { + logger.error( + `[${requestId}] Failed to queue schedule execution for workflow ${schedule.workflowId}`, + error + ) + await releaseScheduleLock( + schedule.id, + requestId, + queuedAt, + `Failed to release lock for schedule ${schedule.id} after queue failure` + ) + } +} + +async function processJobItem(job: ClaimedJob, queuedAt: Date, requestId: string) { + const queueTime = job.lastQueuedAt ?? queuedAt + const payload = { + scheduleId: job.id, + cronExpression: job.cronExpression || undefined, + failedCount: job.failedCount || 0, + now: queueTime.toISOString(), + } + + try { + await executeJobInline(payload) + } catch (error) { + logger.error(`[${requestId}] Job execution failed for ${job.id}`, { + error: toError(error).message, }) + await releaseScheduleLock( + job.id, + requestId, + queuedAt, + `Failed to release lock for job ${job.id}` + ) + } +} - // Mothership jobs are executed inline directly. - const jobPromises = dueJobs.map(async (job) => { - const queueTime = job.lastQueuedAt ?? queuedAt - const payload = { - scheduleId: job.id, - cronExpression: job.cronExpression || undefined, - failedCount: job.failedCount || 0, - now: queueTime.toISOString(), - } +export const GET = withRouteHandler(async (request: NextRequest) => { + const requestId = generateRequestId() + const tickStart = Date.now() + logger.info(`[${requestId}] Scheduled execution triggered at ${new Date().toISOString()}`) - try { - await executeJobInline(payload) - } catch (error) { - logger.error(`[${requestId}] Job execution failed for ${job.id}`, { - error: toError(error).message, - }) - await releaseScheduleLock( - job.id, - requestId, - queuedAt, - `Failed to release lock for job ${job.id}` - ) + const authError = verifyCronAuth(request, 'Schedule execution') + if (authError) { + return authError + } + + try { + const jobQueue = await getJobQueue() + let workflowUtils: WorkflowUtils | undefined + + let totalSchedules = 0 + let totalJobs = 0 + let iterations = 0 + let schedulesExhausted = false + let jobsExhausted = false + + while (Date.now() - tickStart < MAX_TICK_DURATION_MS) { + if (schedulesExhausted && jobsExhausted) break + const queuedAt = new Date() + + const [dueSchedules, dueJobs] = await Promise.all([ + schedulesExhausted ? [] : claimWorkflowSchedules(queuedAt, WORKFLOW_CHUNK_SIZE), + jobsExhausted ? [] : claimJobSchedules(queuedAt, JOB_CHUNK_SIZE), + ]) + + if (dueSchedules.length < WORKFLOW_CHUNK_SIZE) schedulesExhausted = true + if (dueJobs.length < JOB_CHUNK_SIZE) jobsExhausted = true + + if (dueSchedules.length === 0 && dueJobs.length === 0) break + + iterations += 1 + totalSchedules += dueSchedules.length + totalJobs += dueJobs.length + + logger.info( + `[${requestId}] Iteration ${iterations}: claimed ${dueSchedules.length} schedules, ${dueJobs.length} jobs` + ) + + if (dueSchedules.length > 0 && !workflowUtils) { + workflowUtils = await import('@/lib/workflows/utils') } - }) - await Promise.allSettled([...schedulePromises, ...jobPromises]) + const loadedWorkflowUtils = workflowUtils + const schedulePromises = + loadedWorkflowUtils && dueSchedules.length > 0 + ? dueSchedules.map((schedule) => + processScheduleItem(schedule, queuedAt, requestId, jobQueue, loadedWorkflowUtils) + ) + : [] + + await Promise.allSettled([ + ...schedulePromises, + ...dueJobs.map((job) => processJobItem(job, queuedAt, requestId)), + ]) + } - logger.info(`[${requestId}] Processed ${totalCount} items`) + const totalCount = totalSchedules + totalJobs + const durationMs = Date.now() - tickStart + logger.info( + `[${requestId}] Processed ${totalCount} items across ${iterations} iteration(s) in ${durationMs}ms (${totalSchedules} schedules, ${totalJobs} jobs)` + ) return NextResponse.json({ message: 'Scheduled workflow executions processed', From 044e034719161ddbbff213b15c69f741e3e2f978 Mon Sep 17 00:00:00 2001 From: Waleed Date: Thu, 14 May 2026 11:33:16 -0700 Subject: [PATCH 05/12] fix(integrations): gdrive trashed search, slack blocks-with-file, slack get_message ts (#4600) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(integrations): gdrive trashed search, slack blocks-with-file, slack get_message ts - Google Drive search/list: skip default `trashed = false` when user query already specifies a `trashed = ...` predicate, so trashed-file searches work. - Slack send-message with files: forward `blocks` through to `files.completeUploadExternal` so Block Kit renders when files are attached. - Slack get_message: switch from `conversations.history` (oldest lower-bound returned the next message after) to `conversations.replies` with `ts=` for exact-match lookup, plus a defensive ts-equality guard and clearer error. * fix(google_drive): revert list.ts trashed guard — query is plain text, not gdrive syntax * fix(slack): omit initial_comment when blocks present so Block Kit actually renders on file uploads --- apps/sim/app/api/tools/slack/utils.ts | 17 ++++++++++++++--- apps/sim/tools/google_drive/list.ts | 7 +++++-- apps/sim/tools/google_drive/search.ts | 11 ++++++++--- apps/sim/tools/slack/get_message.ts | 23 ++++++++++++++++++----- 4 files changed, 45 insertions(+), 13 deletions(-) diff --git a/apps/sim/app/api/tools/slack/utils.ts b/apps/sim/app/api/tools/slack/utils.ts index d74b078595f..aebd295c30a 100644 --- a/apps/sim/app/api/tools/slack/utils.ts +++ b/apps/sim/app/api/tools/slack/utils.ts @@ -141,7 +141,8 @@ async function completeSlackFileUpload( channel: string, text: string, accessToken: string, - threadTs?: string | null + threadTs?: string | null, + blocks?: unknown[] | null ): Promise<{ ok: boolean; files?: any[]; error?: string }> { const response = await fetch('https://slack.com/api/files.completeUploadExternal', { method: 'POST', @@ -152,7 +153,10 @@ async function completeSlackFileUpload( body: JSON.stringify({ files: uploadedFileIds.map((id) => ({ id })), channel_id: channel, - initial_comment: text, + // Per Slack docs for files.completeUploadExternal: if `initial_comment` + // is provided, `blocks` is silently ignored. So when blocks are present + // we omit initial_comment and let blocks render instead. + ...(blocks && blocks.length > 0 ? { blocks } : { initial_comment: text }), ...(threadTs && { thread_ts: threadTs }), }), }) @@ -295,7 +299,14 @@ export async function sendSlackMessage( } // Complete file upload with thread support - const completeData = await completeSlackFileUpload(fileIds, channel, text, accessToken, threadTs) + const completeData = await completeSlackFileUpload( + fileIds, + channel, + text, + accessToken, + threadTs, + blocks + ) if (!completeData.ok) { logger.error(`[${requestId}] Failed to complete upload:`, completeData.error) diff --git a/apps/sim/tools/google_drive/list.ts b/apps/sim/tools/google_drive/list.ts index afc4ee2c7cb..52bc22b80e5 100644 --- a/apps/sim/tools/google_drive/list.ts +++ b/apps/sim/tools/google_drive/list.ts @@ -66,8 +66,11 @@ export const listTool: ToolConfig value.replace(/\\/g, '\\\\').replace(/'/g, "\\'") - // Build the query conditions - const conditions = ['trashed = false'] // Always exclude trashed files + // Build the query conditions. `params.query` here is a plain-text name + // search term (wrapped in `name contains '...'` below), not Google Drive + // query syntax — so there's no caller-supplied `trashed` predicate to + // honour. Always exclude trashed files. + const conditions: string[] = ['trashed = false'] const folderId = (params.folderId || params.folderSelector)?.trim() if (folderId) { const escapedFolderId = escapeQueryValue(folderId) diff --git a/apps/sim/tools/google_drive/search.ts b/apps/sim/tools/google_drive/search.ts index 52eccb7ef62..33851c83ec6 100644 --- a/apps/sim/tools/google_drive/search.ts +++ b/apps/sim/tools/google_drive/search.ts @@ -64,9 +64,14 @@ export const searchTool: ToolConfig = { id: 'slack_get_message', name: 'Slack Get Message', @@ -49,11 +52,10 @@ export const slackGetMessageTool: ToolConfig { - const url = new URL('https://slack.com/api/conversations.history') + const url = new URL('https://slack.com/api/conversations.replies') url.searchParams.append('channel', params.channel?.trim() ?? '') - url.searchParams.append('oldest', params.timestamp?.trim() ?? '') + url.searchParams.append('ts', params.timestamp?.trim() ?? '') url.searchParams.append('limit', '1') - url.searchParams.append('inclusive', 'true') return url.toString() }, method: 'GET', @@ -63,8 +65,9 @@ export const slackGetMessageTool: ToolConfig { + transformResponse: async (response: Response, params?: SlackGetMessageParams) => { const data = await response.json() + const requestedTs = params?.timestamp?.trim() ?? '' if (!data.ok) { if (data.error === 'missing_scope') { @@ -78,15 +81,25 @@ export const slackGetMessageTool: ToolConfig Date: Thu, 14 May 2026 11:54:35 -0700 Subject: [PATCH 06/12] feat(cloudwatch): add mute and unmute alarm operations (#4602) --- .../api/tools/cloudwatch/mute-alarm/route.ts | 62 +++++++++++++++ .../tools/cloudwatch/unmute-alarm/route.ts | 62 +++++++++++++++ apps/sim/blocks/blocks/cloudwatch.ts | 54 ++++++++++++- .../tools/aws/cloudwatch-mute-alarm.ts | 43 ++++++++++ .../tools/aws/cloudwatch-unmute-alarm.ts | 45 +++++++++++ apps/sim/tools/cloudwatch/index.ts | 4 + apps/sim/tools/cloudwatch/mute_alarm.ts | 75 ++++++++++++++++++ apps/sim/tools/cloudwatch/types.ts | 22 ++++++ apps/sim/tools/cloudwatch/unmute_alarm.ts | 78 +++++++++++++++++++ apps/sim/tools/registry.ts | 4 + scripts/check-api-validation-contracts.ts | 4 +- 11 files changed, 450 insertions(+), 3 deletions(-) create mode 100644 apps/sim/app/api/tools/cloudwatch/mute-alarm/route.ts create mode 100644 apps/sim/app/api/tools/cloudwatch/unmute-alarm/route.ts create mode 100644 apps/sim/lib/api/contracts/tools/aws/cloudwatch-mute-alarm.ts create mode 100644 apps/sim/lib/api/contracts/tools/aws/cloudwatch-unmute-alarm.ts create mode 100644 apps/sim/tools/cloudwatch/mute_alarm.ts create mode 100644 apps/sim/tools/cloudwatch/unmute_alarm.ts diff --git a/apps/sim/app/api/tools/cloudwatch/mute-alarm/route.ts b/apps/sim/app/api/tools/cloudwatch/mute-alarm/route.ts new file mode 100644 index 00000000000..b7d435a6e46 --- /dev/null +++ b/apps/sim/app/api/tools/cloudwatch/mute-alarm/route.ts @@ -0,0 +1,62 @@ +import { CloudWatchClient, DisableAlarmActionsCommand } from '@aws-sdk/client-cloudwatch' +import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { awsCloudwatchMuteAlarmContract } from '@/lib/api/contracts/tools/aws/cloudwatch-mute-alarm' +import { parseToolRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' + +const logger = createLogger('CloudWatchMuteAlarm') + +export const POST = withRouteHandler(async (request: NextRequest) => { + try { + const auth = await checkInternalAuth(request) + if (!auth.success || !auth.userId) { + return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) + } + + const parsed = await parseToolRequest(awsCloudwatchMuteAlarmContract, request, { + errorFormat: 'details', + logger, + }) + if (!parsed.success) return parsed.response + const validatedData = parsed.data.body + + logger.info(`Muting ${validatedData.alarmNames.length} CloudWatch alarm(s)`) + + const client = new CloudWatchClient({ + region: validatedData.region, + credentials: { + accessKeyId: validatedData.accessKeyId, + secretAccessKey: validatedData.secretAccessKey, + }, + }) + + try { + const command = new DisableAlarmActionsCommand({ + AlarmNames: validatedData.alarmNames, + }) + + await client.send(command) + + logger.info(`Successfully muted ${validatedData.alarmNames.length} alarm(s)`) + + return NextResponse.json({ + success: true, + output: { + success: true, + alarmNames: validatedData.alarmNames, + }, + }) + } finally { + client.destroy() + } + } catch (error) { + logger.error('MuteAlarm failed', { error: toError(error).message }) + return NextResponse.json( + { error: `Failed to mute CloudWatch alarm: ${toError(error).message}` }, + { status: 500 } + ) + } +}) diff --git a/apps/sim/app/api/tools/cloudwatch/unmute-alarm/route.ts b/apps/sim/app/api/tools/cloudwatch/unmute-alarm/route.ts new file mode 100644 index 00000000000..79357ae38f1 --- /dev/null +++ b/apps/sim/app/api/tools/cloudwatch/unmute-alarm/route.ts @@ -0,0 +1,62 @@ +import { CloudWatchClient, EnableAlarmActionsCommand } from '@aws-sdk/client-cloudwatch' +import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { awsCloudwatchUnmuteAlarmContract } from '@/lib/api/contracts/tools/aws/cloudwatch-unmute-alarm' +import { parseToolRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' + +const logger = createLogger('CloudWatchUnmuteAlarm') + +export const POST = withRouteHandler(async (request: NextRequest) => { + try { + const auth = await checkInternalAuth(request) + if (!auth.success || !auth.userId) { + return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) + } + + const parsed = await parseToolRequest(awsCloudwatchUnmuteAlarmContract, request, { + errorFormat: 'details', + logger, + }) + if (!parsed.success) return parsed.response + const validatedData = parsed.data.body + + logger.info(`Unmuting ${validatedData.alarmNames.length} CloudWatch alarm(s)`) + + const client = new CloudWatchClient({ + region: validatedData.region, + credentials: { + accessKeyId: validatedData.accessKeyId, + secretAccessKey: validatedData.secretAccessKey, + }, + }) + + try { + const command = new EnableAlarmActionsCommand({ + AlarmNames: validatedData.alarmNames, + }) + + await client.send(command) + + logger.info(`Successfully unmuted ${validatedData.alarmNames.length} alarm(s)`) + + return NextResponse.json({ + success: true, + output: { + success: true, + alarmNames: validatedData.alarmNames, + }, + }) + } finally { + client.destroy() + } + } catch (error) { + logger.error('UnmuteAlarm failed', { error: toError(error).message }) + return NextResponse.json( + { error: `Failed to unmute CloudWatch alarm: ${toError(error).message}` }, + { status: 500 } + ) + } +}) diff --git a/apps/sim/blocks/blocks/cloudwatch.ts b/apps/sim/blocks/blocks/cloudwatch.ts index 30e5245d141..585edb6a074 100644 --- a/apps/sim/blocks/blocks/cloudwatch.ts +++ b/apps/sim/blocks/blocks/cloudwatch.ts @@ -8,8 +8,10 @@ import type { CloudWatchGetLogEventsResponse, CloudWatchGetMetricStatisticsResponse, CloudWatchListMetricsResponse, + CloudWatchMuteAlarmResponse, CloudWatchPutMetricDataResponse, CloudWatchQueryLogsResponse, + CloudWatchUnmuteAlarmResponse, } from '@/tools/cloudwatch/types' export const CloudWatchBlock: BlockConfig< @@ -21,6 +23,8 @@ export const CloudWatchBlock: BlockConfig< | CloudWatchListMetricsResponse | CloudWatchGetMetricStatisticsResponse | CloudWatchPutMetricDataResponse + | CloudWatchMuteAlarmResponse + | CloudWatchUnmuteAlarmResponse > = { type: 'cloudwatch', name: 'CloudWatch', @@ -47,6 +51,8 @@ export const CloudWatchBlock: BlockConfig< { label: 'Get Metric Statistics', id: 'get_metric_statistics' }, { label: 'Publish Metric', id: 'put_metric_data' }, { label: 'Describe Alarms', id: 'describe_alarms' }, + { label: 'Mute Alarm', id: 'mute_alarm' }, + { label: 'Unmute Alarm', id: 'unmute_alarm' }, ], value: () => 'query_logs', }, @@ -360,6 +366,14 @@ Return ONLY the numeric timestamp - no explanations, no quotes, no extra text.`, value: () => '', condition: { field: 'operation', value: 'describe_alarms' }, }, + { + id: 'alarmNames', + title: 'Alarm Names', + type: 'short-input', + placeholder: 'my-alarm-1, my-alarm-2', + condition: { field: 'operation', value: ['mute_alarm', 'unmute_alarm'] }, + required: { field: 'operation', value: ['mute_alarm', 'unmute_alarm'] }, + }, { id: 'limit', title: 'Limit', @@ -389,6 +403,8 @@ Return ONLY the numeric timestamp - no explanations, no quotes, no extra text.`, 'cloudwatch_get_metric_statistics', 'cloudwatch_put_metric_data', 'cloudwatch_describe_alarms', + 'cloudwatch_mute_alarm', + 'cloudwatch_unmute_alarm', ], config: { tool: (params) => { @@ -409,6 +425,10 @@ Return ONLY the numeric timestamp - no explanations, no quotes, no extra text.`, return 'cloudwatch_put_metric_data' case 'describe_alarms': return 'cloudwatch_describe_alarms' + case 'mute_alarm': + return 'cloudwatch_mute_alarm' + case 'unmute_alarm': + return 'cloudwatch_unmute_alarm' default: throw new Error(`Invalid CloudWatch operation: ${params.operation}`) } @@ -613,6 +633,33 @@ Return ONLY the numeric timestamp - no explanations, no quotes, no extra text.`, ...(parsedLimit !== undefined && { limit: parsedLimit }), } + case 'mute_alarm': + case 'unmute_alarm': { + const alarmNames = rest.alarmNames + if (!alarmNames) { + throw new Error('Alarm names are required') + } + + const names = + typeof alarmNames === 'string' + ? alarmNames + .split(',') + .map((n: string) => n.trim()) + .filter(Boolean) + : alarmNames + + if (!Array.isArray(names) || names.length === 0) { + throw new Error('At least one alarm name is required') + } + + return { + awsRegion, + awsAccessKeyId, + awsSecretAccessKey, + alarmNames: names, + } + } + default: throw new Error(`Invalid CloudWatch operation: ${operation}`) } @@ -653,6 +700,7 @@ Return ONLY the numeric timestamp - no explanations, no quotes, no extra text.`, description: 'Alarm state filter (OK, ALARM, INSUFFICIENT_DATA)', }, alarmType: { type: 'string', description: 'Alarm type filter (MetricAlarm, CompositeAlarm)' }, + alarmNames: { type: 'string', description: 'Comma-separated alarm names to mute or unmute' }, limit: { type: 'number', description: 'Maximum number of results' }, }, outputs: { @@ -696,9 +744,13 @@ Return ONLY the numeric timestamp - no explanations, no quotes, no extra text.`, type: 'array', description: 'CloudWatch alarms with state and configuration', }, + alarmNames: { + type: 'array', + description: 'Names of the alarms that were muted or unmuted', + }, success: { type: 'boolean', - description: 'Whether the published metric was successful', + description: 'Whether the operation completed successfully', }, namespace: { type: 'string', diff --git a/apps/sim/lib/api/contracts/tools/aws/cloudwatch-mute-alarm.ts b/apps/sim/lib/api/contracts/tools/aws/cloudwatch-mute-alarm.ts new file mode 100644 index 00000000000..cb95e59d9c3 --- /dev/null +++ b/apps/sim/lib/api/contracts/tools/aws/cloudwatch-mute-alarm.ts @@ -0,0 +1,43 @@ +import { z } from 'zod' +import type { + ContractBody, + ContractBodyInput, + ContractJsonResponse, +} from '@/lib/api/contracts/types' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { validateAwsRegion } from '@/lib/core/security/input-validation' + +const MuteAlarmSchema = z.object({ + region: z + .string() + .min(1, 'AWS region is required') + .refine((v) => validateAwsRegion(v).isValid, { + message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', + }), + accessKeyId: z.string().min(1, 'AWS access key ID is required'), + secretAccessKey: z.string().min(1, 'AWS secret access key is required'), + alarmNames: z + .array(z.string().min(1, 'Alarm name cannot be empty')) + .min(1, 'At least one alarm name is required') + .max(100, 'At most 100 alarm names are allowed per request'), +}) + +const MuteAlarmResponseSchema = z.object({ + success: z.literal(true), + output: z.object({ + success: z.literal(true), + alarmNames: z.array(z.string()), + }), +}) + +export const awsCloudwatchMuteAlarmContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/cloudwatch/mute-alarm', + body: MuteAlarmSchema, + response: { mode: 'json', schema: MuteAlarmResponseSchema }, +}) +export type AwsCloudwatchMuteAlarmRequest = ContractBodyInput +export type AwsCloudwatchMuteAlarmBody = ContractBody +export type AwsCloudwatchMuteAlarmResponse = ContractJsonResponse< + typeof awsCloudwatchMuteAlarmContract +> diff --git a/apps/sim/lib/api/contracts/tools/aws/cloudwatch-unmute-alarm.ts b/apps/sim/lib/api/contracts/tools/aws/cloudwatch-unmute-alarm.ts new file mode 100644 index 00000000000..f0fd1427a83 --- /dev/null +++ b/apps/sim/lib/api/contracts/tools/aws/cloudwatch-unmute-alarm.ts @@ -0,0 +1,45 @@ +import { z } from 'zod' +import type { + ContractBody, + ContractBodyInput, + ContractJsonResponse, +} from '@/lib/api/contracts/types' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { validateAwsRegion } from '@/lib/core/security/input-validation' + +const UnmuteAlarmSchema = z.object({ + region: z + .string() + .min(1, 'AWS region is required') + .refine((v) => validateAwsRegion(v).isValid, { + message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', + }), + accessKeyId: z.string().min(1, 'AWS access key ID is required'), + secretAccessKey: z.string().min(1, 'AWS secret access key is required'), + alarmNames: z + .array(z.string().min(1, 'Alarm name cannot be empty')) + .min(1, 'At least one alarm name is required') + .max(100, 'At most 100 alarm names are allowed per request'), +}) + +const UnmuteAlarmResponseSchema = z.object({ + success: z.literal(true), + output: z.object({ + success: z.literal(true), + alarmNames: z.array(z.string()), + }), +}) + +export const awsCloudwatchUnmuteAlarmContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/cloudwatch/unmute-alarm', + body: UnmuteAlarmSchema, + response: { mode: 'json', schema: UnmuteAlarmResponseSchema }, +}) +export type AwsCloudwatchUnmuteAlarmRequest = ContractBodyInput< + typeof awsCloudwatchUnmuteAlarmContract +> +export type AwsCloudwatchUnmuteAlarmBody = ContractBody +export type AwsCloudwatchUnmuteAlarmResponse = ContractJsonResponse< + typeof awsCloudwatchUnmuteAlarmContract +> diff --git a/apps/sim/tools/cloudwatch/index.ts b/apps/sim/tools/cloudwatch/index.ts index ec67cd532f6..0d92b5662fe 100644 --- a/apps/sim/tools/cloudwatch/index.ts +++ b/apps/sim/tools/cloudwatch/index.ts @@ -4,8 +4,10 @@ import { describeLogStreamsTool } from '@/tools/cloudwatch/describe_log_streams' import { getLogEventsTool } from '@/tools/cloudwatch/get_log_events' import { getMetricStatisticsTool } from '@/tools/cloudwatch/get_metric_statistics' import { listMetricsTool } from '@/tools/cloudwatch/list_metrics' +import { muteAlarmTool } from '@/tools/cloudwatch/mute_alarm' import { putMetricDataTool } from '@/tools/cloudwatch/put_metric_data' import { queryLogsTool } from '@/tools/cloudwatch/query_logs' +import { unmuteAlarmTool } from '@/tools/cloudwatch/unmute_alarm' export * from './types' @@ -15,5 +17,7 @@ export const cloudwatchDescribeLogStreamsTool = describeLogStreamsTool export const cloudwatchGetLogEventsTool = getLogEventsTool export const cloudwatchGetMetricStatisticsTool = getMetricStatisticsTool export const cloudwatchListMetricsTool = listMetricsTool +export const cloudwatchMuteAlarmTool = muteAlarmTool export const cloudwatchPutMetricDataTool = putMetricDataTool export const cloudwatchQueryLogsTool = queryLogsTool +export const cloudwatchUnmuteAlarmTool = unmuteAlarmTool diff --git a/apps/sim/tools/cloudwatch/mute_alarm.ts b/apps/sim/tools/cloudwatch/mute_alarm.ts new file mode 100644 index 00000000000..37591920dbe --- /dev/null +++ b/apps/sim/tools/cloudwatch/mute_alarm.ts @@ -0,0 +1,75 @@ +import type { + CloudWatchMuteAlarmParams, + CloudWatchMuteAlarmResponse, +} from '@/tools/cloudwatch/types' +import type { ToolConfig } from '@/tools/types' + +export const muteAlarmTool: ToolConfig = { + id: 'cloudwatch_mute_alarm', + name: 'CloudWatch Mute Alarm', + description: 'Disable notification actions on one or more CloudWatch alarms', + version: '1.0.0', + + params: { + awsRegion: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS region (e.g., us-east-1)', + }, + awsAccessKeyId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS access key ID', + }, + awsSecretAccessKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS secret access key', + }, + alarmNames: { + type: 'array', + required: true, + visibility: 'user-or-llm', + description: 'Names of the CloudWatch alarms to mute', + }, + }, + + request: { + url: '/api/tools/cloudwatch/mute-alarm', + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + }), + body: (params) => ({ + region: params.awsRegion, + accessKeyId: params.awsAccessKeyId, + secretAccessKey: params.awsSecretAccessKey, + alarmNames: params.alarmNames, + }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error || 'Failed to mute CloudWatch alarm') + } + + return { + success: true, + output: data.output, + } + }, + + outputs: { + success: { type: 'boolean', description: 'Whether the alarms were muted successfully' }, + alarmNames: { + type: 'array', + description: 'Names of the alarms that were muted', + items: { type: 'string' }, + }, + }, +} diff --git a/apps/sim/tools/cloudwatch/types.ts b/apps/sim/tools/cloudwatch/types.ts index f4172283029..0e243ab12ca 100644 --- a/apps/sim/tools/cloudwatch/types.ts +++ b/apps/sim/tools/cloudwatch/types.ts @@ -163,3 +163,25 @@ export interface CloudWatchPutMetricDataResponse extends ToolResponse { timestamp: string } } + +export interface CloudWatchMuteAlarmParams extends CloudWatchConnectionConfig { + alarmNames: string[] +} + +export interface CloudWatchMuteAlarmResponse extends ToolResponse { + output: { + success: boolean + alarmNames: string[] + } +} + +export interface CloudWatchUnmuteAlarmParams extends CloudWatchConnectionConfig { + alarmNames: string[] +} + +export interface CloudWatchUnmuteAlarmResponse extends ToolResponse { + output: { + success: boolean + alarmNames: string[] + } +} diff --git a/apps/sim/tools/cloudwatch/unmute_alarm.ts b/apps/sim/tools/cloudwatch/unmute_alarm.ts new file mode 100644 index 00000000000..a6e0a4270ed --- /dev/null +++ b/apps/sim/tools/cloudwatch/unmute_alarm.ts @@ -0,0 +1,78 @@ +import type { + CloudWatchUnmuteAlarmParams, + CloudWatchUnmuteAlarmResponse, +} from '@/tools/cloudwatch/types' +import type { ToolConfig } from '@/tools/types' + +export const unmuteAlarmTool: ToolConfig< + CloudWatchUnmuteAlarmParams, + CloudWatchUnmuteAlarmResponse +> = { + id: 'cloudwatch_unmute_alarm', + name: 'CloudWatch Unmute Alarm', + description: 'Re-enable notification actions on one or more CloudWatch alarms', + version: '1.0.0', + + params: { + awsRegion: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS region (e.g., us-east-1)', + }, + awsAccessKeyId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS access key ID', + }, + awsSecretAccessKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS secret access key', + }, + alarmNames: { + type: 'array', + required: true, + visibility: 'user-or-llm', + description: 'Names of the CloudWatch alarms to unmute', + }, + }, + + request: { + url: '/api/tools/cloudwatch/unmute-alarm', + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + }), + body: (params) => ({ + region: params.awsRegion, + accessKeyId: params.awsAccessKeyId, + secretAccessKey: params.awsSecretAccessKey, + alarmNames: params.alarmNames, + }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error || 'Failed to unmute CloudWatch alarm') + } + + return { + success: true, + output: data.output, + } + }, + + outputs: { + success: { type: 'boolean', description: 'Whether the alarms were unmuted successfully' }, + alarmNames: { + type: 'array', + description: 'Names of the alarms that were unmuted', + items: { type: 'string' }, + }, + }, +} diff --git a/apps/sim/tools/registry.ts b/apps/sim/tools/registry.ts index 014ca723df2..c71385aa0d9 100644 --- a/apps/sim/tools/registry.ts +++ b/apps/sim/tools/registry.ts @@ -350,8 +350,10 @@ import { cloudwatchGetLogEventsTool, cloudwatchGetMetricStatisticsTool, cloudwatchListMetricsTool, + cloudwatchMuteAlarmTool, cloudwatchPutMetricDataTool, cloudwatchQueryLogsTool, + cloudwatchUnmuteAlarmTool, } from '@/tools/cloudwatch' import { confluenceAddLabelTool, @@ -3813,8 +3815,10 @@ export const tools: Record = { cloudwatch_get_log_events: cloudwatchGetLogEventsTool, cloudwatch_get_metric_statistics: cloudwatchGetMetricStatisticsTool, cloudwatch_list_metrics: cloudwatchListMetricsTool, + cloudwatch_mute_alarm: cloudwatchMuteAlarmTool, cloudwatch_put_metric_data: cloudwatchPutMetricDataTool, cloudwatch_query_logs: cloudwatchQueryLogsTool, + cloudwatch_unmute_alarm: cloudwatchUnmuteAlarmTool, crowdstrike_get_sensor_aggregates: crowdstrikeGetSensorAggregatesTool, crowdstrike_get_sensor_details: crowdstrikeGetSensorDetailsTool, crowdstrike_query_sensors: crowdstrikeQuerySensorsTool, diff --git a/scripts/check-api-validation-contracts.ts b/scripts/check-api-validation-contracts.ts index 1081576504d..6b4425e7cd7 100644 --- a/scripts/check-api-validation-contracts.ts +++ b/scripts/check-api-validation-contracts.ts @@ -9,8 +9,8 @@ const QUERY_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/queries') const SELECTOR_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/selectors') const BASELINE = { - totalRoutes: 736, - zodRoutes: 736, + totalRoutes: 738, + zodRoutes: 738, nonZodRoutes: 0, } as const From 80c9a012759bf1df4406fec91c18fa80d87745e5 Mon Sep 17 00:00:00 2001 From: Waleed Date: Thu, 14 May 2026 13:32:40 -0700 Subject: [PATCH 07/12] fix(security): harden file access controls, webhook auth, and input bounds (#4601) * fix(security): harden file access controls, webhook auth, and input bounds * fix(security): extend file access checks to remaining tool routes * fix(logs): address PR review comments on time filter * fix(logs): set end-time milliseconds to 999 for datetime filter strings * fix(files): return 404 instead of 500 on file access denial in utility paths * remove tooltip from resource tabs --- apps/sim/app/api/files/authorization.ts | 14 ++- .../sim/app/api/tools/agiloft/attach/route.ts | 5 +- apps/sim/app/api/tools/box/upload/route.ts | 5 +- .../confluence/upload-attachment/route.ts | 9 ++ .../api/tools/discord/send-message/route.ts | 35 ++++-- apps/sim/app/api/tools/docusign/route.ts | 10 +- .../sim/app/api/tools/dropbox/upload/route.ts | 5 +- .../app/api/tools/firecrawl/parse/route.ts | 4 + apps/sim/app/api/tools/gmail/draft/route.ts | 29 +++-- .../app/api/tools/gmail/edit-draft/route.ts | 35 ++++-- apps/sim/app/api/tools/gmail/send/route.ts | 29 +++-- .../api/tools/google_drive/upload/route.ts | 6 +- .../api/tools/jira/add-attachment/route.ts | 5 +- .../microsoft-dataverse/upload-file/route.ts | 6 +- .../microsoft_teams/write_channel/route.ts | 10 +- .../tools/microsoft_teams/write_chat/route.ts | 10 +- apps/sim/app/api/tools/mistral/parse/route.ts | 3 + .../app/api/tools/onedrive/upload/route.ts | 6 +- apps/sim/app/api/tools/outlook/draft/route.ts | 33 +++--- apps/sim/app/api/tools/outlook/send/route.ts | 33 +++--- .../api/tools/quiver/image-to-svg/route.ts | 17 ++- .../app/api/tools/quiver/text-to-svg/route.ts | 13 ++- apps/sim/app/api/tools/s3/put-object/route.ts | 6 +- .../app/api/tools/sap_concur/upload/route.ts | 6 +- .../app/api/tools/sendgrid/send-mail/route.ts | 28 +++-- apps/sim/app/api/tools/sftp/upload/route.ts | 2 +- .../app/api/tools/sharepoint/upload/route.ts | 2 +- .../app/api/tools/slack/send-message/route.ts | 12 +- apps/sim/app/api/tools/slack/utils.ts | 15 ++- apps/sim/app/api/tools/smtp/send/route.ts | 51 +++++---- apps/sim/app/api/tools/stt/route.ts | 7 +- .../tools/supabase/storage-upload/route.ts | 5 +- .../api/tools/telegram/send-document/route.ts | 6 +- .../sim/app/api/tools/textract/parse/route.ts | 3 + apps/sim/app/api/tools/video/route.ts | 13 ++- .../sim/app/api/tools/vision/analyze/route.ts | 10 +- .../app/api/tools/wordpress/upload/route.ts | 2 +- .../resource-options-bar.tsx | 2 +- .../resource-tabs/resource-tabs.tsx | 105 ++++++++---------- .../components/logs-toolbar/logs-toolbar.tsx | 32 ++---- .../app/workspace/[workspaceId]/logs/logs.tsx | 24 +--- .../app/workspace/[workspaceId]/logs/utils.ts | 25 +++++ .../components/date-picker/date-picker.tsx | 62 +++++++++-- .../sim/lib/api/contracts/storage-transfer.ts | 2 +- apps/sim/lib/logs/filters.ts | 8 +- apps/sim/lib/webhooks/providers/attio.ts | 12 +- apps/sim/lib/webhooks/providers/github.ts | 10 -- apps/sim/lib/webhooks/providers/intercom.ts | 5 +- apps/sim/lib/webhooks/providers/whatsapp.ts | 2 +- .../executor/human-in-the-loop-manager.ts | 2 +- .../sim/tools/microsoft_teams/server-utils.ts | 9 +- apps/sim/tools/microsoft_teams/utils.ts | 4 +- 52 files changed, 517 insertions(+), 277 deletions(-) diff --git a/apps/sim/app/api/files/authorization.ts b/apps/sim/app/api/files/authorization.ts index ef5183ae0da..07ec36261b4 100644 --- a/apps/sim/app/api/files/authorization.ts +++ b/apps/sim/app/api/files/authorization.ts @@ -14,6 +14,14 @@ import { isUuid } from '@/executor/constants' const logger = createLogger('FileAuthorization') +/** Thrown by utility functions when file access is denied, so route handlers can return 404. */ +export class FileAccessDeniedError extends Error { + constructor() { + super('File not found') + this.name = 'FileAccessDeniedError' + } +} + interface AuthorizationResult { granted: boolean reason: string @@ -598,7 +606,7 @@ async function authorizeFileAccess( */ export async function assertToolFileAccess( key: unknown, - userId: string | undefined, + userId: string, requestId: string, routeLogger: ReturnType ): Promise { @@ -606,10 +614,6 @@ export async function assertToolFileAccess( routeLogger.warn(`[${requestId}] File access check rejected: missing key`) return NextResponse.json({ success: false, error: 'File not found' }, { status: 404 }) } - if (!userId) { - routeLogger.warn(`[${requestId}] File access check requires userId but none available`) - return NextResponse.json({ success: false, error: 'File not found' }, { status: 404 }) - } const hasAccess = await verifyFileAccess(key, userId) if (!hasAccess) { routeLogger.warn(`[${requestId}] File access denied for user`, { userId, key }) diff --git a/apps/sim/app/api/tools/agiloft/attach/route.ts b/apps/sim/app/api/tools/agiloft/attach/route.ts index edcbdc4c0f3..6257502ae4c 100644 --- a/apps/sim/app/api/tools/agiloft/attach/route.ts +++ b/apps/sim/app/api/tools/agiloft/attach/route.ts @@ -10,6 +10,7 @@ import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import type { RawFileInput } from '@/lib/uploads/utils/file-schemas' import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils' import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' +import { assertToolFileAccess } from '@/app/api/files/authorization' import { agiloftLogin, agiloftLogout, buildAttachFileUrl } from '@/tools/agiloft/utils' export const dynamic = 'force-dynamic' @@ -22,7 +23,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { try { const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) - if (!authResult.success) { + if (!authResult.success || !authResult.userId) { logger.warn(`[${requestId}] Unauthorized Agiloft attach attempt: ${authResult.error}`) return NextResponse.json( { success: false, error: authResult.error || 'Authentication required' }, @@ -66,6 +67,8 @@ export const POST = withRouteHandler(async (request: NextRequest) => { `[${requestId}] Downloading file for Agiloft attach: ${userFile.name} (${userFile.size} bytes)` ) + const denied = await assertToolFileAccess(userFile.key, authResult.userId, requestId, logger) + if (denied) return denied const fileBuffer = await downloadFileFromStorage(userFile, requestId, logger) const resolvedFileName = data.fileName || userFile.name || 'attachment' diff --git a/apps/sim/app/api/tools/box/upload/route.ts b/apps/sim/app/api/tools/box/upload/route.ts index 9bd50e77634..73519befcd7 100644 --- a/apps/sim/app/api/tools/box/upload/route.ts +++ b/apps/sim/app/api/tools/box/upload/route.ts @@ -7,6 +7,7 @@ import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { processFilesToUserFiles, type RawFileInput } from '@/lib/uploads/utils/file-utils' import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' +import { assertToolFileAccess } from '@/app/api/files/authorization' export const dynamic = 'force-dynamic' @@ -18,7 +19,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { try { const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) - if (!authResult.success) { + if (!authResult.success || !authResult.userId) { logger.warn(`[${requestId}] Unauthorized Box upload attempt: ${authResult.error}`) return NextResponse.json( { success: false, error: authResult.error || 'Authentication required' }, @@ -49,6 +50,8 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const userFile = userFiles[0] logger.info(`[${requestId}] Downloading file: ${userFile.name} (${userFile.size} bytes)`) + const denied = await assertToolFileAccess(userFile.key, authResult.userId, requestId, logger) + if (denied) return denied fileBuffer = await downloadFileFromStorage(userFile, requestId, logger) fileName = validatedData.fileName || userFile.name } else if (validatedData.fileContent) { diff --git a/apps/sim/app/api/tools/confluence/upload-attachment/route.ts b/apps/sim/app/api/tools/confluence/upload-attachment/route.ts index 713ee1a011a..32ae35a0a27 100644 --- a/apps/sim/app/api/tools/confluence/upload-attachment/route.ts +++ b/apps/sim/app/api/tools/confluence/upload-attachment/route.ts @@ -7,6 +7,7 @@ import { validateAlphanumericId, validateJiraCloudId } from '@/lib/core/security import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { processSingleFileToUserFile, type RawFileInput } from '@/lib/uploads/utils/file-utils' import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' +import { assertToolFileAccess } from '@/app/api/files/authorization' import { getConfluenceCloudId } from '@/tools/confluence/utils' import { parseAtlassianErrorMessage } from '@/tools/jira/utils' @@ -80,6 +81,14 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } + const denied = await assertToolFileAccess( + userFile.key, + auth.userId, + 'confluence-upload', + logger + ) + if (denied) return denied + let fileBuffer: Buffer try { fileBuffer = await downloadFileFromStorage(userFile, 'confluence-upload', logger) diff --git a/apps/sim/app/api/tools/discord/send-message/route.ts b/apps/sim/app/api/tools/discord/send-message/route.ts index 061fec94de7..9a4c24096d4 100644 --- a/apps/sim/app/api/tools/discord/send-message/route.ts +++ b/apps/sim/app/api/tools/discord/send-message/route.ts @@ -8,6 +8,7 @@ import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils' import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' +import { assertToolFileAccess } from '@/app/api/files/authorization' export const dynamic = 'force-dynamic' @@ -19,7 +20,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { try { const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) - if (!authResult.success) { + if (!authResult.success || !authResult.userId) { logger.warn(`[${requestId}] Unauthorized Discord send attempt: ${authResult.error}`) return NextResponse.json( { @@ -30,8 +31,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } + const userId = authResult.userId logger.info(`[${requestId}] Authenticated Discord send request via ${authResult.authType}`, { - userId: authResult.userId, + userId, }) const parsed = await parseRequest(discordSendMessageContract, request, {}) @@ -134,17 +136,30 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } formData.append('payload_json', JSON.stringify(payload)) - const downloadedFiles = await Promise.all( - userFiles.map(async (userFile, i) => { - logger.info(`[${requestId}] Downloading file ${i}: ${userFile.name}`) - const buffer = await downloadFileFromStorage(userFile, requestId, logger) - logger.info(`[${requestId}] Added file ${i}: ${userFile.name} (${buffer.length} bytes)`) - return { userFile, buffer } + const accessResults = await Promise.all( + userFiles.map((file) => assertToolFileAccess(file.key, userId, requestId, logger)) + ) + const denied = accessResults.find((r) => r !== null) + if (denied) return denied + + const buffers = await Promise.all( + userFiles.map(async (file, i) => { + try { + logger.info(`[${requestId}] Downloading file ${i}: ${file.name}`) + return await downloadFileFromStorage(file, requestId, logger) + } catch (error) { + logger.error(`[${requestId}] Failed to download attachment ${file.name}:`, error) + throw new Error( + `Failed to download attachment "${file.name}": ${error instanceof Error ? error.message : 'Unknown error'}` + ) + } }) ) - for (let i = 0; i < downloadedFiles.length; i++) { - const { userFile, buffer } = downloadedFiles[i] + for (let i = 0; i < userFiles.length; i++) { + const userFile = userFiles[i] + const buffer = buffers[i] + logger.info(`[${requestId}] Added file ${i}: ${userFile.name} (${buffer.length} bytes)`) filesOutput.push({ name: userFile.name, mimeType: userFile.type || 'application/octet-stream', diff --git a/apps/sim/app/api/tools/docusign/route.ts b/apps/sim/app/api/tools/docusign/route.ts index a1352435b6f..04c363def3b 100644 --- a/apps/sim/app/api/tools/docusign/route.ts +++ b/apps/sim/app/api/tools/docusign/route.ts @@ -7,6 +7,7 @@ import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { FileInputSchema } from '@/lib/uploads/utils/file-schemas' import { processFilesToUserFiles, type RawFileInput } from '@/lib/uploads/utils/file-utils' import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' +import { assertToolFileAccess } from '@/app/api/files/authorization' const logger = createLogger('DocuSignAPI') @@ -54,7 +55,7 @@ async function resolveAccount(accessToken: string): Promise export const POST = withRouteHandler(async (request: NextRequest) => { const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) - if (!authResult.success) { + if (!authResult.success || !authResult.userId) { return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 }) } @@ -84,7 +85,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { switch (operation) { case 'send_envelope': - return await handleSendEnvelope(apiBase, headers, params) + return await handleSendEnvelope(apiBase, headers, params, authResult.userId) case 'create_from_template': return await handleCreateFromTemplate(apiBase, headers, params) case 'get_envelope': @@ -115,7 +116,8 @@ export const POST = withRouteHandler(async (request: NextRequest) => { async function handleSendEnvelope( apiBase: string, headers: Record, - params: Record + params: Record, + userId: string ) { const { signerEmail, signerName, emailSubject, emailBody, ccEmail, ccName, file, status } = params @@ -135,6 +137,8 @@ async function handleSendEnvelope( const userFiles = processFilesToUserFiles([parsed as RawFileInput], 'docusign-send', logger) if (userFiles.length > 0) { const userFile = userFiles[0] + const denied = await assertToolFileAccess(userFile.key, userId, 'docusign-send', logger) + if (denied) return denied const buffer = await downloadFileFromStorage(userFile, 'docusign-send', logger) documentBase64 = buffer.toString('base64') documentName = userFile.name diff --git a/apps/sim/app/api/tools/dropbox/upload/route.ts b/apps/sim/app/api/tools/dropbox/upload/route.ts index 055ccb140ae..2c14fcd0dc6 100644 --- a/apps/sim/app/api/tools/dropbox/upload/route.ts +++ b/apps/sim/app/api/tools/dropbox/upload/route.ts @@ -8,6 +8,7 @@ import { httpHeaderSafeJson } from '@/lib/core/utils/validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { processFilesToUserFiles, type RawFileInput } from '@/lib/uploads/utils/file-utils' import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' +import { assertToolFileAccess } from '@/app/api/files/authorization' export const dynamic = 'force-dynamic' @@ -19,7 +20,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { try { const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) - if (!authResult.success) { + if (!authResult.success || !authResult.userId) { logger.warn(`[${requestId}] Unauthorized Dropbox upload attempt: ${authResult.error}`) return NextResponse.json( { success: false, error: authResult.error || 'Authentication required' }, @@ -52,6 +53,8 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const userFile = userFiles[0] logger.info(`[${requestId}] Downloading file: ${userFile.name} (${userFile.size} bytes)`) + const denied = await assertToolFileAccess(userFile.key, authResult.userId, requestId, logger) + if (denied) return denied fileBuffer = await downloadFileFromStorage(userFile, requestId, logger) fileName = userFile.name } else if (validatedData.fileContent) { diff --git a/apps/sim/app/api/tools/firecrawl/parse/route.ts b/apps/sim/app/api/tools/firecrawl/parse/route.ts index 1c46e85651e..409f74a6f16 100644 --- a/apps/sim/app/api/tools/firecrawl/parse/route.ts +++ b/apps/sim/app/api/tools/firecrawl/parse/route.ts @@ -8,6 +8,7 @@ import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils' import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' +import { assertToolFileAccess } from '@/app/api/files/authorization' export const dynamic = 'force-dynamic' @@ -43,6 +44,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { size: userFile.size, }) + const denied = await assertToolFileAccess(userFile.key, authResult.userId, requestId, logger) + if (denied) return denied + const buffer = await downloadFileFromStorage(userFile, requestId, logger) const formData = new FormData() diff --git a/apps/sim/app/api/tools/gmail/draft/route.ts b/apps/sim/app/api/tools/gmail/draft/route.ts index bbd5ee5d94d..1b51ff59d28 100644 --- a/apps/sim/app/api/tools/gmail/draft/route.ts +++ b/apps/sim/app/api/tools/gmail/draft/route.ts @@ -7,6 +7,7 @@ import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils' import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' +import { assertToolFileAccess } from '@/app/api/files/authorization' import { base64UrlEncode, buildMimeMessage, @@ -26,7 +27,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { try { const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) - if (!authResult.success) { + if (!authResult.success || !authResult.userId) { logger.warn(`[${requestId}] Unauthorized Gmail draft attempt: ${authResult.error}`) return NextResponse.json( { @@ -37,8 +38,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } + const userId = authResult.userId logger.info(`[${requestId}] Authenticated Gmail draft request via ${authResult.authType}`, { - userId: authResult.userId, + userId, }) const parsed = await parseRequest(gmailDraftContract, request, {}) @@ -85,20 +87,19 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } - const attachmentBuffers = await Promise.all( + const accessResults = await Promise.all( + attachments.map((file) => assertToolFileAccess(file.key, userId, requestId, logger)) + ) + const denied = accessResults.find((r) => r !== null) + if (denied) return denied + + const buffers = await Promise.all( attachments.map(async (file) => { try { logger.info( `[${requestId}] Downloading attachment: ${file.name} (${file.size} bytes)` ) - - const buffer = await downloadFileFromStorage(file, requestId, logger) - - return { - filename: file.name, - mimeType: file.type || 'application/octet-stream', - content: buffer, - } + return await downloadFileFromStorage(file, requestId, logger) } catch (error) { logger.error(`[${requestId}] Failed to download attachment ${file.name}:`, error) throw new Error( @@ -108,6 +109,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => { }) ) + const attachmentBuffers = attachments.map((file, i) => ({ + filename: file.name, + mimeType: file.type || 'application/octet-stream', + content: buffers[i], + })) + const mimeMessage = buildMimeMessage({ to: validatedData.to, cc: validatedData.cc ?? undefined, diff --git a/apps/sim/app/api/tools/gmail/edit-draft/route.ts b/apps/sim/app/api/tools/gmail/edit-draft/route.ts index a9515aff73d..dc8b3e71785 100644 --- a/apps/sim/app/api/tools/gmail/edit-draft/route.ts +++ b/apps/sim/app/api/tools/gmail/edit-draft/route.ts @@ -7,6 +7,7 @@ import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils' import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' +import { assertToolFileAccess } from '@/app/api/files/authorization' import { base64UrlEncode, buildMimeMessage, @@ -25,7 +26,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { try { const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) - if (!authResult.success) { + if (!authResult.success || !authResult.userId) { logger.warn(`[${requestId}] Unauthorized Gmail edit draft attempt: ${authResult.error}`) return NextResponse.json( { @@ -36,9 +37,10 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } + const userId = authResult.userId logger.info( `[${requestId}] Authenticated Gmail edit draft request via ${authResult.authType}`, - { userId: authResult.userId } + { userId } ) const parsed = await parseRequest(gmailEditDraftContract, request, {}) @@ -81,17 +83,34 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } - const attachmentBuffers = await Promise.all( + const accessResults = await Promise.all( + attachments.map((file) => assertToolFileAccess(file.key, userId, requestId, logger)) + ) + const denied = accessResults.find((r) => r !== null) + if (denied) return denied + + const buffers = await Promise.all( attachments.map(async (file) => { - const buffer = await downloadFileFromStorage(file, requestId, logger) - return { - filename: file.name, - mimeType: file.type || 'application/octet-stream', - content: buffer, + try { + logger.info( + `[${requestId}] Downloading attachment: ${file.name} (${file.size} bytes)` + ) + return await downloadFileFromStorage(file, requestId, logger) + } catch (error) { + logger.error(`[${requestId}] Failed to download attachment ${file.name}:`, error) + throw new Error( + `Failed to download attachment "${file.name}": ${error instanceof Error ? error.message : 'Unknown error'}` + ) } }) ) + const attachmentBuffers = attachments.map((file, i) => ({ + filename: file.name, + mimeType: file.type || 'application/octet-stream', + content: buffers[i], + })) + const mimeMessage = buildMimeMessage({ to: validatedData.to, cc: validatedData.cc ?? undefined, diff --git a/apps/sim/app/api/tools/gmail/send/route.ts b/apps/sim/app/api/tools/gmail/send/route.ts index 028216e6283..d0e6d1b6401 100644 --- a/apps/sim/app/api/tools/gmail/send/route.ts +++ b/apps/sim/app/api/tools/gmail/send/route.ts @@ -7,6 +7,7 @@ import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils' import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' +import { assertToolFileAccess } from '@/app/api/files/authorization' import { base64UrlEncode, buildMimeMessage, @@ -26,7 +27,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { try { const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) - if (!authResult.success) { + if (!authResult.success || !authResult.userId) { logger.warn(`[${requestId}] Unauthorized Gmail send attempt: ${authResult.error}`) return NextResponse.json( { @@ -37,8 +38,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } + const userId = authResult.userId logger.info(`[${requestId}] Authenticated Gmail send request via ${authResult.authType}`, { - userId: authResult.userId, + userId, }) const parsed = await parseRequest(gmailSendContract, request, {}) @@ -85,20 +87,19 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } - const attachmentBuffers = await Promise.all( + const accessResults = await Promise.all( + attachments.map((file) => assertToolFileAccess(file.key, userId, requestId, logger)) + ) + const denied = accessResults.find((r) => r !== null) + if (denied) return denied + + const buffers = await Promise.all( attachments.map(async (file) => { try { logger.info( `[${requestId}] Downloading attachment: ${file.name} (${file.size} bytes)` ) - - const buffer = await downloadFileFromStorage(file, requestId, logger) - - return { - filename: file.name, - mimeType: file.type || 'application/octet-stream', - content: buffer, - } + return await downloadFileFromStorage(file, requestId, logger) } catch (error) { logger.error(`[${requestId}] Failed to download attachment ${file.name}:`, error) throw new Error( @@ -108,6 +109,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => { }) ) + const attachmentBuffers = attachments.map((file, i) => ({ + filename: file.name, + mimeType: file.type || 'application/octet-stream', + content: buffers[i], + })) + const mimeMessage = buildMimeMessage({ to: validatedData.to, cc: validatedData.cc ?? undefined, diff --git a/apps/sim/app/api/tools/google_drive/upload/route.ts b/apps/sim/app/api/tools/google_drive/upload/route.ts index 0222cf03b55..797d7cf11d2 100644 --- a/apps/sim/app/api/tools/google_drive/upload/route.ts +++ b/apps/sim/app/api/tools/google_drive/upload/route.ts @@ -7,6 +7,7 @@ import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { processSingleFileToUserFile } from '@/lib/uploads/utils/file-utils' import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' +import { assertToolFileAccess } from '@/app/api/files/authorization' import { GOOGLE_WORKSPACE_MIME_TYPES, handleSheetsFormat, @@ -52,7 +53,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { try { const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) - if (!authResult.success) { + if (!authResult.success || !authResult.userId) { logger.warn(`[${requestId}] Unauthorized Google Drive upload attempt: ${authResult.error}`) return NextResponse.json( { @@ -113,6 +114,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { size: userFile.size, }) + const denied = await assertToolFileAccess(userFile.key, authResult.userId, requestId, logger) + if (denied) return denied + let fileBuffer: Buffer try { diff --git a/apps/sim/app/api/tools/jira/add-attachment/route.ts b/apps/sim/app/api/tools/jira/add-attachment/route.ts index e889ef13745..6f343879d33 100644 --- a/apps/sim/app/api/tools/jira/add-attachment/route.ts +++ b/apps/sim/app/api/tools/jira/add-attachment/route.ts @@ -6,6 +6,7 @@ import { checkInternalAuth } from '@/lib/auth/hybrid' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils' import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' +import { assertToolFileAccess } from '@/app/api/files/authorization' import { getJiraCloudId, parseAtlassianErrorMessage } from '@/tools/jira/utils' const logger = createLogger('JiraAddAttachmentAPI') @@ -17,7 +18,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { try { const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) - if (!authResult.success) { + if (!authResult.success || !authResult.userId) { return NextResponse.json( { success: false, error: authResult.error || 'Unauthorized' }, { status: 401 } @@ -43,6 +44,8 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const formData = new FormData() for (const file of userFiles) { + const denied = await assertToolFileAccess(file.key, authResult.userId, requestId, logger) + if (denied) return denied const buffer = await downloadFileFromStorage(file, requestId, logger) const blob = new Blob([new Uint8Array(buffer)], { type: file.type || 'application/octet-stream', diff --git a/apps/sim/app/api/tools/microsoft-dataverse/upload-file/route.ts b/apps/sim/app/api/tools/microsoft-dataverse/upload-file/route.ts index 31bf0d8fe07..2bfca548e7c 100644 --- a/apps/sim/app/api/tools/microsoft-dataverse/upload-file/route.ts +++ b/apps/sim/app/api/tools/microsoft-dataverse/upload-file/route.ts @@ -8,6 +8,7 @@ import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { processSingleFileToUserFile } from '@/lib/uploads/utils/file-utils' import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' +import { assertToolFileAccess } from '@/app/api/files/authorization' export const dynamic = 'force-dynamic' @@ -19,7 +20,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { try { const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) - if (!authResult.success) { + if (!authResult.success || !authResult.userId) { logger.warn(`[${requestId}] Unauthorized Dataverse upload attempt: ${authResult.error}`) return NextResponse.json( { success: false, error: authResult.error || 'Authentication required' }, @@ -66,6 +67,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } + const denied = await assertToolFileAccess(userFile.key, authResult.userId, requestId, logger) + if (denied) return denied + fileBuffer = await downloadFileFromStorage(userFile, requestId, logger) } else if (validatedData.fileContent) { fileBuffer = Buffer.from(validatedData.fileContent, 'base64') diff --git a/apps/sim/app/api/tools/microsoft_teams/write_channel/route.ts b/apps/sim/app/api/tools/microsoft_teams/write_channel/route.ts index 697497d1357..208261b9b8a 100644 --- a/apps/sim/app/api/tools/microsoft_teams/write_channel/route.ts +++ b/apps/sim/app/api/tools/microsoft_teams/write_channel/route.ts @@ -6,6 +6,7 @@ import { checkInternalAuth } from '@/lib/auth/hybrid' import { secureFetchWithValidation } from '@/lib/core/security/input-validation.server' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { FileAccessDeniedError } from '@/app/api/files/authorization' import { uploadFilesForTeamsMessage } from '@/tools/microsoft_teams/server-utils' import type { GraphApiErrorResponse, GraphChatMessage } from '@/tools/microsoft_teams/types' import { resolveMentionsForChannel, type TeamsMention } from '@/tools/microsoft_teams/utils' @@ -20,7 +21,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { try { const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) - if (!authResult.success) { + if (!authResult.success || !authResult.userId) { logger.warn(`[${requestId}] Unauthorized Teams channel write attempt: ${authResult.error}`) return NextResponse.json( { @@ -31,10 +32,11 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } + const userId = authResult.userId logger.info( `[${requestId}] Authenticated Teams channel write request via ${authResult.authType}`, { - userId: authResult.userId, + userId, } ) @@ -54,6 +56,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { accessToken: validatedData.accessToken, requestId, logger, + userId, }) let messageContent = validatedData.content @@ -160,6 +163,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { }, }) } catch (error) { + if (error instanceof FileAccessDeniedError) { + return NextResponse.json({ success: false, error: 'File not found' }, { status: 404 }) + } logger.error(`[${requestId}] Error sending Teams channel message:`, error) return NextResponse.json( { diff --git a/apps/sim/app/api/tools/microsoft_teams/write_chat/route.ts b/apps/sim/app/api/tools/microsoft_teams/write_chat/route.ts index 964247ed303..ce350752da7 100644 --- a/apps/sim/app/api/tools/microsoft_teams/write_chat/route.ts +++ b/apps/sim/app/api/tools/microsoft_teams/write_chat/route.ts @@ -6,6 +6,7 @@ import { checkInternalAuth } from '@/lib/auth/hybrid' import { secureFetchWithValidation } from '@/lib/core/security/input-validation.server' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { FileAccessDeniedError } from '@/app/api/files/authorization' import { uploadFilesForTeamsMessage } from '@/tools/microsoft_teams/server-utils' import type { GraphApiErrorResponse, GraphChatMessage } from '@/tools/microsoft_teams/types' import { resolveMentionsForChat, type TeamsMention } from '@/tools/microsoft_teams/utils' @@ -20,7 +21,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { try { const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) - if (!authResult.success) { + if (!authResult.success || !authResult.userId) { logger.warn(`[${requestId}] Unauthorized Teams chat write attempt: ${authResult.error}`) return NextResponse.json( { @@ -31,10 +32,11 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } + const userId = authResult.userId logger.info( `[${requestId}] Authenticated Teams chat write request via ${authResult.authType}`, { - userId: authResult.userId, + userId, } ) @@ -53,6 +55,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { accessToken: validatedData.accessToken, requestId, logger, + userId, }) let messageContent = validatedData.content @@ -157,6 +160,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { }, }) } catch (error) { + if (error instanceof FileAccessDeniedError) { + return NextResponse.json({ success: false, error: 'File not found' }, { status: 404 }) + } logger.error(`[${requestId}] Error sending Teams chat message:`, error) return NextResponse.json( { diff --git a/apps/sim/app/api/tools/mistral/parse/route.ts b/apps/sim/app/api/tools/mistral/parse/route.ts index d5334ebf851..c684b9c4492 100644 --- a/apps/sim/app/api/tools/mistral/parse/route.ts +++ b/apps/sim/app/api/tools/mistral/parse/route.ts @@ -14,6 +14,7 @@ import { downloadFileFromStorage, resolveInternalFileUrl, } from '@/lib/uploads/utils/file-utils.server' +import { assertToolFileAccess } from '@/app/api/files/authorization' export const dynamic = 'force-dynamic' @@ -120,6 +121,8 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } let base64 = userFile.base64 if (!base64) { + const denied = await assertToolFileAccess(userFile.key, userId, requestId, logger) + if (denied) return denied const buffer = await downloadFileFromStorage(userFile, requestId, logger) base64 = buffer.toString('base64') } diff --git a/apps/sim/app/api/tools/onedrive/upload/route.ts b/apps/sim/app/api/tools/onedrive/upload/route.ts index 54aafbc0c03..5913cff16e2 100644 --- a/apps/sim/app/api/tools/onedrive/upload/route.ts +++ b/apps/sim/app/api/tools/onedrive/upload/route.ts @@ -13,6 +13,7 @@ import { processSingleFileToUserFile, } from '@/lib/uploads/utils/file-utils' import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' +import { assertToolFileAccess } from '@/app/api/files/authorization' import { normalizeExcelValues } from '@/tools/onedrive/utils' export const dynamic = 'force-dynamic' @@ -47,7 +48,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { try { const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) - if (!authResult.success) { + if (!authResult.success || !authResult.userId) { logger.warn(`[${requestId}] Unauthorized OneDrive upload attempt: ${authResult.error}`) return NextResponse.json( { @@ -108,6 +109,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } + const denied = await assertToolFileAccess(userFile.key, authResult.userId, requestId, logger) + if (denied) return denied + try { fileBuffer = await downloadFileFromStorage(userFile, requestId, logger) } catch (error) { diff --git a/apps/sim/app/api/tools/outlook/draft/route.ts b/apps/sim/app/api/tools/outlook/draft/route.ts index 3b2bf4aec1f..65bec69895d 100644 --- a/apps/sim/app/api/tools/outlook/draft/route.ts +++ b/apps/sim/app/api/tools/outlook/draft/route.ts @@ -7,6 +7,7 @@ import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils' import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' +import { assertToolFileAccess } from '@/app/api/files/authorization' export const dynamic = 'force-dynamic' @@ -18,7 +19,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { try { const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) - if (!authResult.success) { + if (!authResult.success || !authResult.userId) { logger.warn(`[${requestId}] Unauthorized Outlook draft attempt: ${authResult.error}`) return NextResponse.json( { @@ -29,8 +30,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } + const userId = authResult.userId logger.info(`[${requestId}] Authenticated Outlook draft request via ${authResult.authType}`, { - userId: authResult.userId, + userId, }) const parsed = await parseRequest(outlookDraftContract, request, {}) @@ -98,23 +100,19 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } - const attachmentObjects = await Promise.all( + const accessResults = await Promise.all( + attachments.map((file) => assertToolFileAccess(file.key, userId, requestId, logger)) + ) + const denied = accessResults.find((r) => r !== null) + if (denied) return denied + + const buffers = await Promise.all( attachments.map(async (file) => { try { logger.info( `[${requestId}] Downloading attachment: ${file.name} (${file.size} bytes)` ) - - const buffer = await downloadFileFromStorage(file, requestId, logger) - - const base64Content = buffer.toString('base64') - - return { - '@odata.type': '#microsoft.graph.fileAttachment', - name: file.name, - contentType: file.type || 'application/octet-stream', - contentBytes: base64Content, - } + return await downloadFileFromStorage(file, requestId, logger) } catch (error) { logger.error(`[${requestId}] Failed to download attachment ${file.name}:`, error) throw new Error( @@ -124,6 +122,13 @@ export const POST = withRouteHandler(async (request: NextRequest) => { }) ) + const attachmentObjects = attachments.map((file, i) => ({ + '@odata.type': '#microsoft.graph.fileAttachment', + name: file.name, + contentType: file.type || 'application/octet-stream', + contentBytes: buffers[i].toString('base64'), + })) + logger.info(`[${requestId}] Converted ${attachmentObjects.length} attachments to base64`) message.attachments = attachmentObjects } diff --git a/apps/sim/app/api/tools/outlook/send/route.ts b/apps/sim/app/api/tools/outlook/send/route.ts index f30f47b13b1..cbd9a175786 100644 --- a/apps/sim/app/api/tools/outlook/send/route.ts +++ b/apps/sim/app/api/tools/outlook/send/route.ts @@ -7,6 +7,7 @@ import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils' import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' +import { assertToolFileAccess } from '@/app/api/files/authorization' export const dynamic = 'force-dynamic' @@ -18,7 +19,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { try { const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) - if (!authResult.success) { + if (!authResult.success || !authResult.userId) { logger.warn(`[${requestId}] Unauthorized Outlook send attempt: ${authResult.error}`) return NextResponse.json( { @@ -29,8 +30,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } + const userId = authResult.userId logger.info(`[${requestId}] Authenticated Outlook send request via ${authResult.authType}`, { - userId: authResult.userId, + userId, }) const parsed = await parseRequest(outlookSendContract, request, {}) @@ -98,23 +100,19 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } - const attachmentObjects = await Promise.all( + const accessResults = await Promise.all( + attachments.map((file) => assertToolFileAccess(file.key, userId, requestId, logger)) + ) + const denied = accessResults.find((r) => r !== null) + if (denied) return denied + + const buffers = await Promise.all( attachments.map(async (file) => { try { logger.info( `[${requestId}] Downloading attachment: ${file.name} (${file.size} bytes)` ) - - const buffer = await downloadFileFromStorage(file, requestId, logger) - - const base64Content = buffer.toString('base64') - - return { - '@odata.type': '#microsoft.graph.fileAttachment', - name: file.name, - contentType: file.type || 'application/octet-stream', - contentBytes: base64Content, - } + return await downloadFileFromStorage(file, requestId, logger) } catch (error) { logger.error(`[${requestId}] Failed to download attachment ${file.name}:`, error) throw new Error( @@ -124,6 +122,13 @@ export const POST = withRouteHandler(async (request: NextRequest) => { }) ) + const attachmentObjects = attachments.map((file, i) => ({ + '@odata.type': '#microsoft.graph.fileAttachment', + name: file.name, + contentType: file.type || 'application/octet-stream', + contentBytes: buffers[i].toString('base64'), + })) + logger.info(`[${requestId}] Converted ${attachmentObjects.length} attachments to base64`) message.attachments = attachmentObjects } diff --git a/apps/sim/app/api/tools/quiver/image-to-svg/route.ts b/apps/sim/app/api/tools/quiver/image-to-svg/route.ts index 190c004916b..226e955d848 100644 --- a/apps/sim/app/api/tools/quiver/image-to-svg/route.ts +++ b/apps/sim/app/api/tools/quiver/image-to-svg/route.ts @@ -8,6 +8,7 @@ import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import type { RawFileInput } from '@/lib/uploads/utils/file-schemas' import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils' import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' +import { assertToolFileAccess } from '@/app/api/files/authorization' const logger = createLogger('QuiverImageToSvgAPI') @@ -15,7 +16,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) - if (!authResult.success) { + if (!authResult.success || !authResult.userId) { return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 }) } @@ -47,6 +48,13 @@ export const POST = withRouteHandler(async (request: NextRequest) => { if (parsed && typeof parsed === 'object') { const userFiles = processFilesToUserFiles([parsed as RawFileInput], requestId, logger) if (userFiles.length > 0) { + const denied = await assertToolFileAccess( + userFiles[0].key, + authResult.userId, + requestId, + logger + ) + if (denied) return denied const buffer = await downloadFileFromStorage(userFiles[0], requestId, logger) apiImage = { base64: buffer.toString('base64') } } else { @@ -64,6 +72,13 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } else if (typeof data.image === 'object' && data.image !== null) { const userFiles = processFilesToUserFiles([data.image as RawFileInput], requestId, logger) if (userFiles.length > 0) { + const denied = await assertToolFileAccess( + userFiles[0].key, + authResult.userId, + requestId, + logger + ) + if (denied) return denied const buffer = await downloadFileFromStorage(userFiles[0], requestId, logger) apiImage = { base64: buffer.toString('base64') } } else { diff --git a/apps/sim/app/api/tools/quiver/text-to-svg/route.ts b/apps/sim/app/api/tools/quiver/text-to-svg/route.ts index 12b456e75a3..11441e059d8 100644 --- a/apps/sim/app/api/tools/quiver/text-to-svg/route.ts +++ b/apps/sim/app/api/tools/quiver/text-to-svg/route.ts @@ -8,6 +8,7 @@ import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import type { RawFileInput } from '@/lib/uploads/utils/file-schemas' import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils' import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' +import { assertToolFileAccess } from '@/app/api/files/authorization' const logger = createLogger('QuiverTextToSvgAPI') @@ -15,9 +16,10 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) - if (!authResult.success) { + if (!authResult.success || !authResult.userId) { return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 }) } + const userId = authResult.userId try { const parsed = await parseRequest( @@ -51,6 +53,13 @@ export const POST = withRouteHandler(async (request: NextRequest) => { if (parsed && typeof parsed === 'object') { const userFiles = processFilesToUserFiles([parsed as RawFileInput], requestId, logger) if (userFiles.length > 0) { + const denied = await assertToolFileAccess( + userFiles[0].key, + userId, + requestId, + logger + ) + if (denied) return denied const buffer = await downloadFileFromStorage(userFiles[0], requestId, logger) apiReferences.push({ base64: buffer.toString('base64') }) } @@ -61,6 +70,8 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } else if (typeof ref === 'object' && ref !== null) { const userFiles = processFilesToUserFiles([ref as RawFileInput], requestId, logger) if (userFiles.length > 0) { + const denied = await assertToolFileAccess(userFiles[0].key, userId, requestId, logger) + if (denied) return denied const buffer = await downloadFileFromStorage(userFiles[0], requestId, logger) apiReferences.push({ base64: buffer.toString('base64') }) } diff --git a/apps/sim/app/api/tools/s3/put-object/route.ts b/apps/sim/app/api/tools/s3/put-object/route.ts index be4ee9fa81a..a2798c4fc17 100644 --- a/apps/sim/app/api/tools/s3/put-object/route.ts +++ b/apps/sim/app/api/tools/s3/put-object/route.ts @@ -8,6 +8,7 @@ import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { processSingleFileToUserFile } from '@/lib/uploads/utils/file-utils' import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' +import { assertToolFileAccess } from '@/app/api/files/authorization' export const dynamic = 'force-dynamic' @@ -19,7 +20,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { try { const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) - if (!authResult.success) { + if (!authResult.success || !authResult.userId) { logger.warn(`[${requestId}] Unauthorized S3 put object attempt: ${authResult.error}`) return NextResponse.json( { @@ -76,6 +77,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } + const denied = await assertToolFileAccess(userFile.key, authResult.userId, requestId, logger) + if (denied) return denied + const buffer = await downloadFileFromStorage(userFile, requestId, logger) uploadBody = buffer diff --git a/apps/sim/app/api/tools/sap_concur/upload/route.ts b/apps/sim/app/api/tools/sap_concur/upload/route.ts index 81885d9a29f..74f8fb093de 100644 --- a/apps/sim/app/api/tools/sap_concur/upload/route.ts +++ b/apps/sim/app/api/tools/sap_concur/upload/route.ts @@ -8,6 +8,7 @@ import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { processFilesToUserFiles, type RawFileInput } from '@/lib/uploads/utils/file-utils' import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' +import { assertToolFileAccess } from '@/app/api/files/authorization' import { assertSafeExternalUrl, extractSapConcurError, @@ -180,13 +181,14 @@ export const POST = withRouteHandler(async (request: NextRequest) => { try { const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) - if (!authResult.success) { + if (!authResult.success || !authResult.userId) { logger.warn(`[${requestId}] Unauthorized Concur upload request: ${authResult.error}`) return NextResponse.json( { success: false, error: authResult.error || 'Authentication required' }, { status: 401 } ) } + const userId = authResult.userId // boundary-raw-json: internal upload envelope validated by SapConcurUploadRequestSchema below; not a public boundary const json = await request.json() @@ -204,6 +206,8 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } const userFile = userFiles[0] + const denied = await assertToolFileAccess(userFile.key, userId, requestId, logger) + if (denied) return denied const fileBuffer = await downloadFileFromStorage(userFile, requestId, logger) const fileName = userFile.name const mimeType = inferMimeType(fileName, userFile.type) diff --git a/apps/sim/app/api/tools/sendgrid/send-mail/route.ts b/apps/sim/app/api/tools/sendgrid/send-mail/route.ts index b4a9844bc97..5c4c13952e9 100644 --- a/apps/sim/app/api/tools/sendgrid/send-mail/route.ts +++ b/apps/sim/app/api/tools/sendgrid/send-mail/route.ts @@ -7,6 +7,7 @@ import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils' import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' +import { assertToolFileAccess } from '@/app/api/files/authorization' export const dynamic = 'force-dynamic' @@ -18,7 +19,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { try { const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) - if (!authResult.success) { + if (!authResult.success || !authResult.userId) { logger.warn(`[${requestId}] Unauthorized SendGrid send attempt: ${authResult.error}`) return NextResponse.json( { success: false, error: authResult.error || 'Authentication required' }, @@ -26,6 +27,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } + const userId = authResult.userId logger.info(`[${requestId}] Authenticated SendGrid send request via ${authResult.authType}`) const parsed = await parseRequest(sendGridSendMailContract, request, {}) @@ -97,20 +99,19 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const userFiles = processFilesToUserFiles(rawAttachments, requestId, logger) if (userFiles.length > 0) { - const sendGridAttachments = await Promise.all( + const accessResults = await Promise.all( + userFiles.map((file) => assertToolFileAccess(file.key, userId, requestId, logger)) + ) + const denied = accessResults.find((r) => r !== null) + if (denied) return denied + + const buffers = await Promise.all( userFiles.map(async (file) => { try { logger.info( `[${requestId}] Downloading attachment: ${file.name} (${file.size} bytes)` ) - const buffer = await downloadFileFromStorage(file, requestId, logger) - - return { - content: buffer.toString('base64'), - filename: file.name, - type: file.type || 'application/octet-stream', - disposition: 'attachment', - } + return await downloadFileFromStorage(file, requestId, logger) } catch (error) { logger.error(`[${requestId}] Failed to download attachment ${file.name}:`, error) throw new Error( @@ -120,6 +121,13 @@ export const POST = withRouteHandler(async (request: NextRequest) => { }) ) + const sendGridAttachments = userFiles.map((file, i) => ({ + content: buffers[i].toString('base64'), + filename: file.name, + type: file.type || 'application/octet-stream', + disposition: 'attachment', + })) + mailBody.attachments = sendGridAttachments } } diff --git a/apps/sim/app/api/tools/sftp/upload/route.ts b/apps/sim/app/api/tools/sftp/upload/route.ts index 8acf93ca585..b7198ee4368 100644 --- a/apps/sim/app/api/tools/sftp/upload/route.ts +++ b/apps/sim/app/api/tools/sftp/upload/route.ts @@ -27,7 +27,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { try { const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) - if (!authResult.success) { + if (!authResult.success || !authResult.userId) { logger.warn(`[${requestId}] Unauthorized SFTP upload attempt: ${authResult.error}`) return NextResponse.json( { success: false, error: authResult.error || 'Authentication required' }, diff --git a/apps/sim/app/api/tools/sharepoint/upload/route.ts b/apps/sim/app/api/tools/sharepoint/upload/route.ts index 2229d1ecc6a..af975058eb1 100644 --- a/apps/sim/app/api/tools/sharepoint/upload/route.ts +++ b/apps/sim/app/api/tools/sharepoint/upload/route.ts @@ -24,7 +24,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { try { const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) - if (!authResult.success) { + if (!authResult.success || !authResult.userId) { logger.warn(`[${requestId}] Unauthorized SharePoint upload attempt: ${authResult.error}`) return NextResponse.json( { diff --git a/apps/sim/app/api/tools/slack/send-message/route.ts b/apps/sim/app/api/tools/slack/send-message/route.ts index 2d776a1eb96..7745d22c51f 100644 --- a/apps/sim/app/api/tools/slack/send-message/route.ts +++ b/apps/sim/app/api/tools/slack/send-message/route.ts @@ -5,7 +5,8 @@ import { parseRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { sendSlackMessage } from '../utils' +import { FileAccessDeniedError } from '@/app/api/files/authorization' +import { sendSlackMessage } from '@/app/api/tools/slack/utils' export const dynamic = 'force-dynamic' @@ -17,7 +18,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { try { const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) - if (!authResult.success) { + if (!authResult.success || !authResult.userId) { logger.warn(`[${requestId}] Unauthorized Slack send attempt: ${authResult.error}`) return NextResponse.json( { @@ -28,8 +29,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } + const userId = authResult.userId logger.info(`[${requestId}] Authenticated Slack send request via ${authResult.authType}`, { - userId: authResult.userId, + userId, }) const parsed = await parseRequest(slackSendMessageContract, request, {}) @@ -50,6 +52,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { accessToken: validatedData.accessToken, channel: validatedData.channel ?? undefined, userId: validatedData.userId ?? undefined, + ownerUserId: userId, text: validatedData.text, threadTs: validatedData.thread_ts ?? undefined, blocks: validatedData.blocks ?? undefined, @@ -65,6 +68,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ success: true, output: result.output }) } catch (error) { + if (error instanceof FileAccessDeniedError) { + return NextResponse.json({ success: false, error: 'File not found' }, { status: 404 }) + } logger.error(`[${requestId}] Error sending Slack message:`, error) return NextResponse.json( { diff --git a/apps/sim/app/api/tools/slack/utils.ts b/apps/sim/app/api/tools/slack/utils.ts index aebd295c30a..91b6fc14534 100644 --- a/apps/sim/app/api/tools/slack/utils.ts +++ b/apps/sim/app/api/tools/slack/utils.ts @@ -2,6 +2,7 @@ import type { Logger } from '@sim/logger' import { secureFetchWithValidation } from '@/lib/core/security/input-validation.server' import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils' import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' +import { FileAccessDeniedError, verifyFileAccess } from '@/app/api/files/authorization' import type { ToolFileData } from '@/tools/types' /** @@ -73,7 +74,8 @@ async function uploadFilesToSlack( files: any[], accessToken: string, requestId: string, - logger: Logger + logger: Logger, + ownerUserId: string ): Promise<{ fileIds: string[]; files: ToolFileData[] }> { const userFiles = processFilesToUserFiles(files, requestId, logger) const uploadedFileIds: string[] = [] @@ -82,6 +84,11 @@ async function uploadFilesToSlack( for (const userFile of userFiles) { logger.info(`[${requestId}] Uploading file: ${userFile.name}`) + const hasAccess = await verifyFileAccess(userFile.key, ownerUserId) + if (!hasAccess) { + throw new FileAccessDeniedError() + } + const buffer = await downloadFileFromStorage(userFile, requestId, logger) const getUrlResponse = await fetch('https://slack.com/api/files.getUploadURLExternal', { @@ -224,6 +231,7 @@ export interface SlackMessageParams { accessToken: string channel?: string userId?: string + ownerUserId: string text: string threadTs?: string | null blocks?: unknown[] | null @@ -249,7 +257,7 @@ export async function sendSlackMessage( } error?: string }> { - const { accessToken, text, threadTs, blocks, files } = params + const { accessToken, text, threadTs, blocks, files, ownerUserId } = params let { channel } = params if (!channel && params.userId) { @@ -282,7 +290,8 @@ export async function sendSlackMessage( files, accessToken, requestId, - logger + logger, + ownerUserId ) // No valid files uploaded - send text-only diff --git a/apps/sim/app/api/tools/smtp/send/route.ts b/apps/sim/app/api/tools/smtp/send/route.ts index ea1f5e16d51..499768b407a 100644 --- a/apps/sim/app/api/tools/smtp/send/route.ts +++ b/apps/sim/app/api/tools/smtp/send/route.ts @@ -22,7 +22,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { try { const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) - if (!authResult.success) { + if (!authResult.success || !authResult.userId) { logger.warn(`[${requestId}] Unauthorized SMTP send attempt: ${authResult.error}`) return NextResponse.json( { @@ -33,8 +33,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } + const userId = authResult.userId logger.info(`[${requestId}] Authenticated SMTP request via ${authResult.authType}`, { - userId: authResult.userId, + userId, }) const parsed = await parseRequest(smtpSendContract, request, {}) @@ -120,25 +121,33 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } - const attachmentBuffers: { filename: string; content: Buffer; contentType: string }[] = [] - for (const file of attachments) { - const denied = await assertToolFileAccess(file.key, authResult.userId, requestId, logger) - if (denied) return denied - try { - logger.info(`[${requestId}] Downloading attachment: ${file.name} (${file.size} bytes)`) - const buffer = await downloadFileFromStorage(file, requestId, logger) - attachmentBuffers.push({ - filename: file.name, - content: buffer, - contentType: file.type || 'application/octet-stream', - }) - } catch (error) { - logger.error(`[${requestId}] Failed to download attachment ${file.name}:`, error) - throw new Error( - `Failed to download attachment "${file.name}": ${error instanceof Error ? error.message : 'Unknown error'}` - ) - } - } + const accessResults = await Promise.all( + attachments.map((file) => assertToolFileAccess(file.key, userId, requestId, logger)) + ) + const denied = accessResults.find((r) => r !== null) + if (denied) return denied + + const buffers = await Promise.all( + attachments.map(async (file) => { + try { + logger.info( + `[${requestId}] Downloading attachment: ${file.name} (${file.size} bytes)` + ) + return await downloadFileFromStorage(file, requestId, logger) + } catch (error) { + logger.error(`[${requestId}] Failed to download attachment ${file.name}:`, error) + throw new Error( + `Failed to download attachment "${file.name}": ${error instanceof Error ? error.message : 'Unknown error'}` + ) + } + }) + ) + + const attachmentBuffers = attachments.map((file, i) => ({ + filename: file.name, + content: buffers[i], + contentType: file.type || 'application/octet-stream', + })) logger.info(`[${requestId}] Processed ${attachmentBuffers.length} attachment(s)`) mailOptions.attachments = attachmentBuffers diff --git a/apps/sim/app/api/tools/stt/route.ts b/apps/sim/app/api/tools/stt/route.ts index 3779a6b2982..44152a4ad52 100644 --- a/apps/sim/app/api/tools/stt/route.ts +++ b/apps/sim/app/api/tools/stt/route.ts @@ -17,6 +17,7 @@ import { downloadFileFromStorage, resolveInternalFileUrl, } from '@/lib/uploads/utils/file-utils.server' +import { assertToolFileAccess } from '@/app/api/files/authorization' import type { TranscriptSegment } from '@/tools/stt/types' const logger = createLogger('SttProxyAPI') @@ -31,7 +32,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { try { const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) - if (!authResult.success) { + if (!authResult.success || !authResult.userId) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } @@ -79,6 +80,8 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const file = Array.isArray(body.audioFile) ? body.audioFile[0] : body.audioFile logger.info(`[${requestId}] Processing uploaded file: ${file.name}`) + const deniedAudio = await assertToolFileAccess(file.key, userId, requestId, logger) + if (deniedAudio) return deniedAudio audioBuffer = await downloadFileFromStorage(file, requestId, logger) audioFileName = file.name // file.type may be missing if the file came from a block that doesn't preserve it @@ -97,6 +100,8 @@ export const POST = withRouteHandler(async (request: NextRequest) => { : body.audioFileReference logger.info(`[${requestId}] Processing referenced file: ${file.name}`) + const deniedRef = await assertToolFileAccess(file.key, userId, requestId, logger) + if (deniedRef) return deniedRef audioBuffer = await downloadFileFromStorage(file, requestId, logger) audioFileName = file.name diff --git a/apps/sim/app/api/tools/supabase/storage-upload/route.ts b/apps/sim/app/api/tools/supabase/storage-upload/route.ts index 8e63421e320..3e9a1ee8640 100644 --- a/apps/sim/app/api/tools/supabase/storage-upload/route.ts +++ b/apps/sim/app/api/tools/supabase/storage-upload/route.ts @@ -8,6 +8,7 @@ import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { processSingleFileToUserFile } from '@/lib/uploads/utils/file-utils' import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' +import { assertToolFileAccess } from '@/app/api/files/authorization' export const dynamic = 'force-dynamic' @@ -19,7 +20,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { try { const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) - if (!authResult.success) { + if (!authResult.success || !authResult.userId) { logger.warn( `[${requestId}] Unauthorized Supabase storage upload attempt: ${authResult.error}` ) @@ -143,6 +144,8 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } + const denied = await assertToolFileAccess(userFile.key, authResult.userId, requestId, logger) + if (denied) return denied const buffer = await downloadFileFromStorage(userFile, requestId, logger) uploadBody = buffer diff --git a/apps/sim/app/api/tools/telegram/send-document/route.ts b/apps/sim/app/api/tools/telegram/send-document/route.ts index 9c27e23ae1d..c9c51c7003e 100644 --- a/apps/sim/app/api/tools/telegram/send-document/route.ts +++ b/apps/sim/app/api/tools/telegram/send-document/route.ts @@ -7,6 +7,7 @@ import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils' import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' +import { assertToolFileAccess } from '@/app/api/files/authorization' import { convertMarkdownToHTML } from '@/tools/telegram/utils' export const dynamic = 'force-dynamic' @@ -21,7 +22,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { requireWorkflowId: false, }) - if (!authResult.success) { + if (!authResult.success || !authResult.userId) { logger.warn(`[${requestId}] Unauthorized Telegram send attempt: ${authResult.error}`) return NextResponse.json( { @@ -88,6 +89,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const userFile = userFiles[0] logger.info(`[${requestId}] Uploading document: ${userFile.name}`) + const denied = await assertToolFileAccess(userFile.key, authResult.userId, requestId, logger) + if (denied) return denied + const buffer = await downloadFileFromStorage(userFile, requestId, logger) const filesOutput = [ { diff --git a/apps/sim/app/api/tools/textract/parse/route.ts b/apps/sim/app/api/tools/textract/parse/route.ts index 465cc92603b..209bb01557a 100644 --- a/apps/sim/app/api/tools/textract/parse/route.ts +++ b/apps/sim/app/api/tools/textract/parse/route.ts @@ -18,6 +18,7 @@ import { downloadFileFromStorage, resolveInternalFileUrl, } from '@/lib/uploads/utils/file-utils.server' +import { assertToolFileAccess } from '@/app/api/files/authorization' export const dynamic = 'force-dynamic' export const maxDuration = 300 // 5 minutes for large multi-page PDF processing @@ -428,6 +429,8 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } + const denied = await assertToolFileAccess(userFile.key, userId, requestId, logger) + if (denied) return denied const buffer = await downloadFileFromStorage(userFile, requestId, logger) bytes = buffer.toString('base64') contentType = userFile.type || 'application/octet-stream' diff --git a/apps/sim/app/api/tools/video/route.ts b/apps/sim/app/api/tools/video/route.ts index ae1021f0fa9..f5f7969ff29 100644 --- a/apps/sim/app/api/tools/video/route.ts +++ b/apps/sim/app/api/tools/video/route.ts @@ -8,6 +8,7 @@ import { checkInternalAuth } from '@/lib/auth/hybrid' import { getMaxExecutionTimeout } from '@/lib/core/execution-limits' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' +import { assertToolFileAccess } from '@/app/api/files/authorization' import type { UserFile } from '@/executor/types' const logger = createLogger('VideoProxyAPI') @@ -21,7 +22,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { try { const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) - if (!authResult.success) { + if (!authResult.success || !authResult.userId) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } @@ -100,6 +101,16 @@ export const POST = withRouteHandler(async (request: NextRequest) => { let jobId: string | undefined let actualDuration: number | undefined + if (body.visualReference) { + const denied = await assertToolFileAccess( + body.visualReference.key, + authResult.userId, + requestId, + logger + ) + if (denied) return denied + } + try { if (provider === 'runway') { const result = await generateWithRunway( diff --git a/apps/sim/app/api/tools/vision/analyze/route.ts b/apps/sim/app/api/tools/vision/analyze/route.ts index 9b8183d413b..4779dcb86fa 100644 --- a/apps/sim/app/api/tools/vision/analyze/route.ts +++ b/apps/sim/app/api/tools/vision/analyze/route.ts @@ -15,6 +15,7 @@ import { downloadFileFromStorage, resolveInternalFileUrl, } from '@/lib/uploads/utils/file-utils.server' +import { assertToolFileAccess } from '@/app/api/files/authorization' import { convertUsageMetadata, extractTextContent } from '@/providers/google/utils' export const dynamic = 'force-dynamic' @@ -27,7 +28,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { try { const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) - if (!authResult.success) { + if (!authResult.success || !authResult.userId) { logger.warn(`[${requestId}] Unauthorized Vision analyze attempt: ${authResult.error}`) return NextResponse.json( { @@ -87,6 +88,13 @@ export const POST = withRouteHandler(async (request: NextRequest) => { let base64 = userFile.base64 let bufferLength = 0 if (!base64) { + const denied = await assertToolFileAccess( + userFile.key, + authResult.userId, + requestId, + logger + ) + if (denied) return denied const buffer = await downloadFileFromStorage(userFile, requestId, logger) base64 = buffer.toString('base64') bufferLength = buffer.length diff --git a/apps/sim/app/api/tools/wordpress/upload/route.ts b/apps/sim/app/api/tools/wordpress/upload/route.ts index 859aef52f54..5b9a1cf9383 100644 --- a/apps/sim/app/api/tools/wordpress/upload/route.ts +++ b/apps/sim/app/api/tools/wordpress/upload/route.ts @@ -25,7 +25,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { try { const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) - if (!authResult.success) { + if (!authResult.success || !authResult.userId) { logger.warn(`[${requestId}] Unauthorized WordPress upload attempt: ${authResult.error}`) return NextResponse.json( { diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-options-bar/resource-options-bar.tsx b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-options-bar/resource-options-bar.tsx index 64c364677d7..fc3442c124f 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-options-bar/resource-options-bar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-options-bar/resource-options-bar.tsx @@ -98,7 +98,7 @@ export const ResourceOptionsBar = memo(function ResourceOptionsBar({ - - -

{displayName}

-
- + + + + + )} + {showGapAfter && (
)} diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/logs-toolbar/logs-toolbar.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/logs-toolbar/logs-toolbar.tsx index da0a9dc3cd2..c770e2b26bf 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/logs-toolbar/logs-toolbar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/logs-toolbar/logs-toolbar.tsx @@ -21,7 +21,11 @@ import { hasActiveFilters } from '@/lib/logs/filters' import { getTriggerOptions } from '@/lib/logs/get-trigger-options' import { captureEvent } from '@/lib/posthog/client' import { workflowBorderColor } from '@/lib/workspaces/colors' -import { type LogStatus, STATUS_CONFIG } from '@/app/workspace/[workspaceId]/logs/utils' +import { + formatDateShort, + type LogStatus, + STATUS_CONFIG, +} from '@/app/workspace/[workspaceId]/logs/utils' import { getBlock } from '@/blocks/registry' import { useFolderMap } from '@/hooks/queries/folders' import { useWorkflows } from '@/hooks/queries/workflows' @@ -43,28 +47,6 @@ const TIME_RANGE_OPTIONS: ComboboxOption[] = [ { value: 'Custom range', label: 'Custom range' }, ] as const -/** - * Formats a date string (YYYY-MM-DD) for display. - */ -function formatDateShort(dateStr: string): string { - const date = new Date(dateStr) - const months = [ - 'Jan', - 'Feb', - 'Mar', - 'Apr', - 'May', - 'Jun', - 'Jul', - 'Aug', - 'Sep', - 'Oct', - 'Nov', - 'Dec', - ] - return `${months[date.getMonth()]} ${date.getDate()}` -} - type ViewMode = 'logs' | 'dashboard' interface LogsToolbarProps { @@ -794,11 +776,13 @@ export const LogsToolbar = memo(function LogsToolbar({ } size='sm' align='end' - className='h-[32px] w-[120px] rounded-md' + className='h-[32px] w-[160px] rounded-md' + maxHeight={320} /> { if (!isOpen) { diff --git a/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx b/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx index 7aa4ad2322e..7ddf9eec8f2 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx @@ -91,6 +91,7 @@ import { DELETED_WORKFLOW_LABEL, extractRetryInput, formatDate, + formatDateShort, getDisplayStatus, type LogStatus, parseDuration, @@ -205,25 +206,6 @@ function SpinningRefreshCw(props: React.SVGProps) { return } -function formatDateShort(dateStr: string): string { - const date = new Date(dateStr) - const months = [ - 'Jan', - 'Feb', - 'Mar', - 'Apr', - 'May', - 'Jun', - 'Jul', - 'Aug', - 'Sep', - 'Oct', - 'Nov', - 'Dec', - ] - return `${months[date.getMonth()]} ${date.getDate()}` -} - /** * Logs page component displaying workflow execution history. * Supports filtering, search, live updates, and detailed log inspection. @@ -866,7 +848,7 @@ export default function Logs() { tags.push({ label: timeRange === 'Custom range' && startDate && endDate - ? `${startDate} – ${endDate}` + ? `${formatDateShort(startDate)} – ${formatDateShort(endDate)}` : timeRange, onRemove: () => { clearDateRange() @@ -1519,10 +1501,12 @@ function LogsFilterPanel({ searchQuery, onSearchQueryChange }: LogsFilterPanelPr } size='sm' className='h-[32px] w-full rounded-md' + maxHeight={320} /> { if (!isOpen) { diff --git a/apps/sim/app/workspace/[workspaceId]/logs/utils.ts b/apps/sim/app/workspace/[workspaceId]/logs/utils.ts index 8fa8f4624a6..a8a847f4d86 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/utils.ts +++ b/apps/sim/app/workspace/[workspaceId]/logs/utils.ts @@ -179,6 +179,31 @@ export function formatLatency(ms: number): string { return formatDuration(ms, { precision: 2 }) ?? '—' } +export function formatDateShort(dateStr: string): string { + const hasTime = dateStr.includes('T') + const [datePart, timePart] = dateStr.split('T') + const [, month, day] = datePart.split('-').map(Number) + const months = [ + 'Jan', + 'Feb', + 'Mar', + 'Apr', + 'May', + 'Jun', + 'Jul', + 'Aug', + 'Sep', + 'Oct', + 'Nov', + 'Dec', + ] + const dateLabel = `${months[month - 1]} ${day}` + if (hasTime && timePart) { + return `${dateLabel} ${timePart.slice(0, 5)}` + } + return dateLabel +} + export const formatDate = (dateString: string) => { const date = new Date(dateString) return { diff --git a/apps/sim/components/emcn/components/date-picker/date-picker.tsx b/apps/sim/components/emcn/components/date-picker/date-picker.tsx index ca6ac6c0de3..b4c3a847d74 100644 --- a/apps/sim/components/emcn/components/date-picker/date-picker.tsx +++ b/apps/sim/components/emcn/components/date-picker/date-picker.tsx @@ -33,6 +33,7 @@ import { PopoverAnchor, PopoverContent, } from '@/components/emcn/components/popover/popover' +import { TimePicker } from '@/components/emcn/components/time-picker/time-picker' import { cn } from '@/lib/core/utils/cn' /** @@ -96,22 +97,26 @@ interface DatePickerSingleProps extends DatePickerBaseProps { onCancel?: never /** Not used in single mode */ onClear?: never + /** Not used in single mode */ + showTime?: never } /** Props for range date mode */ interface DatePickerRangeProps extends DatePickerBaseProps { /** Selection mode */ mode: 'range' - /** Start date for range mode (YYYY-MM-DD string or Date) */ + /** Start date for range mode (YYYY-MM-DD or YYYY-MM-DDTHH:mm string or Date) */ startDate?: string | Date - /** End date for range mode (YYYY-MM-DD string or Date) */ + /** End date for range mode (YYYY-MM-DD or YYYY-MM-DDTHH:mm string or Date) */ endDate?: string | Date - /** Callback when date range is applied */ + /** Callback when date range is applied — returns YYYY-MM-DD or YYYY-MM-DDTHH:mm depending on showTime */ onRangeChange?: (startDate: string, endDate: string) => void /** Callback when range selection is cancelled */ onCancel?: () => void /** Callback when range is cleared */ onClear?: () => void + /** Whether to show time inputs for precise range selection */ + showTime?: boolean /** Not used in range mode */ value?: never /** Not used in range mode */ @@ -503,6 +508,7 @@ const DatePicker = React.forwardRef((props, ref onRangeChange: _onRangeChange, onCancel: _onCancel, onClear: _onClear, + showTime = false, ...htmlProps } = rest as any @@ -530,6 +536,8 @@ const DatePicker = React.forwardRef((props, ref const [rangeEnd, setRangeEnd] = React.useState(initialEnd) const [hoverDate, setHoverDate] = React.useState(null) const [selectingEnd, setSelectingEnd] = React.useState(false) + const [startTime, setStartTime] = React.useState('00:00') + const [endTime, setEndTime] = React.useState('23:59') const [viewMonth, setViewMonth] = React.useState(() => { const d = selectedDate || initialStart || new Date() @@ -548,6 +556,12 @@ const DatePicker = React.forwardRef((props, ref setRangeStart(initialStart) setRangeEnd(initialEnd) setSelectingEnd(false) + if (showTime) { + const sd = isRangeMode ? props.startDate : undefined + const ed = isRangeMode ? props.endDate : undefined + setStartTime(typeof sd === 'string' && sd.includes('T') ? sd.slice(11, 16) : '00:00') + setEndTime(typeof ed === 'string' && ed.includes('T') ? ed.slice(11, 16) : '23:59') + } if (initialStart) { setViewMonth(initialStart.getMonth()) setViewYear(initialStart.getFullYear()) @@ -557,7 +571,7 @@ const DatePicker = React.forwardRef((props, ref setViewYear(now.getFullYear()) } } - }, [open, isRangeMode, initialStart, initialEnd]) + }, [open, isRangeMode, initialStart, initialEnd, showTime, props.startDate, props.endDate]) const singleValueKey = !isRangeMode && selectedDate ? selectedDate.getTime() : undefined const [prevSingleValueKey, setPrevSingleValueKey] = React.useState(singleValueKey) @@ -661,13 +675,32 @@ const DatePicker = React.forwardRef((props, ref if (isRangeMode && props.onRangeChange && rangeStart) { const start = rangeEnd && rangeEnd < rangeStart ? rangeEnd : rangeStart const end = rangeEnd && rangeEnd < rangeStart ? rangeStart : rangeEnd || rangeStart + const startStr = formatDateAsString(start.getFullYear(), start.getMonth(), start.getDate()) + const endStr = formatDateAsString(end.getFullYear(), end.getMonth(), end.getDate()) + + let effectiveStartTime = startTime + let effectiveEndTime = endTime + if (showTime && startStr === endStr && startTime > endTime) { + effectiveStartTime = endTime + effectiveEndTime = startTime + } + props.onRangeChange( - formatDateAsString(start.getFullYear(), start.getMonth(), start.getDate()), - formatDateAsString(end.getFullYear(), end.getMonth(), end.getDate()) + showTime ? `${startStr}T${effectiveStartTime}` : startStr, + showTime ? `${endStr}T${effectiveEndTime}:59` : endStr ) setOpen(false) } - }, [isRangeMode, props.onRangeChange, rangeStart, rangeEnd, setOpen]) + }, [ + isRangeMode, + props.onRangeChange, + rangeStart, + rangeEnd, + showTime, + startTime, + endTime, + setOpen, + ]) /** * Cancels range selection. @@ -754,6 +787,21 @@ const DatePicker = React.forwardRef((props, ref />
+ {/* Time inputs */} + {showTime && ( +
+
+ Start + +
+
+
+ End + +
+
+ )} + {/* Actions */}