From b69fdbd17b4f05b5b09120454f279e82151623dc Mon Sep 17 00:00:00 2001 From: Waleed Date: Tue, 28 Jul 2026 12:56:38 -0700 Subject: [PATCH] fix(api): give every v1 endpoint quota headers and errors that name the field (#6012) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(api): give every v1 endpoint quota headers and errors that name the field Three consistency gaps found by probing the live v1 surface end to end. Rate-limit headers were only published by routes built on createApiResponse — workflows, logs and audit-logs. Tables, files and knowledge are rate limited by the same bucket and will return 429, but published no quota on success, so a client discovered the ceiling only by hitting it. Adds a shared rateLimitHeaders() builder, reused by createRateLimitResponse, and attaches it to all 31 success responses on those three families. Missing required fields did not name themselves. .min(1, '...') only fires for a present-but-empty string, so an omitted field fell through to Zod's default "Invalid input: expected string, received undefined". A previous pass fixed the shared id schemas, but 22 of 23 v1 workspaceId declarations bypassed them, so the fix reached almost nothing. Adds requiredFieldSchema(message) and routes every v1 request input through it, preserving each site's existing, more specific wording (for example "workspaceId query parameter is required") instead of flattening them to the generic one. Response schemas are left alone — "required" wording would be wrong there. Validation failures on tables, files and knowledge reported the literal "Validation error" and discarded the schema's message. Adds v1ValidationErrorResponse, which surfaces the first issue while keeping details, and wires it into the 19 parseRequest calls that had no handler. Routes with deliberately specific wording keep theirs. The global default is untouched, since routes outside v1 assert the current string. * fix(api): finish the v1 consistency sweep at call level, not file level Review found three places the first pass missed, all from filters that worked on whole files instead of individual call sites. - GET /api/v1/files/{fileId} returns the file bytes via `new Response`, not a `success: true` JSON body, so the header pass skipped it. The download now carries the same quota headers as the DELETE beside it. - Four parseRequest calls in the table-row routes still reported the generic "Validation error". The first pass skipped any file that already had a handler anywhere in it, which excluded these two files wholesale. The check is now per call site, and no bare call remains. - POST /api/v1/tables takes its body from the shared tables contract, which still used the bare `.min(1)` form, so an omitted workspaceId did not name itself. Converted there and in the other v1-reachable contracts. Scope note: roughly a thousand `.min(1, '... is required')` declarations remain under contracts/tools/**. Those are block and tool definitions rather than the public REST surface, and converting them belongs in its own change. * refactor(api): publish quota headers from one chokepoint, not 32 call sites A quality pass found the previous commit only made the happy path consistent. Those three route families have 117 response sites; 32 got headers. The other 85 are the error paths — 400/403/404/500 — which are exactly the responses a client is deciding whether to retry, and they published no quota at all. `checkRateLimit` now records the bucket snapshot against the request, and `withRouteHandler` attaches the headers next to the `x-request-id` it already sets, on both the success and the unhandled-error branch. Every v1 response carries the quota now, and a new v1 route gets it without remembering to. The carrier is a WeakMap keyed by the request, so it needs no cleanup and routes that never record a snapshot — everything outside v1 — are untouched. This deletes more than it adds: the 32 decorations are gone, and so is the `rateLimit` parameter that had been threaded into `handleBatchInsert` purely so a business-logic helper could decorate its own response. Also from the same pass: - One definition of the header trio. `createApiResponse` had its own copy, so after the last commit there were two; both now build from `buildRateLimitHeaders`. - `v1ValidationErrorResponse` delegates to the shared `validationErrorResponse` instead of hand-rolling the same body, and takes a fallback message, which collapses four route closures that differed only in that string. - 36 sites wrote `requiredFieldSchema('Workspace ID is required')` — the verbatim definition of the exported `workspaceIdSchema`. Using the primitive is the whole point of having it; they now import it. - Dropped TSDoc that had gone stale or contradicted the call sites it advised. * docs(api): reattach the withRouteHandler docblock and drop stale wording The comment pass caught a real casualty of the previous commit: inserting `applyResponseHeaders` put it between `withRouteHandler`'s docblock and the function itself, so the file's most-used export lost its documentation to the new private helper. Reattached, and its header bullet now mentions the rate-limit trio it also emits. Remaining edits are wording only. The WeakMap rationale moved off `RateLimitSnapshot` — three self-evident fields — onto the `snapshots` declaration it actually describes. The record site no longer restates that rationale; it keeps only the part unique to it. And three id-schema docs claimed "same constraint as nonEmptyIdSchema", which stopped being true when that schema was documented as deliberately message-less. * docs(api): document the quota headers on every v1 success response The spec already asserted, in the RateLimited description, that the X-RateLimit-* trio accompanies every authenticated response. Before this branch that was false for tables, files and knowledge; it is true now, but no operation documented it — only 1 of 40 v1 success responses carried the headers. All 40 now reference the shared header components. The shared BadRequest, Forbidden and NotFound components are deliberately left alone: they are also $ref-ed by non-v1 operations that publish no quota, so annotating them there would over-claim. The RateLimited description carries the general rule instead, now stating explicitly that the only responses without the headers are the ones that failed authentication. * fix(api): stop the last v1 validation paths from swallowing the message Bugbot found GET /api/v1/tables/{tableId}/rows still answering with the bare "Validation error". Its handler special-cases malformed filter/sort JSON and then falls back to the shared helper — so the site looked handled to a check that only asked whether a handler existed, which is why the earlier call-level sweep passed over it. Auditing the whole class turned up more of the same shape: - The optional-body parses on deploy and rollback, where a bad `version` lost "version must be a positive integer". - Eleven catch-block `validationErrorResponseFromError` handlers across the table routes, which discard the message of any ZodError thrown deeper. Adds `v1ValidationErrorResponseFromError` as the v1 counterpart for unknown caught values, and routes every remaining v1 validation path through the v1 helpers. No call to the generic helpers survives under app/api/v1 outside admin, which keeps its own error envelope. --- apps/docs/openapi.json | 440 +++++++++++++++++- apps/sim/app/api/v1/audit-logs/route.test.ts | 2 + apps/sim/app/api/v1/audit-logs/route.ts | 17 +- .../app/api/v1/files/[fileId]/route.test.ts | 2 + apps/sim/app/api/v1/files/[fileId]/route.ts | 9 +- apps/sim/app/api/v1/files/route.ts | 10 +- .../[id]/documents/[documentId]/route.ts | 10 +- .../v1/knowledge/[id]/documents/route.test.ts | 2 + .../api/v1/knowledge/[id]/documents/route.ts | 10 +- apps/sim/app/api/v1/knowledge/[id]/route.ts | 14 +- apps/sim/app/api/v1/knowledge/route.ts | 24 +- .../app/api/v1/knowledge/search/route.test.ts | 2 + apps/sim/app/api/v1/knowledge/search/route.ts | 15 +- apps/sim/app/api/v1/logs/meta.ts | 5 +- apps/sim/app/api/v1/logs/route.ts | 12 +- apps/sim/app/api/v1/middleware.test.ts | 108 ++++- apps/sim/app/api/v1/middleware.ts | 46 +- .../api/v1/tables/[tableId]/columns/route.ts | 22 +- .../v1/tables/[tableId]/rows/[rowId]/route.ts | 10 +- .../app/api/v1/tables/[tableId]/rows/route.ts | 30 +- .../v1/tables/[tableId]/rows/upsert/route.ts | 10 +- apps/sim/app/api/v1/tables/route.ts | 26 +- .../v1/workflows/[id]/deploy/route.test.ts | 2 + .../app/api/v1/workflows/[id]/deploy/route.ts | 10 +- .../v1/workflows/[id]/rollback/route.test.ts | 2 + .../api/v1/workflows/[id]/rollback/route.ts | 10 +- .../app/api/v1/workflows/import/route.test.ts | 2 + apps/sim/app/api/v1/workflows/import/route.ts | 11 +- apps/sim/app/api/v1/workflows/route.ts | 12 +- apps/sim/lib/api/contracts/primitives.ts | 56 +-- apps/sim/lib/api/contracts/tables.ts | 79 ++-- apps/sim/lib/api/contracts/v1/files.ts | 5 +- .../lib/api/contracts/v1/knowledge/index.ts | 17 +- apps/sim/lib/api/contracts/v1/logs.ts | 4 +- apps/sim/lib/api/contracts/v1/tables/index.ts | 5 +- apps/sim/lib/api/contracts/v1/workflows.ts | 2 +- apps/sim/lib/api/contracts/workflows.ts | 20 +- apps/sim/lib/api/server/rate-limit-context.ts | 49 ++ apps/sim/lib/core/utils/with-route-handler.ts | 28 +- 39 files changed, 941 insertions(+), 199 deletions(-) create mode 100644 apps/sim/lib/api/server/rate-limit-context.ts diff --git a/apps/docs/openapi.json b/apps/docs/openapi.json index 79c22942f3..1844cc7a1e 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 336f50371c..9fa39f447e 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 227cb7f8e6..c6eca39ffe 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 9a85c408bb..34cbd6a1e7 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 ee5c13c350..89c63c37d3 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 350a2080c7..c101b8cf59 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 ae67288f2a..94c4832f26 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 ab80d99c89..18898d704a 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 0b1c096ad9..dfd08d4c89 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 b47241f019..8dbb280559 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 04e9d5f580..5b60848402 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 9c423d1b2d..978fbfdf75 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 36dc755821..3ac848b303 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 1db305e1e2..47d374c7a8 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 bd6a2185dd..be40f9ae2d 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 6b0efaabf3..c49850c6a4 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 eebebc0497..25357c19d1 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 46723538c7..f1751ee212 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 2a7ea2fe7a..5fee4f3d03 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 d0e37376cc..cc56cc6118 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 1df6b4b238..bf4a00df91 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 441f4fc141..82bc661824 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 42e977bdfe..7ec6df5963 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 822e00ab2c..7068239e13 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 8ee6ec2940..2327f71325 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 63ca166cdf..a0779babf5 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 f35a471f80..ab3492b0fc 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 037377ed06..dc602b987d 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 c0e89a86b5..3b24eefc55 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 79e6243aab..cf7fffe80f 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 c1107e3591..b128660d85 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 65a881c4a8..c196e0953c 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 829acb2de5..0134da1147 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 52e48b096d..87279d8d72 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 9546e2a2c0..4491b8840b 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 aa5a78c901..10ef1c3520 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 23fc05c736..60db7b61c7 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 0000000000..3d9d9fd04a --- /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 b61cd0d3b3..2c4bc973ce 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 }) }