From c2b7656640bc3c1d5376aec9f9f6433c95ef656c Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 28 Jul 2026 12:01:11 -0700 Subject: [PATCH 1/6] fix(api): give every v1 endpoint quota headers and errors that name the field MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three consistency gaps found by probing the live v1 surface end to end. Rate-limit headers were only published by routes built on createApiResponse — workflows, logs and audit-logs. Tables, files and knowledge are rate limited by the same bucket and will return 429, but published no quota on success, so a client discovered the ceiling only by hitting it. Adds a shared rateLimitHeaders() builder, reused by createRateLimitResponse, and attaches it to all 31 success responses on those three families. Missing required fields did not name themselves. .min(1, '...') only fires for a present-but-empty string, so an omitted field fell through to Zod's default "Invalid input: expected string, received undefined". A previous pass fixed the shared id schemas, but 22 of 23 v1 workspaceId declarations bypassed them, so the fix reached almost nothing. Adds requiredFieldSchema(message) and routes every v1 request input through it, preserving each site's existing, more specific wording (for example "workspaceId query parameter is required") instead of flattening them to the generic one. Response schemas are left alone — "required" wording would be wrong there. Validation failures on tables, files and knowledge reported the literal "Validation error" and discarded the schema's message. Adds v1ValidationErrorResponse, which surfaces the first issue while keeping details, and wires it into the 19 parseRequest calls that had no handler. Routes with deliberately specific wording keep theirs. The global default is untouched, since routes outside v1 assert the current string. --- .../app/api/v1/files/[fileId]/route.test.ts | 3 + apps/sim/app/api/v1/files/[fileId]/route.ts | 23 ++- apps/sim/app/api/v1/files/route.ts | 73 ++++--- .../[id]/documents/[documentId]/route.ts | 74 ++++--- .../v1/knowledge/[id]/documents/route.test.ts | 3 + .../api/v1/knowledge/[id]/documents/route.ts | 90 +++++---- apps/sim/app/api/v1/knowledge/[id]/route.ts | 59 ++++-- apps/sim/app/api/v1/knowledge/route.ts | 55 ++++-- .../app/api/v1/knowledge/search/route.test.ts | 3 + apps/sim/app/api/v1/knowledge/search/route.ts | 77 +++++--- apps/sim/app/api/v1/middleware.test.ts | 61 +++++- apps/sim/app/api/v1/middleware.ts | 47 ++++- .../api/v1/tables/[tableId]/columns/route.ts | 53 ++++-- apps/sim/app/api/v1/tables/[tableId]/route.ts | 59 +++--- .../v1/tables/[tableId]/rows/[rowId]/route.ts | 80 ++++---- .../app/api/v1/tables/[tableId]/rows/route.ts | 180 ++++++++++-------- .../v1/tables/[tableId]/rows/upsert/route.ts | 43 +++-- apps/sim/app/api/v1/tables/route.ts | 116 ++++++----- apps/sim/lib/api/contracts/primitives.ts | 33 ++-- apps/sim/lib/api/contracts/v1/files.ts | 5 +- .../lib/api/contracts/v1/knowledge/index.ts | 13 +- apps/sim/lib/api/contracts/v1/logs.ts | 4 +- apps/sim/lib/api/contracts/v1/tables/index.ts | 5 +- apps/sim/lib/api/contracts/v1/workflows.ts | 2 +- 24 files changed, 740 insertions(+), 421 deletions(-) diff --git a/apps/sim/app/api/v1/files/[fileId]/route.test.ts b/apps/sim/app/api/v1/files/[fileId]/route.test.ts index 9a85c408bb1..f682a2173a9 100644 --- a/apps/sim/app/api/v1/files/[fileId]/route.test.ts +++ b/apps/sim/app/api/v1/files/[fileId]/route.test.ts @@ -20,6 +20,9 @@ vi.mock('@/app/api/v1/middleware', () => ({ checkRateLimit: mockCheckRateLimit, createRateLimitResponse: () => new Response('rate limited', { status: 429 }), validateWorkspaceAccess: mockValidateWorkspaceAccess, + rateLimitHeaders: () => ({}), + v1ValidationErrorResponse: (e: { issues: unknown[] }) => + NextResponse.json({ error: 'Validation error', details: e.issues }, { status: 400 }), })) vi.mock('@/lib/uploads/contexts/workspace', () => ({ getWorkspaceFile: mockGetWorkspaceFile, diff --git a/apps/sim/app/api/v1/files/[fileId]/route.ts b/apps/sim/app/api/v1/files/[fileId]/route.ts index ee5c13c3509..d64b5799920 100644 --- a/apps/sim/app/api/v1/files/[fileId]/route.ts +++ b/apps/sim/app/api/v1/files/[fileId]/route.ts @@ -15,6 +15,8 @@ import { performDeleteWorkspaceFileItems } from '@/lib/workspace-files/orchestra import { checkRateLimit, createRateLimitResponse, + rateLimitHeaders, + v1ValidationErrorResponse, validateWorkspaceAccess, } from '@/app/api/v1/middleware' @@ -38,7 +40,9 @@ export const GET = withRouteHandler(async (request: NextRequest, context: FileRo } const userId = rateLimit.userId! - const parsed = await parseRequest(v1DownloadFileContract, request, context) + const parsed = await parseRequest(v1DownloadFileContract, request, context, { + validationErrorResponse: v1ValidationErrorResponse, + }) if (!parsed.success) return parsed.response const { fileId } = parsed.data.params @@ -119,7 +123,9 @@ export const DELETE = withRouteHandler(async (request: NextRequest, context: Fil } const userId = rateLimit.userId! - const parsed = await parseRequest(v1DeleteFileContract, request, context) + const parsed = await parseRequest(v1DeleteFileContract, request, context, { + validationErrorResponse: v1ValidationErrorResponse, + }) if (!parsed.success) return parsed.response const { fileId } = parsed.data.params @@ -146,12 +152,15 @@ export const DELETE = withRouteHandler(async (request: NextRequest, context: Fil `[${requestId}] Archived file: ${fileRecord.name} (${fileId}) from workspace ${workspaceId}` ) - return NextResponse.json({ - success: true, - data: { - message: 'File archived successfully', + return NextResponse.json( + { + success: true, + data: { + message: 'File archived successfully', + }, }, - }) + { headers: rateLimitHeaders(rateLimit) } + ) } catch (error) { logger.error(`[${requestId}] Error deleting file:`, error) return NextResponse.json({ error: 'Failed to delete file' }, { status: 500 }) diff --git a/apps/sim/app/api/v1/files/route.ts b/apps/sim/app/api/v1/files/route.ts index 350a2080c72..6fc830eeefe 100644 --- a/apps/sim/app/api/v1/files/route.ts +++ b/apps/sim/app/api/v1/files/route.ts @@ -22,6 +22,8 @@ import { checkRateLimit, checkWorkspaceScope, createRateLimitResponse, + rateLimitHeaders, + v1ValidationErrorResponse, validateWorkspaceAccess, } from '@/app/api/v1/middleware' @@ -43,7 +45,14 @@ export const GET = withRouteHandler(async (request: NextRequest) => { } const userId = rateLimit.userId! - const parsed = await parseRequest(v1ListFilesContract, request, {}) + const parsed = await parseRequest( + v1ListFilesContract, + request, + {}, + { + validationErrorResponse: v1ValidationErrorResponse, + } + ) if (!parsed.success) return parsed.response const { workspaceId } = parsed.data.query @@ -53,22 +62,25 @@ export const GET = withRouteHandler(async (request: NextRequest) => { const files = await listWorkspaceFiles(workspaceId) - return NextResponse.json({ - success: true, - data: { - files: files.map((f) => ({ - id: f.id, - name: f.name, - size: f.size, - type: f.type, - key: f.key, - uploadedBy: f.uploadedBy, - uploadedAt: - f.uploadedAt instanceof Date ? f.uploadedAt.toISOString() : String(f.uploadedAt), - })), - totalCount: files.length, + return NextResponse.json( + { + success: true, + data: { + files: files.map((f) => ({ + id: f.id, + name: f.name, + size: f.size, + type: f.type, + key: f.key, + uploadedBy: f.uploadedBy, + uploadedAt: + f.uploadedAt instanceof Date ? f.uploadedAt.toISOString() : String(f.uploadedAt), + })), + totalCount: files.length, + }, }, - }) + { headers: rateLimitHeaders(rateLimit) } + ) } catch (error) { logger.error(`[${requestId}] Error listing files:`, error) return NextResponse.json({ error: 'Failed to list files' }, { status: 500 }) @@ -171,21 +183,24 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ? String(fileRecord.uploadedAt) : new Date().toISOString() - return NextResponse.json({ - success: true, - data: { - file: { - id: userFile.id, - name: userFile.name, - size: userFile.size, - type: userFile.type, - key: userFile.key, - uploadedBy: userId, - uploadedAt, + return NextResponse.json( + { + success: true, + data: { + file: { + id: userFile.id, + name: userFile.name, + size: userFile.size, + type: userFile.type, + key: userFile.key, + uploadedBy: userId, + uploadedAt, + }, + message: 'File uploaded successfully', }, - message: 'File uploaded successfully', }, - }) + { headers: rateLimitHeaders(rateLimit) } + ) } catch (error) { if (isPayloadSizeLimitError(error)) { return NextResponse.json({ error: error.message }, { status: 413 }) diff --git a/apps/sim/app/api/v1/knowledge/[id]/documents/[documentId]/route.ts b/apps/sim/app/api/v1/knowledge/[id]/documents/[documentId]/route.ts index ae67288f2ab..3c35c85c9fa 100644 --- a/apps/sim/app/api/v1/knowledge/[id]/documents/[documentId]/route.ts +++ b/apps/sim/app/api/v1/knowledge/[id]/documents/[documentId]/route.ts @@ -11,7 +11,11 @@ import { parseRequest } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { deleteDocument } from '@/lib/knowledge/documents/service' import { handleError, resolveKnowledgeBase, serializeDate } from '@/app/api/v1/knowledge/utils' -import { authenticateRequest } from '@/app/api/v1/middleware' +import { + authenticateRequest, + rateLimitHeaders, + v1ValidationErrorResponse, +} from '@/app/api/v1/middleware' export const dynamic = 'force-dynamic' export const revalidate = 0 @@ -28,7 +32,9 @@ export const GET = withRouteHandler( const { requestId, userId, rateLimit } = auth try { - const parsed = await parseRequest(v1GetKnowledgeDocumentContract, request, context) + const parsed = await parseRequest(v1GetKnowledgeDocumentContract, request, context, { + validationErrorResponse: v1ValidationErrorResponse, + }) if (!parsed.success) return parsed.response const { id: knowledgeBaseId, documentId } = parsed.data.params @@ -79,30 +85,33 @@ export const GET = withRouteHandler( const doc = docs[0] - return NextResponse.json({ - success: true, - data: { - document: { - id: doc.id, - knowledgeBaseId: doc.knowledgeBaseId, - filename: doc.filename, - fileSize: doc.fileSize, - mimeType: doc.mimeType, - processingStatus: doc.processingStatus, - processingError: doc.processingError, - processingStartedAt: serializeDate(doc.processingStartedAt), - processingCompletedAt: serializeDate(doc.processingCompletedAt), - chunkCount: doc.chunkCount, - tokenCount: doc.tokenCount, - characterCount: doc.characterCount, - enabled: doc.enabled, - connectorId: doc.connectorId, - connectorType: doc.connectorType, - sourceUrl: doc.sourceUrl, - createdAt: serializeDate(doc.uploadedAt), + return NextResponse.json( + { + success: true, + data: { + document: { + id: doc.id, + knowledgeBaseId: doc.knowledgeBaseId, + filename: doc.filename, + fileSize: doc.fileSize, + mimeType: doc.mimeType, + processingStatus: doc.processingStatus, + processingError: doc.processingError, + processingStartedAt: serializeDate(doc.processingStartedAt), + processingCompletedAt: serializeDate(doc.processingCompletedAt), + chunkCount: doc.chunkCount, + tokenCount: doc.tokenCount, + characterCount: doc.characterCount, + enabled: doc.enabled, + connectorId: doc.connectorId, + connectorType: doc.connectorType, + sourceUrl: doc.sourceUrl, + createdAt: serializeDate(doc.uploadedAt), + }, }, }, - }) + { headers: rateLimitHeaders(rateLimit) } + ) } catch (error) { return handleError(requestId, error, 'Failed to get document') } @@ -117,7 +126,9 @@ export const DELETE = withRouteHandler( const { requestId, userId, rateLimit } = auth try { - const parsed = await parseRequest(v1DeleteKnowledgeDocumentContract, request, context) + const parsed = await parseRequest(v1DeleteKnowledgeDocumentContract, request, context, { + validationErrorResponse: v1ValidationErrorResponse, + }) if (!parsed.success) return parsed.response const { id: knowledgeBaseId, documentId } = parsed.data.params @@ -162,12 +173,15 @@ export const DELETE = withRouteHandler( request, }) - return NextResponse.json({ - success: true, - data: { - message: 'Document deleted successfully', + return NextResponse.json( + { + success: true, + data: { + message: 'Document deleted successfully', + }, }, - }) + { headers: rateLimitHeaders(rateLimit) } + ) } catch (error) { return handleError(requestId, error, 'Failed to delete document') } diff --git a/apps/sim/app/api/v1/knowledge/[id]/documents/route.test.ts b/apps/sim/app/api/v1/knowledge/[id]/documents/route.test.ts index ab80d99c890..e2d94afd6e1 100644 --- a/apps/sim/app/api/v1/knowledge/[id]/documents/route.test.ts +++ b/apps/sim/app/api/v1/knowledge/[id]/documents/route.test.ts @@ -46,6 +46,9 @@ const SYSTEM_BILLING_ATTRIBUTION = { vi.mock('@/app/api/v1/middleware', () => ({ authenticateRequest: mockAuthenticateRequest, + rateLimitHeaders: () => ({}), + v1ValidationErrorResponse: (e: { issues: unknown[] }) => + NextResponse.json({ error: 'Validation error', details: e.issues }, { status: 400 }), })) vi.mock('@/app/api/v1/knowledge/utils', () => ({ diff --git a/apps/sim/app/api/v1/knowledge/[id]/documents/route.ts b/apps/sim/app/api/v1/knowledge/[id]/documents/route.ts index 0b1c096ad96..6c7f2485c75 100644 --- a/apps/sim/app/api/v1/knowledge/[id]/documents/route.ts +++ b/apps/sim/app/api/v1/knowledge/[id]/documents/route.ts @@ -26,7 +26,11 @@ import type { DocumentSortField, SortOrder } from '@/lib/knowledge/documents/typ import { uploadWorkspaceFile } from '@/lib/uploads/contexts/workspace' import { validateFileType } from '@/lib/uploads/utils/validation' import { handleError, resolveKnowledgeBase, serializeDate } from '@/app/api/v1/knowledge/utils' -import { authenticateRequest } from '@/app/api/v1/middleware' +import { + authenticateRequest, + rateLimitHeaders, + v1ValidationErrorResponse, +} from '@/app/api/v1/middleware' export const dynamic = 'force-dynamic' export const revalidate = 0 @@ -44,7 +48,9 @@ export const GET = withRouteHandler(async (request: NextRequest, context: Docume const { requestId, userId, rateLimit } = auth try { - const parsed = await parseRequest(v1ListKnowledgeDocumentsContract, request, context) + const parsed = await parseRequest(v1ListKnowledgeDocumentsContract, request, context, { + validationErrorResponse: v1ValidationErrorResponse, + }) if (!parsed.success) return parsed.response const { workspaceId, limit, offset, search, enabledFilter, sortBy, sortOrder } = @@ -67,25 +73,28 @@ export const GET = withRouteHandler(async (request: NextRequest, context: Docume requestId ) - return NextResponse.json({ - success: true, - data: { - documents: documentsResult.documents.map((doc) => ({ - id: doc.id, - knowledgeBaseId, - filename: doc.filename, - fileSize: doc.fileSize, - mimeType: doc.mimeType, - processingStatus: doc.processingStatus, - chunkCount: doc.chunkCount, - tokenCount: doc.tokenCount, - characterCount: doc.characterCount, - enabled: doc.enabled, - createdAt: serializeDate(doc.uploadedAt), - })), - pagination: documentsResult.pagination, + return NextResponse.json( + { + success: true, + data: { + documents: documentsResult.documents.map((doc) => ({ + id: doc.id, + knowledgeBaseId, + filename: doc.filename, + fileSize: doc.fileSize, + mimeType: doc.mimeType, + processingStatus: doc.processingStatus, + chunkCount: doc.chunkCount, + tokenCount: doc.tokenCount, + characterCount: doc.characterCount, + enabled: doc.enabled, + createdAt: serializeDate(doc.uploadedAt), + })), + pagination: documentsResult.pagination, + }, }, - }) + { headers: rateLimitHeaders(rateLimit) } + ) } catch (error) { return handleError(requestId, error, 'Failed to list documents') } @@ -99,7 +108,9 @@ export const POST = withRouteHandler( const { requestId, userId, rateLimit } = auth try { - const parsed = await parseRequest(v1UploadKnowledgeDocumentContract, request, context) + const parsed = await parseRequest(v1UploadKnowledgeDocumentContract, request, context, { + validationErrorResponse: v1ValidationErrorResponse, + }) if (!parsed.success) return parsed.response const { id: knowledgeBaseId } = parsed.data.params @@ -227,25 +238,28 @@ export const POST = withRouteHandler( request, }) - return NextResponse.json({ - success: true, - data: { - document: { - id: newDocument.id, - knowledgeBaseId, - filename: newDocument.filename, - fileSize: newDocument.fileSize, - mimeType: newDocument.mimeType, - processingStatus: 'pending', - chunkCount: 0, - tokenCount: 0, - characterCount: 0, - enabled: newDocument.enabled, - createdAt: serializeDate(newDocument.uploadedAt), + return NextResponse.json( + { + success: true, + data: { + document: { + id: newDocument.id, + knowledgeBaseId, + filename: newDocument.filename, + fileSize: newDocument.fileSize, + mimeType: newDocument.mimeType, + processingStatus: 'pending', + chunkCount: 0, + tokenCount: 0, + characterCount: 0, + enabled: newDocument.enabled, + createdAt: serializeDate(newDocument.uploadedAt), + }, + message: 'Document uploaded successfully. Processing will begin shortly.', }, - message: 'Document uploaded successfully. Processing will begin shortly.', }, - }) + { headers: rateLimitHeaders(rateLimit) } + ) } catch (error) { return handleError(requestId, error, 'Failed to upload document') } diff --git a/apps/sim/app/api/v1/knowledge/[id]/route.ts b/apps/sim/app/api/v1/knowledge/[id]/route.ts index b47241f0199..95d2e58e24c 100644 --- a/apps/sim/app/api/v1/knowledge/[id]/route.ts +++ b/apps/sim/app/api/v1/knowledge/[id]/route.ts @@ -13,7 +13,11 @@ import { handleError, resolveKnowledgeBase, } from '@/app/api/v1/knowledge/utils' -import { authenticateRequest } from '@/app/api/v1/middleware' +import { + authenticateRequest, + rateLimitHeaders, + v1ValidationErrorResponse, +} from '@/app/api/v1/middleware' export const dynamic = 'force-dynamic' export const revalidate = 0 @@ -29,19 +33,24 @@ export const GET = withRouteHandler(async (request: NextRequest, context: Knowle const { requestId, userId, rateLimit } = auth try { - const parsed = await parseRequest(v1GetKnowledgeBaseContract, request, context) + const parsed = await parseRequest(v1GetKnowledgeBaseContract, request, context, { + validationErrorResponse: v1ValidationErrorResponse, + }) if (!parsed.success) return parsed.response const { id } = parsed.data.params const result = await resolveKnowledgeBase(id, parsed.data.query.workspaceId, userId, rateLimit) if (result instanceof NextResponse) return result - return NextResponse.json({ - success: true, - data: { - knowledgeBase: formatKnowledgeBase(result.kb), + return NextResponse.json( + { + success: true, + data: { + knowledgeBase: formatKnowledgeBase(result.kb), + }, }, - }) + { headers: rateLimitHeaders(rateLimit) } + ) } catch (error) { return handleError(requestId, error, 'Failed to get knowledge base') } @@ -54,7 +63,9 @@ export const PUT = withRouteHandler(async (request: NextRequest, context: Knowle const { requestId, userId, rateLimit } = auth try { - const parsed = await parseRequest(v1UpdateKnowledgeBaseContract, request, context) + const parsed = await parseRequest(v1UpdateKnowledgeBaseContract, request, context, { + validationErrorResponse: v1ValidationErrorResponse, + }) if (!parsed.success) return parsed.response const { id } = parsed.data.params @@ -86,13 +97,16 @@ export const PUT = withRouteHandler(async (request: NextRequest, context: Knowle request, }) - return NextResponse.json({ - success: true, - data: { - knowledgeBase: formatKnowledgeBase(updatedKb), - message: 'Knowledge base updated successfully', + return NextResponse.json( + { + success: true, + data: { + knowledgeBase: formatKnowledgeBase(updatedKb), + message: 'Knowledge base updated successfully', + }, }, - }) + { headers: rateLimitHeaders(rateLimit) } + ) } catch (error) { return handleError(requestId, error, 'Failed to update knowledge base') } @@ -106,7 +120,9 @@ export const DELETE = withRouteHandler( const { requestId, userId, rateLimit } = auth try { - const parsed = await parseRequest(v1DeleteKnowledgeBaseContract, request, context) + const parsed = await parseRequest(v1DeleteKnowledgeBaseContract, request, context, { + validationErrorResponse: v1ValidationErrorResponse, + }) if (!parsed.success) return parsed.response const { id } = parsed.data.params @@ -132,12 +148,15 @@ export const DELETE = withRouteHandler( request, }) - return NextResponse.json({ - success: true, - data: { - message: 'Knowledge base deleted successfully', + return NextResponse.json( + { + success: true, + data: { + message: 'Knowledge base deleted successfully', + }, }, - }) + { headers: rateLimitHeaders(rateLimit) } + ) } catch (error) { return handleError(requestId, error, 'Failed to delete knowledge base') } diff --git a/apps/sim/app/api/v1/knowledge/route.ts b/apps/sim/app/api/v1/knowledge/route.ts index 04e9d5f5800..d4ed1b77de8 100644 --- a/apps/sim/app/api/v1/knowledge/route.ts +++ b/apps/sim/app/api/v1/knowledge/route.ts @@ -9,7 +9,12 @@ import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { EMBEDDING_DIMENSIONS, getConfiguredEmbeddingModel } from '@/lib/knowledge/embeddings' import { createKnowledgeBase, getKnowledgeBases } from '@/lib/knowledge/service' import { formatKnowledgeBase, handleError } from '@/app/api/v1/knowledge/utils' -import { authenticateRequest, validateWorkspaceAccess } from '@/app/api/v1/middleware' +import { + authenticateRequest, + rateLimitHeaders, + v1ValidationErrorResponse, + validateWorkspaceAccess, +} from '@/app/api/v1/middleware' export const dynamic = 'force-dynamic' export const revalidate = 0 @@ -21,7 +26,14 @@ export const GET = withRouteHandler(async (request: NextRequest) => { const { requestId, userId, rateLimit } = auth try { - const parsed = await parseRequest(v1ListKnowledgeBasesContract, request, {}) + const parsed = await parseRequest( + v1ListKnowledgeBasesContract, + request, + {}, + { + validationErrorResponse: v1ValidationErrorResponse, + } + ) if (!parsed.success) return parsed.response const { workspaceId } = parsed.data.query @@ -31,13 +43,16 @@ export const GET = withRouteHandler(async (request: NextRequest) => { const knowledgeBases = await getKnowledgeBases(userId, workspaceId) - return NextResponse.json({ - success: true, - data: { - knowledgeBases: knowledgeBases.map(formatKnowledgeBase), - totalCount: knowledgeBases.length, + return NextResponse.json( + { + success: true, + data: { + knowledgeBases: knowledgeBases.map(formatKnowledgeBase), + totalCount: knowledgeBases.length, + }, }, - }) + { headers: rateLimitHeaders(rateLimit) } + ) } catch (error) { return handleError(requestId, error, 'Failed to list knowledge bases') } @@ -50,7 +65,14 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const { requestId, userId, rateLimit } = auth try { - const parsed = await parseRequest(v1CreateKnowledgeBaseContract, request, {}) + const parsed = await parseRequest( + v1CreateKnowledgeBaseContract, + request, + {}, + { + validationErrorResponse: v1ValidationErrorResponse, + } + ) if (!parsed.success) return parsed.response const { workspaceId, name, description, chunkingConfig } = parsed.data.body @@ -83,13 +105,16 @@ export const POST = withRouteHandler(async (request: NextRequest) => { request, }) - return NextResponse.json({ - success: true, - data: { - knowledgeBase: formatKnowledgeBase(kb), - message: 'Knowledge base created successfully', + return NextResponse.json( + { + success: true, + data: { + knowledgeBase: formatKnowledgeBase(kb), + message: 'Knowledge base created successfully', + }, }, - }) + { headers: rateLimitHeaders(rateLimit) } + ) } catch (error) { return handleError(requestId, error, 'Failed to create knowledge base') } diff --git a/apps/sim/app/api/v1/knowledge/search/route.test.ts b/apps/sim/app/api/v1/knowledge/search/route.test.ts index 9c423d1b2d9..db33c47eb05 100644 --- a/apps/sim/app/api/v1/knowledge/search/route.test.ts +++ b/apps/sim/app/api/v1/knowledge/search/route.test.ts @@ -78,6 +78,9 @@ vi.mock('@/lib/knowledge/embeddings', () => ({ vi.mock('@/app/api/v1/middleware', () => ({ authenticateRequest: mockAuthenticateRequest, validateWorkspaceAccess: mockValidateWorkspaceAccess, + rateLimitHeaders: () => ({}), + v1ValidationErrorResponse: (e: { issues: unknown[] }) => + NextResponse.json({ error: 'Validation error', details: e.issues }, { status: 400 }), })) vi.mock('@/app/api/v1/knowledge/utils', () => ({ diff --git a/apps/sim/app/api/v1/knowledge/search/route.ts b/apps/sim/app/api/v1/knowledge/search/route.ts index 36dc7558214..979b7f51a59 100644 --- a/apps/sim/app/api/v1/knowledge/search/route.ts +++ b/apps/sim/app/api/v1/knowledge/search/route.ts @@ -23,7 +23,12 @@ import { } from '@/app/api/knowledge/search/utils' import { checkKnowledgeBaseAccess, type KnowledgeBaseAccessResult } from '@/app/api/knowledge/utils' import { handleError } from '@/app/api/v1/knowledge/utils' -import { authenticateRequest, validateWorkspaceAccess } from '@/app/api/v1/middleware' +import { + authenticateRequest, + rateLimitHeaders, + v1ValidationErrorResponse, + validateWorkspaceAccess, +} from '@/app/api/v1/middleware' export const dynamic = 'force-dynamic' export const revalidate = 0 @@ -35,7 +40,14 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const { requestId, userId, rateLimit } = auth try { - const parsed = await parseRequest(v1KnowledgeSearchContract, request, {}) + const parsed = await parseRequest( + v1KnowledgeSearchContract, + request, + {}, + { + validationErrorResponse: v1ValidationErrorResponse, + } + ) if (!parsed.success) return parsed.response const { workspaceId, topK, query, tagFilters } = parsed.data.body @@ -256,38 +268,41 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const documentIds = results.map((r) => r.documentId) const documentMetadataMap = await getDocumentMetadataByIds(documentIds) - return NextResponse.json({ - success: true, - data: { - results: results.map((result) => { - const kbTagMap = tagDefinitionsMap[result.knowledgeBaseId] || {} - const tags: Record = {} + return NextResponse.json( + { + success: true, + data: { + results: results.map((result) => { + const kbTagMap = tagDefinitionsMap[result.knowledgeBaseId] || {} + const tags: Record = {} - ALL_TAG_SLOTS.forEach((slot) => { - const tagValue = result[slot as keyof SearchResult] - if (tagValue !== null && tagValue !== undefined) { - const displayName = kbTagMap[slot] || slot - tags[displayName] = tagValue as string | number | boolean | Date | null - } - }) + ALL_TAG_SLOTS.forEach((slot) => { + const tagValue = result[slot as keyof SearchResult] + if (tagValue !== null && tagValue !== undefined) { + const displayName = kbTagMap[slot] || slot + tags[displayName] = tagValue as string | number | boolean | Date | null + } + }) - const docMeta = documentMetadataMap[result.documentId] - return { - documentId: result.documentId, - documentName: docMeta?.filename || undefined, - sourceUrl: docMeta?.sourceUrl ?? null, - content: result.content, - chunkIndex: result.chunkIndex, - metadata: tags, - similarity: hasQuery ? 1 - result.distance : 1, - } - }), - query: query || '', - knowledgeBaseIds: accessibleKbIds, - topK, - totalResults: results.length, + const docMeta = documentMetadataMap[result.documentId] + return { + documentId: result.documentId, + documentName: docMeta?.filename || undefined, + sourceUrl: docMeta?.sourceUrl ?? null, + content: result.content, + chunkIndex: result.chunkIndex, + metadata: tags, + similarity: hasQuery ? 1 - result.distance : 1, + } + }), + query: query || '', + knowledgeBaseIds: accessibleKbIds, + topK, + totalResults: results.length, + }, }, - }) + { headers: rateLimitHeaders(rateLimit) } + ) } catch (error) { return handleError(requestId, error, 'Failed to perform search') } diff --git a/apps/sim/app/api/v1/middleware.test.ts b/apps/sim/app/api/v1/middleware.test.ts index 6b0efaabf3a..720f5729e65 100644 --- a/apps/sim/app/api/v1/middleware.test.ts +++ b/apps/sim/app/api/v1/middleware.test.ts @@ -9,6 +9,8 @@ import { createMockRequest } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { z } from 'zod' +import { workspaceIdSchema } from '@/lib/api/contracts/primitives' const { mockAuthenticateV1Request, mockGetSubscription, mockCheckRateLimit, mockGetRateLimit } = vi.hoisted(() => ({ @@ -33,7 +35,12 @@ vi.mock('@/lib/core/rate-limiter', () => ({ }, })) -import { checkRateLimit, createRateLimitResponse } from '@/app/api/v1/middleware' +import { + checkRateLimit, + createRateLimitResponse, + rateLimitHeaders, + v1ValidationErrorResponse, +} from '@/app/api/v1/middleware' /** Mirrors `createBucketConfig`: capacity is the per-minute rate x burst multiplier. */ const TEAM_BUCKET = { maxTokens: 400, refillRate: 200, refillIntervalMs: 60_000 } @@ -132,3 +139,55 @@ describe('createRateLimitResponse', () => { await expect(response.json()).resolves.toEqual({ error: 'API key required' }) }) }) + +describe('rateLimitHeaders', () => { + it('reports a consistent limit/remaining pair', () => { + const headers = rateLimitHeaders({ + allowed: true, + remaining: 399, + limit: 400, + resetAt: new Date('2026-07-28T18:28:48.354Z'), + }) + + expect(headers['X-RateLimit-Limit']).toBe('400') + expect(headers['X-RateLimit-Remaining']).toBe('399') + expect(Number(headers['X-RateLimit-Remaining'])).toBeLessThanOrEqual( + Number(headers['X-RateLimit-Limit']) + ) + expect(headers['X-RateLimit-Reset']).toBe('2026-07-28T18:28:48.354Z') + }) + + it('publishes nothing when no bucket was consulted', () => { + expect( + rateLimitHeaders({ + allowed: false, + remaining: 0, + limit: 0, + resetAt: new Date(), + error: 'API key required', + }) + ).toEqual({}) + }) +}) + +describe('v1ValidationErrorResponse', () => { + it('surfaces the schema message instead of a generic string', async () => { + const schema = z.object({ workspaceId: workspaceIdSchema }) + const parsed = schema.safeParse({}) + + const response = v1ValidationErrorResponse(parsed.error!) + const body = await response.json() + + expect(response.status).toBe(400) + expect(body.error).toBe('Workspace ID is required') + expect(body.error).not.toBe('Validation error') + expect(Array.isArray(body.details)).toBe(true) + }) + + it('keeps the issue list alongside the message', async () => { + const schema = z.object({ workspaceId: workspaceIdSchema }) + const body = await v1ValidationErrorResponse(schema.safeParse({}).error!).json() + + expect(body.details[0].path).toEqual(['workspaceId']) + }) +}) diff --git a/apps/sim/app/api/v1/middleware.ts b/apps/sim/app/api/v1/middleware.ts index eebebc04973..96712cb65b7 100644 --- a/apps/sim/app/api/v1/middleware.ts +++ b/apps/sim/app/api/v1/middleware.ts @@ -1,6 +1,8 @@ import { createLogger } from '@sim/logger' import { type PermissionType, permissionSatisfies } from '@sim/platform-authz/workspace' import { type NextRequest, NextResponse } from 'next/server' +import type { ZodError } from 'zod' +import { getValidationErrorMessage, serializeZodIssues } from '@/lib/api/server' import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription' import type { SubscriptionPlan } from '@/lib/core/rate-limiter' import { getRateLimit, RateLimiter } from '@/lib/core/rate-limiter' @@ -144,6 +146,23 @@ export async function authenticateRequest( return { requestId, userId: rateLimit.userId!, rateLimit } } +/** + * The `X-RateLimit-*` trio every authenticated v1 response should carry, so a + * client can see its remaining quota before it is throttled rather than only + * discovering the ceiling on a 429. + * + * Returns an empty object when no bucket was consulted (authentication failure, + * checker error) — publishing a fabricated quota there is worse than silence. + */ +export function rateLimitHeaders(result: RateLimitResult): Record { + if (result.error) return {} + return { + 'X-RateLimit-Limit': result.limit.toString(), + 'X-RateLimit-Remaining': result.remaining.toString(), + 'X-RateLimit-Reset': result.resetAt.toISOString(), + } +} + export function createRateLimitResponse(result: RateLimitResult): NextResponse { /** * An authentication failure never reaches the token bucket, so there is no @@ -154,11 +173,7 @@ export function createRateLimitResponse(result: RateLimitResult): NextResponse { return NextResponse.json({ error: result.error || 'Unauthorized' }, { status: 401 }) } - const headers = { - 'X-RateLimit-Limit': result.limit.toString(), - 'X-RateLimit-Remaining': result.remaining.toString(), - 'X-RateLimit-Reset': result.resetAt.toISOString(), - } + const headers = rateLimitHeaders(result) const retryAfterSeconds = result.retryAfterMs ? Math.ceil(result.retryAfterMs / 1000) @@ -251,3 +266,25 @@ export async function validateWorkspaceAccess( } return null } + +/** + * Shared 400 handler for v1 contract validation failures. + * + * `parseRequest`'s default reports the literal `"Validation error"`, which tells + * a caller nothing about which field was wrong — the schema already produced a + * specific message, and the default discards it. Surfacing the first issue keeps + * `details` intact while making the common case self-explanatory. + * + * Pass as `parseRequest(contract, request, context, { validationErrorResponse: + * v1ValidationErrorResponse })`. Routes with a more specific message of their + * own (for example `'Invalid workflow ID'`) should keep it. + */ +export function v1ValidationErrorResponse(error: ZodError): NextResponse { + return NextResponse.json( + { + error: getValidationErrorMessage(error, 'Invalid request'), + details: serializeZodIssues(error), + }, + { status: 400 } + ) +} diff --git a/apps/sim/app/api/v1/tables/[tableId]/columns/route.ts b/apps/sim/app/api/v1/tables/[tableId]/columns/route.ts index 46723538c7c..f9ce87db47d 100644 --- a/apps/sim/app/api/v1/tables/[tableId]/columns/route.ts +++ b/apps/sim/app/api/v1/tables/[tableId]/columns/route.ts @@ -28,6 +28,8 @@ import { checkRateLimit, checkWorkspaceScope, createRateLimitResponse, + rateLimitHeaders, + v1ValidationErrorResponse, } from '@/app/api/v1/middleware' const logger = createLogger('V1TableColumnsAPI') @@ -51,7 +53,9 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Colum const userId = rateLimit.userId! - const parsed = await parseRequest(v1AddTableColumnContract, request, context) + const parsed = await parseRequest(v1AddTableColumnContract, request, context, { + validationErrorResponse: v1ValidationErrorResponse, + }) if (!parsed.success) return parsed.response const { tableId } = parsed.data.params const validated = parsed.data.body @@ -82,12 +86,15 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Colum request, }) - return NextResponse.json({ - success: true, - data: { - columns: updatedTable.schema.columns.map(normalizeColumn), + return NextResponse.json( + { + success: true, + data: { + columns: updatedTable.schema.columns.map(normalizeColumn), + }, }, - }) + { headers: rateLimitHeaders(rateLimit) } + ) } catch (error) { const lockError = tableLockErrorResponse(error) if (lockError) return lockError @@ -128,7 +135,9 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Colu const userId = rateLimit.userId! - const parsed = await parseRequest(v1UpdateTableColumnContract, request, context) + const parsed = await parseRequest(v1UpdateTableColumnContract, request, context, { + validationErrorResponse: v1ValidationErrorResponse, + }) if (!parsed.success) return parsed.response const { tableId } = parsed.data.params const validated = parsed.data.body @@ -231,12 +240,15 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Colu request, }) - return NextResponse.json({ - success: true, - data: { - columns: updatedTable.schema.columns.map(normalizeColumn), + return NextResponse.json( + { + success: true, + data: { + columns: updatedTable.schema.columns.map(normalizeColumn), + }, }, - }) + { headers: rateLimitHeaders(rateLimit) } + ) } catch (error) { const lockError = tableLockErrorResponse(error) if (lockError) return lockError @@ -280,7 +292,9 @@ export const DELETE = withRouteHandler( const userId = rateLimit.userId! - const parsed = await parseRequest(v1DeleteTableColumnContract, request, context) + const parsed = await parseRequest(v1DeleteTableColumnContract, request, context, { + validationErrorResponse: v1ValidationErrorResponse, + }) if (!parsed.success) return parsed.response const { tableId } = parsed.data.params const validated = parsed.data.body @@ -314,12 +328,15 @@ export const DELETE = withRouteHandler( request, }) - return NextResponse.json({ - success: true, - data: { - columns: updatedTable.schema.columns.map(normalizeColumn), + return NextResponse.json( + { + success: true, + data: { + columns: updatedTable.schema.columns.map(normalizeColumn), + }, }, - }) + { headers: rateLimitHeaders(rateLimit) } + ) } catch (error) { const lockError = tableLockErrorResponse(error) if (lockError) return lockError diff --git a/apps/sim/app/api/v1/tables/[tableId]/route.ts b/apps/sim/app/api/v1/tables/[tableId]/route.ts index c06492d02b7..951d1510781 100644 --- a/apps/sim/app/api/v1/tables/[tableId]/route.ts +++ b/apps/sim/app/api/v1/tables/[tableId]/route.ts @@ -16,6 +16,7 @@ import { checkRateLimit, checkWorkspaceScope, createRateLimitResponse, + rateLimitHeaders, } from '@/app/api/v1/middleware' const logger = createLogger('V1TableDetailAPI') @@ -70,30 +71,33 @@ export const GET = withRouteHandler(async (request: NextRequest, context: TableR const schemaData = table.schema as TableSchema - return NextResponse.json({ - success: true, - data: { - table: { - id: table.id, - name: table.name, - description: table.description, - schema: { - columns: schemaData.columns.map(normalizeColumn), + return NextResponse.json( + { + success: true, + data: { + table: { + id: table.id, + name: table.name, + description: table.description, + schema: { + columns: schemaData.columns.map(normalizeColumn), + }, + rowCount: table.rowCount, + maxRows: table.maxRows, + locks: table.locks, + createdAt: + table.createdAt instanceof Date + ? table.createdAt.toISOString() + : String(table.createdAt), + updatedAt: + table.updatedAt instanceof Date + ? table.updatedAt.toISOString() + : String(table.updatedAt), }, - rowCount: table.rowCount, - maxRows: table.maxRows, - locks: table.locks, - createdAt: - table.createdAt instanceof Date - ? table.createdAt.toISOString() - : String(table.createdAt), - updatedAt: - table.updatedAt instanceof Date - ? table.updatedAt.toISOString() - : String(table.updatedAt), }, }, - }) + { headers: rateLimitHeaders(rateLimit) } + ) } catch (error) { logger.error(`[${requestId}] Error getting table:`, error) return NextResponse.json({ error: 'Failed to get table' }, { status: 500 }) @@ -152,12 +156,15 @@ export const DELETE = withRouteHandler(async (request: NextRequest, context: Tab request, }) - return NextResponse.json({ - success: true, - data: { - message: 'Table archived successfully', + return NextResponse.json( + { + success: true, + data: { + message: 'Table archived successfully', + }, }, - }) + { headers: rateLimitHeaders(rateLimit) } + ) } catch (error) { const lockError = tableLockErrorResponse(error) if (lockError) return lockError diff --git a/apps/sim/app/api/v1/tables/[tableId]/rows/[rowId]/route.ts b/apps/sim/app/api/v1/tables/[tableId]/rows/[rowId]/route.ts index 2a7ea2fe7a5..72162aedc11 100644 --- a/apps/sim/app/api/v1/tables/[tableId]/rows/[rowId]/route.ts +++ b/apps/sim/app/api/v1/tables/[tableId]/rows/[rowId]/route.ts @@ -21,6 +21,7 @@ import { checkRateLimit, checkWorkspaceScope, createRateLimitResponse, + rateLimitHeaders, resolveWorkspaceRequestActor, } from '@/app/api/v1/middleware' @@ -85,20 +86,23 @@ export const GET = withRouteHandler(async (request: NextRequest, context: RowRou } const toNamedRow = namedRowMapper((result.table.schema as TableSchema).columns) - return NextResponse.json({ - success: true, - data: { - row: { - id: row.id, - data: toNamedRow(row.data as RowData), - position: row.position, - createdAt: - row.createdAt instanceof Date ? row.createdAt.toISOString() : String(row.createdAt), - updatedAt: - row.updatedAt instanceof Date ? row.updatedAt.toISOString() : String(row.updatedAt), + return NextResponse.json( + { + success: true, + data: { + row: { + id: row.id, + data: toNamedRow(row.data as RowData), + position: row.position, + createdAt: + row.createdAt instanceof Date ? row.createdAt.toISOString() : String(row.createdAt), + updatedAt: + row.updatedAt instanceof Date ? row.updatedAt.toISOString() : String(row.updatedAt), + }, }, }, - }) + { headers: rateLimitHeaders(rateLimit) } + ) } catch (error) { logger.error(`[${requestId}] Error getting row:`, error) return NextResponse.json({ error: 'Failed to get row' }, { status: 500 }) @@ -159,25 +163,28 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: RowR // Firing a second mode: 'incomplete' dispatch here would race with it AND // bulk-clear sibling-group outputs. - return NextResponse.json({ - success: true, - data: { - row: { - id: updatedRow.id, - data: toNamedRow(updatedRow.data), - position: updatedRow.position, - createdAt: - updatedRow.createdAt instanceof Date - ? updatedRow.createdAt.toISOString() - : updatedRow.createdAt, - updatedAt: - updatedRow.updatedAt instanceof Date - ? updatedRow.updatedAt.toISOString() - : updatedRow.updatedAt, + return NextResponse.json( + { + success: true, + data: { + row: { + id: updatedRow.id, + data: toNamedRow(updatedRow.data), + position: updatedRow.position, + createdAt: + updatedRow.createdAt instanceof Date + ? updatedRow.createdAt.toISOString() + : updatedRow.createdAt, + updatedAt: + updatedRow.updatedAt instanceof Date + ? updatedRow.updatedAt.toISOString() + : updatedRow.updatedAt, + }, + message: 'Row updated successfully', }, - message: 'Row updated successfully', }, - }) + { headers: rateLimitHeaders(rateLimit) } + ) } catch (error) { const lockError = tableLockErrorResponse(error) if (lockError) return lockError @@ -238,13 +245,16 @@ export const DELETE = withRouteHandler(async (request: NextRequest, context: Row // enforced — the raw path would return 200 on a locked table. await deleteRow(result.table, rowId, requestId) - return NextResponse.json({ - success: true, - data: { - message: 'Row deleted successfully', - deletedCount: 1, + return NextResponse.json( + { + success: true, + data: { + message: 'Row deleted successfully', + deletedCount: 1, + }, }, - }) + { headers: rateLimitHeaders(rateLimit) } + ) } catch (error) { const lockError = tableLockErrorResponse(error) if (lockError) return lockError diff --git a/apps/sim/app/api/v1/tables/[tableId]/rows/route.ts b/apps/sim/app/api/v1/tables/[tableId]/rows/route.ts index d0e37376cca..70a6ac13bf0 100644 --- a/apps/sim/app/api/v1/tables/[tableId]/rows/route.ts +++ b/apps/sim/app/api/v1/tables/[tableId]/rows/route.ts @@ -40,6 +40,8 @@ import { checkRateLimit, checkWorkspaceScope, createRateLimitResponse, + type RateLimitResult, + rateLimitHeaders, resolveWorkspaceRequestActor, } from '@/app/api/v1/middleware' @@ -57,7 +59,8 @@ async function handleBatchInsert( tableId: string, validated: V1BatchInsertTableRowsBody, userId: string, - actorUserId: string + actorUserId: string, + rateLimit: RateLimitResult ): Promise { const accessResult = await checkAccess(tableId, userId, 'write') if (!accessResult.ok) return accessError(accessResult, requestId, tableId) @@ -92,20 +95,23 @@ async function handleBatchInsert( requestId ) - return NextResponse.json({ - success: true, - data: { - rows: insertedRows.map((r) => ({ - id: r.id, - data: toNamedRow(r.data), - position: r.position, - createdAt: r.createdAt instanceof Date ? r.createdAt.toISOString() : r.createdAt, - updatedAt: r.updatedAt instanceof Date ? r.updatedAt.toISOString() : r.updatedAt, - })), - insertedCount: insertedRows.length, - message: `Successfully inserted ${insertedRows.length} rows`, + return NextResponse.json( + { + success: true, + data: { + rows: insertedRows.map((r) => ({ + id: r.id, + data: toNamedRow(r.data), + position: r.position, + createdAt: r.createdAt instanceof Date ? r.createdAt.toISOString() : r.createdAt, + updatedAt: r.updatedAt instanceof Date ? r.updatedAt.toISOString() : r.updatedAt, + })), + insertedCount: insertedRows.length, + message: `Successfully inserted ${insertedRows.length} rows`, + }, }, - }) + { headers: rateLimitHeaders(rateLimit) } + ) } catch (error) { const response = rowWriteErrorResponse(error) if (response) return response @@ -178,22 +184,27 @@ export const GET = withRouteHandler(async (request: NextRequest, context: TableR requestId ) - return NextResponse.json({ - success: true, - data: { - rows: result.rows.map((r) => ({ - id: r.id, - data: toNamedRow(r.data), - position: r.position, - createdAt: r.createdAt instanceof Date ? r.createdAt.toISOString() : String(r.createdAt), - updatedAt: r.updatedAt instanceof Date ? r.updatedAt.toISOString() : String(r.updatedAt), - })), - rowCount: result.rowCount, - totalCount: result.totalCount, - limit: result.limit, - offset: result.offset, + return NextResponse.json( + { + success: true, + data: { + rows: result.rows.map((r) => ({ + id: r.id, + data: toNamedRow(r.data), + position: r.position, + createdAt: + r.createdAt instanceof Date ? r.createdAt.toISOString() : String(r.createdAt), + updatedAt: + r.updatedAt instanceof Date ? r.updatedAt.toISOString() : String(r.updatedAt), + })), + rowCount: result.rowCount, + totalCount: result.totalCount, + limit: result.limit, + offset: result.offset, + }, }, - }) + { headers: rateLimitHeaders(rateLimit) } + ) } catch (error) { const validationResponse = validationErrorResponseFromError(error) if (validationResponse) return validationResponse @@ -236,7 +247,7 @@ export const POST = withRouteHandler( `Unable to resolve system actor for workspace ${batchValidated.workspaceId}` ) } - return handleBatchInsert(requestId, tableId, batchValidated, userId, actorUserId) + return handleBatchInsert(requestId, tableId, batchValidated, userId, actorUserId, rateLimit) } const validated = parsed.data.body @@ -279,19 +290,24 @@ export const POST = withRouteHandler( requestId ) - return NextResponse.json({ - success: true, - data: { - row: { - id: row.id, - data: toNamedRow(row.data), - position: row.position, - createdAt: row.createdAt instanceof Date ? row.createdAt.toISOString() : row.createdAt, - updatedAt: row.updatedAt instanceof Date ? row.updatedAt.toISOString() : row.updatedAt, + return NextResponse.json( + { + success: true, + data: { + row: { + id: row.id, + data: toNamedRow(row.data), + position: row.position, + createdAt: + row.createdAt instanceof Date ? row.createdAt.toISOString() : row.createdAt, + updatedAt: + row.updatedAt instanceof Date ? row.updatedAt.toISOString() : row.updatedAt, + }, + message: 'Row inserted successfully', }, - message: 'Row inserted successfully', }, - }) + { headers: rateLimitHeaders(rateLimit) } + ) } catch (error) { const validationResponse = validationErrorResponseFromError(error) if (validationResponse) return validationResponse @@ -363,23 +379,29 @@ export const PUT = withRouteHandler(async (request: NextRequest, context: TableR ) if (result.affectedCount === 0) { - return NextResponse.json({ - success: true, - data: { - message: 'No rows matched the filter criteria', - updatedCount: 0, + return NextResponse.json( + { + success: true, + data: { + message: 'No rows matched the filter criteria', + updatedCount: 0, + }, }, - }) + { headers: rateLimitHeaders(rateLimit) } + ) } - return NextResponse.json({ - success: true, - data: { - message: 'Rows updated successfully', - updatedCount: result.affectedCount, - updatedRowIds: result.affectedRowIds, + return NextResponse.json( + { + success: true, + data: { + message: 'Rows updated successfully', + updatedCount: result.affectedCount, + updatedRowIds: result.affectedRowIds, + }, }, - }) + { headers: rateLimitHeaders(rateLimit) } + ) } catch (error) { const validationResponse = validationErrorResponseFromError(error) if (validationResponse) return validationResponse @@ -432,19 +454,22 @@ export const DELETE = withRouteHandler( requestId ) - return NextResponse.json({ - success: true, - data: { - message: - result.deletedCount === 0 - ? 'No matching rows found for the provided IDs' - : 'Rows deleted successfully', - deletedCount: result.deletedCount, - deletedRowIds: result.deletedRowIds, - requestedCount: result.requestedCount, - ...(result.missingRowIds.length > 0 ? { missingRowIds: result.missingRowIds } : {}), + return NextResponse.json( + { + success: true, + data: { + message: + result.deletedCount === 0 + ? 'No matching rows found for the provided IDs' + : 'Rows deleted successfully', + deletedCount: result.deletedCount, + deletedRowIds: result.deletedRowIds, + requestedCount: result.requestedCount, + ...(result.missingRowIds.length > 0 ? { missingRowIds: result.missingRowIds } : {}), + }, }, - }) + { headers: rateLimitHeaders(rateLimit) } + ) } const idByName = buildIdByName(table.schema as TableSchema) @@ -460,17 +485,20 @@ export const DELETE = withRouteHandler( requestId ) - return NextResponse.json({ - success: true, - data: { - message: - result.affectedCount === 0 - ? 'No rows matched the filter criteria' - : 'Rows deleted successfully', - deletedCount: result.affectedCount, - deletedRowIds: result.affectedRowIds, + return NextResponse.json( + { + success: true, + data: { + message: + result.affectedCount === 0 + ? 'No rows matched the filter criteria' + : 'Rows deleted successfully', + deletedCount: result.affectedCount, + deletedRowIds: result.affectedRowIds, + }, }, - }) + { headers: rateLimitHeaders(rateLimit) } + ) } catch (error) { const validationResponse = validationErrorResponseFromError(error) if (validationResponse) return validationResponse diff --git a/apps/sim/app/api/v1/tables/[tableId]/rows/upsert/route.ts b/apps/sim/app/api/v1/tables/[tableId]/rows/upsert/route.ts index 1df6b4b2384..54c66abf82a 100644 --- a/apps/sim/app/api/v1/tables/[tableId]/rows/upsert/route.ts +++ b/apps/sim/app/api/v1/tables/[tableId]/rows/upsert/route.ts @@ -14,7 +14,9 @@ import { checkRateLimit, checkWorkspaceScope, createRateLimitResponse, + rateLimitHeaders, resolveWorkspaceRequestActor, + v1ValidationErrorResponse, } from '@/app/api/v1/middleware' const logger = createLogger('V1TableUpsertAPI') @@ -37,7 +39,9 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Upser } const userId = rateLimit.userId! - const parsed = await parseRequest(v1UpsertTableRowContract, request, context) + const parsed = await parseRequest(v1UpsertTableRowContract, request, context, { + validationErrorResponse: v1ValidationErrorResponse, + }) if (!parsed.success) return parsed.response const { tableId } = parsed.data.params const validated = parsed.data.body @@ -72,25 +76,28 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Upser requestId ) - return NextResponse.json({ - success: true, - data: { - row: { - id: upsertResult.row.id, - data: toNamedRow(upsertResult.row.data), - createdAt: - upsertResult.row.createdAt instanceof Date - ? upsertResult.row.createdAt.toISOString() - : upsertResult.row.createdAt, - updatedAt: - upsertResult.row.updatedAt instanceof Date - ? upsertResult.row.updatedAt.toISOString() - : upsertResult.row.updatedAt, + return NextResponse.json( + { + success: true, + data: { + row: { + id: upsertResult.row.id, + data: toNamedRow(upsertResult.row.data), + createdAt: + upsertResult.row.createdAt instanceof Date + ? upsertResult.row.createdAt.toISOString() + : upsertResult.row.createdAt, + updatedAt: + upsertResult.row.updatedAt instanceof Date + ? upsertResult.row.updatedAt.toISOString() + : upsertResult.row.updatedAt, + }, + operation: upsertResult.operation, + message: `Row ${upsertResult.operation === 'update' ? 'updated' : 'inserted'} successfully`, }, - operation: upsertResult.operation, - message: `Row ${upsertResult.operation === 'update' ? 'updated' : 'inserted'} successfully`, }, - }) + { headers: rateLimitHeaders(rateLimit) } + ) } catch (error) { const lockError = tableLockErrorResponse(error) if (lockError) return lockError diff --git a/apps/sim/app/api/v1/tables/route.ts b/apps/sim/app/api/v1/tables/route.ts index 441f4fc1413..977da40c0fa 100644 --- a/apps/sim/app/api/v1/tables/route.ts +++ b/apps/sim/app/api/v1/tables/route.ts @@ -10,6 +10,8 @@ import { normalizeColumn } from '@/app/api/table/utils' import { checkRateLimit, createRateLimitResponse, + rateLimitHeaders, + v1ValidationErrorResponse, validateWorkspaceAccess, } from '@/app/api/v1/middleware' @@ -29,7 +31,14 @@ export const GET = withRouteHandler(async (request: NextRequest) => { } const userId = rateLimit.userId! - const parsed = await parseRequest(v1ListTablesContract, request, {}) + const parsed = await parseRequest( + v1ListTablesContract, + request, + {}, + { + validationErrorResponse: v1ValidationErrorResponse, + } + ) if (!parsed.success) return parsed.response const { workspaceId } = parsed.data.query @@ -39,30 +48,33 @@ export const GET = withRouteHandler(async (request: NextRequest) => { const tables = await listTables(workspaceId) - return NextResponse.json({ - success: true, - data: { - tables: tables.map((t) => { - const schemaData = t.schema as TableSchema - return { - id: t.id, - name: t.name, - description: t.description, - schema: { - columns: schemaData.columns.map(normalizeColumn), - }, - rowCount: t.rowCount, - maxRows: t.maxRows, - locks: t.locks, - createdAt: - t.createdAt instanceof Date ? t.createdAt.toISOString() : String(t.createdAt), - updatedAt: - t.updatedAt instanceof Date ? t.updatedAt.toISOString() : String(t.updatedAt), - } - }), - totalCount: tables.length, + return NextResponse.json( + { + success: true, + data: { + tables: tables.map((t) => { + const schemaData = t.schema as TableSchema + return { + id: t.id, + name: t.name, + description: t.description, + schema: { + columns: schemaData.columns.map(normalizeColumn), + }, + rowCount: t.rowCount, + maxRows: t.maxRows, + locks: t.locks, + createdAt: + t.createdAt instanceof Date ? t.createdAt.toISOString() : String(t.createdAt), + updatedAt: + t.updatedAt instanceof Date ? t.updatedAt.toISOString() : String(t.updatedAt), + } + }), + totalCount: tables.length, + }, }, - }) + { headers: rateLimitHeaders(rateLimit) } + ) } catch (error) { const validationResponse = validationErrorResponseFromError(error) if (validationResponse) return validationResponse @@ -84,7 +96,14 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const userId = rateLimit.userId! - const parsed = await parseRequest(v1CreateTableContract, request, {}) + const parsed = await parseRequest( + v1CreateTableContract, + request, + {}, + { + validationErrorResponse: v1ValidationErrorResponse, + } + ) if (!parsed.success) return parsed.response const params = parsed.data.body @@ -126,31 +145,34 @@ export const POST = withRouteHandler(async (request: NextRequest) => { request, }) - return NextResponse.json({ - success: true, - data: { - table: { - id: table.id, - name: table.name, - description: table.description, - schema: { - columns: (table.schema as TableSchema).columns.map(normalizeColumn), + return NextResponse.json( + { + success: true, + data: { + table: { + id: table.id, + name: table.name, + description: table.description, + schema: { + columns: (table.schema as TableSchema).columns.map(normalizeColumn), + }, + rowCount: table.rowCount, + maxRows: table.maxRows, + locks: table.locks, + createdAt: + table.createdAt instanceof Date + ? table.createdAt.toISOString() + : String(table.createdAt), + updatedAt: + table.updatedAt instanceof Date + ? table.updatedAt.toISOString() + : String(table.updatedAt), }, - rowCount: table.rowCount, - maxRows: table.maxRows, - locks: table.locks, - createdAt: - table.createdAt instanceof Date - ? table.createdAt.toISOString() - : String(table.createdAt), - updatedAt: - table.updatedAt instanceof Date - ? table.updatedAt.toISOString() - : String(table.updatedAt), + message: 'Table created successfully', }, - message: 'Table created successfully', }, - }) + { headers: rateLimitHeaders(rateLimit) } + ) } catch (error) { const validationResponse = validationErrorResponseFromError(error) if (validationResponse) return validationResponse diff --git a/apps/sim/lib/api/contracts/primitives.ts b/apps/sim/lib/api/contracts/primitives.ts index 79e6243aab4..20e05339ad5 100644 --- a/apps/sim/lib/api/contracts/primitives.ts +++ b/apps/sim/lib/api/contracts/primitives.ts @@ -32,6 +32,23 @@ export const jobIdParamsSchema = z.object({ */ export const nonEmptyIdSchema = z.string().min(1) +/** + * Builds a required, non-empty string schema whose message covers **both** + * failure modes. + * + * `.min(1, message)` alone only fires for a present-but-empty string; an omitted + * field falls through to Zod's default `Invalid input: expected string, received + * undefined`, which never names the field the caller left out. Passing the same + * message to the `z.string({ error })` constructor closes that gap. + * + * Prefer this over a bare `z.string().min(1, '...')` for any required request + * field, and keep the wording specific to the field and where it belongs (for + * example `'workspaceId query parameter is required'`). + */ +export function requiredFieldSchema(message: string) { + return z.string({ error: message }).min(1, message) +} + /** * Non-empty `workspaceId` field. Same constraint as `nonEmptyIdSchema` with a * stable, human-readable message. Use to deduplicate the @@ -43,25 +60,19 @@ export const nonEmptyIdSchema = z.string().min(1) * undefined`, which does not name the field. The same applies to the sibling id * schemas below. */ -export const workspaceIdSchema = z - .string({ error: 'Workspace ID is required' }) - .min(1, 'Workspace ID is required') +export const workspaceIdSchema = requiredFieldSchema('Workspace ID is required') /** * Non-empty `organizationId` field. Same constraint as `nonEmptyIdSchema` with a * stable, human-readable message. */ -export const organizationIdSchema = z - .string({ error: 'Organization ID is required' }) - .min(1, 'Organization ID is required') +export const organizationIdSchema = requiredFieldSchema('Organization ID is required') /** * Non-empty `workflowId` field. Same constraint as `nonEmptyIdSchema` with a * stable, human-readable message. */ -export const workflowIdSchema = z - .string({ error: 'Workflow ID is required' }) - .min(1, 'Workflow ID is required') +export const workflowIdSchema = requiredFieldSchema('Workflow ID is required') /** * A `workspace_files.id` value. The column is a free-form `text` primary key, so @@ -70,9 +81,7 @@ export const workflowIdSchema = z * path. Both are drawn from `[A-Za-z0-9_-]`, so accept that charset rather than a * UUID-only schema — a `.uuid()` constraint here silently 400s every `wf_` file. */ -export const workspaceFileIdSchema = z - .string({ error: 'File ID is required' }) - .min(1, 'File ID is required') +export const workspaceFileIdSchema = requiredFieldSchema('File ID is required') .max(128, 'File ID is too long') .regex(/^[A-Za-z0-9_-]+$/, 'Invalid file id') diff --git a/apps/sim/lib/api/contracts/v1/files.ts b/apps/sim/lib/api/contracts/v1/files.ts index 65a881c4a86..c196e0953cf 100644 --- a/apps/sim/lib/api/contracts/v1/files.ts +++ b/apps/sim/lib/api/contracts/v1/files.ts @@ -1,4 +1,5 @@ import { z } from 'zod' +import { requiredFieldSchema } from '@/lib/api/contracts/primitives' import { defineRouteContract } from '@/lib/api/contracts/types' export const v1FileParamsSchema = z.object({ @@ -6,11 +7,11 @@ export const v1FileParamsSchema = z.object({ }) export const v1WorkspaceIdQuerySchema = z.object({ - workspaceId: z.string().min(1, 'workspaceId query parameter is required'), + workspaceId: requiredFieldSchema('workspaceId query parameter is required'), }) export const v1UploadFileFormFieldsSchema = z.object({ - workspaceId: z.string().min(1, 'workspaceId form field is required'), + workspaceId: requiredFieldSchema('workspaceId form field is required'), }) export type V1FileParams = z.output diff --git a/apps/sim/lib/api/contracts/v1/knowledge/index.ts b/apps/sim/lib/api/contracts/v1/knowledge/index.ts index 829acb2de51..c682b2a4c09 100644 --- a/apps/sim/lib/api/contracts/v1/knowledge/index.ts +++ b/apps/sim/lib/api/contracts/v1/knowledge/index.ts @@ -4,6 +4,7 @@ import { knowledgeDocumentParamsSchema, successResponseSchema, } from '@/lib/api/contracts/knowledge/shared' +import { requiredFieldSchema } from '@/lib/api/contracts/primitives' import { defineRouteContract } from '@/lib/api/contracts/types' import { KNOWLEDGE_BASE_DESCRIPTION_MAX_LENGTH } from '@/lib/knowledge/constants' @@ -30,12 +31,12 @@ export const v1ChunkingConfigSchema = z.object({ /** GET `/api/v1/knowledge` — list knowledge bases scoped to a workspace. */ export const v1ListKnowledgeBasesQuerySchema = z.object({ - workspaceId: z.string().min(1, 'workspaceId query parameter is required'), + workspaceId: requiredFieldSchema('workspaceId query parameter is required'), }) /** POST `/api/v1/knowledge` — create a knowledge base. */ export const v1CreateKnowledgeBaseBodySchema = z.object({ - workspaceId: z.string().min(1, 'Workspace ID is required'), + workspaceId: requiredFieldSchema('Workspace ID is required'), name: z.string().min(1, 'Name is required').max(255, 'Name must be 255 characters or less'), description: z .string() @@ -53,13 +54,13 @@ export const v1CreateKnowledgeBaseBodySchema = z.object({ /** GET/DELETE `/api/v1/knowledge/[id]` — workspace scope param. */ export const v1KnowledgeWorkspaceQuerySchema = z.object({ - workspaceId: z.string().min(1, 'workspaceId query parameter is required'), + workspaceId: requiredFieldSchema('workspaceId query parameter is required'), }) /** PUT `/api/v1/knowledge/[id]` — partial update with workspace scope in body. */ export const v1UpdateKnowledgeBaseBodySchema = z .object({ - workspaceId: z.string().min(1, 'Workspace ID is required'), + workspaceId: requiredFieldSchema('Workspace ID is required'), name: z.string().min(1).max(255, 'Name must be 255 characters or less').optional(), description: z .string() @@ -86,7 +87,7 @@ export const v1UpdateKnowledgeBaseBodySchema = z /** GET `/api/v1/knowledge/[id]/documents` — list documents (defaults differ from in-app list). */ export const v1ListKnowledgeDocumentsQuerySchema = z.object({ - workspaceId: z.string().min(1, 'workspaceId query parameter is required'), + workspaceId: requiredFieldSchema('workspaceId query parameter is required'), limit: z.coerce.number().int().min(1).max(100).default(50), offset: z.coerce.number().int().min(0).default(0), search: z.string().optional(), @@ -121,7 +122,7 @@ export const v1SearchTagFilterSchema = z.object({ /** POST `/api/v1/knowledge/search` body. */ export const v1KnowledgeSearchBodySchema = z .object({ - workspaceId: z.string().min(1, 'Workspace ID is required'), + workspaceId: requiredFieldSchema('Workspace ID is required'), knowledgeBaseIds: z.union([ z.string().min(1, 'Knowledge base ID is required'), z diff --git a/apps/sim/lib/api/contracts/v1/logs.ts b/apps/sim/lib/api/contracts/v1/logs.ts index 52e48b096d2..87279d8d72a 100644 --- a/apps/sim/lib/api/contracts/v1/logs.ts +++ b/apps/sim/lib/api/contracts/v1/logs.ts @@ -1,5 +1,5 @@ import { z } from 'zod' -import { booleanQueryFlagSchema } from '@/lib/api/contracts/primitives' +import { booleanQueryFlagSchema, workspaceIdSchema } from '@/lib/api/contracts/primitives' import { defineRouteContract } from '@/lib/api/contracts/types' export const v1LogParamsSchema = z.object({ @@ -11,7 +11,7 @@ export const v1ExecutionParamsSchema = z.object({ }) export const v1ListLogsQuerySchema = z.object({ - workspaceId: z.string().min(1), + workspaceId: workspaceIdSchema, workflowIds: z.string().optional(), folderIds: z.string().optional(), triggers: z.string().optional(), diff --git a/apps/sim/lib/api/contracts/v1/tables/index.ts b/apps/sim/lib/api/contracts/v1/tables/index.ts index 9546e2a2c09..1890ffe42ee 100644 --- a/apps/sim/lib/api/contracts/v1/tables/index.ts +++ b/apps/sim/lib/api/contracts/v1/tables/index.ts @@ -1,5 +1,6 @@ import { isRecordLike } from '@sim/utils/object' import { z } from 'zod' +import { requiredFieldSchema } from '@/lib/api/contracts/primitives' import { createTableBodySchema, createTableColumnBodySchema, @@ -48,7 +49,7 @@ export const v1TableRowsQuerySchema = tableRowsQueryBaseSchema.omit({ after: tru }) export const v1ListTablesQuerySchema = z.object({ - workspaceId: z.string().min(1, 'workspaceId query parameter is required'), + workspaceId: requiredFieldSchema('workspaceId query parameter is required'), }) export const v1CreateTableBodySchema = createTableBodySchema.omit({ @@ -67,7 +68,7 @@ export const v1InsertTableRowBodySchema = insertTableRowBodyBaseSchema * Public API batch insert body — no `positions`. Same rationale as above. */ export const v1BatchInsertTableRowsBodySchema = z.object({ - workspaceId: z.string().min(1, 'Workspace ID is required'), + workspaceId: requiredFieldSchema('Workspace ID is required'), rows: z .array(rowDataSchema) .min(1, 'At least one row is required') diff --git a/apps/sim/lib/api/contracts/v1/workflows.ts b/apps/sim/lib/api/contracts/v1/workflows.ts index aa5a78c901d..10ef1c35205 100644 --- a/apps/sim/lib/api/contracts/v1/workflows.ts +++ b/apps/sim/lib/api/contracts/v1/workflows.ts @@ -9,7 +9,7 @@ import { defineRouteContract } from '@/lib/api/contracts/types' import { workflowIdParamsSchema, workflowStateSchema } from '@/lib/api/contracts/workflows' export const v1ListWorkflowsQuerySchema = z.object({ - workspaceId: z.string().min(1), + workspaceId: workspaceIdSchema, folderId: z.string().optional(), deployedOnly: booleanQueryFlagSchema.optional().default(false), limit: z.coerce.number().min(1).max(100).optional().default(50), From 51f010431d3be0a4aed9fa686f18647f69ca2853 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 28 Jul 2026 12:13:27 -0700 Subject: [PATCH 2/6] fix(api): finish the v1 consistency sweep at call level, not file level Review found three places the first pass missed, all from filters that worked on whole files instead of individual call sites. - GET /api/v1/files/{fileId} returns the file bytes via `new Response`, not a `success: true` JSON body, so the header pass skipped it. The download now carries the same quota headers as the DELETE beside it. - Four parseRequest calls in the table-row routes still reported the generic "Validation error". The first pass skipped any file that already had a handler anywhere in it, which excluded these two files wholesale. The check is now per call site, and no bare call remains. - POST /api/v1/tables takes its body from the shared tables contract, which still used the bare `.min(1)` form, so an omitted workspaceId did not name itself. Converted there and in the other v1-reachable contracts. Scope note: roughly a thousand `.min(1, '... is required')` declarations remain under contracts/tools/**. Those are block and tool definitions rather than the public REST surface, and converting them belongs in its own change. --- apps/sim/app/api/v1/files/[fileId]/route.ts | 1 + .../v1/tables/[tableId]/rows/[rowId]/route.ts | 5 +- .../app/api/v1/tables/[tableId]/rows/route.ts | 13 ++- apps/sim/lib/api/contracts/tables.ts | 79 ++++++++++--------- .../lib/api/contracts/v1/knowledge/index.ts | 4 +- apps/sim/lib/api/contracts/workflows.ts | 16 ++-- 6 files changed, 65 insertions(+), 53 deletions(-) diff --git a/apps/sim/app/api/v1/files/[fileId]/route.ts b/apps/sim/app/api/v1/files/[fileId]/route.ts index d64b5799920..c7c3ee53209 100644 --- a/apps/sim/app/api/v1/files/[fileId]/route.ts +++ b/apps/sim/app/api/v1/files/[fileId]/route.ts @@ -89,6 +89,7 @@ export const GET = withRouteHandler(async (request: NextRequest, context: FileRo { status: 200, headers: { + ...rateLimitHeaders(rateLimit), 'Content-Type': contentType || fileRecord.type || 'application/octet-stream', 'Content-Disposition': `attachment; filename="${fileRecord.name.replace(/[^\w.-]/g, '_')}"; filename*=UTF-8''${encodeURIComponent(fileRecord.name)}`, 'Content-Length': String(buffer.length), diff --git a/apps/sim/app/api/v1/tables/[tableId]/rows/[rowId]/route.ts b/apps/sim/app/api/v1/tables/[tableId]/rows/[rowId]/route.ts index 72162aedc11..85e5f652597 100644 --- a/apps/sim/app/api/v1/tables/[tableId]/rows/[rowId]/route.ts +++ b/apps/sim/app/api/v1/tables/[tableId]/rows/[rowId]/route.ts @@ -23,6 +23,7 @@ import { createRateLimitResponse, rateLimitHeaders, resolveWorkspaceRequestActor, + v1ValidationErrorResponse, } from '@/app/api/v1/middleware' const logger = createLogger('V1TableRowAPI') @@ -120,7 +121,9 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: RowR } const userId = rateLimit.userId! - const parsed = await parseRequest(v1UpdateTableRowContract, request, context) + const parsed = await parseRequest(v1UpdateTableRowContract, request, context, { + validationErrorResponse: v1ValidationErrorResponse, + }) if (!parsed.success) return parsed.response const { tableId, rowId } = parsed.data.params const validated = parsed.data.body diff --git a/apps/sim/app/api/v1/tables/[tableId]/rows/route.ts b/apps/sim/app/api/v1/tables/[tableId]/rows/route.ts index 70a6ac13bf0..65bad2827f2 100644 --- a/apps/sim/app/api/v1/tables/[tableId]/rows/route.ts +++ b/apps/sim/app/api/v1/tables/[tableId]/rows/route.ts @@ -43,6 +43,7 @@ import { type RateLimitResult, rateLimitHeaders, resolveWorkspaceRequestActor, + v1ValidationErrorResponse, } from '@/app/api/v1/middleware' const logger = createLogger('V1TableRowsAPI') @@ -230,7 +231,9 @@ export const POST = withRouteHandler( } const userId = rateLimit.userId! - const parsed = await parseRequest(v1CreateTableRowContract, request, context) + const parsed = await parseRequest(v1CreateTableRowContract, request, context, { + validationErrorResponse: v1ValidationErrorResponse, + }) if (!parsed.success) return parsed.response const { tableId } = parsed.data.params @@ -332,7 +335,9 @@ export const PUT = withRouteHandler(async (request: NextRequest, context: TableR } const userId = rateLimit.userId! - const parsed = await parseRequest(v1UpdateRowsByFilterContract, request, context) + const parsed = await parseRequest(v1UpdateRowsByFilterContract, request, context, { + validationErrorResponse: v1ValidationErrorResponse, + }) if (!parsed.success) return parsed.response const { tableId } = parsed.data.params const validated = parsed.data.body @@ -430,7 +435,9 @@ export const DELETE = withRouteHandler( } const userId = rateLimit.userId! - const parsed = await parseRequest(v1DeleteTableRowsContract, request, context) + const parsed = await parseRequest(v1DeleteTableRowsContract, request, context, { + validationErrorResponse: v1ValidationErrorResponse, + }) if (!parsed.success) return parsed.response const { tableId } = parsed.data.params const validated = parsed.data.body diff --git a/apps/sim/lib/api/contracts/tables.ts b/apps/sim/lib/api/contracts/tables.ts index c1107e3591d..66ddff124f3 100644 --- a/apps/sim/lib/api/contracts/tables.ts +++ b/apps/sim/lib/api/contracts/tables.ts @@ -1,5 +1,6 @@ import { isRecordLike } from '@sim/utils/object' import { z } from 'zod' +import { requiredFieldSchema } from '@/lib/api/contracts/primitives' import { type ContractJsonResponse, defineRouteContract } from '@/lib/api/contracts/types' import { ianaTimezoneSchema } from '@/lib/api/contracts/user' import type { @@ -27,7 +28,7 @@ export const columnTypeSchema = z.enum(COLUMN_TYPES) /** One choice in a `select` column. `id` is the stable cell key. */ export const selectOptionSchema = z.object({ - id: z.string().min(1, 'Option id is required'), + id: requiredFieldSchema('Option id is required'), name: z .string() .min(1, 'Option name is required') @@ -126,12 +127,12 @@ export const tableRowParamsSchema = tableIdParamsSchema.extend({ }) export const listTablesQuerySchema = z.object({ - workspaceId: z.string().min(1, 'Workspace ID is required'), + workspaceId: requiredFieldSchema('Workspace ID is required'), scope: tableScopeSchema.default('active'), }) export const getTableQuerySchema = z.object({ - workspaceId: z.string().min(1, 'Workspace ID is required'), + workspaceId: requiredFieldSchema('Workspace ID is required'), }) export const tableColumnSchema = z @@ -163,12 +164,12 @@ export const createTableBodySchema = z.object({ `Table cannot have more than ${TABLE_LIMITS.MAX_COLUMNS_PER_TABLE} columns` ), }), - workspaceId: z.string().min(1, 'Workspace ID is required'), + workspaceId: requiredFieldSchema('Workspace ID is required'), initialRowCount: z.number().int().min(0).max(100).optional(), }) export const renameTableBodySchema = z.object({ - workspaceId: z.string().min(1, 'Workspace ID is required'), + workspaceId: requiredFieldSchema('Workspace ID is required'), name: tableNameSchema, }) @@ -187,7 +188,7 @@ export const tableLocksSchema = z.object({ */ export const updateTableBodySchema = z .object({ - workspaceId: z.string().min(1, 'Workspace ID is required'), + workspaceId: requiredFieldSchema('Workspace ID is required'), name: tableNameSchema.optional(), locks: tableLocksSchema.partial().optional(), }) @@ -202,7 +203,7 @@ export const updateTableBodySchema = z }) export const createTableColumnBodySchema = z.object({ - workspaceId: z.string().min(1, 'Workspace ID is required'), + workspaceId: requiredFieldSchema('Workspace ID is required'), column: z .object({ // Optional stable id — first-party undo of a delete re-creates the column @@ -220,7 +221,7 @@ export const createTableColumnBodySchema = z.object({ }) export const updateTableColumnBodySchema = z.object({ - workspaceId: z.string().min(1, 'Workspace ID is required'), + workspaceId: requiredFieldSchema('Workspace ID is required'), columnName: columnNameSchema, updates: z .object({ @@ -235,7 +236,7 @@ export const updateTableColumnBodySchema = z.object({ }) export const deleteTableColumnBodySchema = z.object({ - workspaceId: z.string().min(1, 'Workspace ID is required'), + workspaceId: requiredFieldSchema('Workspace ID is required'), columnName: columnNameSchema, }) @@ -246,7 +247,7 @@ export const tableMetadataSchema = z.object({ }) satisfies z.ZodType export const updateTableMetadataBodySchema = z.object({ - workspaceId: z.string().min(1, 'Workspace ID is required'), + workspaceId: requiredFieldSchema('Workspace ID is required'), metadata: tableMetadataSchema, }) @@ -260,7 +261,7 @@ export const tableRowSchema = domainObjectSchema() * {@link rowAnchorMutexRefine} — Zod forbids `.omit()` on a refined schema. */ export const insertTableRowBodyBaseSchema = z.object({ - workspaceId: z.string().min(1, 'Workspace ID is required'), + workspaceId: requiredFieldSchema('Workspace ID is required'), data: rowDataSchema, position: z.number().int().min(0).optional(), /** Fractional ordering: insert directly after this row id. Takes precedence over `position`. */ @@ -283,14 +284,14 @@ export const insertTableRowBodySchema = insertTableRowBodyBaseSchema.refine(...r * unique column when omitted). */ export const upsertTableRowBodySchema = z.object({ - workspaceId: z.string().min(1, 'Workspace ID is required'), + workspaceId: requiredFieldSchema('Workspace ID is required'), data: rowDataSchema, conflictTarget: z.string().min(1).optional(), }) export const batchInsertTableRowsBodySchema = z .object({ - workspaceId: z.string().min(1, 'Workspace ID is required'), + workspaceId: requiredFieldSchema('Workspace ID is required'), rows: z .array(rowDataSchema) .min(1, 'At least one row is required') @@ -318,12 +319,12 @@ export const insertTableRowsBodySchema = z.union([ ]) export const updateTableRowBodySchema = z.object({ - workspaceId: z.string().min(1, 'Workspace ID is required'), + workspaceId: requiredFieldSchema('Workspace ID is required'), data: rowDataSchema, }) export const batchUpdateTableRowsBodySchema = z.object({ - workspaceId: z.string().min(1, 'Workspace ID is required'), + workspaceId: requiredFieldSchema('Workspace ID is required'), updates: z .array( z.object({ @@ -365,12 +366,12 @@ const optionalPositiveLimit = (max: number, label: string) => ) export const deleteTableRowBodySchema = z.object({ - workspaceId: z.string().min(1, 'Workspace ID is required'), + workspaceId: requiredFieldSchema('Workspace ID is required'), }) export const deleteTableRowsBodySchema = z .object({ - workspaceId: z.string().min(1, 'Workspace ID is required'), + workspaceId: requiredFieldSchema('Workspace ID is required'), filter: nonEmptyFilterSchema.optional(), limit: optionalPositiveLimit(TABLE_LIMITS.MAX_BULK_OPERATION_SIZE, 'Limit').optional(), rowIds: z @@ -388,7 +389,7 @@ export const deleteTableRowsBodySchema = z /** Unrefined base so v1 contracts can `.extend()` — consumers use {@link tableRowsQuerySchema}. */ export const tableRowsQueryBaseSchema = z.object({ - workspaceId: z.string().min(1, 'Workspace ID is required'), + workspaceId: requiredFieldSchema('Workspace ID is required'), filter: domainObjectSchema().optional(), sort: domainObjectSchema().optional(), /** @@ -435,7 +436,7 @@ export const tableRowsQuerySchema = tableRowsQueryBaseSchema.refine( ) export const updateRowsByFilterBodySchema = z.object({ - workspaceId: z.string().min(1, 'Workspace ID is required'), + workspaceId: requiredFieldSchema('Workspace ID is required'), filter: nonEmptyFilterSchema, data: rowDataSchema, limit: optionalPositiveLimit(TABLE_LIMITS.MAX_BULK_OPERATION_SIZE, 'Limit').optional(), @@ -488,9 +489,9 @@ export const createTableContract = defineRouteContract({ * `importing` table and runs the load in the background. */ export const importTableAsyncBodySchema = z.object({ - workspaceId: z.string().min(1, 'Workspace ID is required'), - fileKey: z.string().min(1, 'fileKey is required'), - fileName: z.string().min(1, 'fileName is required'), + workspaceId: requiredFieldSchema('Workspace ID is required'), + fileKey: requiredFieldSchema('fileKey is required'), + fileName: requiredFieldSchema('fileName is required'), /** * Whether the source object is deleted once the import is terminal. Defaults to true (the upload * flow stores a single-use temp object); pass false when importing an existing workspace file @@ -650,8 +651,8 @@ export const listTableRowsContract = defineRouteContract({ }) export const findTableRowsQuerySchema = z.object({ - workspaceId: z.string().min(1, 'Workspace ID is required'), - q: z.string().min(1, 'Search query is required'), + workspaceId: requiredFieldSchema('Workspace ID is required'), + q: requiredFieldSchema('Search query is required'), filter: domainObjectSchema().optional(), sort: domainObjectSchema().optional(), }) @@ -799,9 +800,9 @@ export const csvExtensionSchema = z.enum(['csv', 'tsv'], { * resolved column mapping (the dialog computes them from its preview). */ export const importIntoTableAsyncBodySchema = z.object({ - workspaceId: z.string().min(1, 'Workspace ID is required'), - fileKey: z.string().min(1, 'fileKey is required'), - fileName: z.string().min(1, 'fileName is required'), + workspaceId: requiredFieldSchema('Workspace ID is required'), + fileKey: requiredFieldSchema('fileKey is required'), + fileName: requiredFieldSchema('fileName is required'), mode: csvImportModeSchema, mapping: z.record(z.string(), z.string().nullable()).optional(), createColumns: z.array(z.string()).optional(), @@ -868,7 +869,7 @@ export const tableExportFormatSchema = z .default('csv') export const exportTableAsyncBodySchema = z.object({ - workspaceId: z.string().min(1, 'Workspace ID is required'), + workspaceId: requiredFieldSchema('Workspace ID is required'), format: z.enum(['csv', 'json']).default('csv'), }) @@ -904,7 +905,7 @@ export const tableJobSummarySchema = z.object({ export type TableJobSummary = z.output export const listTableJobsQuerySchema = z.object({ - workspaceId: z.string().min(1, 'Workspace ID is required'), + workspaceId: requiredFieldSchema('Workspace ID is required'), type: z.literal('export'), }) @@ -924,8 +925,8 @@ export const listTableJobsContract = defineRouteContract({ }) export const exportDownloadQuerySchema = z.object({ - workspaceId: z.string().min(1, 'Workspace ID is required'), - jobId: z.string().min(1, 'Job ID is required'), + workspaceId: requiredFieldSchema('Workspace ID is required'), + jobId: requiredFieldSchema('Job ID is required'), }) /** Resolves a completed export job to a short-lived presigned download URL. */ @@ -1079,7 +1080,7 @@ export const deleteTableRowsContract = defineRouteContract({ * worker deletes in paginated batches. Omitting `filter` deletes the whole table (at the cutoff). */ export const deleteTableRowsAsyncBodySchema = z.object({ - workspaceId: z.string().min(1, 'Workspace ID is required'), + workspaceId: requiredFieldSchema('Workspace ID is required'), filter: nonEmptyFilterSchema.optional(), excludeRowIds: z .array(z.string().min(1)) @@ -1151,7 +1152,7 @@ export const groupIdParamsSchema = tableIdParamsSchema.extend({ }) export const addWorkflowGroupBodySchema = z.object({ - workspaceId: z.string().min(1, 'Workspace ID is required'), + workspaceId: requiredFieldSchema('Workspace ID is required'), group: z.object({ id: z.string().min(1), /** Workflow id for manual groups; `''` (or omitted) for enrichment groups. */ @@ -1196,7 +1197,7 @@ const workflowGroupMappingUpdateSchema = z.object({ }) export const updateWorkflowGroupBodySchema = z.object({ - workspaceId: z.string().min(1, 'Workspace ID is required'), + workspaceId: requiredFieldSchema('Workspace ID is required'), groupId: z.string().min(1), workflowId: z.string().min(1).optional(), name: z.string().optional(), @@ -1220,7 +1221,7 @@ export const updateWorkflowGroupBodySchema = z.object({ }) export const deleteWorkflowGroupBodySchema = z.object({ - workspaceId: z.string().min(1, 'Workspace ID is required'), + workspaceId: requiredFieldSchema('Workspace ID is required'), groupId: z.string().min(1), }) @@ -1272,7 +1273,7 @@ export const deleteWorkflowGroupContract = defineRouteContract({ */ export const cancelTableRunsBodySchema = z .object({ - workspaceId: z.string().min(1, 'Workspace ID is required'), + workspaceId: requiredFieldSchema('Workspace ID is required'), scope: z.enum(['all', 'row']), rowId: z.string().min(1).optional(), filter: domainObjectSchema().optional(), @@ -1321,8 +1322,8 @@ export const cancelTableRunsContract = defineRouteContract({ }) export const cancelTableJobBodySchema = z.object({ - workspaceId: z.string().min(1, 'Workspace ID is required'), - jobId: z.string().min(1, 'Job ID is required'), + workspaceId: requiredFieldSchema('Workspace ID is required'), + jobId: requiredFieldSchema('Job ID is required'), }) /** @@ -1373,7 +1374,7 @@ export const runLimitSchema = z.object({ export const runColumnBodySchema = z .object({ - workspaceId: z.string().min(1, 'Workspace ID is required'), + workspaceId: requiredFieldSchema('Workspace ID is required'), groupIds: z.array(z.string().min(1)).min(1), runMode: z.enum(['all', 'incomplete']).default('all'), rowIds: z.array(z.string().min(1)).min(1).optional(), diff --git a/apps/sim/lib/api/contracts/v1/knowledge/index.ts b/apps/sim/lib/api/contracts/v1/knowledge/index.ts index c682b2a4c09..0eb7df6c308 100644 --- a/apps/sim/lib/api/contracts/v1/knowledge/index.ts +++ b/apps/sim/lib/api/contracts/v1/knowledge/index.ts @@ -37,7 +37,7 @@ export const v1ListKnowledgeBasesQuerySchema = z.object({ /** POST `/api/v1/knowledge` — create a knowledge base. */ export const v1CreateKnowledgeBaseBodySchema = z.object({ workspaceId: requiredFieldSchema('Workspace ID is required'), - name: z.string().min(1, 'Name is required').max(255, 'Name must be 255 characters or less'), + name: requiredFieldSchema('Name is required').max(255, 'Name must be 255 characters or less'), description: z .string() .max( @@ -124,7 +124,7 @@ export const v1KnowledgeSearchBodySchema = z .object({ workspaceId: requiredFieldSchema('Workspace ID is required'), knowledgeBaseIds: z.union([ - z.string().min(1, 'Knowledge base ID is required'), + requiredFieldSchema('Knowledge base ID is required'), z .array(z.string().min(1)) .min(1, 'At least one knowledge base ID is required') diff --git a/apps/sim/lib/api/contracts/workflows.ts b/apps/sim/lib/api/contracts/workflows.ts index 23fc05c736c..3c12ae30d42 100644 --- a/apps/sim/lib/api/contracts/workflows.ts +++ b/apps/sim/lib/api/contracts/workflows.ts @@ -1,5 +1,5 @@ import { z } from 'zod' -import { workspaceIdSchema } from '@/lib/api/contracts/primitives' +import { requiredFieldSchema, workspaceIdSchema } from '@/lib/api/contracts/primitives' import { defineRouteContract } from '@/lib/api/contracts/types' const subBlockValuesSchema = z.record(z.string(), z.record(z.string(), z.unknown())) @@ -229,7 +229,7 @@ export const workflowListItemSchema = z.object({ export const createWorkflowBodySchema = z.object({ id: z.string().uuid().optional(), - name: z.string().min(1, 'Name is required'), + name: requiredFieldSchema('Name is required'), description: z.string().optional().default(''), workspaceId: z.string().optional(), folderId: z.string().nullable().optional(), @@ -254,7 +254,7 @@ export type CreateWorkflowBody = z.input export type CreateWorkflowResponse = z.output export const duplicateWorkflowBodySchema = z.object({ - name: z.string().min(1, 'Name is required'), + name: requiredFieldSchema('Name is required'), description: z.string().optional(), workspaceId: z.string().optional(), folderId: z.string().nullable().optional(), @@ -278,7 +278,7 @@ export type DuplicateWorkflowBody = z.input export type DuplicateWorkflowResponse = z.output export const updateWorkflowBodySchema = z.object({ - name: z.string().min(1, 'Name is required').optional(), + name: requiredFieldSchema('Name is required').optional(), description: z.string().optional(), folderId: z.string().nullable().optional(), sortOrder: z.number().int().min(0).optional(), @@ -302,7 +302,7 @@ export const reorderWorkflowsBodySchema = z.object({ export type ReorderWorkflowsBody = z.input export const executeWorkflowRunFromBlockSchema = z.object({ - startBlockId: z.string().min(1, 'Start block ID is required'), + startBlockId: requiredFieldSchema('Start block ID is required'), sourceSnapshot: z .object({ blockStates: z.record(z.string(), z.any()), @@ -454,14 +454,14 @@ export const workflowLogResultSchema = z.object({ export const workflowLogBodySchema = z.object({ logs: z.array(z.any()).optional(), - executionId: z.string().min(1, 'Execution ID is required').optional(), + executionId: requiredFieldSchema('Execution ID is required').optional(), result: workflowLogResultSchema.optional(), }) export type WorkflowLogBody = z.input export const importWorkflowAsSuperuserBodySchema = z.object({ - workflowId: z.string().min(1, 'Workflow ID is required'), - targetWorkspaceId: z.string().min(1, 'Target workspace ID is required'), + workflowId: requiredFieldSchema('Workflow ID is required'), + targetWorkspaceId: requiredFieldSchema('Target workspace ID is required'), }) export type ImportWorkflowAsSuperuserBody = z.input From 607ea4aa28cfd1eb04ab3dae28e9788920f7ac60 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 28 Jul 2026 12:31:06 -0700 Subject: [PATCH 3/6] refactor(api): publish quota headers from one chokepoint, not 32 call sites MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A quality pass found the previous commit only made the happy path consistent. Those three route families have 117 response sites; 32 got headers. The other 85 are the error paths — 400/403/404/500 — which are exactly the responses a client is deciding whether to retry, and they published no quota at all. `checkRateLimit` now records the bucket snapshot against the request, and `withRouteHandler` attaches the headers next to the `x-request-id` it already sets, on both the success and the unhandled-error branch. Every v1 response carries the quota now, and a new v1 route gets it without remembering to. The carrier is a WeakMap keyed by the request, so it needs no cleanup and routes that never record a snapshot — everything outside v1 — are untouched. This deletes more than it adds: the 32 decorations are gone, and so is the `rateLimit` parameter that had been threaded into `handleBatchInsert` purely so a business-logic helper could decorate its own response. Also from the same pass: - One definition of the header trio. `createApiResponse` had its own copy, so after the last commit there were two; both now build from `buildRateLimitHeaders`. - `v1ValidationErrorResponse` delegates to the shared `validationErrorResponse` instead of hand-rolling the same body, and takes a fallback message, which collapses four route closures that differed only in that string. - 36 sites wrote `requiredFieldSchema('Workspace ID is required')` — the verbatim definition of the exported `workspaceIdSchema`. Using the primitive is the whole point of having it; they now import it. - Dropped TSDoc that had gone stale or contradicted the call sites it advised. --- apps/sim/app/api/v1/audit-logs/route.test.ts | 2 + apps/sim/app/api/v1/audit-logs/route.ts | 17 +- .../app/api/v1/files/[fileId]/route.test.ts | 1 - apps/sim/app/api/v1/files/[fileId]/route.ts | 15 +- apps/sim/app/api/v1/files/route.ts | 63 +++--- .../[id]/documents/[documentId]/route.ts | 66 +++---- .../v1/knowledge/[id]/documents/route.test.ts | 1 - .../api/v1/knowledge/[id]/documents/route.ts | 82 ++++---- apps/sim/app/api/v1/knowledge/[id]/route.ts | 47 ++--- apps/sim/app/api/v1/knowledge/route.ts | 31 ++- .../app/api/v1/knowledge/search/route.test.ts | 1 - apps/sim/app/api/v1/knowledge/search/route.ts | 62 +++--- apps/sim/app/api/v1/logs/meta.ts | 5 +- apps/sim/app/api/v1/logs/route.ts | 12 +- apps/sim/app/api/v1/middleware.test.ts | 107 ++++++++--- apps/sim/app/api/v1/middleware.ts | 45 ++--- .../api/v1/tables/[tableId]/columns/route.ts | 40 ++-- apps/sim/app/api/v1/tables/[tableId]/route.ts | 59 +++--- .../v1/tables/[tableId]/rows/[rowId]/route.ts | 80 ++++---- .../app/api/v1/tables/[tableId]/rows/route.ts | 180 ++++++++---------- .../v1/tables/[tableId]/rows/upsert/route.ts | 38 ++-- apps/sim/app/api/v1/tables/route.ts | 97 +++++----- .../app/api/v1/workflows/import/route.test.ts | 2 + apps/sim/app/api/v1/workflows/import/route.ts | 11 +- apps/sim/app/api/v1/workflows/route.ts | 12 +- apps/sim/lib/api/contracts/primitives.ts | 17 +- apps/sim/lib/api/contracts/tables.ts | 64 +++---- .../lib/api/contracts/v1/knowledge/index.ts | 8 +- apps/sim/lib/api/contracts/v1/tables/index.ts | 4 +- apps/sim/lib/api/contracts/workflows.ts | 8 +- apps/sim/lib/api/server/rate-limit-context.ts | 53 ++++++ apps/sim/lib/core/utils/with-route-handler.ts | 25 ++- 32 files changed, 610 insertions(+), 645 deletions(-) create mode 100644 apps/sim/lib/api/server/rate-limit-context.ts diff --git a/apps/sim/app/api/v1/audit-logs/route.test.ts b/apps/sim/app/api/v1/audit-logs/route.test.ts index 336f50371c1..9fa39f447eb 100644 --- a/apps/sim/app/api/v1/audit-logs/route.test.ts +++ b/apps/sim/app/api/v1/audit-logs/route.test.ts @@ -26,6 +26,8 @@ const { vi.mock('@/app/api/v1/middleware', () => ({ checkRateLimit: mockCheckRateLimit, createRateLimitResponse: vi.fn(), + v1ValidationErrorResponse: (e: { issues: unknown[] }) => + NextResponse.json({ error: 'Validation error', details: e.issues }, { status: 400 }), })) vi.mock('@/app/api/v1/audit-logs/auth', () => ({ diff --git a/apps/sim/app/api/v1/audit-logs/route.ts b/apps/sim/app/api/v1/audit-logs/route.ts index 227cb7f8e64..c6eca39ffe2 100644 --- a/apps/sim/app/api/v1/audit-logs/route.ts +++ b/apps/sim/app/api/v1/audit-logs/route.ts @@ -24,7 +24,7 @@ import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { type NextRequest, NextResponse } from 'next/server' import { v1ListAuditLogsContract } from '@/lib/api/contracts/v1/audit-logs' -import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' +import { parseRequest } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { validateEnterpriseAuditAccess } from '@/app/api/v1/audit-logs/auth' import { formatAuditLogEntry } from '@/app/api/v1/audit-logs/format' @@ -35,7 +35,11 @@ import { queryAuditLogs, } from '@/app/api/v1/audit-logs/query' import { createApiResponse, getUserLimits } from '@/app/api/v1/logs/meta' -import { checkRateLimit, createRateLimitResponse } from '@/app/api/v1/middleware' +import { + checkRateLimit, + createRateLimitResponse, + v1ValidationErrorResponse, +} from '@/app/api/v1/middleware' const logger = createLogger('V1AuditLogsAPI') @@ -65,14 +69,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { request, {}, { - validationErrorResponse: (error) => - NextResponse.json( - { - error: getValidationErrorMessage(error, 'Invalid parameters'), - details: error.issues, - }, - { status: 400 } - ), + validationErrorResponse: (error) => v1ValidationErrorResponse(error, 'Invalid parameters'), } ) if (!parsed.success) return parsed.response diff --git a/apps/sim/app/api/v1/files/[fileId]/route.test.ts b/apps/sim/app/api/v1/files/[fileId]/route.test.ts index f682a2173a9..34cbd6a1e79 100644 --- a/apps/sim/app/api/v1/files/[fileId]/route.test.ts +++ b/apps/sim/app/api/v1/files/[fileId]/route.test.ts @@ -20,7 +20,6 @@ vi.mock('@/app/api/v1/middleware', () => ({ checkRateLimit: mockCheckRateLimit, createRateLimitResponse: () => new Response('rate limited', { status: 429 }), validateWorkspaceAccess: mockValidateWorkspaceAccess, - rateLimitHeaders: () => ({}), v1ValidationErrorResponse: (e: { issues: unknown[] }) => NextResponse.json({ error: 'Validation error', details: e.issues }, { status: 400 }), })) diff --git a/apps/sim/app/api/v1/files/[fileId]/route.ts b/apps/sim/app/api/v1/files/[fileId]/route.ts index c7c3ee53209..89c63c37d31 100644 --- a/apps/sim/app/api/v1/files/[fileId]/route.ts +++ b/apps/sim/app/api/v1/files/[fileId]/route.ts @@ -15,7 +15,6 @@ import { performDeleteWorkspaceFileItems } from '@/lib/workspace-files/orchestra import { checkRateLimit, createRateLimitResponse, - rateLimitHeaders, v1ValidationErrorResponse, validateWorkspaceAccess, } from '@/app/api/v1/middleware' @@ -89,7 +88,6 @@ export const GET = withRouteHandler(async (request: NextRequest, context: FileRo { status: 200, headers: { - ...rateLimitHeaders(rateLimit), 'Content-Type': contentType || fileRecord.type || 'application/octet-stream', 'Content-Disposition': `attachment; filename="${fileRecord.name.replace(/[^\w.-]/g, '_')}"; filename*=UTF-8''${encodeURIComponent(fileRecord.name)}`, 'Content-Length': String(buffer.length), @@ -153,15 +151,12 @@ export const DELETE = withRouteHandler(async (request: NextRequest, context: Fil `[${requestId}] Archived file: ${fileRecord.name} (${fileId}) from workspace ${workspaceId}` ) - return NextResponse.json( - { - success: true, - data: { - message: 'File archived successfully', - }, + return NextResponse.json({ + success: true, + data: { + message: 'File archived successfully', }, - { headers: rateLimitHeaders(rateLimit) } - ) + }) } catch (error) { logger.error(`[${requestId}] Error deleting file:`, error) return NextResponse.json({ error: 'Failed to delete file' }, { status: 500 }) diff --git a/apps/sim/app/api/v1/files/route.ts b/apps/sim/app/api/v1/files/route.ts index 6fc830eeefe..c101b8cf597 100644 --- a/apps/sim/app/api/v1/files/route.ts +++ b/apps/sim/app/api/v1/files/route.ts @@ -22,7 +22,6 @@ import { checkRateLimit, checkWorkspaceScope, createRateLimitResponse, - rateLimitHeaders, v1ValidationErrorResponse, validateWorkspaceAccess, } from '@/app/api/v1/middleware' @@ -62,25 +61,22 @@ export const GET = withRouteHandler(async (request: NextRequest) => { const files = await listWorkspaceFiles(workspaceId) - return NextResponse.json( - { - success: true, - data: { - files: files.map((f) => ({ - id: f.id, - name: f.name, - size: f.size, - type: f.type, - key: f.key, - uploadedBy: f.uploadedBy, - uploadedAt: - f.uploadedAt instanceof Date ? f.uploadedAt.toISOString() : String(f.uploadedAt), - })), - totalCount: files.length, - }, + return NextResponse.json({ + success: true, + data: { + files: files.map((f) => ({ + id: f.id, + name: f.name, + size: f.size, + type: f.type, + key: f.key, + uploadedBy: f.uploadedBy, + uploadedAt: + f.uploadedAt instanceof Date ? f.uploadedAt.toISOString() : String(f.uploadedAt), + })), + totalCount: files.length, }, - { headers: rateLimitHeaders(rateLimit) } - ) + }) } catch (error) { logger.error(`[${requestId}] Error listing files:`, error) return NextResponse.json({ error: 'Failed to list files' }, { status: 500 }) @@ -183,24 +179,21 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ? String(fileRecord.uploadedAt) : new Date().toISOString() - return NextResponse.json( - { - success: true, - data: { - file: { - id: userFile.id, - name: userFile.name, - size: userFile.size, - type: userFile.type, - key: userFile.key, - uploadedBy: userId, - uploadedAt, - }, - message: 'File uploaded successfully', + return NextResponse.json({ + success: true, + data: { + file: { + id: userFile.id, + name: userFile.name, + size: userFile.size, + type: userFile.type, + key: userFile.key, + uploadedBy: userId, + uploadedAt, }, + message: 'File uploaded successfully', }, - { headers: rateLimitHeaders(rateLimit) } - ) + }) } catch (error) { if (isPayloadSizeLimitError(error)) { return NextResponse.json({ error: error.message }, { status: 413 }) diff --git a/apps/sim/app/api/v1/knowledge/[id]/documents/[documentId]/route.ts b/apps/sim/app/api/v1/knowledge/[id]/documents/[documentId]/route.ts index 3c35c85c9fa..94c4832f265 100644 --- a/apps/sim/app/api/v1/knowledge/[id]/documents/[documentId]/route.ts +++ b/apps/sim/app/api/v1/knowledge/[id]/documents/[documentId]/route.ts @@ -11,11 +11,7 @@ import { parseRequest } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { deleteDocument } from '@/lib/knowledge/documents/service' import { handleError, resolveKnowledgeBase, serializeDate } from '@/app/api/v1/knowledge/utils' -import { - authenticateRequest, - rateLimitHeaders, - v1ValidationErrorResponse, -} from '@/app/api/v1/middleware' +import { authenticateRequest, v1ValidationErrorResponse } from '@/app/api/v1/middleware' export const dynamic = 'force-dynamic' export const revalidate = 0 @@ -85,33 +81,30 @@ export const GET = withRouteHandler( const doc = docs[0] - return NextResponse.json( - { - success: true, - data: { - document: { - id: doc.id, - knowledgeBaseId: doc.knowledgeBaseId, - filename: doc.filename, - fileSize: doc.fileSize, - mimeType: doc.mimeType, - processingStatus: doc.processingStatus, - processingError: doc.processingError, - processingStartedAt: serializeDate(doc.processingStartedAt), - processingCompletedAt: serializeDate(doc.processingCompletedAt), - chunkCount: doc.chunkCount, - tokenCount: doc.tokenCount, - characterCount: doc.characterCount, - enabled: doc.enabled, - connectorId: doc.connectorId, - connectorType: doc.connectorType, - sourceUrl: doc.sourceUrl, - createdAt: serializeDate(doc.uploadedAt), - }, + return NextResponse.json({ + success: true, + data: { + document: { + id: doc.id, + knowledgeBaseId: doc.knowledgeBaseId, + filename: doc.filename, + fileSize: doc.fileSize, + mimeType: doc.mimeType, + processingStatus: doc.processingStatus, + processingError: doc.processingError, + processingStartedAt: serializeDate(doc.processingStartedAt), + processingCompletedAt: serializeDate(doc.processingCompletedAt), + chunkCount: doc.chunkCount, + tokenCount: doc.tokenCount, + characterCount: doc.characterCount, + enabled: doc.enabled, + connectorId: doc.connectorId, + connectorType: doc.connectorType, + sourceUrl: doc.sourceUrl, + createdAt: serializeDate(doc.uploadedAt), }, }, - { headers: rateLimitHeaders(rateLimit) } - ) + }) } catch (error) { return handleError(requestId, error, 'Failed to get document') } @@ -173,15 +166,12 @@ export const DELETE = withRouteHandler( request, }) - return NextResponse.json( - { - success: true, - data: { - message: 'Document deleted successfully', - }, + return NextResponse.json({ + success: true, + data: { + message: 'Document deleted successfully', }, - { headers: rateLimitHeaders(rateLimit) } - ) + }) } catch (error) { return handleError(requestId, error, 'Failed to delete document') } diff --git a/apps/sim/app/api/v1/knowledge/[id]/documents/route.test.ts b/apps/sim/app/api/v1/knowledge/[id]/documents/route.test.ts index e2d94afd6e1..18898d704af 100644 --- a/apps/sim/app/api/v1/knowledge/[id]/documents/route.test.ts +++ b/apps/sim/app/api/v1/knowledge/[id]/documents/route.test.ts @@ -46,7 +46,6 @@ const SYSTEM_BILLING_ATTRIBUTION = { vi.mock('@/app/api/v1/middleware', () => ({ authenticateRequest: mockAuthenticateRequest, - rateLimitHeaders: () => ({}), v1ValidationErrorResponse: (e: { issues: unknown[] }) => NextResponse.json({ error: 'Validation error', details: e.issues }, { status: 400 }), })) diff --git a/apps/sim/app/api/v1/knowledge/[id]/documents/route.ts b/apps/sim/app/api/v1/knowledge/[id]/documents/route.ts index 6c7f2485c75..dfd08d4c892 100644 --- a/apps/sim/app/api/v1/knowledge/[id]/documents/route.ts +++ b/apps/sim/app/api/v1/knowledge/[id]/documents/route.ts @@ -26,11 +26,7 @@ import type { DocumentSortField, SortOrder } from '@/lib/knowledge/documents/typ import { uploadWorkspaceFile } from '@/lib/uploads/contexts/workspace' import { validateFileType } from '@/lib/uploads/utils/validation' import { handleError, resolveKnowledgeBase, serializeDate } from '@/app/api/v1/knowledge/utils' -import { - authenticateRequest, - rateLimitHeaders, - v1ValidationErrorResponse, -} from '@/app/api/v1/middleware' +import { authenticateRequest, v1ValidationErrorResponse } from '@/app/api/v1/middleware' export const dynamic = 'force-dynamic' export const revalidate = 0 @@ -73,28 +69,25 @@ export const GET = withRouteHandler(async (request: NextRequest, context: Docume requestId ) - return NextResponse.json( - { - success: true, - data: { - documents: documentsResult.documents.map((doc) => ({ - id: doc.id, - knowledgeBaseId, - filename: doc.filename, - fileSize: doc.fileSize, - mimeType: doc.mimeType, - processingStatus: doc.processingStatus, - chunkCount: doc.chunkCount, - tokenCount: doc.tokenCount, - characterCount: doc.characterCount, - enabled: doc.enabled, - createdAt: serializeDate(doc.uploadedAt), - })), - pagination: documentsResult.pagination, - }, + return NextResponse.json({ + success: true, + data: { + documents: documentsResult.documents.map((doc) => ({ + id: doc.id, + knowledgeBaseId, + filename: doc.filename, + fileSize: doc.fileSize, + mimeType: doc.mimeType, + processingStatus: doc.processingStatus, + chunkCount: doc.chunkCount, + tokenCount: doc.tokenCount, + characterCount: doc.characterCount, + enabled: doc.enabled, + createdAt: serializeDate(doc.uploadedAt), + })), + pagination: documentsResult.pagination, }, - { headers: rateLimitHeaders(rateLimit) } - ) + }) } catch (error) { return handleError(requestId, error, 'Failed to list documents') } @@ -238,28 +231,25 @@ export const POST = withRouteHandler( request, }) - return NextResponse.json( - { - success: true, - data: { - document: { - id: newDocument.id, - knowledgeBaseId, - filename: newDocument.filename, - fileSize: newDocument.fileSize, - mimeType: newDocument.mimeType, - processingStatus: 'pending', - chunkCount: 0, - tokenCount: 0, - characterCount: 0, - enabled: newDocument.enabled, - createdAt: serializeDate(newDocument.uploadedAt), - }, - message: 'Document uploaded successfully. Processing will begin shortly.', + return NextResponse.json({ + success: true, + data: { + document: { + id: newDocument.id, + knowledgeBaseId, + filename: newDocument.filename, + fileSize: newDocument.fileSize, + mimeType: newDocument.mimeType, + processingStatus: 'pending', + chunkCount: 0, + tokenCount: 0, + characterCount: 0, + enabled: newDocument.enabled, + createdAt: serializeDate(newDocument.uploadedAt), }, + message: 'Document uploaded successfully. Processing will begin shortly.', }, - { headers: rateLimitHeaders(rateLimit) } - ) + }) } catch (error) { return handleError(requestId, error, 'Failed to upload document') } diff --git a/apps/sim/app/api/v1/knowledge/[id]/route.ts b/apps/sim/app/api/v1/knowledge/[id]/route.ts index 95d2e58e24c..8dbb280559f 100644 --- a/apps/sim/app/api/v1/knowledge/[id]/route.ts +++ b/apps/sim/app/api/v1/knowledge/[id]/route.ts @@ -13,11 +13,7 @@ import { handleError, resolveKnowledgeBase, } from '@/app/api/v1/knowledge/utils' -import { - authenticateRequest, - rateLimitHeaders, - v1ValidationErrorResponse, -} from '@/app/api/v1/middleware' +import { authenticateRequest, v1ValidationErrorResponse } from '@/app/api/v1/middleware' export const dynamic = 'force-dynamic' export const revalidate = 0 @@ -42,15 +38,12 @@ export const GET = withRouteHandler(async (request: NextRequest, context: Knowle const result = await resolveKnowledgeBase(id, parsed.data.query.workspaceId, userId, rateLimit) if (result instanceof NextResponse) return result - return NextResponse.json( - { - success: true, - data: { - knowledgeBase: formatKnowledgeBase(result.kb), - }, + return NextResponse.json({ + success: true, + data: { + knowledgeBase: formatKnowledgeBase(result.kb), }, - { headers: rateLimitHeaders(rateLimit) } - ) + }) } catch (error) { return handleError(requestId, error, 'Failed to get knowledge base') } @@ -97,16 +90,13 @@ export const PUT = withRouteHandler(async (request: NextRequest, context: Knowle request, }) - return NextResponse.json( - { - success: true, - data: { - knowledgeBase: formatKnowledgeBase(updatedKb), - message: 'Knowledge base updated successfully', - }, + return NextResponse.json({ + success: true, + data: { + knowledgeBase: formatKnowledgeBase(updatedKb), + message: 'Knowledge base updated successfully', }, - { headers: rateLimitHeaders(rateLimit) } - ) + }) } catch (error) { return handleError(requestId, error, 'Failed to update knowledge base') } @@ -148,15 +138,12 @@ export const DELETE = withRouteHandler( request, }) - return NextResponse.json( - { - success: true, - data: { - message: 'Knowledge base deleted successfully', - }, + return NextResponse.json({ + success: true, + data: { + message: 'Knowledge base deleted successfully', }, - { headers: rateLimitHeaders(rateLimit) } - ) + }) } catch (error) { return handleError(requestId, error, 'Failed to delete knowledge base') } diff --git a/apps/sim/app/api/v1/knowledge/route.ts b/apps/sim/app/api/v1/knowledge/route.ts index d4ed1b77de8..5b608484025 100644 --- a/apps/sim/app/api/v1/knowledge/route.ts +++ b/apps/sim/app/api/v1/knowledge/route.ts @@ -11,7 +11,6 @@ import { createKnowledgeBase, getKnowledgeBases } from '@/lib/knowledge/service' import { formatKnowledgeBase, handleError } from '@/app/api/v1/knowledge/utils' import { authenticateRequest, - rateLimitHeaders, v1ValidationErrorResponse, validateWorkspaceAccess, } from '@/app/api/v1/middleware' @@ -43,16 +42,13 @@ export const GET = withRouteHandler(async (request: NextRequest) => { const knowledgeBases = await getKnowledgeBases(userId, workspaceId) - return NextResponse.json( - { - success: true, - data: { - knowledgeBases: knowledgeBases.map(formatKnowledgeBase), - totalCount: knowledgeBases.length, - }, + return NextResponse.json({ + success: true, + data: { + knowledgeBases: knowledgeBases.map(formatKnowledgeBase), + totalCount: knowledgeBases.length, }, - { headers: rateLimitHeaders(rateLimit) } - ) + }) } catch (error) { return handleError(requestId, error, 'Failed to list knowledge bases') } @@ -105,16 +101,13 @@ export const POST = withRouteHandler(async (request: NextRequest) => { request, }) - return NextResponse.json( - { - success: true, - data: { - knowledgeBase: formatKnowledgeBase(kb), - message: 'Knowledge base created successfully', - }, + return NextResponse.json({ + success: true, + data: { + knowledgeBase: formatKnowledgeBase(kb), + message: 'Knowledge base created successfully', }, - { headers: rateLimitHeaders(rateLimit) } - ) + }) } catch (error) { return handleError(requestId, error, 'Failed to create knowledge base') } diff --git a/apps/sim/app/api/v1/knowledge/search/route.test.ts b/apps/sim/app/api/v1/knowledge/search/route.test.ts index db33c47eb05..978fbfdf751 100644 --- a/apps/sim/app/api/v1/knowledge/search/route.test.ts +++ b/apps/sim/app/api/v1/knowledge/search/route.test.ts @@ -78,7 +78,6 @@ vi.mock('@/lib/knowledge/embeddings', () => ({ vi.mock('@/app/api/v1/middleware', () => ({ authenticateRequest: mockAuthenticateRequest, validateWorkspaceAccess: mockValidateWorkspaceAccess, - rateLimitHeaders: () => ({}), v1ValidationErrorResponse: (e: { issues: unknown[] }) => NextResponse.json({ error: 'Validation error', details: e.issues }, { status: 400 }), })) diff --git a/apps/sim/app/api/v1/knowledge/search/route.ts b/apps/sim/app/api/v1/knowledge/search/route.ts index 979b7f51a59..3ac848b303d 100644 --- a/apps/sim/app/api/v1/knowledge/search/route.ts +++ b/apps/sim/app/api/v1/knowledge/search/route.ts @@ -25,7 +25,6 @@ import { checkKnowledgeBaseAccess, type KnowledgeBaseAccessResult } from '@/app/ import { handleError } from '@/app/api/v1/knowledge/utils' import { authenticateRequest, - rateLimitHeaders, v1ValidationErrorResponse, validateWorkspaceAccess, } from '@/app/api/v1/middleware' @@ -268,41 +267,38 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const documentIds = results.map((r) => r.documentId) const documentMetadataMap = await getDocumentMetadataByIds(documentIds) - return NextResponse.json( - { - success: true, - data: { - results: results.map((result) => { - const kbTagMap = tagDefinitionsMap[result.knowledgeBaseId] || {} - const tags: Record = {} - - ALL_TAG_SLOTS.forEach((slot) => { - const tagValue = result[slot as keyof SearchResult] - if (tagValue !== null && tagValue !== undefined) { - const displayName = kbTagMap[slot] || slot - tags[displayName] = tagValue as string | number | boolean | Date | null - } - }) + return NextResponse.json({ + success: true, + data: { + results: results.map((result) => { + const kbTagMap = tagDefinitionsMap[result.knowledgeBaseId] || {} + const tags: Record = {} - const docMeta = documentMetadataMap[result.documentId] - return { - documentId: result.documentId, - documentName: docMeta?.filename || undefined, - sourceUrl: docMeta?.sourceUrl ?? null, - content: result.content, - chunkIndex: result.chunkIndex, - metadata: tags, - similarity: hasQuery ? 1 - result.distance : 1, + ALL_TAG_SLOTS.forEach((slot) => { + const tagValue = result[slot as keyof SearchResult] + if (tagValue !== null && tagValue !== undefined) { + const displayName = kbTagMap[slot] || slot + tags[displayName] = tagValue as string | number | boolean | Date | null } - }), - query: query || '', - knowledgeBaseIds: accessibleKbIds, - topK, - totalResults: results.length, - }, + }) + + const docMeta = documentMetadataMap[result.documentId] + return { + documentId: result.documentId, + documentName: docMeta?.filename || undefined, + sourceUrl: docMeta?.sourceUrl ?? null, + content: result.content, + chunkIndex: result.chunkIndex, + metadata: tags, + similarity: hasQuery ? 1 - result.distance : 1, + } + }), + query: query || '', + knowledgeBaseIds: accessibleKbIds, + topK, + totalResults: results.length, }, - { headers: rateLimitHeaders(rateLimit) } - ) + }) } catch (error) { return handleError(requestId, error, 'Failed to perform search') } diff --git a/apps/sim/app/api/v1/logs/meta.ts b/apps/sim/app/api/v1/logs/meta.ts index 1db305e1e25..47d374c7a8d 100644 --- a/apps/sim/app/api/v1/logs/meta.ts +++ b/apps/sim/app/api/v1/logs/meta.ts @@ -1,3 +1,4 @@ +import { buildRateLimitHeaders } from '@/lib/api/server/rate-limit-context' import { checkServerSideUsageLimits } from '@/lib/billing' import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription' import { getEffectiveCurrentPeriodCost } from '@/lib/billing/core/usage' @@ -74,9 +75,7 @@ export function createApiResponse( limits, }, headers: { - 'X-RateLimit-Limit': apiRateLimit.limit.toString(), - 'X-RateLimit-Remaining': apiRateLimit.remaining.toString(), - 'X-RateLimit-Reset': apiRateLimit.resetAt.toISOString(), + ...buildRateLimitHeaders(apiRateLimit), }, } } diff --git a/apps/sim/app/api/v1/logs/route.ts b/apps/sim/app/api/v1/logs/route.ts index bd6a2185dd5..be40f9ae2dd 100644 --- a/apps/sim/app/api/v1/logs/route.ts +++ b/apps/sim/app/api/v1/logs/route.ts @@ -5,7 +5,7 @@ import { generateId } from '@sim/utils/id' import { eq, sql } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { v1ListLogsContract } from '@/lib/api/contracts/v1/logs' -import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' +import { parseRequest } from '@/lib/api/server' import { MATERIALIZE_CONCURRENCY, mapWithConcurrency } from '@/lib/core/utils/concurrency' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { materializeExecutionData } from '@/lib/logs/execution/trace-store' @@ -14,6 +14,7 @@ import { createApiResponse, getUserLimits } from '@/app/api/v1/logs/meta' import { checkRateLimit, createRateLimitResponse, + v1ValidationErrorResponse, validateWorkspaceAccess, } from '@/app/api/v1/middleware' @@ -54,14 +55,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { request, {}, { - validationErrorResponse: (error) => - NextResponse.json( - { - error: getValidationErrorMessage(error, 'Invalid parameters'), - details: error.issues, - }, - { status: 400 } - ), + validationErrorResponse: (error) => v1ValidationErrorResponse(error, 'Invalid parameters'), } ) if (!parsed.success) return parsed.response diff --git a/apps/sim/app/api/v1/middleware.test.ts b/apps/sim/app/api/v1/middleware.test.ts index 720f5729e65..c49850c6a45 100644 --- a/apps/sim/app/api/v1/middleware.test.ts +++ b/apps/sim/app/api/v1/middleware.test.ts @@ -11,6 +11,11 @@ import { createMockRequest } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' import { z } from 'zod' import { workspaceIdSchema } from '@/lib/api/contracts/primitives' +import { + buildRateLimitHeaders, + getRateLimitHeaders, + recordRateLimitSnapshot, +} from '@/lib/api/server/rate-limit-context' const { mockAuthenticateV1Request, mockGetSubscription, mockCheckRateLimit, mockGetRateLimit } = vi.hoisted(() => ({ @@ -38,7 +43,6 @@ vi.mock('@/lib/core/rate-limiter', () => ({ import { checkRateLimit, createRateLimitResponse, - rateLimitHeaders, v1ValidationErrorResponse, } from '@/app/api/v1/middleware' @@ -140,14 +144,52 @@ describe('createRateLimitResponse', () => { }) }) -describe('rateLimitHeaders', () => { - it('reports a consistent limit/remaining pair', () => { - const headers = rateLimitHeaders({ +describe('v1ValidationErrorResponse', () => { + it('surfaces the schema message instead of a generic string', async () => { + const schema = z.object({ workspaceId: workspaceIdSchema }) + const parsed = schema.safeParse({}) + + const response = v1ValidationErrorResponse(parsed.error!) + const body = await response.json() + + expect(response.status).toBe(400) + expect(body.error).toBe('Workspace ID is required') + expect(Array.isArray(body.details)).toBe(true) + }) + + it('keeps the issue list alongside the message', async () => { + const schema = z.object({ workspaceId: workspaceIdSchema }) + const body = await v1ValidationErrorResponse(schema.safeParse({}).error!).json() + + expect(body.details[0].path).toEqual(['workspaceId']) + }) +}) + +describe('rate-limit snapshot context', () => { + beforeEach(() => { + vi.clearAllMocks() + mockAuthenticateV1Request.mockResolvedValue({ + authenticated: true, + userId: 'user-1', + keyType: 'personal', + }) + mockGetSubscription.mockResolvedValue({ plan: 'team' }) + mockGetRateLimit.mockReturnValue(TEAM_BUCKET) + mockCheckRateLimit.mockResolvedValue({ allowed: true, remaining: 399, - limit: 400, resetAt: new Date('2026-07-28T18:28:48.354Z'), }) + }) + + const SNAPSHOT = { + limit: 400, + remaining: 399, + resetAt: new Date('2026-07-28T18:28:48.354Z'), + } + + it('builds a consistent limit/remaining pair', () => { + const headers = buildRateLimitHeaders(SNAPSHOT) expect(headers['X-RateLimit-Limit']).toBe('400') expect(headers['X-RateLimit-Remaining']).toBe('399') @@ -157,37 +199,42 @@ describe('rateLimitHeaders', () => { expect(headers['X-RateLimit-Reset']).toBe('2026-07-28T18:28:48.354Z') }) - it('publishes nothing when no bucket was consulted', () => { - expect( - rateLimitHeaders({ - allowed: false, - remaining: 0, - limit: 0, - resetAt: new Date(), - error: 'API key required', - }) - ).toEqual({}) + it('returns null for a request that never consulted a bucket', () => { + expect(getRateLimitHeaders({})).toBeNull() }) -}) -describe('v1ValidationErrorResponse', () => { - it('surfaces the schema message instead of a generic string', async () => { - const schema = z.object({ workspaceId: workspaceIdSchema }) - const parsed = schema.safeParse({}) + it('returns the headers once a snapshot is recorded for that request', () => { + const req = {} + recordRateLimitSnapshot(req, SNAPSHOT) - const response = v1ValidationErrorResponse(parsed.error!) - const body = await response.json() + expect(getRateLimitHeaders(req)).toEqual(buildRateLimitHeaders(SNAPSHOT)) + }) - expect(response.status).toBe(400) - expect(body.error).toBe('Workspace ID is required') - expect(body.error).not.toBe('Validation error') - expect(Array.isArray(body.details)).toBe(true) + it('keeps snapshots per request, not global', () => { + const a = {} + const b = {} + recordRateLimitSnapshot(a, SNAPSHOT) + + expect(getRateLimitHeaders(a)).not.toBeNull() + expect(getRateLimitHeaders(b)).toBeNull() }) - it('keeps the issue list alongside the message', async () => { - const schema = z.object({ workspaceId: workspaceIdSchema }) - const body = await v1ValidationErrorResponse(schema.safeParse({}).error!).json() + it('records a snapshot as a side effect of checkRateLimit', async () => { + const req = request() - expect(body.details[0].path).toEqual(['workspaceId']) + await checkRateLimit(req, 'workflows') + + const headers = getRateLimitHeaders(req) + expect(headers).not.toBeNull() + expect(headers?.['X-RateLimit-Limit']).toBe(String(TEAM_BUCKET.maxTokens)) + }) + + it('records nothing when authentication fails', async () => { + mockAuthenticateV1Request.mockResolvedValue({ authenticated: false, error: 'API key required' }) + const req = request() + + await checkRateLimit(req, 'workflows') + + expect(getRateLimitHeaders(req)).toBeNull() }) }) diff --git a/apps/sim/app/api/v1/middleware.ts b/apps/sim/app/api/v1/middleware.ts index 96712cb65b7..93f07c6feef 100644 --- a/apps/sim/app/api/v1/middleware.ts +++ b/apps/sim/app/api/v1/middleware.ts @@ -2,7 +2,8 @@ import { createLogger } from '@sim/logger' import { type PermissionType, permissionSatisfies } from '@sim/platform-authz/workspace' import { type NextRequest, NextResponse } from 'next/server' import type { ZodError } from 'zod' -import { getValidationErrorMessage, serializeZodIssues } from '@/lib/api/server' +import { getValidationErrorMessage, validationErrorResponse } from '@/lib/api/server' +import { buildRateLimitHeaders, recordRateLimitSnapshot } from '@/lib/api/server/rate-limit-context' import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription' import type { SubscriptionPlan } from '@/lib/core/rate-limiter' import { getRateLimit, RateLimiter } from '@/lib/core/rate-limiter' @@ -99,6 +100,17 @@ export async function checkRateLimit( const plan = (subscription?.plan || 'free') as SubscriptionPlan const config = getRateLimit(plan, 'api-endpoint') + /** + * Recorded here — the one place the bucket is actually consulted — so + * `withRouteHandler` can publish the quota on every response the route + * returns, including error paths, without each `return` remembering to. + */ + recordRateLimitSnapshot(request, { + limit: config.maxTokens, + remaining: result.remaining, + resetAt: result.resetAt, + }) + return { allowed: result.allowed, remaining: result.remaining, @@ -146,23 +158,6 @@ export async function authenticateRequest( return { requestId, userId: rateLimit.userId!, rateLimit } } -/** - * The `X-RateLimit-*` trio every authenticated v1 response should carry, so a - * client can see its remaining quota before it is throttled rather than only - * discovering the ceiling on a 429. - * - * Returns an empty object when no bucket was consulted (authentication failure, - * checker error) — publishing a fabricated quota there is worse than silence. - */ -export function rateLimitHeaders(result: RateLimitResult): Record { - if (result.error) return {} - return { - 'X-RateLimit-Limit': result.limit.toString(), - 'X-RateLimit-Remaining': result.remaining.toString(), - 'X-RateLimit-Reset': result.resetAt.toISOString(), - } -} - export function createRateLimitResponse(result: RateLimitResult): NextResponse { /** * An authentication failure never reaches the token bucket, so there is no @@ -173,8 +168,6 @@ export function createRateLimitResponse(result: RateLimitResult): NextResponse { return NextResponse.json({ error: result.error || 'Unauthorized' }, { status: 401 }) } - const headers = rateLimitHeaders(result) - const retryAfterSeconds = result.retryAfterMs ? Math.ceil(result.retryAfterMs / 1000) : Math.ceil((result.resetAt.getTime() - Date.now()) / 1000) @@ -188,7 +181,7 @@ export function createRateLimitResponse(result: RateLimitResult): NextResponse { { status: 429, headers: { - ...headers, + ...buildRateLimitHeaders(result), 'Retry-After': retryAfterSeconds.toString(), }, } @@ -279,12 +272,6 @@ export async function validateWorkspaceAccess( * v1ValidationErrorResponse })`. Routes with a more specific message of their * own (for example `'Invalid workflow ID'`) should keep it. */ -export function v1ValidationErrorResponse(error: ZodError): NextResponse { - return NextResponse.json( - { - error: getValidationErrorMessage(error, 'Invalid request'), - details: serializeZodIssues(error), - }, - { status: 400 } - ) +export function v1ValidationErrorResponse(error: ZodError, fallback = 'Invalid request') { + return validationErrorResponse(error, getValidationErrorMessage(error, fallback)) } diff --git a/apps/sim/app/api/v1/tables/[tableId]/columns/route.ts b/apps/sim/app/api/v1/tables/[tableId]/columns/route.ts index f9ce87db47d..22b3d83f2cd 100644 --- a/apps/sim/app/api/v1/tables/[tableId]/columns/route.ts +++ b/apps/sim/app/api/v1/tables/[tableId]/columns/route.ts @@ -28,7 +28,6 @@ import { checkRateLimit, checkWorkspaceScope, createRateLimitResponse, - rateLimitHeaders, v1ValidationErrorResponse, } from '@/app/api/v1/middleware' @@ -86,15 +85,12 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Colum request, }) - return NextResponse.json( - { - success: true, - data: { - columns: updatedTable.schema.columns.map(normalizeColumn), - }, + return NextResponse.json({ + success: true, + data: { + columns: updatedTable.schema.columns.map(normalizeColumn), }, - { headers: rateLimitHeaders(rateLimit) } - ) + }) } catch (error) { const lockError = tableLockErrorResponse(error) if (lockError) return lockError @@ -240,15 +236,12 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Colu request, }) - return NextResponse.json( - { - success: true, - data: { - columns: updatedTable.schema.columns.map(normalizeColumn), - }, + return NextResponse.json({ + success: true, + data: { + columns: updatedTable.schema.columns.map(normalizeColumn), }, - { headers: rateLimitHeaders(rateLimit) } - ) + }) } catch (error) { const lockError = tableLockErrorResponse(error) if (lockError) return lockError @@ -328,15 +321,12 @@ export const DELETE = withRouteHandler( request, }) - return NextResponse.json( - { - success: true, - data: { - columns: updatedTable.schema.columns.map(normalizeColumn), - }, + return NextResponse.json({ + success: true, + data: { + columns: updatedTable.schema.columns.map(normalizeColumn), }, - { headers: rateLimitHeaders(rateLimit) } - ) + }) } catch (error) { const lockError = tableLockErrorResponse(error) if (lockError) return lockError diff --git a/apps/sim/app/api/v1/tables/[tableId]/route.ts b/apps/sim/app/api/v1/tables/[tableId]/route.ts index 951d1510781..c06492d02b7 100644 --- a/apps/sim/app/api/v1/tables/[tableId]/route.ts +++ b/apps/sim/app/api/v1/tables/[tableId]/route.ts @@ -16,7 +16,6 @@ import { checkRateLimit, checkWorkspaceScope, createRateLimitResponse, - rateLimitHeaders, } from '@/app/api/v1/middleware' const logger = createLogger('V1TableDetailAPI') @@ -71,33 +70,30 @@ export const GET = withRouteHandler(async (request: NextRequest, context: TableR const schemaData = table.schema as TableSchema - return NextResponse.json( - { - success: true, - data: { - table: { - id: table.id, - name: table.name, - description: table.description, - schema: { - columns: schemaData.columns.map(normalizeColumn), - }, - rowCount: table.rowCount, - maxRows: table.maxRows, - locks: table.locks, - createdAt: - table.createdAt instanceof Date - ? table.createdAt.toISOString() - : String(table.createdAt), - updatedAt: - table.updatedAt instanceof Date - ? table.updatedAt.toISOString() - : String(table.updatedAt), + return NextResponse.json({ + success: true, + data: { + table: { + id: table.id, + name: table.name, + description: table.description, + schema: { + columns: schemaData.columns.map(normalizeColumn), }, + rowCount: table.rowCount, + maxRows: table.maxRows, + locks: table.locks, + createdAt: + table.createdAt instanceof Date + ? table.createdAt.toISOString() + : String(table.createdAt), + updatedAt: + table.updatedAt instanceof Date + ? table.updatedAt.toISOString() + : String(table.updatedAt), }, }, - { headers: rateLimitHeaders(rateLimit) } - ) + }) } catch (error) { logger.error(`[${requestId}] Error getting table:`, error) return NextResponse.json({ error: 'Failed to get table' }, { status: 500 }) @@ -156,15 +152,12 @@ export const DELETE = withRouteHandler(async (request: NextRequest, context: Tab request, }) - return NextResponse.json( - { - success: true, - data: { - message: 'Table archived successfully', - }, + return NextResponse.json({ + success: true, + data: { + message: 'Table archived successfully', }, - { headers: rateLimitHeaders(rateLimit) } - ) + }) } catch (error) { const lockError = tableLockErrorResponse(error) if (lockError) return lockError diff --git a/apps/sim/app/api/v1/tables/[tableId]/rows/[rowId]/route.ts b/apps/sim/app/api/v1/tables/[tableId]/rows/[rowId]/route.ts index 85e5f652597..ad43dc56afb 100644 --- a/apps/sim/app/api/v1/tables/[tableId]/rows/[rowId]/route.ts +++ b/apps/sim/app/api/v1/tables/[tableId]/rows/[rowId]/route.ts @@ -21,7 +21,6 @@ import { checkRateLimit, checkWorkspaceScope, createRateLimitResponse, - rateLimitHeaders, resolveWorkspaceRequestActor, v1ValidationErrorResponse, } from '@/app/api/v1/middleware' @@ -87,23 +86,20 @@ export const GET = withRouteHandler(async (request: NextRequest, context: RowRou } const toNamedRow = namedRowMapper((result.table.schema as TableSchema).columns) - return NextResponse.json( - { - success: true, - data: { - row: { - id: row.id, - data: toNamedRow(row.data as RowData), - position: row.position, - createdAt: - row.createdAt instanceof Date ? row.createdAt.toISOString() : String(row.createdAt), - updatedAt: - row.updatedAt instanceof Date ? row.updatedAt.toISOString() : String(row.updatedAt), - }, + return NextResponse.json({ + success: true, + data: { + row: { + id: row.id, + data: toNamedRow(row.data as RowData), + position: row.position, + createdAt: + row.createdAt instanceof Date ? row.createdAt.toISOString() : String(row.createdAt), + updatedAt: + row.updatedAt instanceof Date ? row.updatedAt.toISOString() : String(row.updatedAt), }, }, - { headers: rateLimitHeaders(rateLimit) } - ) + }) } catch (error) { logger.error(`[${requestId}] Error getting row:`, error) return NextResponse.json({ error: 'Failed to get row' }, { status: 500 }) @@ -166,28 +162,25 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: RowR // Firing a second mode: 'incomplete' dispatch here would race with it AND // bulk-clear sibling-group outputs. - return NextResponse.json( - { - success: true, - data: { - row: { - id: updatedRow.id, - data: toNamedRow(updatedRow.data), - position: updatedRow.position, - createdAt: - updatedRow.createdAt instanceof Date - ? updatedRow.createdAt.toISOString() - : updatedRow.createdAt, - updatedAt: - updatedRow.updatedAt instanceof Date - ? updatedRow.updatedAt.toISOString() - : updatedRow.updatedAt, - }, - message: 'Row updated successfully', + return NextResponse.json({ + success: true, + data: { + row: { + id: updatedRow.id, + data: toNamedRow(updatedRow.data), + position: updatedRow.position, + createdAt: + updatedRow.createdAt instanceof Date + ? updatedRow.createdAt.toISOString() + : updatedRow.createdAt, + updatedAt: + updatedRow.updatedAt instanceof Date + ? updatedRow.updatedAt.toISOString() + : updatedRow.updatedAt, }, + message: 'Row updated successfully', }, - { headers: rateLimitHeaders(rateLimit) } - ) + }) } catch (error) { const lockError = tableLockErrorResponse(error) if (lockError) return lockError @@ -248,16 +241,13 @@ export const DELETE = withRouteHandler(async (request: NextRequest, context: Row // enforced — the raw path would return 200 on a locked table. await deleteRow(result.table, rowId, requestId) - return NextResponse.json( - { - success: true, - data: { - message: 'Row deleted successfully', - deletedCount: 1, - }, + return NextResponse.json({ + success: true, + data: { + message: 'Row deleted successfully', + deletedCount: 1, }, - { headers: rateLimitHeaders(rateLimit) } - ) + }) } catch (error) { const lockError = tableLockErrorResponse(error) if (lockError) return lockError diff --git a/apps/sim/app/api/v1/tables/[tableId]/rows/route.ts b/apps/sim/app/api/v1/tables/[tableId]/rows/route.ts index 65bad2827f2..9fff01c9f25 100644 --- a/apps/sim/app/api/v1/tables/[tableId]/rows/route.ts +++ b/apps/sim/app/api/v1/tables/[tableId]/rows/route.ts @@ -40,8 +40,6 @@ import { checkRateLimit, checkWorkspaceScope, createRateLimitResponse, - type RateLimitResult, - rateLimitHeaders, resolveWorkspaceRequestActor, v1ValidationErrorResponse, } from '@/app/api/v1/middleware' @@ -60,8 +58,7 @@ async function handleBatchInsert( tableId: string, validated: V1BatchInsertTableRowsBody, userId: string, - actorUserId: string, - rateLimit: RateLimitResult + actorUserId: string ): Promise { const accessResult = await checkAccess(tableId, userId, 'write') if (!accessResult.ok) return accessError(accessResult, requestId, tableId) @@ -96,23 +93,20 @@ async function handleBatchInsert( requestId ) - return NextResponse.json( - { - success: true, - data: { - rows: insertedRows.map((r) => ({ - id: r.id, - data: toNamedRow(r.data), - position: r.position, - createdAt: r.createdAt instanceof Date ? r.createdAt.toISOString() : r.createdAt, - updatedAt: r.updatedAt instanceof Date ? r.updatedAt.toISOString() : r.updatedAt, - })), - insertedCount: insertedRows.length, - message: `Successfully inserted ${insertedRows.length} rows`, - }, + return NextResponse.json({ + success: true, + data: { + rows: insertedRows.map((r) => ({ + id: r.id, + data: toNamedRow(r.data), + position: r.position, + createdAt: r.createdAt instanceof Date ? r.createdAt.toISOString() : r.createdAt, + updatedAt: r.updatedAt instanceof Date ? r.updatedAt.toISOString() : r.updatedAt, + })), + insertedCount: insertedRows.length, + message: `Successfully inserted ${insertedRows.length} rows`, }, - { headers: rateLimitHeaders(rateLimit) } - ) + }) } catch (error) { const response = rowWriteErrorResponse(error) if (response) return response @@ -185,27 +179,22 @@ export const GET = withRouteHandler(async (request: NextRequest, context: TableR requestId ) - return NextResponse.json( - { - success: true, - data: { - rows: result.rows.map((r) => ({ - id: r.id, - data: toNamedRow(r.data), - position: r.position, - createdAt: - r.createdAt instanceof Date ? r.createdAt.toISOString() : String(r.createdAt), - updatedAt: - r.updatedAt instanceof Date ? r.updatedAt.toISOString() : String(r.updatedAt), - })), - rowCount: result.rowCount, - totalCount: result.totalCount, - limit: result.limit, - offset: result.offset, - }, + return NextResponse.json({ + success: true, + data: { + rows: result.rows.map((r) => ({ + id: r.id, + data: toNamedRow(r.data), + position: r.position, + createdAt: r.createdAt instanceof Date ? r.createdAt.toISOString() : String(r.createdAt), + updatedAt: r.updatedAt instanceof Date ? r.updatedAt.toISOString() : String(r.updatedAt), + })), + rowCount: result.rowCount, + totalCount: result.totalCount, + limit: result.limit, + offset: result.offset, }, - { headers: rateLimitHeaders(rateLimit) } - ) + }) } catch (error) { const validationResponse = validationErrorResponseFromError(error) if (validationResponse) return validationResponse @@ -250,7 +239,7 @@ export const POST = withRouteHandler( `Unable to resolve system actor for workspace ${batchValidated.workspaceId}` ) } - return handleBatchInsert(requestId, tableId, batchValidated, userId, actorUserId, rateLimit) + return handleBatchInsert(requestId, tableId, batchValidated, userId, actorUserId) } const validated = parsed.data.body @@ -293,24 +282,19 @@ export const POST = withRouteHandler( requestId ) - return NextResponse.json( - { - success: true, - data: { - row: { - id: row.id, - data: toNamedRow(row.data), - position: row.position, - createdAt: - row.createdAt instanceof Date ? row.createdAt.toISOString() : row.createdAt, - updatedAt: - row.updatedAt instanceof Date ? row.updatedAt.toISOString() : row.updatedAt, - }, - message: 'Row inserted successfully', + return NextResponse.json({ + success: true, + data: { + row: { + id: row.id, + data: toNamedRow(row.data), + position: row.position, + createdAt: row.createdAt instanceof Date ? row.createdAt.toISOString() : row.createdAt, + updatedAt: row.updatedAt instanceof Date ? row.updatedAt.toISOString() : row.updatedAt, }, + message: 'Row inserted successfully', }, - { headers: rateLimitHeaders(rateLimit) } - ) + }) } catch (error) { const validationResponse = validationErrorResponseFromError(error) if (validationResponse) return validationResponse @@ -384,29 +368,23 @@ export const PUT = withRouteHandler(async (request: NextRequest, context: TableR ) if (result.affectedCount === 0) { - return NextResponse.json( - { - success: true, - data: { - message: 'No rows matched the filter criteria', - updatedCount: 0, - }, - }, - { headers: rateLimitHeaders(rateLimit) } - ) - } - - return NextResponse.json( - { + return NextResponse.json({ success: true, data: { - message: 'Rows updated successfully', - updatedCount: result.affectedCount, - updatedRowIds: result.affectedRowIds, + message: 'No rows matched the filter criteria', + updatedCount: 0, }, + }) + } + + return NextResponse.json({ + success: true, + data: { + message: 'Rows updated successfully', + updatedCount: result.affectedCount, + updatedRowIds: result.affectedRowIds, }, - { headers: rateLimitHeaders(rateLimit) } - ) + }) } catch (error) { const validationResponse = validationErrorResponseFromError(error) if (validationResponse) return validationResponse @@ -461,22 +439,19 @@ export const DELETE = withRouteHandler( requestId ) - return NextResponse.json( - { - success: true, - data: { - message: - result.deletedCount === 0 - ? 'No matching rows found for the provided IDs' - : 'Rows deleted successfully', - deletedCount: result.deletedCount, - deletedRowIds: result.deletedRowIds, - requestedCount: result.requestedCount, - ...(result.missingRowIds.length > 0 ? { missingRowIds: result.missingRowIds } : {}), - }, + return NextResponse.json({ + success: true, + data: { + message: + result.deletedCount === 0 + ? 'No matching rows found for the provided IDs' + : 'Rows deleted successfully', + deletedCount: result.deletedCount, + deletedRowIds: result.deletedRowIds, + requestedCount: result.requestedCount, + ...(result.missingRowIds.length > 0 ? { missingRowIds: result.missingRowIds } : {}), }, - { headers: rateLimitHeaders(rateLimit) } - ) + }) } const idByName = buildIdByName(table.schema as TableSchema) @@ -492,20 +467,17 @@ export const DELETE = withRouteHandler( requestId ) - return NextResponse.json( - { - success: true, - data: { - message: - result.affectedCount === 0 - ? 'No rows matched the filter criteria' - : 'Rows deleted successfully', - deletedCount: result.affectedCount, - deletedRowIds: result.affectedRowIds, - }, + return NextResponse.json({ + success: true, + data: { + message: + result.affectedCount === 0 + ? 'No rows matched the filter criteria' + : 'Rows deleted successfully', + deletedCount: result.affectedCount, + deletedRowIds: result.affectedRowIds, }, - { headers: rateLimitHeaders(rateLimit) } - ) + }) } catch (error) { const validationResponse = validationErrorResponseFromError(error) if (validationResponse) return validationResponse diff --git a/apps/sim/app/api/v1/tables/[tableId]/rows/upsert/route.ts b/apps/sim/app/api/v1/tables/[tableId]/rows/upsert/route.ts index 54c66abf82a..298a48ab526 100644 --- a/apps/sim/app/api/v1/tables/[tableId]/rows/upsert/route.ts +++ b/apps/sim/app/api/v1/tables/[tableId]/rows/upsert/route.ts @@ -14,7 +14,6 @@ import { checkRateLimit, checkWorkspaceScope, createRateLimitResponse, - rateLimitHeaders, resolveWorkspaceRequestActor, v1ValidationErrorResponse, } from '@/app/api/v1/middleware' @@ -76,28 +75,25 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Upser requestId ) - return NextResponse.json( - { - success: true, - data: { - row: { - id: upsertResult.row.id, - data: toNamedRow(upsertResult.row.data), - createdAt: - upsertResult.row.createdAt instanceof Date - ? upsertResult.row.createdAt.toISOString() - : upsertResult.row.createdAt, - updatedAt: - upsertResult.row.updatedAt instanceof Date - ? upsertResult.row.updatedAt.toISOString() - : upsertResult.row.updatedAt, - }, - operation: upsertResult.operation, - message: `Row ${upsertResult.operation === 'update' ? 'updated' : 'inserted'} successfully`, + return NextResponse.json({ + success: true, + data: { + row: { + id: upsertResult.row.id, + data: toNamedRow(upsertResult.row.data), + createdAt: + upsertResult.row.createdAt instanceof Date + ? upsertResult.row.createdAt.toISOString() + : upsertResult.row.createdAt, + updatedAt: + upsertResult.row.updatedAt instanceof Date + ? upsertResult.row.updatedAt.toISOString() + : upsertResult.row.updatedAt, }, + operation: upsertResult.operation, + message: `Row ${upsertResult.operation === 'update' ? 'updated' : 'inserted'} successfully`, }, - { headers: rateLimitHeaders(rateLimit) } - ) + }) } catch (error) { const lockError = tableLockErrorResponse(error) if (lockError) return lockError diff --git a/apps/sim/app/api/v1/tables/route.ts b/apps/sim/app/api/v1/tables/route.ts index 977da40c0fa..37f8515d83d 100644 --- a/apps/sim/app/api/v1/tables/route.ts +++ b/apps/sim/app/api/v1/tables/route.ts @@ -10,7 +10,6 @@ import { normalizeColumn } from '@/app/api/table/utils' import { checkRateLimit, createRateLimitResponse, - rateLimitHeaders, v1ValidationErrorResponse, validateWorkspaceAccess, } from '@/app/api/v1/middleware' @@ -48,33 +47,30 @@ export const GET = withRouteHandler(async (request: NextRequest) => { const tables = await listTables(workspaceId) - return NextResponse.json( - { - success: true, - data: { - tables: tables.map((t) => { - const schemaData = t.schema as TableSchema - return { - id: t.id, - name: t.name, - description: t.description, - schema: { - columns: schemaData.columns.map(normalizeColumn), - }, - rowCount: t.rowCount, - maxRows: t.maxRows, - locks: t.locks, - createdAt: - t.createdAt instanceof Date ? t.createdAt.toISOString() : String(t.createdAt), - updatedAt: - t.updatedAt instanceof Date ? t.updatedAt.toISOString() : String(t.updatedAt), - } - }), - totalCount: tables.length, - }, + return NextResponse.json({ + success: true, + data: { + tables: tables.map((t) => { + const schemaData = t.schema as TableSchema + return { + id: t.id, + name: t.name, + description: t.description, + schema: { + columns: schemaData.columns.map(normalizeColumn), + }, + rowCount: t.rowCount, + maxRows: t.maxRows, + locks: t.locks, + createdAt: + t.createdAt instanceof Date ? t.createdAt.toISOString() : String(t.createdAt), + updatedAt: + t.updatedAt instanceof Date ? t.updatedAt.toISOString() : String(t.updatedAt), + } + }), + totalCount: tables.length, }, - { headers: rateLimitHeaders(rateLimit) } - ) + }) } catch (error) { const validationResponse = validationErrorResponseFromError(error) if (validationResponse) return validationResponse @@ -145,34 +141,31 @@ export const POST = withRouteHandler(async (request: NextRequest) => { request, }) - return NextResponse.json( - { - success: true, - data: { - table: { - id: table.id, - name: table.name, - description: table.description, - schema: { - columns: (table.schema as TableSchema).columns.map(normalizeColumn), - }, - rowCount: table.rowCount, - maxRows: table.maxRows, - locks: table.locks, - createdAt: - table.createdAt instanceof Date - ? table.createdAt.toISOString() - : String(table.createdAt), - updatedAt: - table.updatedAt instanceof Date - ? table.updatedAt.toISOString() - : String(table.updatedAt), + return NextResponse.json({ + success: true, + data: { + table: { + id: table.id, + name: table.name, + description: table.description, + schema: { + columns: (table.schema as TableSchema).columns.map(normalizeColumn), }, - message: 'Table created successfully', + rowCount: table.rowCount, + maxRows: table.maxRows, + locks: table.locks, + createdAt: + table.createdAt instanceof Date + ? table.createdAt.toISOString() + : String(table.createdAt), + updatedAt: + table.updatedAt instanceof Date + ? table.updatedAt.toISOString() + : String(table.updatedAt), }, + message: 'Table created successfully', }, - { headers: rateLimitHeaders(rateLimit) } - ) + }) } catch (error) { const validationResponse = validationErrorResponseFromError(error) if (validationResponse) return validationResponse diff --git a/apps/sim/app/api/v1/workflows/import/route.test.ts b/apps/sim/app/api/v1/workflows/import/route.test.ts index f35a471f802..ab3492b0fc0 100644 --- a/apps/sim/app/api/v1/workflows/import/route.test.ts +++ b/apps/sim/app/api/v1/workflows/import/route.test.ts @@ -45,6 +45,8 @@ vi.mock('@/app/api/v1/middleware', () => ({ NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) ), validateWorkspaceAccess: mockValidateWorkspaceAccess, + v1ValidationErrorResponse: (e: { issues: unknown[] }) => + NextResponse.json({ error: 'Validation error', details: e.issues }, { status: 400 }), })) vi.mock('@/lib/workflows/orchestration', () => ({ diff --git a/apps/sim/app/api/v1/workflows/import/route.ts b/apps/sim/app/api/v1/workflows/import/route.ts index 037377ed063..dc602b987db 100644 --- a/apps/sim/app/api/v1/workflows/import/route.ts +++ b/apps/sim/app/api/v1/workflows/import/route.ts @@ -19,7 +19,7 @@ import { v1ImportWorkflowContract, } from '@/lib/api/contracts/v1/workflows' import { workflowStateSchema } from '@/lib/api/contracts/workflows' -import { getValidationErrorMessage, parseRequest, serializeZodIssues } from '@/lib/api/server' +import { parseRequest, serializeZodIssues } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { parseWorkflowJson } from '@/lib/workflows/operations/import-export' import { performCreateWorkflow } from '@/lib/workflows/orchestration' @@ -31,6 +31,7 @@ import { createApiResponse, getUserLimits } from '@/app/api/v1/logs/meta' import { checkRateLimit, createRateLimitResponse, + v1ValidationErrorResponse, validateWorkspaceAccess, } from '@/app/api/v1/middleware' import type { WorkflowState } from '@/stores/workflows/workflow/types' @@ -160,13 +161,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { { maxBodyBytes: MAX_IMPORT_BODY_BYTES, validationErrorResponse: (error) => - NextResponse.json( - { - error: getValidationErrorMessage(error, 'Invalid request body'), - details: error.issues, - }, - { status: 400 } - ), + v1ValidationErrorResponse(error, 'Invalid request body'), } ) if (!parsed.success) return parsed.response diff --git a/apps/sim/app/api/v1/workflows/route.ts b/apps/sim/app/api/v1/workflows/route.ts index c0e89a86b56..3b24eefc55d 100644 --- a/apps/sim/app/api/v1/workflows/route.ts +++ b/apps/sim/app/api/v1/workflows/route.ts @@ -6,12 +6,13 @@ import { generateId } from '@sim/utils/id' import { and, asc, eq, gt, isNull, or } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { v1ListWorkflowsContract } from '@/lib/api/contracts/v1/workflows' -import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' +import { parseRequest } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createApiResponse, getUserLimits } from '@/app/api/v1/logs/meta' import { checkRateLimit, createRateLimitResponse, + v1ValidationErrorResponse, validateWorkspaceAccess, } from '@/app/api/v1/middleware' @@ -53,14 +54,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { request, {}, { - validationErrorResponse: (error) => - NextResponse.json( - { - error: getValidationErrorMessage(error, 'Invalid parameters'), - details: error.issues, - }, - { status: 400 } - ), + validationErrorResponse: (error) => v1ValidationErrorResponse(error, 'Invalid parameters'), } ) if (!parsed.success) return parsed.response diff --git a/apps/sim/lib/api/contracts/primitives.ts b/apps/sim/lib/api/contracts/primitives.ts index 20e05339ad5..232b7ec3176 100644 --- a/apps/sim/lib/api/contracts/primitives.ts +++ b/apps/sim/lib/api/contracts/primitives.ts @@ -26,9 +26,10 @@ export const jobIdParamsSchema = z.object({ }) /** - * Non-empty string identifier (used for workspace, workflow, user, table, etc.). - * Prefer this over inline `z.string().min(1)` so error wording stays consistent - * and refactors can centralize ID validation in one place. + * Non-empty string identifier with no custom message — suitable for internal + * shapes where the field name is not worth surfacing. For a required *request* + * field prefer {@link requiredFieldSchema} (or a named primitive below), which + * also names the field when it is omitted entirely. */ export const nonEmptyIdSchema = z.string().min(1) @@ -42,8 +43,8 @@ export const nonEmptyIdSchema = z.string().min(1) * message to the `z.string({ error })` constructor closes that gap. * * Prefer this over a bare `z.string().min(1, '...')` for any required request - * field, and keep the wording specific to the field and where it belongs (for - * example `'workspaceId query parameter is required'`). + * field. When a named primitive below already carries the right wording, import + * that instead of rebuilding it here. */ export function requiredFieldSchema(message: string) { return z.string({ error: message }).min(1, message) @@ -53,12 +54,6 @@ export function requiredFieldSchema(message: string) { * Non-empty `workspaceId` field. Same constraint as `nonEmptyIdSchema` with a * stable, human-readable message. Use to deduplicate the * `z.string().min(1, 'Workspace ID is required')` pattern across contracts. - * - * The message is given twice on purpose: `.min(1)` only fires for a present but - * empty string, so without the `z.string({ error })` form an *omitted* field - * falls back to Zod's default `Invalid input: expected string, received - * undefined`, which does not name the field. The same applies to the sibling id - * schemas below. */ export const workspaceIdSchema = requiredFieldSchema('Workspace ID is required') diff --git a/apps/sim/lib/api/contracts/tables.ts b/apps/sim/lib/api/contracts/tables.ts index 66ddff124f3..b128660d855 100644 --- a/apps/sim/lib/api/contracts/tables.ts +++ b/apps/sim/lib/api/contracts/tables.ts @@ -1,6 +1,6 @@ import { isRecordLike } from '@sim/utils/object' import { z } from 'zod' -import { requiredFieldSchema } from '@/lib/api/contracts/primitives' +import { requiredFieldSchema, workspaceIdSchema } from '@/lib/api/contracts/primitives' import { type ContractJsonResponse, defineRouteContract } from '@/lib/api/contracts/types' import { ianaTimezoneSchema } from '@/lib/api/contracts/user' import type { @@ -127,12 +127,12 @@ export const tableRowParamsSchema = tableIdParamsSchema.extend({ }) export const listTablesQuerySchema = z.object({ - workspaceId: requiredFieldSchema('Workspace ID is required'), + workspaceId: workspaceIdSchema, scope: tableScopeSchema.default('active'), }) export const getTableQuerySchema = z.object({ - workspaceId: requiredFieldSchema('Workspace ID is required'), + workspaceId: workspaceIdSchema, }) export const tableColumnSchema = z @@ -164,12 +164,12 @@ export const createTableBodySchema = z.object({ `Table cannot have more than ${TABLE_LIMITS.MAX_COLUMNS_PER_TABLE} columns` ), }), - workspaceId: requiredFieldSchema('Workspace ID is required'), + workspaceId: workspaceIdSchema, initialRowCount: z.number().int().min(0).max(100).optional(), }) export const renameTableBodySchema = z.object({ - workspaceId: requiredFieldSchema('Workspace ID is required'), + workspaceId: workspaceIdSchema, name: tableNameSchema, }) @@ -188,7 +188,7 @@ export const tableLocksSchema = z.object({ */ export const updateTableBodySchema = z .object({ - workspaceId: requiredFieldSchema('Workspace ID is required'), + workspaceId: workspaceIdSchema, name: tableNameSchema.optional(), locks: tableLocksSchema.partial().optional(), }) @@ -203,7 +203,7 @@ export const updateTableBodySchema = z }) export const createTableColumnBodySchema = z.object({ - workspaceId: requiredFieldSchema('Workspace ID is required'), + workspaceId: workspaceIdSchema, column: z .object({ // Optional stable id — first-party undo of a delete re-creates the column @@ -221,7 +221,7 @@ export const createTableColumnBodySchema = z.object({ }) export const updateTableColumnBodySchema = z.object({ - workspaceId: requiredFieldSchema('Workspace ID is required'), + workspaceId: workspaceIdSchema, columnName: columnNameSchema, updates: z .object({ @@ -236,7 +236,7 @@ export const updateTableColumnBodySchema = z.object({ }) export const deleteTableColumnBodySchema = z.object({ - workspaceId: requiredFieldSchema('Workspace ID is required'), + workspaceId: workspaceIdSchema, columnName: columnNameSchema, }) @@ -247,7 +247,7 @@ export const tableMetadataSchema = z.object({ }) satisfies z.ZodType export const updateTableMetadataBodySchema = z.object({ - workspaceId: requiredFieldSchema('Workspace ID is required'), + workspaceId: workspaceIdSchema, metadata: tableMetadataSchema, }) @@ -261,7 +261,7 @@ export const tableRowSchema = domainObjectSchema() * {@link rowAnchorMutexRefine} — Zod forbids `.omit()` on a refined schema. */ export const insertTableRowBodyBaseSchema = z.object({ - workspaceId: requiredFieldSchema('Workspace ID is required'), + workspaceId: workspaceIdSchema, data: rowDataSchema, position: z.number().int().min(0).optional(), /** Fractional ordering: insert directly after this row id. Takes precedence over `position`. */ @@ -284,14 +284,14 @@ export const insertTableRowBodySchema = insertTableRowBodyBaseSchema.refine(...r * unique column when omitted). */ export const upsertTableRowBodySchema = z.object({ - workspaceId: requiredFieldSchema('Workspace ID is required'), + workspaceId: workspaceIdSchema, data: rowDataSchema, conflictTarget: z.string().min(1).optional(), }) export const batchInsertTableRowsBodySchema = z .object({ - workspaceId: requiredFieldSchema('Workspace ID is required'), + workspaceId: workspaceIdSchema, rows: z .array(rowDataSchema) .min(1, 'At least one row is required') @@ -319,12 +319,12 @@ export const insertTableRowsBodySchema = z.union([ ]) export const updateTableRowBodySchema = z.object({ - workspaceId: requiredFieldSchema('Workspace ID is required'), + workspaceId: workspaceIdSchema, data: rowDataSchema, }) export const batchUpdateTableRowsBodySchema = z.object({ - workspaceId: requiredFieldSchema('Workspace ID is required'), + workspaceId: workspaceIdSchema, updates: z .array( z.object({ @@ -366,12 +366,12 @@ const optionalPositiveLimit = (max: number, label: string) => ) export const deleteTableRowBodySchema = z.object({ - workspaceId: requiredFieldSchema('Workspace ID is required'), + workspaceId: workspaceIdSchema, }) export const deleteTableRowsBodySchema = z .object({ - workspaceId: requiredFieldSchema('Workspace ID is required'), + workspaceId: workspaceIdSchema, filter: nonEmptyFilterSchema.optional(), limit: optionalPositiveLimit(TABLE_LIMITS.MAX_BULK_OPERATION_SIZE, 'Limit').optional(), rowIds: z @@ -389,7 +389,7 @@ export const deleteTableRowsBodySchema = z /** Unrefined base so v1 contracts can `.extend()` — consumers use {@link tableRowsQuerySchema}. */ export const tableRowsQueryBaseSchema = z.object({ - workspaceId: requiredFieldSchema('Workspace ID is required'), + workspaceId: workspaceIdSchema, filter: domainObjectSchema().optional(), sort: domainObjectSchema().optional(), /** @@ -436,7 +436,7 @@ export const tableRowsQuerySchema = tableRowsQueryBaseSchema.refine( ) export const updateRowsByFilterBodySchema = z.object({ - workspaceId: requiredFieldSchema('Workspace ID is required'), + workspaceId: workspaceIdSchema, filter: nonEmptyFilterSchema, data: rowDataSchema, limit: optionalPositiveLimit(TABLE_LIMITS.MAX_BULK_OPERATION_SIZE, 'Limit').optional(), @@ -489,7 +489,7 @@ export const createTableContract = defineRouteContract({ * `importing` table and runs the load in the background. */ export const importTableAsyncBodySchema = z.object({ - workspaceId: requiredFieldSchema('Workspace ID is required'), + workspaceId: workspaceIdSchema, fileKey: requiredFieldSchema('fileKey is required'), fileName: requiredFieldSchema('fileName is required'), /** @@ -651,7 +651,7 @@ export const listTableRowsContract = defineRouteContract({ }) export const findTableRowsQuerySchema = z.object({ - workspaceId: requiredFieldSchema('Workspace ID is required'), + workspaceId: workspaceIdSchema, q: requiredFieldSchema('Search query is required'), filter: domainObjectSchema().optional(), sort: domainObjectSchema().optional(), @@ -800,7 +800,7 @@ export const csvExtensionSchema = z.enum(['csv', 'tsv'], { * resolved column mapping (the dialog computes them from its preview). */ export const importIntoTableAsyncBodySchema = z.object({ - workspaceId: requiredFieldSchema('Workspace ID is required'), + workspaceId: workspaceIdSchema, fileKey: requiredFieldSchema('fileKey is required'), fileName: requiredFieldSchema('fileName is required'), mode: csvImportModeSchema, @@ -869,7 +869,7 @@ export const tableExportFormatSchema = z .default('csv') export const exportTableAsyncBodySchema = z.object({ - workspaceId: requiredFieldSchema('Workspace ID is required'), + workspaceId: workspaceIdSchema, format: z.enum(['csv', 'json']).default('csv'), }) @@ -905,7 +905,7 @@ export const tableJobSummarySchema = z.object({ export type TableJobSummary = z.output export const listTableJobsQuerySchema = z.object({ - workspaceId: requiredFieldSchema('Workspace ID is required'), + workspaceId: workspaceIdSchema, type: z.literal('export'), }) @@ -925,7 +925,7 @@ export const listTableJobsContract = defineRouteContract({ }) export const exportDownloadQuerySchema = z.object({ - workspaceId: requiredFieldSchema('Workspace ID is required'), + workspaceId: workspaceIdSchema, jobId: requiredFieldSchema('Job ID is required'), }) @@ -1080,7 +1080,7 @@ export const deleteTableRowsContract = defineRouteContract({ * worker deletes in paginated batches. Omitting `filter` deletes the whole table (at the cutoff). */ export const deleteTableRowsAsyncBodySchema = z.object({ - workspaceId: requiredFieldSchema('Workspace ID is required'), + workspaceId: workspaceIdSchema, filter: nonEmptyFilterSchema.optional(), excludeRowIds: z .array(z.string().min(1)) @@ -1152,7 +1152,7 @@ export const groupIdParamsSchema = tableIdParamsSchema.extend({ }) export const addWorkflowGroupBodySchema = z.object({ - workspaceId: requiredFieldSchema('Workspace ID is required'), + workspaceId: workspaceIdSchema, group: z.object({ id: z.string().min(1), /** Workflow id for manual groups; `''` (or omitted) for enrichment groups. */ @@ -1197,7 +1197,7 @@ const workflowGroupMappingUpdateSchema = z.object({ }) export const updateWorkflowGroupBodySchema = z.object({ - workspaceId: requiredFieldSchema('Workspace ID is required'), + workspaceId: workspaceIdSchema, groupId: z.string().min(1), workflowId: z.string().min(1).optional(), name: z.string().optional(), @@ -1221,7 +1221,7 @@ export const updateWorkflowGroupBodySchema = z.object({ }) export const deleteWorkflowGroupBodySchema = z.object({ - workspaceId: requiredFieldSchema('Workspace ID is required'), + workspaceId: workspaceIdSchema, groupId: z.string().min(1), }) @@ -1273,7 +1273,7 @@ export const deleteWorkflowGroupContract = defineRouteContract({ */ export const cancelTableRunsBodySchema = z .object({ - workspaceId: requiredFieldSchema('Workspace ID is required'), + workspaceId: workspaceIdSchema, scope: z.enum(['all', 'row']), rowId: z.string().min(1).optional(), filter: domainObjectSchema().optional(), @@ -1322,7 +1322,7 @@ export const cancelTableRunsContract = defineRouteContract({ }) export const cancelTableJobBodySchema = z.object({ - workspaceId: requiredFieldSchema('Workspace ID is required'), + workspaceId: workspaceIdSchema, jobId: requiredFieldSchema('Job ID is required'), }) @@ -1374,7 +1374,7 @@ export const runLimitSchema = z.object({ export const runColumnBodySchema = z .object({ - workspaceId: requiredFieldSchema('Workspace ID is required'), + workspaceId: workspaceIdSchema, groupIds: z.array(z.string().min(1)).min(1), runMode: z.enum(['all', 'incomplete']).default('all'), rowIds: z.array(z.string().min(1)).min(1).optional(), diff --git a/apps/sim/lib/api/contracts/v1/knowledge/index.ts b/apps/sim/lib/api/contracts/v1/knowledge/index.ts index 0eb7df6c308..0134da1147a 100644 --- a/apps/sim/lib/api/contracts/v1/knowledge/index.ts +++ b/apps/sim/lib/api/contracts/v1/knowledge/index.ts @@ -4,7 +4,7 @@ import { knowledgeDocumentParamsSchema, successResponseSchema, } from '@/lib/api/contracts/knowledge/shared' -import { requiredFieldSchema } from '@/lib/api/contracts/primitives' +import { requiredFieldSchema, workspaceIdSchema } from '@/lib/api/contracts/primitives' import { defineRouteContract } from '@/lib/api/contracts/types' import { KNOWLEDGE_BASE_DESCRIPTION_MAX_LENGTH } from '@/lib/knowledge/constants' @@ -36,7 +36,7 @@ export const v1ListKnowledgeBasesQuerySchema = z.object({ /** POST `/api/v1/knowledge` — create a knowledge base. */ export const v1CreateKnowledgeBaseBodySchema = z.object({ - workspaceId: requiredFieldSchema('Workspace ID is required'), + workspaceId: workspaceIdSchema, name: requiredFieldSchema('Name is required').max(255, 'Name must be 255 characters or less'), description: z .string() @@ -60,7 +60,7 @@ export const v1KnowledgeWorkspaceQuerySchema = z.object({ /** PUT `/api/v1/knowledge/[id]` — partial update with workspace scope in body. */ export const v1UpdateKnowledgeBaseBodySchema = z .object({ - workspaceId: requiredFieldSchema('Workspace ID is required'), + workspaceId: workspaceIdSchema, name: z.string().min(1).max(255, 'Name must be 255 characters or less').optional(), description: z .string() @@ -122,7 +122,7 @@ export const v1SearchTagFilterSchema = z.object({ /** POST `/api/v1/knowledge/search` body. */ export const v1KnowledgeSearchBodySchema = z .object({ - workspaceId: requiredFieldSchema('Workspace ID is required'), + workspaceId: workspaceIdSchema, knowledgeBaseIds: z.union([ requiredFieldSchema('Knowledge base ID is required'), z diff --git a/apps/sim/lib/api/contracts/v1/tables/index.ts b/apps/sim/lib/api/contracts/v1/tables/index.ts index 1890ffe42ee..4491b8840be 100644 --- a/apps/sim/lib/api/contracts/v1/tables/index.ts +++ b/apps/sim/lib/api/contracts/v1/tables/index.ts @@ -1,6 +1,6 @@ import { isRecordLike } from '@sim/utils/object' import { z } from 'zod' -import { requiredFieldSchema } from '@/lib/api/contracts/primitives' +import { requiredFieldSchema, workspaceIdSchema } from '@/lib/api/contracts/primitives' import { createTableBodySchema, createTableColumnBodySchema, @@ -68,7 +68,7 @@ export const v1InsertTableRowBodySchema = insertTableRowBodyBaseSchema * Public API batch insert body — no `positions`. Same rationale as above. */ export const v1BatchInsertTableRowsBodySchema = z.object({ - workspaceId: requiredFieldSchema('Workspace ID is required'), + workspaceId: workspaceIdSchema, rows: z .array(rowDataSchema) .min(1, 'At least one row is required') diff --git a/apps/sim/lib/api/contracts/workflows.ts b/apps/sim/lib/api/contracts/workflows.ts index 3c12ae30d42..60db7b61c7c 100644 --- a/apps/sim/lib/api/contracts/workflows.ts +++ b/apps/sim/lib/api/contracts/workflows.ts @@ -1,5 +1,9 @@ import { z } from 'zod' -import { requiredFieldSchema, workspaceIdSchema } from '@/lib/api/contracts/primitives' +import { + requiredFieldSchema, + workflowIdSchema, + workspaceIdSchema, +} from '@/lib/api/contracts/primitives' import { defineRouteContract } from '@/lib/api/contracts/types' const subBlockValuesSchema = z.record(z.string(), z.record(z.string(), z.unknown())) @@ -460,7 +464,7 @@ export const workflowLogBodySchema = z.object({ export type WorkflowLogBody = z.input export const importWorkflowAsSuperuserBodySchema = z.object({ - workflowId: requiredFieldSchema('Workflow ID is required'), + workflowId: workflowIdSchema, targetWorkspaceId: requiredFieldSchema('Target workspace ID is required'), }) diff --git a/apps/sim/lib/api/server/rate-limit-context.ts b/apps/sim/lib/api/server/rate-limit-context.ts new file mode 100644 index 00000000000..be45c9e7c4e --- /dev/null +++ b/apps/sim/lib/api/server/rate-limit-context.ts @@ -0,0 +1,53 @@ +/** + * Request-scoped carrier for the rate-limit snapshot, so response headers can be + * attached once at the route boundary instead of at every `return`. + * + * A route computes its rate limit at the top of the handler but returns from + * many places — success, validation failure, not-found, access denied, and the + * unhandled-error path inside `withRouteHandler`. Decorating each return means + * the headers are only as complete as the least-careful branch, and a new branch + * silently ships without them. Recording the snapshot once lets + * `withRouteHandler` publish it on whatever response comes back. + * + * A `WeakMap` keyed by the request avoids `AsyncLocalStorage` plumbing and needs + * no cleanup: the entry becomes collectable as soon as the request object does. + * Routes that never record a snapshot (everything outside the v1 API) read + * `undefined` and are left untouched. + */ +export interface RateLimitSnapshot { + limit: number + remaining: number + resetAt: Date +} + +const snapshots = new WeakMap() + +/** + * Records the rate-limit snapshot for this request. Called by the v1 middleware + * once the token bucket has been consulted; a request that fails authentication + * records nothing, so no quota is published for it. + */ +export function recordRateLimitSnapshot(request: object, snapshot: RateLimitSnapshot): void { + snapshots.set(request, snapshot) +} + +/** + * The `X-RateLimit-*` trio. The single definition of these header names and + * their formatting — every emitter goes through here so a change to the header + * contract lands in one place. + */ +export function buildRateLimitHeaders(snapshot: RateLimitSnapshot): Record { + return { + 'X-RateLimit-Limit': snapshot.limit.toString(), + 'X-RateLimit-Remaining': snapshot.remaining.toString(), + 'X-RateLimit-Reset': snapshot.resetAt.toISOString(), + } +} + +/** + * Headers for a request, or `null` when no bucket was consulted for it. + */ +export function getRateLimitHeaders(request: object): Record | null { + const snapshot = snapshots.get(request) + return snapshot ? buildRateLimitHeaders(snapshot) : null +} diff --git a/apps/sim/lib/core/utils/with-route-handler.ts b/apps/sim/lib/core/utils/with-route-handler.ts index b61cd0d3b30..c3b781b6a28 100644 --- a/apps/sim/lib/core/utils/with-route-handler.ts +++ b/apps/sim/lib/core/utils/with-route-handler.ts @@ -2,6 +2,7 @@ import { createLogger, runWithRequestContext } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import type { NextRequest } from 'next/server' import { NextResponse } from 'next/server' +import { getRateLimitHeaders } from '@/lib/api/server/rate-limit-context' import { HttpError } from '@/lib/core/utils/http-error' import { generateRequestId } from '@/lib/core/utils/request' @@ -44,6 +45,26 @@ function readTypedErrorStatus(error: unknown): number | undefined { * - Catches unhandled errors, logs them, and returns a 500 with the request ID * - Attaches `x-request-id` response header */ +/** + * Stamps the request id, plus the rate-limit trio when the route consulted a + * bucket for this request. Applied on both the success and the unhandled-error + * path so a caller can read its quota from any response — including the 4xx and + * 5xx ones, which are exactly the responses worth retrying. + */ +function applyResponseHeaders( + response: NextResponse | Response | undefined, + request: NextRequest, + requestId: string +): void { + if (!response?.headers) return + response.headers.set('x-request-id', requestId) + const rateLimit = getRateLimitHeaders(request) + if (!rateLimit) return + for (const [name, value] of Object.entries(rateLimit)) { + response.headers.set(name, value) + } +} + export function withRouteHandler(handler: RouteHandler): RouteHandler { return async (request: NextRequest, context: T) => { const requestId = generateRequestId() @@ -74,7 +95,7 @@ export function withRouteHandler(handler: RouteHandler): RouteHandler { { status: 500 } ) } - response?.headers?.set('x-request-id', requestId) + applyResponseHeaders(response, request, requestId) return response } @@ -89,7 +110,7 @@ export function withRouteHandler(handler: RouteHandler): RouteHandler { logger.info('OK', { status, duration }) } - response?.headers?.set('x-request-id', requestId) + applyResponseHeaders(response, request, requestId) return response }) } From b912fd917251b78acdac2c9cbffcd9f811ac250b Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 28 Jul 2026 12:36:35 -0700 Subject: [PATCH 4/6] docs(api): reattach the withRouteHandler docblock and drop stale wording MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The comment pass caught a real casualty of the previous commit: inserting `applyResponseHeaders` put it between `withRouteHandler`'s docblock and the function itself, so the file's most-used export lost its documentation to the new private helper. Reattached, and its header bullet now mentions the rate-limit trio it also emits. Remaining edits are wording only. The WeakMap rationale moved off `RateLimitSnapshot` — three self-evident fields — onto the `snapshots` declaration it actually describes. The record site no longer restates that rationale; it keeps only the part unique to it. And three id-schema docs claimed "same constraint as nonEmptyIdSchema", which stopped being true when that schema was documented as deliberately message-less. --- apps/sim/app/api/v1/middleware.ts | 6 +----- apps/sim/lib/api/contracts/primitives.ts | 16 +++------------- apps/sim/lib/api/server/rate-limit-context.ts | 18 +++++++----------- apps/sim/lib/core/utils/with-route-handler.ts | 19 ++++++++++--------- 4 files changed, 21 insertions(+), 38 deletions(-) diff --git a/apps/sim/app/api/v1/middleware.ts b/apps/sim/app/api/v1/middleware.ts index 93f07c6feef..aac1c3820ba 100644 --- a/apps/sim/app/api/v1/middleware.ts +++ b/apps/sim/app/api/v1/middleware.ts @@ -100,11 +100,7 @@ export async function checkRateLimit( const plan = (subscription?.plan || 'free') as SubscriptionPlan const config = getRateLimit(plan, 'api-endpoint') - /** - * Recorded here — the one place the bucket is actually consulted — so - * `withRouteHandler` can publish the quota on every response the route - * returns, including error paths, without each `return` remembering to. - */ + /** Recorded here — the one place the bucket is actually consulted. */ recordRateLimitSnapshot(request, { limit: config.maxTokens, remaining: result.remaining, diff --git a/apps/sim/lib/api/contracts/primitives.ts b/apps/sim/lib/api/contracts/primitives.ts index 232b7ec3176..cf7fffe80f1 100644 --- a/apps/sim/lib/api/contracts/primitives.ts +++ b/apps/sim/lib/api/contracts/primitives.ts @@ -50,23 +50,13 @@ export function requiredFieldSchema(message: string) { return z.string({ error: message }).min(1, message) } -/** - * Non-empty `workspaceId` field. Same constraint as `nonEmptyIdSchema` with a - * stable, human-readable message. Use to deduplicate the - * `z.string().min(1, 'Workspace ID is required')` pattern across contracts. - */ +/** Non-empty `workspaceId` field with a stable, human-readable message. */ export const workspaceIdSchema = requiredFieldSchema('Workspace ID is required') -/** - * Non-empty `organizationId` field. Same constraint as `nonEmptyIdSchema` with a - * stable, human-readable message. - */ +/** Non-empty `organizationId` field with a stable, human-readable message. */ export const organizationIdSchema = requiredFieldSchema('Organization ID is required') -/** - * Non-empty `workflowId` field. Same constraint as `nonEmptyIdSchema` with a - * stable, human-readable message. - */ +/** Non-empty `workflowId` field with a stable, human-readable message. */ export const workflowIdSchema = requiredFieldSchema('Workflow ID is required') /** diff --git a/apps/sim/lib/api/server/rate-limit-context.ts b/apps/sim/lib/api/server/rate-limit-context.ts index be45c9e7c4e..3d9d9fd04ad 100644 --- a/apps/sim/lib/api/server/rate-limit-context.ts +++ b/apps/sim/lib/api/server/rate-limit-context.ts @@ -1,3 +1,9 @@ +export interface RateLimitSnapshot { + limit: number + remaining: number + resetAt: Date +} + /** * Request-scoped carrier for the rate-limit snapshot, so response headers can be * attached once at the route boundary instead of at every `return`. @@ -14,12 +20,6 @@ * Routes that never record a snapshot (everything outside the v1 API) read * `undefined` and are left untouched. */ -export interface RateLimitSnapshot { - limit: number - remaining: number - resetAt: Date -} - const snapshots = new WeakMap() /** @@ -31,11 +31,7 @@ export function recordRateLimitSnapshot(request: object, snapshot: RateLimitSnap snapshots.set(request, snapshot) } -/** - * The `X-RateLimit-*` trio. The single definition of these header names and - * their formatting — every emitter goes through here so a change to the header - * contract lands in one place. - */ +/** The single definition of the `X-RateLimit-*` header names and formatting. */ export function buildRateLimitHeaders(snapshot: RateLimitSnapshot): Record { return { 'X-RateLimit-Limit': snapshot.limit.toString(), diff --git a/apps/sim/lib/core/utils/with-route-handler.ts b/apps/sim/lib/core/utils/with-route-handler.ts index c3b781b6a28..2c4bc973ce2 100644 --- a/apps/sim/lib/core/utils/with-route-handler.ts +++ b/apps/sim/lib/core/utils/with-route-handler.ts @@ -36,15 +36,6 @@ function readTypedErrorStatus(error: unknown): number | undefined { return status } -/** - * Wraps a Next.js API route handler with centralized error reporting. - * - * - Generates a unique request ID and stores it in AsyncLocalStorage so every - * logger in the request lifecycle automatically includes it - * - Logs all 4xx and 5xx responses with method, path, status, duration - * - Catches unhandled errors, logs them, and returns a 500 with the request ID - * - Attaches `x-request-id` response header - */ /** * Stamps the request id, plus the rate-limit trio when the route consulted a * bucket for this request. Applied on both the success and the unhandled-error @@ -65,6 +56,16 @@ function applyResponseHeaders( } } +/** + * Wraps a Next.js API route handler with centralized error reporting. + * + * - Generates a unique request ID and stores it in AsyncLocalStorage so every + * logger in the request lifecycle automatically includes it + * - Logs all 4xx and 5xx responses with method, path, status, duration + * - Catches unhandled errors, logs them, and returns a 500 with the request ID + * - Attaches `x-request-id`, plus the rate-limit headers when the route + * recorded a snapshot for the request + */ export function withRouteHandler(handler: RouteHandler): RouteHandler { return async (request: NextRequest, context: T) => { const requestId = generateRequestId() From 74c43acc69b1f42975dd4cfb001cad90849e7007 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 28 Jul 2026 12:41:52 -0700 Subject: [PATCH 5/6] docs(api): document the quota headers on every v1 success response MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The spec already asserted, in the RateLimited description, that the X-RateLimit-* trio accompanies every authenticated response. Before this branch that was false for tables, files and knowledge; it is true now, but no operation documented it — only 1 of 40 v1 success responses carried the headers. All 40 now reference the shared header components. The shared BadRequest, Forbidden and NotFound components are deliberately left alone: they are also $ref-ed by non-v1 operations that publish no quota, so annotating them there would over-claim. The RateLimited description carries the general rule instead, now stating explicitly that the only responses without the headers are the ones that failed authentication. --- apps/docs/openapi.json | 440 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 439 insertions(+), 1 deletion(-) diff --git a/apps/docs/openapi.json b/apps/docs/openapi.json index 79c22942f33..1844cc7a1e2 100644 --- a/apps/docs/openapi.json +++ b/apps/docs/openapi.json @@ -985,6 +985,17 @@ "responses": { "200": { "description": "A paginated list of workflows.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, "content": { "application/json": { "schema": { @@ -1110,6 +1121,17 @@ "responses": { "201": { "description": "The workflow was imported.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, "content": { "application/json": { "schema": { @@ -1218,6 +1240,17 @@ "responses": { "200": { "description": "Workflow details including input field definitions.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, "content": { "application/json": { "schema": { @@ -1287,6 +1320,17 @@ "responses": { "200": { "description": "The workflow export envelope.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, "content": { "application/json": { "schema": { @@ -1400,6 +1444,17 @@ "responses": { "200": { "description": "Workflow deployed successfully.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, "content": { "application/json": { "schema": { @@ -1485,6 +1540,17 @@ "responses": { "200": { "description": "Workflow undeployed successfully.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, "content": { "application/json": { "schema": { @@ -1591,6 +1657,17 @@ "responses": { "200": { "description": "Workflow rolled back successfully.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, "content": { "application/json": { "schema": { @@ -1877,6 +1954,17 @@ "responses": { "200": { "description": "A paginated list of execution logs matching the filter criteria.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, "content": { "application/json": { "schema": { @@ -1955,6 +2043,17 @@ "responses": { "200": { "description": "Detailed log entry with full execution data and cost breakdown.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, "content": { "application/json": { "schema": { @@ -2026,6 +2125,17 @@ "responses": { "200": { "description": "Full execution state snapshot with workflow state and metadata.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, "content": { "application/json": { "schema": { @@ -2216,6 +2326,17 @@ "responses": { "200": { "description": "A paginated list of audit log entries.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, "content": { "application/json": { "schema": { @@ -2299,6 +2420,17 @@ "responses": { "200": { "description": "The audit log entry.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, "content": { "application/json": { "schema": { @@ -2423,6 +2555,17 @@ "responses": { "200": { "description": "List of tables in the workspace.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, "content": { "application/json": { "schema": { @@ -2576,6 +2719,17 @@ "responses": { "200": { "description": "Table created successfully.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, "content": { "application/json": { "schema": { @@ -2670,6 +2824,17 @@ "responses": { "200": { "description": "Table details.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, "content": { "application/json": { "schema": { @@ -2759,6 +2924,17 @@ "responses": { "200": { "description": "Table deleted successfully.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, "content": { "application/json": { "schema": { @@ -2881,6 +3057,17 @@ "responses": { "200": { "description": "Column added successfully", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, "content": { "application/json": { "schema": { @@ -3009,6 +3196,17 @@ "responses": { "200": { "description": "Column updated successfully", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, "content": { "application/json": { "schema": { @@ -3110,6 +3308,17 @@ "responses": { "200": { "description": "Column deleted successfully", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, "content": { "application/json": { "schema": { @@ -3228,6 +3437,17 @@ "responses": { "200": { "description": "Rows matching the query.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, "content": { "application/json": { "schema": { @@ -3401,6 +3621,17 @@ "responses": { "200": { "description": "Row(s) inserted successfully.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, "content": { "application/json": { "schema": { @@ -3635,6 +3866,17 @@ "responses": { "200": { "description": "Rows deleted.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, "content": { "application/json": { "schema": { @@ -3832,6 +4074,17 @@ "responses": { "200": { "description": "Row data.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, "content": { "application/json": { "schema": { @@ -3940,6 +4193,17 @@ "responses": { "200": { "description": "Row updated.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, "content": { "application/json": { "schema": { @@ -4025,6 +4289,17 @@ "responses": { "200": { "description": "Row deleted.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, "content": { "application/json": { "schema": { @@ -4153,6 +4428,17 @@ "responses": { "200": { "description": "Row upserted successfully.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, "content": { "application/json": { "schema": { @@ -4242,6 +4528,17 @@ "responses": { "200": { "description": "List of workspace files.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, "content": { "application/json": { "schema": { @@ -4342,6 +4639,17 @@ "responses": { "200": { "description": "File uploaded successfully.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, "content": { "application/json": { "schema": { @@ -4489,6 +4797,15 @@ "type": "string", "format": "date-time" } + }, + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" } }, "content": { @@ -4548,6 +4865,17 @@ "responses": { "200": { "description": "File deleted successfully.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, "content": { "application/json": { "schema": { @@ -4618,6 +4946,17 @@ "responses": { "200": { "description": "List of knowledge bases in the workspace.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, "content": { "application/json": { "schema": { @@ -4733,6 +5072,17 @@ "responses": { "200": { "description": "Knowledge base created successfully.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, "content": { "application/json": { "schema": { @@ -4824,6 +5174,17 @@ "responses": { "200": { "description": "Knowledge base details.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, "content": { "application/json": { "schema": { @@ -4943,6 +5304,17 @@ "responses": { "200": { "description": "Knowledge base updated successfully.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, "content": { "application/json": { "schema": { @@ -5034,6 +5406,17 @@ "responses": { "200": { "description": "Knowledge base deleted successfully.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, "content": { "application/json": { "schema": { @@ -5177,6 +5560,17 @@ "responses": { "200": { "description": "List of documents.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, "content": { "application/json": { "schema": { @@ -5317,6 +5711,17 @@ "responses": { "200": { "description": "Document uploaded successfully. Processing will begin shortly.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, "content": { "application/json": { "schema": { @@ -5469,6 +5874,17 @@ "responses": { "200": { "description": "Document details.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, "content": { "application/json": { "schema": { @@ -5564,6 +5980,17 @@ "responses": { "200": { "description": "Document deleted successfully.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, "content": { "application/json": { "schema": { @@ -5683,6 +6110,17 @@ "responses": { "200": { "description": "Search results.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, "content": { "application/json": { "schema": { @@ -7762,7 +8200,7 @@ } }, "RateLimited": { - "description": "Rate limit exceeded. Wait for the duration specified in the Retry-After header before retrying. The X-RateLimit-* headers below accompany every authenticated response, not just this one; they are omitted when a request fails authentication, since no rate-limit bucket is consulted in that case.", + "description": "Rate limit exceeded. Wait for the duration specified in the Retry-After header before retrying. The X-RateLimit-* headers accompany every response from an authenticated v1 request \u2014 success and error alike \u2014 and are omitted only when the request fails authentication, since no rate-limit bucket is consulted in that case.", "headers": { "Retry-After": { "description": "Number of seconds to wait before retrying the request.", From 3a22a751b91bec193be20688bc39e178529f1dd6 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 28 Jul 2026 12:50:41 -0700 Subject: [PATCH 6/6] fix(api): stop the last v1 validation paths from swallowing the message MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bugbot found GET /api/v1/tables/{tableId}/rows still answering with the bare "Validation error". Its handler special-cases malformed filter/sort JSON and then falls back to the shared helper — so the site looked handled to a check that only asked whether a handler existed, which is why the earlier call-level sweep passed over it. Auditing the whole class turned up more of the same shape: - The optional-body parses on deploy and rollback, where a bad `version` lost "version must be a positive integer". - Eleven catch-block `validationErrorResponseFromError` handlers across the table routes, which discard the message of any ZodError thrown deeper. Adds `v1ValidationErrorResponseFromError` as the v1 counterpart for unknown caught values, and routes every remaining v1 validation path through the v1 helpers. No call to the generic helpers survives under app/api/v1 outside admin, which keeps its own error envelope. --- apps/sim/app/api/v1/middleware.ts | 14 +++++++++++++- .../api/v1/tables/[tableId]/columns/route.ts | 9 +++++---- .../v1/tables/[tableId]/rows/[rowId]/route.ts | 5 +++-- .../app/api/v1/tables/[tableId]/rows/route.ts | 17 +++++++---------- .../v1/tables/[tableId]/rows/upsert/route.ts | 5 +++-- apps/sim/app/api/v1/tables/route.ts | 7 ++++--- .../api/v1/workflows/[id]/deploy/route.test.ts | 2 ++ .../app/api/v1/workflows/[id]/deploy/route.ts | 10 +++++++--- .../v1/workflows/[id]/rollback/route.test.ts | 2 ++ .../app/api/v1/workflows/[id]/rollback/route.ts | 10 +++++++--- 10 files changed, 53 insertions(+), 28 deletions(-) diff --git a/apps/sim/app/api/v1/middleware.ts b/apps/sim/app/api/v1/middleware.ts index aac1c3820ba..25357c19d18 100644 --- a/apps/sim/app/api/v1/middleware.ts +++ b/apps/sim/app/api/v1/middleware.ts @@ -2,7 +2,7 @@ import { createLogger } from '@sim/logger' import { type PermissionType, permissionSatisfies } from '@sim/platform-authz/workspace' import { type NextRequest, NextResponse } from 'next/server' import type { ZodError } from 'zod' -import { getValidationErrorMessage, validationErrorResponse } from '@/lib/api/server' +import { getValidationErrorMessage, isZodError, validationErrorResponse } from '@/lib/api/server' import { buildRateLimitHeaders, recordRateLimitSnapshot } from '@/lib/api/server/rate-limit-context' import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription' import type { SubscriptionPlan } from '@/lib/core/rate-limiter' @@ -271,3 +271,15 @@ export async function validateWorkspaceAccess( export function v1ValidationErrorResponse(error: ZodError, fallback = 'Invalid request') { return validationErrorResponse(error, getValidationErrorMessage(error, fallback)) } + +/** + * v1 counterpart to `validationErrorResponseFromError` for unknown caught + * values: returns a 400 naming the failing field when the error is a + * `ZodError`, otherwise `null` so the caller can keep handling it. + */ +export function v1ValidationErrorResponseFromError( + error: unknown, + fallback = 'Invalid request' +): NextResponse | null { + return isZodError(error) ? v1ValidationErrorResponse(error, fallback) : null +} diff --git a/apps/sim/app/api/v1/tables/[tableId]/columns/route.ts b/apps/sim/app/api/v1/tables/[tableId]/columns/route.ts index 22b3d83f2cd..f1751ee2120 100644 --- a/apps/sim/app/api/v1/tables/[tableId]/columns/route.ts +++ b/apps/sim/app/api/v1/tables/[tableId]/columns/route.ts @@ -6,7 +6,7 @@ import { v1DeleteTableColumnContract, v1UpdateTableColumnContract, } from '@/lib/api/contracts/v1/tables' -import { parseRequest, validationErrorResponseFromError } from '@/lib/api/server' +import { parseRequest } from '@/lib/api/server' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { @@ -29,6 +29,7 @@ import { checkWorkspaceScope, createRateLimitResponse, v1ValidationErrorResponse, + v1ValidationErrorResponseFromError, } from '@/app/api/v1/middleware' const logger = createLogger('V1TableColumnsAPI') @@ -94,7 +95,7 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Colum } catch (error) { const lockError = tableLockErrorResponse(error) if (lockError) return lockError - const validationResponse = validationErrorResponseFromError(error) + const validationResponse = v1ValidationErrorResponseFromError(error) if (validationResponse) return validationResponse if (error instanceof Error) { @@ -245,7 +246,7 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Colu } catch (error) { const lockError = tableLockErrorResponse(error) if (lockError) return lockError - const validationResponse = validationErrorResponseFromError(error) + const validationResponse = v1ValidationErrorResponseFromError(error) if (validationResponse) return validationResponse if (error instanceof Error) { @@ -330,7 +331,7 @@ export const DELETE = withRouteHandler( } catch (error) { const lockError = tableLockErrorResponse(error) if (lockError) return lockError - const validationResponse = validationErrorResponseFromError(error) + const validationResponse = v1ValidationErrorResponseFromError(error) if (validationResponse) return validationResponse if (error instanceof Error) { diff --git a/apps/sim/app/api/v1/tables/[tableId]/rows/[rowId]/route.ts b/apps/sim/app/api/v1/tables/[tableId]/rows/[rowId]/route.ts index ad43dc56afb..5fee4f3d03b 100644 --- a/apps/sim/app/api/v1/tables/[tableId]/rows/[rowId]/route.ts +++ b/apps/sim/app/api/v1/tables/[tableId]/rows/[rowId]/route.ts @@ -9,7 +9,7 @@ import { v1GetTableRowContract, v1UpdateTableRowContract, } from '@/lib/api/contracts/v1/tables' -import { parseRequest, validationErrorResponseFromError } from '@/lib/api/server' +import { parseRequest } from '@/lib/api/server' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import type { RowData, TableSchema } from '@/lib/table' @@ -23,6 +23,7 @@ import { createRateLimitResponse, resolveWorkspaceRequestActor, v1ValidationErrorResponse, + v1ValidationErrorResponseFromError, } from '@/app/api/v1/middleware' const logger = createLogger('V1TableRowAPI') @@ -184,7 +185,7 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: RowR } catch (error) { const lockError = tableLockErrorResponse(error) if (lockError) return lockError - const validationResponse = validationErrorResponseFromError(error) + const validationResponse = v1ValidationErrorResponseFromError(error) if (validationResponse) return validationResponse const errorMessage = toError(error).message diff --git a/apps/sim/app/api/v1/tables/[tableId]/rows/route.ts b/apps/sim/app/api/v1/tables/[tableId]/rows/route.ts index 9fff01c9f25..cc56cc61183 100644 --- a/apps/sim/app/api/v1/tables/[tableId]/rows/route.ts +++ b/apps/sim/app/api/v1/tables/[tableId]/rows/route.ts @@ -7,11 +7,7 @@ import { v1ListTableRowsContract, v1UpdateRowsByFilterContract, } from '@/lib/api/contracts/v1/tables' -import { - parseRequest, - validationErrorResponse, - validationErrorResponseFromError, -} from '@/lib/api/server' +import { parseRequest } from '@/lib/api/server' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import type { Filter, RowData, TableSchema } from '@/lib/table' @@ -42,6 +38,7 @@ import { createRateLimitResponse, resolveWorkspaceRequestActor, v1ValidationErrorResponse, + v1ValidationErrorResponseFromError, } from '@/app/api/v1/middleware' const logger = createLogger('V1TableRowsAPI') @@ -136,7 +133,7 @@ export const GET = withRouteHandler(async (request: NextRequest, context: TableR if (hasJsonError) { return NextResponse.json({ error: 'Invalid filter or sort JSON' }, { status: 400 }) } - return validationErrorResponse(error) + return v1ValidationErrorResponse(error) }, }) if (!parsed.success) return parsed.response @@ -196,7 +193,7 @@ export const GET = withRouteHandler(async (request: NextRequest, context: TableR }, }) } catch (error) { - const validationResponse = validationErrorResponseFromError(error) + const validationResponse = v1ValidationErrorResponseFromError(error) if (validationResponse) return validationResponse if (error instanceof TableQueryValidationError) { @@ -296,7 +293,7 @@ export const POST = withRouteHandler( }, }) } catch (error) { - const validationResponse = validationErrorResponseFromError(error) + const validationResponse = v1ValidationErrorResponseFromError(error) if (validationResponse) return validationResponse const response = rowWriteErrorResponse(error) @@ -386,7 +383,7 @@ export const PUT = withRouteHandler(async (request: NextRequest, context: TableR }, }) } catch (error) { - const validationResponse = validationErrorResponseFromError(error) + const validationResponse = v1ValidationErrorResponseFromError(error) if (validationResponse) return validationResponse if (error instanceof TableQueryValidationError) { @@ -479,7 +476,7 @@ export const DELETE = withRouteHandler( }, }) } catch (error) { - const validationResponse = validationErrorResponseFromError(error) + const validationResponse = v1ValidationErrorResponseFromError(error) if (validationResponse) return validationResponse if (error instanceof TableQueryValidationError) { diff --git a/apps/sim/app/api/v1/tables/[tableId]/rows/upsert/route.ts b/apps/sim/app/api/v1/tables/[tableId]/rows/upsert/route.ts index 298a48ab526..bf4a00df91b 100644 --- a/apps/sim/app/api/v1/tables/[tableId]/rows/upsert/route.ts +++ b/apps/sim/app/api/v1/tables/[tableId]/rows/upsert/route.ts @@ -2,7 +2,7 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' import { v1UpsertTableRowContract } from '@/lib/api/contracts/v1/tables' -import { parseRequest, validationErrorResponseFromError } from '@/lib/api/server' +import { parseRequest } from '@/lib/api/server' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import type { RowData, TableSchema } from '@/lib/table' @@ -16,6 +16,7 @@ import { createRateLimitResponse, resolveWorkspaceRequestActor, v1ValidationErrorResponse, + v1ValidationErrorResponseFromError, } from '@/app/api/v1/middleware' const logger = createLogger('V1TableUpsertAPI') @@ -97,7 +98,7 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Upser } catch (error) { const lockError = tableLockErrorResponse(error) if (lockError) return lockError - const validationResponse = validationErrorResponseFromError(error) + const validationResponse = v1ValidationErrorResponseFromError(error) if (validationResponse) return validationResponse const errorMessage = toError(error).message diff --git a/apps/sim/app/api/v1/tables/route.ts b/apps/sim/app/api/v1/tables/route.ts index 37f8515d83d..82bc6618247 100644 --- a/apps/sim/app/api/v1/tables/route.ts +++ b/apps/sim/app/api/v1/tables/route.ts @@ -2,7 +2,7 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { v1CreateTableContract, v1ListTablesContract } from '@/lib/api/contracts/v1/tables' -import { parseRequest, validationErrorResponseFromError } from '@/lib/api/server' +import { parseRequest } from '@/lib/api/server' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createTable, getWorkspaceTableLimits, listTables, type TableSchema } from '@/lib/table' @@ -11,6 +11,7 @@ import { checkRateLimit, createRateLimitResponse, v1ValidationErrorResponse, + v1ValidationErrorResponseFromError, validateWorkspaceAccess, } from '@/app/api/v1/middleware' @@ -72,7 +73,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { }, }) } catch (error) { - const validationResponse = validationErrorResponseFromError(error) + const validationResponse = v1ValidationErrorResponseFromError(error) if (validationResponse) return validationResponse logger.error(`[${requestId}] Error listing tables:`, error) @@ -167,7 +168,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { }, }) } catch (error) { - const validationResponse = validationErrorResponseFromError(error) + const validationResponse = v1ValidationErrorResponseFromError(error) if (validationResponse) return validationResponse if (error instanceof Error) { diff --git a/apps/sim/app/api/v1/workflows/[id]/deploy/route.test.ts b/apps/sim/app/api/v1/workflows/[id]/deploy/route.test.ts index 42e977bdfe2..7ec6df59634 100644 --- a/apps/sim/app/api/v1/workflows/[id]/deploy/route.test.ts +++ b/apps/sim/app/api/v1/workflows/[id]/deploy/route.test.ts @@ -31,6 +31,8 @@ vi.mock('@/app/api/v1/middleware', () => ({ NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) ), validateWorkspaceAccess: mockValidateWorkspaceAccess, + v1ValidationErrorResponse: (e: { issues: unknown[] }) => + NextResponse.json({ error: 'Validation error', details: e.issues }, { status: 400 }), })) vi.mock('@/lib/workflows/orchestration', () => ({ diff --git a/apps/sim/app/api/v1/workflows/[id]/deploy/route.ts b/apps/sim/app/api/v1/workflows/[id]/deploy/route.ts index 822e00ab2ca..7068239e134 100644 --- a/apps/sim/app/api/v1/workflows/[id]/deploy/route.ts +++ b/apps/sim/app/api/v1/workflows/[id]/deploy/route.ts @@ -7,14 +7,18 @@ import { v1DeployWorkflowContract, v1UndeployWorkflowContract, } from '@/lib/api/contracts/v1/workflows' -import { parseOptionalJsonBody, parseRequest, validationErrorResponse } from '@/lib/api/server' +import { parseOptionalJsonBody, parseRequest } from '@/lib/api/server' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { captureServerEvent } from '@/lib/posthog/server' import { performFullDeploy, performFullUndeploy } from '@/lib/workflows/orchestration' import { statusForOrchestrationError } from '@/lib/workflows/orchestration/types' import { createApiResponse, getUserLimits } from '@/app/api/v1/logs/meta' -import { checkRateLimit, createRateLimitResponse } from '@/app/api/v1/middleware' +import { + checkRateLimit, + createRateLimitResponse, + v1ValidationErrorResponse, +} from '@/app/api/v1/middleware' import { resolveV1DeploymentWorkflow } from '@/app/api/v1/workflows/utils' const logger = createLogger('V1WorkflowDeployAPI') @@ -46,7 +50,7 @@ export const POST = withRouteHandler( if (!rawBody.success) return rawBody.response const body = v1DeployWorkflowBodySchema.safeParse(rawBody.data ?? {}) if (!body.success) { - return validationErrorResponse(body.error) + return v1ValidationErrorResponse(body.error) } const target = await resolveV1DeploymentWorkflow(rateLimit, userId, id) diff --git a/apps/sim/app/api/v1/workflows/[id]/rollback/route.test.ts b/apps/sim/app/api/v1/workflows/[id]/rollback/route.test.ts index 8ee6ec2940b..2327f71325b 100644 --- a/apps/sim/app/api/v1/workflows/[id]/rollback/route.test.ts +++ b/apps/sim/app/api/v1/workflows/[id]/rollback/route.test.ts @@ -33,6 +33,8 @@ vi.mock('@/app/api/v1/middleware', () => ({ NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) ), validateWorkspaceAccess: mockValidateWorkspaceAccess, + v1ValidationErrorResponse: (e: { issues: unknown[] }) => + NextResponse.json({ error: 'Validation error', details: e.issues }, { status: 400 }), })) vi.mock('@/lib/workflows/orchestration', () => ({ diff --git a/apps/sim/app/api/v1/workflows/[id]/rollback/route.ts b/apps/sim/app/api/v1/workflows/[id]/rollback/route.ts index 63ca166cdf1..a0779babf51 100644 --- a/apps/sim/app/api/v1/workflows/[id]/rollback/route.ts +++ b/apps/sim/app/api/v1/workflows/[id]/rollback/route.ts @@ -6,14 +6,18 @@ import { v1RollbackWorkflowBodySchema, v1RollbackWorkflowContract, } from '@/lib/api/contracts/v1/workflows' -import { parseOptionalJsonBody, parseRequest, validationErrorResponse } from '@/lib/api/server' +import { parseOptionalJsonBody, parseRequest } from '@/lib/api/server' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { performActivateVersion } from '@/lib/workflows/orchestration' import { statusForOrchestrationError } from '@/lib/workflows/orchestration/types' import { findPreviousDeploymentVersion } from '@/lib/workflows/persistence/utils' import { createApiResponse, getUserLimits } from '@/app/api/v1/logs/meta' -import { checkRateLimit, createRateLimitResponse } from '@/app/api/v1/middleware' +import { + checkRateLimit, + createRateLimitResponse, + v1ValidationErrorResponse, +} from '@/app/api/v1/middleware' import { resolveV1DeploymentWorkflow } from '@/app/api/v1/workflows/utils' const logger = createLogger('V1WorkflowRollbackAPI') @@ -45,7 +49,7 @@ export const POST = withRouteHandler( if (!rawBody.success) return rawBody.response const body = v1RollbackWorkflowBodySchema.safeParse(rawBody.data ?? {}) if (!body.success) { - return validationErrorResponse(body.error) + return v1ValidationErrorResponse(body.error) } const target = await resolveV1DeploymentWorkflow(rateLimit, userId, id)