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.", 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 9a85c408bb1..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,6 +20,8 @@ vi.mock('@/app/api/v1/middleware', () => ({ checkRateLimit: mockCheckRateLimit, createRateLimitResponse: () => new Response('rate limited', { status: 429 }), validateWorkspaceAccess: mockValidateWorkspaceAccess, + 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..89c63c37d31 100644 --- a/apps/sim/app/api/v1/files/[fileId]/route.ts +++ b/apps/sim/app/api/v1/files/[fileId]/route.ts @@ -15,6 +15,7 @@ import { performDeleteWorkspaceFileItems } from '@/lib/workspace-files/orchestra import { checkRateLimit, createRateLimitResponse, + v1ValidationErrorResponse, validateWorkspaceAccess, } from '@/app/api/v1/middleware' @@ -38,7 +39,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 +122,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 diff --git a/apps/sim/app/api/v1/files/route.ts b/apps/sim/app/api/v1/files/route.ts index 350a2080c72..c101b8cf597 100644 --- a/apps/sim/app/api/v1/files/route.ts +++ b/apps/sim/app/api/v1/files/route.ts @@ -22,6 +22,7 @@ import { checkRateLimit, checkWorkspaceScope, createRateLimitResponse, + v1ValidationErrorResponse, validateWorkspaceAccess, } from '@/app/api/v1/middleware' @@ -43,7 +44,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 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..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,7 +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 } from '@/app/api/v1/middleware' +import { authenticateRequest, v1ValidationErrorResponse } from '@/app/api/v1/middleware' export const dynamic = 'force-dynamic' export const revalidate = 0 @@ -28,7 +28,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 @@ -117,7 +119,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 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..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,6 +46,8 @@ const SYSTEM_BILLING_ATTRIBUTION = { vi.mock('@/app/api/v1/middleware', () => ({ authenticateRequest: mockAuthenticateRequest, + 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..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,7 +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 } from '@/app/api/v1/middleware' +import { authenticateRequest, v1ValidationErrorResponse } from '@/app/api/v1/middleware' export const dynamic = 'force-dynamic' export const revalidate = 0 @@ -44,7 +44,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 } = @@ -99,7 +101,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 diff --git a/apps/sim/app/api/v1/knowledge/[id]/route.ts b/apps/sim/app/api/v1/knowledge/[id]/route.ts index b47241f0199..8dbb280559f 100644 --- a/apps/sim/app/api/v1/knowledge/[id]/route.ts +++ b/apps/sim/app/api/v1/knowledge/[id]/route.ts @@ -13,7 +13,7 @@ import { handleError, resolveKnowledgeBase, } from '@/app/api/v1/knowledge/utils' -import { authenticateRequest } from '@/app/api/v1/middleware' +import { authenticateRequest, v1ValidationErrorResponse } from '@/app/api/v1/middleware' export const dynamic = 'force-dynamic' export const revalidate = 0 @@ -29,7 +29,9 @@ 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 @@ -54,7 +56,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 @@ -106,7 +110,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 diff --git a/apps/sim/app/api/v1/knowledge/route.ts b/apps/sim/app/api/v1/knowledge/route.ts index 04e9d5f5800..5b608484025 100644 --- a/apps/sim/app/api/v1/knowledge/route.ts +++ b/apps/sim/app/api/v1/knowledge/route.ts @@ -9,7 +9,11 @@ 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, + v1ValidationErrorResponse, + validateWorkspaceAccess, +} from '@/app/api/v1/middleware' export const dynamic = 'force-dynamic' export const revalidate = 0 @@ -21,7 +25,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 @@ -50,7 +61,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 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..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,6 +78,8 @@ vi.mock('@/lib/knowledge/embeddings', () => ({ vi.mock('@/app/api/v1/middleware', () => ({ authenticateRequest: mockAuthenticateRequest, validateWorkspaceAccess: mockValidateWorkspaceAccess, + 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..3ac848b303d 100644 --- a/apps/sim/app/api/v1/knowledge/search/route.ts +++ b/apps/sim/app/api/v1/knowledge/search/route.ts @@ -23,7 +23,11 @@ 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, + v1ValidationErrorResponse, + validateWorkspaceAccess, +} from '@/app/api/v1/middleware' export const dynamic = 'force-dynamic' export const revalidate = 0 @@ -35,7 +39,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 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 6b0efaabf3a..c49850c6a45 100644 --- a/apps/sim/app/api/v1/middleware.test.ts +++ b/apps/sim/app/api/v1/middleware.test.ts @@ -9,6 +9,13 @@ 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(() => ({ @@ -33,7 +40,11 @@ vi.mock('@/lib/core/rate-limiter', () => ({ }, })) -import { checkRateLimit, createRateLimitResponse } from '@/app/api/v1/middleware' +import { + checkRateLimit, + createRateLimitResponse, + 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 +143,98 @@ describe('createRateLimitResponse', () => { await expect(response.json()).resolves.toEqual({ error: 'API key required' }) }) }) + +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, + 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') + 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('returns null for a request that never consulted a bucket', () => { + expect(getRateLimitHeaders({})).toBeNull() + }) + + it('returns the headers once a snapshot is recorded for that request', () => { + const req = {} + recordRateLimitSnapshot(req, SNAPSHOT) + + expect(getRateLimitHeaders(req)).toEqual(buildRateLimitHeaders(SNAPSHOT)) + }) + + it('keeps snapshots per request, not global', () => { + const a = {} + const b = {} + recordRateLimitSnapshot(a, SNAPSHOT) + + expect(getRateLimitHeaders(a)).not.toBeNull() + expect(getRateLimitHeaders(b)).toBeNull() + }) + + it('records a snapshot as a side effect of checkRateLimit', async () => { + const req = request() + + 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 eebebc04973..25357c19d18 100644 --- a/apps/sim/app/api/v1/middleware.ts +++ b/apps/sim/app/api/v1/middleware.ts @@ -1,6 +1,9 @@ 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, 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' import { getRateLimit, RateLimiter } from '@/lib/core/rate-limiter' @@ -97,6 +100,13 @@ 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. */ + recordRateLimitSnapshot(request, { + limit: config.maxTokens, + remaining: result.remaining, + resetAt: result.resetAt, + }) + return { allowed: result.allowed, remaining: result.remaining, @@ -154,12 +164,6 @@ 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 retryAfterSeconds = result.retryAfterMs ? Math.ceil(result.retryAfterMs / 1000) : Math.ceil((result.resetAt.getTime() - Date.now()) / 1000) @@ -173,7 +177,7 @@ export function createRateLimitResponse(result: RateLimitResult): NextResponse { { status: 429, headers: { - ...headers, + ...buildRateLimitHeaders(result), 'Retry-After': retryAfterSeconds.toString(), }, } @@ -251,3 +255,31 @@ 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, 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 46723538c7c..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 { @@ -28,6 +28,8 @@ import { checkRateLimit, checkWorkspaceScope, createRateLimitResponse, + v1ValidationErrorResponse, + v1ValidationErrorResponseFromError, } 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 @@ -91,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) { @@ -128,7 +132,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 @@ -240,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) { @@ -280,7 +286,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 @@ -323,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 2a7ea2fe7a5..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' @@ -22,6 +22,8 @@ import { checkWorkspaceScope, createRateLimitResponse, resolveWorkspaceRequestActor, + v1ValidationErrorResponse, + v1ValidationErrorResponseFromError, } from '@/app/api/v1/middleware' const logger = createLogger('V1TableRowAPI') @@ -116,7 +118,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 @@ -181,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 d0e37376cca..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' @@ -41,6 +37,8 @@ import { checkWorkspaceScope, createRateLimitResponse, resolveWorkspaceRequestActor, + v1ValidationErrorResponse, + v1ValidationErrorResponseFromError, } from '@/app/api/v1/middleware' const logger = createLogger('V1TableRowsAPI') @@ -135,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 @@ -195,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) { @@ -219,7 +217,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 @@ -293,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) @@ -316,7 +316,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 @@ -381,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) { @@ -408,7 +410,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 @@ -472,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 1df6b4b2384..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' @@ -15,6 +15,8 @@ import { checkWorkspaceScope, createRateLimitResponse, resolveWorkspaceRequestActor, + v1ValidationErrorResponse, + v1ValidationErrorResponseFromError, } 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 @@ -94,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 441f4fc1413..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' @@ -10,6 +10,8 @@ import { normalizeColumn } from '@/app/api/table/utils' import { checkRateLimit, createRateLimitResponse, + v1ValidationErrorResponse, + v1ValidationErrorResponseFromError, 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 @@ -64,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) @@ -84,7 +93,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 @@ -152,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) 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 79e6243aab4..cf7fffe80f1 100644 --- a/apps/sim/lib/api/contracts/primitives.ts +++ b/apps/sim/lib/api/contracts/primitives.ts @@ -26,42 +26,38 @@ 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) /** - * 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. + * Builds a required, non-empty string schema whose message covers **both** + * failure modes. * - * 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. + * `.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. When a named primitive below already carries the right wording, import + * that instead of rebuilding it here. */ -export const workspaceIdSchema = z - .string({ error: 'Workspace ID is required' }) - .min(1, 'Workspace ID is required') +export function requiredFieldSchema(message: string) { + return z.string({ error: message }).min(1, message) +} -/** - * 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') +/** Non-empty `workspaceId` field with a stable, human-readable message. */ +export const workspaceIdSchema = requiredFieldSchema('Workspace 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') +/** Non-empty `organizationId` field with a stable, human-readable message. */ +export const organizationIdSchema = requiredFieldSchema('Organization ID is required') + +/** Non-empty `workflowId` field with a stable, human-readable message. */ +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 +66,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/tables.ts b/apps/sim/lib/api/contracts/tables.ts index c1107e3591d..b128660d855 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, workspaceIdSchema } 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: workspaceIdSchema, scope: tableScopeSchema.default('active'), }) export const getTableQuerySchema = z.object({ - workspaceId: z.string().min(1, 'Workspace ID is required'), + workspaceId: workspaceIdSchema, }) 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: workspaceIdSchema, initialRowCount: z.number().int().min(0).max(100).optional(), }) export const renameTableBodySchema = z.object({ - workspaceId: z.string().min(1, 'Workspace ID is required'), + workspaceId: workspaceIdSchema, 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: workspaceIdSchema, 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: workspaceIdSchema, 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: workspaceIdSchema, 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: workspaceIdSchema, 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: workspaceIdSchema, 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: workspaceIdSchema, 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: workspaceIdSchema, data: rowDataSchema, conflictTarget: z.string().min(1).optional(), }) export const batchInsertTableRowsBodySchema = z .object({ - workspaceId: z.string().min(1, 'Workspace ID is required'), + workspaceId: workspaceIdSchema, 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: workspaceIdSchema, data: rowDataSchema, }) export const batchUpdateTableRowsBodySchema = z.object({ - workspaceId: z.string().min(1, 'Workspace ID is required'), + workspaceId: workspaceIdSchema, 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: workspaceIdSchema, }) export const deleteTableRowsBodySchema = z .object({ - workspaceId: z.string().min(1, 'Workspace ID is required'), + workspaceId: workspaceIdSchema, 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: workspaceIdSchema, 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: workspaceIdSchema, 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: workspaceIdSchema, + 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: workspaceIdSchema, + 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: workspaceIdSchema, + 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: workspaceIdSchema, 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: workspaceIdSchema, 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: workspaceIdSchema, + 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: workspaceIdSchema, 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: workspaceIdSchema, 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: workspaceIdSchema, 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: workspaceIdSchema, 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: workspaceIdSchema, 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: workspaceIdSchema, + 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: 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/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..0134da1147a 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, workspaceIdSchema } from '@/lib/api/contracts/primitives' import { defineRouteContract } from '@/lib/api/contracts/types' import { KNOWLEDGE_BASE_DESCRIPTION_MAX_LENGTH } from '@/lib/knowledge/constants' @@ -30,13 +31,13 @@ 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'), - name: z.string().min(1, 'Name is required').max(255, 'Name must be 255 characters or less'), + workspaceId: workspaceIdSchema, + name: requiredFieldSchema('Name is required').max(255, 'Name must be 255 characters or less'), description: z .string() .max( @@ -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: workspaceIdSchema, 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,9 +122,9 @@ 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: workspaceIdSchema, 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/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..4491b8840be 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, workspaceIdSchema } 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: workspaceIdSchema, 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), diff --git a/apps/sim/lib/api/contracts/workflows.ts b/apps/sim/lib/api/contracts/workflows.ts index 23fc05c736c..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 { 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())) @@ -229,7 +233,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 +258,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 +282,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 +306,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 +458,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: workflowIdSchema, + targetWorkspaceId: requiredFieldSchema('Target workspace ID is required'), }) export type ImportWorkflowAsSuperuserBody = z.input 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..3d9d9fd04ad --- /dev/null +++ b/apps/sim/lib/api/server/rate-limit-context.ts @@ -0,0 +1,49 @@ +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`. + * + * 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. + */ +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 single definition of the `X-RateLimit-*` header names and formatting. */ +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..2c4bc973ce2 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' @@ -35,6 +36,26 @@ function readTypedErrorStatus(error: unknown): number | undefined { return status } +/** + * 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) + } +} + /** * Wraps a Next.js API route handler with centralized error reporting. * @@ -42,7 +63,8 @@ function readTypedErrorStatus(error: unknown): number | undefined { * 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 + * - 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) => { @@ -74,7 +96,7 @@ export function withRouteHandler(handler: RouteHandler): RouteHandler { { status: 500 } ) } - response?.headers?.set('x-request-id', requestId) + applyResponseHeaders(response, request, requestId) return response } @@ -89,7 +111,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 }) }