fix(api): give every v1 endpoint quota headers and errors that name the field (#6012)

* 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.
This commit is contained in:
Waleed
2026-07-28 12:56:38 -07:00
committed by GitHub
parent 02311cafb9
commit b69fdbd17b
39 changed files with 941 additions and 199 deletions
+439 -1
View File
@@ -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.",
@@ -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', () => ({
+7 -10
View File
@@ -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
@@ -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,
+7 -2
View File
@@ -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
+9 -1
View File
@@ -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
@@ -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
@@ -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', () => ({
@@ -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
+10 -4
View File
@@ -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
+21 -3
View File
@@ -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
@@ -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', () => ({
+13 -2
View File
@@ -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
+2 -3
View File
@@ -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<T>(
limits,
},
headers: {
'X-RateLimit-Limit': apiRateLimit.limit.toString(),
'X-RateLimit-Remaining': apiRateLimit.remaining.toString(),
'X-RateLimit-Reset': apiRateLimit.resetAt.toISOString(),
...buildRateLimitHeaders(apiRateLimit),
},
}
}
+3 -9
View File
@@ -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
+107 -1
View File
@@ -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()
})
})
+39 -7
View File
@@ -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
}
@@ -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) {
@@ -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
@@ -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) {
@@ -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
+21 -5
View File
@@ -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) {
@@ -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', () => ({
@@ -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)
@@ -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', () => ({
@@ -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)
@@ -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', () => ({
@@ -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
+3 -9
View File
@@ -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
+25 -31
View File
@@ -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')
+40 -39
View File
@@ -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<TableMetadata>
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<TableRow>()
* {@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<Filter>().optional(),
sort: domainObjectSchema<Sort>().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<Filter>().optional(),
sort: domainObjectSchema<Sort>().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<typeof tableJobSummarySchema>
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<Filter>().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(),
+3 -2
View File
@@ -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<typeof v1FileParamsSchema>
@@ -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')
+2 -2
View File
@@ -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(),
@@ -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')
+1 -1
View File
@@ -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),
+12 -8
View File
@@ -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<typeof createWorkflowBodySchema>
export type CreateWorkflowResponse = z.output<typeof createWorkflowResponseSchema>
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<typeof duplicateWorkflowBodySchema>
export type DuplicateWorkflowResponse = z.output<typeof duplicateWorkflowResponseSchema>
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<typeof reorderWorkflowsBodySchema>
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<typeof workflowLogBodySchema>
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<typeof importWorkflowAsSuperuserBodySchema>
@@ -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<object, RateLimitSnapshot>()
/**
* 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<string, string> {
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<string, string> | null {
const snapshot = snapshots.get(request)
return snapshot ? buildRateLimitHeaders(snapshot) : null
}
+25 -3
View File
@@ -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<T>(handler: RouteHandler<T>): RouteHandler<T> {
return async (request: NextRequest, context: T) => {
@@ -74,7 +96,7 @@ export function withRouteHandler<T>(handler: RouteHandler<T>): RouteHandler<T> {
{ status: 500 }
)
}
response?.headers?.set('x-request-id', requestId)
applyResponseHeaders(response, request, requestId)
return response
}
@@ -89,7 +111,7 @@ export function withRouteHandler<T>(handler: RouteHandler<T>): RouteHandler<T> {
logger.info('OK', { status, duration })
}
response?.headers?.set('x-request-id', requestId)
applyResponseHeaders(response, request, requestId)
return response
})
}