diff --git a/.agents/skills/v2-api-conventions/SKILL.md b/.agents/skills/v2-api-conventions/SKILL.md index 3431b558056..55da39cd239 100644 --- a/.agents/skills/v2-api-conventions/SKILL.md +++ b/.agents/skills/v2-api-conventions/SKILL.md @@ -19,7 +19,7 @@ Nothing else at the top level. No `success: true`, no bare `{ "error": "string" That promise is worth stating as a rule because it has been broken five separate ways, each time by a route or a builder taking a shortcut that looked local: - `GET /workflows?limit=1.5` returned **500**. The contract was copied from a sibling and lost its `.int()`, so a fractional limit passed validation and reached Postgres as `LIMIT 2.5`. -- A malformed JSON body returned **`{"error":"Request body must be valid JSON"}`** — a bare string. The envelope was a per-route opt-in that only 8 of 77 routes remembered. +- A malformed JSON body returned **`{"error":"Request body must be valid JSON"}`** — a bare string. The envelope was a per-route opt-in that only 8 routes remembered. - `GET /api/v2/nonexistent` returned a **full HTML 404 document**, because no route file matched and the request fell through to the app's global not-found page. - Four collections returned `nextCursor` while **silently discarding** any `limit` the caller sent, because Zod strips unknown keys unless the schema is `.strict()`. - Handing back a `nextCursor` from any timestamp-sorted list and passing it straight in returned **500**. The value was validated and bound — but bound with no SQL type, into `date_trunc`, which is overloaded, so Postgres could resolve no overload. Validation was never the missing half; the type was. @@ -52,10 +52,10 @@ A route built with `defineV2JsonRoute` gets this for free: its `present` returns | 200 / 201 | — | Success. 201 only for a created resource. | | 400 | `BAD_REQUEST` | Contract validation. Carries field-level `details` from `serializeZodIssues`. | | 401 | `UNAUTHORIZED` | No/!valid API key. **Runs before the rollout gate.** | -| 403 | `FORBIDDEN` | Authenticated, same tenant, insufficient rights. Carries a machine-readable `details.code` from the closed set in `lib/core/application/forbidden.ts` (e.g. `INSUFFICIENT_WORKSPACE_ROLE`, `PERSONAL_API_KEYS_DISABLED`). | +| 403 | `FORBIDDEN` | Authenticated, same tenant, insufficient rights. Where the cause is one a caller can act on it is named in `details.code`, from the closed set in `lib/core/application/forbidden.ts` (e.g. `INSUFFICIENT_WORKSPACE_ROLE`, `PERSONAL_API_KEYS_DISABLED`). A few domain refusals still reach the wire without one. | | 404 | `NOT_FOUND` | Not found, **and** cross-tenant concealment, **and** the rollout gate, **and** an unknown path. | | 409 | `CONFLICT` | Uniqueness/state conflict, human-readable message. | -| 413 | `PAYLOAD_TOO_LARGE` | Body over the route's `maxBodyBytes`. | +| 413 | `PAYLOAD_TOO_LARGE` | Body over the route's `maxBodyBytes` — **and** a collection the response must materialize that is over *its* ceiling. Fourteen bodyless `GET`/`DELETE` operations publish it for the folder-tree cap (`FolderCollectionLimitExceededError`) or the rendered-artifact cap. | | 429 | `RATE_LIMITED` | With `Retry-After` and `X-RateLimit-*`. | | 500 | `INTERNAL_ERROR` | Genuine server fault only. Message is always generic. | @@ -74,7 +74,7 @@ And this class survives a green test suite — `keysetAfter` returned well-forme - An operation whose `minimumRole` is `write` or `admin` can always 403 — a member with a lower role hits it. Document 403. - An operation whose `minimumRole` is `read` cannot 403 *that* way, because `read` is the floor of the `read < write < admin` ordering and anyone without access is concealed as 404 instead. It can still 403 through `PersonalApiKeysDisabledError` (a personal API key against a workspace whose organization disabled them) or `WorkspaceApiKeyAuthorizationError` (`workspaceApiKey: 'deny'`), and every v2 operation is reachable by a personal API key. **So in practice every workspace-scoped v2 operation documents 403**, and the reads that omitted it were wrong, not principled. -**Every 403 names its cause in `error.details.code`.** One status covers four different remedies — raise a member's role, issue a personal key instead of a workspace-scoped one, re-point a workspace key, buy an enterprise plan — and prose is not branchable, so a client that must tell them apart was string-matching messages, which turns every reword into a silent break. +**A 403 a caller can act on names its cause in `error.details.code` — but not every 403 does yet.** One status covers several different remedies — raise a member's role, issue a personal key instead of a workspace-scoped one, re-point a workspace key, buy an enterprise plan, delete a resource to get under a quota — and prose is not branchable, so a client that must tell them apart was string-matching messages, which turns every reword into a silent break. A handful of domain refusals still throw a bare `OrchestrationError('forbidden', …)` and reach the wire with no code, so **write client code that treats `details.code` as optional**, and read `openapi/shared.ts`'s `FORBIDDEN_DESCRIPTION` for the current position rather than assuming the sweep is finished. For code you are *writing*, the rule below is unconditional. The vocabulary is a closed set, `FORBIDDEN_DETAIL_CODES` in `lib/core/application/forbidden.ts`, with a `Record` of descriptions beside it that the generated OpenAPI 403 description is built from. Adding a member fails to compile until it is documented, so a code cannot reach the wire unpublished. Do not invent a code at a route: throw `ForbiddenOperationError(code, message)` from the domain and let `v2CaughtOrchestrationError` — the function every v2 error policy falls through to — attach it. `InsufficientWorkspacePermissionsError`, `PersonalApiKeysDisabledError`, `WorkspaceApiKeyAuthorizationError`, and `PrincipalKindAuthorizationError` already carry theirs. @@ -84,6 +84,8 @@ Use the shared sets in `contracts/v2/openapi/shared.ts` — `RESOURCE_ERRORS`, ` **HEAD is answered by the `GET` handler, not rejected.** Next aliases a missing `HEAD` export onto `GET` and drops the body when sending, so a route's `GET` legitimately runs with `request.method === 'HEAD'`. The builders' method guard accepts that pairing via `methodMatchesContract`; any other mismatch stays a hard error. Never hand-write a `HEAD` export to "fix" this. +**A `GET` with side effects must declare `headSafe: false`.** The aliasing above is only sound because RFC 9110 §9.2.1 defines `HEAD` as safe. A `GET` that writes a row or opens an outbound connection is not, and an uptime monitor or link checker walking the documented URL list would drive those effects on every probe — `GET /files/{fileId}` records a `FILE_DOWNLOADED` audit event, so a `HEAD` used to fabricate a download that never happened. Both the JSON and binary builders take `headSafe`: a route that sets it `false` still authenticates and rate-limits a `HEAD`, then answers `v2HeadNoEffect()` — a bodiless 200 — before parsing or executing. Nothing observable is lost, because `HEAD` carries no body either way. Audit this whenever a read acquires an audit projection or an outbound call. + ## Rule 3 — a collection that returns `nextCursor` must accept `limit` + `cursor`, and must apply them Every list returns `{ data, nextCursor }`. Whether it *pages* is a separate, pinned decision — see `lib/api/contracts/v2/__tests__/list-pagination.test.ts`, which enumerates both sets and fails when a new list is in neither. @@ -96,10 +98,16 @@ Build the query slice from the shared helper, never by hand: That gives `limit` (integer, 1..`V2_MAX_PAGE_SIZE`, defaulting to `V2_DEFAULT_PAGE_SIZE` = 50) and an opaque `cursor`. Re-declaring `limit: z.coerce.number()...` inline is how the 500 happened; there is one schema so the family cannot drift again. -Two cursor schemes exist, both opaque base64-JSON from `response.ts`. Which one you use is decided by what the read can express, not by taste: +Three cursor schemes exist. Two are the shared codecs in `response.ts`, both opaque base64-JSON, and which of them you use is decided by what the read can express, not by taste: + +- **Keyset** (`readSortedCursor` in, `encodeSortedCursor` out) — the default. Requires the page to come from one ordered SQL read. The sort AND the filters are stamped into the cursor and re-checked on replay, so changing `sortBy` or any filter mid-pagination is a 400, not a silently skipped page. +- **Offset** (`decodeOffsetCursor` / `encodeOffsetCursor`) — only when a keyset is impossible. Two lists qualify: `GET /skills` merges a static in-code registry with DB rows and re-sorts in JS, and `GET /knowledge/{id}/documents` sits on a limit/offset query. A bare offset replayed against a re-sorted or re-filtered sequence names a different row, which skips or repeats results. + +Both take the same two stamps: `cursorSortKey(sortBy, sortOrder)` for the ordering, and `cursorFilterScope({ ... })` for every param that filters the sequence. **`limit` is never a stamp** — it selects how much of the sequence to return, not what the sequence is, and binding it strands every cursor the moment a caller changes page size. Params that only shape the response body are out for the same reason. -- **Keyset** (`readSortedCursor` in, `encodeSortedCursor` out) — the default. Requires the page to come from one ordered SQL read. The sort is stamped into the cursor and re-checked on replay, so changing `sortBy` mid-pagination is a 400, not a silently skipped page. -- **Offset** (`decodeOffsetCursor` / `encodeOffsetCursor`) — only when a keyset is impossible. Two lists qualify: `GET /skills` merges a static in-code registry with DB rows and re-sorts in JS, and `GET /knowledge/{id}/documents` sits on a limit/offset query. An offset cursor **must** be stamped with `offsetCursorScope(...)` covering every param that filters or orders the sequence (not `limit`, which only selects how much of it to return). A bare offset replayed against a re-sorted or re-filtered sequence names a different row, which skips or repeats results — the exact failure the keyset's sort stamp already prevents. +The third is **per-domain**: a list whose read predates the shared codecs, or whose page boundary is not expressible as one, mints its own — a bare `encodeCursor({ version })` on `GET /workflows/{id}/versions` and `encodeCursor({ email })` on the workspace member list, the local codecs in `lib/audit-logs/query.ts`, `lib/logs/list-logs.ts`, and `lib/table/rows/cursor.ts`, and a usage-event id passed straight through by `GET /billing/logs`. Those tokens stay opaque and untouched, but a domain-minted cursor on a list a caller can re-filter is wrapped at the surface with `encodeScopedCursor(cursorFilterScope({...}), token)` and unwrapped with `readScopedCursor`, so it carries the same binding as the shared schemes. **A new list picks one of the two shared schemes.** Do not add a fourth. + +Every paged list's binding is declared in `lib/api/contracts/v2/__tests__/list-pagination.test.ts` and checked against what the contract actually accepts, in both directions. A new list, or a new filter on an existing one, fails that test until its binding is declared or the param is explicitly recorded as unable to change the sequence. **A keyset's key list must end in a unique column (`id`).** A non-unique trailing key cannot separate tied rows, so the page boundary either repeats or drops them. `lib/api/list-keyset-paging.test.ts` demonstrates the failure. @@ -111,6 +119,10 @@ Return `nextCursor: null` on the last page and only then. Never construct a curs ## Rule 4 — reject what you do not implement +**Every contract declares a `query`, even when the endpoint takes none** — `query: noInputSchema` (`z.object({}).strict()`), never omission. `parseRequest` validates the query slice only when the contract declares one, so an omitted `query` means "never look at the query string", not "takes no query params". The two were indistinguishable, which is how 69 contracts ended up accepting anything without anyone deciding they should: `GET /workflows/{id}?bogus=1` answered 200 while every list answered 400 for the same shape. `query-declaration.test.ts` sweeps the tree so contract 70 fails at authoring time rather than shipping unvalidated. + +Declaring them is a **deliberate tightening** of endpoints that previously ignored an unknown param. It was weighed and kept: the v2 body slice on those same endpoints was already strict, so the split was arbitrary rather than a promise to callers; a mistyped param that is silently dropped is the bug class this rule exists to prevent; and no first-party caller sends an undeclared v2 query param (the two SDKs send only `includeOutput`/`selectedOutputs`, both declared; the UI makes no v2 calls at all; `requestJson` appends nothing implicitly and no v2 cache buster exists). Third-party callers appending a tracking tag or cache buster do break, which is why `api-reference/getting-started.mdx` documents the behavior rather than leaving it to be discovered from a 400. + Query and body schemas are **`.strict()`** — and `.strict()` binds the **top level only**. A strict body containing a non-strict nested object still drops unknown keys one level down, which is the headline `filter` bug at a smaller scale: `sort: [{ field, direction, nulls: 'last' }]` answered 200 and ordered by the default. Strictness belongs on the shared nested schema (`sortSpecSchema`'s element, `tableViewConfigSchema`), not restated per body. Before tightening a schema that is **also** a response or a stored blob, make the read canonical first. `table_views.config` is schemaless JSONB, so a legacy row carrying a retired key would fail a newly strict response parse and become a 500; `normalizeStoredViewConfig` projects the stored blob onto the declared keys so the tightening is safe in both directions. Zod strips unknown keys by default, so a non-strict schema answers `?limit=1` with 200 and the whole set — the caller believes it bounded the response and it did not. That is a contract lie, and on an uncapped list it is also an unbounded-response risk. @@ -132,7 +144,7 @@ That last one is the standard to aim for. A message that only says `Invalid inpu Order matters because each layer is checked against the one before it. 1. **Contract** in `lib/api/contracts/v2/.ts` via `defineRouteContract`. Response schemas are `.parse`d on the way out, so a field the producer does not actually emit becomes a 500 on a successful read — assert only what you can prove. -2. **Application use case** owns canonical loading, authorization, business behavior, and audit. The route's `present` receives **only the use-case result**, so anything the presenter needs (e.g. the active `sortBy`/`sortOrder` to stamp a cursor) must be returned by the use case. +2. **Application use case** owns canonical loading, authorization, business behavior, and audit. The route's `present` receives the use-case result **and the parsed request**, so a presenter reads request params (the active `sortBy`/`sortOrder` and filters it stamps into a cursor) straight from `query`/`params` rather than making the use case carry an HTTP concern back out. 3. **Route** with `defineV2JsonRoute`, declaring `contract`, `auth: v2ApiKeyAuth`, `operation`, `rateLimit`, `errorPolicy`, `mapInput`, `useCase`, `present`. Auth and rate limiting run before parsing. 4. **OpenAPI description** in `lib/api/contracts/v2/openapi/.ts`, then `bun run generate:openapi`. A description that claims behaviour the route does not have is the same class of bug as a wrong schema. @@ -153,7 +165,7 @@ Do not add a default for any other code. 400/403/404/409 are not fixed by waitin **A failure whose outcome is unknown must not advise a retry.** `ASYNC_ENQUEUE_AMBIGUOUS` is a 503 whose enqueue may have succeeded — it deliberately retains its execution-ID claim. Telling that caller to come back in 5 seconds invites a client with no `X-Run-Id` to start and bill a second run. It passes `omitRetryAfter: true` and returns the run id so the caller reconciles instead. Any future "we don't know if it happened" failure does the same. -RFC 9110 §10.2.3 gives 503 this field's clearest meaning — "how long the service is expected to be unavailable to the client". Note the requirement level is only `MAY`, on 503 (§15.6.4) and, via RFC 6585 §4, on 429. It is `SHOULD` on exactly one status, 413, and only when the condition is temporary; Sim's 413 is a fixed byte ceiling, so it correctly sends none. +RFC 9110 §10.2.3 gives 503 this field's clearest meaning — "how long the service is expected to be unavailable to the client". Note the requirement level is only `MAY`, on 503 (§15.6.4) and, via RFC 6585 §4, on 429. It is `SHOULD` on exactly one status, 413, and only when the condition is temporary; none of Sim's 413s are temporary — they are fixed ceilings, on the request body and on the collections a response must materialize — so it correctly sends none. ## Deliberate non-adoptions @@ -186,7 +198,7 @@ That makes the money path safe against double-execution **for callers that opt i The base64-JSON cursor is **not signed**, and does not need to be. Tampering is bounded by construction, and that is a property to preserve: - Every key value is re-validated by its `KeysetKey.bind`, which returns `null` for a wrong-typed or unparseable value and becomes a 400. A forged cursor cannot reach SQL as `NaN` or an `Invalid Date`. -- The sort is stamped into the cursor and re-checked (`decodeSortedCursor`), so a cursor from a differently-sorted query is a 400, not a silently skipped page. +- The sort and a fingerprint of the filters are stamped into the cursor and re-checked (`decodeSortedCursor`), so a cursor from a differently-sorted or differently-filtered query is a 400, not a silently skipped page. The filters are hashed (SHA-256, via `lib/api/cursor-binding.ts`) rather than embedded, so the token stays short and a caller cannot cheaply construct a filter that collides with another sequence's stamp. - The offset codec rejects anything that is not a non-negative integer. - Authorization is **never** carried in the cursor. Every list re-derives its workspace scope from the authenticated principal, so a cursor lifted from another query — or another tenant — can only move the caller within their own authorized result set. @@ -199,9 +211,11 @@ Run this against any new or changed v2 endpoint. - [ ] Success body is exactly `{data}` or `{data, nextCursor}`; failures are exactly `{error:{code,message,details?}}`. - [ ] Route uses a shared builder; no hand-built `NextResponse.json`. - [ ] Query and body schemas are `.strict()`. +- [ ] The contract declares a `query` — `noInputSchema` when the endpoint takes no query params, never omission. - [ ] No caller-supplied value can produce a 500 — check every numeric param reaches SQL as a validated integer, and that any bound value passed as an argument to a SQL function carries an explicit type. - [ ] `limit` comes from `v2PaginationFields`, not a hand-written `z.coerce.number()`. - [ ] If the response carries `nextCursor`, the query accepts `limit` + `cursor` and the query actually applies them. +- [ ] The cursor is bound to every param that filters or orders the sequence, and to none that do not (never `limit`), with the binding declared in `list-pagination.test.ts`. - [ ] Keyset sorts end in a unique `id` key. - [ ] The list is classified in `list-pagination.test.ts`. - [ ] Cross-tenant access answers 404, never 403 — and carries `Cache-Control: private, no-store`, because RFC 9110 §15.5.5 makes 404 heuristically cacheable and an authorization-dependent 404 must never be stored. `v2Error` sets this unconditionally; do not build a v2 response any other way. diff --git a/.claude/commands/v2-api-conventions.md b/.claude/commands/v2-api-conventions.md index 89095067c66..c4d86a251dc 100644 --- a/.claude/commands/v2-api-conventions.md +++ b/.claude/commands/v2-api-conventions.md @@ -18,7 +18,7 @@ Nothing else at the top level. No `success: true`, no bare `{ "error": "string" That promise is worth stating as a rule because it has been broken five separate ways, each time by a route or a builder taking a shortcut that looked local: - `GET /workflows?limit=1.5` returned **500**. The contract was copied from a sibling and lost its `.int()`, so a fractional limit passed validation and reached Postgres as `LIMIT 2.5`. -- A malformed JSON body returned **`{"error":"Request body must be valid JSON"}`** — a bare string. The envelope was a per-route opt-in that only 8 of 77 routes remembered. +- A malformed JSON body returned **`{"error":"Request body must be valid JSON"}`** — a bare string. The envelope was a per-route opt-in that only 8 routes remembered. - `GET /api/v2/nonexistent` returned a **full HTML 404 document**, because no route file matched and the request fell through to the app's global not-found page. - Four collections returned `nextCursor` while **silently discarding** any `limit` the caller sent, because Zod strips unknown keys unless the schema is `.strict()`. - Handing back a `nextCursor` from any timestamp-sorted list and passing it straight in returned **500**. The value was validated and bound — but bound with no SQL type, into `date_trunc`, which is overloaded, so Postgres could resolve no overload. Validation was never the missing half; the type was. @@ -51,10 +51,10 @@ A route built with `defineV2JsonRoute` gets this for free: its `present` returns | 200 / 201 | — | Success. 201 only for a created resource. | | 400 | `BAD_REQUEST` | Contract validation. Carries field-level `details` from `serializeZodIssues`. | | 401 | `UNAUTHORIZED` | No/!valid API key. **Runs before the rollout gate.** | -| 403 | `FORBIDDEN` | Authenticated, same tenant, insufficient rights. Carries a machine-readable `details.code` from the closed set in `lib/core/application/forbidden.ts` (e.g. `INSUFFICIENT_WORKSPACE_ROLE`, `PERSONAL_API_KEYS_DISABLED`). | +| 403 | `FORBIDDEN` | Authenticated, same tenant, insufficient rights. Where the cause is one a caller can act on it is named in `details.code`, from the closed set in `lib/core/application/forbidden.ts` (e.g. `INSUFFICIENT_WORKSPACE_ROLE`, `PERSONAL_API_KEYS_DISABLED`). A few domain refusals still reach the wire without one. | | 404 | `NOT_FOUND` | Not found, **and** cross-tenant concealment, **and** the rollout gate, **and** an unknown path. | | 409 | `CONFLICT` | Uniqueness/state conflict, human-readable message. | -| 413 | `PAYLOAD_TOO_LARGE` | Body over the route's `maxBodyBytes`. | +| 413 | `PAYLOAD_TOO_LARGE` | Body over the route's `maxBodyBytes` — **and** a collection the response must materialize that is over *its* ceiling. Fourteen bodyless `GET`/`DELETE` operations publish it for the folder-tree cap (`FolderCollectionLimitExceededError`) or the rendered-artifact cap. | | 429 | `RATE_LIMITED` | With `Retry-After` and `X-RateLimit-*`. | | 500 | `INTERNAL_ERROR` | Genuine server fault only. Message is always generic. | @@ -73,7 +73,7 @@ And this class survives a green test suite — `keysetAfter` returned well-forme - An operation whose `minimumRole` is `write` or `admin` can always 403 — a member with a lower role hits it. Document 403. - An operation whose `minimumRole` is `read` cannot 403 *that* way, because `read` is the floor of the `read < write < admin` ordering and anyone without access is concealed as 404 instead. It can still 403 through `PersonalApiKeysDisabledError` (a personal API key against a workspace whose organization disabled them) or `WorkspaceApiKeyAuthorizationError` (`workspaceApiKey: 'deny'`), and every v2 operation is reachable by a personal API key. **So in practice every workspace-scoped v2 operation documents 403**, and the reads that omitted it were wrong, not principled. -**Every 403 names its cause in `error.details.code`.** One status covers four different remedies — raise a member's role, issue a personal key instead of a workspace-scoped one, re-point a workspace key, buy an enterprise plan — and prose is not branchable, so a client that must tell them apart was string-matching messages, which turns every reword into a silent break. +**A 403 a caller can act on names its cause in `error.details.code` — but not every 403 does yet.** One status covers several different remedies — raise a member's role, issue a personal key instead of a workspace-scoped one, re-point a workspace key, buy an enterprise plan, delete a resource to get under a quota — and prose is not branchable, so a client that must tell them apart was string-matching messages, which turns every reword into a silent break. A handful of domain refusals still throw a bare `OrchestrationError('forbidden', …)` and reach the wire with no code, so **write client code that treats `details.code` as optional**, and read `openapi/shared.ts`'s `FORBIDDEN_DESCRIPTION` for the current position rather than assuming the sweep is finished. For code you are *writing*, the rule below is unconditional. The vocabulary is a closed set, `FORBIDDEN_DETAIL_CODES` in `lib/core/application/forbidden.ts`, with a `Record` of descriptions beside it that the generated OpenAPI 403 description is built from. Adding a member fails to compile until it is documented, so a code cannot reach the wire unpublished. Do not invent a code at a route: throw `ForbiddenOperationError(code, message)` from the domain and let `v2CaughtOrchestrationError` — the function every v2 error policy falls through to — attach it. `InsufficientWorkspacePermissionsError`, `PersonalApiKeysDisabledError`, `WorkspaceApiKeyAuthorizationError`, and `PrincipalKindAuthorizationError` already carry theirs. @@ -83,6 +83,8 @@ Use the shared sets in `contracts/v2/openapi/shared.ts` — `RESOURCE_ERRORS`, ` **HEAD is answered by the `GET` handler, not rejected.** Next aliases a missing `HEAD` export onto `GET` and drops the body when sending, so a route's `GET` legitimately runs with `request.method === 'HEAD'`. The builders' method guard accepts that pairing via `methodMatchesContract`; any other mismatch stays a hard error. Never hand-write a `HEAD` export to "fix" this. +**A `GET` with side effects must declare `headSafe: false`.** The aliasing above is only sound because RFC 9110 §9.2.1 defines `HEAD` as safe. A `GET` that writes a row or opens an outbound connection is not, and an uptime monitor or link checker walking the documented URL list would drive those effects on every probe — `GET /files/{fileId}` records a `FILE_DOWNLOADED` audit event, so a `HEAD` used to fabricate a download that never happened. Both the JSON and binary builders take `headSafe`: a route that sets it `false` still authenticates and rate-limits a `HEAD`, then answers `v2HeadNoEffect()` — a bodiless 200 — before parsing or executing. Nothing observable is lost, because `HEAD` carries no body either way. Audit this whenever a read acquires an audit projection or an outbound call. + ## Rule 3 — a collection that returns `nextCursor` must accept `limit` + `cursor`, and must apply them Every list returns `{ data, nextCursor }`. Whether it *pages* is a separate, pinned decision — see `lib/api/contracts/v2/__tests__/list-pagination.test.ts`, which enumerates both sets and fails when a new list is in neither. @@ -95,10 +97,16 @@ Build the query slice from the shared helper, never by hand: That gives `limit` (integer, 1..`V2_MAX_PAGE_SIZE`, defaulting to `V2_DEFAULT_PAGE_SIZE` = 50) and an opaque `cursor`. Re-declaring `limit: z.coerce.number()...` inline is how the 500 happened; there is one schema so the family cannot drift again. -Two cursor schemes exist, both opaque base64-JSON from `response.ts`. Which one you use is decided by what the read can express, not by taste: +Three cursor schemes exist. Two are the shared codecs in `response.ts`, both opaque base64-JSON, and which of them you use is decided by what the read can express, not by taste: + +- **Keyset** (`readSortedCursor` in, `encodeSortedCursor` out) — the default. Requires the page to come from one ordered SQL read. The sort AND the filters are stamped into the cursor and re-checked on replay, so changing `sortBy` or any filter mid-pagination is a 400, not a silently skipped page. +- **Offset** (`decodeOffsetCursor` / `encodeOffsetCursor`) — only when a keyset is impossible. Two lists qualify: `GET /skills` merges a static in-code registry with DB rows and re-sorts in JS, and `GET /knowledge/{id}/documents` sits on a limit/offset query. A bare offset replayed against a re-sorted or re-filtered sequence names a different row, which skips or repeats results. + +Both take the same two stamps: `cursorSortKey(sortBy, sortOrder)` for the ordering, and `cursorFilterScope({ ... })` for every param that filters the sequence. **`limit` is never a stamp** — it selects how much of the sequence to return, not what the sequence is, and binding it strands every cursor the moment a caller changes page size. Params that only shape the response body are out for the same reason. -- **Keyset** (`readSortedCursor` in, `encodeSortedCursor` out) — the default. Requires the page to come from one ordered SQL read. The sort is stamped into the cursor and re-checked on replay, so changing `sortBy` mid-pagination is a 400, not a silently skipped page. -- **Offset** (`decodeOffsetCursor` / `encodeOffsetCursor`) — only when a keyset is impossible. Two lists qualify: `GET /skills` merges a static in-code registry with DB rows and re-sorts in JS, and `GET /knowledge/{id}/documents` sits on a limit/offset query. An offset cursor **must** be stamped with `offsetCursorScope(...)` covering every param that filters or orders the sequence (not `limit`, which only selects how much of it to return). A bare offset replayed against a re-sorted or re-filtered sequence names a different row, which skips or repeats results — the exact failure the keyset's sort stamp already prevents. +The third is **per-domain**: a list whose read predates the shared codecs, or whose page boundary is not expressible as one, mints its own — a bare `encodeCursor({ version })` on `GET /workflows/{id}/versions` and `encodeCursor({ email })` on the workspace member list, the local codecs in `lib/audit-logs/query.ts`, `lib/logs/list-logs.ts`, and `lib/table/rows/cursor.ts`, and a usage-event id passed straight through by `GET /billing/logs`. Those tokens stay opaque and untouched, but a domain-minted cursor on a list a caller can re-filter is wrapped at the surface with `encodeScopedCursor(cursorFilterScope({...}), token)` and unwrapped with `readScopedCursor`, so it carries the same binding as the shared schemes. **A new list picks one of the two shared schemes.** Do not add a fourth. + +Every paged list's binding is declared in `lib/api/contracts/v2/__tests__/list-pagination.test.ts` and checked against what the contract actually accepts, in both directions. A new list, or a new filter on an existing one, fails that test until its binding is declared or the param is explicitly recorded as unable to change the sequence. **A keyset's key list must end in a unique column (`id`).** A non-unique trailing key cannot separate tied rows, so the page boundary either repeats or drops them. `lib/api/list-keyset-paging.test.ts` demonstrates the failure. @@ -110,6 +118,10 @@ Return `nextCursor: null` on the last page and only then. Never construct a curs ## Rule 4 — reject what you do not implement +**Every contract declares a `query`, even when the endpoint takes none** — `query: noInputSchema` (`z.object({}).strict()`), never omission. `parseRequest` validates the query slice only when the contract declares one, so an omitted `query` means "never look at the query string", not "takes no query params". The two were indistinguishable, which is how 69 contracts ended up accepting anything without anyone deciding they should: `GET /workflows/{id}?bogus=1` answered 200 while every list answered 400 for the same shape. `query-declaration.test.ts` sweeps the tree so contract 70 fails at authoring time rather than shipping unvalidated. + +Declaring them is a **deliberate tightening** of endpoints that previously ignored an unknown param. It was weighed and kept: the v2 body slice on those same endpoints was already strict, so the split was arbitrary rather than a promise to callers; a mistyped param that is silently dropped is the bug class this rule exists to prevent; and no first-party caller sends an undeclared v2 query param (the two SDKs send only `includeOutput`/`selectedOutputs`, both declared; the UI makes no v2 calls at all; `requestJson` appends nothing implicitly and no v2 cache buster exists). Third-party callers appending a tracking tag or cache buster do break, which is why `api-reference/getting-started.mdx` documents the behavior rather than leaving it to be discovered from a 400. + Query and body schemas are **`.strict()`** — and `.strict()` binds the **top level only**. A strict body containing a non-strict nested object still drops unknown keys one level down, which is the headline `filter` bug at a smaller scale: `sort: [{ field, direction, nulls: 'last' }]` answered 200 and ordered by the default. Strictness belongs on the shared nested schema (`sortSpecSchema`'s element, `tableViewConfigSchema`), not restated per body. Before tightening a schema that is **also** a response or a stored blob, make the read canonical first. `table_views.config` is schemaless JSONB, so a legacy row carrying a retired key would fail a newly strict response parse and become a 500; `normalizeStoredViewConfig` projects the stored blob onto the declared keys so the tightening is safe in both directions. Zod strips unknown keys by default, so a non-strict schema answers `?limit=1` with 200 and the whole set — the caller believes it bounded the response and it did not. That is a contract lie, and on an uncapped list it is also an unbounded-response risk. @@ -131,7 +143,7 @@ That last one is the standard to aim for. A message that only says `Invalid inpu Order matters because each layer is checked against the one before it. 1. **Contract** in `lib/api/contracts/v2/.ts` via `defineRouteContract`. Response schemas are `.parse`d on the way out, so a field the producer does not actually emit becomes a 500 on a successful read — assert only what you can prove. -2. **Application use case** owns canonical loading, authorization, business behavior, and audit. The route's `present` receives **only the use-case result**, so anything the presenter needs (e.g. the active `sortBy`/`sortOrder` to stamp a cursor) must be returned by the use case. +2. **Application use case** owns canonical loading, authorization, business behavior, and audit. The route's `present` receives the use-case result **and the parsed request**, so a presenter reads request params (the active `sortBy`/`sortOrder` and filters it stamps into a cursor) straight from `query`/`params` rather than making the use case carry an HTTP concern back out. 3. **Route** with `defineV2JsonRoute`, declaring `contract`, `auth: v2ApiKeyAuth`, `operation`, `rateLimit`, `errorPolicy`, `mapInput`, `useCase`, `present`. Auth and rate limiting run before parsing. 4. **OpenAPI description** in `lib/api/contracts/v2/openapi/.ts`, then `bun run generate:openapi`. A description that claims behaviour the route does not have is the same class of bug as a wrong schema. @@ -152,7 +164,7 @@ Do not add a default for any other code. 400/403/404/409 are not fixed by waitin **A failure whose outcome is unknown must not advise a retry.** `ASYNC_ENQUEUE_AMBIGUOUS` is a 503 whose enqueue may have succeeded — it deliberately retains its execution-ID claim. Telling that caller to come back in 5 seconds invites a client with no `X-Run-Id` to start and bill a second run. It passes `omitRetryAfter: true` and returns the run id so the caller reconciles instead. Any future "we don't know if it happened" failure does the same. -RFC 9110 §10.2.3 gives 503 this field's clearest meaning — "how long the service is expected to be unavailable to the client". Note the requirement level is only `MAY`, on 503 (§15.6.4) and, via RFC 6585 §4, on 429. It is `SHOULD` on exactly one status, 413, and only when the condition is temporary; Sim's 413 is a fixed byte ceiling, so it correctly sends none. +RFC 9110 §10.2.3 gives 503 this field's clearest meaning — "how long the service is expected to be unavailable to the client". Note the requirement level is only `MAY`, on 503 (§15.6.4) and, via RFC 6585 §4, on 429. It is `SHOULD` on exactly one status, 413, and only when the condition is temporary; none of Sim's 413s are temporary — they are fixed ceilings, on the request body and on the collections a response must materialize — so it correctly sends none. ## Deliberate non-adoptions @@ -185,7 +197,7 @@ That makes the money path safe against double-execution **for callers that opt i The base64-JSON cursor is **not signed**, and does not need to be. Tampering is bounded by construction, and that is a property to preserve: - Every key value is re-validated by its `KeysetKey.bind`, which returns `null` for a wrong-typed or unparseable value and becomes a 400. A forged cursor cannot reach SQL as `NaN` or an `Invalid Date`. -- The sort is stamped into the cursor and re-checked (`decodeSortedCursor`), so a cursor from a differently-sorted query is a 400, not a silently skipped page. +- The sort and a fingerprint of the filters are stamped into the cursor and re-checked (`decodeSortedCursor`), so a cursor from a differently-sorted or differently-filtered query is a 400, not a silently skipped page. The filters are hashed (SHA-256, via `lib/api/cursor-binding.ts`) rather than embedded, so the token stays short and a caller cannot cheaply construct a filter that collides with another sequence's stamp. - The offset codec rejects anything that is not a non-negative integer. - Authorization is **never** carried in the cursor. Every list re-derives its workspace scope from the authenticated principal, so a cursor lifted from another query — or another tenant — can only move the caller within their own authorized result set. @@ -198,9 +210,11 @@ Run this against any new or changed v2 endpoint. - [ ] Success body is exactly `{data}` or `{data, nextCursor}`; failures are exactly `{error:{code,message,details?}}`. - [ ] Route uses a shared builder; no hand-built `NextResponse.json`. - [ ] Query and body schemas are `.strict()`. +- [ ] The contract declares a `query` — `noInputSchema` when the endpoint takes no query params, never omission. - [ ] No caller-supplied value can produce a 500 — check every numeric param reaches SQL as a validated integer, and that any bound value passed as an argument to a SQL function carries an explicit type. - [ ] `limit` comes from `v2PaginationFields`, not a hand-written `z.coerce.number()`. - [ ] If the response carries `nextCursor`, the query accepts `limit` + `cursor` and the query actually applies them. +- [ ] The cursor is bound to every param that filters or orders the sequence, and to none that do not (never `limit`), with the binding declared in `list-pagination.test.ts`. - [ ] Keyset sorts end in a unique `id` key. - [ ] The list is classified in `list-pagination.test.ts`. - [ ] Cross-tenant access answers 404, never 403 — and carries `Cache-Control: private, no-store`, because RFC 9110 §15.5.5 makes 404 heuristically cacheable and an authorization-dependent 404 must never be stored. `v2Error` sets this unconditionally; do not build a v2 response any other way. diff --git a/.cursor/commands/v2-api-conventions.md b/.cursor/commands/v2-api-conventions.md index 7fa3e1a18ae..7456c295e20 100644 --- a/.cursor/commands/v2-api-conventions.md +++ b/.cursor/commands/v2-api-conventions.md @@ -13,7 +13,7 @@ Nothing else at the top level. No `success: true`, no bare `{ "error": "string" That promise is worth stating as a rule because it has been broken five separate ways, each time by a route or a builder taking a shortcut that looked local: - `GET /workflows?limit=1.5` returned **500**. The contract was copied from a sibling and lost its `.int()`, so a fractional limit passed validation and reached Postgres as `LIMIT 2.5`. -- A malformed JSON body returned **`{"error":"Request body must be valid JSON"}`** — a bare string. The envelope was a per-route opt-in that only 8 of 77 routes remembered. +- A malformed JSON body returned **`{"error":"Request body must be valid JSON"}`** — a bare string. The envelope was a per-route opt-in that only 8 routes remembered. - `GET /api/v2/nonexistent` returned a **full HTML 404 document**, because no route file matched and the request fell through to the app's global not-found page. - Four collections returned `nextCursor` while **silently discarding** any `limit` the caller sent, because Zod strips unknown keys unless the schema is `.strict()`. - Handing back a `nextCursor` from any timestamp-sorted list and passing it straight in returned **500**. The value was validated and bound — but bound with no SQL type, into `date_trunc`, which is overloaded, so Postgres could resolve no overload. Validation was never the missing half; the type was. @@ -46,10 +46,10 @@ A route built with `defineV2JsonRoute` gets this for free: its `present` returns | 200 / 201 | — | Success. 201 only for a created resource. | | 400 | `BAD_REQUEST` | Contract validation. Carries field-level `details` from `serializeZodIssues`. | | 401 | `UNAUTHORIZED` | No/!valid API key. **Runs before the rollout gate.** | -| 403 | `FORBIDDEN` | Authenticated, same tenant, insufficient rights. Carries a machine-readable `details.code` from the closed set in `lib/core/application/forbidden.ts` (e.g. `INSUFFICIENT_WORKSPACE_ROLE`, `PERSONAL_API_KEYS_DISABLED`). | +| 403 | `FORBIDDEN` | Authenticated, same tenant, insufficient rights. Where the cause is one a caller can act on it is named in `details.code`, from the closed set in `lib/core/application/forbidden.ts` (e.g. `INSUFFICIENT_WORKSPACE_ROLE`, `PERSONAL_API_KEYS_DISABLED`). A few domain refusals still reach the wire without one. | | 404 | `NOT_FOUND` | Not found, **and** cross-tenant concealment, **and** the rollout gate, **and** an unknown path. | | 409 | `CONFLICT` | Uniqueness/state conflict, human-readable message. | -| 413 | `PAYLOAD_TOO_LARGE` | Body over the route's `maxBodyBytes`. | +| 413 | `PAYLOAD_TOO_LARGE` | Body over the route's `maxBodyBytes` — **and** a collection the response must materialize that is over *its* ceiling. Fourteen bodyless `GET`/`DELETE` operations publish it for the folder-tree cap (`FolderCollectionLimitExceededError`) or the rendered-artifact cap. | | 429 | `RATE_LIMITED` | With `Retry-After` and `X-RateLimit-*`. | | 500 | `INTERNAL_ERROR` | Genuine server fault only. Message is always generic. | @@ -68,7 +68,7 @@ And this class survives a green test suite — `keysetAfter` returned well-forme - An operation whose `minimumRole` is `write` or `admin` can always 403 — a member with a lower role hits it. Document 403. - An operation whose `minimumRole` is `read` cannot 403 *that* way, because `read` is the floor of the `read < write < admin` ordering and anyone without access is concealed as 404 instead. It can still 403 through `PersonalApiKeysDisabledError` (a personal API key against a workspace whose organization disabled them) or `WorkspaceApiKeyAuthorizationError` (`workspaceApiKey: 'deny'`), and every v2 operation is reachable by a personal API key. **So in practice every workspace-scoped v2 operation documents 403**, and the reads that omitted it were wrong, not principled. -**Every 403 names its cause in `error.details.code`.** One status covers four different remedies — raise a member's role, issue a personal key instead of a workspace-scoped one, re-point a workspace key, buy an enterprise plan — and prose is not branchable, so a client that must tell them apart was string-matching messages, which turns every reword into a silent break. +**A 403 a caller can act on names its cause in `error.details.code` — but not every 403 does yet.** One status covers several different remedies — raise a member's role, issue a personal key instead of a workspace-scoped one, re-point a workspace key, buy an enterprise plan, delete a resource to get under a quota — and prose is not branchable, so a client that must tell them apart was string-matching messages, which turns every reword into a silent break. A handful of domain refusals still throw a bare `OrchestrationError('forbidden', …)` and reach the wire with no code, so **write client code that treats `details.code` as optional**, and read `openapi/shared.ts`'s `FORBIDDEN_DESCRIPTION` for the current position rather than assuming the sweep is finished. For code you are *writing*, the rule below is unconditional. The vocabulary is a closed set, `FORBIDDEN_DETAIL_CODES` in `lib/core/application/forbidden.ts`, with a `Record` of descriptions beside it that the generated OpenAPI 403 description is built from. Adding a member fails to compile until it is documented, so a code cannot reach the wire unpublished. Do not invent a code at a route: throw `ForbiddenOperationError(code, message)` from the domain and let `v2CaughtOrchestrationError` — the function every v2 error policy falls through to — attach it. `InsufficientWorkspacePermissionsError`, `PersonalApiKeysDisabledError`, `WorkspaceApiKeyAuthorizationError`, and `PrincipalKindAuthorizationError` already carry theirs. @@ -78,6 +78,8 @@ Use the shared sets in `contracts/v2/openapi/shared.ts` — `RESOURCE_ERRORS`, ` **HEAD is answered by the `GET` handler, not rejected.** Next aliases a missing `HEAD` export onto `GET` and drops the body when sending, so a route's `GET` legitimately runs with `request.method === 'HEAD'`. The builders' method guard accepts that pairing via `methodMatchesContract`; any other mismatch stays a hard error. Never hand-write a `HEAD` export to "fix" this. +**A `GET` with side effects must declare `headSafe: false`.** The aliasing above is only sound because RFC 9110 §9.2.1 defines `HEAD` as safe. A `GET` that writes a row or opens an outbound connection is not, and an uptime monitor or link checker walking the documented URL list would drive those effects on every probe — `GET /files/{fileId}` records a `FILE_DOWNLOADED` audit event, so a `HEAD` used to fabricate a download that never happened. Both the JSON and binary builders take `headSafe`: a route that sets it `false` still authenticates and rate-limits a `HEAD`, then answers `v2HeadNoEffect()` — a bodiless 200 — before parsing or executing. Nothing observable is lost, because `HEAD` carries no body either way. Audit this whenever a read acquires an audit projection or an outbound call. + ## Rule 3 — a collection that returns `nextCursor` must accept `limit` + `cursor`, and must apply them Every list returns `{ data, nextCursor }`. Whether it *pages* is a separate, pinned decision — see `lib/api/contracts/v2/__tests__/list-pagination.test.ts`, which enumerates both sets and fails when a new list is in neither. @@ -90,10 +92,16 @@ Build the query slice from the shared helper, never by hand: That gives `limit` (integer, 1..`V2_MAX_PAGE_SIZE`, defaulting to `V2_DEFAULT_PAGE_SIZE` = 50) and an opaque `cursor`. Re-declaring `limit: z.coerce.number()...` inline is how the 500 happened; there is one schema so the family cannot drift again. -Two cursor schemes exist, both opaque base64-JSON from `response.ts`. Which one you use is decided by what the read can express, not by taste: +Three cursor schemes exist. Two are the shared codecs in `response.ts`, both opaque base64-JSON, and which of them you use is decided by what the read can express, not by taste: + +- **Keyset** (`readSortedCursor` in, `encodeSortedCursor` out) — the default. Requires the page to come from one ordered SQL read. The sort AND the filters are stamped into the cursor and re-checked on replay, so changing `sortBy` or any filter mid-pagination is a 400, not a silently skipped page. +- **Offset** (`decodeOffsetCursor` / `encodeOffsetCursor`) — only when a keyset is impossible. Two lists qualify: `GET /skills` merges a static in-code registry with DB rows and re-sorts in JS, and `GET /knowledge/{id}/documents` sits on a limit/offset query. A bare offset replayed against a re-sorted or re-filtered sequence names a different row, which skips or repeats results. + +Both take the same two stamps: `cursorSortKey(sortBy, sortOrder)` for the ordering, and `cursorFilterScope({ ... })` for every param that filters the sequence. **`limit` is never a stamp** — it selects how much of the sequence to return, not what the sequence is, and binding it strands every cursor the moment a caller changes page size. Params that only shape the response body are out for the same reason. -- **Keyset** (`readSortedCursor` in, `encodeSortedCursor` out) — the default. Requires the page to come from one ordered SQL read. The sort is stamped into the cursor and re-checked on replay, so changing `sortBy` mid-pagination is a 400, not a silently skipped page. -- **Offset** (`decodeOffsetCursor` / `encodeOffsetCursor`) — only when a keyset is impossible. Two lists qualify: `GET /skills` merges a static in-code registry with DB rows and re-sorts in JS, and `GET /knowledge/{id}/documents` sits on a limit/offset query. An offset cursor **must** be stamped with `offsetCursorScope(...)` covering every param that filters or orders the sequence (not `limit`, which only selects how much of it to return). A bare offset replayed against a re-sorted or re-filtered sequence names a different row, which skips or repeats results — the exact failure the keyset's sort stamp already prevents. +The third is **per-domain**: a list whose read predates the shared codecs, or whose page boundary is not expressible as one, mints its own — a bare `encodeCursor({ version })` on `GET /workflows/{id}/versions` and `encodeCursor({ email })` on the workspace member list, the local codecs in `lib/audit-logs/query.ts`, `lib/logs/list-logs.ts`, and `lib/table/rows/cursor.ts`, and a usage-event id passed straight through by `GET /billing/logs`. Those tokens stay opaque and untouched, but a domain-minted cursor on a list a caller can re-filter is wrapped at the surface with `encodeScopedCursor(cursorFilterScope({...}), token)` and unwrapped with `readScopedCursor`, so it carries the same binding as the shared schemes. **A new list picks one of the two shared schemes.** Do not add a fourth. + +Every paged list's binding is declared in `lib/api/contracts/v2/__tests__/list-pagination.test.ts` and checked against what the contract actually accepts, in both directions. A new list, or a new filter on an existing one, fails that test until its binding is declared or the param is explicitly recorded as unable to change the sequence. **A keyset's key list must end in a unique column (`id`).** A non-unique trailing key cannot separate tied rows, so the page boundary either repeats or drops them. `lib/api/list-keyset-paging.test.ts` demonstrates the failure. @@ -105,6 +113,10 @@ Return `nextCursor: null` on the last page and only then. Never construct a curs ## Rule 4 — reject what you do not implement +**Every contract declares a `query`, even when the endpoint takes none** — `query: noInputSchema` (`z.object({}).strict()`), never omission. `parseRequest` validates the query slice only when the contract declares one, so an omitted `query` means "never look at the query string", not "takes no query params". The two were indistinguishable, which is how 69 contracts ended up accepting anything without anyone deciding they should: `GET /workflows/{id}?bogus=1` answered 200 while every list answered 400 for the same shape. `query-declaration.test.ts` sweeps the tree so contract 70 fails at authoring time rather than shipping unvalidated. + +Declaring them is a **deliberate tightening** of endpoints that previously ignored an unknown param. It was weighed and kept: the v2 body slice on those same endpoints was already strict, so the split was arbitrary rather than a promise to callers; a mistyped param that is silently dropped is the bug class this rule exists to prevent; and no first-party caller sends an undeclared v2 query param (the two SDKs send only `includeOutput`/`selectedOutputs`, both declared; the UI makes no v2 calls at all; `requestJson` appends nothing implicitly and no v2 cache buster exists). Third-party callers appending a tracking tag or cache buster do break, which is why `api-reference/getting-started.mdx` documents the behavior rather than leaving it to be discovered from a 400. + Query and body schemas are **`.strict()`** — and `.strict()` binds the **top level only**. A strict body containing a non-strict nested object still drops unknown keys one level down, which is the headline `filter` bug at a smaller scale: `sort: [{ field, direction, nulls: 'last' }]` answered 200 and ordered by the default. Strictness belongs on the shared nested schema (`sortSpecSchema`'s element, `tableViewConfigSchema`), not restated per body. Before tightening a schema that is **also** a response or a stored blob, make the read canonical first. `table_views.config` is schemaless JSONB, so a legacy row carrying a retired key would fail a newly strict response parse and become a 500; `normalizeStoredViewConfig` projects the stored blob onto the declared keys so the tightening is safe in both directions. Zod strips unknown keys by default, so a non-strict schema answers `?limit=1` with 200 and the whole set — the caller believes it bounded the response and it did not. That is a contract lie, and on an uncapped list it is also an unbounded-response risk. @@ -126,7 +138,7 @@ That last one is the standard to aim for. A message that only says `Invalid inpu Order matters because each layer is checked against the one before it. 1. **Contract** in `lib/api/contracts/v2/.ts` via `defineRouteContract`. Response schemas are `.parse`d on the way out, so a field the producer does not actually emit becomes a 500 on a successful read — assert only what you can prove. -2. **Application use case** owns canonical loading, authorization, business behavior, and audit. The route's `present` receives **only the use-case result**, so anything the presenter needs (e.g. the active `sortBy`/`sortOrder` to stamp a cursor) must be returned by the use case. +2. **Application use case** owns canonical loading, authorization, business behavior, and audit. The route's `present` receives the use-case result **and the parsed request**, so a presenter reads request params (the active `sortBy`/`sortOrder` and filters it stamps into a cursor) straight from `query`/`params` rather than making the use case carry an HTTP concern back out. 3. **Route** with `defineV2JsonRoute`, declaring `contract`, `auth: v2ApiKeyAuth`, `operation`, `rateLimit`, `errorPolicy`, `mapInput`, `useCase`, `present`. Auth and rate limiting run before parsing. 4. **OpenAPI description** in `lib/api/contracts/v2/openapi/.ts`, then `bun run generate:openapi`. A description that claims behaviour the route does not have is the same class of bug as a wrong schema. @@ -147,7 +159,7 @@ Do not add a default for any other code. 400/403/404/409 are not fixed by waitin **A failure whose outcome is unknown must not advise a retry.** `ASYNC_ENQUEUE_AMBIGUOUS` is a 503 whose enqueue may have succeeded — it deliberately retains its execution-ID claim. Telling that caller to come back in 5 seconds invites a client with no `X-Run-Id` to start and bill a second run. It passes `omitRetryAfter: true` and returns the run id so the caller reconciles instead. Any future "we don't know if it happened" failure does the same. -RFC 9110 §10.2.3 gives 503 this field's clearest meaning — "how long the service is expected to be unavailable to the client". Note the requirement level is only `MAY`, on 503 (§15.6.4) and, via RFC 6585 §4, on 429. It is `SHOULD` on exactly one status, 413, and only when the condition is temporary; Sim's 413 is a fixed byte ceiling, so it correctly sends none. +RFC 9110 §10.2.3 gives 503 this field's clearest meaning — "how long the service is expected to be unavailable to the client". Note the requirement level is only `MAY`, on 503 (§15.6.4) and, via RFC 6585 §4, on 429. It is `SHOULD` on exactly one status, 413, and only when the condition is temporary; none of Sim's 413s are temporary — they are fixed ceilings, on the request body and on the collections a response must materialize — so it correctly sends none. ## Deliberate non-adoptions @@ -180,7 +192,7 @@ That makes the money path safe against double-execution **for callers that opt i The base64-JSON cursor is **not signed**, and does not need to be. Tampering is bounded by construction, and that is a property to preserve: - Every key value is re-validated by its `KeysetKey.bind`, which returns `null` for a wrong-typed or unparseable value and becomes a 400. A forged cursor cannot reach SQL as `NaN` or an `Invalid Date`. -- The sort is stamped into the cursor and re-checked (`decodeSortedCursor`), so a cursor from a differently-sorted query is a 400, not a silently skipped page. +- The sort and a fingerprint of the filters are stamped into the cursor and re-checked (`decodeSortedCursor`), so a cursor from a differently-sorted or differently-filtered query is a 400, not a silently skipped page. The filters are hashed (SHA-256, via `lib/api/cursor-binding.ts`) rather than embedded, so the token stays short and a caller cannot cheaply construct a filter that collides with another sequence's stamp. - The offset codec rejects anything that is not a non-negative integer. - Authorization is **never** carried in the cursor. Every list re-derives its workspace scope from the authenticated principal, so a cursor lifted from another query — or another tenant — can only move the caller within their own authorized result set. @@ -193,9 +205,11 @@ Run this against any new or changed v2 endpoint. - [ ] Success body is exactly `{data}` or `{data, nextCursor}`; failures are exactly `{error:{code,message,details?}}`. - [ ] Route uses a shared builder; no hand-built `NextResponse.json`. - [ ] Query and body schemas are `.strict()`. +- [ ] The contract declares a `query` — `noInputSchema` when the endpoint takes no query params, never omission. - [ ] No caller-supplied value can produce a 500 — check every numeric param reaches SQL as a validated integer, and that any bound value passed as an argument to a SQL function carries an explicit type. - [ ] `limit` comes from `v2PaginationFields`, not a hand-written `z.coerce.number()`. - [ ] If the response carries `nextCursor`, the query accepts `limit` + `cursor` and the query actually applies them. +- [ ] The cursor is bound to every param that filters or orders the sequence, and to none that do not (never `limit`), with the binding declared in `list-pagination.test.ts`. - [ ] Keyset sorts end in a unique `id` key. - [ ] The list is classified in `list-pagination.test.ts`. - [ ] Cross-tenant access answers 404, never 403 — and carries `Cache-Control: private, no-store`, because RFC 9110 §15.5.5 makes 404 heuristically cacheable and an authorization-dependent 404 must never be stored. `v2Error` sets this unconditionally; do not build a v2 response any other way. diff --git a/.gitattributes b/.gitattributes index 8347b118c47..ffd7862b0be 100644 --- a/.gitattributes +++ b/.gitattributes @@ -21,6 +21,15 @@ Dockerfile* text eol=lf .gitignore text eol=lf .gitattributes text eol=lf +# Source files always diff as text. Git otherwise classifies a whole file as +# binary the moment it contains a NUL byte, hiding every line of it from review. +*.ts diff +*.tsx diff +*.js diff +*.jsx diff +*.json diff +*.md diff + # Denote all files that are truly binary and should not be modified *.png binary *.jpg binary diff --git a/apps/desktop/src/main/downloads.test.ts b/apps/desktop/src/main/downloads.test.ts index e8308633b60..92721bfa706 100644 Binary files a/apps/desktop/src/main/downloads.test.ts and b/apps/desktop/src/main/downloads.test.ts differ diff --git a/apps/docs/content/docs/de/api-reference/getting-started.mdx b/apps/docs/content/docs/de/api-reference/getting-started.mdx index 55c4503f38b..4f5ae08008f 100644 --- a/apps/docs/content/docs/de/api-reference/getting-started.mdx +++ b/apps/docs/content/docs/de/api-reference/getting-started.mdx @@ -171,6 +171,24 @@ The API uses standard HTTP status codes. v2 errors include a stable code and hum | `404` | Resource not found | Verify the ID exists and belongs to your workspace | | `429` | Rate limit exceeded | Wait for the duration in the `Retry-After` header | +### Unrecognized fields are rejected + +Every v2 endpoint validates the request against its published schema — path parameters, query string, and body — and answers `400` for any field it does not declare. A misspelled parameter is an error rather than a silent no-op, so `?limt=20` fails instead of quietly returning an unbounded list. + +This holds for endpoints that declare no query parameters at all. Do not append tracking tags, cache busters, or other extra parameters to a v2 URL; send only what the endpoint documents. + +```json +{ + "error": { + "code": "BAD_REQUEST", + "message": "Invalid request", + "details": [ + { "code": "unrecognized_keys", "keys": ["limt"], "path": [], "message": "Unrecognized key: \"limt\"" } + ] + } +} +``` + Use [Get Billing Status](/api-reference/billing/getBillingStatus) to inspect current credit and storage usage. diff --git a/apps/docs/content/docs/en/api-reference/getting-started.mdx b/apps/docs/content/docs/en/api-reference/getting-started.mdx index 8136cec9df3..73b72490885 100644 --- a/apps/docs/content/docs/en/api-reference/getting-started.mdx +++ b/apps/docs/content/docs/en/api-reference/getting-started.mdx @@ -171,6 +171,24 @@ The API uses standard HTTP status codes. v2 errors include a stable code and hum | `404` | Resource not found | Verify the ID exists and belongs to your workspace | | `429` | Rate limit exceeded | Wait for the duration in the `Retry-After` header | +### Unrecognized fields are rejected + +Every v2 endpoint validates the request against its published schema — path parameters, query string, and body — and answers `400` for any field it does not declare. A misspelled parameter is an error rather than a silent no-op, so `?limt=20` fails instead of quietly returning an unbounded list. + +This holds for endpoints that declare no query parameters at all. Do not append tracking tags, cache busters, or other extra parameters to a v2 URL; send only what the endpoint documents. + +```json +{ + "error": { + "code": "BAD_REQUEST", + "message": "Invalid request", + "details": [ + { "code": "unrecognized_keys", "keys": ["limt"], "path": [], "message": "Unrecognized key: \"limt\"" } + ] + } +} +``` + Use [Get Billing Status](/api-reference/billing/getBillingStatus) to inspect current credit and storage usage. diff --git a/apps/docs/content/docs/es/api-reference/getting-started.mdx b/apps/docs/content/docs/es/api-reference/getting-started.mdx index 8136cec9df3..73b72490885 100644 --- a/apps/docs/content/docs/es/api-reference/getting-started.mdx +++ b/apps/docs/content/docs/es/api-reference/getting-started.mdx @@ -171,6 +171,24 @@ The API uses standard HTTP status codes. v2 errors include a stable code and hum | `404` | Resource not found | Verify the ID exists and belongs to your workspace | | `429` | Rate limit exceeded | Wait for the duration in the `Retry-After` header | +### Unrecognized fields are rejected + +Every v2 endpoint validates the request against its published schema — path parameters, query string, and body — and answers `400` for any field it does not declare. A misspelled parameter is an error rather than a silent no-op, so `?limt=20` fails instead of quietly returning an unbounded list. + +This holds for endpoints that declare no query parameters at all. Do not append tracking tags, cache busters, or other extra parameters to a v2 URL; send only what the endpoint documents. + +```json +{ + "error": { + "code": "BAD_REQUEST", + "message": "Invalid request", + "details": [ + { "code": "unrecognized_keys", "keys": ["limt"], "path": [], "message": "Unrecognized key: \"limt\"" } + ] + } +} +``` + Use [Get Billing Status](/api-reference/billing/getBillingStatus) to inspect current credit and storage usage. diff --git a/apps/docs/content/docs/fr/api-reference/getting-started.mdx b/apps/docs/content/docs/fr/api-reference/getting-started.mdx index 8136cec9df3..73b72490885 100644 --- a/apps/docs/content/docs/fr/api-reference/getting-started.mdx +++ b/apps/docs/content/docs/fr/api-reference/getting-started.mdx @@ -171,6 +171,24 @@ The API uses standard HTTP status codes. v2 errors include a stable code and hum | `404` | Resource not found | Verify the ID exists and belongs to your workspace | | `429` | Rate limit exceeded | Wait for the duration in the `Retry-After` header | +### Unrecognized fields are rejected + +Every v2 endpoint validates the request against its published schema — path parameters, query string, and body — and answers `400` for any field it does not declare. A misspelled parameter is an error rather than a silent no-op, so `?limt=20` fails instead of quietly returning an unbounded list. + +This holds for endpoints that declare no query parameters at all. Do not append tracking tags, cache busters, or other extra parameters to a v2 URL; send only what the endpoint documents. + +```json +{ + "error": { + "code": "BAD_REQUEST", + "message": "Invalid request", + "details": [ + { "code": "unrecognized_keys", "keys": ["limt"], "path": [], "message": "Unrecognized key: \"limt\"" } + ] + } +} +``` + Use [Get Billing Status](/api-reference/billing/getBillingStatus) to inspect current credit and storage usage. diff --git a/apps/docs/content/docs/ja/api-reference/getting-started.mdx b/apps/docs/content/docs/ja/api-reference/getting-started.mdx index 8136cec9df3..73b72490885 100644 --- a/apps/docs/content/docs/ja/api-reference/getting-started.mdx +++ b/apps/docs/content/docs/ja/api-reference/getting-started.mdx @@ -171,6 +171,24 @@ The API uses standard HTTP status codes. v2 errors include a stable code and hum | `404` | Resource not found | Verify the ID exists and belongs to your workspace | | `429` | Rate limit exceeded | Wait for the duration in the `Retry-After` header | +### Unrecognized fields are rejected + +Every v2 endpoint validates the request against its published schema — path parameters, query string, and body — and answers `400` for any field it does not declare. A misspelled parameter is an error rather than a silent no-op, so `?limt=20` fails instead of quietly returning an unbounded list. + +This holds for endpoints that declare no query parameters at all. Do not append tracking tags, cache busters, or other extra parameters to a v2 URL; send only what the endpoint documents. + +```json +{ + "error": { + "code": "BAD_REQUEST", + "message": "Invalid request", + "details": [ + { "code": "unrecognized_keys", "keys": ["limt"], "path": [], "message": "Unrecognized key: \"limt\"" } + ] + } +} +``` + Use [Get Billing Status](/api-reference/billing/getBillingStatus) to inspect current credit and storage usage. diff --git a/apps/docs/content/docs/zh/api-reference/getting-started.mdx b/apps/docs/content/docs/zh/api-reference/getting-started.mdx index 8136cec9df3..73b72490885 100644 --- a/apps/docs/content/docs/zh/api-reference/getting-started.mdx +++ b/apps/docs/content/docs/zh/api-reference/getting-started.mdx @@ -171,6 +171,24 @@ The API uses standard HTTP status codes. v2 errors include a stable code and hum | `404` | Resource not found | Verify the ID exists and belongs to your workspace | | `429` | Rate limit exceeded | Wait for the duration in the `Retry-After` header | +### Unrecognized fields are rejected + +Every v2 endpoint validates the request against its published schema — path parameters, query string, and body — and answers `400` for any field it does not declare. A misspelled parameter is an error rather than a silent no-op, so `?limt=20` fails instead of quietly returning an unbounded list. + +This holds for endpoints that declare no query parameters at all. Do not append tracking tags, cache busters, or other extra parameters to a v2 URL; send only what the endpoint documents. + +```json +{ + "error": { + "code": "BAD_REQUEST", + "message": "Invalid request", + "details": [ + { "code": "unrecognized_keys", "keys": ["limt"], "path": [], "message": "Unrecognized key: \"limt\"" } + ] + } +} +``` + Use [Get Billing Status](/api-reference/billing/getBillingStatus) to inspect current credit and storage usage. diff --git a/apps/docs/openapi-v2-billing.json b/apps/docs/openapi-v2-billing.json index f611771391b..5b513c1c03d 100644 --- a/apps/docs/openapi-v2-billing.json +++ b/apps/docs/openapi-v2-billing.json @@ -36,7 +36,7 @@ "get": { "operationId": "getBillingStatus", "summary": "Get Billing Status", - "description": "Return the current plan, billing standing, credit allowance, and storage quota. `credits` and `storage` report the payer's pooled allowances and are null unless the caller can manage that payer's billing; they are always null for a workspace API key. Billing history lives at `GET /api/v2/billing/logs`. Without a Stripe subscription — notably on the free plan — there is no real billing period: `period` is the open interval 1970-01-01 to 9999-12-31 and `credits.used` is lifetime consumption, not consumption since a period start.", + "description": "Return the current plan, billing standing, credit allowance, and storage quota. `credits` and `storage` report the payer's pooled allowances and are null unless the caller can manage that payer's billing; they are always null for a workspace API key. Billing history lives at `GET /api/v2/billing/logs`.", "tags": ["Billing"], "parameters": [ { @@ -101,7 +101,7 @@ "get": { "operationId": "listBillingLogs", "summary": "List Billing Logs", - "description": "List the credit-denominated billing ledger with source filtering and opaque cursor pagination. `period` defaults to `30d`, so an unqualified request covers only the last 30 days: paginating to `nextCursor: null` exhausts that window, not the whole ledger. Pass `period=all` for full history, or `period=custom` with `startDate` and `endDate` for a specific range.", + "description": "List the credit-denominated billing ledger with source filtering and opaque cursor pagination. `period` defaults to `30d`, so an unqualified request covers only the last 30 days: paginating to `nextCursor: null` exhausts that window, not the whole ledger. An inverted custom window is a 400 rather than an empty page.", "tags": ["Billing"], "parameters": [ { @@ -140,10 +140,10 @@ "name": "period", "in": "query", "required": false, - "description": "Relative window, all history, or a custom date range.", + "description": "Relative window, all history, or a custom date range. `startDate` and `endDate` are accepted only with `custom`; every other value computes its own window.", "schema": { "default": "30d", - "description": "Relative window, all history, or a custom date range.", + "description": "Relative window, all history, or a custom date range. `startDate` and `endDate` are accepted only with `custom`; every other value computes its own window.", "type": "string", "enum": ["1d", "7d", "30d", "all", "custom"] } @@ -152,22 +152,24 @@ "name": "startDate", "in": "query", "required": false, - "description": "Start of a custom window as a Date-parseable string.", + "description": "Only include usage events recorded at or after this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. Requires `period=custom`. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected, as is year `0000`, which names no storable instant.", "schema": { - "description": "Start of a custom window as a Date-parseable string.", "type": "string", - "minLength": 1 + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "Only include usage events recorded at or after this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. Requires `period=custom`. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected, as is year `0000`, which names no storable instant." } }, { "name": "endDate", "in": "query", "required": false, - "description": "End of a custom window as a Date-parseable string; defaults to now.", + "description": "Only include usage events recorded at or before this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. Requires `period=custom`, and defaults to now when omitted. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected, as is year `0000`, which names no storable instant.", "schema": { - "description": "End of a custom window as a Date-parseable string; defaults to now.", "type": "string", - "minLength": 1 + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "Only include usage events recorded at or before this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. Requires `period=custom`, and defaults to now when omitted. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected, as is year `0000`, which names no storable instant." } }, { @@ -187,9 +189,9 @@ "name": "cursor", "in": "query", "required": false, - "description": "Opaque cursor returned by the previous page.", + "description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.", "schema": { - "description": "Opaque cursor returned by the previous page.", + "description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.", "type": "string", "minLength": 1 } @@ -248,7 +250,7 @@ "type": "apiKey", "in": "header", "name": "X-API-Key", - "description": "Your Sim API key, personal or workspace-scoped. Generate one under Settings > API Keys. Operations that reject workspace keys say so in their own description." + "description": "Your Sim API key, personal or workspace-scoped. Generate one under Settings, then API Keys. Operations that reject workspace keys say so in their own description." } }, "headers": { @@ -283,13 +285,13 @@ } }, "Retry-After": { - "description": "Seconds to wait before retrying. Sent on `429` (derived from the caller rate-limit window) and on `503` (a fixed transient-failure floor). Add jitter rather than retrying at exactly this offset.", + "description": "Seconds to wait before retrying, sent on `429` and `503`. Add jitter rather than retrying at exactly this offset.", "schema": { "type": "integer", "minimum": 0, "maximum": 9007199254740991, "title": "Retry after", - "description": "Seconds to wait before retrying. Sent on `429` (derived from the caller rate-limit window) and on `503` (a fixed transient-failure floor). Add jitter rather than retrying at exactly this offset." + "description": "Seconds to wait before retrying, sent on `429` and `503`. Add jitter rather than retrying at exactly this offset." } }, "X-Run-Id": { @@ -304,7 +306,7 @@ }, "responses": { "BadRequest": { - "description": "The request is invalid.", + "description": "The request is invalid. This includes a query parameter sent with no value (`?limit=`, `?search=`), which is rejected rather than read as zero, empty, or the parameter default — omit the parameter instead.", "content": { "application/json": { "schema": { @@ -334,7 +336,7 @@ } }, "Forbidden": { - "description": "The caller lacks the rights this operation requires. Where the cause is one a caller can act on, `error.details.code` names it, drawn from a closed set:\n- `INSUFFICIENT_WORKSPACE_ROLE` — The caller has access to the workspace but its role is below the one this operation requires.\n- `PERSONAL_API_KEYS_DISABLED` — The workspace's organization does not allow personal API keys. Use a workspace API key.\n- `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` — This operation is not available to a workspace-scoped API key. Use a personal API key.\n- `PRINCIPAL_KIND_NOT_PERMITTED` — This operation does not accept the caller’s kind of API key.\n- `ORGANIZATION_MEMBERSHIP_REQUIRED` — The caller is not a member of the organization it named.\n- `ORGANIZATION_ADMIN_REQUIRED` — The caller is a member of the organization but not an admin or owner.\n- `ENTERPRISE_PLAN_REQUIRED` — The organization has no active enterprise subscription.\n- `AUDIT_LOGS_DISABLED` — Audit logging is not enabled for this deployment.\n- `SKILL_EDITOR_ACCESS_REQUIRED` — The caller can write in the workspace but is not an editor of this skill.\n- `MCP_SERVER_URL_NOT_ALLOWED` — The supplied MCP server URL is outside the allowed domains or resolves to an internal address.\nA resource in a workspace the caller cannot reach at all answers `404`, not `403`, so absence and denial are indistinguishable to a caller who was never entitled to tell them apart.", + "description": "The caller lacks the rights this operation requires. When the cause is one a caller can act on, `error.details.code` names it. A resource in a workspace the caller cannot reach at all answers `404` instead, so absence and denial are indistinguishable.", "content": { "application/json": { "schema": { @@ -364,7 +366,7 @@ } }, "RunIdConflict": { - "description": "The run cannot be started. Two causes share this status, distinguished by `error.details.code`: `RUN_ID_CONFLICT` when the supplied `X-Run-Id` is already associated with a different request, and `CALL_CHAIN_DEPTH_EXCEEDED` when the incoming `X-Sim-Via` chain has already reached the maximum workflow-to-workflow call depth.", + "description": "The run cannot be started, for one of two causes named by `error.details.code`: `RUN_ID_CONFLICT` when the supplied `X-Run-Id` is already claimed, and `CALL_CHAIN_DEPTH_EXCEEDED` when the incoming `X-Sim-Via` chain has reached the maximum workflow-to-workflow call depth.", "headers": { "X-Run-Id": { "$ref": "#/components/headers/X-Run-Id" @@ -378,18 +380,8 @@ } } }, - "Gone": { - "description": "The requested generated resource has expired.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2Error" - } - } - } - }, "PayloadTooLarge": { - "description": "The request, or a resource collection it must materialize, exceeds the allowed size. Besides an oversized request body, this covers a generated artifact that renders past the download ceiling and a workspace folder tree too large to load in full.", + "description": "The request, or a resource collection it must materialize, exceeds the allowed size: an oversized request body, a generated artifact past the download ceiling, or a workspace folder tree too large to load in full.", "content": { "application/json": { "schema": { @@ -434,7 +426,7 @@ } }, "ClientClosedRequest": { - "description": "The client closed the connection before the response was produced.", + "description": "The client closed the connection before the response was produced. An abort can leave the run going, so `error.details.runId` carries the run id — reconcile against the runs resource rather than starting another run.", "content": { "application/json": { "schema": { @@ -454,7 +446,7 @@ } }, "ServiceUnavailable": { - "description": "A required service is temporarily unavailable. The condition is transient, so the response normally carries `Retry-After` with the number of seconds to wait; treat that value as a floor and add jitter before retrying. One case deliberately omits the header: when `error.details.code` is `ASYNC_ENQUEUE_AMBIGUOUS`, the run may already have started, so retrying could start and bill a second run. Reconcile against the returned run id instead of retrying.", + "description": "A required service is temporarily unavailable. `Retry-After` carries the seconds to wait; treat it as a floor and add jitter. The header is omitted when `error.details.code` is `ASYNC_ENQUEUE_AMBIGUOUS`, because the run may already have started — reconcile against the returned run id instead of retrying.", "headers": { "Retry-After": { "$ref": "#/components/headers/Retry-After" @@ -485,7 +477,7 @@ "description": "Human-readable explanation of the error." }, "details": { - "description": "Optional structured error details." + "description": "Structured error details. On a `403` whose cause a caller can act on, this carries a `code` from a closed set:\n- `INSUFFICIENT_WORKSPACE_ROLE` — The caller has access to the workspace but its role is below the one this operation requires.\n- `PERSONAL_API_KEYS_DISABLED` — The workspace's organization does not allow personal API keys. Use a workspace API key.\n- `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` — This operation is not available to a workspace-scoped API key. Use a personal API key.\n- `PRINCIPAL_KIND_NOT_PERMITTED` — This operation does not accept the caller’s kind of API key.\n- `ORGANIZATION_MEMBERSHIP_REQUIRED` — The caller is not a member of the organization it named.\n- `ORGANIZATION_ADMIN_REQUIRED` — The caller is a member of the organization but not an admin or owner.\n- `ENTERPRISE_PLAN_REQUIRED` — The organization has no active enterprise subscription.\n- `AUDIT_LOGS_DISABLED` — Audit logging is not enabled for this deployment.\n- `SKILL_EDITOR_ACCESS_REQUIRED` — The caller can write in the workspace but is not an editor of this skill.\n- `SECRET_ADMIN_ACCESS_REQUIRED` — The caller can write in the workspace but is not an admin of this secret. Ask a workspace admin, or someone holding admin on the secret, to grant access or set the value.\n- `WORKSPACE_RESOURCE_LIMIT_REACHED` — The workspace already holds the maximum number of resources of this kind. Delete one, or contact Sim to raise the limit; the message names the ceiling.\n- `PUBLIC_SHARING_NOT_ALLOWED` — The workspace's organization does not permit sharing this resource publicly. An organization admin controls the policy.\n- `MCP_SERVER_URL_NOT_ALLOWED` — The supplied MCP server URL is outside the allowed domains or resolves to an internal address." } }, "required": ["code", "message"], @@ -754,7 +746,7 @@ "type": "null" } ], - "description": "Opaque cursor for the next page: send it back as `cursor` to continue, and stop when it is null. Most v2 lists page, so null means the last page was reached. A few are full-set lists that return their whole bounded result in one response and therefore always report null; those say so in the operation description. Either way, null means there is nothing further to fetch — never construct a cursor yourself." + "description": "Opaque cursor for the next page. Send it back as `cursor`; `null` means there is nothing further to fetch. Never construct one yourself." } }, "required": ["data", "nextCursor"], diff --git a/apps/docs/openapi-v2-files-audit.json b/apps/docs/openapi-v2-files-audit.json index 7e981126eeb..3e605aa00e1 100644 --- a/apps/docs/openapi-v2-files-audit.json +++ b/apps/docs/openapi-v2-files-audit.json @@ -2,7 +2,7 @@ "openapi": "3.1.0", "info": { "title": "Sim API v2 — Files & Audit Logs", - "description": "Version 2 of the Sim REST API for workspace files and organization audit logs. Lists use opaque cursors, and rate-limit state is returned in response headers. Download File streams raw bytes as `application/octet-stream`; every other response uses the canonical v2 data, cursor-list, or error envelope.", + "description": "Version 2 of the Sim REST API for workspace files, resumable uploads, public shares, and organization audit logs.", "version": "2.0.0", "contact": { "name": "Sim Support", @@ -40,7 +40,7 @@ "get": { "operationId": "listFiles", "summary": "List Files", - "description": "List workspace files with search, sorting, folder filtering, and opaque cursor pagination. Defaults to active files; pass `scope=archived` to page over soft-deleted files, whose `deletedAt` is non-null and which `POST /files/{fileId}/restore` can bring back.", + "description": "List workspace files with search, sorting, folder filtering, and opaque cursor pagination. Defaults to active files; pass `scope=archived` to page over soft-deleted ones. A workspace folder tree over 10,000 folders is a `413`.", "tags": ["Files"], "parameters": [ { @@ -58,20 +58,20 @@ "name": "folderPath", "in": "query", "required": false, - "description": "Restrict results to files directly inside this folder.", + "description": "Restrict results to files directly inside this folder. A path that names no folder narrows the result to nothing, so the response is an empty page rather than an error.", "schema": { - "description": "Restrict results to files directly inside this folder.", - "type": "string" + "description": "Restrict results to files directly inside this folder. A path that names no folder narrows the result to nothing, so the response is an empty page rather than an error.", + "$ref": "#/components/schemas/FolderPathInput" } }, { "name": "scope", "in": "query", "required": false, - "description": "Which lifecycle set to list: `active` (default) for live files, `archived` for files a `DELETE` soft-deleted and that `POST /files/{fileId}/restore` can bring back. `folderPath` resolves against active folders only, so combining it with `scope=archived` returns 404 when the containing folder was archived too.", + "description": "Which lifecycle set to list: `active` (default) for live files, `archived` for files a `DELETE` soft-deleted. `folderPath` resolves against active folders only, so pairing it with `scope=archived` returns an empty page when the containing folder was archived too.", "schema": { "default": "active", - "description": "Which lifecycle set to list: `active` (default) for live files, `archived` for files a `DELETE` soft-deleted and that `POST /files/{fileId}/restore` can bring back. `folderPath` resolves against active folders only, so combining it with `scope=archived` returns 404 when the containing folder was archived too.", + "description": "Which lifecycle set to list: `active` (default) for live files, `archived` for files a `DELETE` soft-deleted. `folderPath` resolves against active folders only, so pairing it with `scope=archived` returns an empty page when the containing folder was archived too.", "type": "string", "enum": ["active", "archived"] } @@ -92,10 +92,10 @@ "name": "sortBy", "in": "query", "required": false, - "description": "Field used to sort the result.", + "description": "Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order.", "schema": { "default": "uploadedAt", - "description": "Field used to sort the result.", + "description": "Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order.", "type": "string", "enum": ["name", "size", "uploadedAt", "updatedAt"] } @@ -120,8 +120,6 @@ "schema": { "description": "Maximum files per page. Values outside 1–1000 are truncated and clamped into that range rather than rejected. Defaults to 100.", "type": "integer", - "minimum": 1, - "maximum": 1000, "default": 100 } }, @@ -129,9 +127,9 @@ "name": "cursor", "in": "query", "required": false, - "description": "Opaque cursor returned by the previous page.", + "description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.", "schema": { - "description": "Opaque cursor returned by the previous page.", + "description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.", "type": "string", "minLength": 1 } @@ -171,6 +169,9 @@ "404": { "$ref": "#/components/responses/NotFound" }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -301,6 +302,9 @@ "404": { "$ref": "#/components/responses/NotFound" }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -492,6 +496,9 @@ "409": { "$ref": "#/components/responses/Conflict" }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -598,7 +605,7 @@ "get": { "operationId": "downloadFile", "summary": "Download File", - "description": "Download the current file bytes from a workspace. A generated document is served as its compiled artifact, so it returns `409` while that artifact is still compiling and `413` if it renders past the size ceiling.", + "description": "Download the current file bytes from a workspace. A generated document is served as its compiled artifact, so it answers `409` while that artifact is still compiling and `413` if it renders past the size ceiling. Downloading records an audit event, so it is not a safe read. A `HEAD` skips the effect but is authorized exactly as the `GET` is, so it answers `400`, `401`, `403`, or `404` wherever the `GET` would and an empty `200` otherwise. Skipping the effect means skipping the read that produces the payload, so that `200` carries none of the response headers documented below — it answers whether the `GET` would be allowed, not what the `GET` would return. In particular a `HEAD` does not report `Content-Length`, so it cannot be used to size a download in advance; read the size from the file resource instead.", "tags": ["Files"], "parameters": [ { @@ -690,7 +697,7 @@ "delete": { "operationId": "deleteFile", "summary": "Delete File", - "description": "Archive a workspace file. This is a soft delete: the row is retained with a deletion timestamp, the file stops appearing in the default listing and is no longer readable through the API, and its stored bytes are never removed. List archived files with `GET /files?scope=archived` and reverse the delete with `POST /files/{fileId}/restore`.", + "description": "Archive a workspace file. This is a soft delete: the file stops appearing in the default listing and is no longer readable through the API, but its stored bytes are never removed. Archiving an already-archived file is a `404`, not a no-op. List archived files with `GET /files?scope=archived`, and reverse the delete with `POST /files/{fileId}/restore`.", "tags": ["Files"], "parameters": [ { @@ -752,9 +759,6 @@ "404": { "$ref": "#/components/responses/NotFound" }, - "409": { - "$ref": "#/components/responses/Conflict" - }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -834,6 +838,9 @@ "409": { "$ref": "#/components/responses/Conflict" }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -850,7 +857,7 @@ "post": { "operationId": "restoreFile", "summary": "Restore File", - "description": "Reverse a soft delete and return the file to the workspace. Restore is not a pure undo: the file comes back at the workspace root regardless of the folder it was deleted from, and it gains a `_restored` suffix when another file at the root already holds its name — so read `folderPath` and `name` off the response rather than assuming the pre-delete values. Restoring a file that is already active is a no-op that returns that file, so a retry is safe. Returns 400 when the workspace itself has been archived, and 409 when no free restore name could be found.", + "description": "Reverse a soft delete and return the file to the workspace. Not a pure undo: the file comes back at the workspace root, and gains a `_restored` suffix when another file there already holds its name, so read `folderPath` and `name` off the response. Restoring an already-active file returns it unchanged, so a retry is safe. An archived workspace is a `400`, and a name the restore could not free is a `409`.", "tags": ["Files"], "parameters": [ { @@ -915,6 +922,9 @@ "409": { "$ref": "#/components/responses/Conflict" }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -1009,7 +1019,7 @@ "get": { "operationId": "listAuditLogs", "summary": "List Audit Logs", - "description": "List an organization audit trail with filters and opaque cursor pagination. Requires an Enterprise subscription and organization admin or owner access. A workspace API key cannot call this operation and is rejected with `403`; use a personal API key.", + "description": "List an organization audit trail with filters and opaque cursor pagination. Requires an Enterprise subscription and organization admin or owner access. A workspace API key is rejected with `403`; use a personal API key.", "tags": ["Audit Logs"], "parameters": [ { @@ -1049,27 +1059,32 @@ "description": "Filter to actions in one workspace.", "schema": { "description": "Filter to actions in one workspace.", - "type": "string" + "type": "string", + "minLength": 1 } }, { "name": "startDate", "in": "query", "required": false, - "description": "Inclusive ISO 8601 start timestamp.", + "description": "Only include runs started at or after this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected, as is year `0000`, which names no storable instant.", "schema": { - "description": "Inclusive ISO 8601 start timestamp.", - "type": "string" + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "Only include runs started at or after this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected, as is year `0000`, which names no storable instant." } }, { "name": "endDate", "in": "query", "required": false, - "description": "Inclusive ISO 8601 end timestamp.", + "description": "Only include runs started at or before this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected, as is year `0000`, which names no storable instant.", "schema": { - "description": "Inclusive ISO 8601 end timestamp.", - "type": "string" + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "Only include runs started at or before this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected, as is year `0000`, which names no storable instant." } }, { @@ -1099,9 +1114,9 @@ "name": "cursor", "in": "query", "required": false, - "description": "Opaque cursor returned by the previous page.", + "description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.", "schema": { - "description": "Opaque cursor returned by the previous page.", + "description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.", "type": "string", "minLength": 1 } @@ -1161,9 +1176,6 @@ "403": { "$ref": "#/components/responses/Forbidden" }, - "404": { - "$ref": "#/components/responses/NotFound" - }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -1180,7 +1192,7 @@ "get": { "operationId": "getAuditLog", "summary": "Get Audit Log", - "description": "Return one organization audit-log entry. Requires an Enterprise subscription and organization admin or owner access. A workspace API key cannot call this operation and is rejected with `403`; use a personal API key.", + "description": "Return one organization audit-log entry. Requires an Enterprise subscription and organization admin or owner access. A workspace API key is rejected with `403`; use a personal API key.", "tags": ["Audit Logs"], "parameters": [ { @@ -1306,6 +1318,9 @@ "409": { "$ref": "#/components/responses/Conflict" }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -1398,7 +1413,7 @@ "patch": { "operationId": "upsertFileShare", "summary": "Enable or Disable File Share", - "description": "Create or partially update a server-tokenized public share. Only isActive is required, and an omitted authType keeps the stored auth mode. What happens to password and allowedEmails depends on the resulting mode, because enabling a share always rewrites the credentials the chosen mode does not use: 'public' clears the stored password and empties allowedEmails; 'password' keeps the stored password when password is omitted but empties allowedEmails; 'email' and 'sso' clear the stored password and keep the stored allowedEmails when the field is omitted. Only disabling with isActive false preserves the whole access configuration untouched — it also retains the token, so re-enabling restores the share as it was. Two enabling combinations are rejected outright with a 400 instead of being partially applied: 'password' when neither a password is supplied nor one is already stored, and 'email' or 'sso' when the resulting allowedEmails would be empty because none was supplied and none is stored. On a file that has never been shared there is nothing stored to fall back on, so enabling any mode other than 'public' must carry its credential in the same request. A workspace API key cannot call this operation. Because unauthorized resources are concealed, the rejection is reported as `404` rather than `403`; use a personal API key.", + "description": "Create or partially update a server-tokenized public share. Only `isActive` is required; each other field states what enabling a mode does to it. Enabling any mode other than `public` on a file that has never been shared must carry its credential in the same request. A workspace API key is rejected with `403`; use a personal API key.", "tags": ["Files"], "parameters": [ { @@ -1460,6 +1475,9 @@ "404": { "$ref": "#/components/responses/NotFound" }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -1607,6 +1625,9 @@ "409": { "$ref": "#/components/responses/Conflict" }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -1623,7 +1644,7 @@ "get": { "operationId": "listFilesFolders", "summary": "List Folders", - "description": "List workspace file folders with optional parent-path filtering and sorting. The bounded set is returned in one page with `nextCursor` always null; there is no second page to fetch.", + "description": "List workspace file folders with optional parent-path filtering and sorting. The bounded set is returned in one page; `nextCursor` is always null.", "tags": ["Files"], "parameters": [ { @@ -1644,7 +1665,7 @@ "description": "Restrict results to direct children of this parent path.", "schema": { "description": "Restrict results to direct children of this parent path.", - "type": "string" + "$ref": "#/components/schemas/FolderPathInput" } }, { @@ -1663,10 +1684,10 @@ "name": "sortBy", "in": "query", "required": false, - "description": "Field used to sort the result.", + "description": "Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order.", "schema": { "default": "name", - "description": "Field used to sort the result.", + "description": "Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order.", "type": "string", "enum": ["name", "createdAt", "updatedAt"] } @@ -1782,6 +1803,9 @@ "409": { "$ref": "#/components/responses/Conflict" }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -1846,6 +1870,9 @@ "409": { "$ref": "#/components/responses/Conflict" }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -1881,16 +1908,30 @@ "description": "Path of the folder to delete.", "schema": { "description": "Path of the folder to delete.", - "type": "string" + "$ref": "#/components/schemas/NonRootFolderPathInput" } }, { "name": "recursive", "in": "query", "required": false, - "description": "Delete nested files and folders when true.", - "schema": { - "description": "Delete nested files and folders when true.", + "description": "Delete the folder's nested files and folders too. An empty folder deletes either way; a non-empty one needs this. The listed spellings are the whole accepted vocabulary and are case-sensitive; any other value is rejected.", + "schema": { + "description": "Delete the folder's nested files and folders too. An empty folder deletes either way; a non-empty one needs this. The listed spellings are the whole accepted vocabulary and are case-sensitive; any other value is rejected.", + "enum": [ + "true", + "1", + "yes", + "on", + "y", + "enabled", + "false", + "0", + "no", + "off", + "n", + "disabled" + ], "default": "false", "type": "string" } @@ -1952,7 +1993,7 @@ "type": "apiKey", "in": "header", "name": "X-API-Key", - "description": "Your Sim API key, personal or workspace-scoped. Generate one under Settings > API Keys. Operations that reject workspace keys say so in their own description." + "description": "Your Sim API key, personal or workspace-scoped. Generate one under Settings, then API Keys. Operations that reject workspace keys say so in their own description." } }, "headers": { @@ -2012,13 +2053,13 @@ } }, "Retry-After": { - "description": "Seconds to wait before retrying. Sent on `429` (derived from the caller rate-limit window) and on `503` (a fixed transient-failure floor). Add jitter rather than retrying at exactly this offset.", + "description": "Seconds to wait before retrying, sent on `429` and `503`. Add jitter rather than retrying at exactly this offset.", "schema": { "type": "integer", "minimum": 0, "maximum": 9007199254740991, "title": "Retry after", - "description": "Seconds to wait before retrying. Sent on `429` (derived from the caller rate-limit window) and on `503` (a fixed transient-failure floor). Add jitter rather than retrying at exactly this offset." + "description": "Seconds to wait before retrying, sent on `429` and `503`. Add jitter rather than retrying at exactly this offset." } }, "X-Run-Id": { @@ -2033,7 +2074,7 @@ }, "responses": { "BadRequest": { - "description": "The request is invalid.", + "description": "The request is invalid. This includes a query parameter sent with no value (`?limit=`, `?search=`), which is rejected rather than read as zero, empty, or the parameter default — omit the parameter instead.", "content": { "application/json": { "schema": { @@ -2063,7 +2104,7 @@ } }, "Forbidden": { - "description": "The caller lacks the rights this operation requires. Where the cause is one a caller can act on, `error.details.code` names it, drawn from a closed set:\n- `INSUFFICIENT_WORKSPACE_ROLE` — The caller has access to the workspace but its role is below the one this operation requires.\n- `PERSONAL_API_KEYS_DISABLED` — The workspace's organization does not allow personal API keys. Use a workspace API key.\n- `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` — This operation is not available to a workspace-scoped API key. Use a personal API key.\n- `PRINCIPAL_KIND_NOT_PERMITTED` — This operation does not accept the caller’s kind of API key.\n- `ORGANIZATION_MEMBERSHIP_REQUIRED` — The caller is not a member of the organization it named.\n- `ORGANIZATION_ADMIN_REQUIRED` — The caller is a member of the organization but not an admin or owner.\n- `ENTERPRISE_PLAN_REQUIRED` — The organization has no active enterprise subscription.\n- `AUDIT_LOGS_DISABLED` — Audit logging is not enabled for this deployment.\n- `SKILL_EDITOR_ACCESS_REQUIRED` — The caller can write in the workspace but is not an editor of this skill.\n- `MCP_SERVER_URL_NOT_ALLOWED` — The supplied MCP server URL is outside the allowed domains or resolves to an internal address.\nA resource in a workspace the caller cannot reach at all answers `404`, not `403`, so absence and denial are indistinguishable to a caller who was never entitled to tell them apart.", + "description": "The caller lacks the rights this operation requires. When the cause is one a caller can act on, `error.details.code` names it. A resource in a workspace the caller cannot reach at all answers `404` instead, so absence and denial are indistinguishable.", "content": { "application/json": { "schema": { @@ -2093,7 +2134,7 @@ } }, "RunIdConflict": { - "description": "The run cannot be started. Two causes share this status, distinguished by `error.details.code`: `RUN_ID_CONFLICT` when the supplied `X-Run-Id` is already associated with a different request, and `CALL_CHAIN_DEPTH_EXCEEDED` when the incoming `X-Sim-Via` chain has already reached the maximum workflow-to-workflow call depth.", + "description": "The run cannot be started, for one of two causes named by `error.details.code`: `RUN_ID_CONFLICT` when the supplied `X-Run-Id` is already claimed, and `CALL_CHAIN_DEPTH_EXCEEDED` when the incoming `X-Sim-Via` chain has reached the maximum workflow-to-workflow call depth.", "headers": { "X-Run-Id": { "$ref": "#/components/headers/X-Run-Id" @@ -2107,18 +2148,8 @@ } } }, - "Gone": { - "description": "The requested generated resource has expired.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2Error" - } - } - } - }, "PayloadTooLarge": { - "description": "The request, or a resource collection it must materialize, exceeds the allowed size. Besides an oversized request body, this covers a generated artifact that renders past the download ceiling and a workspace folder tree too large to load in full.", + "description": "The request, or a resource collection it must materialize, exceeds the allowed size: an oversized request body, a generated artifact past the download ceiling, or a workspace folder tree too large to load in full.", "content": { "application/json": { "schema": { @@ -2163,7 +2194,7 @@ } }, "ClientClosedRequest": { - "description": "The client closed the connection before the response was produced.", + "description": "The client closed the connection before the response was produced. An abort can leave the run going, so `error.details.runId` carries the run id — reconcile against the runs resource rather than starting another run.", "content": { "application/json": { "schema": { @@ -2183,7 +2214,7 @@ } }, "ServiceUnavailable": { - "description": "A required service is temporarily unavailable. The condition is transient, so the response normally carries `Retry-After` with the number of seconds to wait; treat that value as a floor and add jitter before retrying. One case deliberately omits the header: when `error.details.code` is `ASYNC_ENQUEUE_AMBIGUOUS`, the run may already have started, so retrying could start and bill a second run. Reconcile against the returned run id instead of retrying.", + "description": "A required service is temporarily unavailable. `Retry-After` carries the seconds to wait; treat it as a floor and add jitter. The header is omitted when `error.details.code` is `ASYNC_ENQUEUE_AMBIGUOUS`, because the run may already have started — reconcile against the returned run id instead of retrying.", "headers": { "Retry-After": { "$ref": "#/components/headers/Retry-After" @@ -2214,7 +2245,7 @@ "description": "Human-readable explanation of the error." }, "details": { - "description": "Optional structured error details." + "description": "Structured error details. On a `403` whose cause a caller can act on, this carries a `code` from a closed set:\n- `INSUFFICIENT_WORKSPACE_ROLE` — The caller has access to the workspace but its role is below the one this operation requires.\n- `PERSONAL_API_KEYS_DISABLED` — The workspace's organization does not allow personal API keys. Use a workspace API key.\n- `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` — This operation is not available to a workspace-scoped API key. Use a personal API key.\n- `PRINCIPAL_KIND_NOT_PERMITTED` — This operation does not accept the caller’s kind of API key.\n- `ORGANIZATION_MEMBERSHIP_REQUIRED` — The caller is not a member of the organization it named.\n- `ORGANIZATION_ADMIN_REQUIRED` — The caller is a member of the organization but not an admin or owner.\n- `ENTERPRISE_PLAN_REQUIRED` — The organization has no active enterprise subscription.\n- `AUDIT_LOGS_DISABLED` — Audit logging is not enabled for this deployment.\n- `SKILL_EDITOR_ACCESS_REQUIRED` — The caller can write in the workspace but is not an editor of this skill.\n- `SECRET_ADMIN_ACCESS_REQUIRED` — The caller can write in the workspace but is not an admin of this secret. Ask a workspace admin, or someone holding admin on the secret, to grant access or set the value.\n- `WORKSPACE_RESOURCE_LIMIT_REACHED` — The workspace already holds the maximum number of resources of this kind. Delete one, or contact Sim to raise the limit; the message names the ceiling.\n- `PUBLIC_SHARING_NOT_ALLOWED` — The workspace's organization does not permit sharing this resource publicly. An organization admin controls the policy.\n- `MCP_SERVER_URL_NOT_ALLOWED` — The supplied MCP server URL is outside the allowed domains or resolves to an internal address." } }, "required": ["code", "message"], @@ -2235,6 +2266,12 @@ } ] }, + "FolderPathInput": { + "title": "Folder path input", + "description": "Folder path. A missing leading slash is normalized before validation. Segments are percent-encoded, so a folder shown as \"New folder\" is `/New%20folder`: everything outside `A-Z a-z 0-9 - _ . ~` is escaped as uppercase hex, and only that exact encoding is accepted. A trailing slash, an empty segment, and a literal `.` or `..` segment are rejected. At most 64 segments and 4096 encoded bytes.", + "maxLength": 4096, + "type": "string" + }, "V2File": { "type": "object", "properties": { @@ -2251,12 +2288,12 @@ "size": { "type": "number", "minimum": 0, - "description": "Size in bytes of the stored file. For a generated document (docx, pptx, pdf, xlsx) the stored file is the generation source rather than the rendered document, so this does not predict how many bytes `GET /files/{fileId}` returns — that endpoint serves the compiled artifact, which is typically much larger.", + "description": "Size in bytes of the stored file. For a generated document (docx, pptx, pdf, xlsx) this is the generation source, not the rendered document, so it does not predict how many bytes `GET /files/{fileId}` returns.", "examples": [1024] }, "type": { "type": "string", - "description": "MIME type of the stored file. For a generated document (docx, pptx, pdf, xlsx) the stored file is the generation source, so this describes the source and not what `GET /files/{fileId}` serves — that endpoint returns the compiled artifact under the rendered document type.", + "description": "MIME type of the stored file. For a generated document (docx, pptx, pdf, xlsx) this is the generation source type, not the rendered document type `GET /files/{fileId}` serves.", "examples": ["text/csv"] }, "key": { @@ -2266,7 +2303,9 @@ }, "folderPath": { "type": "string", - "description": "Canonical containing-folder path. `/` is the workspace root." + "title": "Folder path", + "description": "Canonical containing-folder path. `/` is the workspace root.", + "maxLength": 4096 }, "uploadedByEmail": { "type": "string", @@ -2336,7 +2375,7 @@ "type": "null" } ], - "description": "Opaque cursor for the next page: send it back as `cursor` to continue, and stop when it is null. Most v2 lists page, so null means the last page was reached. A few are full-set lists that return their whole bounded result in one response and therefore always report null; those say so in the operation description. Either way, null means there is nothing further to fetch — never construct a cursor yourself." + "description": "Opaque cursor for the next page. Send it back as `cursor`; `null` means there is nothing further to fetch. Never construct one yourself." } }, "required": ["data", "nextCursor"], @@ -2414,11 +2453,11 @@ }, "folderPath": { "description": "Canonical containing-folder path. Omit for the workspace root.", - "type": "string" + "$ref": "#/components/schemas/FolderPathInput" }, "content": { "default": "", - "description": "Initial file content. Omit or send an empty string for a zero-byte file. The 70,000,000-character bound is a JSON-envelope guard, not the file-size limit: the decoded bytes must be at most 50 MiB, so a longer base64 payload is admitted here and then rejected with 413. Use an upload session for anything larger.", + "description": "Initial file content. Omit or send an empty string for a zero-byte file. The 70,000,000-character bound guards the JSON envelope; the decoded bytes must be at most 50 MiB, and a longer base64 payload is rejected with `413`. Use an upload session for anything larger.", "type": "string", "maxLength": 70000000 }, @@ -2514,7 +2553,7 @@ "url": { "type": "string", "format": "uri", - "description": "Signed URL to which the file bytes are uploaded." + "description": "Signed URL to which the file bytes are uploaded. Send the bytes with `PUT` to this URL, including exactly the headers in `headers` and nothing that alters the body. Success is `204` with an empty body. A failure is the same `{ \"error\": { \"code\", \"message\" } }` envelope as every other v2 response: `400` when the body does not match the size or content type the session was created for, `403` when the token is invalid, expired, or belongs to another session, and `409` when the session is no longer accepting bytes. The URL is signed and self-describing — construct it from this field only, never by hand." }, "headers": { "type": "object", @@ -2634,7 +2673,7 @@ }, "folderPath": { "description": "Canonical destination folder path. Omit for the workspace root.", - "type": "string" + "$ref": "#/components/schemas/FolderPathInput" } }, "required": ["workspaceId", "name", "contentType", "size"], @@ -2667,7 +2706,7 @@ "url": { "type": "string", "format": "uri", - "description": "Signed URL for this upload part." + "description": "Signed URL for this upload part. Send the bytes with `PUT` to this URL, including exactly the headers in `headers` and nothing that alters the body. Success is `204` with an empty body. A failure is the same `{ \"error\": { \"code\", \"message\" } }` envelope as every other v2 response: `400` when the body does not match the size or content type the session was created for, `403` when the token is invalid, expired, or belongs to another session, and `409` when the session is no longer accepting bytes. The URL is signed and self-describing — construct it from this field only, never by hand." }, "headers": { "type": "object", @@ -2931,12 +2970,12 @@ "size": { "type": "number", "minimum": 0, - "description": "Size in bytes of the stored file. For a generated document (docx, pptx, pdf, xlsx) the stored file is the generation source rather than the rendered document, so this does not predict how many bytes `GET /files/{fileId}` returns — that endpoint serves the compiled artifact, which is typically much larger.", + "description": "Size in bytes of the stored file. For a generated document (docx, pptx, pdf, xlsx) this is the generation source, not the rendered document, so it does not predict how many bytes `GET /files/{fileId}` returns.", "examples": [1024] }, "type": { "type": "string", - "description": "MIME type of the stored file. For a generated document (docx, pptx, pdf, xlsx) the stored file is the generation source, so this describes the source and not what `GET /files/{fileId}` serves — that endpoint returns the compiled artifact under the rendered document type.", + "description": "MIME type of the stored file. For a generated document (docx, pptx, pdf, xlsx) this is the generation source type, not the rendered document type `GET /files/{fileId}` serves.", "examples": ["text/csv"] }, "key": { @@ -2946,7 +2985,9 @@ }, "folderPath": { "type": "string", - "description": "Canonical containing-folder path. `/` is the workspace root." + "title": "Folder path", + "description": "Canonical containing-folder path. `/` is the workspace root.", + "maxLength": 4096 }, "uploadedByEmail": { "type": "string", @@ -3196,7 +3237,7 @@ "type": "null" } ], - "description": "Opaque cursor for the next page: send it back as `cursor` to continue, and stop when it is null. Most v2 lists page, so null means the last page was reached. A few are full-set lists that return their whole bounded result in one response and therefore always report null; those say so in the operation description. Either way, null means there is nothing further to fetch — never construct a cursor yourself." + "description": "Opaque cursor for the next page. Send it back as `cursor`; `null` means there is nothing further to fetch. Never construct one yourself." } }, "required": ["data", "nextCursor"], @@ -3325,7 +3366,7 @@ }, "targetFolderPath": { "description": "Destination folder path. Omit to move files to the workspace root.", - "type": "string" + "$ref": "#/components/schemas/FolderPathInput" } }, "required": ["workspaceId", "fileIds"], @@ -3409,21 +3450,21 @@ }, "isActive": { "type": "boolean", - "description": "Whether the share should resolve." + "description": "Whether the share should resolve. Disabling preserves the token and the whole access configuration, so re-enabling restores the share as it was; enabling rewrites the credentials the resulting mode does not use." }, "authType": { - "description": "How access to the share is gated.", + "description": "How access to the share is gated. The stored mode is kept when omitted. Enabling `public` clears the stored password and empties `allowedEmails`; `password` empties `allowedEmails`; `email` and `sso` clear the stored password.", "type": "string", "enum": ["public", "password", "email", "sso"] }, "password": { - "description": "Password for a password-gated share.", + "description": "Password for a password-gated share. Kept when omitted; enabling `password` with neither a supplied nor a stored password is a 400.", "type": "string", "minLength": 1, "maxLength": 1024 }, "allowedEmails": { - "description": "Allowed addresses or @domain patterns for email and SSO shares.", + "description": "Allowed addresses or `@domain` patterns for email and SSO shares. Kept when omitted; enabling `email` or `sso` with an empty resulting list is a 400.", "maxItems": 200, "type": "array", "items": { @@ -3460,7 +3501,7 @@ "content": { "type": "string", "maxLength": 70000000, - "description": "Complete replacement content for the file. The 70,000,000-character bound is a JSON-envelope guard, not the file-size limit: the decoded bytes must be at most 50 MiB, so a longer base64 payload is admitted here and then rejected with 413." + "description": "Complete replacement content for the file. The 70,000,000-character bound guards the JSON envelope; the decoded bytes must be at most 50 MiB, and a longer base64 payload is rejected with `413`." }, "encoding": { "default": "utf-8", @@ -3564,11 +3605,15 @@ }, "path": { "type": "string", - "description": "Canonical folder path used as the public folder identifier." + "title": "Non-root folder path", + "description": "Canonical folder path used as the public folder identifier.", + "maxLength": 4096 }, "parentPath": { "type": "string", - "description": "Canonical parent path; `/` is the root." + "title": "Folder path", + "description": "Canonical parent path; `/` is the root.", + "maxLength": 4096 }, "createdAt": { "type": "string", @@ -3605,7 +3650,7 @@ "type": "null" } ], - "description": "Opaque cursor for the next page: send it back as `cursor` to continue, and stop when it is null. Most v2 lists page, so null means the last page was reached. A few are full-set lists that return their whole bounded result in one response and therefore always report null; those say so in the operation description. Either way, null means there is nothing further to fetch — never construct a cursor yourself." + "description": "Always `null` — this list has no `cursor` or `limit` param and returns its whole bounded set in one page. Present so the list can gain pages later without a shape change." } }, "required": ["data", "nextCursor"], @@ -3626,6 +3671,12 @@ "title": "File folder response", "description": "A single workspace file folder." }, + "NonRootFolderPathInput": { + "title": "Non-root folder path input", + "description": "Non-root folder path. A missing leading slash is normalized before validation. Segments are percent-encoded, so a folder shown as \"New folder\" is `/New%20folder`: everything outside `A-Z a-z 0-9 - _ . ~` is escaped as uppercase hex, and only that exact encoding is accepted. A trailing slash, an empty segment, and a literal `.` or `..` segment are rejected. At most 64 segments and 4096 encoded bytes.", + "maxLength": 4096, + "type": "string" + }, "CreateFileFolderRequest": { "type": "object", "properties": { @@ -3636,7 +3687,7 @@ }, "path": { "description": "Path of the folder to create.", - "type": "string" + "$ref": "#/components/schemas/NonRootFolderPathInput" } }, "required": ["workspaceId", "path"], @@ -3654,11 +3705,11 @@ }, "path": { "description": "Current folder path.", - "type": "string" + "$ref": "#/components/schemas/NonRootFolderPathInput" }, "destinationPath": { "description": "New full path for the folder and its descendants.", - "type": "string" + "$ref": "#/components/schemas/NonRootFolderPathInput" } }, "required": ["workspaceId", "path", "destinationPath"], @@ -3671,7 +3722,9 @@ "properties": { "path": { "type": "string", - "description": "Deleted folder path." + "title": "Folder path", + "description": "Deleted folder path.", + "maxLength": 4096 }, "deleted": { "type": "boolean", diff --git a/apps/docs/openapi-v2-knowledge.json b/apps/docs/openapi-v2-knowledge.json index 01ae9282ec0..e0d93148561 100644 --- a/apps/docs/openapi-v2-knowledge.json +++ b/apps/docs/openapi-v2-knowledge.json @@ -36,7 +36,7 @@ "get": { "operationId": "listKnowledgeBases", "summary": "List Knowledge Bases", - "description": "List knowledge bases in a workspace with folder filtering, search, and sorting. Paginate with `limit` and `cursor`, stopping when `nextCursor` is null. An unknown `folderPath` is a 404. A workspace whose folder tree exceeds 10,000 folders is a 413, because the response needs the whole tree to render folder paths.", + "description": "List knowledge bases in a workspace with folder filtering, search, sorting, and opaque cursor pagination. A workspace folder tree over 10,000 folders is a `413`.", "tags": ["Knowledge Bases"], "parameters": [ { @@ -54,19 +54,19 @@ "name": "folderPath", "in": "query", "required": false, - "description": "Restrict results to knowledge bases in this folder.", + "description": "Restrict results to knowledge bases in this folder. A path that names no folder narrows the result to nothing, so the response is an empty page rather than an error.", "schema": { - "description": "Restrict results to knowledge bases in this folder.", - "type": "string" + "description": "Restrict results to knowledge bases in this folder. A path that names no folder narrows the result to nothing, so the response is an empty page rather than an error.", + "$ref": "#/components/schemas/FolderPathInput" } }, { "name": "search", "in": "query", "required": false, - "description": "Case-insensitive substring search on the resource name.", + "description": "Case-insensitive substring match against the resource name.", "schema": { - "description": "Case-insensitive substring search on the resource name.", + "description": "Case-insensitive substring match against the resource name.", "type": "string", "minLength": 1, "maxLength": 200 @@ -76,10 +76,10 @@ "name": "sortBy", "in": "query", "required": false, - "description": "Field used to sort the result.", + "description": "Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order.", "schema": { "default": "createdAt", - "description": "Field used to sort the result.", + "description": "Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order.", "type": "string", "enum": ["name", "createdAt", "updatedAt"] } @@ -113,9 +113,9 @@ "name": "cursor", "in": "query", "required": false, - "description": "Opaque cursor returned by the previous page.", + "description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.", "schema": { - "description": "Opaque cursor returned by the previous page.", + "description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.", "type": "string", "minLength": 1 } @@ -172,7 +172,7 @@ "post": { "operationId": "createKnowledgeBase", "summary": "Create Knowledge Base", - "description": "Create a knowledge base in a workspace with optional folder placement and chunking configuration. An unknown `folderPath` is a 404. A workspace whose folder tree exceeds 10,000 folders is a 413, because the response needs the whole tree to render folder paths.", + "description": "Create a knowledge base in a workspace with optional folder placement and chunking configuration. An unknown `folderPath` is a `404`. A workspace folder tree over 10,000 folders is a `413`.", "tags": ["Knowledge Bases"], "requestBody": { "required": true, @@ -241,7 +241,7 @@ "get": { "operationId": "getKnowledgeBase", "summary": "Get Knowledge Base", - "description": "Retrieve a knowledge base by identifier. Inaccessible knowledge bases are reported as not found. A workspace whose folder tree exceeds 10,000 folders is a 413, because the response needs the whole tree to render folder paths.", + "description": "Retrieve a knowledge base by identifier. Inaccessible knowledge bases are reported as not found. A workspace folder tree over 10,000 folders is a `413`.", "tags": ["Knowledge Bases"], "parameters": [ { @@ -318,7 +318,7 @@ "patch": { "operationId": "updateKnowledgeBase", "summary": "Update Knowledge Base", - "description": "Update a knowledge base name, description, chunking configuration, or folder placement. A workspace whose folder tree exceeds 10,000 folders is a 413, because the response needs the whole tree to render folder paths.", + "description": "Update a knowledge base name, description, chunking configuration, or folder placement. A workspace folder tree over 10,000 folders is a `413`.", "tags": ["Knowledge Bases"], "parameters": [ { @@ -474,7 +474,7 @@ "post": { "operationId": "searchKnowledge", "summary": "Search Knowledge", - "description": "Search one or more knowledge bases with semantic vector retrieval, optional hybrid full-text retrieval, and structured tag filters. Set `rerankerEnabled` with a `rerankerModel` to re-order the retrieved chunks with a reranking model before truncating to `topK`; reranked results carry a `rerankerScore` and are ordered by it, and reranking is billed as an additional search unit. Every result names the `knowledgeBaseId` it came from. The request body is capped at 2 MiB; a larger body is a 413.", + "description": "Search one or more knowledge bases with semantic vector retrieval, optional hybrid full-text retrieval, and structured tag filters. Every result names the `knowledgeBaseId` it came from. A request body over 2 MiB is a `413`.", "tags": ["Knowledge Bases"], "requestBody": { "required": true, @@ -543,7 +543,7 @@ "get": { "operationId": "listKnowledgeTags", "summary": "List Tags", - "description": "List the knowledge base's tag vocabulary: each tag's display name, the slot it is stored in, and its field type. Display names are what tag filters and the tag values on document reads use; slots are what document writes set. Every slot listed here is writable, in its declared type: `tag1`..`tag7` take a string, `number1`..`number5` a number, `date1`..`date2` a `YYYY-MM-DD` string, and `boolean1`..`boolean3` a boolean. The vocabulary is bounded by the fixed slot table. The bounded set is returned in one page with `nextCursor` always null; there is no second page to fetch.", + "description": "List the knowledge base's tag vocabulary: each tag's display name, the slot it is stored in, and its field type. Filters and document reads use display names; document writes address slots. The bounded set is returned in one page; `nextCursor` is always null.", "tags": ["Knowledge Bases"], "parameters": [ { @@ -619,7 +619,7 @@ "get": { "operationId": "listKnowledgeDocuments", "summary": "List Documents", - "description": "List documents in a knowledge base with filename search, state filtering, tag filtering, sorting, and opaque cursor pagination. Each document carries its tag values keyed by tag display name; resolve those names to write slots with `GET /api/v2/knowledge/{id}/tags`.", + "description": "List documents in a knowledge base with filename search, state filtering, tag filtering, sorting, and opaque cursor pagination. Tag values are keyed by display name; resolve those to write slots with `GET /api/v2/knowledge/{id}/tags`.", "tags": ["Knowledge Bases"], "parameters": [ { @@ -648,10 +648,10 @@ "name": "limit", "in": "query", "required": false, - "description": "Maximum documents to return, between 1 and 100.", + "description": "Maximum documents to return per page. Must be a whole number from 1 to 100. Defaults to 50.", "schema": { "default": 50, - "description": "Maximum documents to return, between 1 and 100.", + "description": "Maximum documents to return per page. Must be a whole number from 1 to 100. Defaults to 50.", "type": "integer", "minimum": 1, "maximum": 100 @@ -661,10 +661,12 @@ "name": "search", "in": "query", "required": false, - "description": "Case-insensitive filename search.", + "description": "Case-insensitive substring match against the document filename.", "schema": { - "description": "Case-insensitive filename search.", - "type": "string" + "description": "Case-insensitive substring match against the document filename.", + "type": "string", + "minLength": 1, + "maxLength": 200 } }, { @@ -683,10 +685,10 @@ "name": "sortBy", "in": "query", "required": false, - "description": "Document field used to sort results.", + "description": "Field used to sort the result. Sorting by `filename` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order.", "schema": { "default": "uploadedAt", - "description": "Document field used to sort results.", + "description": "Field used to sort the result. Sorting by `filename` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order.", "type": "string", "enum": [ "filename", @@ -715,9 +717,9 @@ "name": "cursor", "in": "query", "required": false, - "description": "Opaque cursor returned by the previous page.", + "description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.", "schema": { - "description": "Opaque cursor returned by the previous page.", + "description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.", "type": "string", "minLength": 1 } @@ -784,7 +786,7 @@ "patch": { "operationId": "bulkUpdateKnowledgeDocuments", "summary": "Bulk Enable or Disable Documents", - "description": "Enable or disable many documents in one request, either by identifier (up to 100) or, with `selectAll`, every document in the knowledge base optionally narrowed by `enabledFilter`. Disabling keeps a document indexed but excludes it from search. Bulk delete is deliberately not offered: the bulk path records no audit entries, so deletions go through `DELETE /api/v2/knowledge/{id}/documents/{documentId}`, which audits each one. An identifier request echoes the documents it changed in `documentIds`; a `selectAll` request omits that field because the selection is unbounded, and reports `updatedCount` alone. A workspace API key cannot call this operation and is rejected with `403`; use a personal API key.", + "description": "Enable or disable many documents in one request, either by identifier or, with `selectAll`, every document in the knowledge base. Bulk delete is not offered; delete documents one at a time with `DELETE /api/v2/knowledge/{id}/documents/{documentId}`. A workspace API key is rejected with `403`; use a personal API key.", "tags": ["Knowledge Bases"], "parameters": [ { @@ -844,6 +846,9 @@ "404": { "$ref": "#/components/responses/NotFound" }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -1236,6 +1241,9 @@ "409": { "$ref": "#/components/responses/Conflict" }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -1441,7 +1449,7 @@ "patch": { "operationId": "updateKnowledgeDocument", "summary": "Update Document", - "description": "Rename a document, enable or disable it for search, set any of its 17 tag slots, or requeue it for processing. A tag slot takes its declared type — a string for `tag1`..`tag7`, a number for `number1`..`number5`, a `YYYY-MM-DD` string for `date1`..`date2`, a boolean for `boolean1`..`boolean3` — and a value that is not valid for the slot is a `400` rather than a silently cleared tag. Resolve a display name to its slot with `GET /api/v2/knowledge/{id}/tags`. Absent fields are unchanged. Only caller-owned fields are accepted: derived indexing state (`chunkCount`, `tokenCount`, `characterCount`, `processingStatus`, `processingError`) is written by the processing pipeline and cannot be asserted here. `retryProcessing: true` re-queues a failed or stuck document and must be sent on its own — it runs instead of, not alongside, the field updates — and it answers with a queue acknowledgement rather than the document. Otherwise the updated document is returned; it omits the connector provenance the detail read carries, so re-read with GET when that is needed. A workspace API key cannot call this operation and is rejected with `403`; use a personal API key.", + "description": "Rename a document, enable or disable it for search, set any of its 17 tag slots, or requeue it for processing. Absent fields are unchanged, and derived indexing state is read-only. Resolve a tag display name to its slot with `GET /api/v2/knowledge/{id}/tags`. The returned document omits the connector provenance the detail read carries. A workspace API key is rejected with `403`; use a personal API key.", "tags": ["Knowledge Bases"], "parameters": [ { @@ -1512,6 +1520,9 @@ "404": { "$ref": "#/components/responses/NotFound" }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -1526,7 +1537,7 @@ "delete": { "operationId": "deleteKnowledgeDocument", "summary": "Delete Document", - "description": "Remove one document from a knowledge base. What that means depends on the document. A directly uploaded document is deleted outright along with its indexed chunks. A connector-backed document is instead excluded: its row survives, marked excluded and disabled so it stops being searchable and a later connector sync does not re-add it, and its embeddings are not deleted. Either way the document no longer appears in listings or search results.", + "description": "Remove one document from a knowledge base. An uploaded document is deleted outright with its indexed chunks. A connector-backed document is instead excluded — its row and embeddings survive, but it stops being searchable and a later sync does not re-add it. Either way it no longer appears in listings or search results.", "tags": ["Knowledge Bases"], "parameters": [ { @@ -1613,7 +1624,7 @@ "get": { "operationId": "listKnowledgeFolders", "summary": "List Folders", - "description": "List folders in the knowledge-base folder tree with filtering and sorting. The bounded set is returned in one page with `nextCursor` always null; there is no second page to fetch. A workspace whose folder tree exceeds 10,000 folders is a 413, because the response needs the whole tree to render folder paths.", + "description": "List folders in the knowledge-base folder tree with filtering and sorting. The bounded set is returned in one page; `nextCursor` is always null. A workspace folder tree over 10,000 folders is a `413`.", "tags": ["Knowledge Bases"], "parameters": [ { @@ -1634,7 +1645,7 @@ "description": "Restrict results to direct children of this parent path.", "schema": { "description": "Restrict results to direct children of this parent path.", - "type": "string" + "$ref": "#/components/schemas/FolderPathInput" } }, { @@ -1653,10 +1664,10 @@ "name": "sortBy", "in": "query", "required": false, - "description": "Field used to sort the result.", + "description": "Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order.", "schema": { "default": "name", - "description": "Field used to sort the result.", + "description": "Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order.", "type": "string", "enum": ["name", "createdAt", "updatedAt"] } @@ -1725,7 +1736,7 @@ "post": { "operationId": "createKnowledgeFolder", "summary": "Create Folder", - "description": "Create a folder in the knowledge-base folder tree. A workspace whose folder tree exceeds 10,000 folders is a 413, because the response needs the whole tree to render folder paths.", + "description": "Create a folder in the knowledge-base folder tree. A workspace folder tree over 10,000 folders is a `413`.", "tags": ["Knowledge Bases"], "requestBody": { "required": true, @@ -1792,7 +1803,7 @@ "patch": { "operationId": "relocateKnowledgeFolder", "summary": "Rename or Move Folder", - "description": "Rename or move a folder and atomically rewrite descendant paths. A workspace whose folder tree exceeds 10,000 folders is a 413, because the response needs the whole tree to render folder paths.", + "description": "Rename or move a folder and atomically rewrite descendant paths. A workspace folder tree over 10,000 folders is a `413`.", "tags": ["Knowledge Bases"], "requestBody": { "required": true, @@ -1880,16 +1891,30 @@ "description": "Path of the folder to delete.", "schema": { "description": "Path of the folder to delete.", - "type": "string" + "$ref": "#/components/schemas/NonRootFolderPathInput" } }, { "name": "recursive", "in": "query", "required": false, - "description": "Delete nested files and folders when true.", + "description": "Delete the folder's nested files and folders too. An empty folder deletes either way; a non-empty one needs this. The listed spellings are the whole accepted vocabulary and are case-sensitive; any other value is rejected.", "schema": { - "description": "Delete nested files and folders when true.", + "description": "Delete the folder's nested files and folders too. An empty folder deletes either way; a non-empty one needs this. The listed spellings are the whole accepted vocabulary and are case-sensitive; any other value is rejected.", + "enum": [ + "true", + "1", + "yes", + "on", + "y", + "enabled", + "false", + "0", + "no", + "off", + "n", + "disabled" + ], "default": "false", "type": "string" } @@ -1954,7 +1979,7 @@ "type": "apiKey", "in": "header", "name": "X-API-Key", - "description": "Your Sim API key, personal or workspace-scoped. Generate one under Settings > API Keys. Operations that reject workspace keys say so in their own description." + "description": "Your Sim API key, personal or workspace-scoped. Generate one under Settings, then API Keys. Operations that reject workspace keys say so in their own description." } }, "headers": { @@ -1989,13 +2014,13 @@ } }, "Retry-After": { - "description": "Seconds to wait before retrying. Sent on `429` (derived from the caller rate-limit window) and on `503` (a fixed transient-failure floor). Add jitter rather than retrying at exactly this offset.", + "description": "Seconds to wait before retrying, sent on `429` and `503`. Add jitter rather than retrying at exactly this offset.", "schema": { "type": "integer", "minimum": 0, "maximum": 9007199254740991, "title": "Retry after", - "description": "Seconds to wait before retrying. Sent on `429` (derived from the caller rate-limit window) and on `503` (a fixed transient-failure floor). Add jitter rather than retrying at exactly this offset." + "description": "Seconds to wait before retrying, sent on `429` and `503`. Add jitter rather than retrying at exactly this offset." } }, "X-Run-Id": { @@ -2010,7 +2035,7 @@ }, "responses": { "BadRequest": { - "description": "The request is invalid.", + "description": "The request is invalid. This includes a query parameter sent with no value (`?limit=`, `?search=`), which is rejected rather than read as zero, empty, or the parameter default — omit the parameter instead.", "content": { "application/json": { "schema": { @@ -2040,7 +2065,7 @@ } }, "Forbidden": { - "description": "The caller lacks the rights this operation requires. Where the cause is one a caller can act on, `error.details.code` names it, drawn from a closed set:\n- `INSUFFICIENT_WORKSPACE_ROLE` — The caller has access to the workspace but its role is below the one this operation requires.\n- `PERSONAL_API_KEYS_DISABLED` — The workspace's organization does not allow personal API keys. Use a workspace API key.\n- `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` — This operation is not available to a workspace-scoped API key. Use a personal API key.\n- `PRINCIPAL_KIND_NOT_PERMITTED` — This operation does not accept the caller’s kind of API key.\n- `ORGANIZATION_MEMBERSHIP_REQUIRED` — The caller is not a member of the organization it named.\n- `ORGANIZATION_ADMIN_REQUIRED` — The caller is a member of the organization but not an admin or owner.\n- `ENTERPRISE_PLAN_REQUIRED` — The organization has no active enterprise subscription.\n- `AUDIT_LOGS_DISABLED` — Audit logging is not enabled for this deployment.\n- `SKILL_EDITOR_ACCESS_REQUIRED` — The caller can write in the workspace but is not an editor of this skill.\n- `MCP_SERVER_URL_NOT_ALLOWED` — The supplied MCP server URL is outside the allowed domains or resolves to an internal address.\nA resource in a workspace the caller cannot reach at all answers `404`, not `403`, so absence and denial are indistinguishable to a caller who was never entitled to tell them apart.", + "description": "The caller lacks the rights this operation requires. When the cause is one a caller can act on, `error.details.code` names it. A resource in a workspace the caller cannot reach at all answers `404` instead, so absence and denial are indistinguishable.", "content": { "application/json": { "schema": { @@ -2070,7 +2095,7 @@ } }, "RunIdConflict": { - "description": "The run cannot be started. Two causes share this status, distinguished by `error.details.code`: `RUN_ID_CONFLICT` when the supplied `X-Run-Id` is already associated with a different request, and `CALL_CHAIN_DEPTH_EXCEEDED` when the incoming `X-Sim-Via` chain has already reached the maximum workflow-to-workflow call depth.", + "description": "The run cannot be started, for one of two causes named by `error.details.code`: `RUN_ID_CONFLICT` when the supplied `X-Run-Id` is already claimed, and `CALL_CHAIN_DEPTH_EXCEEDED` when the incoming `X-Sim-Via` chain has reached the maximum workflow-to-workflow call depth.", "headers": { "X-Run-Id": { "$ref": "#/components/headers/X-Run-Id" @@ -2084,18 +2109,8 @@ } } }, - "Gone": { - "description": "The requested generated resource has expired.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2Error" - } - } - } - }, "PayloadTooLarge": { - "description": "The request, or a resource collection it must materialize, exceeds the allowed size. Besides an oversized request body, this covers a generated artifact that renders past the download ceiling and a workspace folder tree too large to load in full.", + "description": "The request, or a resource collection it must materialize, exceeds the allowed size: an oversized request body, a generated artifact past the download ceiling, or a workspace folder tree too large to load in full.", "content": { "application/json": { "schema": { @@ -2140,7 +2155,7 @@ } }, "ClientClosedRequest": { - "description": "The client closed the connection before the response was produced.", + "description": "The client closed the connection before the response was produced. An abort can leave the run going, so `error.details.runId` carries the run id — reconcile against the runs resource rather than starting another run.", "content": { "application/json": { "schema": { @@ -2160,7 +2175,7 @@ } }, "ServiceUnavailable": { - "description": "A required service is temporarily unavailable. The condition is transient, so the response normally carries `Retry-After` with the number of seconds to wait; treat that value as a floor and add jitter before retrying. One case deliberately omits the header: when `error.details.code` is `ASYNC_ENQUEUE_AMBIGUOUS`, the run may already have started, so retrying could start and bill a second run. Reconcile against the returned run id instead of retrying.", + "description": "A required service is temporarily unavailable. `Retry-After` carries the seconds to wait; treat it as a floor and add jitter. The header is omitted when `error.details.code` is `ASYNC_ENQUEUE_AMBIGUOUS`, because the run may already have started — reconcile against the returned run id instead of retrying.", "headers": { "Retry-After": { "$ref": "#/components/headers/Retry-After" @@ -2191,7 +2206,7 @@ "description": "Human-readable explanation of the error." }, "details": { - "description": "Optional structured error details." + "description": "Structured error details. On a `403` whose cause a caller can act on, this carries a `code` from a closed set:\n- `INSUFFICIENT_WORKSPACE_ROLE` — The caller has access to the workspace but its role is below the one this operation requires.\n- `PERSONAL_API_KEYS_DISABLED` — The workspace's organization does not allow personal API keys. Use a workspace API key.\n- `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` — This operation is not available to a workspace-scoped API key. Use a personal API key.\n- `PRINCIPAL_KIND_NOT_PERMITTED` — This operation does not accept the caller’s kind of API key.\n- `ORGANIZATION_MEMBERSHIP_REQUIRED` — The caller is not a member of the organization it named.\n- `ORGANIZATION_ADMIN_REQUIRED` — The caller is a member of the organization but not an admin or owner.\n- `ENTERPRISE_PLAN_REQUIRED` — The organization has no active enterprise subscription.\n- `AUDIT_LOGS_DISABLED` — Audit logging is not enabled for this deployment.\n- `SKILL_EDITOR_ACCESS_REQUIRED` — The caller can write in the workspace but is not an editor of this skill.\n- `SECRET_ADMIN_ACCESS_REQUIRED` — The caller can write in the workspace but is not an admin of this secret. Ask a workspace admin, or someone holding admin on the secret, to grant access or set the value.\n- `WORKSPACE_RESOURCE_LIMIT_REACHED` — The workspace already holds the maximum number of resources of this kind. Delete one, or contact Sim to raise the limit; the message names the ceiling.\n- `PUBLIC_SHARING_NOT_ALLOWED` — The workspace's organization does not permit sharing this resource publicly. An organization admin controls the policy.\n- `MCP_SERVER_URL_NOT_ALLOWED` — The supplied MCP server URL is outside the allowed domains or resolves to an internal address." } }, "required": ["code", "message"], @@ -2212,6 +2227,12 @@ } ] }, + "FolderPathInput": { + "title": "Folder path input", + "description": "Folder path. A missing leading slash is normalized before validation. Segments are percent-encoded, so a folder shown as \"New folder\" is `/New%20folder`: everything outside `A-Z a-z 0-9 - _ . ~` is escaped as uppercase hex, and only that exact encoding is accepted. A trailing slash, an empty segment, and a literal `.` or `..` segment are rejected. At most 64 segments and 4096 encoded bytes.", + "maxLength": 4096, + "type": "string" + }, "V2KnowledgeBase": { "type": "object", "properties": { @@ -2289,7 +2310,9 @@ }, "folderPath": { "type": "string", + "title": "Folder path", "description": "Canonical containing-folder path; `/` is the workspace root.", + "maxLength": 4096, "examples": ["/Product"] } }, @@ -2388,7 +2411,7 @@ "type": "null" } ], - "description": "Opaque cursor for the next page: send it back as `cursor` to continue, and stop when it is null. Most v2 lists page, so null means the last page was reached. A few are full-set lists that return their whole bounded result in one response and therefore always report null; those say so in the operation description. Either way, null means there is nothing further to fetch — never construct a cursor yourself." + "description": "Opaque cursor for the next page. Send it back as `cursor`; `null` means there is nothing further to fetch. Never construct one yourself." } }, "required": ["data", "nextCursor"], @@ -2472,7 +2495,7 @@ }, "folderPath": { "description": "Containing folder path; omission creates the knowledge base at the root.", - "type": "string" + "$ref": "#/components/schemas/FolderPathInput" } }, "required": ["workspaceId", "name"], @@ -2507,7 +2530,7 @@ }, "folderPath": { "description": "New containing-folder path.", - "type": "string" + "$ref": "#/components/schemas/FolderPathInput" } }, "required": ["workspaceId"], @@ -2672,9 +2695,22 @@ "maximum": 9007199254740991, "description": "Number of results returned.", "examples": [4] + }, + "rerankerStatus": { + "type": "string", + "enum": ["not_requested", "skipped", "unavailable", "applied"], + "description": "What the reranker did on this search. `applied` means it ordered the results, which carry `rerankerScore`. `unavailable` means it was attempted but could not complete, so results are in vector order with no `rerankerScore` — the search still succeeded, and is worth retrying. `skipped` means there was nothing to rank. `not_requested` means `rerankerEnabled` was absent or false.", + "examples": ["applied"] } }, - "required": ["results", "query", "knowledgeBaseIds", "topK", "totalResults"], + "required": [ + "results", + "query", + "knowledgeBaseIds", + "topK", + "totalResults", + "rerankerStatus" + ], "additionalProperties": false, "title": "Knowledge search data", "description": "Results and execution context for a knowledge search." @@ -2782,7 +2818,7 @@ "maximum": 100 }, "tagFilters": { - "description": "Structured tag filters. Supported across multiple knowledge bases, but each filtered tag must resolve to the same slot and field type in every knowledge base selected; a tag missing from one of them, or defined inconsistently across them, is rejected and those knowledge bases must be searched separately. A tag name defined in none of the selected knowledge bases is rejected, never ignored; list the available names with GET /api/v2/knowledge/{id}/tags.", + "description": "Structured tag filters. Each filtered tag must resolve to the same slot and field type in every knowledge base selected; one missing from any of them, or defined inconsistently across them, is rejected rather than ignored, and those knowledge bases must be searched separately. List the available names with `GET /api/v2/knowledge/{id}/tags`.", "type": "array", "items": { "$ref": "#/components/schemas/V2KnowledgeSearchTagFilter" @@ -2802,11 +2838,12 @@ ] }, "rerankerEnabled": { - "description": "Re-order retrieved chunks with a reranking model before truncating to `topK`. Ignored for a tag-only search, which has no query to rank against. Reranking is billed as an additional search unit.", + "description": "Re-order retrieved chunks with a reranking model before truncating to `topK`. Ignored for a tag-only search, and billed as an additional search unit. Reranking is best-effort — a provider failure falls back to vector ordering, so check `rerankerStatus` on the response.", "type": "boolean" }, "rerankerModel": { - "description": "Reranking model to use; required for reranking to run.", + "default": "rerank-v4.0-fast", + "description": "Reranking model to use when `rerankerEnabled` is true. Defaults to `rerank-v4.0-fast`.", "type": "string", "enum": ["rerank-v4.0-pro", "rerank-v4.0-fast", "rerank-v3.5"] }, @@ -2818,6 +2855,7 @@ } }, "required": ["workspaceId", "knowledgeBaseIds"], + "additionalProperties": false, "title": "Search knowledge request", "description": "Knowledge bases, query, result limit, retrieval mode, and optional tag filters." }, @@ -2864,7 +2902,7 @@ "type": "null" } ], - "description": "Opaque cursor for the next page: send it back as `cursor` to continue, and stop when it is null. Most v2 lists page, so null means the last page was reached. A few are full-set lists that return their whole bounded result in one response and therefore always report null; those say so in the operation description. Either way, null means there is nothing further to fetch — never construct a cursor yourself." + "description": "Always `null` — this list has no `cursor` or `limit` param and returns its whole bounded set in one page. Present so the list can gain pages later without a shape change." } }, "required": ["data", "nextCursor"], @@ -3007,7 +3045,7 @@ "type": "null" } ], - "description": "Opaque cursor for the next page: send it back as `cursor` to continue, and stop when it is null. Most v2 lists page, so null means the last page was reached. A few are full-set lists that return their whole bounded result in one response and therefore always report null; those say so in the operation description. Either way, null means there is nothing further to fetch — never construct a cursor yourself." + "description": "Opaque cursor for the next page. Send it back as `cursor`; `null` means there is nothing further to fetch. Never construct one yourself." } }, "required": ["data", "nextCursor"], @@ -3324,7 +3362,7 @@ "url": { "type": "string", "format": "uri", - "description": "Signed URL to which the file bytes are uploaded." + "description": "Signed URL to which the file bytes are uploaded. Send the bytes with `PUT` to this URL, including exactly the headers in `headers` and nothing that alters the body. Success is `204` with an empty body. A failure is the same `{ \"error\": { \"code\", \"message\" } }` envelope as every other v2 response: `400` when the body does not match the size or content type the session was created for, `403` when the token is invalid, expired, or belongs to another session, and `409` when the session is no longer accepting bytes. The URL is signed and self-describing — construct it from this field only, never by hand." }, "headers": { "type": "object", @@ -3527,7 +3565,7 @@ "url": { "type": "string", "format": "uri", - "description": "Signed URL for this upload part." + "description": "Signed URL for this upload part. Send the bytes with `PUT` to this URL, including exactly the headers in `headers` and nothing that alters the body. Success is `204` with an empty body. A failure is the same `{ \"error\": { \"code\", \"message\" } }` envelope as every other v2 response: `400` when the body does not match the size or content type the session was created for, `403` when the token is invalid, expired, or belongs to another session, and `409` when the session is no longer accepting bytes. The URL is signed and self-describing — construct it from this field only, never by hand." }, "headers": { "type": "object", @@ -3955,7 +3993,7 @@ "type": "boolean" }, "retryProcessing": { - "description": "Requeue the document for processing. Send it alone: no other field may accompany it.", + "description": "Requeue a failed or stuck document for processing. Send it alone — no other field may accompany it — and it answers with a queue acknowledgement rather than the document.", "type": "boolean", "const": true } @@ -3981,11 +4019,15 @@ }, "path": { "type": "string", - "description": "Canonical folder path used as the public folder identifier." + "title": "Non-root folder path", + "description": "Canonical folder path used as the public folder identifier.", + "maxLength": 4096 }, "parentPath": { "type": "string", - "description": "Canonical parent path; `/` is the root." + "title": "Folder path", + "description": "Canonical parent path; `/` is the root.", + "maxLength": 4096 }, "createdAt": { "type": "string", @@ -4022,13 +4064,13 @@ "type": "null" } ], - "description": "Opaque cursor for the next page: send it back as `cursor` to continue, and stop when it is null. Most v2 lists page, so null means the last page was reached. A few are full-set lists that return their whole bounded result in one response and therefore always report null; those say so in the operation description. Either way, null means there is nothing further to fetch — never construct a cursor yourself." + "description": "Always `null` — this list has no `cursor` or `limit` param and returns its whole bounded set in one page. Present so the list can gain pages later without a shape change." } }, "required": ["data", "nextCursor"], "additionalProperties": false, "title": "Knowledge folder list response", - "description": "A cursor-paginated page of knowledge-base folders." + "description": "The whole bounded set of knowledge-base folders, in one page." }, "V2KnowledgeFolderResponse": { "type": "object", @@ -4043,6 +4085,12 @@ "title": "Knowledge folder response", "description": "A single knowledge-base folder." }, + "NonRootFolderPathInput": { + "title": "Non-root folder path input", + "description": "Non-root folder path. A missing leading slash is normalized before validation. Segments are percent-encoded, so a folder shown as \"New folder\" is `/New%20folder`: everything outside `A-Z a-z 0-9 - _ . ~` is escaped as uppercase hex, and only that exact encoding is accepted. A trailing slash, an empty segment, and a literal `.` or `..` segment are rejected. At most 64 segments and 4096 encoded bytes.", + "maxLength": 4096, + "type": "string" + }, "CreateKnowledgeFolderRequest": { "type": "object", "properties": { @@ -4053,7 +4101,7 @@ }, "path": { "description": "Path of the folder to create.", - "type": "string" + "$ref": "#/components/schemas/NonRootFolderPathInput" } }, "required": ["workspaceId", "path"], @@ -4071,11 +4119,11 @@ }, "path": { "description": "Current folder path.", - "type": "string" + "$ref": "#/components/schemas/NonRootFolderPathInput" }, "destinationPath": { "description": "New full path for the folder and its descendants.", - "type": "string" + "$ref": "#/components/schemas/NonRootFolderPathInput" } }, "required": ["workspaceId", "path", "destinationPath"], @@ -4088,7 +4136,9 @@ "properties": { "path": { "type": "string", - "description": "Canonical path of the deleted folder." + "title": "Folder path", + "description": "Canonical path of the deleted folder.", + "maxLength": 4096 }, "deleted": { "type": "boolean", diff --git a/apps/docs/openapi-v2-logs.json b/apps/docs/openapi-v2-logs.json index 9ca4c9cb168..eaf97c33c96 100644 --- a/apps/docs/openapi-v2-logs.json +++ b/apps/docs/openapi-v2-logs.json @@ -36,7 +36,7 @@ "get": { "operationId": "listLogs", "summary": "List Logs", - "description": "List workflow execution logs for a workspace with filters, selectable detail, and opaque cursor pagination. This list predates the shared sort convention: it has no `sortBy` (the sort column is fixed to execution start time) and spells the direction `order` rather than `sortOrder`. Trace spans are stored separately from the log row and are pruned on their own retention schedule: `includeTraceSpans=true` on a run whose stored spans have aged out returns `traceSpans: []` rather than an error, so an empty array does not mean the run recorded no spans.", + "description": "List workflow execution logs for a workspace with filters, selectable detail, and opaque cursor pagination. Runs are hard-deleted once they pass the payer's log retention window, so an older run is simply absent rather than reported as removed. The window is 30 days from run start on the free plan, unbounded on Pro and Team, and set per organization on Enterprise with an optional per-workspace override.", "tags": ["Logs"], "parameters": [ { @@ -54,20 +54,20 @@ "name": "workflowIds", "in": "query", "required": false, - "description": "Comma-separated workflow identifiers to include.", + "description": "Comma-separated workflow identifiers to include. An empty entry is rejected.", "schema": { "type": "string", - "description": "Comma-separated workflow identifiers to include." + "description": "Comma-separated workflow identifiers to include. An empty entry is rejected." } }, { "name": "triggers", "in": "query", "required": false, - "description": "Comma-separated trigger types to include.", + "description": "Comma-separated trigger types to include. An empty entry is rejected. The literal value `all` is a sentinel that disables this filter entirely, so a list containing it returns runs of every trigger type; no real trigger type is named `all`.", "schema": { "type": "string", - "description": "Comma-separated trigger types to include." + "description": "Comma-separated trigger types to include. An empty entry is rejected. The literal value `all` is a sentinel that disables this filter entirely, so a list containing it returns runs of every trigger type; no real trigger type is named `all`." } }, { @@ -85,44 +85,48 @@ "name": "startDate", "in": "query", "required": false, - "description": "Only include runs started at or after this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected.", + "description": "Only include runs started at or after this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected, as is year `0000`, which names no storable instant.", "schema": { "type": "string", "format": "date-time", "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - "description": "Only include runs started at or after this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected." + "description": "Only include runs started at or after this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected, as is year `0000`, which names no storable instant." } }, { "name": "endDate", "in": "query", "required": false, - "description": "Only include runs started at or before this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected.", + "description": "Only include runs started at or before this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected, as is year `0000`, which names no storable instant.", "schema": { "type": "string", "format": "date-time", "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - "description": "Only include runs started at or before this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected." + "description": "Only include runs started at or before this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected, as is year `0000`, which names no storable instant." } }, { "name": "minDurationMs", "in": "query", "required": false, - "description": "Minimum total execution duration in milliseconds.", + "description": "Minimum total execution duration in milliseconds. Whole milliseconds from 0 to 2147483647; the stored duration is a 32-bit integer, so a fractional or out-of-range bound is rejected.", "schema": { - "type": "number", - "description": "Minimum total execution duration in milliseconds." + "type": "integer", + "minimum": 0, + "maximum": 2147483647, + "description": "Minimum total execution duration in milliseconds. Whole milliseconds from 0 to 2147483647; the stored duration is a 32-bit integer, so a fractional or out-of-range bound is rejected." } }, { "name": "maxDurationMs", "in": "query", "required": false, - "description": "Maximum total execution duration in milliseconds.", + "description": "Maximum total execution duration in milliseconds. Whole milliseconds from 0 to 2147483647; the stored duration is a 32-bit integer, so a fractional or out-of-range bound is rejected.", "schema": { - "type": "number", - "description": "Maximum total execution duration in milliseconds." + "type": "integer", + "minimum": 0, + "maximum": 2147483647, + "description": "Maximum total execution duration in milliseconds. Whole milliseconds from 0 to 2147483647; the stored duration is a 32-bit integer, so a fractional or out-of-range bound is rejected." } }, { @@ -159,21 +163,21 @@ "name": "details", "in": "query", "required": false, - "description": "Response detail level.", + "description": "Response detail level. `full` adds the `workflow` summary to every item. `includeTraceSpans=true` and `includeFinalOutput=true` each imply `full`, so either one adds `workflow` even when `details=basic` is sent explicitly.", "schema": { "default": "basic", "type": "string", "enum": ["basic", "full"], - "description": "Response detail level." + "description": "Response detail level. `full` adds the `workflow` summary to every item. `includeTraceSpans=true` and `includeFinalOutput=true` each imply `full`, so either one adds `workflow` even when `details=basic` is sent explicitly." } }, { "name": "includeTraceSpans", "in": "query", "required": false, - "description": "Whether to include block-level trace spans.", + "description": "Whether to include block-level trace spans. Implies `details=full`. Spans are pruned on their own retention schedule, so a run whose spans have aged out returns `traceSpans: []` rather than an error.", "schema": { - "description": "Whether to include block-level trace spans.", + "description": "Whether to include block-level trace spans. Implies `details=full`. Spans are pruned on their own retention schedule, so a run whose spans have aged out returns `traceSpans: []` rather than an error.", "type": "boolean" } }, @@ -181,9 +185,9 @@ "name": "includeFinalOutput", "in": "query", "required": false, - "description": "Whether to include the final workflow output.", + "description": "Whether to include the final workflow output. Implies `details=full`, so the `workflow` summary is present regardless of what `details` is set to.", "schema": { - "description": "Whether to include the final workflow output.", + "description": "Whether to include the final workflow output. Implies `details=full`, so the `workflow` summary is present regardless of what `details` is set to.", "type": "boolean" } }, @@ -195,8 +199,6 @@ "schema": { "description": "Maximum log entries per page. Values outside 1–1000 are truncated and clamped into that range rather than rejected. Defaults to 100.", "type": "integer", - "minimum": 1, - "maximum": 1000, "default": 100 } }, @@ -204,9 +206,9 @@ "name": "cursor", "in": "query", "required": false, - "description": "Opaque cursor returned by the previous page.", + "description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.", "schema": { - "description": "Opaque cursor returned by the previous page.", + "description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.", "type": "string", "minLength": 1 } @@ -215,12 +217,12 @@ "name": "order", "in": "query", "required": false, - "description": "Sort direction by execution start time. This operation deviates from the v2 `sortBy` + `sortOrder` convention: logs are sortable only by start time, so the direction is carried by this single `order` param and `sortBy`/`sortOrder` are not accepted.", + "description": "Sort direction by execution start time. This list is sortable only by execution start time, so it takes `order` in place of `sortBy`/`sortOrder`, which it rejects.", "schema": { "default": "desc", + "description": "Sort direction by execution start time. This list is sortable only by execution start time, so it takes `order` in place of `sortBy`/`sortOrder`, which it rejects.", "type": "string", - "enum": ["desc", "asc"], - "description": "Sort direction by execution start time. This operation deviates from the v2 `sortBy` + `sortOrder` convention: logs are sortable only by start time, so the direction is carried by this single `order` param and `sortBy`/`sortOrder` are not accepted." + "enum": ["asc", "desc"] } }, { @@ -231,6 +233,8 @@ "schema": { "type": "string", "minLength": 1, + "maxLength": 128, + "pattern": "^[A-Za-z0-9._:-]+$", "description": "Exact run identifier to match." } }, @@ -238,10 +242,10 @@ "name": "folderPaths", "in": "query", "required": false, - "description": "Comma-separated workflow folder paths to include.", + "description": "Comma-separated workflow folder paths to include. A path that names no folder narrows the result to nothing, so the response is an empty page rather than an error.", "schema": { "type": "string", - "description": "Comma-separated workflow folder paths to include." + "description": "Comma-separated workflow folder paths to include. A path that names no folder narrows the result to nothing, so the response is an empty page rather than an error." } } ], @@ -295,18 +299,20 @@ "get": { "operationId": "getLog", "summary": "Get Log", - "description": "Retrieve the diagnostic representation of a run, including its workflow snapshot, trace spans, final output, and cost. The returned `workflowState` snapshot has credential values redacted: OAuth credential references and secret (`password`) sub-block values are null, while `{{VAR}}` environment-variable references are preserved so consecutive snapshots stay diffable. Trace spans are stored separately from the log row and are pruned on their own retention schedule: a run whose stored spans have aged out returns `traceSpans: []` rather than an error, so an empty array does not mean the run recorded no spans.", + "description": "Retrieve the diagnostic representation of a run, including its workflow snapshot, trace spans, final output, and cost. Trace spans are pruned on their own retention schedule, so an empty `traceSpans` array does not mean the run recorded none.", "tags": ["Logs"], "parameters": [ { "name": "runId", "in": "path", "required": true, - "description": "The unique run identifier shared by lifecycle and diagnostic resources.", + "description": "Unique workflow run identifier.", "schema": { "type": "string", "minLength": 1, - "description": "The unique run identifier shared by lifecycle and diagnostic resources." + "maxLength": 128, + "pattern": "^[A-Za-z0-9._:-]+$", + "description": "Unique workflow run identifier." } } ], @@ -363,7 +369,7 @@ "type": "apiKey", "in": "header", "name": "X-API-Key", - "description": "Your Sim API key, personal or workspace-scoped. Generate one under Settings > API Keys. Operations that reject workspace keys say so in their own description." + "description": "Your Sim API key, personal or workspace-scoped. Generate one under Settings, then API Keys. Operations that reject workspace keys say so in their own description." } }, "headers": { @@ -398,13 +404,13 @@ } }, "Retry-After": { - "description": "Seconds to wait before retrying. Sent on `429` (derived from the caller rate-limit window) and on `503` (a fixed transient-failure floor). Add jitter rather than retrying at exactly this offset.", + "description": "Seconds to wait before retrying, sent on `429` and `503`. Add jitter rather than retrying at exactly this offset.", "schema": { "type": "integer", "minimum": 0, "maximum": 9007199254740991, "title": "Retry after", - "description": "Seconds to wait before retrying. Sent on `429` (derived from the caller rate-limit window) and on `503` (a fixed transient-failure floor). Add jitter rather than retrying at exactly this offset." + "description": "Seconds to wait before retrying, sent on `429` and `503`. Add jitter rather than retrying at exactly this offset." } }, "X-Run-Id": { @@ -419,7 +425,7 @@ }, "responses": { "BadRequest": { - "description": "The request is invalid.", + "description": "The request is invalid. This includes a query parameter sent with no value (`?limit=`, `?search=`), which is rejected rather than read as zero, empty, or the parameter default — omit the parameter instead.", "content": { "application/json": { "schema": { @@ -449,7 +455,7 @@ } }, "Forbidden": { - "description": "The caller lacks the rights this operation requires. Where the cause is one a caller can act on, `error.details.code` names it, drawn from a closed set:\n- `INSUFFICIENT_WORKSPACE_ROLE` — The caller has access to the workspace but its role is below the one this operation requires.\n- `PERSONAL_API_KEYS_DISABLED` — The workspace's organization does not allow personal API keys. Use a workspace API key.\n- `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` — This operation is not available to a workspace-scoped API key. Use a personal API key.\n- `PRINCIPAL_KIND_NOT_PERMITTED` — This operation does not accept the caller’s kind of API key.\n- `ORGANIZATION_MEMBERSHIP_REQUIRED` — The caller is not a member of the organization it named.\n- `ORGANIZATION_ADMIN_REQUIRED` — The caller is a member of the organization but not an admin or owner.\n- `ENTERPRISE_PLAN_REQUIRED` — The organization has no active enterprise subscription.\n- `AUDIT_LOGS_DISABLED` — Audit logging is not enabled for this deployment.\n- `SKILL_EDITOR_ACCESS_REQUIRED` — The caller can write in the workspace but is not an editor of this skill.\n- `MCP_SERVER_URL_NOT_ALLOWED` — The supplied MCP server URL is outside the allowed domains or resolves to an internal address.\nA resource in a workspace the caller cannot reach at all answers `404`, not `403`, so absence and denial are indistinguishable to a caller who was never entitled to tell them apart.", + "description": "The caller lacks the rights this operation requires. When the cause is one a caller can act on, `error.details.code` names it. A resource in a workspace the caller cannot reach at all answers `404` instead, so absence and denial are indistinguishable.", "content": { "application/json": { "schema": { @@ -479,7 +485,7 @@ } }, "RunIdConflict": { - "description": "The run cannot be started. Two causes share this status, distinguished by `error.details.code`: `RUN_ID_CONFLICT` when the supplied `X-Run-Id` is already associated with a different request, and `CALL_CHAIN_DEPTH_EXCEEDED` when the incoming `X-Sim-Via` chain has already reached the maximum workflow-to-workflow call depth.", + "description": "The run cannot be started, for one of two causes named by `error.details.code`: `RUN_ID_CONFLICT` when the supplied `X-Run-Id` is already claimed, and `CALL_CHAIN_DEPTH_EXCEEDED` when the incoming `X-Sim-Via` chain has reached the maximum workflow-to-workflow call depth.", "headers": { "X-Run-Id": { "$ref": "#/components/headers/X-Run-Id" @@ -493,18 +499,8 @@ } } }, - "Gone": { - "description": "The requested generated resource has expired.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2Error" - } - } - } - }, "PayloadTooLarge": { - "description": "The request, or a resource collection it must materialize, exceeds the allowed size. Besides an oversized request body, this covers a generated artifact that renders past the download ceiling and a workspace folder tree too large to load in full.", + "description": "The request, or a resource collection it must materialize, exceeds the allowed size: an oversized request body, a generated artifact past the download ceiling, or a workspace folder tree too large to load in full.", "content": { "application/json": { "schema": { @@ -549,7 +545,7 @@ } }, "ClientClosedRequest": { - "description": "The client closed the connection before the response was produced.", + "description": "The client closed the connection before the response was produced. An abort can leave the run going, so `error.details.runId` carries the run id — reconcile against the runs resource rather than starting another run.", "content": { "application/json": { "schema": { @@ -569,7 +565,7 @@ } }, "ServiceUnavailable": { - "description": "A required service is temporarily unavailable. The condition is transient, so the response normally carries `Retry-After` with the number of seconds to wait; treat that value as a floor and add jitter before retrying. One case deliberately omits the header: when `error.details.code` is `ASYNC_ENQUEUE_AMBIGUOUS`, the run may already have started, so retrying could start and bill a second run. Reconcile against the returned run id instead of retrying.", + "description": "A required service is temporarily unavailable. `Retry-After` carries the seconds to wait; treat it as a floor and add jitter. The header is omitted when `error.details.code` is `ASYNC_ENQUEUE_AMBIGUOUS`, because the run may already have started — reconcile against the returned run id instead of retrying.", "headers": { "Retry-After": { "$ref": "#/components/headers/Retry-After" @@ -600,7 +596,7 @@ "description": "Human-readable explanation of the error." }, "details": { - "description": "Optional structured error details." + "description": "Structured error details. On a `403` whose cause a caller can act on, this carries a `code` from a closed set:\n- `INSUFFICIENT_WORKSPACE_ROLE` — The caller has access to the workspace but its role is below the one this operation requires.\n- `PERSONAL_API_KEYS_DISABLED` — The workspace's organization does not allow personal API keys. Use a workspace API key.\n- `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` — This operation is not available to a workspace-scoped API key. Use a personal API key.\n- `PRINCIPAL_KIND_NOT_PERMITTED` — This operation does not accept the caller’s kind of API key.\n- `ORGANIZATION_MEMBERSHIP_REQUIRED` — The caller is not a member of the organization it named.\n- `ORGANIZATION_ADMIN_REQUIRED` — The caller is a member of the organization but not an admin or owner.\n- `ENTERPRISE_PLAN_REQUIRED` — The organization has no active enterprise subscription.\n- `AUDIT_LOGS_DISABLED` — Audit logging is not enabled for this deployment.\n- `SKILL_EDITOR_ACCESS_REQUIRED` — The caller can write in the workspace but is not an editor of this skill.\n- `SECRET_ADMIN_ACCESS_REQUIRED` — The caller can write in the workspace but is not an admin of this secret. Ask a workspace admin, or someone holding admin on the secret, to grant access or set the value.\n- `WORKSPACE_RESOURCE_LIMIT_REACHED` — The workspace already holds the maximum number of resources of this kind. Delete one, or contact Sim to raise the limit; the message names the ceiling.\n- `PUBLIC_SHARING_NOT_ALLOWED` — The workspace's organization does not permit sharing this resource publicly. An organization admin controls the policy.\n- `MCP_SERVER_URL_NOT_ALLOWED` — The supplied MCP server URL is outside the allowed domains or resolves to an internal address." } }, "required": ["code", "message"], @@ -661,7 +657,7 @@ "failed", "cancelled" ], - "description": "Current execution status, reported as persisted. `redacting` is transient while run output is scrubbed. `paused` is reported only when a resume attempt did not run to completion and the run is waiting to be resumed again. **This differs from the run resources for the same run:** `GET /api/v2/workflows/{id}/runs` and `GET /api/v2/workflows/{id}/runs/{runId}` additionally report `paused` for a run held at a human-in-the-loop pause point, which this field reports as `pending`. Use the run resources when the pause state matters." + "description": "Current execution status, reported as persisted. `redacting` is transient while run output is scrubbed. `paused` is reported only when a resume attempt did not complete; a run held at a human-in-the-loop pause point reads `pending` here, and `paused` on the workflow run resources. Use those when the pause state matters." }, "level": { "type": "string", @@ -987,7 +983,7 @@ "type": "null" } ], - "description": "Opaque cursor for the next page: send it back as `cursor` to continue, and stop when it is null. Most v2 lists page, so null means the last page was reached. A few are full-set lists that return their whole bounded result in one response and therefore always report null; those say so in the operation description. Either way, null means there is nothing further to fetch — never construct a cursor yourself." + "description": "Opaque cursor for the next page. Send it back as `cursor`; `null` means there is nothing further to fetch. Never construct one yourself." } }, "required": ["data", "nextCursor"], @@ -1057,7 +1053,7 @@ "failed", "cancelled" ], - "description": "Current execution status, reported as persisted. `redacting` is transient while run output is scrubbed. `paused` is reported only when a resume attempt did not run to completion and the run is waiting to be resumed again. **This differs from the run resources for the same run:** `GET /api/v2/workflows/{id}/runs` and `GET /api/v2/workflows/{id}/runs/{runId}` additionally report `paused` for a run held at a human-in-the-loop pause point, which this field reports as `pending`. Use the run resources when the pause state matters." + "description": "Current execution status, reported as persisted. `redacting` is transient while run output is scrubbed. `paused` is reported only when a resume attempt did not complete; a run held at a human-in-the-loop pause point reads `pending` here, and `paused` on the workflow run resources. Use those when the pause state matters." }, "level": { "type": "string", @@ -1144,13 +1140,15 @@ "anyOf": [ { "type": "string", - "description": "Canonical slash-prefixed folder path. `/` is the workspace root." + "title": "Folder path", + "description": "Canonical slash-prefixed folder path. `/` is the workspace root. Segments are percent-encoded, so a folder shown as \"New folder\" is `/New%20folder`: everything outside `A-Z a-z 0-9 - _ . ~` is escaped as uppercase hex, and only that exact encoding is accepted. A trailing slash, an empty segment, and a literal `.` or `..` segment are rejected. At most 64 segments and 4096 encoded bytes.", + "maxLength": 4096 }, { "type": "null" } ], - "description": "Workflow folder path, or null when unavailable." + "description": "Canonical folder path of the workflow, in the same form `folderPaths` accepts as a filter: `/` for a workflow at the workspace root. Null only when the path cannot be resolved — the folder has been deleted, or the workflow itself no longer exists." }, "ownerEmail": { "anyOf": [ @@ -1234,7 +1232,7 @@ "type": "null" } ], - "description": "Workflow graph snapshot captured for the run, with credential values redacted: `oauth-input`, `password: true`, and table sub-block values are null; sensitive nested tool parameters and every parameter without authoritative codec metadata are null; and `{{VAR}}` references in non-opaque fields are preserved. Null when no snapshot is retained." + "description": "Workflow graph snapshot captured for the run, or null when none is retained. Credential-bearing values are redacted to null: `oauth-input`, `password: true`, table sub-block values, sensitive nested tool parameters, and any parameter without authoritative codec metadata. `{{VAR}}` references in non-opaque fields are preserved." }, "traceSpans": { "type": "array", diff --git a/apps/docs/openapi-v2-resources.json b/apps/docs/openapi-v2-resources.json index 2f03ec3e899..6b7fe8bd62b 100644 --- a/apps/docs/openapi-v2-resources.json +++ b/apps/docs/openapi-v2-resources.json @@ -152,9 +152,9 @@ "name": "cursor", "in": "query", "required": false, - "description": "Opaque cursor returned by the previous page.", + "description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.", "schema": { - "description": "Opaque cursor returned by the previous page.", + "description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.", "type": "string", "minLength": 1 } @@ -210,7 +210,7 @@ "get": { "operationId": "listMcpServers", "summary": "List MCP Servers", - "description": "List MCP servers registered in a workspace. Request-header values and OAuth client secrets are never returned. Nothing caps how many servers a workspace registers, so this list is paginated: paginate with `limit` and `cursor`, stopping when `nextCursor` is null. `connectionStatus`, `toolCount`, `lastError`, and `lastToolsRefresh` describe the most recent tool discovery and stay at their registration defaults until one runs — call `GET /api/v2/mcp-servers/{id}/tools` to run it.", + "description": "List MCP servers registered in a workspace. Request-header values and OAuth client secrets are never returned. The discovery fields stay at their registration defaults until `GET /api/v2/mcp-servers/{id}/tools` runs a discovery.", "tags": ["MCP Servers"], "parameters": [ { @@ -240,10 +240,10 @@ "name": "sortBy", "in": "query", "required": false, - "description": "Field used to sort the result.", + "description": "Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order.", "schema": { "default": "createdAt", - "description": "Field used to sort the result.", + "description": "Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order.", "type": "string", "enum": ["name", "createdAt", "updatedAt"] } @@ -277,9 +277,9 @@ "name": "cursor", "in": "query", "required": false, - "description": "Opaque cursor returned by the previous page.", + "description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.", "schema": { - "description": "Opaque cursor returned by the previous page.", + "description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.", "type": "string", "minLength": 1 } @@ -333,7 +333,7 @@ "post": { "operationId": "createMcpServer", "summary": "Create MCP Server", - "description": "Register an MCP server in a workspace. The endpoint URL determines server identity, must be absolute HTTP or HTTPS, and cannot contain environment-variable references. Header values and OAuth client secrets are write-only. `transport`, `timeout`, `retries`, and `enabled` are applied server-side when omitted; the effective values are in the response.", + "description": "Register an MCP server in a workspace. The endpoint URL is the server identity, so a URL already registered here is a `409` — reconfigure that server with `PATCH /api/v2/mcp-servers/{id}` instead. Registration never connects to the endpoint: the server comes back `disconnected` and stays unavailable until `GET /api/v2/mcp-servers/{id}/tools` succeeds.", "tags": ["MCP Servers"], "requestBody": { "required": true, @@ -383,6 +383,9 @@ "409": { "$ref": "#/components/responses/Conflict" }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -406,11 +409,11 @@ "name": "id", "in": "path", "required": true, - "description": "MCP server the operation acts on.", + "description": "Unique MCP server identifier.", "schema": { "type": "string", "minLength": 1, - "description": "MCP server the operation acts on." + "description": "Unique MCP server identifier." } }, { @@ -473,18 +476,18 @@ "patch": { "operationId": "updateMcpServer", "summary": "Update MCP Server", - "description": "Update the supplied MCP server fields. The URL is immutable because it determines server identity; delete and recreate the server to change endpoints. Two fields do not follow the omitted-fields-are-retained rule. `headers` is replaced wholesale rather than merged: sending it drops every stored header it does not repeat, and the only way to keep a header is to resend it. Changing `oauthClientId`, or sending `oauthClientSecret` as null or a new value, revokes the stored OAuth grant and forces reauthorization; switching away from OAuth authentication revokes it too.", + "description": "Update the supplied MCP server fields. Omitted fields are retained, except where a field says otherwise. Any change that invalidates authentication revokes the stored OAuth grant, resets `connectionStatus` to `disconnected`, and clears `lastConnected` and `lastError`, so the server must be rediscovered.", "tags": ["MCP Servers"], "parameters": [ { "name": "id", "in": "path", "required": true, - "description": "MCP server the operation acts on.", + "description": "Unique MCP server identifier.", "schema": { "type": "string", "minLength": 1, - "description": "MCP server the operation acts on." + "description": "Unique MCP server identifier." } } ], @@ -533,6 +536,9 @@ "404": { "$ref": "#/components/responses/NotFound" }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -554,11 +560,11 @@ "name": "id", "in": "path", "required": true, - "description": "MCP server the operation acts on.", + "description": "Unique MCP server identifier.", "schema": { "type": "string", "minLength": 1, - "description": "MCP server the operation acts on." + "description": "Unique MCP server identifier." } }, { @@ -623,18 +629,18 @@ "get": { "operationId": "listMcpServerTools", "summary": "List MCP Server Tools", - "description": "Connect to a registered MCP server and return the tools it exposes. Unlike most reads this one has side effects: it opens a live connection to the third-party server and writes `connectionStatus`, `toolCount`, `lastError`, and `lastToolsRefresh` on the server resource, so registering a server and then calling this completes onboarding without opening the Sim UI. Because the pass is not a safe read, a `HEAD` request is answered with an empty `200` without connecting or writing, so it reports only that the endpoint exists and the caller is authorized. Results are served from a short-lived per-workspace cache, so an uncached call reflects whichever workspace member last ran discovery; pass `refresh=true` to reconnect under your own credentials and pick up tools added since the last pass, at the cost of a live round trip to the server. The set is bounded by discovery itself — at most 1,000 tools and 5 MB of tool payload per server. The bounded set is returned in one page with `nextCursor` always null; there is no second page to fetch. An unreachable, slow, or cooling-down server is a `503`; a server whose stored OAuth grant no longer works is a `409` with `error.details.code` `MCP_SERVER_REAUTHORIZATION_REQUIRED`, meaning the registration is intact but a human must reauthorize it in Sim — your API key is fine and re-issuing it changes nothing. A workspace API key cannot call this operation and is rejected with `403`; use a personal API key. Discovery resolves the calling user's own OAuth credentials for the server, which a workspace key cannot supply — so a workspace key that can register a server cannot list its tools.", + "description": "Connect to a registered MCP server and return the tools it exposes. This read has side effects: it opens a live connection to the third-party server and writes `connectionStatus`, `toolCount`, `lastError`, and `lastToolsRefresh`. A `HEAD` skips the effect but is authorized exactly as the `GET` is, so it answers `400`, `401`, `403`, or `404` wherever the `GET` would and an empty `200` otherwise. Skipping the effect means skipping the read that produces the payload, so that `200` carries none of the response headers documented below — it answers whether the `GET` would be allowed, not what the `GET` would return. Discovery is bounded at 1,000 tools and 5 MB of tool payload per server. The bounded set is returned in one page; `nextCursor` is always null. An unreachable, slow, or cooling-down server is a `503`; a stored OAuth grant that no longer works is a `409` with `error.details.code` `MCP_SERVER_REAUTHORIZATION_REQUIRED`, which only a human reauthorizing in Sim can clear. A workspace API key is rejected with `403`; use a personal API key.", "tags": ["MCP Servers"], "parameters": [ { "name": "id", "in": "path", "required": true, - "description": "MCP server the operation acts on.", + "description": "Unique MCP server identifier.", "schema": { "type": "string", "minLength": 1, - "description": "MCP server the operation acts on." + "description": "Unique MCP server identifier." } }, { @@ -652,9 +658,9 @@ "name": "refresh", "in": "query", "required": false, - "description": "Bypass the cached tool list and reconnect to the server. Slower, and the only way to pick up a tool added since the last refresh.", + "description": "Bypass the short-lived per-workspace tool cache and reconnect under your own credentials. A cached result reflects whichever workspace member last ran discovery, so this is the only way to pick up a tool added since then; it costs a live round trip.", "schema": { - "description": "Bypass the cached tool list and reconnect to the server. Slower, and the only way to pick up a tool added since the last refresh.", + "description": "Bypass the short-lived per-workspace tool cache and reconnect under your own credentials. A cached result reflects whichever workspace member last ran discovery, so this is the only way to pick up a tool added since then; it costs a live round trip.", "type": "boolean" } } @@ -712,7 +718,7 @@ "get": { "operationId": "listSkills", "summary": "List Skills", - "description": "List workspace and built-in skills. Built-ins are marked read-only. The list omits skill bodies; fetch one skill to read its content. Paginate with `limit` and `cursor`, stopping when `nextCursor` is null.", + "description": "List workspace and built-in skills with opaque cursor pagination. Built-ins are marked read-only. The list omits skill bodies; fetch one skill to read its content.", "tags": ["Skills"], "parameters": [ { @@ -742,10 +748,10 @@ "name": "sortBy", "in": "query", "required": false, - "description": "Field used to sort the result.", + "description": "Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order.", "schema": { "default": "createdAt", - "description": "Field used to sort the result.", + "description": "Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order.", "type": "string", "enum": ["name", "createdAt", "updatedAt"] } @@ -779,9 +785,9 @@ "name": "cursor", "in": "query", "required": false, - "description": "Opaque cursor returned by the previous page.", + "description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.", "schema": { - "description": "Opaque cursor returned by the previous page.", + "description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.", "type": "string", "minLength": 1 } @@ -835,7 +841,7 @@ "post": { "operationId": "createSkill", "summary": "Create Skill", - "description": "Create one skill in a workspace. Its kebab-case name must be unique and cannot be reserved by a built-in skill. Note that a workspace API key may create a skill but may not later update or delete it.", + "description": "Create one skill in a workspace. Its kebab-case name must be unique and cannot be reserved by a built-in skill. A workspace API key is rejected with `403`; use a personal API key.", "tags": ["Skills"], "requestBody": { "required": true, @@ -885,6 +891,9 @@ "409": { "$ref": "#/components/responses/Conflict" }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -908,11 +917,11 @@ "name": "id", "in": "path", "required": true, - "description": "Skill to retrieve, update, or delete. Built-in skills use their name as the id.", + "description": "Unique skill identifier. A built-in skill is `builtin-` followed by its name, for example `builtin-research`.", "schema": { "type": "string", "minLength": 1, - "description": "Skill to retrieve, update, or delete. Built-in skills use their name as the id." + "description": "Unique skill identifier. A built-in skill is `builtin-` followed by its name, for example `builtin-research`." } }, { @@ -975,18 +984,18 @@ "patch": { "operationId": "updateSkill", "summary": "Update Skill", - "description": "Update the supplied fields on a workspace skill. Omitted fields retain their stored values. Built-in skills are read-only. A workspace API key cannot call this operation and is rejected with `403`; use a personal API key.", + "description": "Update the supplied fields on a workspace skill. Omitted fields retain their stored values. Built-in skills are read-only. A workspace API key is rejected with `403`; use a personal API key.", "tags": ["Skills"], "parameters": [ { "name": "id", "in": "path", "required": true, - "description": "Skill to retrieve, update, or delete. Built-in skills use their name as the id.", + "description": "Unique skill identifier. A built-in skill is `builtin-` followed by its name, for example `builtin-research`.", "schema": { "type": "string", "minLength": 1, - "description": "Skill to retrieve, update, or delete. Built-in skills use their name as the id." + "description": "Unique skill identifier. A built-in skill is `builtin-` followed by its name, for example `builtin-research`." } } ], @@ -1038,6 +1047,9 @@ "409": { "$ref": "#/components/responses/Conflict" }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -1052,18 +1064,18 @@ "delete": { "operationId": "deleteSkill", "summary": "Delete Skill", - "description": "Delete a workspace skill. Built-in skills are read-only and cannot be deleted. A workspace API key cannot call this operation and is rejected with `403`; use a personal API key.", + "description": "Delete a workspace skill. Built-in skills are read-only and cannot be deleted. A workspace API key is rejected with `403`; use a personal API key.", "tags": ["Skills"], "parameters": [ { "name": "id", "in": "path", "required": true, - "description": "Skill to retrieve, update, or delete. Built-in skills use their name as the id.", + "description": "Unique skill identifier. A built-in skill is `builtin-` followed by its name, for example `builtin-research`.", "schema": { "type": "string", "minLength": 1, - "description": "Skill to retrieve, update, or delete. Built-in skills use their name as the id." + "description": "Unique skill identifier. A built-in skill is `builtin-` followed by its name, for example `builtin-research`." } }, { @@ -1128,7 +1140,7 @@ "get": { "operationId": "listCustomTools", "summary": "List Custom Tools", - "description": "List code-backed custom tools defined in a workspace. Legacy personal tools are excluded. Paginate with `limit` and `cursor`, stopping when `nextCursor` is null.", + "description": "List code-backed custom tools defined in a workspace, with opaque cursor pagination. Legacy personal tools are excluded.", "tags": ["Custom Tools"], "parameters": [ { @@ -1195,9 +1207,9 @@ "name": "cursor", "in": "query", "required": false, - "description": "Opaque cursor returned by the previous page.", + "description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.", "schema": { - "description": "Opaque cursor returned by the previous page.", + "description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.", "type": "string", "minLength": 1 } @@ -1301,6 +1313,9 @@ "409": { "$ref": "#/components/responses/Conflict" }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -1324,11 +1339,11 @@ "name": "id", "in": "path", "required": true, - "description": "Custom tool to retrieve, update, or delete.", + "description": "Unique custom tool identifier.", "schema": { "type": "string", "minLength": 1, - "description": "Custom tool to retrieve, update, or delete." + "description": "Unique custom tool identifier." } }, { @@ -1398,11 +1413,11 @@ "name": "id", "in": "path", "required": true, - "description": "Custom tool to retrieve, update, or delete.", + "description": "Unique custom tool identifier.", "schema": { "type": "string", "minLength": 1, - "description": "Custom tool to retrieve, update, or delete." + "description": "Unique custom tool identifier." } } ], @@ -1454,6 +1469,9 @@ "409": { "$ref": "#/components/responses/Conflict" }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -1475,11 +1493,11 @@ "name": "id", "in": "path", "required": true, - "description": "Custom tool to retrieve, update, or delete.", + "description": "Unique custom tool identifier.", "schema": { "type": "string", "minLength": 1, - "description": "Custom tool to retrieve, update, or delete." + "description": "Unique custom tool identifier." } }, { @@ -1544,7 +1562,7 @@ "get": { "operationId": "listCredentials", "summary": "List Credentials", - "description": "List OAuth and service-account connections visible to the caller. Secret material is never returned. Credential mutations and single-resource reads are intentionally not exposed. Paginate with `limit` and `cursor`, stopping when `nextCursor` is null.", + "description": "List OAuth and service-account connections visible to the caller. Secret material is never returned. Credential mutations and single-resource reads are not exposed.", "tags": ["Credentials"], "parameters": [ { @@ -1633,9 +1651,9 @@ "name": "cursor", "in": "query", "required": false, - "description": "Opaque cursor returned by the previous page.", + "description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.", "schema": { - "description": "Opaque cursor returned by the previous page.", + "description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.", "type": "string", "minLength": 1 } @@ -1691,7 +1709,7 @@ "get": { "operationId": "listSecrets", "summary": "List Secrets", - "description": "List workspace and caller-owned personal secret metadata. Only names, scope, role, and timestamps are returned; secret values are never read or returned. Paginate with `limit` and `cursor`, stopping when `nextCursor` is null. A workspace API key cannot call this operation and is rejected with `403`; use a personal API key.", + "description": "List workspace and caller-owned personal secret metadata with opaque cursor pagination. Only names, scope, role, and timestamps are returned; secret values are never returned. A workspace API key is rejected with `403`; use a personal API key.", "tags": ["Secrets"], "parameters": [ { @@ -1732,10 +1750,10 @@ "name": "sortBy", "in": "query", "required": false, - "description": "Field used to sort the result.", + "description": "Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order.", "schema": { "default": "name", - "description": "Field used to sort the result.", + "description": "Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order.", "type": "string", "enum": ["name", "createdAt", "updatedAt"] } @@ -1769,9 +1787,9 @@ "name": "cursor", "in": "query", "required": false, - "description": "Opaque cursor returned by the previous page.", + "description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.", "schema": { - "description": "Opaque cursor returned by the previous page.", + "description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.", "type": "string", "minLength": 1 } @@ -1827,7 +1845,7 @@ "put": { "operationId": "setSecret", "summary": "Set Secret", - "description": "Create or replace a workspace or caller-owned personal secret. The value is encrypted at rest, is write-only, and is never included in the response. A workspace API key cannot call this operation and is rejected with `403`; use a personal API key.", + "description": "Create or replace a workspace or caller-owned personal secret. The value is encrypted at rest, is write-only, and is never included in the response. A workspace API key is rejected with `403`; use a personal API key.", "tags": ["Secrets"], "parameters": [ { @@ -1910,6 +1928,9 @@ "404": { "$ref": "#/components/responses/NotFound" }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -1924,7 +1945,7 @@ "delete": { "operationId": "deleteSecret", "summary": "Delete Secret", - "description": "Delete a workspace or caller-owned personal secret without reading or returning its stored value. A workspace API key cannot call this operation and is rejected with `403`; use a personal API key.", + "description": "Delete a workspace or caller-owned personal secret without reading or returning its stored value. A workspace API key is rejected with `403`; use a personal API key.", "tags": ["Secrets"], "parameters": [ { @@ -2016,7 +2037,7 @@ "type": "apiKey", "in": "header", "name": "X-API-Key", - "description": "Your Sim API key, personal or workspace-scoped. Generate one under Settings > API Keys. Operations that reject workspace keys say so in their own description." + "description": "Your Sim API key, personal or workspace-scoped. Generate one under Settings, then API Keys. Operations that reject workspace keys say so in their own description." } }, "headers": { @@ -2051,13 +2072,13 @@ } }, "Retry-After": { - "description": "Seconds to wait before retrying. Sent on `429` (derived from the caller rate-limit window) and on `503` (a fixed transient-failure floor). Add jitter rather than retrying at exactly this offset.", + "description": "Seconds to wait before retrying, sent on `429` and `503`. Add jitter rather than retrying at exactly this offset.", "schema": { "type": "integer", "minimum": 0, "maximum": 9007199254740991, "title": "Retry after", - "description": "Seconds to wait before retrying. Sent on `429` (derived from the caller rate-limit window) and on `503` (a fixed transient-failure floor). Add jitter rather than retrying at exactly this offset." + "description": "Seconds to wait before retrying, sent on `429` and `503`. Add jitter rather than retrying at exactly this offset." } }, "X-Run-Id": { @@ -2072,7 +2093,7 @@ }, "responses": { "BadRequest": { - "description": "The request is invalid.", + "description": "The request is invalid. This includes a query parameter sent with no value (`?limit=`, `?search=`), which is rejected rather than read as zero, empty, or the parameter default — omit the parameter instead.", "content": { "application/json": { "schema": { @@ -2102,7 +2123,7 @@ } }, "Forbidden": { - "description": "The caller lacks the rights this operation requires. Where the cause is one a caller can act on, `error.details.code` names it, drawn from a closed set:\n- `INSUFFICIENT_WORKSPACE_ROLE` — The caller has access to the workspace but its role is below the one this operation requires.\n- `PERSONAL_API_KEYS_DISABLED` — The workspace's organization does not allow personal API keys. Use a workspace API key.\n- `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` — This operation is not available to a workspace-scoped API key. Use a personal API key.\n- `PRINCIPAL_KIND_NOT_PERMITTED` — This operation does not accept the caller’s kind of API key.\n- `ORGANIZATION_MEMBERSHIP_REQUIRED` — The caller is not a member of the organization it named.\n- `ORGANIZATION_ADMIN_REQUIRED` — The caller is a member of the organization but not an admin or owner.\n- `ENTERPRISE_PLAN_REQUIRED` — The organization has no active enterprise subscription.\n- `AUDIT_LOGS_DISABLED` — Audit logging is not enabled for this deployment.\n- `SKILL_EDITOR_ACCESS_REQUIRED` — The caller can write in the workspace but is not an editor of this skill.\n- `MCP_SERVER_URL_NOT_ALLOWED` — The supplied MCP server URL is outside the allowed domains or resolves to an internal address.\nA resource in a workspace the caller cannot reach at all answers `404`, not `403`, so absence and denial are indistinguishable to a caller who was never entitled to tell them apart.", + "description": "The caller lacks the rights this operation requires. When the cause is one a caller can act on, `error.details.code` names it. A resource in a workspace the caller cannot reach at all answers `404` instead, so absence and denial are indistinguishable.", "content": { "application/json": { "schema": { @@ -2132,7 +2153,7 @@ } }, "RunIdConflict": { - "description": "The run cannot be started. Two causes share this status, distinguished by `error.details.code`: `RUN_ID_CONFLICT` when the supplied `X-Run-Id` is already associated with a different request, and `CALL_CHAIN_DEPTH_EXCEEDED` when the incoming `X-Sim-Via` chain has already reached the maximum workflow-to-workflow call depth.", + "description": "The run cannot be started, for one of two causes named by `error.details.code`: `RUN_ID_CONFLICT` when the supplied `X-Run-Id` is already claimed, and `CALL_CHAIN_DEPTH_EXCEEDED` when the incoming `X-Sim-Via` chain has reached the maximum workflow-to-workflow call depth.", "headers": { "X-Run-Id": { "$ref": "#/components/headers/X-Run-Id" @@ -2146,18 +2167,8 @@ } } }, - "Gone": { - "description": "The requested generated resource has expired.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2Error" - } - } - } - }, "PayloadTooLarge": { - "description": "The request, or a resource collection it must materialize, exceeds the allowed size. Besides an oversized request body, this covers a generated artifact that renders past the download ceiling and a workspace folder tree too large to load in full.", + "description": "The request, or a resource collection it must materialize, exceeds the allowed size: an oversized request body, a generated artifact past the download ceiling, or a workspace folder tree too large to load in full.", "content": { "application/json": { "schema": { @@ -2202,7 +2213,7 @@ } }, "ClientClosedRequest": { - "description": "The client closed the connection before the response was produced.", + "description": "The client closed the connection before the response was produced. An abort can leave the run going, so `error.details.runId` carries the run id — reconcile against the runs resource rather than starting another run.", "content": { "application/json": { "schema": { @@ -2222,7 +2233,7 @@ } }, "ServiceUnavailable": { - "description": "A required service is temporarily unavailable. The condition is transient, so the response normally carries `Retry-After` with the number of seconds to wait; treat that value as a floor and add jitter before retrying. One case deliberately omits the header: when `error.details.code` is `ASYNC_ENQUEUE_AMBIGUOUS`, the run may already have started, so retrying could start and bill a second run. Reconcile against the returned run id instead of retrying.", + "description": "A required service is temporarily unavailable. `Retry-After` carries the seconds to wait; treat it as a floor and add jitter. The header is omitted when `error.details.code` is `ASYNC_ENQUEUE_AMBIGUOUS`, because the run may already have started — reconcile against the returned run id instead of retrying.", "headers": { "Retry-After": { "$ref": "#/components/headers/Retry-After" @@ -2253,7 +2264,7 @@ "description": "Human-readable explanation of the error." }, "details": { - "description": "Optional structured error details." + "description": "Structured error details. On a `403` whose cause a caller can act on, this carries a `code` from a closed set:\n- `INSUFFICIENT_WORKSPACE_ROLE` — The caller has access to the workspace but its role is below the one this operation requires.\n- `PERSONAL_API_KEYS_DISABLED` — The workspace's organization does not allow personal API keys. Use a workspace API key.\n- `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` — This operation is not available to a workspace-scoped API key. Use a personal API key.\n- `PRINCIPAL_KIND_NOT_PERMITTED` — This operation does not accept the caller’s kind of API key.\n- `ORGANIZATION_MEMBERSHIP_REQUIRED` — The caller is not a member of the organization it named.\n- `ORGANIZATION_ADMIN_REQUIRED` — The caller is a member of the organization but not an admin or owner.\n- `ENTERPRISE_PLAN_REQUIRED` — The organization has no active enterprise subscription.\n- `AUDIT_LOGS_DISABLED` — Audit logging is not enabled for this deployment.\n- `SKILL_EDITOR_ACCESS_REQUIRED` — The caller can write in the workspace but is not an editor of this skill.\n- `SECRET_ADMIN_ACCESS_REQUIRED` — The caller can write in the workspace but is not an admin of this secret. Ask a workspace admin, or someone holding admin on the secret, to grant access or set the value.\n- `WORKSPACE_RESOURCE_LIMIT_REACHED` — The workspace already holds the maximum number of resources of this kind. Delete one, or contact Sim to raise the limit; the message names the ceiling.\n- `PUBLIC_SHARING_NOT_ALLOWED` — The workspace's organization does not permit sharing this resource publicly. An organization admin controls the policy.\n- `MCP_SERVER_URL_NOT_ALLOWED` — The supplied MCP server URL is outside the allowed domains or resolves to an internal address." } }, "required": ["code", "message"], @@ -2382,7 +2393,7 @@ }, "isExternal": { "type": "boolean", - "description": "Whether the member belongs to a different organization than the workspace. True for an explicitly granted member whose own organization differs from the workspace's; false for the workspace owner and for a member sharing the workspace organization. Inherited organization-administrator access is always reported as false, so this is not a signal that access came from outside the explicit member list." + "description": "Whether the member belongs to a different organization than the workspace. True only for an explicitly granted member whose own organization differs; inherited organization-administrator access is always reported as false, so this does not detect every outside caller." }, "joinedAt": { "type": "string", @@ -2415,7 +2426,7 @@ "type": "null" } ], - "description": "Opaque cursor for the next page: send it back as `cursor` to continue, and stop when it is null. Most v2 lists page, so null means the last page was reached. A few are full-set lists that return their whole bounded result in one response and therefore always report null; those say so in the operation description. Either way, null means there is nothing further to fetch — never construct a cursor yourself." + "description": "Opaque cursor for the next page. Send it back as `cursor`; `null` means there is nothing further to fetch. Never construct one yourself." } }, "required": ["data", "nextCursor"], @@ -2481,12 +2492,12 @@ "description": "Whether the server tools are available to workflows." }, "connectionStatus": { - "description": "Result of the most recent connection attempt.", + "description": "Result of the most recent connection attempt. Registration and re-registration store a configuration without contacting the endpoint, so a server begins — and returns to — `disconnected` until a tool discovery runs.", "type": "string", "enum": ["connected", "disconnected", "error"] }, "lastError": { - "description": "Message from the most recent failed connection, or null when absent.", + "description": "Message from the most recent failed connection, or null when absent. A re-registration clears it, since the configuration it described no longer applies.", "anyOf": [ { "type": "string" @@ -2507,7 +2518,7 @@ "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" }, "lastConnected": { - "description": "ISO 8601 timestamp of the most recent successful connection.", + "description": "ISO 8601 timestamp of the most recent successful connection. Absent until the server completes one; registering a server does not set it.", "type": "string", "format": "date-time", "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" @@ -2579,7 +2590,7 @@ "type": "null" } ], - "description": "Opaque cursor for the next page: send it back as `cursor` to continue, and stop when it is null. Most v2 lists page, so null means the last page was reached. A few are full-set lists that return their whole bounded result in one response and therefore always report null; those say so in the operation description. Either way, null means there is nothing further to fetch — never construct a cursor yourself." + "description": "Opaque cursor for the next page. Send it back as `cursor`; `null` means there is nothing further to fetch. Never construct one yourself." } }, "required": ["data", "nextCursor"], @@ -2639,11 +2650,9 @@ "timeout": 30000, "retries": 3, "enabled": true, - "connectionStatus": "connected", + "connectionStatus": "disconnected", "lastError": null, - "toolCount": 7, - "lastToolsRefresh": "2026-06-20T14:02:11.000Z", - "lastConnected": "2026-06-20T14:02:11.000Z", + "toolCount": 0, "createdAt": "2026-06-01T09:14:00.000Z", "updatedAt": "2026-06-20T14:02:11.000Z", "hasHeaders": true, @@ -2682,15 +2691,16 @@ "type": "string", "minLength": 1, "maxLength": 2048, - "description": "Absolute HTTP or HTTPS endpoint URL without `{{ENV_VAR}}` references." + "description": "Absolute HTTP or HTTPS endpoint URL without `{{ENV_VAR}}` references. It determines server identity and is immutable: delete and recreate the server to change endpoints." }, "authType": { - "description": "Authentication method. Sim detects it from the server when omitted.", + "description": "Authentication method. Applied server-side as `headers` when omitted; registration never contacts the server, so an omitted value is never detected from it.", + "default": "headers", "type": "string", "enum": ["none", "headers", "oauth"] }, "headers": { - "description": "Write-only request headers sent to the server.", + "description": "Write-only request headers sent to the server. Replaced wholesale rather than merged on update: sending this field drops every stored header it does not repeat.", "writeOnly": true, "type": "object", "propertyNames": { @@ -2722,7 +2732,7 @@ "type": "boolean" }, "oauthClientId": { - "description": "Pre-registered OAuth client identifier.", + "description": "Pre-registered OAuth client identifier. Changing it on update revokes the stored OAuth grant and forces reauthorization.", "anyOf": [ { "type": "string", @@ -2734,7 +2744,7 @@ ] }, "oauthClientSecret": { - "description": "Write-only pre-registered OAuth client secret.", + "description": "Write-only pre-registered OAuth client secret. Sending it on update as null or a new value revokes the stored OAuth grant and forces reauthorization, as does switching away from OAuth authentication.", "writeOnly": true, "anyOf": [ { @@ -2871,12 +2881,13 @@ "maxLength": 2048 }, "authType": { - "description": "Authentication method. Sim detects it from the server when omitted.", + "description": "Authentication method. Applied server-side as `headers` when omitted; registration never contacts the server, so an omitted value is never detected from it.", + "default": "headers", "type": "string", "enum": ["none", "headers", "oauth"] }, "headers": { - "description": "Write-only request headers sent to the server.", + "description": "Write-only request headers sent to the server. Replaced wholesale rather than merged on update: sending this field drops every stored header it does not repeat.", "writeOnly": true, "type": "object", "propertyNames": { @@ -2908,7 +2919,7 @@ "type": "boolean" }, "oauthClientId": { - "description": "Pre-registered OAuth client identifier.", + "description": "Pre-registered OAuth client identifier. Changing it on update revokes the stored OAuth grant and forces reauthorization.", "anyOf": [ { "type": "string", @@ -2920,7 +2931,7 @@ ] }, "oauthClientSecret": { - "description": "Write-only pre-registered OAuth client secret.", + "description": "Write-only pre-registered OAuth client secret. Sending it on update as null or a new value revokes the stored OAuth grant and forces reauthorization, as does switching away from OAuth authentication.", "writeOnly": true, "anyOf": [ { @@ -3019,10 +3030,6 @@ "type": "string", "description": "Name of a required argument." } - }, - "description": { - "description": "Description of the argument object.", - "type": "string" } }, "required": ["type"], @@ -3064,7 +3071,7 @@ "type": "null" } ], - "description": "Opaque cursor for the next page: send it back as `cursor` to continue, and stop when it is null. Most v2 lists page, so null means the last page was reached. A few are full-set lists that return their whole bounded result in one response and therefore always report null; those say so in the operation description. Either way, null means there is nothing further to fetch — never construct a cursor yourself." + "description": "Always `null` — this list has no `cursor` or `limit` param and returns its whole bounded set in one page. Present so the list can gain pages later without a shape change." } }, "required": ["data", "nextCursor"], @@ -3100,7 +3107,7 @@ "properties": { "id": { "type": "string", - "description": "Unique skill identifier. Built-in skills use their name as the id." + "description": "Unique skill identifier. A built-in skill is `builtin-` followed by its name, for example `builtin-research`." }, "name": { "type": "string", @@ -3151,7 +3158,7 @@ "type": "null" } ], - "description": "Opaque cursor for the next page: send it back as `cursor` to continue, and stop when it is null. Most v2 lists page, so null means the last page was reached. A few are full-set lists that return their whole bounded result in one response and therefore always report null; those say so in the operation description. Either way, null means there is nothing further to fetch — never construct a cursor yourself." + "description": "Opaque cursor for the next page. Send it back as `cursor`; `null` means there is nothing further to fetch. Never construct one yourself." } }, "required": ["data", "nextCursor"], @@ -3179,7 +3186,7 @@ "properties": { "id": { "type": "string", - "description": "Unique skill identifier. Built-in skills use their name as the id." + "description": "Unique skill identifier. A built-in skill is `builtin-` followed by its name, for example `builtin-research`." }, "name": { "type": "string", @@ -3529,7 +3536,7 @@ "type": "null" } ], - "description": "Opaque cursor for the next page: send it back as `cursor` to continue, and stop when it is null. Most v2 lists page, so null means the last page was reached. A few are full-set lists that return their whole bounded result in one response and therefore always report null; those say so in the operation description. Either way, null means there is nothing further to fetch — never construct a cursor yourself." + "description": "Opaque cursor for the next page. Send it back as `cursor`; `null` means there is nothing further to fetch. Never construct one yourself." } }, "required": ["data", "nextCursor"], @@ -4041,7 +4048,7 @@ "type": "null" } ], - "description": "Opaque cursor for the next page: send it back as `cursor` to continue, and stop when it is null. Most v2 lists page, so null means the last page was reached. A few are full-set lists that return their whole bounded result in one response and therefore always report null; those say so in the operation description. Either way, null means there is nothing further to fetch — never construct a cursor yourself." + "description": "Opaque cursor for the next page. Send it back as `cursor`; `null` means there is nothing further to fetch. Never construct one yourself." } }, "required": ["data", "nextCursor"], @@ -4125,7 +4132,7 @@ "type": "null" } ], - "description": "Opaque cursor for the next page: send it back as `cursor` to continue, and stop when it is null. Most v2 lists page, so null means the last page was reached. A few are full-set lists that return their whole bounded result in one response and therefore always report null; those say so in the operation description. Either way, null means there is nothing further to fetch — never construct a cursor yourself." + "description": "Opaque cursor for the next page. Send it back as `cursor`; `null` means there is nothing further to fetch. Never construct one yourself." } }, "required": ["data", "nextCursor"], diff --git a/apps/docs/openapi-v2-tables.json b/apps/docs/openapi-v2-tables.json index 64093eeed92..a4aff379376 100644 --- a/apps/docs/openapi-v2-tables.json +++ b/apps/docs/openapi-v2-tables.json @@ -2,7 +2,7 @@ "openapi": "3.1.0", "info": { "title": "Sim Tables API v2", - "description": "Manage tables, typed columns, rows, saved views, workflow groups, folders, imports, and exports through the public v2 API. Row data is keyed by column name.", + "description": "Version 2 of the Sim REST API for tables, typed columns, rows, saved views, workflow groups, folders, imports, and exports. Row data is keyed by column name.", "version": "2.0.0", "contact": { "name": "Sim Support", @@ -36,7 +36,7 @@ "get": { "operationId": "listTables", "summary": "List Tables", - "description": "List tables in a workspace with optional folder filtering, search, sorting, and an opaque cursor envelope. A workspace whose folder tree exceeds 10,000 folders is a 413, because the response needs the whole tree to render folder paths.", + "description": "List tables in a workspace with optional folder filtering, search, sorting, and an opaque cursor envelope. A workspace folder tree over 10,000 folders is a `413`.", "tags": ["Tables"], "parameters": [ { @@ -54,19 +54,19 @@ "name": "folderPath", "in": "query", "required": false, - "description": "Restrict results to tables in this folder.", + "description": "Restrict results to tables in this folder. A path that names no folder narrows the result to nothing, so the response is an empty page rather than an error.", "schema": { - "description": "Restrict results to tables in this folder.", - "type": "string" + "description": "Restrict results to tables in this folder. A path that names no folder narrows the result to nothing, so the response is an empty page rather than an error.", + "$ref": "#/components/schemas/FolderPathInput" } }, { "name": "search", "in": "query", "required": false, - "description": "Case-insensitive substring search on the resource name.", + "description": "Case-insensitive substring match against the resource name.", "schema": { - "description": "Case-insensitive substring search on the resource name.", + "description": "Case-insensitive substring match against the resource name.", "type": "string", "minLength": 1, "maxLength": 200 @@ -76,10 +76,10 @@ "name": "sortBy", "in": "query", "required": false, - "description": "Field used to sort the result.", + "description": "Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order.", "schema": { "default": "createdAt", - "description": "Field used to sort the result.", + "description": "Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order.", "type": "string", "enum": ["name", "createdAt", "updatedAt"] } @@ -104,8 +104,6 @@ "schema": { "description": "Maximum tables to return per page. Values outside 1–1000 are truncated and clamped into that range rather than rejected. Defaults to 100.", "type": "integer", - "minimum": 1, - "maximum": 1000, "default": 100 } }, @@ -113,9 +111,9 @@ "name": "cursor", "in": "query", "required": false, - "description": "Opaque cursor returned by the previous page.", + "description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.", "schema": { - "description": "Opaque cursor returned by the previous page.", + "description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.", "type": "string", "minLength": 1 } @@ -241,7 +239,7 @@ "get": { "operationId": "getTable", "summary": "Get Table", - "description": "Retrieve a table with its metadata, column schema, locks, and current job. A workspace whose folder tree exceeds 10,000 folders is a 413, because the response needs the whole tree to render folder paths.", + "description": "Retrieve a table with its metadata, column schema, locks, and current job. A workspace folder tree over 10,000 folders is a `413`.", "tags": ["Tables"], "parameters": [ { @@ -395,7 +393,7 @@ "patch": { "operationId": "updateTable", "summary": "Update Table", - "description": "Rename a table, edit its description, or move it to a canonical folder. At least one mutable field is required; lock flags remain read-only.\n\nThis operation is NOT atomic. The name, description, and folder changes are written independently in that order, so a failure part-way through leaves the earlier writes committed — a 4xx does NOT mean nothing changed. When at least one field landed before the failure, the error body carries `details.applied`: the list of fields (`name`, `description`, `folderPath`) that were successfully written. Re-read the table, or retry with only the fields missing from `details.applied`.\n\nA workspace whose folder tree exceeds 10,000 folders is a 413, because the response needs the whole tree to render folder paths.", + "description": "Rename a table, edit its description, or move it to a canonical folder. At least one mutable field is required; lock flags remain read-only.\n\nNOT atomic: name, description, and folder are written independently, so a 4xx does not mean nothing changed. The error body carries `details.applied` naming the fields that landed — retry with only the ones missing from it.\n\nA workspace folder tree over 10,000 folders is a `413`.", "tags": ["Tables"], "parameters": [ { @@ -504,7 +502,7 @@ } }, "responses": { - "200": { + "201": { "description": "The updated table columns.", "headers": { "X-RateLimit-Limit": { @@ -537,6 +535,9 @@ "404": { "$ref": "#/components/responses/NotFound" }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, "423": { "$ref": "#/components/responses/Locked" }, @@ -614,6 +615,9 @@ "404": { "$ref": "#/components/responses/NotFound" }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, "423": { "$ref": "#/components/responses/Locked" }, @@ -691,6 +695,9 @@ "404": { "$ref": "#/components/responses/NotFound" }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, "423": { "$ref": "#/components/responses/Locked" }, @@ -739,10 +746,10 @@ "name": "limit", "in": "query", "required": false, - "description": "Maximum rows to return in the current page.", + "description": "Maximum rows to return per page. Must be a whole number from 1 to 1000. Defaults to 100.", "schema": { "default": 100, - "description": "Maximum rows to return in the current page.", + "description": "Maximum rows to return per page. Must be a whole number from 1 to 1000. Defaults to 100.", "type": "integer", "minimum": 1, "maximum": 1000 @@ -752,9 +759,9 @@ "name": "cursor", "in": "query", "required": false, - "description": "Opaque cursor returned by the previous page.", + "description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.", "schema": { - "description": "Opaque cursor returned by the previous page.", + "description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.", "type": "string", "minLength": 1 } @@ -835,7 +842,7 @@ } }, "responses": { - "200": { + "201": { "description": "The inserted row or rows.", "headers": { "X-RateLimit-Limit": { @@ -868,6 +875,9 @@ "404": { "$ref": "#/components/responses/NotFound" }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, "423": { "$ref": "#/components/responses/Locked" }, @@ -945,6 +955,9 @@ "404": { "$ref": "#/components/responses/NotFound" }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, "423": { "$ref": "#/components/responses/Locked" }, @@ -1022,6 +1035,9 @@ "404": { "$ref": "#/components/responses/NotFound" }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, "423": { "$ref": "#/components/responses/Locked" }, @@ -1197,6 +1213,9 @@ "404": { "$ref": "#/components/responses/NotFound" }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, "423": { "$ref": "#/components/responses/Locked" }, @@ -1304,7 +1323,7 @@ "post": { "operationId": "upsertTableRow", "summary": "Upsert Row", - "description": "Insert a row or update the existing row that conflicts on a selected unique column.\n\nWARNING — the update branch REPLACES the row, it does not merge. `data` is treated as the complete new row value, so every column you omit is cleared on the matched row. Upserting 2 of 10 columns blanks the other 8. This differs from `PATCH /api/v2/tables/{tableId}/rows/{rowId}`, which merges the patch into the existing row data. Send the full row here, or use PATCH when you only mean to change a subset.", + "description": "Insert a row or update the existing row that conflicts on a selected unique column.\n\nWARNING — the update branch REPLACES the row, it does not merge. `data` is the complete new row value, so every column you omit is cleared on the matched row. Send the full row here, or use `PATCH /api/v2/tables/{tableId}/rows/{rowId}` to change a subset.", "tags": ["Tables"], "parameters": [ { @@ -1364,6 +1383,9 @@ "404": { "$ref": "#/components/responses/NotFound" }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, "423": { "$ref": "#/components/responses/Locked" }, @@ -1383,7 +1405,7 @@ "post": { "operationId": "queryTableRows", "summary": "Query Rows", - "description": "Query rows with a typed predicate, ordered sort specification, and opaque cursor pagination. Bounded pages are capped at 5MB by default and may contain fewer rows than the requested limit; continue until nextCursor is null.", + "description": "Query rows with a typed predicate, ordered sort specification, and opaque cursor pagination. Bounded pages are capped at 5MB by default and may contain fewer rows than the requested limit; continue until nextCursor is null. A predicate larger than the request-body ceiling is a `413`.", "tags": ["Tables"], "parameters": [ { @@ -1443,6 +1465,9 @@ "404": { "$ref": "#/components/responses/NotFound" }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -1459,7 +1484,7 @@ "post": { "operationId": "countTableRows", "summary": "Count Rows", - "description": "Count the rows matching a typed predicate across the entire table. The paged reads carry no total, and rowCount on the table resource counts every row rather than the predicate matches. Omit the predicate to count the whole table. A predicate larger than the request-body ceiling is a `413`.", + "description": "Count the rows matching a typed predicate across the entire table. The paged reads carry no total, and `rowCount` on the table resource counts every row rather than the matches. Omit the predicate to count the whole table. A predicate larger than the request-body ceiling is a `413`.", "tags": ["Tables"], "parameters": [ { @@ -1538,7 +1563,7 @@ "get": { "operationId": "listTableViews", "summary": "List Views", - "description": "List the bounded set of saved table views, with references to removed columns pruned on read. The bounded set is returned in one page with `nextCursor` always null; there is no second page to fetch.", + "description": "List the bounded set of saved table views, with references to removed columns pruned on read. The bounded set is returned in one page; `nextCursor` is always null.", "tags": ["Tables"], "parameters": [ { @@ -1672,6 +1697,9 @@ "404": { "$ref": "#/components/responses/NotFound" }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -1844,6 +1872,9 @@ "404": { "$ref": "#/components/responses/NotFound" }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -1945,7 +1976,7 @@ "get": { "operationId": "listTableWorkflowGroups", "summary": "List Workflow Groups", - "description": "List the workflow and enrichment groups that can be dispatched for a table. The bounded set is returned in one page with `nextCursor` always null; there is no second page to fetch.", + "description": "List the workflow and enrichment groups that can be dispatched for a table. The bounded set is returned in one page; `nextCursor` is always null.", "tags": ["Tables"], "parameters": [ { @@ -2079,6 +2110,9 @@ "404": { "$ref": "#/components/responses/NotFound" }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, "423": { "$ref": "#/components/responses/Locked" }, @@ -2096,7 +2130,7 @@ "patch": { "operationId": "updateTableWorkflowGroup", "summary": "Update Workflow Group", - "description": "Restructure a workflow group, its producer, outputs, or execution behavior.\n\nOutput leaf types are resolved against the group’s workflow outside the write lock. If the group is repointed at a different workflow concurrently, that snapshot is invalidated and the request returns `409` — retry the update.", + "description": "Restructure a workflow group, its producer, outputs, or execution behavior. Repointing the group at a different workflow concurrently invalidates the resolved output types and returns `409` — retry the update.", "tags": ["Tables"], "parameters": [ { @@ -2159,6 +2193,9 @@ "409": { "$ref": "#/components/responses/Conflict" }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, "423": { "$ref": "#/components/responses/Locked" }, @@ -2236,6 +2273,9 @@ "404": { "$ref": "#/components/responses/NotFound" }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, "423": { "$ref": "#/components/responses/Locked" }, @@ -2315,6 +2355,9 @@ "404": { "$ref": "#/components/responses/NotFound" }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -2413,6 +2456,9 @@ "404": { "$ref": "#/components/responses/NotFound" }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -2489,6 +2535,9 @@ "404": { "$ref": "#/components/responses/NotFound" }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -2577,7 +2626,7 @@ "get": { "operationId": "getTableImport", "summary": "Get Table Import", - "description": "Read progress and terminal state for a durable table import.", + "description": "Read progress and terminal state for a durable table import.\n\nAn upload-backed import has no durable record until its upload completes, so send the signed upload control token to read it during the `uploading` phase; without the token that phase is a `404`.", "tags": ["Tables"], "parameters": [ { @@ -2601,6 +2650,17 @@ "minLength": 1, "description": "Workspace that owns the transfer resource." } + }, + { + "name": "upload-token", + "in": "header", + "required": false, + "description": "Signed upload control token returned when an upload-backed import was created.", + "schema": { + "description": "Signed upload control token returned when an upload-backed import was created.", + "type": "string", + "minLength": 1 + } } ], "responses": { @@ -2651,7 +2711,7 @@ "delete": { "operationId": "cancelTableImport", "summary": "Cancel Table Import", - "description": "Cancel an upload or processing import without rolling back committed row batches.\n\nCanceling an import that is not in a cancelable state returns `409` naming the current status, and that includes an expired import — `expired` is a terminal import status, not a `410`. An import id that never existed, or one whose retention window already purged the record, returns `404`.", + "description": "Cancel an upload or processing import without rolling back committed row batches.\n\nAn import that is not in a cancelable state, including an `expired` one, is a `409` naming the current status. An unknown or already-purged import id is a `404`.", "tags": ["Tables"], "parameters": [ { @@ -2741,7 +2801,7 @@ "post": { "operationId": "createTableImportPartUrls", "summary": "Create Table Import Part URLs", - "description": "Issue short-lived signed PUT URLs for a bounded set of multipart part numbers.\n\nThe import must still be in the `uploading` state. An import that has moved on — including one that has `expired` — returns `409` naming the current status; a purged or unknown import id returns `404`.", + "description": "Issue short-lived signed PUT URLs for a bounded set of multipart part numbers.\n\nThe import must still be `uploading`; one that has moved on, including an `expired` one, is a `409` naming the current status. An unknown or already-purged import id is a `404`.", "tags": ["Tables"], "parameters": [ { @@ -2826,6 +2886,9 @@ "409": { "$ref": "#/components/responses/Conflict" }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -2842,7 +2905,7 @@ "post": { "operationId": "completeTableImportUpload", "summary": "Complete Table Import Upload", - "description": "Verify or assemble the uploaded CSV and begin processing with the same import id.\n\nCompleting an import that is no longer awaiting an upload — including one that has `expired` — returns `409` naming the current status; a purged or unknown import id returns `404`.", + "description": "Verify or assemble the uploaded CSV and begin processing with the same import id.\n\nAn import no longer awaiting an upload, including an `expired` one, is a `409` naming the current status. An unknown or already-purged import id is a `404`.", "tags": ["Tables"], "parameters": [ { @@ -2998,6 +3061,9 @@ "409": { "$ref": "#/components/responses/Conflict" }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -3167,7 +3233,7 @@ "get": { "operationId": "downloadTableExport", "summary": "Download Table Export", - "description": "Return a short-lived signed download URL for a completed table export.\n\nThe export must have reached the `completed` status. An export still processing, or one that failed or was canceled, returns `409` naming the current status. An export whose generated file is no longer available — the retention window elapsed, or the object was purged — returns `404` (`Export file is no longer available`), not `410`.", + "description": "Return a short-lived signed download URL for a completed table export.\n\nThe export must have reached `completed`; one still processing, failed, or canceled is a `409` naming the current status. An export whose file is no longer available is a `404`, not a `410`.", "tags": ["Tables"], "parameters": [ { @@ -3306,6 +3372,9 @@ "404": { "$ref": "#/components/responses/NotFound" }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -3322,7 +3391,7 @@ "get": { "operationId": "listTablesFolders", "summary": "List Folders", - "description": "List table folders, optionally restricting the result to direct children of a canonical parent path. The bounded set is returned in one page with `nextCursor` always null; there is no second page to fetch.", + "description": "List table folders, optionally restricting the result to direct children of a canonical parent path. The bounded set is returned in one page; `nextCursor` is always null.", "tags": ["Tables"], "parameters": [ { @@ -3343,7 +3412,7 @@ "description": "Restrict results to direct children of this parent path.", "schema": { "description": "Restrict results to direct children of this parent path.", - "type": "string" + "$ref": "#/components/schemas/FolderPathInput" } }, { @@ -3362,10 +3431,10 @@ "name": "sortBy", "in": "query", "required": false, - "description": "Field used to sort the result.", + "description": "Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order.", "schema": { "default": "name", - "description": "Field used to sort the result.", + "description": "Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order.", "type": "string", "enum": ["name", "createdAt", "updatedAt"] } @@ -3589,16 +3658,30 @@ "description": "Path of the folder to delete.", "schema": { "description": "Path of the folder to delete.", - "type": "string" + "$ref": "#/components/schemas/NonRootFolderPathInput" } }, { "name": "recursive", "in": "query", "required": false, - "description": "Delete nested files and folders when true.", + "description": "Delete the folder's nested files and folders too. An empty folder deletes either way; a non-empty one needs this. The listed spellings are the whole accepted vocabulary and are case-sensitive; any other value is rejected.", "schema": { - "description": "Delete nested files and folders when true.", + "description": "Delete the folder's nested files and folders too. An empty folder deletes either way; a non-empty one needs this. The listed spellings are the whole accepted vocabulary and are case-sensitive; any other value is rejected.", + "enum": [ + "true", + "1", + "yes", + "on", + "y", + "enabled", + "false", + "0", + "no", + "off", + "n", + "disabled" + ], "default": "false", "type": "string" } @@ -3666,7 +3749,7 @@ "type": "apiKey", "in": "header", "name": "X-API-Key", - "description": "Your Sim API key, personal or workspace-scoped. Generate one under Settings > API Keys. Operations that reject workspace keys say so in their own description." + "description": "Your Sim API key, personal or workspace-scoped. Generate one under Settings, then API Keys. Operations that reject workspace keys say so in their own description." } }, "headers": { @@ -3701,13 +3784,13 @@ } }, "Retry-After": { - "description": "Seconds to wait before retrying. Sent on `429` (derived from the caller rate-limit window) and on `503` (a fixed transient-failure floor). Add jitter rather than retrying at exactly this offset.", + "description": "Seconds to wait before retrying, sent on `429` and `503`. Add jitter rather than retrying at exactly this offset.", "schema": { "type": "integer", "minimum": 0, "maximum": 9007199254740991, "title": "Retry after", - "description": "Seconds to wait before retrying. Sent on `429` (derived from the caller rate-limit window) and on `503` (a fixed transient-failure floor). Add jitter rather than retrying at exactly this offset." + "description": "Seconds to wait before retrying, sent on `429` and `503`. Add jitter rather than retrying at exactly this offset." } }, "X-Run-Id": { @@ -3722,7 +3805,7 @@ }, "responses": { "BadRequest": { - "description": "The request is invalid.", + "description": "The request is invalid. This includes a query parameter sent with no value (`?limit=`, `?search=`), which is rejected rather than read as zero, empty, or the parameter default — omit the parameter instead.", "content": { "application/json": { "schema": { @@ -3752,7 +3835,7 @@ } }, "Forbidden": { - "description": "The caller lacks the rights this operation requires. Where the cause is one a caller can act on, `error.details.code` names it, drawn from a closed set:\n- `INSUFFICIENT_WORKSPACE_ROLE` — The caller has access to the workspace but its role is below the one this operation requires.\n- `PERSONAL_API_KEYS_DISABLED` — The workspace's organization does not allow personal API keys. Use a workspace API key.\n- `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` — This operation is not available to a workspace-scoped API key. Use a personal API key.\n- `PRINCIPAL_KIND_NOT_PERMITTED` — This operation does not accept the caller’s kind of API key.\n- `ORGANIZATION_MEMBERSHIP_REQUIRED` — The caller is not a member of the organization it named.\n- `ORGANIZATION_ADMIN_REQUIRED` — The caller is a member of the organization but not an admin or owner.\n- `ENTERPRISE_PLAN_REQUIRED` — The organization has no active enterprise subscription.\n- `AUDIT_LOGS_DISABLED` — Audit logging is not enabled for this deployment.\n- `SKILL_EDITOR_ACCESS_REQUIRED` — The caller can write in the workspace but is not an editor of this skill.\n- `MCP_SERVER_URL_NOT_ALLOWED` — The supplied MCP server URL is outside the allowed domains or resolves to an internal address.\nA resource in a workspace the caller cannot reach at all answers `404`, not `403`, so absence and denial are indistinguishable to a caller who was never entitled to tell them apart.", + "description": "The caller lacks the rights this operation requires. When the cause is one a caller can act on, `error.details.code` names it. A resource in a workspace the caller cannot reach at all answers `404` instead, so absence and denial are indistinguishable.", "content": { "application/json": { "schema": { @@ -3782,7 +3865,7 @@ } }, "RunIdConflict": { - "description": "The run cannot be started. Two causes share this status, distinguished by `error.details.code`: `RUN_ID_CONFLICT` when the supplied `X-Run-Id` is already associated with a different request, and `CALL_CHAIN_DEPTH_EXCEEDED` when the incoming `X-Sim-Via` chain has already reached the maximum workflow-to-workflow call depth.", + "description": "The run cannot be started, for one of two causes named by `error.details.code`: `RUN_ID_CONFLICT` when the supplied `X-Run-Id` is already claimed, and `CALL_CHAIN_DEPTH_EXCEEDED` when the incoming `X-Sim-Via` chain has reached the maximum workflow-to-workflow call depth.", "headers": { "X-Run-Id": { "$ref": "#/components/headers/X-Run-Id" @@ -3796,18 +3879,8 @@ } } }, - "Gone": { - "description": "The requested generated resource has expired.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2Error" - } - } - } - }, "PayloadTooLarge": { - "description": "The request, or a resource collection it must materialize, exceeds the allowed size. Besides an oversized request body, this covers a generated artifact that renders past the download ceiling and a workspace folder tree too large to load in full.", + "description": "The request, or a resource collection it must materialize, exceeds the allowed size: an oversized request body, a generated artifact past the download ceiling, or a workspace folder tree too large to load in full.", "content": { "application/json": { "schema": { @@ -3852,7 +3925,7 @@ } }, "ClientClosedRequest": { - "description": "The client closed the connection before the response was produced.", + "description": "The client closed the connection before the response was produced. An abort can leave the run going, so `error.details.runId` carries the run id — reconcile against the runs resource rather than starting another run.", "content": { "application/json": { "schema": { @@ -3872,7 +3945,7 @@ } }, "ServiceUnavailable": { - "description": "A required service is temporarily unavailable. The condition is transient, so the response normally carries `Retry-After` with the number of seconds to wait; treat that value as a floor and add jitter before retrying. One case deliberately omits the header: when `error.details.code` is `ASYNC_ENQUEUE_AMBIGUOUS`, the run may already have started, so retrying could start and bill a second run. Reconcile against the returned run id instead of retrying.", + "description": "A required service is temporarily unavailable. `Retry-After` carries the seconds to wait; treat it as a floor and add jitter. The header is omitted when `error.details.code` is `ASYNC_ENQUEUE_AMBIGUOUS`, because the run may already have started — reconcile against the returned run id instead of retrying.", "headers": { "Retry-After": { "$ref": "#/components/headers/Retry-After" @@ -3903,7 +3976,7 @@ "description": "Human-readable explanation of the error." }, "details": { - "description": "Optional structured error details." + "description": "Structured error details. On a `403` whose cause a caller can act on, this carries a `code` from a closed set:\n- `INSUFFICIENT_WORKSPACE_ROLE` — The caller has access to the workspace but its role is below the one this operation requires.\n- `PERSONAL_API_KEYS_DISABLED` — The workspace's organization does not allow personal API keys. Use a workspace API key.\n- `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` — This operation is not available to a workspace-scoped API key. Use a personal API key.\n- `PRINCIPAL_KIND_NOT_PERMITTED` — This operation does not accept the caller’s kind of API key.\n- `ORGANIZATION_MEMBERSHIP_REQUIRED` — The caller is not a member of the organization it named.\n- `ORGANIZATION_ADMIN_REQUIRED` — The caller is a member of the organization but not an admin or owner.\n- `ENTERPRISE_PLAN_REQUIRED` — The organization has no active enterprise subscription.\n- `AUDIT_LOGS_DISABLED` — Audit logging is not enabled for this deployment.\n- `SKILL_EDITOR_ACCESS_REQUIRED` — The caller can write in the workspace but is not an editor of this skill.\n- `SECRET_ADMIN_ACCESS_REQUIRED` — The caller can write in the workspace but is not an admin of this secret. Ask a workspace admin, or someone holding admin on the secret, to grant access or set the value.\n- `WORKSPACE_RESOURCE_LIMIT_REACHED` — The workspace already holds the maximum number of resources of this kind. Delete one, or contact Sim to raise the limit; the message names the ceiling.\n- `PUBLIC_SHARING_NOT_ALLOWED` — The workspace's organization does not permit sharing this resource publicly. An organization admin controls the policy.\n- `MCP_SERVER_URL_NOT_ALLOWED` — The supplied MCP server URL is outside the allowed domains or resolves to an internal address." } }, "required": ["code", "message"], @@ -3924,6 +3997,12 @@ } ] }, + "FolderPathInput": { + "title": "Folder path input", + "description": "Folder path. A missing leading slash is normalized before validation. Segments are percent-encoded, so a folder shown as \"New folder\" is `/New%20folder`: everything outside `A-Z a-z 0-9 - _ . ~` is escaped as uppercase hex, and only that exact encoding is accepted. A trailing slash, an empty segment, and a literal `.` or `..` segment are rejected. At most 64 segments and 4096 encoded bytes.", + "maxLength": 4096, + "type": "string" + }, "V2ApiTable": { "type": "object", "properties": { @@ -4045,7 +4124,9 @@ }, "folderPath": { "type": "string", - "description": "Canonical slash-prefixed folder path. `/` is the workspace root." + "title": "Folder path", + "description": "Canonical slash-prefixed folder path. `/` is the workspace root. Segments are percent-encoded, so a folder shown as \"New folder\" is `/New%20folder`: everything outside `A-Z a-z 0-9 - _ . ~` is escaped as uppercase hex, and only that exact encoding is accepted. A trailing slash, an empty segment, and a literal `.` or `..` segment are rejected. At most 64 segments and 4096 encoded bytes.", + "maxLength": 4096 }, "locks": { "type": "object", @@ -4184,7 +4265,7 @@ "type": "null" } ], - "description": "Opaque cursor for the next page: send it back as `cursor` to continue, and stop when it is null. Most v2 lists page, so null means the last page was reached. A few are full-set lists that return their whole bounded result in one response and therefore always report null; those say so in the operation description. Either way, null means there is nothing further to fetch — never construct a cursor yourself." + "description": "Opaque cursor for the next page. Send it back as `cursor`; `null` means there is nothing further to fetch. Never construct one yourself." } }, "required": ["data", "nextCursor"], @@ -4307,7 +4388,7 @@ }, "folderPath": { "description": "Folder in which to create the table.", - "type": "string" + "$ref": "#/components/schemas/FolderPathInput" } }, "required": ["name", "workspaceId", "schema"], @@ -4401,8 +4482,7 @@ "description": "Replacement table description, or null to clear it." }, "folderPath": { - "description": "Folder path. A missing leading slash is normalized before validation.", - "type": "string" + "$ref": "#/components/schemas/FolderPathInput" } }, "required": ["workspaceId"], @@ -4779,7 +4859,7 @@ "type": "null" } ], - "description": "Opaque cursor for the next page: send it back as `cursor` to continue, and stop when it is null. Most v2 lists page, so null means the last page was reached. A few are full-set lists that return their whole bounded result in one response and therefore always report null; those say so in the operation description. Either way, null means there is nothing further to fetch — never construct a cursor yourself." + "description": "Opaque cursor for the next page. Send it back as `cursor`; `null` means there is nothing further to fetch. Never construct one yourself." } }, "required": ["data", "nextCursor"], @@ -4940,6 +5020,145 @@ "title": "Update table rows response", "description": "Updated row count and identifiers." }, + "TablePredicate": { + "title": "Table predicate", + "description": "Recursive predicate tree. Each group node is exactly one non-empty `all` or `any` array whose members are further groups or `{ field, op, value }` conditions; the root must be a group, not a bare condition. At most 100 members per group, 10 levels of nesting, and 500 nodes in total. The negating operators include nulls: `ne`, `nin`, `ncontains`, `nlike`, and `nilike` match rows whose column is null or absent, so \"not X\" is not the complement of \"X\" over a nullable column. That holds for every column type, multi-select included. To exclude nulls, `all`-combine the negation with `isNotEmpty` (multi-select) or `isNotNull`. Comparison: `eq`, `ne`, `gt`, `gte`, `lt`, `lte`. Membership: `in`, `nin` (array operand). Emptiness: `isEmpty`, `isNotEmpty`, `isNull`, `isNotNull` (no operand). Substring, always case-insensitive, operand matched literally: `contains`, `ncontains`, `startsWith`, `endsWith`. Pattern: `like`/`nlike` (case-sensitive), `ilike`/`nilike` (case-insensitive). **`*` is the only wildcard** and stands for any run of characters; `%`, `_`, and backslash match themselves. Use `like: \"Hi*\"`, not `like: \"Hi%\"`. A `select` column compares by option id and restricts its operators: single-select accepts `eq`, `ne`, `in`, `nin`; multi-select accepts `contains`, `ncontains`. Option names are accepted as operands and resolved to ids.", + "type": "object", + "oneOf": [ + { + "type": "object", + "description": "Matches a row when every member matches.", + "properties": { + "all": { + "type": "array", + "minItems": 1, + "maxItems": 100, + "description": "Members combined with AND. An empty group is rejected, because it would compile to no filter at all.", + "items": { + "description": "A nested group, or a single condition.", + "anyOf": [ + { + "$ref": "#/components/schemas/TablePredicate" + }, + { + "type": "object", + "title": "Predicate condition", + "description": "One column comparison.", + "properties": { + "field": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Column name to compare, or one of the system fields `id`, `createdAt`, `updatedAt`." + }, + "op": { + "type": "string", + "enum": [ + "eq", + "ne", + "gt", + "gte", + "lt", + "lte", + "in", + "nin", + "contains", + "ncontains", + "startsWith", + "endsWith", + "like", + "ilike", + "nlike", + "nilike", + "isEmpty", + "isNotEmpty", + "isNull", + "isNotNull" + ], + "description": "Comparison operator. The `TablePredicate` schema description carries the grammar for all of them." + }, + "value": { + "description": "Operand. A scalar for the comparison operators, an array of at most 1000 entries for `in`/`nin`, a pattern for the matching operators, and omitted for `isEmpty`/`isNotEmpty`/`isNull`/`isNotNull`." + } + }, + "required": ["field", "op"], + "additionalProperties": false + } + ] + } + } + }, + "required": ["all"], + "additionalProperties": false + }, + { + "type": "object", + "description": "Matches a row when at least one member matches.", + "properties": { + "any": { + "type": "array", + "minItems": 1, + "maxItems": 100, + "description": "Members combined with OR. An empty group is rejected, because it would compile to no filter at all.", + "items": { + "description": "A nested group, or a single condition.", + "anyOf": [ + { + "$ref": "#/components/schemas/TablePredicate" + }, + { + "type": "object", + "title": "Predicate condition", + "description": "One column comparison.", + "properties": { + "field": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Column name to compare, or one of the system fields `id`, `createdAt`, `updatedAt`." + }, + "op": { + "type": "string", + "enum": [ + "eq", + "ne", + "gt", + "gte", + "lt", + "lte", + "in", + "nin", + "contains", + "ncontains", + "startsWith", + "endsWith", + "like", + "ilike", + "nlike", + "nilike", + "isEmpty", + "isNotEmpty", + "isNull", + "isNotNull" + ], + "description": "Comparison operator. The `TablePredicate` schema description carries the grammar for all of them." + }, + "value": { + "description": "Operand. A scalar for the comparison operators, an array of at most 1000 entries for `in`/`nin`, a pattern for the matching operators, and omitted for `isEmpty`/`isNotEmpty`/`isNull`/`isNotNull`." + } + }, + "required": ["field", "op"], + "additionalProperties": false + } + ] + } + } + }, + "required": ["any"], + "additionalProperties": false + } + ] + }, "UpdateTableRowsRequest": { "type": "object", "properties": { @@ -4949,7 +5168,7 @@ "description": "Unique workspace identifier." }, "filter": { - "description": "Recursive predicate tree with exactly one non-empty `all` or `any` group at each group node." + "$ref": "#/components/schemas/TablePredicate" }, "data": { "description": "Row-data patch applied to every matching row.", @@ -5020,7 +5239,7 @@ "description": "Unique workspace identifier." }, "filter": { - "description": "Recursive predicate tree with exactly one non-empty `all` or `any` group at each group node." + "$ref": "#/components/schemas/TablePredicate" }, "limit": { "description": "Maximum matching rows to delete.", @@ -5160,7 +5379,7 @@ "description": "Unique workspace identifier." }, "data": { - "description": "Complete set of row cells keyed by column name. On the update branch this REPLACES the matched row: any column not present here is cleared, unlike the merging PATCH /rows/{rowId}.", + "description": "Complete set of row cells keyed by column name. On the update branch this REPLACES the matched row: any column not present here is cleared, unlike the merging `PATCH /api/v2/tables/{tableId}/rows/{rowId}`.", "$ref": "#/components/schemas/V2TableRowData" }, "conflictTarget": { @@ -5203,7 +5422,7 @@ "type": "null" } ], - "description": "Opaque cursor for the next page: send it back as `cursor` to continue, and stop when it is null. Most v2 lists page, so null means the last page was reached. A few are full-set lists that return their whole bounded result in one response and therefore always report null; those say so in the operation description. Either way, null means there is nothing further to fetch — never construct a cursor yourself." + "description": "Opaque cursor for the next page. Send it back as `cursor`; `null` means there is nothing further to fetch. Never construct one yourself." } }, "required": ["data", "nextCursor"], @@ -5220,7 +5439,7 @@ "description": "Unique workspace identifier." }, "predicate": { - "description": "Recursive predicate tree with exactly one non-empty `all` or `any` group at each group node." + "$ref": "#/components/schemas/TablePredicate" }, "sort": { "description": "Ordered table-row sort specification.", @@ -5320,7 +5539,7 @@ "description": "Unique workspace identifier." }, "predicate": { - "description": "Recursive predicate tree with exactly one non-empty `all` or `any` group at each group node." + "$ref": "#/components/schemas/TablePredicate" } }, "required": ["workspaceId"], @@ -5409,7 +5628,7 @@ "type": "object", "properties": { "columnWidths": { - "description": "Column widths keyed by stable column identifier.", + "description": "Column widths keyed by column name.", "type": "object", "propertyNames": { "type": "string" @@ -5420,21 +5639,21 @@ } }, "columnOrder": { - "description": "Stable column identifiers in display order.", + "description": "Column names in display order.", "type": "array", "items": { "type": "string" } }, "pinnedColumns": { - "description": "Stable identifiers of pinned columns.", + "description": "Names of pinned columns.", "type": "array", "items": { "type": "string" } }, "hiddenColumns": { - "description": "Stable identifiers of hidden columns.", + "description": "Names of hidden columns.", "type": "array", "items": { "type": "string" @@ -5505,7 +5724,7 @@ "type": "null" } ], - "description": "Opaque cursor for the next page: send it back as `cursor` to continue, and stop when it is null. Most v2 lists page, so null means the last page was reached. A few are full-set lists that return their whole bounded result in one response and therefore always report null; those say so in the operation description. Either way, null means there is nothing further to fetch — never construct a cursor yourself." + "description": "Always `null` — this list has no `cursor` or `limit` param and returns its whole bounded set in one page. Present so the list can gain pages later without a shape change." } }, "required": ["data", "nextCursor"], @@ -5526,6 +5745,188 @@ "title": "Create table view response", "description": "The created saved view." }, + "TablePredicateInput": { + "title": "Table predicate input", + "description": "A single `{ field, op, value }` condition or a group, normalized to a grouped predicate after validation. Same grammar and limits as `TablePredicate`.", + "oneOf": [ + { + "type": "object", + "description": "Matches a row when every member matches.", + "properties": { + "all": { + "type": "array", + "minItems": 1, + "maxItems": 100, + "description": "Members combined with AND. An empty group is rejected, because it would compile to no filter at all.", + "items": { + "description": "A nested group, or a single condition.", + "anyOf": [ + { + "$ref": "#/components/schemas/TablePredicateInput" + }, + { + "type": "object", + "title": "Predicate condition", + "description": "One column comparison.", + "properties": { + "field": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Column name to compare, or one of the system fields `id`, `createdAt`, `updatedAt`." + }, + "op": { + "type": "string", + "enum": [ + "eq", + "ne", + "gt", + "gte", + "lt", + "lte", + "in", + "nin", + "contains", + "ncontains", + "startsWith", + "endsWith", + "like", + "ilike", + "nlike", + "nilike", + "isEmpty", + "isNotEmpty", + "isNull", + "isNotNull" + ], + "description": "Comparison operator. The `TablePredicate` schema description carries the grammar for all of them." + }, + "value": { + "description": "Operand. A scalar for the comparison operators, an array of at most 1000 entries for `in`/`nin`, a pattern for the matching operators, and omitted for `isEmpty`/`isNotEmpty`/`isNull`/`isNotNull`." + } + }, + "required": ["field", "op"], + "additionalProperties": false + } + ] + } + } + }, + "required": ["all"], + "additionalProperties": false + }, + { + "type": "object", + "description": "Matches a row when at least one member matches.", + "properties": { + "any": { + "type": "array", + "minItems": 1, + "maxItems": 100, + "description": "Members combined with OR. An empty group is rejected, because it would compile to no filter at all.", + "items": { + "description": "A nested group, or a single condition.", + "anyOf": [ + { + "$ref": "#/components/schemas/TablePredicateInput" + }, + { + "type": "object", + "title": "Predicate condition", + "description": "One column comparison.", + "properties": { + "field": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Column name to compare, or one of the system fields `id`, `createdAt`, `updatedAt`." + }, + "op": { + "type": "string", + "enum": [ + "eq", + "ne", + "gt", + "gte", + "lt", + "lte", + "in", + "nin", + "contains", + "ncontains", + "startsWith", + "endsWith", + "like", + "ilike", + "nlike", + "nilike", + "isEmpty", + "isNotEmpty", + "isNull", + "isNotNull" + ], + "description": "Comparison operator. The `TablePredicate` schema description carries the grammar for all of them." + }, + "value": { + "description": "Operand. A scalar for the comparison operators, an array of at most 1000 entries for `in`/`nin`, a pattern for the matching operators, and omitted for `isEmpty`/`isNotEmpty`/`isNull`/`isNotNull`." + } + }, + "required": ["field", "op"], + "additionalProperties": false + } + ] + } + } + }, + "required": ["any"], + "additionalProperties": false + }, + { + "type": "object", + "title": "Predicate condition", + "description": "One column comparison.", + "properties": { + "field": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Column name to compare, or one of the system fields `id`, `createdAt`, `updatedAt`." + }, + "op": { + "type": "string", + "enum": [ + "eq", + "ne", + "gt", + "gte", + "lt", + "lte", + "in", + "nin", + "contains", + "ncontains", + "startsWith", + "endsWith", + "like", + "ilike", + "nlike", + "nilike", + "isEmpty", + "isNotEmpty", + "isNull", + "isNotNull" + ], + "description": "Comparison operator. The `TablePredicate` schema description carries the grammar for all of them." + }, + "value": { + "description": "Operand. A scalar for the comparison operators, an array of at most 1000 entries for `in`/`nin`, a pattern for the matching operators, and omitted for `isEmpty`/`isNotEmpty`/`isNull`/`isNotNull`." + } + }, + "required": ["field", "op"], + "additionalProperties": false + } + ] + }, "CreateTableViewRequest": { "type": "object", "properties": { @@ -5543,7 +5944,7 @@ "type": "object", "properties": { "columnWidths": { - "description": "Column widths keyed by stable column identifier.", + "description": "Column widths keyed by column name or stable column identifier.", "type": "object", "propertyNames": { "type": "string" @@ -5554,21 +5955,21 @@ } }, "columnOrder": { - "description": "Stable column identifiers in display order.", + "description": "Columns in display order, by name or stable identifier.", "type": "array", "items": { "type": "string" } }, "pinnedColumns": { - "description": "Stable identifiers of pinned columns.", + "description": "Pinned columns, by name or stable identifier.", "type": "array", "items": { "type": "string" } }, "hiddenColumns": { - "description": "Stable identifiers of hidden columns.", + "description": "Hidden columns, by name or stable identifier.", "type": "array", "items": { "type": "string" @@ -5578,7 +5979,7 @@ "description": "Saved row predicate, or null when the view is unfiltered.", "anyOf": [ { - "description": "Recursive predicate condition or group, normalized to a grouped predicate after validation." + "$ref": "#/components/schemas/TablePredicateInput" }, { "type": "null" @@ -5656,7 +6057,7 @@ "type": "object", "properties": { "columnWidths": { - "description": "Column widths keyed by stable column identifier.", + "description": "Column widths keyed by column name or stable column identifier.", "type": "object", "propertyNames": { "type": "string" @@ -5667,21 +6068,21 @@ } }, "columnOrder": { - "description": "Stable column identifiers in display order.", + "description": "Columns in display order, by name or stable identifier.", "type": "array", "items": { "type": "string" } }, "pinnedColumns": { - "description": "Stable identifiers of pinned columns.", + "description": "Pinned columns, by name or stable identifier.", "type": "array", "items": { "type": "string" } }, "hiddenColumns": { - "description": "Stable identifiers of hidden columns.", + "description": "Hidden columns, by name or stable identifier.", "type": "array", "items": { "type": "string" @@ -5691,7 +6092,7 @@ "description": "Saved row predicate, or null when the view is unfiltered.", "anyOf": [ { - "description": "Recursive predicate condition or group, normalized to a grouped predicate after validation." + "$ref": "#/components/schemas/TablePredicateInput" }, { "type": "null" @@ -5736,7 +6137,7 @@ "type": "object", "properties": { "columnWidths": { - "description": "Column widths keyed by stable column identifier.", + "description": "Column widths keyed by column name or stable column identifier.", "type": "object", "propertyNames": { "type": "string" @@ -5747,21 +6148,21 @@ } }, "columnOrder": { - "description": "Stable column identifiers in display order.", + "description": "Columns in display order, by name or stable identifier.", "type": "array", "items": { "type": "string" } }, "pinnedColumns": { - "description": "Stable identifiers of pinned columns.", + "description": "Pinned columns, by name or stable identifier.", "type": "array", "items": { "type": "string" } }, "hiddenColumns": { - "description": "Stable identifiers of hidden columns.", + "description": "Hidden columns, by name or stable identifier.", "type": "array", "items": { "type": "string" @@ -5771,7 +6172,7 @@ "description": "Saved row predicate, or null when the view is unfiltered.", "anyOf": [ { - "description": "Recursive predicate condition or group, normalized to a grouped predicate after validation." + "$ref": "#/components/schemas/TablePredicateInput" }, { "type": "null" @@ -5909,7 +6310,7 @@ }, "columnName": { "type": "string", - "description": "Table column receiving the output." + "description": "Name of the table column receiving the output." } }, "required": ["blockId", "path", "columnName"], @@ -5929,7 +6330,7 @@ }, "columnName": { "type": "string", - "description": "Source table column name." + "description": "Name of the source table column." } }, "required": ["inputName", "columnName"], @@ -5970,7 +6371,7 @@ "type": "null" } ], - "description": "Opaque cursor for the next page: send it back as `cursor` to continue, and stop when it is null. Most v2 lists page, so null means the last page was reached. A few are full-set lists that return their whole bounded result in one response and therefore always report null; those say so in the operation description. Either way, null means there is nothing further to fetch — never construct a cursor yourself." + "description": "Always `null` — this list has no `cursor` or `limit` param and returns its whole bounded set in one page. Present so the list can gain pages later without a shape change." } }, "required": ["data", "nextCursor"], @@ -6095,9 +6496,9 @@ "minLength": 1 }, "workflowId": { - "default": "", - "description": "Backing workflow identifier for a manual group.", - "type": "string" + "description": "Backing workflow identifier. Required when `type` is `manual` (which is also the default when `type` is omitted); omit it for an `enrichment` group.", + "type": "string", + "minLength": 1 }, "enrichmentId": { "description": "Registry enrichment identifier.", @@ -6617,6 +7018,7 @@ "rowIds": { "description": "Explicit row subset to run.", "minItems": 1, + "maxItems": 1000000, "type": "array", "items": { "type": "string", @@ -6624,7 +7026,7 @@ } }, "filter": { - "description": "Recursive predicate tree with exactly one non-empty `all` or `any` group at each group node." + "$ref": "#/components/schemas/TablePredicate" }, "excludeRowIds": { "description": "Rows excluded from a select-all run scope.", @@ -6722,15 +7124,16 @@ "type": "object", "properties": { "matches": { + "maxItems": 1000, "type": "array", "items": { "$ref": "#/components/schemas/V2TableRowMatch" }, - "description": "Matching table cells." + "description": "Matching table cells, at most 1000." }, "truncated": { "type": "boolean", - "description": "Whether more matches exist beyond the server cap." + "description": "Whether more than 1000 cells matched, so the list was cut." } }, "required": ["matches", "truncated"], @@ -6762,10 +7165,11 @@ "q": { "type": "string", "minLength": 1, + "maxLength": 200, "description": "Case-insensitive cell substring to find." }, "predicate": { - "description": "Recursive predicate tree with exactly one non-empty `all` or `any` group at each group node." + "$ref": "#/components/schemas/TablePredicate" }, "sort": { "description": "Ordered table-row sort specification.", @@ -6856,15 +7260,7 @@ }, "status": { "type": "string", - "enum": [ - "uploading", - "queued", - "processing", - "completed", - "failed", - "canceled", - "expired" - ], + "enum": ["uploading", "processing", "completed", "failed", "canceled", "expired"], "description": "Current import lifecycle state." }, "source": { @@ -6889,8 +7285,7 @@ "description": "Name of the table to create." }, "folderPath": { - "description": "Folder path. A missing leading slash is normalized before validation.", - "type": "string" + "$ref": "#/components/schemas/FolderPathInput" } }, "required": ["type", "name"], @@ -7003,7 +7398,7 @@ "url": { "type": "string", "format": "uri", - "description": "Signed URL to which the file bytes are uploaded." + "description": "Signed URL to which the file bytes are uploaded. Send the bytes with `PUT` to this URL, including exactly the headers in `headers` and nothing that alters the body. Success is `204` with an empty body. A failure is the same `{ \"error\": { \"code\", \"message\" } }` envelope as every other v2 response: `400` when the body does not match the size or content type the session was created for, `403` when the token is invalid, expired, or belongs to another session, and `409` when the session is no longer accepting bytes. The URL is signed and self-describing — construct it from this field only, never by hand." }, "headers": { "type": "object", @@ -7085,15 +7480,7 @@ }, "status": { "type": "string", - "enum": [ - "uploading", - "queued", - "processing", - "completed", - "failed", - "canceled", - "expired" - ], + "enum": ["uploading", "processing", "completed", "failed", "canceled", "expired"], "description": "Current import lifecycle state." }, "source": { @@ -7118,8 +7505,7 @@ "description": "Name of the table to create." }, "folderPath": { - "description": "Folder path. A missing leading slash is normalized before validation.", - "type": "string" + "$ref": "#/components/schemas/FolderPathInput" } }, "required": ["type", "name"], @@ -7259,11 +7645,11 @@ }, "uploadToken": { "type": "null", - "description": "Always null for workspace-file imports." + "description": "Always null; a workspace-file import has no upload to authorize." }, "transfer": { "type": "null", - "description": "Always null for workspace-file imports." + "description": "Always null; a workspace-file import has no bytes to transfer." } }, "required": ["session", "uploadToken", "transfer"], @@ -7323,8 +7709,7 @@ "description": "Name of the table to create." }, "folderPath": { - "description": "Folder path. A missing leading slash is normalized before validation.", - "type": "string" + "$ref": "#/components/schemas/FolderPathInput" } }, "required": ["type", "name"], @@ -7409,15 +7794,7 @@ }, "status": { "type": "string", - "enum": [ - "uploading", - "queued", - "processing", - "completed", - "failed", - "canceled", - "expired" - ], + "enum": ["uploading", "processing", "completed", "failed", "canceled", "expired"], "description": "Current import lifecycle state." }, "source": { @@ -7449,8 +7826,7 @@ "description": "Name of the table to create." }, "folderPath": { - "description": "Folder path. A missing leading slash is normalized before validation.", - "type": "string" + "$ref": "#/components/schemas/FolderPathInput" } }, "required": ["type", "name"], @@ -7590,7 +7966,7 @@ "url": { "type": "string", "format": "uri", - "description": "Signed URL for this upload part." + "description": "Signed URL for this upload part. Send the bytes with `PUT` to this URL, including exactly the headers in `headers` and nothing that alters the body. Success is `204` with an empty body. A failure is the same `{ \"error\": { \"code\", \"message\" } }` envelope as every other v2 response: `400` when the body does not match the size or content type the session was created for, `403` when the token is invalid, expired, or belongs to another session, and `409` when the session is no longer accepting bytes. The URL is signed and self-describing — construct it from this field only, never by hand." }, "headers": { "type": "object", @@ -7913,7 +8289,7 @@ "minLength": 1 }, "filter": { - "description": "Recursive predicate tree with exactly one non-empty `all` or `any` group at each group node." + "$ref": "#/components/schemas/TablePredicate" }, "excludeRowIds": { "description": "Rows excluded from an all-scope cancellation.", @@ -7946,11 +8322,15 @@ }, "path": { "type": "string", - "description": "Canonical folder path used as the public folder identifier." + "title": "Non-root folder path", + "description": "Canonical folder path used as the public folder identifier.", + "maxLength": 4096 }, "parentPath": { "type": "string", - "description": "Canonical parent path; `/` is the root." + "title": "Folder path", + "description": "Canonical parent path; `/` is the root.", + "maxLength": 4096 }, "createdAt": { "type": "string", @@ -7987,7 +8367,7 @@ "type": "null" } ], - "description": "Opaque cursor for the next page: send it back as `cursor` to continue, and stop when it is null. Most v2 lists page, so null means the last page was reached. A few are full-set lists that return their whole bounded result in one response and therefore always report null; those say so in the operation description. Either way, null means there is nothing further to fetch — never construct a cursor yourself." + "description": "Always `null` — this list has no `cursor` or `limit` param and returns its whole bounded set in one page. Present so the list can gain pages later without a shape change." } }, "required": ["data", "nextCursor"], @@ -8008,6 +8388,12 @@ "title": "Create table folder response", "description": "The created table folder." }, + "NonRootFolderPathInput": { + "title": "Non-root folder path input", + "description": "Non-root folder path. A missing leading slash is normalized before validation. Segments are percent-encoded, so a folder shown as \"New folder\" is `/New%20folder`: everything outside `A-Z a-z 0-9 - _ . ~` is escaped as uppercase hex, and only that exact encoding is accepted. A trailing slash, an empty segment, and a literal `.` or `..` segment are rejected. At most 64 segments and 4096 encoded bytes.", + "maxLength": 4096, + "type": "string" + }, "CreateTableFolderRequest": { "type": "object", "properties": { @@ -8018,7 +8404,7 @@ }, "path": { "description": "Path of the folder to create.", - "type": "string" + "$ref": "#/components/schemas/NonRootFolderPathInput" } }, "required": ["workspaceId", "path"], @@ -8049,11 +8435,11 @@ }, "path": { "description": "Current folder path.", - "type": "string" + "$ref": "#/components/schemas/NonRootFolderPathInput" }, "destinationPath": { "description": "New full path for the folder and its descendants.", - "type": "string" + "$ref": "#/components/schemas/NonRootFolderPathInput" } }, "required": ["workspaceId", "path", "destinationPath"], @@ -8066,7 +8452,9 @@ "properties": { "path": { "type": "string", - "description": "Canonical path of the deleted folder." + "title": "Folder path", + "description": "Canonical path of the deleted folder.", + "maxLength": 4096 }, "deleted": { "type": "boolean", diff --git a/apps/docs/openapi-v2-workflows.json b/apps/docs/openapi-v2-workflows.json index 1d9df284ce8..c93a2b3352e 100644 --- a/apps/docs/openapi-v2-workflows.json +++ b/apps/docs/openapi-v2-workflows.json @@ -40,7 +40,7 @@ "get": { "operationId": "listWorkflows", "summary": "List Workflows", - "description": "List workflows in a workspace with folder and deployment filters, search, sorting, and opaque cursor pagination. A workspace whose folder tree exceeds 10,000 folders is a 413, because the response needs the whole tree to render folder paths.", + "description": "List workflows in a workspace with folder and deployment filters, search, sorting, and opaque cursor pagination. A workspace folder tree over 10,000 folders is a `413`.", "tags": ["Workflows"], "parameters": [ { @@ -58,10 +58,10 @@ "name": "folderPath", "in": "query", "required": false, - "description": "Restrict results to workflows in this folder path.", + "description": "Restrict results to workflows in this folder path. A path that names no folder narrows the result to nothing, so the response is an empty page rather than an error.", "schema": { - "description": "Restrict results to workflows in this folder path.", - "type": "string" + "description": "Restrict results to workflows in this folder path. A path that names no folder narrows the result to nothing, so the response is an empty page rather than an error.", + "$ref": "#/components/schemas/FolderPathInput" } }, { @@ -91,9 +91,9 @@ "name": "cursor", "in": "query", "required": false, - "description": "Opaque cursor returned by the previous page.", + "description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.", "schema": { - "description": "Opaque cursor returned by the previous page.", + "description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.", "type": "string", "minLength": 1 } @@ -102,9 +102,9 @@ "name": "search", "in": "query", "required": false, - "description": "Case-insensitive substring search on the resource name.", + "description": "Case-insensitive substring match against the resource name.", "schema": { - "description": "Case-insensitive substring search on the resource name.", + "description": "Case-insensitive substring match against the resource name.", "type": "string", "minLength": 1, "maxLength": 200 @@ -114,10 +114,10 @@ "name": "sortBy", "in": "query", "required": false, - "description": "Field used to sort the result.", + "description": "Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order.", "schema": { "default": "position", - "description": "Field used to sort the result.", + "description": "Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order.", "type": "string", "enum": ["position", "name", "createdAt", "updatedAt", "runCount"] } @@ -186,7 +186,7 @@ "post": { "operationId": "createWorkflowV2", "summary": "Create Workflow", - "description": "Create a workflow in a workspace root or canonical workflow folder. A workspace whose folder tree exceeds 10,000 folders is a 413, because the response needs the whole tree to render folder paths.", + "description": "Create a workflow in a workspace root or canonical workflow folder. A workspace folder tree over 10,000 folders is a `413`.", "tags": ["Workflows"], "requestBody": { "required": true, @@ -258,7 +258,7 @@ "get": { "operationId": "getWorkflow", "summary": "Get Workflow", - "description": "Get a workflow with its variables and deployed API-trigger inputs. A workspace whose folder tree exceeds 10,000 folders is a 413, because the response needs the whole tree to render folder paths.", + "description": "Get a workflow with its variables and deployed API-trigger inputs. A workspace folder tree over 10,000 folders is a `413`.", "tags": ["Workflows"], "parameters": [ { @@ -325,7 +325,7 @@ "patch": { "operationId": "updateWorkflowV2", "summary": "Update Workflow", - "description": "Rename, describe, or move a workflow to a canonical folder path. A workspace whose folder tree exceeds 10,000 folders is a 413, because the response needs the whole tree to render folder paths.", + "description": "Rename, describe, or move a workflow to a canonical folder path. A workspace folder tree over 10,000 folders is a `413`.", "tags": ["Workflows"], "parameters": [ { @@ -510,9 +510,9 @@ "name": "cursor", "in": "query", "required": false, - "description": "Opaque cursor returned by the previous page.", + "description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.", "schema": { - "description": "Opaque cursor returned by the previous page.", + "description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.", "type": "string", "minLength": 1 } @@ -590,7 +590,7 @@ "schema": { "type": "integer", "exclusiveMinimum": 0, - "maximum": 9007199254740991, + "maximum": 2147483647, "description": "Numeric deployment version.", "examples": [3] } @@ -646,7 +646,7 @@ "get": { "operationId": "getWorkflowDeployment", "summary": "Get Workflow Deployment", - "description": "Read the current deployment state of a workflow: whether a version is live, when it went live, the most recent deployment attempt with its readiness and failure payload, and whether the editable draft has since diverged from the live version. This is the only place `needsRedeployment` is published — the deploy, undeploy, and rollback responses cannot carry it, because they answer at the moment the draft and the live version are equal.", + "description": "Read the current deployment state of a workflow: whether a version is live, when it went live, the most recent deployment attempt with its readiness and failure payload, and whether the editable draft has since diverged from the live version. This is the only operation that publishes `needsRedeployment`.", "tags": ["Workflows"], "parameters": [ { @@ -712,7 +712,7 @@ "post": { "operationId": "deployWorkflow", "summary": "Deploy Workflow", - "description": "Create and asynchronously activate a deployment version. This request is not idempotent: it accepts no idempotency key and every call mints a new deployment version, so retrying after a timeout creates a second version rather than returning the first. The response carries `latestDeploymentAttempt` for the accepted attempt, but `GET /workflows/{id}` does not expose that field — poll activation with `isDeployed` and `deployedAt` on the workflow, or with `isActive` on `GET /workflows/{id}/versions`. Returns 409 when the deployment would conflict with an existing webhook path. A workspace API key cannot call this operation. Because unauthorized resources are concealed, the rejection is reported as `404` rather than `403`; use a personal API key.", + "description": "Create and asynchronously activate a deployment version. Not idempotent: every call mints a new version, so a retry after a timeout creates a second one. A deployment that would conflict with an existing webhook path is a `409`. A workspace API key is rejected with `403`; use a personal API key.", "tags": ["Workflows"], "parameters": [ { @@ -796,7 +796,7 @@ "delete": { "operationId": "undeployWorkflow", "summary": "Undeploy Workflow", - "description": "Deactivate the currently serving workflow version. A workspace API key cannot call this operation. Because unauthorized resources are concealed, the rejection is reported as `404` rather than `403`; use a personal API key.", + "description": "Deactivate the currently serving workflow version. A workspace API key is rejected with `403`; use a personal API key.", "tags": ["Workflows"], "parameters": [ { @@ -865,7 +865,7 @@ "post": { "operationId": "rollbackWorkflow", "summary": "Rollback Workflow", - "description": "Asynchronously reactivate a previous deployment version, selecting the preceding active version when no version is supplied. A workspace API key cannot call this operation. Because unauthorized resources are concealed, the rejection is reported as `404` rather than `403`; use a personal API key.", + "description": "Asynchronously reactivate a previous deployment version, selecting the preceding active version when no version is supplied. A workspace API key is rejected with `403`; use a personal API key.", "tags": ["Workflows"], "parameters": [ { @@ -926,6 +926,9 @@ "404": { "$ref": "#/components/responses/NotFound" }, + "409": { + "$ref": "#/components/responses/Conflict" + }, "413": { "$ref": "#/components/responses/PayloadTooLarge" }, @@ -948,7 +951,7 @@ "get": { "operationId": "exportWorkflow", "summary": "Export Workflow", - "description": "Export a portable, secret-sanitized workflow. Workspace-scoped bindings must be selected again after import. A workspace whose folder tree exceeds 10,000 folders is a 413, because the response needs the whole tree to render folder paths.", + "description": "Export a portable, secret-sanitized workflow. Workspace-scoped bindings must be selected again after import. Exporting records an audit event, so it is not a safe read. A `HEAD` skips the effect but is authorized exactly as the `GET` is, so it answers `400`, `401`, `403`, or `404` wherever the `GET` would and an empty `200` otherwise. Skipping the effect means skipping the read that produces the payload, so that `200` carries none of the response headers documented below — it answers whether the `GET` would be allowed, not what the `GET` would return. A workspace folder tree over 10,000 folders is a `413`.", "tags": ["Workflows"], "parameters": [ { @@ -1017,7 +1020,7 @@ "post": { "operationId": "importWorkflow", "summary": "Import Workflow", - "description": "Create a workflow from a portable export object, bare state, or JSON string.", + "description": "Create a workflow from a portable export object, bare state, or JSON string. A workspace folder tree over 10,000 folders is a `413`.", "tags": ["Workflows"], "requestBody": { "required": true, @@ -1089,7 +1092,7 @@ "post": { "operationId": "executeWorkflowV2", "summary": "Execute Workflow", - "description": "Execute a deployed workflow synchronously, asynchronously, or as Server-Sent Events. Public workflows permit anonymous synchronous and streaming execution; asynchronous execution requires an API key. A synchronous run that exceeds its execution timeout returns HTTP 200 with `status: \"failed\"` and `error.code: \"TIMEOUT\"` rather than an HTTP error, so branch on `status`. The optional `X-Run-Id` header is a one-shot uniqueness claim, not an idempotency key: reusing a value returns 409 with `error.details.code: \"RUN_ID_CONFLICT\"` and never replays the earlier run. Option constraints — each is a 400: (1) `async: true` requires an API key; anonymous public-workflow callers may only execute synchronously or as a stream. (2) `async` and `stream` cannot both be true. (3) `executionTimeoutSeconds` is accepted only when `async: true`. (4) `async: true` rejects every streaming and output-shaping option — `selectedOutputs`, `includeThinking`, `includeToolCalls`, `includeFileBase64`, and `base64MaxBytes`. (5) `includeThinking` and `includeToolCalls` require `stream: true`. (6) `includeThinking` and `includeToolCalls` require the `X-Sim-Stream-Protocol: agent-events-v1` request header, which declares that the client understands agent-event frames.", + "description": "Execute a deployed workflow synchronously, asynchronously, or as Server-Sent Events. Public workflows permit anonymous synchronous and streaming execution; asynchronous execution requires an API key. A synchronous run that exceeds its execution timeout returns HTTP 200 with `status: \"failed\"` and `error.code: \"TIMEOUT\"` rather than an HTTP error, so branch on `status`. Each option carries the modes it requires and the modes that reject it; a violated combination is a 400.", "tags": ["Workflows"], "security": [ { @@ -1114,9 +1117,9 @@ "name": "x-run-id", "in": "header", "required": false, - "description": "Caller-supplied run identifier, available only to API-key callers. This is a one-shot uniqueness claim, NOT an idempotency key: the first request to use a value starts a run, and any later request reusing it fails with 409 and `error.details.code: \"RUN_ID_CONFLICT\"` instead of replaying the original result. To retry safely, generate a fresh value per attempt and reconcile duplicates yourself, or omit the header and let the server allocate the run identifier.", + "description": "Caller-supplied run identifier, available only to API-key callers. A one-shot uniqueness claim, NOT an idempotency key: reusing a value fails with `409` and `error.details.code: \"RUN_ID_CONFLICT\"` rather than replaying the original result. To retry safely, send a fresh value per attempt, or omit the header and let the server allocate one.", "schema": { - "description": "Caller-supplied run identifier, available only to API-key callers. This is a one-shot uniqueness claim, NOT an idempotency key: the first request to use a value starts a run, and any later request reusing it fails with 409 and `error.details.code: \"RUN_ID_CONFLICT\"` instead of replaying the original result. To retry safely, generate a fresh value per attempt and reconcile duplicates yourself, or omit the header and let the server allocate the run identifier.", + "description": "Caller-supplied run identifier, available only to API-key callers. A one-shot uniqueness claim, NOT an idempotency key: reusing a value fails with `409` and `error.details.code: \"RUN_ID_CONFLICT\"` rather than replaying the original result. To retry safely, send a fresh value per attempt, or omit the header and let the server allocate one.", "type": "string", "minLength": 1, "maxLength": 128, @@ -1128,16 +1131,16 @@ "name": "x-sim-via", "in": "header", "required": false, - "description": "Comma-separated workflow identifiers describing the workflow-to-workflow call chain that led to this request. Each hop appends its own workflow id, and Sim sets it automatically when one workflow calls another; supply it yourself only when relaying an existing chain. A chain already at the maximum depth is rejected with 409 and `error.details.code: \"CALL_CHAIN_DEPTH_EXCEEDED\"`, which is how runaway recursion between workflows is stopped.", + "description": "Comma-separated workflow identifiers naming the workflow-to-workflow call chain that led to this request. Each hop appends its own workflow id, and Sim sets it automatically; supply it yourself only when relaying an existing chain. A chain at the maximum depth is rejected with `409` and `error.details.code: \"CALL_CHAIN_DEPTH_EXCEEDED\"`.", "schema": { - "description": "Comma-separated workflow identifiers describing the workflow-to-workflow call chain that led to this request. Each hop appends its own workflow id, and Sim sets it automatically when one workflow calls another; supply it yourself only when relaying an existing chain. A chain already at the maximum depth is rejected with 409 and `error.details.code: \"CALL_CHAIN_DEPTH_EXCEEDED\"`, which is how runaway recursion between workflows is stopped.", + "description": "Comma-separated workflow identifiers naming the workflow-to-workflow call chain that led to this request. Each hop appends its own workflow id, and Sim sets it automatically; supply it yourself only when relaying an existing chain. A chain at the maximum depth is rejected with `409` and `error.details.code: \"CALL_CHAIN_DEPTH_EXCEEDED\"`.", "type": "string" } } ], "requestBody": { "required": true, - "description": "Input and execution-mode options for a deployed workflow. Option constraints — each is a 400: (1) `async: true` requires an API key; anonymous public-workflow callers may only execute synchronously or as a stream. (2) `async` and `stream` cannot both be true. (3) `executionTimeoutSeconds` is accepted only when `async: true`. (4) `async: true` rejects every streaming and output-shaping option — `selectedOutputs`, `includeThinking`, `includeToolCalls`, `includeFileBase64`, and `base64MaxBytes`. (5) `includeThinking` and `includeToolCalls` require `stream: true`. (6) `includeThinking` and `includeToolCalls` require the `X-Sim-Stream-Protocol: agent-events-v1` request header, which declares that the client understands agent-event frames.", + "description": "Input and execution-mode options for a deployed workflow. Each option carries the modes it requires and the modes that reject it; a violated combination is a 400.", "content": { "application/json": { "schema": { @@ -1240,7 +1243,7 @@ "get": { "operationId": "listWorkflowRunsV2", "summary": "List Workflow Runs", - "description": "List recorded runs of a workflow with filtering and opaque cursor pagination. Ordering deviates from the v2 `sortBy` + `sortOrder` convention: runs are sortable only by start time, so direction is carried by the single `order` param.", + "description": "List recorded runs of a workflow with filtering and opaque cursor pagination. Runs are hard-deleted once they pass the payer's log retention window, so an older run is simply absent rather than reported as removed. The window is 30 days from run start on the free plan, unbounded on Pro and Team, and set per organization on Enterprise with an optional per-workspace override.", "tags": ["Workflow Runs"], "parameters": [ { @@ -1281,24 +1284,24 @@ "name": "startDate", "in": "query", "required": false, - "description": "Only include runs started at or after this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected.", + "description": "Only include runs started at or after this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected, as is year `0000`, which names no storable instant.", "schema": { "type": "string", "format": "date-time", "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - "description": "Only include runs started at or after this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected." + "description": "Only include runs started at or after this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected, as is year `0000`, which names no storable instant." } }, { "name": "endDate", "in": "query", "required": false, - "description": "Only include runs started at or before this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected.", + "description": "Only include runs started at or before this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected, as is year `0000`, which names no storable instant.", "schema": { "type": "string", "format": "date-time", "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - "description": "Only include runs started at or before this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected." + "description": "Only include runs started at or before this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected, as is year `0000`, which names no storable instant." } }, { @@ -1318,9 +1321,9 @@ "name": "cursor", "in": "query", "required": false, - "description": "Opaque cursor returned by the previous page.", + "description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.", "schema": { - "description": "Opaque cursor returned by the previous page.", + "description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.", "type": "string", "minLength": 1 } @@ -1329,10 +1332,10 @@ "name": "order", "in": "query", "required": false, - "description": "Sort direction by run start time. This operation deviates from the v2 `sortBy` + `sortOrder` convention: runs are sortable only by start time, so the direction is carried by this single `order` param and `sortBy`/`sortOrder` are not accepted.", + "description": "Sort direction by run start time. This list is sortable only by run start time, so it takes `order` in place of `sortBy`/`sortOrder`, which it rejects.", "schema": { "default": "desc", - "description": "Sort direction by run start time. This operation deviates from the v2 `sortBy` + `sortOrder` convention: runs are sortable only by start time, so the direction is carried by this single `order` param and `sortBy`/`sortOrder` are not accepted.", + "description": "Sort direction by run start time. This list is sortable only by run start time, so it takes `order` in place of `sortBy`/`sortOrder`, which it rejects.", "type": "string", "enum": ["asc", "desc"] } @@ -1616,7 +1619,7 @@ "post": { "operationId": "cancelRunV2", "summary": "Cancel Workflow Run", - "description": "Request cancellation of a running, queued, or paused workflow run. Cancelling a run that has already reached a terminal state succeeds with no effect rather than returning an error. The `reason` field is present on every response, including full successes — `recorded` is the success value; it is not a partial-failure marker. A run produced by a table workflow group is a 409 when its cell can no longer accept the cancellation, because the run and its cell must reach the cancelled state together.", + "description": "Request cancellation of a running, queued, or paused workflow run. Cancelling a run already in a terminal state succeeds with no effect. A run produced by a table workflow group is a `409` when its cell can no longer accept the cancellation.", "tags": ["Workflow Runs"], "parameters": [ { @@ -1698,7 +1701,7 @@ "get": { "operationId": "listWorkflowsFolders", "summary": "List Workflow Folders", - "description": "List canonical workflow folders in a workspace. The bounded set is returned in one page with `nextCursor` always null; there is no second page to fetch.", + "description": "List canonical workflow folders in a workspace. The bounded set is returned in one page; `nextCursor` is always null. A workspace folder tree over 10,000 folders is a `413`.", "tags": ["Workflows"], "parameters": [ { @@ -1719,7 +1722,7 @@ "description": "Restrict results to direct children of this parent path.", "schema": { "description": "Restrict results to direct children of this parent path.", - "type": "string" + "$ref": "#/components/schemas/FolderPathInput" } }, { @@ -1738,10 +1741,10 @@ "name": "sortBy", "in": "query", "required": false, - "description": "Field used to sort the result.", + "description": "Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order.", "schema": { "default": "name", - "description": "Field used to sort the result.", + "description": "Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order.", "type": "string", "enum": ["name", "createdAt", "updatedAt"] } @@ -1810,7 +1813,7 @@ "post": { "operationId": "createWorkflowsFolder", "summary": "Create Workflow Folder", - "description": "Create a canonical workflow folder in a workspace.", + "description": "Create a canonical workflow folder in a workspace. A workspace folder tree over 10,000 folders is a `413`.", "tags": ["Workflows"], "requestBody": { "required": true, @@ -1880,7 +1883,7 @@ "patch": { "operationId": "relocateWorkflowsFolder", "summary": "Rename or Move Workflow Folder", - "description": "Rename or move a workflow folder and its descendants to a canonical path.", + "description": "Rename or move a workflow folder and its descendants to a canonical path. A workspace folder tree over 10,000 folders is a `413`.", "tags": ["Workflows"], "requestBody": { "required": true, @@ -1971,16 +1974,30 @@ "description": "Path of the folder to delete.", "schema": { "description": "Path of the folder to delete.", - "type": "string" + "$ref": "#/components/schemas/NonRootFolderPathInput" } }, { "name": "recursive", "in": "query", "required": false, - "description": "Delete nested files and folders when true.", + "description": "Delete the folder's nested files and folders too. An empty folder deletes either way; a non-empty one needs this. The listed spellings are the whole accepted vocabulary and are case-sensitive; any other value is rejected.", "schema": { - "description": "Delete nested files and folders when true.", + "description": "Delete the folder's nested files and folders too. An empty folder deletes either way; a non-empty one needs this. The listed spellings are the whole accepted vocabulary and are case-sensitive; any other value is rejected.", + "enum": [ + "true", + "1", + "yes", + "on", + "y", + "enabled", + "false", + "0", + "no", + "off", + "n", + "disabled" + ], "default": "false", "type": "string" } @@ -2048,7 +2065,7 @@ "type": "apiKey", "in": "header", "name": "X-API-Key", - "description": "Your Sim API key, personal or workspace-scoped. Generate one under Settings > API Keys. Operations that reject workspace keys say so in their own description." + "description": "Your Sim API key, personal or workspace-scoped. Generate one under Settings, then API Keys. Operations that reject workspace keys say so in their own description." } }, "headers": { @@ -2083,13 +2100,13 @@ } }, "Retry-After": { - "description": "Seconds to wait before retrying. Sent on `429` (derived from the caller rate-limit window) and on `503` (a fixed transient-failure floor). Add jitter rather than retrying at exactly this offset.", + "description": "Seconds to wait before retrying, sent on `429` and `503`. Add jitter rather than retrying at exactly this offset.", "schema": { "type": "integer", "minimum": 0, "maximum": 9007199254740991, "title": "Retry after", - "description": "Seconds to wait before retrying. Sent on `429` (derived from the caller rate-limit window) and on `503` (a fixed transient-failure floor). Add jitter rather than retrying at exactly this offset." + "description": "Seconds to wait before retrying, sent on `429` and `503`. Add jitter rather than retrying at exactly this offset." } }, "X-Run-Id": { @@ -2104,7 +2121,7 @@ }, "responses": { "BadRequest": { - "description": "The request is invalid.", + "description": "The request is invalid. This includes a query parameter sent with no value (`?limit=`, `?search=`), which is rejected rather than read as zero, empty, or the parameter default — omit the parameter instead.", "content": { "application/json": { "schema": { @@ -2134,7 +2151,7 @@ } }, "Forbidden": { - "description": "The caller lacks the rights this operation requires. Where the cause is one a caller can act on, `error.details.code` names it, drawn from a closed set:\n- `INSUFFICIENT_WORKSPACE_ROLE` — The caller has access to the workspace but its role is below the one this operation requires.\n- `PERSONAL_API_KEYS_DISABLED` — The workspace's organization does not allow personal API keys. Use a workspace API key.\n- `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` — This operation is not available to a workspace-scoped API key. Use a personal API key.\n- `PRINCIPAL_KIND_NOT_PERMITTED` — This operation does not accept the caller’s kind of API key.\n- `ORGANIZATION_MEMBERSHIP_REQUIRED` — The caller is not a member of the organization it named.\n- `ORGANIZATION_ADMIN_REQUIRED` — The caller is a member of the organization but not an admin or owner.\n- `ENTERPRISE_PLAN_REQUIRED` — The organization has no active enterprise subscription.\n- `AUDIT_LOGS_DISABLED` — Audit logging is not enabled for this deployment.\n- `SKILL_EDITOR_ACCESS_REQUIRED` — The caller can write in the workspace but is not an editor of this skill.\n- `MCP_SERVER_URL_NOT_ALLOWED` — The supplied MCP server URL is outside the allowed domains or resolves to an internal address.\nA resource in a workspace the caller cannot reach at all answers `404`, not `403`, so absence and denial are indistinguishable to a caller who was never entitled to tell them apart.", + "description": "The caller lacks the rights this operation requires. When the cause is one a caller can act on, `error.details.code` names it. A resource in a workspace the caller cannot reach at all answers `404` instead, so absence and denial are indistinguishable.", "content": { "application/json": { "schema": { @@ -2164,7 +2181,7 @@ } }, "RunIdConflict": { - "description": "The run cannot be started. Two causes share this status, distinguished by `error.details.code`: `RUN_ID_CONFLICT` when the supplied `X-Run-Id` is already associated with a different request, and `CALL_CHAIN_DEPTH_EXCEEDED` when the incoming `X-Sim-Via` chain has already reached the maximum workflow-to-workflow call depth.", + "description": "The run cannot be started, for one of two causes named by `error.details.code`: `RUN_ID_CONFLICT` when the supplied `X-Run-Id` is already claimed, and `CALL_CHAIN_DEPTH_EXCEEDED` when the incoming `X-Sim-Via` chain has reached the maximum workflow-to-workflow call depth.", "headers": { "X-Run-Id": { "$ref": "#/components/headers/X-Run-Id" @@ -2178,18 +2195,8 @@ } } }, - "Gone": { - "description": "The requested generated resource has expired.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2Error" - } - } - } - }, "PayloadTooLarge": { - "description": "The request, or a resource collection it must materialize, exceeds the allowed size. Besides an oversized request body, this covers a generated artifact that renders past the download ceiling and a workspace folder tree too large to load in full.", + "description": "The request, or a resource collection it must materialize, exceeds the allowed size: an oversized request body, a generated artifact past the download ceiling, or a workspace folder tree too large to load in full.", "content": { "application/json": { "schema": { @@ -2234,7 +2241,7 @@ } }, "ClientClosedRequest": { - "description": "The client closed the connection before the response was produced.", + "description": "The client closed the connection before the response was produced. An abort can leave the run going, so `error.details.runId` carries the run id — reconcile against the runs resource rather than starting another run.", "content": { "application/json": { "schema": { @@ -2254,7 +2261,7 @@ } }, "ServiceUnavailable": { - "description": "A required service is temporarily unavailable. The condition is transient, so the response normally carries `Retry-After` with the number of seconds to wait; treat that value as a floor and add jitter before retrying. One case deliberately omits the header: when `error.details.code` is `ASYNC_ENQUEUE_AMBIGUOUS`, the run may already have started, so retrying could start and bill a second run. Reconcile against the returned run id instead of retrying.", + "description": "A required service is temporarily unavailable. `Retry-After` carries the seconds to wait; treat it as a floor and add jitter. The header is omitted when `error.details.code` is `ASYNC_ENQUEUE_AMBIGUOUS`, because the run may already have started — reconcile against the returned run id instead of retrying.", "headers": { "Retry-After": { "$ref": "#/components/headers/Retry-After" @@ -2285,7 +2292,7 @@ "description": "Human-readable explanation of the error." }, "details": { - "description": "Optional structured error details." + "description": "Structured error details. On a `403` whose cause a caller can act on, this carries a `code` from a closed set:\n- `INSUFFICIENT_WORKSPACE_ROLE` — The caller has access to the workspace but its role is below the one this operation requires.\n- `PERSONAL_API_KEYS_DISABLED` — The workspace's organization does not allow personal API keys. Use a workspace API key.\n- `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` — This operation is not available to a workspace-scoped API key. Use a personal API key.\n- `PRINCIPAL_KIND_NOT_PERMITTED` — This operation does not accept the caller’s kind of API key.\n- `ORGANIZATION_MEMBERSHIP_REQUIRED` — The caller is not a member of the organization it named.\n- `ORGANIZATION_ADMIN_REQUIRED` — The caller is a member of the organization but not an admin or owner.\n- `ENTERPRISE_PLAN_REQUIRED` — The organization has no active enterprise subscription.\n- `AUDIT_LOGS_DISABLED` — Audit logging is not enabled for this deployment.\n- `SKILL_EDITOR_ACCESS_REQUIRED` — The caller can write in the workspace but is not an editor of this skill.\n- `SECRET_ADMIN_ACCESS_REQUIRED` — The caller can write in the workspace but is not an admin of this secret. Ask a workspace admin, or someone holding admin on the secret, to grant access or set the value.\n- `WORKSPACE_RESOURCE_LIMIT_REACHED` — The workspace already holds the maximum number of resources of this kind. Delete one, or contact Sim to raise the limit; the message names the ceiling.\n- `PUBLIC_SHARING_NOT_ALLOWED` — The workspace's organization does not permit sharing this resource publicly. An organization admin controls the policy.\n- `MCP_SERVER_URL_NOT_ALLOWED` — The supplied MCP server URL is outside the allowed domains or resolves to an internal address." } }, "required": ["code", "message"], @@ -2306,6 +2313,12 @@ } ] }, + "FolderPathInput": { + "title": "Folder path input", + "description": "Folder path. A missing leading slash is normalized before validation. Segments are percent-encoded, so a folder shown as \"New folder\" is `/New%20folder`: everything outside `A-Z a-z 0-9 - _ . ~` is escaped as uppercase hex, and only that exact encoding is accepted. A trailing slash, an empty segment, and a literal `.` or `..` segment are rejected. At most 64 segments and 4096 encoded bytes.", + "maxLength": 4096, + "type": "string" + }, "WorkflowListItem": { "type": "object", "properties": { @@ -2332,7 +2345,9 @@ }, "folderPath": { "type": "string", + "title": "Folder path", "description": "Canonical containing-folder path; `/` is the workspace root.", + "maxLength": 4096, "examples": ["/Operations"] }, "workspaceId": { @@ -2359,7 +2374,7 @@ "type": "integer", "minimum": 0, "maximum": 9007199254740991, - "description": "Total recorded workflow runs." + "description": "Runs that finished successfully. Failed, cancelled, and paused runs are not counted, and the counter is never reduced when a run ages out of log retention — so it does not match the size of `GET /api/v2/workflows/{id}/runs`, in either direction." }, "lastRunAt": { "anyOf": [ @@ -2370,7 +2385,7 @@ "type": "null" } ], - "description": "ISO 8601 timestamp of the latest run, or null when never run.", + "description": "ISO 8601 timestamp of the latest run counted by `runCount`, or null when none has been. Stamped by the same successful-run path, so a workflow whose only runs failed reports null here.", "format": "date-time" }, "createdAt": { @@ -2420,7 +2435,7 @@ "type": "null" } ], - "description": "Opaque cursor for the next page: send it back as `cursor` to continue, and stop when it is null. Most v2 lists page, so null means the last page was reached. A few are full-set lists that return their whole bounded result in one response and therefore always report null; those say so in the operation description. Either way, null means there is nothing further to fetch — never construct a cursor yourself." + "description": "Opaque cursor for the next page. Send it back as `cursor`; `null` means there is nothing further to fetch. Never construct one yourself." } }, "required": ["data", "nextCursor"], @@ -2505,8 +2520,7 @@ ] }, "folderPath": { - "description": "Folder path. A missing leading slash is normalized before validation.", - "type": "string" + "$ref": "#/components/schemas/FolderPathInput" } }, "required": ["workspaceId", "name"], @@ -2561,7 +2575,9 @@ }, "folderPath": { "type": "string", + "title": "Folder path", "description": "Canonical containing-folder path; `/` is the workspace root.", + "maxLength": 4096, "examples": ["/Operations"] }, "workspaceId": { @@ -2588,7 +2604,7 @@ "type": "integer", "minimum": 0, "maximum": 9007199254740991, - "description": "Total recorded workflow runs." + "description": "Runs that finished successfully. Failed, cancelled, and paused runs are not counted, and the counter is never reduced when a run ages out of log retention — so it does not match the size of `GET /api/v2/workflows/{id}/runs`, in either direction." }, "lastRunAt": { "anyOf": [ @@ -2599,7 +2615,7 @@ "type": "null" } ], - "description": "ISO 8601 timestamp of the latest run, or null when never run.", + "description": "ISO 8601 timestamp of the latest run counted by `runCount`, or null when none has been. Stamped by the same successful-run path, so a workflow whose only runs failed reports null here.", "format": "date-time" }, "createdAt": { @@ -2734,7 +2750,7 @@ }, "folderPath": { "description": "Destination folder path; `/` moves the workflow to the workspace root.", - "type": "string" + "$ref": "#/components/schemas/FolderPathInput" } }, "additionalProperties": false, @@ -2872,7 +2888,7 @@ "type": "null" } ], - "description": "Opaque cursor for the next page: send it back as `cursor` to continue, and stop when it is null. Most v2 lists page, so null means the last page was reached. A few are full-set lists that return their whole bounded result in one response and therefore always report null; those say so in the operation description. Either way, null means there is nothing further to fetch — never construct a cursor yourself." + "description": "Opaque cursor for the next page. Send it back as `cursor`; `null` means there is nothing further to fetch. Never construct one yourself." } }, "required": ["data", "nextCursor"], @@ -2948,7 +2964,7 @@ "format": "date-time" }, "state": { - "description": "Deployed workflow graph snapshot pinned by this version. Credential-bearing values are redacted: `oauth-input`, `password: true`, and table sub-block values are null; sensitive nested tool parameters and every parameter without authoritative codec metadata are null.", + "description": "Deployed workflow graph snapshot pinned by this version, with credential-bearing values redacted to null: `oauth-input`, `password: true`, table sub-block values, sensitive nested tool parameters, and any parameter without authoritative codec metadata.", "$ref": "#/components/schemas/DeployedWorkflowState" } }, @@ -3321,7 +3337,7 @@ ], "additionalProperties": false, "title": "Deploy result", - "description": "Deployment attempt accepted for processing. Activation is asynchronous; `latestDeploymentAttempt` on this response is the attempt handle. The request is NOT idempotent — every POST mints a new deployment version, so a retry after a timeout creates a second version rather than returning the first. `latestDeploymentAttempt` is returned only here: `GET /workflows/{id}` does not carry it, so poll activation with `isDeployed` and `deployedAt` on the workflow, or with `isActive` on `GET /workflows/{id}/versions`." + "description": "Deployment attempt accepted for processing. Activation is asynchronous, and `latestDeploymentAttempt` is the attempt handle — returned only here. Poll activation with `isDeployed` and `deployedAt` on the workflow, or `isActive` on `GET /workflows/{id}/versions`." }, "DeployWorkflowResponse": { "type": "object", @@ -3394,7 +3410,8 @@ } ] } - } + }, + "additionalProperties": false }, "UndeployResult": { "type": "object", @@ -3620,7 +3637,8 @@ "minimum": 1, "maximum": 2147483647 } - } + }, + "additionalProperties": false }, "WorkflowExportPayload": { "type": "object", @@ -3670,7 +3688,9 @@ }, "folderPath": { "type": "string", - "description": "Canonical containing-folder path; `/` is the workspace root." + "title": "Folder path", + "description": "Canonical containing-folder path; `/` is the workspace root.", + "maxLength": 4096 } }, "required": ["id", "name", "description", "workspaceId", "folderPath"], @@ -3748,7 +3768,9 @@ }, "folderPath": { "type": "string", - "description": "Canonical containing-folder path." + "title": "Folder path", + "description": "Canonical containing-folder path.", + "maxLength": 4096 }, "createdAt": { "type": "string", @@ -3825,7 +3847,7 @@ }, "folderPath": { "description": "Destination folder path; omit for the workspace root.", - "type": "string" + "$ref": "#/components/schemas/FolderPathInput" }, "name": { "description": "Override for the imported workflow name.", @@ -3936,7 +3958,7 @@ "required": ["runId", "workflowId", "status", "output", "error"], "additionalProperties": false, "title": "Workflow run result", - "description": "Synchronous workflow run output and in-band execution status. Run failures are reported in band, not as HTTP errors — a synchronous run that exceeds its execution timeout returns HTTP 200 with `status: \"failed\"` and `error.code: \"TIMEOUT\"`, so always branch on `status` rather than on the HTTP status alone." + "description": "Synchronous workflow run output and in-band execution status. Run failures are reported in band, not as HTTP errors — a run that exceeds its execution timeout returns HTTP 200 with `status: \"failed\"` and `error.code: \"TIMEOUT\"`, so branch on `status`." }, "ExecuteWorkflowSyncResponse": { "type": "object", @@ -4029,7 +4051,7 @@ "type": "boolean" }, "executionTimeoutSeconds": { - "description": "Requested server-side timeout for an asynchronous run, in seconds. This is an upper bound on the request, not the effective timeout: the run uses the smaller of this value and the account plan's execution timeout, so requesting more than the plan allows silently yields the plan timeout with no warning. Rejected with 400 unless `async` is true.", + "description": "Requested server-side timeout for an asynchronous run, in seconds. An upper bound, not the effective timeout: the run uses the smaller of this value and the plan's execution timeout, so requesting more than the plan allows silently yields the plan timeout. Rejected with `400` unless `async` is true.", "type": "integer", "minimum": 1, "maximum": 604800 @@ -4059,11 +4081,11 @@ "type": "boolean" }, "includeFileBase64": { - "description": "Inline eligible output files as base64 content.", + "description": "Inline eligible output files as base64 content. Rejected when `async` is true.", "type": "boolean" }, "base64MaxBytes": { - "description": "Maximum total bytes of file content to inline as base64.", + "description": "Maximum total bytes of file content to inline as base64. Rejected when `async` is true.", "type": "integer", "exclusiveMinimum": 0, "maximum": 10485760 @@ -4071,7 +4093,7 @@ }, "additionalProperties": false, "title": "Execute workflow request", - "description": "Input and execution-mode options for a deployed workflow. Option constraints — each is a 400: (1) `async: true` requires an API key; anonymous public-workflow callers may only execute synchronously or as a stream. (2) `async` and `stream` cannot both be true. (3) `executionTimeoutSeconds` is accepted only when `async: true`. (4) `async: true` rejects every streaming and output-shaping option — `selectedOutputs`, `includeThinking`, `includeToolCalls`, `includeFileBase64`, and `base64MaxBytes`. (5) `includeThinking` and `includeToolCalls` require `stream: true`. (6) `includeThinking` and `includeToolCalls` require the `X-Sim-Stream-Protocol: agent-events-v1` request header, which declares that the client understands agent-event frames.", + "description": "Input and execution-mode options for a deployed workflow. Each option carries the modes it requires and the modes that reject it; a violated combination is a 400.", "examples": [ { "input": { @@ -4118,7 +4140,7 @@ "failed", "cancelled" ], - "description": "Current or terminal run status. `redacting` is transient, reported while the output of a finished run is being scrubbed. `paused` means the run is not executing and is waiting to be resumed: either held at a human-in-the-loop pause point, or left paused because a resume attempt did not run to completion. The status alone does not say which. On the single-run response `paused.automaticResumeWaitingReason` distinguishes them: it is recorded whenever a resume attempt fails and cleared once a resume succeeds, so a null value means the run is waiting on human input. When the failure is not retryable or the automatic retries are exhausted, the reason is prefixed `Automatic resume requires manual intervention: `. Run-list items carry no `paused` object, so the two cases are indistinguishable there." + "description": "Current or terminal run status. `redacting` is transient, reported while a finished run's output is being scrubbed. `paused` means the run is waiting to be resumed — either held at a human-in-the-loop pause point, or left paused by a resume attempt that did not complete. Only the single-run response distinguishes the two, through `paused.automaticResumeWaitingReason`." }, "trigger": { "type": "string", @@ -4205,7 +4227,7 @@ "type": "null" } ], - "description": "Opaque cursor for the next page: send it back as `cursor` to continue, and stop when it is null. Most v2 lists page, so null means the last page was reached. A few are full-set lists that return their whole bounded result in one response and therefore always report null; those say so in the operation description. Either way, null means there is nothing further to fetch — never construct a cursor yourself." + "description": "Opaque cursor for the next page. Send it back as `cursor`; `null` means there is nothing further to fetch. Never construct one yourself." } }, "required": ["data", "nextCursor"], @@ -4259,7 +4281,7 @@ "cancelled", "queued" ], - "description": "Current or terminal run status. `redacting` is transient, reported while the output of a finished run is being scrubbed. `paused` means the run is not executing and is waiting to be resumed: either held at a human-in-the-loop pause point, or left paused because a resume attempt did not run to completion. The status alone does not say which. On the single-run response `paused.automaticResumeWaitingReason` distinguishes them: it is recorded whenever a resume attempt fails and cleared once a resume succeeds, so a null value means the run is waiting on human input. When the failure is not retryable or the automatic retries are exhausted, the reason is prefixed `Automatic resume requires manual intervention: `. Run-list items carry no `paused` object, so the two cases are indistinguishable there." + "description": "Current or terminal run status. `redacting` is transient, reported while a finished run's output is being scrubbed. `paused` means the run is waiting to be resumed — either held at a human-in-the-loop pause point, or left paused by a resume attempt that did not complete. Only the single-run response distinguishes the two, through `paused.automaticResumeWaitingReason`." }, "trigger": { "anyOf": [ @@ -4374,7 +4396,7 @@ "type": "null" } ], - "description": "Reason automatic resume is waiting, or null when it is not waiting." + "description": "Why automatic resume is waiting, or null when it is not — on a paused run, null means it is waiting on human input. Recorded whenever a resume attempt fails and cleared once one succeeds. A non-retryable or exhausted failure is prefixed `Automatic resume requires manual intervention: `." }, "pausePointCount": { "type": "number", @@ -4650,7 +4672,7 @@ "description": "Whether a paused execution was cancelled." }, "reason": { - "description": "Machine-readable cancellation outcome. Present on every cancellation, including full successes — it is not a partial-failure marker. `recorded` means cancellation was durably recorded (the normal success value). `redis_unavailable` and `redis_write_failed` mean the distributed cancellation signal could not be written, so an already-running execution may not observe the cancellation. `paused_event_publish_failed` and `paused_database_cancel_failed` name the failing step when cancelling a paused human-in-the-loop run.", + "description": "Machine-readable cancellation outcome, present on every cancellation including full successes. `recorded` is the success value. `redis_unavailable` and `redis_write_failed` mean the distributed cancellation signal was not written, so an already-running execution may not observe the cancellation. `paused_event_publish_failed` and `paused_database_cancel_failed` name the failing step for a paused run.", "type": "string", "enum": [ "recorded", @@ -4671,7 +4693,7 @@ ], "additionalProperties": false, "title": "Cancel workflow run result", - "description": "Outcome of a workflow run cancellation request. Cancelling a run that has already reached a terminal state (completed, failed, or cancelled) succeeds with no effect rather than returning an error — treat this endpoint as best-effort and poll the run to observe the final state." + "description": "Outcome of a workflow run cancellation request. Cancellation is best-effort: a run already in a terminal state succeeds with no effect, so poll the run to observe its final state." }, "CancelWorkflowRunResponse": { "type": "object", @@ -4708,11 +4730,15 @@ }, "path": { "type": "string", - "description": "Canonical folder path used as the public folder identifier." + "title": "Non-root folder path", + "description": "Canonical folder path used as the public folder identifier.", + "maxLength": 4096 }, "parentPath": { "type": "string", - "description": "Canonical parent path; `/` is the root." + "title": "Folder path", + "description": "Canonical parent path; `/` is the root.", + "maxLength": 4096 }, "createdAt": { "type": "string", @@ -4753,7 +4779,7 @@ "type": "null" } ], - "description": "Opaque cursor for the next page: send it back as `cursor` to continue, and stop when it is null. Most v2 lists page, so null means the last page was reached. A few are full-set lists that return their whole bounded result in one response and therefore always report null; those say so in the operation description. Either way, null means there is nothing further to fetch — never construct a cursor yourself." + "description": "Always `null` — this list has no `cursor` or `limit` param and returns its whole bounded set in one page. Present so the list can gain pages later without a shape change." } }, "required": ["data", "nextCursor"], @@ -4801,6 +4827,12 @@ } ] }, + "NonRootFolderPathInput": { + "title": "Non-root folder path input", + "description": "Non-root folder path. A missing leading slash is normalized before validation. Segments are percent-encoded, so a folder shown as \"New folder\" is `/New%20folder`: everything outside `A-Z a-z 0-9 - _ . ~` is escaped as uppercase hex, and only that exact encoding is accepted. A trailing slash, an empty segment, and a literal `.` or `..` segment are rejected. At most 64 segments and 4096 encoded bytes.", + "maxLength": 4096, + "type": "string" + }, "CreateWorkflowFolderRequest": { "type": "object", "properties": { @@ -4811,7 +4843,7 @@ }, "path": { "description": "Path of the folder to create.", - "type": "string" + "$ref": "#/components/schemas/NonRootFolderPathInput" } }, "required": ["workspaceId", "path"], @@ -4854,11 +4886,11 @@ }, "path": { "description": "Current folder path.", - "type": "string" + "$ref": "#/components/schemas/NonRootFolderPathInput" }, "destinationPath": { "description": "New full path for the folder and its descendants.", - "type": "string" + "$ref": "#/components/schemas/NonRootFolderPathInput" } }, "required": ["workspaceId", "path", "destinationPath"], @@ -4871,7 +4903,9 @@ "properties": { "path": { "type": "string", - "description": "Path of the deleted workflow folder." + "title": "Folder path", + "description": "Path of the deleted workflow folder.", + "maxLength": 4096 }, "deleted": { "type": "boolean", diff --git a/apps/realtime/src/database/operations.ts b/apps/realtime/src/database/operations.ts index 1cf601ae965..4e7baa85d78 100644 --- a/apps/realtime/src/database/operations.ts +++ b/apps/realtime/src/database/operations.ts @@ -8,6 +8,7 @@ import { workflowEdges, workflowSubflows, } from '@sim/db' +import { withUtcTimestamps } from '@sim/db/timestamps' import { createLogger } from '@sim/logger' import { getActiveWorkflowContext } from '@sim/platform-authz/workflow' import { @@ -222,16 +223,20 @@ const connectionString = // Realtime process footprint = this socketDb pool + the shared @sim/db pool. const socketDb = drizzle( instrumentPoolClient( - postgres(connectionString, { - prepare: false, - // See `packages/db/db.ts` — skips the per-connection pg_type roundtrip. - fetch_types: false, - idle_timeout: 10, - connect_timeout: 20, - max: 10, - onnotice: () => {}, - connection: { application_name: process.env.DB_APP_NAME ?? 'sim-realtime' }, - }), + postgres( + connectionString, + // `withUtcTimestamps` — see `packages/db/timestamps.ts`. + withUtcTimestamps({ + prepare: false, + // See `packages/db/db.ts` — skips the per-connection pg_type roundtrip. + fetch_types: false, + idle_timeout: 10, + connect_timeout: 20, + max: 10, + onnotice: () => {}, + connection: { application_name: process.env.DB_APP_NAME ?? 'sim-realtime' }, + }) + ), 'socketDb' ), { schema } diff --git a/apps/sim/app/api/help/route.ts b/apps/sim/app/api/help/route.ts index b5c25a9c5c3..3bbb7fef636 100644 --- a/apps/sim/app/api/help/route.ts +++ b/apps/sim/app/api/help/route.ts @@ -6,6 +6,7 @@ import { validationErrorResponse } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { generateRequestId } from '@/lib/core/utils/request' import { + isMultipartFieldValidationError, isPayloadSizeLimitError, MAX_MULTIPART_OVERHEAD_BYTES, readFormDataWithLimit, @@ -145,6 +146,9 @@ ${message} { status: 200 } ) } catch (error) { + if (isMultipartFieldValidationError(error)) { + return NextResponse.json({ error: error.message }, { status: 400 }) + } if (isPayloadSizeLimitError(error)) { logger.warn(`[${requestId}] Help request form data too large`, { message: error.message }) return NextResponse.json( diff --git a/apps/sim/app/api/mcp/servers/[id]/refresh/route.test.ts b/apps/sim/app/api/mcp/servers/[id]/refresh/route.test.ts index e31560a561d..55eb87f4ce7 100644 --- a/apps/sim/app/api/mcp/servers/[id]/refresh/route.test.ts +++ b/apps/sim/app/api/mcp/servers/[id]/refresh/route.test.ts @@ -98,6 +98,35 @@ describe('MCP server refresh route', () => { ) }) + /** + * `updatedAt` means "when the server's configuration last changed" and is one + * of the public list's keyset sorts, so a refresh must not stamp it. The + * service's discovery status write already holds that invariant; this route + * writes the same row from the UI's refresh button, and stamping it here moves + * the row to the head of `sortBy=updatedAt` under an in-flight v2 page, which + * duplicates some servers across pages and skips others. Liveness is published + * through `lastToolsRefresh`, `lastConnected`, and `lastError`. + */ + it('records the refresh without stamping updatedAt', async () => { + mockDiscoverServerTools.mockResolvedValueOnce([]) + + const request = new Request('http://localhost/api/mcp/servers/server-1/refresh', { + method: 'POST', + }) as NextRequest + await POST(request, { params: Promise.resolve({ id: 'server-1' }) }) + + const refreshWrites = dbChainMockFns.set.mock.calls.filter( + ([values]) => (values as Record)?.lastToolsRefresh !== undefined + ) + expect(refreshWrites.length).toBeGreaterThan(0) + for (const [values] of refreshWrites) { + expect( + (values as Record).updatedAt, + 'the refresh route stamped updatedAt, corrupting the updatedAt keyset page' + ).toBeUndefined() + } + }) + it('reports the discovery failure when status persistence leaves a stale connected row', async () => { const reflectedSecret = 'Bearer reflected-static-token' mockDiscoverServerTools.mockRejectedValueOnce( diff --git a/apps/sim/app/api/mcp/servers/[id]/refresh/route.ts b/apps/sim/app/api/mcp/servers/[id]/refresh/route.ts index 90a91aeae7d..550aa2f77d3 100644 --- a/apps/sim/app/api/mcp/servers/[id]/refresh/route.ts +++ b/apps/sim/app/api/mcp/servers/[id]/refresh/route.ts @@ -229,11 +229,20 @@ export const POST = withRouteHandler( const now = new Date() + /** + * Deliberately leaves `updatedAt` alone, matching the invariant + * `McpService.updateServerStatus` holds: `updatedAt` means "when the + * server's configuration last changed", and it is one of the public + * list's keyset sorts. A refresh stamping it moves the row to the head + * of `sortBy=updatedAt` under an in-flight page, so a caller walking the + * list while anyone presses this button sees servers duplicated across + * pages and others skipped. Refresh liveness is already published + * through `lastToolsRefresh`, `lastConnected`, and `lastError`. + */ const [refreshedServer] = await db .update(mcpServers) .set({ lastToolsRefresh: now, - updatedAt: now, }) .where( and( diff --git a/apps/sim/app/api/table/[tableId]/query/route.ts b/apps/sim/app/api/table/[tableId]/query/route.ts index 9d156eeb656..ebfb0507504 100644 --- a/apps/sim/app/api/table/[tableId]/query/route.ts +++ b/apps/sim/app/api/table/[tableId]/query/route.ts @@ -10,7 +10,7 @@ import type { Sort, TableSchema } from '@/lib/table' import { buildIdByName, sortSpecNamesToIds } from '@/lib/table/column-keys' import { TableQueryValidationError } from '@/lib/table/errors' import { validatePredicate, validateSortSpec } from '@/lib/table/query-builder/validate' -import { assertCursorSortBinding, decodeCursor } from '@/lib/table/rows/cursor' +import { assertCursorQueryBinding, decodeCursor } from '@/lib/table/rows/cursor' import { queryRows } from '@/lib/table/rows/service' import { predicateToStorage } from '@/lib/table/select-values' import { createTableRowsResponse } from '@/app/api/table/row-secret-provenance' @@ -84,7 +84,7 @@ export const POST = withRouteHandler(async (request: NextRequest, context: RowQu // Cursor↔sort binding: keyset cursors are default-order only; an offset // cursor must be replayed under the exact sort it was minted with. - if (cursor) assertCursorSortBinding(cursor, sort) + if (cursor) assertCursorQueryBinding(cursor, { sort, predicate }) const result = await queryRows( table, diff --git a/apps/sim/app/api/v1/files/route.ts b/apps/sim/app/api/v1/files/route.ts index 7cc3f0fbd51..36adcb392e4 100644 --- a/apps/sim/app/api/v1/files/route.ts +++ b/apps/sim/app/api/v1/files/route.ts @@ -6,6 +6,7 @@ import { v1ListFilesContract, v1UploadFileFormFieldsSchema } from '@/lib/api/con import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' import { generateRequestId } from '@/lib/core/utils/request' import { + isMultipartFieldValidationError, isPayloadSizeLimitError, MAX_MULTIPART_OVERHEAD_BYTES, readFileToBufferWithLimit, @@ -106,6 +107,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { if (isPayloadSizeLimitError(error)) { return NextResponse.json({ error: error.message }, { status: 413 }) } + if (isMultipartFieldValidationError(error)) { + return NextResponse.json({ error: error.message }, { status: 400 }) + } return NextResponse.json( { error: 'Request body must be valid multipart form data' }, { status: 400 } diff --git a/apps/sim/app/api/v1/knowledge/[id]/documents/route.ts b/apps/sim/app/api/v1/knowledge/[id]/documents/route.ts index 94999266c7a..2a5ddb92f2c 100644 --- a/apps/sim/app/api/v1/knowledge/[id]/documents/route.ts +++ b/apps/sim/app/api/v1/knowledge/[id]/documents/route.ts @@ -14,6 +14,7 @@ import { statusForOrchestrationError, } from '@/lib/core/orchestration/types' import { + isMultipartFieldValidationError, isPayloadSizeLimitError, MAX_MULTIPART_OVERHEAD_BYTES, readFormDataWithLimit, @@ -117,6 +118,9 @@ export const POST = withRouteHandler( if (isPayloadSizeLimitError(error)) { return NextResponse.json({ error: error.message }, { status: 413 }) } + if (isMultipartFieldValidationError(error)) { + return NextResponse.json({ error: error.message }, { status: 400 }) + } return NextResponse.json( { error: 'Request body must be valid multipart form data' }, { status: 400 } diff --git a/apps/sim/app/api/v2/audit-logs/route.test.ts b/apps/sim/app/api/v2/audit-logs/route.test.ts index ec46e6fe9d6..b77f30edc06 100644 --- a/apps/sim/app/api/v2/audit-logs/route.test.ts +++ b/apps/sim/app/api/v2/audit-logs/route.test.ts @@ -29,6 +29,7 @@ vi.mock('@/lib/audit-logs/application/get-audit-log', () => ({ getAuditLog: { operation: { id: 'audit_logs.read_detail' }, execute: mocks.get }, })) +import { REFILTERED_CURSOR_MESSAGE } from '@/lib/api/cursor-binding' import { OrchestrationError } from '@/lib/core/orchestration/types' import { GET as getDetail } from '@/app/api/v2/audit-logs/[id]/route' import { GET as listLogs } from '@/app/api/v2/audit-logs/route' @@ -84,7 +85,12 @@ describe('v2 audit-log routes', () => { const response = await listLogs(request) expect(response.status).toBe(200) - expect(await response.json()).toMatchObject({ data: [{ id: 'audit-1' }], nextCursor: 'next-1' }) + const body = await response.json() + expect(body).toMatchObject({ data: [{ id: 'audit-1' }] }) + /** The domain token travels inside the query-bound wrapper, not bare. */ + expect(JSON.parse(Buffer.from(body.nextCursor, 'base64').toString())).toMatchObject({ + inner: 'next-1', + }) expect(mocks.list).toHaveBeenCalledWith({ principal: auth.principal, input: expect.objectContaining({ @@ -96,6 +102,83 @@ describe('v2 audit-log routes', () => { expect(response.headers.get('x-ratelimit-limit')).toBe('100') }) + /** + * Pins the binding end-to-end — the mint in `present` and the read in + * `mapInput` — because the contract-level sweep only checks a hand-maintained + * map of param names and stays green when a route drops the stamp entirely. + */ + it('refuses a cursor minted under a different filter', async () => { + const minted = await listLogs( + new NextRequest( + 'http://localhost:3000/api/v2/audit-logs?organizationId=org-1&actorEmail=ada%40example.com' + ) + ) + const { nextCursor } = await minted.json() + expect(nextCursor).toEqual(expect.any(String)) + + mocks.list.mockClear() + const replayed = await listLogs( + new NextRequest( + `http://localhost:3000/api/v2/audit-logs?organizationId=org-1&actorEmail=bob%40example.com&cursor=${encodeURIComponent(nextCursor)}` + ) + ) + + expect(replayed.status).toBe(400) + expect((await replayed.json()).error.message).toBe(REFILTERED_CURSOR_MESSAGE) + expect(mocks.list).not.toHaveBeenCalled() + }) + + /** + * A window bound selects by instant, and the query schema admits every + * sub-second spelling of one, so the same window written a different way must + * resume rather than 400. + */ + it('resumes a cursor whose window bound is respelled to the same instant', async () => { + const minted = await listLogs( + new NextRequest( + 'http://localhost:3000/api/v2/audit-logs?organizationId=org-1&startDate=2026-01-01T00%3A00%3A00Z' + ) + ) + const { nextCursor } = await minted.json() + expect(nextCursor).toEqual(expect.any(String)) + + mocks.list.mockClear() + const resumed = await listLogs( + new NextRequest( + `http://localhost:3000/api/v2/audit-logs?organizationId=org-1&startDate=2026-01-01T00%3A00%3A00.000Z&cursor=${encodeURIComponent(nextCursor)}` + ) + ) + + expect(resumed.status).toBe(200) + expect(mocks.list).toHaveBeenCalled() + }) + + it('resumes a cursor replayed under the filters it was minted with', async () => { + const minted = await listLogs( + new NextRequest( + 'http://localhost:3000/api/v2/audit-logs?organizationId=org-1&actorEmail=ada%40example.com' + ) + ) + const { nextCursor } = await minted.json() + + mocks.list.mockClear() + const resumed = await listLogs( + new NextRequest( + `http://localhost:3000/api/v2/audit-logs?organizationId=org-1&actorEmail=ada%40example.com&cursor=${encodeURIComponent(nextCursor)}` + ) + ) + + expect(resumed.status).toBe(200) + expect(mocks.list).toHaveBeenCalledWith({ + principal: auth.principal, + input: expect.objectContaining({ + filters: expect.objectContaining({ actorEmail: 'ada@example.com' }), + cursor: 'next-1', + }), + request: expect.anything(), + }) + }) + it('projects typed admin-policy failures without leaking internals', async () => { mocks.list.mockRejectedValueOnce(new OrchestrationError('forbidden', 'Admin required')) diff --git a/apps/sim/app/api/v2/audit-logs/route.ts b/apps/sim/app/api/v2/audit-logs/route.ts index b9bc8743819..53022dff0d0 100644 --- a/apps/sim/app/api/v2/audit-logs/route.ts +++ b/apps/sim/app/api/v2/audit-logs/route.ts @@ -1,4 +1,5 @@ import { v2ListAuditLogsContract } from '@/lib/api/contracts/v2/audit-logs' +import { cursorScopeKey, instantScopePart } from '@/lib/api/cursor-binding' import { defineV2JsonRoute, v2ApiKeyAuth, @@ -8,6 +9,32 @@ import { import { listAuditLogs } from '@/lib/audit-logs/application/list-audit-logs' import { auditLogOperations } from '@/lib/audit-logs/application/operations' import { formatV2AuditLogEntry } from '@/app/api/v2/audit-logs/format' +import { encodeScopedCursor, readScopedCursor } from '@/app/api/v2/lib/response' + +/** Every param that changes which audit entries, in which order, this list returns. */ +function auditLogCursorFilters(query: { + organizationId: string + includeDeparted: boolean + action?: string + resourceType?: string + resourceId?: string + workspaceId?: string + actorEmail?: string + startDate?: string + endDate?: string +}) { + return cursorScopeKey({ + organizationId: query.organizationId, + includeDeparted: query.includeDeparted, + action: query.action, + resourceType: query.resourceType, + resourceId: query.resourceId, + workspaceId: query.workspaceId, + actorEmail: query.actorEmail, + startDate: instantScopePart(query.startDate), + endDate: instantScopePart(query.endDate), + }) +} export const dynamic = 'force-dynamic' export const revalidate = 0 @@ -38,11 +65,11 @@ export const GET = defineV2JsonRoute({ endDate: query.endDate, }, limit: query.limit, - cursor: query.cursor, + cursor: readScopedCursor(query.cursor, auditLogCursorFilters(query)), }), useCase: listAuditLogs, - present: ({ data, nextCursor }) => ({ + present: ({ data, nextCursor }, { query }) => ({ data: data.map(formatV2AuditLogEntry), - nextCursor: nextCursor ?? null, + nextCursor: nextCursor ? encodeScopedCursor(auditLogCursorFilters(query), nextCursor) : null, }), }) diff --git a/apps/sim/app/api/v2/billing/logs/route.test.ts b/apps/sim/app/api/v2/billing/logs/route.test.ts index b323e834a83..f1534458b47 100644 --- a/apps/sim/app/api/v2/billing/logs/route.test.ts +++ b/apps/sim/app/api/v2/billing/logs/route.test.ts @@ -24,9 +24,19 @@ vi.mock('@/lib/billing/application/list-billing-logs', () => ({ listBillingLogs: { operation: { id: 'billing.logs.list' }, execute: mocks.execute }, })) +import { cursorScopeKey } from '@/lib/api/cursor-binding' import { UNKNOWN_CURSOR_MESSAGE } from '@/lib/billing/core/usage-log' import { OrchestrationError } from '@/lib/core/orchestration/types' import { GET } from '@/app/api/v2/billing/logs/route' +import { encodeScopedCursor } from '@/app/api/v2/lib/response' + +/** A ledger cursor exactly as the route mints one, for the filters given. */ +function ledgerCursor( + inner: string, + filters: { source?: string; workspaceId?: string; period?: string } +): string { + return encodeScopedCursor(cursorScopeKey(filters), inner) +} const auth = { principal: { kind: 'personal_api_key' as const, userId: 'user-1', keyId: 'key-1' }, @@ -101,9 +111,12 @@ describe('GET /api/v2/billing/logs', () => { mocks.execute.mockRejectedValueOnce( new OrchestrationError('validation', UNKNOWN_CURSOR_MESSAGE) ) + const cursor = ledgerCursor('log-from-another-ledger', { period: '30d' }) const response = await GET( - new NextRequest('http://localhost:3000/api/v2/billing/logs?cursor=log-from-another-ledger') + new NextRequest( + `http://localhost:3000/api/v2/billing/logs?cursor=${encodeURIComponent(cursor)}` + ) ) expect(response.status).toBe(400) @@ -112,6 +125,76 @@ describe('GET /api/v2/billing/logs', () => { }) }) + /** + * The ledger cursor is a usage-event id, so it names a row rather than an + * ordinal — but which rows follow it depends entirely on the window and source + * filters, so replaying one across a changed filter walks a different ledger + * and never reaches the entries the caller narrowed to. + */ + it('rejects a cursor replayed under a different filter without reaching the ledger', async () => { + const cursor = ledgerCursor('usage-1', { period: '30d' }) + + const response = await GET( + new NextRequest( + `http://localhost:3000/api/v2/billing/logs?source=workflow&cursor=${encodeURIComponent(cursor)}` + ) + ) + + expect(response.status).toBe(400) + expect(await response.json()).toMatchObject({ + error: { code: 'BAD_REQUEST', message: expect.stringContaining('requested filters') }, + }) + expect(mocks.execute).not.toHaveBeenCalled() + }) + + /** + * An empty inner token reads as falsy in the ledger reader, so no cursor + * condition is applied and the caller walks the first page again — the very + * failure {@link UNKNOWN_CURSOR_MESSAGE} exists to make visible. + */ + it('rejects a cursor whose inner token is empty instead of restarting at page one', async () => { + const cursor = ledgerCursor('', { period: 'all' }) + + const response = await GET( + new NextRequest( + `http://localhost:3000/api/v2/billing/logs?period=all&limit=1&cursor=${encodeURIComponent(cursor)}` + ) + ) + + expect(response.status).toBe(400) + expect(mocks.execute).not.toHaveBeenCalled() + }) + + /** This operation takes neither param, so naming them sends the caller nowhere. */ + it('names the params a rejected cursor is actually bound to', async () => { + const response = await GET( + new NextRequest('http://localhost:3000/api/v2/billing/logs?cursor=not-a-cursor') + ) + + const body = await response.json() + expect(body.error.message).not.toContain('sortBy') + expect(body.error.message).not.toContain('sortOrder') + }) + + /** + * `0000` satisfies the published `\d{4}` date-time pattern but names no + * instant Postgres can store, so the value has to be refused before + * `resolveDateRange` turns it into a bind parameter. + */ + it('rejects a year-0000 custom range bound before it can reach the ledger', async () => { + const response = await GET( + new NextRequest( + `http://localhost:3000/api/v2/billing/logs?period=custom&startDate=${encodeURIComponent('0000-01-01T00:00:00Z')}` + ) + ) + + expect(response.status).toBe(400) + expect(await response.json()).toMatchObject({ + error: { code: 'BAD_REQUEST', message: expect.stringContaining('startDate') }, + }) + expect(mocks.execute).not.toHaveBeenCalled() + }) + it('authenticates before rejecting invalid custom ranges', async () => { const response = await GET( new NextRequest('http://localhost:3000/api/v2/billing/logs?period=custom') @@ -121,4 +204,81 @@ describe('GET /api/v2/billing/logs', () => { expect(v2RouteMocks.authenticate).toHaveBeenCalled() expect(mocks.execute).not.toHaveBeenCalled() }) + + it('rejects a window bound the effective period would discard', async () => { + const response = await GET( + new NextRequest( + 'http://localhost:3000/api/v2/billing/logs?startDate=2030-01-01T00:00:00Z&limit=100' + ) + ) + + expect(response.status).toBe(400) + expect(await response.json()).toMatchObject({ + error: { + code: 'BAD_REQUEST', + message: expect.stringContaining('period=custom'), + }, + }) + expect(mocks.execute).not.toHaveBeenCalled() + }) + + it('rejects an endDate paired with an explicit relative period', async () => { + const response = await GET( + new NextRequest( + 'http://localhost:3000/api/v2/billing/logs?period=7d&endDate=2026-07-01T00:00:00Z' + ) + ) + + expect(response.status).toBe(400) + expect(mocks.execute).not.toHaveBeenCalled() + }) + + it('rejects a window bound that is not a UTC ISO 8601 timestamp', async () => { + const response = await GET( + new NextRequest( + 'http://localhost:3000/api/v2/billing/logs?period=custom&startDate=2026-08-01' + ) + ) + + expect(response.status).toBe(400) + expect(await response.json()).toMatchObject({ + error: { code: 'BAD_REQUEST', message: expect.stringContaining('UTC ISO 8601') }, + }) + expect(mocks.execute).not.toHaveBeenCalled() + }) + + it('rejects an inverted custom range instead of answering with an empty page', async () => { + const response = await GET( + new NextRequest( + 'http://localhost:3000/api/v2/billing/logs?period=custom&startDate=2026-08-06T00:00:00Z&endDate=2026-08-05T00:00:00Z' + ) + ) + + expect(response.status).toBe(400) + expect(await response.json()).toMatchObject({ + error: { + code: 'BAD_REQUEST', + message: expect.stringContaining('startDate must be before or equal to endDate'), + }, + }) + expect(mocks.execute).not.toHaveBeenCalled() + }) + + it('forwards a valid custom range to the ledger read', async () => { + const response = await GET( + new NextRequest( + 'http://localhost:3000/api/v2/billing/logs?period=custom&startDate=2026-07-01T00:00:00Z&endDate=2026-07-31T00:00:00Z' + ) + ) + + expect(response.status).toBe(200) + expect(mocks.execute).toHaveBeenCalledWith({ + principal: auth.principal, + input: expect.objectContaining({ + startDate: new Date('2026-07-01T00:00:00Z'), + endDate: new Date('2026-07-31T00:00:00Z'), + }), + request: expect.anything(), + }) + }) }) diff --git a/apps/sim/app/api/v2/billing/logs/route.ts b/apps/sim/app/api/v2/billing/logs/route.ts index 03d02a8d715..f306ff40682 100644 --- a/apps/sim/app/api/v2/billing/logs/route.ts +++ b/apps/sim/app/api/v2/billing/logs/route.ts @@ -1,25 +1,53 @@ import { v2ListBillingLogsContract } from '@/lib/api/contracts/v2/billing' -import { - defineV2JsonRoute, - v2ApiKeyAuth, - v2OrchestrationErrorPolicy, - v2RateLimits, -} from '@/lib/api/server/routes' +import { cursorScopeKey, instantScopePart } from '@/lib/api/cursor-binding' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2BillingErrorPolicies } from '@/lib/billing/api/route-policies' import { listBillingLogs } from '@/lib/billing/application/list-billing-logs' import { billingOperations } from '@/lib/billing/application/operations' import { toBillingUsageLogSource, toInternalUsageLogSources } from '@/lib/billing/usage-sources' import { resolveDateRange } from '@/app/api/users/me/usage-logs/shared' +import { encodeScopedCursor, readScopedCursor } from '@/app/api/v2/lib/response' export const dynamic = 'force-dynamic' export const revalidate = 0 +/** + * Every param that changes which ledger entries, in which order, this list + * returns. + * + * The raw params are stamped, not the range `resolveDateRange` derives from + * them: a relative `period` resolves against the clock, so hashing the resolved + * window would produce a different stamp on every request and reject each next + * page. `period=30d` and an explicit custom range covering the same days are + * therefore two scopes, which is right — one is a moving window. + * + * The explicit bounds still bind by instant rather than spelling. That is a + * pure function of the caller's own text, so it collapses `…00Z` and `…00.000Z` + * without resolving anything against the clock. + */ +function billingLogCursorFilters(query: { + source?: string + workspaceId?: string + period?: string + startDate?: string + endDate?: string +}) { + return cursorScopeKey({ + source: query.source, + workspaceId: query.workspaceId, + period: query.period, + startDate: instantScopePart(query.startDate), + endDate: instantScopePart(query.endDate), + }) +} + /** Cursor-paged, credit-denominated billing ledger. */ export const GET = defineV2JsonRoute({ contract: v2ListBillingLogsContract, auth: v2ApiKeyAuth, operation: billingOperations.listLogs, rateLimit: v2RateLimits.publicApi, - errorPolicy: v2OrchestrationErrorPolicy, + errorPolicy: v2BillingErrorPolicies.concealWorkspaceAuthorization, mapInput: ({ query }) => { const dateRange = resolveDateRange(query.period, query.startDate, query.endDate) return { @@ -28,11 +56,11 @@ export const GET = defineV2JsonRoute({ startDate: dateRange.startDate, endDate: dateRange.endDate, limit: query.limit, - cursor: query.cursor, + cursor: readScopedCursor(query.cursor, billingLogCursorFilters(query)), } }, useCase: listBillingLogs, - present: ({ usage, creditsByLogId }) => ({ + present: ({ usage, creditsByLogId }, { query }) => ({ data: usage.logs.map((log) => ({ id: log.id, createdAt: log.createdAt, @@ -42,6 +70,9 @@ export const GET = defineV2JsonRoute({ runId: log.executionId ?? null, creditCost: creditsByLogId[log.id] ?? 0, })), - nextCursor: usage.pagination.hasMore ? (usage.pagination.nextCursor ?? null) : null, + nextCursor: + usage.pagination.hasMore && usage.pagination.nextCursor + ? encodeScopedCursor(billingLogCursorFilters(query), usage.pagination.nextCursor) + : null, }), }) diff --git a/apps/sim/app/api/v2/billing/status/route.test.ts b/apps/sim/app/api/v2/billing/status/route.test.ts index d7ccd3d07e4..bc82837a17b 100644 --- a/apps/sim/app/api/v2/billing/status/route.test.ts +++ b/apps/sim/app/api/v2/billing/status/route.test.ts @@ -24,7 +24,10 @@ vi.mock('@/lib/billing/application/get-billing-status', () => ({ getBillingStatus: { operation: { id: 'billing.status.read' }, execute: mocks.execute }, })) -import { OrchestrationError } from '@/lib/core/orchestration/types' +import { + PersonalApiKeysDisabledError, + WorkspaceApiKeyScopeAuthorizationError, +} from '@/lib/core/application' import { GET } from '@/app/api/v2/billing/status/route' const auth = { @@ -93,17 +96,34 @@ describe('GET /api/v2/billing/status', () => { } ) - it('projects typed workspace-policy errors', async () => { - mocks.execute.mockRejectedValueOnce( - new OrchestrationError('forbidden', 'API key is not authorized for this workspace') + it('names the cause of an actionable workspace-policy refusal', async () => { + mocks.execute.mockRejectedValueOnce(new PersonalApiKeysDisabledError()) + + const response = await GET( + new NextRequest('http://localhost:3000/api/v2/billing/status?workspaceId=workspace-1') ) + expect(response.status).toBe(403) + expect(await response.json()).toMatchObject({ + error: { code: 'FORBIDDEN', details: { code: 'PERSONAL_API_KEYS_DISABLED' } }, + }) + }) + + /** + * A workspace key naming another workspace must not learn that the workspace + * exists, so this refusal is answered exactly as an unknown workspace id is. + */ + it('conceals a cross-tenant workspace-key refusal as a not-found workspace', async () => { + mocks.execute.mockRejectedValueOnce(new WorkspaceApiKeyScopeAuthorizationError()) + const response = await GET( new NextRequest('http://localhost:3000/api/v2/billing/status?workspaceId=workspace-2') ) - expect(response.status).toBe(403) - expect(await response.json()).toMatchObject({ error: { code: 'FORBIDDEN' } }) + expect(response.status).toBe(404) + expect(await response.json()).toMatchObject({ + error: { code: 'NOT_FOUND', message: 'Workspace not found' }, + }) }) it('hides unknown billing infrastructure errors', async () => { diff --git a/apps/sim/app/api/v2/billing/status/route.ts b/apps/sim/app/api/v2/billing/status/route.ts index b5a7fd95b5f..50c7e8bd95d 100644 --- a/apps/sim/app/api/v2/billing/status/route.ts +++ b/apps/sim/app/api/v2/billing/status/route.ts @@ -1,10 +1,6 @@ import { v2GetBillingStatusContract } from '@/lib/api/contracts/v2/billing' -import { - defineV2JsonRoute, - v2ApiKeyAuth, - v2OrchestrationErrorPolicy, - v2RateLimits, -} from '@/lib/api/server/routes' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2BillingErrorPolicies } from '@/lib/billing/api/route-policies' import { getBillingStatus } from '@/lib/billing/application/get-billing-status' import { billingOperations } from '@/lib/billing/application/operations' @@ -17,7 +13,7 @@ export const GET = defineV2JsonRoute({ auth: v2ApiKeyAuth, operation: billingOperations.readStatus, rateLimit: v2RateLimits.publicApi, - errorPolicy: v2OrchestrationErrorPolicy, + errorPolicy: v2BillingErrorPolicies.concealWorkspaceAuthorization, mapInput: ({ query }) => ({ workspaceId: query.workspaceId }), useCase: getBillingStatus, present: (data) => ({ data }), diff --git a/apps/sim/app/api/v2/credentials/route.test.ts b/apps/sim/app/api/v2/credentials/route.test.ts index a84ba1f6d68..465d2cbd6be 100644 --- a/apps/sim/app/api/v2/credentials/route.test.ts +++ b/apps/sim/app/api/v2/credentials/route.test.ts @@ -28,6 +28,7 @@ vi.mock('@/lib/credentials/application/list-workspace-credentials', () => ({ })) import { V2_DEFAULT_PAGE_SIZE } from '@/lib/api/contracts/v2/shared' +import { REFILTERED_CURSOR_MESSAGE } from '@/lib/api/cursor-binding' import { GET } from '@/app/api/v2/credentials/route' const WORKSPACE_ID = '11111111-2222-4333-8444-555555555555' @@ -107,6 +108,72 @@ describe('GET /api/v2/credentials', () => { }) }) + /** + * Pins the binding end-to-end — the mint in `present` and the read in + * `mapInput` — because the contract-level sweep only checks a hand-maintained + * map of param names and stays green when a route drops the stamp entirely. + */ + it('refuses a cursor minted under a different filter', async () => { + mocks.execute.mockResolvedValue({ + credentials: [credential], + nextCursorKeys: ['2026-01-01T00:00:00.000Z', 'credential-1'], + sortBy: 'createdAt', + sortOrder: 'desc', + }) + + const minted = await GET( + new NextRequest( + `http://localhost:3000/api/v2/credentials?workspaceId=${WORKSPACE_ID}&search=zoom` + ) + ) + const { nextCursor } = await minted.json() + expect(nextCursor).toEqual(expect.any(String)) + + mocks.execute.mockClear() + const replayed = await GET( + new NextRequest( + `http://localhost:3000/api/v2/credentials?workspaceId=${WORKSPACE_ID}&search=slack&cursor=${encodeURIComponent(nextCursor)}` + ) + ) + + expect(replayed.status).toBe(400) + expect((await replayed.json()).error.message).toBe(REFILTERED_CURSOR_MESSAGE) + expect(mocks.execute).not.toHaveBeenCalled() + }) + + it('resumes a cursor replayed under the filters it was minted with', async () => { + mocks.execute.mockResolvedValue({ + credentials: [credential], + nextCursorKeys: ['2026-01-01T00:00:00.000Z', 'credential-1'], + sortBy: 'createdAt', + sortOrder: 'desc', + }) + + const minted = await GET( + new NextRequest( + `http://localhost:3000/api/v2/credentials?workspaceId=${WORKSPACE_ID}&search=zoom` + ) + ) + const { nextCursor } = await minted.json() + + mocks.execute.mockClear() + const resumed = await GET( + new NextRequest( + `http://localhost:3000/api/v2/credentials?workspaceId=${WORKSPACE_ID}&search=zoom&cursor=${encodeURIComponent(nextCursor)}` + ) + ) + + expect(resumed.status).toBe(200) + expect(mocks.execute).toHaveBeenCalledWith({ + principal: auth.principal, + input: expect.objectContaining({ + search: 'zoom', + cursorKeys: ['2026-01-01T00:00:00.000Z', 'credential-1'], + }), + request: expect.anything(), + }) + }) + it('projects credential metadata field by field without secret material', async () => { const response = await GET( new NextRequest(`http://localhost:3000/api/v2/credentials?workspaceId=${WORKSPACE_ID}`) diff --git a/apps/sim/app/api/v2/credentials/route.ts b/apps/sim/app/api/v2/credentials/route.ts index 057ab1012be..bccfffccbe2 100644 --- a/apps/sim/app/api/v2/credentials/route.ts +++ b/apps/sim/app/api/v2/credentials/route.ts @@ -1,4 +1,6 @@ +import type { V2Credential } from '@/lib/api/contracts/v2/credentials' import { v2ListCredentialsContract } from '@/lib/api/contracts/v2/credentials' +import { cursorScopeKey } from '@/lib/api/cursor-binding' import { defineV2JsonRoute, v2ApiKeyAuth, @@ -7,12 +9,47 @@ import { } from '@/lib/api/server/routes' import { listWorkspaceCredentials } from '@/lib/credentials/application/list-workspace-credentials' import { credentialOperations } from '@/lib/credentials/application/operations' -import { toV2Credential } from '@/app/api/v2/credentials/utils' -import { cursorSortKey, encodeSortedCursor, readSortedCursor } from '@/app/api/v2/lib/response' +import type { VisibleWorkspaceCredential } from '@/lib/credentials/queries' +import { readSortedCursor, writeSortedCursor } from '@/app/api/v2/lib/response' export const dynamic = 'force-dynamic' export const revalidate = 0 +/** Serialize connection metadata field by field so encrypted columns can never reach the wire. */ +function toV2Credential(row: VisibleWorkspaceCredential): V2Credential { + if (row.type !== 'oauth' && row.type !== 'service_account') { + throw new Error(`Secret credential type ${row.type} reached the credentials API`) + } + + return { + id: row.id, + type: row.type, + displayName: row.displayName, + description: row.description, + providerId: row.providerId, + accountId: row.accountId, + hasServiceAccountKey: row.hasServiceAccountKey, + role: row.role, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + } +} + +/** Every param that changes which credentials, in which order, this list returns. */ +function credentialCursorFilters(query: { + workspaceId: string + type?: string + providerId?: string + search?: string +}) { + return cursorScopeKey({ + workspaceId: query.workspaceId, + type: query.type, + providerId: query.providerId, + search: query.search, + }) +} + /** GET /api/v2/credentials — List the credentials the caller can see in a workspace. */ export const GET = defineV2JsonRoute({ contract: v2ListCredentialsContract, @@ -22,13 +59,21 @@ export const GET = defineV2JsonRoute({ errorPolicy: v2OrchestrationErrorPolicy, mapInput: ({ query }) => ({ ...query, - cursorKeys: readSortedCursor(query.cursor, query.sortBy, query.sortOrder), + cursorKeys: readSortedCursor( + query.cursor, + query.sortBy, + query.sortOrder, + credentialCursorFilters(query) + ), }), useCase: listWorkspaceCredentials, - present: ({ credentials, nextCursorKeys, sortBy, sortOrder }) => ({ + present: ({ credentials, nextCursorKeys }, { query }) => ({ data: credentials.map(toV2Credential), - nextCursor: nextCursorKeys - ? encodeSortedCursor(cursorSortKey(sortBy, sortOrder), nextCursorKeys) - : null, + nextCursor: writeSortedCursor( + nextCursorKeys, + query.sortBy, + query.sortOrder, + credentialCursorFilters(query) + ), }), }) diff --git a/apps/sim/app/api/v2/credentials/utils.ts b/apps/sim/app/api/v2/credentials/utils.ts deleted file mode 100644 index e186a4f1558..00000000000 --- a/apps/sim/app/api/v2/credentials/utils.ts +++ /dev/null @@ -1,22 +0,0 @@ -import type { V2Credential } from '@/lib/api/contracts/v2/credentials' -import type { VisibleWorkspaceCredential } from '@/lib/credentials/queries' - -/** Serialize connection metadata field by field so encrypted columns can never reach the wire. */ -export function toV2Credential(row: VisibleWorkspaceCredential): V2Credential { - if (row.type !== 'oauth' && row.type !== 'service_account') { - throw new Error(`Secret credential type ${row.type} reached the credentials API`) - } - - return { - id: row.id, - type: row.type, - displayName: row.displayName, - description: row.description, - providerId: row.providerId, - accountId: row.accountId, - hasServiceAccountKey: row.hasServiceAccountKey, - role: row.role, - createdAt: row.createdAt.toISOString(), - updatedAt: row.updatedAt.toISOString(), - } -} diff --git a/apps/sim/app/api/v2/custom-tools/[id]/route.test.ts b/apps/sim/app/api/v2/custom-tools/[id]/route.test.ts index b55abafc968..ca4784712e4 100644 --- a/apps/sim/app/api/v2/custom-tools/[id]/route.test.ts +++ b/apps/sim/app/api/v2/custom-tools/[id]/route.test.ts @@ -93,18 +93,22 @@ const tool = { } const context = { params: Promise.resolve({ id: tool.id }) } +/** + * The read and delete verbs scope themselves with `?workspaceId=`; the write + * verb carries `workspaceId` in its body. Sending the query copy on a write is + * now a 400 rather than a silently dropped key, so the helper only appends it + * where the contract declares it. + */ function request(method: 'GET' | 'PATCH' | 'DELETE', body?: unknown) { - return new NextRequest( - `http://localhost:3000/api/v2/custom-tools/${tool.id}?workspaceId=${WORKSPACE_ID}`, - { - method, - headers: { - 'x-api-key': 'key', - ...(body === undefined ? {} : { 'Content-Type': 'application/json' }), - }, - ...(body === undefined ? {} : { body: JSON.stringify(body) }), - } - ) + const query = method === 'PATCH' ? '' : `?workspaceId=${WORKSPACE_ID}` + return new NextRequest(`http://localhost:3000/api/v2/custom-tools/${tool.id}${query}`, { + method, + headers: { + 'x-api-key': 'key', + ...(body === undefined ? {} : { 'Content-Type': 'application/json' }), + }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }) } describe('/api/v2/custom-tools/[id]', () => { @@ -130,6 +134,25 @@ describe('/api/v2/custom-tools/[id]', () => { }) }) + /** + * Every list in this family rejects a query param it does not implement, so + * the single-resource reads must too. A caller who mistypes a flag otherwise + * gets a 200 that silently ignored it, which reads as confirmation the flag + * exists and does nothing. + */ + it('rejects a query param it does not implement', async () => { + const response = await GET( + new NextRequest( + `http://localhost:3000/api/v2/custom-tools/${tool.id}?workspaceId=${WORKSPACE_ID}&includeCodes=true`, + { method: 'GET', headers: { 'x-api-key': 'key' } } + ), + context + ) + + expect(response.status).toBe(400) + expect(mocks.get).not.toHaveBeenCalled() + }) + it('updates a custom tool through its semantic update operation', async () => { const response = await PATCH( request('PATCH', { workspaceId: WORKSPACE_ID, code: 'return 2' }), diff --git a/apps/sim/app/api/v2/custom-tools/route.test.ts b/apps/sim/app/api/v2/custom-tools/route.test.ts index b4609531cff..3b32d07ccab 100644 --- a/apps/sim/app/api/v2/custom-tools/route.test.ts +++ b/apps/sim/app/api/v2/custom-tools/route.test.ts @@ -55,6 +55,7 @@ vi.mock('@/lib/custom-tools/application/use-cases', () => ({ })) import { V2_DEFAULT_PAGE_SIZE } from '@/lib/api/contracts/v2/shared' +import { REFILTERED_CURSOR_MESSAGE } from '@/lib/api/cursor-binding' import { GET, POST } from '@/app/api/v2/custom-tools/route' const WORKSPACE_ID = 'workspace-1' @@ -135,6 +136,66 @@ describe('/api/v2/custom-tools', () => { ) }) + /** + * Pins the binding end-to-end — the mint in `present` and the read in + * `mapInput` — because the contract-level sweep only checks a hand-maintained + * map of param names and stays green when a route drops the stamp entirely. + */ + it('refuses a cursor minted under a different filter', async () => { + mocks.list.mockResolvedValue({ + tools: [tool], + nextCursorKeys: ['2026-01-01T00:00:00.000Z', 'tool-1'], + }) + + const minted = await GET( + request('GET', `/api/v2/custom-tools?workspaceId=${WORKSPACE_ID}&search=lookup`) + ) + const { nextCursor } = await minted.json() + expect(nextCursor).toEqual(expect.any(String)) + + mocks.list.mockClear() + const replayed = await GET( + request( + 'GET', + `/api/v2/custom-tools?workspaceId=${WORKSPACE_ID}&search=refund&cursor=${encodeURIComponent(nextCursor)}` + ) + ) + + expect(replayed.status).toBe(400) + expect((await replayed.json()).error.message).toBe(REFILTERED_CURSOR_MESSAGE) + expect(mocks.list).not.toHaveBeenCalled() + }) + + it('resumes a cursor replayed under the filters it was minted with', async () => { + mocks.list.mockResolvedValue({ + tools: [tool], + nextCursorKeys: ['2026-01-01T00:00:00.000Z', 'tool-1'], + }) + + const minted = await GET( + request('GET', `/api/v2/custom-tools?workspaceId=${WORKSPACE_ID}&search=lookup`) + ) + const { nextCursor } = await minted.json() + + mocks.list.mockClear() + const resumed = await GET( + request( + 'GET', + `/api/v2/custom-tools?workspaceId=${WORKSPACE_ID}&search=lookup&cursor=${encodeURIComponent(nextCursor)}` + ) + ) + + expect(resumed.status).toBe(200) + expect(mocks.list).toHaveBeenCalledWith({ + principal: PRINCIPAL, + input: expect.objectContaining({ + search: 'lookup', + cursorKeys: ['2026-01-01T00:00:00.000Z', 'tool-1'], + }), + request: expect.anything(), + }) + }) + it('creates exactly one custom tool with the v2 source and status', async () => { const response = await POST( request('POST', '/api/v2/custom-tools', { diff --git a/apps/sim/app/api/v2/custom-tools/route.ts b/apps/sim/app/api/v2/custom-tools/route.ts index 88d691efd3a..ca027ae013a 100644 --- a/apps/sim/app/api/v2/custom-tools/route.ts +++ b/apps/sim/app/api/v2/custom-tools/route.ts @@ -2,6 +2,7 @@ import { v2CreateCustomToolContract, v2ListCustomToolsContract, } from '@/lib/api/contracts/v2/custom-tools' +import { cursorScopeKey } from '@/lib/api/cursor-binding' import { defineV2JsonRoute, v2ApiKeyAuth, @@ -14,11 +15,19 @@ import { listWorkspaceCustomToolsUseCase, } from '@/lib/custom-tools/application/use-cases' import { toV2CustomTool } from '@/app/api/v2/custom-tools/utils' -import { cursorSortKey, encodeSortedCursor, readSortedCursor } from '@/app/api/v2/lib/response' +import { readSortedCursor, writeSortedCursor } from '@/app/api/v2/lib/response' export const dynamic = 'force-dynamic' export const revalidate = 0 +/** Every param that changes which custom tools, in which order, this list returns. */ +function customToolCursorFilters(query: { workspaceId: string; search?: string }) { + return cursorScopeKey({ + workspaceId: query.workspaceId, + search: query.search, + }) +} + /** GET /api/v2/custom-tools — List custom tools in a workspace. */ export const GET = defineV2JsonRoute({ contract: v2ListCustomToolsContract, @@ -28,14 +37,22 @@ export const GET = defineV2JsonRoute({ errorPolicy: v2OrchestrationErrorPolicy, mapInput: ({ query }) => ({ ...query, - cursorKeys: readSortedCursor(query.cursor, query.sortBy, query.sortOrder), + cursorKeys: readSortedCursor( + query.cursor, + query.sortBy, + query.sortOrder, + customToolCursorFilters(query) + ), }), useCase: listWorkspaceCustomToolsUseCase, - present: ({ tools, nextCursorKeys, sortBy, sortOrder }) => ({ + present: ({ tools, nextCursorKeys }, { query }) => ({ data: tools.map(toV2CustomTool), - nextCursor: nextCursorKeys - ? encodeSortedCursor(cursorSortKey(sortBy, sortOrder), nextCursorKeys) - : null, + nextCursor: writeSortedCursor( + nextCursorKeys, + query.sortBy, + query.sortOrder, + customToolCursorFilters(query) + ), }), }) diff --git a/apps/sim/app/api/v2/custom-tools/utils.ts b/apps/sim/app/api/v2/custom-tools/utils.ts index 516101065ad..d3f1181a10e 100644 --- a/apps/sim/app/api/v2/custom-tools/utils.ts +++ b/apps/sim/app/api/v2/custom-tools/utils.ts @@ -1,33 +1,8 @@ import type { customTools } from '@sim/db/schema' -import { getErrorMessage, getPostgresErrorCode } from '@sim/utils/errors' -import type { NextResponse } from 'next/server' import type { V2CustomTool } from '@/lib/api/contracts/v2/custom-tools' -import { v2Error } from '@/app/api/v2/lib/response' /** Shared serialization + error mapping for the v2 custom tool surface. */ -/** - * Classifies a title collision as a conflict so it surfaces as 409 rather than a - * generic 500. Two distinct failures reach here and both must be covered: - * - * - `upsertCustomTools` throws its own message when its in-transaction duplicate - * `SELECT` finds one. - * - Under a concurrent create or rename, both callers pass that `SELECT` too, and - * the loser is rejected by `custom_tools_workspace_title_unique` as a raw - * Postgres `23505` — whose message matches nothing, which is exactly the race - * the message check alone cannot see. - */ -export function v2CustomToolWriteError(error: unknown): NextResponse | null { - if (getPostgresErrorCode(error) === '23505') { - return v2Error('CONFLICT', 'A custom tool with that title already exists in this workspace') - } - const message = getErrorMessage(error, '') - if (/already exists in this workspace/i.test(message)) { - return v2Error('CONFLICT', message) - } - return null -} - type CustomToolRow = typeof customTools.$inferSelect /** diff --git a/apps/sim/app/api/v2/files/[fileId]/route.test.ts b/apps/sim/app/api/v2/files/[fileId]/route.test.ts index d4405e2afd0..973435bcc52 100644 --- a/apps/sim/app/api/v2/files/[fileId]/route.test.ts +++ b/apps/sim/app/api/v2/files/[fileId]/route.test.ts @@ -15,6 +15,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ download: vi.fn(), + authorizeDownload: vi.fn(), rename: vi.fn(), deleteFile: vi.fn(), getUserEmailsByIds: vi.fn(), @@ -24,6 +25,7 @@ vi.mock('@/lib/workspace-files/application/download-workspace-file', () => ({ downloadWorkspaceFileStream: { operation: { id: 'files.download', minimumRole: 'read', workspaceApiKey: 'allow' }, execute: mocks.download, + authorize: mocks.authorizeDownload, }, })) @@ -72,6 +74,12 @@ const auth = { keyType: 'workspace' as const, } +function headRequest(query: string): NextRequest { + return new NextRequest(`http://localhost:3000/api/v2/files/${FILE_ID}?${query}`, { + method: 'HEAD', + }) +} + function fileRecord(overrides: Record = {}) { return { id: FILE_ID, @@ -109,6 +117,49 @@ describe('v2 single-file routes', () => { deleted: true, }) mocks.getUserEmailsByIds.mockResolvedValue(new Map([['user-1', 'ada@example.com']])) + mocks.authorizeDownload.mockResolvedValue(undefined) + }) + + /** + * A download `HEAD` answered before the use case's workspace-scoped file + * resolution is an existence oracle: any valid API key draws a bodiless 200 + * for a file id whose `GET` answers 404. These pin the probe to the answer the + * download gives, and to still not auditing one. + */ + it('answers an authorized HEAD bodiless without auditing a download', async () => { + const response = await GET(headRequest(`workspaceId=${WORKSPACE_ID}`), context) + + expect(response.status).toBe(200) + expect(await response.text()).toBe('') + expect(mocks.download).not.toHaveBeenCalled() + expect(mocks.authorizeDownload).toHaveBeenCalledOnce() + }) + + it('does not confirm a file the caller cannot reach', async () => { + mocks.authorizeDownload.mockRejectedValueOnce(new NoWorkspaceAccessError()) + + const response = await GET(headRequest('workspaceId=someone-elses-workspace'), context) + + expect(response.status).toBe(404) + expect(mocks.download).not.toHaveBeenCalled() + }) + + it('does not confirm a file id that does not exist', async () => { + mocks.authorizeDownload.mockRejectedValueOnce( + new OrchestrationError('not_found', 'File not found') + ) + + const response = await GET(headRequest(`workspaceId=${WORKSPACE_ID}`), context) + + expect(response.status).toBe(404) + expect(mocks.download).not.toHaveBeenCalled() + }) + + it('rejects a HEAD missing the required workspaceId instead of answering 200', async () => { + const response = await GET(headRequest(''), context) + + expect(response.status).toBe(400) + expect(mocks.authorizeDownload).not.toHaveBeenCalled() }) it('downloads bytes through the binary adapter with operation rate headers', async () => { diff --git a/apps/sim/app/api/v2/files/[fileId]/route.ts b/apps/sim/app/api/v2/files/[fileId]/route.ts index 0a7bbc896bc..eb0df4f5f66 100644 --- a/apps/sim/app/api/v2/files/[fileId]/route.ts +++ b/apps/sim/app/api/v2/files/[fileId]/route.ts @@ -28,10 +28,14 @@ export const revalidate = 0 * Lookups are workspace-scoped (IDOR-safe): a file in another workspace 404s. * * A generated doc whose artifact is still compiling renders `CONFLICT`; retry. + * + * `headSafe: false` because downloading records a `FILE_DOWNLOADED` audit event + * and pulls the bytes out of object storage. */ export const GET = defineV2BinaryRoute({ contract: v2DownloadFileContract, auth: v2ApiKeyAuth, + headSafe: false, operation: fileOperations.download, rateLimit: v2RateLimits.publicApi, errorPolicy: v2FileErrorPolicies.concealResourceAuthorization, diff --git a/apps/sim/app/api/v2/files/folders/route.test.ts b/apps/sim/app/api/v2/files/folders/route.test.ts index bbdf3ffdf82..8c42e0ebd66 100644 --- a/apps/sim/app/api/v2/files/folders/route.test.ts +++ b/apps/sim/app/api/v2/files/folders/route.test.ts @@ -272,6 +272,31 @@ describe('/api/v2/files/folders', () => { expect((await response.json()).error.code).toBe('NOT_FOUND') }) + it('rejects a percent-encoded NUL in a canonical path before the write reaches Postgres', async () => { + const created = await POST( + request('POST', '/api/v2/files/folders', { + workspaceId: WORKSPACE_ID, + path: '/apitest_%00x', + }), + context + ) + const relocated = await PATCH( + request('PATCH', '/api/v2/files/folders', { + workspaceId: WORKSPACE_ID, + path: '/Reports', + destinationPath: '/apitest_%00b', + }), + context + ) + + expect(created.status).toBe(400) + expect((await created.json()).error.code).toBe('BAD_REQUEST') + expect(relocated.status).toBe(400) + expect((await relocated.json()).error.code).toBe('BAD_REQUEST') + expect(mocks.createFolder).not.toHaveBeenCalled() + expect(mocks.updateFolder).not.toHaveBeenCalled() + }) + it('authenticates before parsing folder input', async () => { v2RouteMocks.authenticate.mockRejectedValueOnce(new MockV2ApiKeyUnauthenticatedError()) diff --git a/apps/sim/app/api/v2/files/route.test.ts b/apps/sim/app/api/v2/files/route.test.ts index 242bfbf5aa4..a1c162f1319 100644 --- a/apps/sim/app/api/v2/files/route.test.ts +++ b/apps/sim/app/api/v2/files/route.test.ts @@ -90,7 +90,6 @@ describe('/api/v2/files', () => { mocks.queryFiles.mockResolvedValue({ files: [FILE], nextKeys: undefined, - cursorSort: 'name:asc', }) mocks.createFile.mockResolvedValue({ file: FILE }) mocks.getUserEmailsByIds.mockResolvedValue(new Map([['user-1', 'ada@example.com']])) @@ -105,6 +104,36 @@ describe('/api/v2/files', () => { expect(mocks.queryFiles).not.toHaveBeenCalled() }) + /** + * `?limit=` is not `limit` omitted. `Number('') === 0`, and this list clamps + * out-of-range values, so an unrejected blank reaches the query as `LIMIT 1` + * and returns a single row where the omitted param returns a hundred — a + * silently wrong page, not an error. Whitespace-only is the same value. + */ + it.each(['limit=', 'limit=%20', 'sortBy=', 'cursor='])( + 'rejects the blank query value %s instead of coercing it', + async (param) => { + const response = await GET( + new NextRequest(`http://localhost:3000/api/v2/files?workspaceId=${WORKSPACE_ID}&${param}`) + ) + + expect(response.status).toBe(400) + expect((await response.json()).error.code).toBe('BAD_REQUEST') + expect(mocks.queryFiles).not.toHaveBeenCalled() + } + ) + + it('still applies the documented default when limit is omitted entirely', async () => { + const response = await GET( + new NextRequest(`http://localhost:3000/api/v2/files?workspaceId=${WORKSPACE_ID}`) + ) + + expect(response.status).toBe(200) + expect(mocks.queryFiles).toHaveBeenCalledWith( + expect.objectContaining({ input: expect.objectContaining({ limit: 100 }) }) + ) + }) + it('rejects an unauthenticated request', async () => { v2RouteMocks.authenticate.mockRejectedValueOnce(new MockV2ApiKeyUnauthenticatedError()) @@ -157,7 +186,6 @@ describe('/api/v2/files', () => { mocks.queryFiles.mockResolvedValueOnce({ files: [{ ...FILE, deletedAt: new Date('2026-08-06T00:00:00.000Z') }], nextKeys: undefined, - cursorSort: 'uploadedAt:asc', }) const response = await GET( new NextRequest( @@ -187,7 +215,6 @@ describe('/api/v2/files', () => { mocks.queryFiles.mockResolvedValueOnce({ files: [{ ...FILE, folderId: 'folder-1', folderPath: 'Finance\\/Legal' }], nextKeys: undefined, - cursorSort: 'name:asc', }) const response = await GET( new NextRequest(`http://localhost:3000/api/v2/files?workspaceId=${WORKSPACE_ID}`) @@ -197,6 +224,63 @@ describe('/api/v2/files', () => { expect((await response.json()).data[0].folderPath).toBe('/Finance%2FLegal') }) + /** + * A keyset cursor stays *coherent* under a changed filter, which is what makes + * it dangerous: replaying it under a narrowed `search` returns a correctly + * ordered page of the new matches that happen to sort after the old position, + * and silently omits every match before it. The caller sees an opaque token + * and a short page, and reads that as "almost nothing matched". + */ + it.each([ + ['search', 'search=quarterly'], + ['scope', 'scope=archived'], + ['folderPath', 'folderPath=/Finance'], + ])('refuses a cursor replayed under a different %s', async (_filter, param) => { + mocks.queryFiles.mockResolvedValueOnce({ files: [FILE], nextKeys: ['notes.md', FILE.id] }) + const firstPage = await ( + await GET(new NextRequest(`http://localhost:3000/api/v2/files?workspaceId=${WORKSPACE_ID}`)) + ).json() + expect(firstPage.nextCursor).toEqual(expect.any(String)) + mocks.queryFiles.mockClear() + + const response = await GET( + new NextRequest( + `http://localhost:3000/api/v2/files?workspaceId=${WORKSPACE_ID}&${param}&cursor=${encodeURIComponent(firstPage.nextCursor)}` + ) + ) + + expect(response.status).toBe(400) + expect(await response.json()).toMatchObject({ + error: { code: 'BAD_REQUEST', message: expect.stringContaining('requested filters') }, + }) + expect(mocks.queryFiles).not.toHaveBeenCalled() + }) + + /** + * `limit` is not part of the binding: it selects how much of the sequence to + * return, not what the sequence is. + */ + it('resumes a cursor under an unchanged filter and a changed page size', async () => { + mocks.queryFiles.mockResolvedValueOnce({ files: [FILE], nextKeys: ['notes.md', FILE.id] }) + const firstPage = await ( + await GET(new NextRequest(`http://localhost:3000/api/v2/files?workspaceId=${WORKSPACE_ID}`)) + ).json() + mocks.queryFiles.mockResolvedValueOnce({ files: [FILE], nextKeys: undefined }) + + const response = await GET( + new NextRequest( + `http://localhost:3000/api/v2/files?workspaceId=${WORKSPACE_ID}&limit=5&cursor=${encodeURIComponent(firstPage.nextCursor)}` + ) + ) + + expect(response.status).toBe(200) + expect(mocks.queryFiles).toHaveBeenLastCalledWith( + expect.objectContaining({ + input: expect.objectContaining({ limit: 5, after: ['notes.md', FILE.id] }), + }) + ) + }) + it('rejects malformed cursors before the application service', async () => { const response = await GET( new NextRequest( diff --git a/apps/sim/app/api/v2/files/route.ts b/apps/sim/app/api/v2/files/route.ts index 8a5a20ceee7..31930cf1e60 100644 --- a/apps/sim/app/api/v2/files/route.ts +++ b/apps/sim/app/api/v2/files/route.ts @@ -3,6 +3,7 @@ import { v2CreateFileContract, v2ListFilesContract, } from '@/lib/api/contracts/v2/files' +import { cursorScopeKey } from '@/lib/api/cursor-binding' import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' import { getFileExtension, getMimeTypeFromExtension } from '@/lib/uploads/utils/file-utils' import { v2FileErrorPolicies } from '@/lib/workspace-files/api' @@ -11,11 +12,26 @@ import { queryWorkspaceFilePage } from '@/lib/workspace-files/application/list-w import { fileOperations } from '@/lib/workspace-files/application/operations' import { MAX_WORKSPACE_FILE_INLINE_BODY_BYTES } from '@/lib/workspace-files/orchestration' import { toV2File, toV2Files } from '@/app/api/v2/files/utils' -import { cursorSortKey, encodeSortedCursor, readSortedCursor } from '@/app/api/v2/lib/response' +import { readSortedCursor, writeSortedCursor } from '@/app/api/v2/lib/response' export const dynamic = 'force-dynamic' export const revalidate = 0 +/** Every param that changes which files, in which order, this list returns. */ +function fileCursorFilters(query: { + workspaceId: string + scope?: string + folderPath?: string + search?: string +}) { + return cursorScopeKey({ + workspaceId: query.workspaceId, + scope: query.scope, + folderPath: query.folderPath, + search: query.search, + }) +} + /** GET /api/v2/files — List files with search, sort, and cursor pagination. */ export const GET = defineV2JsonRoute({ contract: v2ListFilesContract, @@ -31,13 +47,20 @@ export const GET = defineV2JsonRoute({ sortBy: query.sortBy, sortOrder: query.sortOrder, limit: query.limit, - after: readSortedCursor(query.cursor, query.sortBy, query.sortOrder), - cursorSort: cursorSortKey(query.sortBy, query.sortOrder), + after: readSortedCursor(query.cursor, query.sortBy, query.sortOrder, fileCursorFilters(query)), }), useCase: queryWorkspaceFilePage, - present: async ({ files, nextKeys, cursorSort }) => { + present: async ({ files, nextKeys }, { query }) => { const items: V2File[] = await toV2Files(files) - return { data: items, nextCursor: nextKeys ? encodeSortedCursor(cursorSort, nextKeys) : null } + return { + data: items, + nextCursor: writeSortedCursor( + nextKeys, + query.sortBy, + query.sortOrder, + fileCursorFilters(query) + ), + } }, }) diff --git a/apps/sim/app/api/v2/knowledge/[id]/documents/collection.test.ts b/apps/sim/app/api/v2/knowledge/[id]/documents/collection.test.ts index b199cbc898e..6f018f33d1b 100644 --- a/apps/sim/app/api/v2/knowledge/[id]/documents/collection.test.ts +++ b/apps/sim/app/api/v2/knowledge/[id]/documents/collection.test.ts @@ -117,7 +117,6 @@ describe('GET /api/v2/knowledge/[id]/documents', () => { documents: [DOCUMENT], tagDefinitions: TAG_DEFINITIONS, pagination: { total: 1, limit: 50, offset: 0, hasMore: false }, - cursorScope: 'scope', workspaceId: WORKSPACE_ID, }) }) @@ -151,19 +150,37 @@ describe('GET /api/v2/knowledge/[id]/documents', () => { ) }) - it('stamps the tag filters into the cursor scope so a replayed cursor cannot cross filters', async () => { + it('stamps the tag filters into the cursor so a replayed cursor cannot cross filters', async () => { const tagFilters = JSON.stringify([{ tagName: 'category', operator: 'eq', value: 'billing' }]) + mockListDocuments.mockResolvedValue({ + documents: [DOCUMENT], + tagDefinitions: TAG_DEFINITIONS, + pagination: { total: 4, limit: 2, offset: 0, hasMore: true }, + workspaceId: WORKSPACE_ID, + }) - await GET(buildListRequest(`?workspaceId=${WORKSPACE_ID}`), context) - await GET( - buildListRequest(`?workspaceId=${WORKSPACE_ID}&tagFilters=${encodeURIComponent(tagFilters)}`), + const unfiltered = await ( + await GET(buildListRequest(`?workspaceId=${WORKSPACE_ID}`), context) + ).json() + const filtered = await ( + await GET( + buildListRequest( + `?workspaceId=${WORKSPACE_ID}&tagFilters=${encodeURIComponent(tagFilters)}` + ), + context + ) + ).json() + + expect(unfiltered.nextCursor).not.toEqual(filtered.nextCursor) + + const replayed = await GET( + buildListRequest( + `?workspaceId=${WORKSPACE_ID}&tagFilters=${encodeURIComponent(tagFilters)}&cursor=${encodeURIComponent(unfiltered.nextCursor)}` + ), context ) - const [unfiltered, filtered] = mockListDocuments.mock.calls.map( - ([call]) => call.input.cursorScope - ) - expect(unfiltered).not.toEqual(filtered) + expect(replayed.status).toBe(400) }) it('rejects malformed and wrongly shaped tag filters with a 400', async () => { diff --git a/apps/sim/app/api/v2/knowledge/[id]/documents/route.test.ts b/apps/sim/app/api/v2/knowledge/[id]/documents/route.test.ts index 203817873d4..e85abb1cce8 100644 --- a/apps/sim/app/api/v2/knowledge/[id]/documents/route.test.ts +++ b/apps/sim/app/api/v2/knowledge/[id]/documents/route.test.ts @@ -20,7 +20,10 @@ const { mockPlatformUploaded, mockCapture, mockIsPayloadSizeLimitError, + mockIsMultipartFieldValidationError, + mockListDocuments, } = vi.hoisted(() => ({ + mockListDocuments: vi.fn(), mockAdmitUpload: vi.fn(), mockUploadDocument: vi.fn(), mockReadFormData: vi.fn(), @@ -28,6 +31,7 @@ const { mockPlatformUploaded: vi.fn(), mockCapture: vi.fn(), mockIsPayloadSizeLimitError: vi.fn(), + mockIsMultipartFieldValidationError: vi.fn(), })) vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => v2ApiKeyAuthModuleMock) @@ -37,7 +41,7 @@ vi.mock('@/app/api/v2/lib/gate', () => v2GateModuleMock) vi.mock('@/lib/knowledge/application/documents', () => ({ listKnowledgeDocuments: { operation: { id: 'knowledge.documents.list' }, - execute: vi.fn(), + execute: mockListDocuments, }, bulkUpdateKnowledgeDocuments: { operation: { id: 'knowledge.documents.bulk' }, @@ -56,6 +60,7 @@ vi.mock('@/lib/knowledge/application/documents', () => ({ vi.mock('@/lib/core/utils/stream-limits', () => ({ MAX_MULTIPART_OVERHEAD_BYTES: 1024 * 1024, isPayloadSizeLimitError: mockIsPayloadSizeLimitError, + isMultipartFieldValidationError: mockIsMultipartFieldValidationError, readFormDataWithLimit: mockReadFormData, readFileToBufferWithLimit: mockReadFile, })) @@ -66,11 +71,12 @@ vi.mock('@/lib/core/telemetry', () => ({ vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: mockCapture })) +import { REFILTERED_CURSOR_MESSAGE } from '@/lib/api/cursor-binding' import { OrchestrationError } from '@/lib/core/orchestration/types' import { KnowledgeUsageLimitExceededError } from '@/lib/knowledge/application/billing' import { MAX_KNOWLEDGE_DOCUMENT_FILE_SIZE } from '@/lib/uploads/shared/types' import { validateFileType } from '@/lib/uploads/utils/validation' -import { POST } from '@/app/api/v2/knowledge/[id]/documents/route' +import { GET, POST } from '@/app/api/v2/knowledge/[id]/documents/route' const WORKSPACE_ID = 'workspace-1' const PRINCIPAL = { kind: 'personal_api_key', userId: 'user-1', keyId: 'key-1' } as const @@ -89,6 +95,7 @@ describe('POST /api/v2/knowledge/[id]/documents', () => { v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) v2RouteMocks.gate.mockResolvedValue(null) mockIsPayloadSizeLimitError.mockReturnValue(false) + mockIsMultipartFieldValidationError.mockReturnValue(false) v2RouteMocks.authenticate.mockResolvedValue({ principal: PRINCIPAL, rolloutUserId: 'user-1', @@ -215,6 +222,24 @@ describe('POST /api/v2/knowledge/[id]/documents', () => { expect(mockPlatformUploaded).not.toHaveBeenCalled() }) + it('surfaces an unstorable multipart field as its own bad request', async () => { + const error = new Error( + 'Multipart file name for field "file" cannot contain a NUL character (U+0000)' + ) + mockReadFormData.mockRejectedValueOnce(error) + mockIsMultipartFieldValidationError.mockImplementation( + (candidate: unknown) => candidate === error + ) + + const response = await POST(buildRequest(), { params: Promise.resolve({ id: 'kb-1' }) }) + + expect(response.status).toBe(400) + expect(await response.json()).toEqual({ + error: { code: 'BAD_REQUEST', message: error.message }, + }) + expect(mockUploadDocument).not.toHaveBeenCalled() + }) + it('preserves bounded multipart rejection and stops before the upload operation', async () => { const error = new Error('knowledge document upload body exceeds maximum size') mockReadFormData.mockRejectedValueOnce(error) @@ -305,3 +330,121 @@ describe('POST /api/v2/knowledge/[id]/documents', () => { expect(mockCapture).not.toHaveBeenCalled() }) }) + +describe('GET /api/v2/knowledge/[id]/documents', () => { + const document = { + id: 'doc-1', + knowledgeBaseId: 'kb-1', + filename: 'support.txt', + fileUrl: 's3://workspace/support.txt', + fileSize: 5, + mimeType: 'text/plain', + processingStatus: 'completed', + chunkCount: 1, + tokenCount: 2, + characterCount: 5, + enabled: true, + uploadedAt: new Date('2024-01-01T00:00:00Z'), + } + + function listRequest(query: string) { + return new NextRequest(`http://localhost/api/v2/knowledge/kb-1/documents?${query}`, { + headers: { 'x-api-key': 'secret' }, + }) + } + + function list(query: string) { + return GET(listRequest(query), { params: Promise.resolve({ id: 'kb-1' }) }) + } + + beforeEach(() => { + vi.clearAllMocks() + v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) + v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) + v2RouteMocks.gate.mockResolvedValue(null) + v2RouteMocks.authenticate.mockResolvedValue({ + principal: PRINCIPAL, + rolloutUserId: 'user-1', + rateLimitSubjectIds: ['api-key:key-1', 'user:user-1'], + rateLimitSubscription: null, + keyType: 'personal', + }) + mockListDocuments.mockResolvedValue({ + documents: [document], + tagDefinitions: [], + pagination: { hasMore: true, offset: 0, limit: 1 }, + }) + }) + + /** + * An offset cursor is the weaker scheme: replayed under a different filter it + * names an ordinal in an unrelated sequence. Pins the binding end-to-end — the + * mint in `present` and the read in `mapInput` — because the contract-level + * sweep only checks a hand-maintained map of param names and stays green when + * a route drops the stamp entirely. + */ + it('refuses a cursor minted under a different filter', async () => { + const minted = await list(`workspaceId=${WORKSPACE_ID}&limit=1&search=support`) + const { nextCursor } = await minted.json() + expect(nextCursor).toEqual(expect.any(String)) + + mockListDocuments.mockClear() + const replayed = await list( + `workspaceId=${WORKSPACE_ID}&limit=1&search=billing&cursor=${encodeURIComponent(nextCursor)}` + ) + + expect(replayed.status).toBe(400) + expect((await replayed.json()).error.message).toBe(REFILTERED_CURSOR_MESSAGE) + expect(mockListDocuments).not.toHaveBeenCalled() + }) + + /** + * `tagFilters` binds through the contract's parser, so spellings that parse to + * one filter share one scope. The schema defaults `operator` to `eq` and AND + * is commutative, so omitting the operator, stating it, and reordering the + * clauses all name the same sequence and must all resume. + */ + it.each([ + [ + 'the default operator stated explicitly', + '[{"tagName":"a","value":"1","operator":"eq"},{"tagName":"b","value":"2","operator":"eq"}]', + ], + [ + 'a fieldType the resolver overrides with the stored definition', + '[{"tagName":"a","value":"1","fieldType":"text"},{"tagName":"b","value":"2","fieldType":"text"}]', + ], + ['the clauses reordered', '[{"tagName":"b","value":"2"},{"tagName":"a","value":"1"}]'], + ])('resumes a tag-filter cursor with %s', async (_label, replayFilters) => { + const mintFilters = '[{"tagName":"a","value":"1"},{"tagName":"b","value":"2"}]' + const minted = await list( + `workspaceId=${WORKSPACE_ID}&limit=1&tagFilters=${encodeURIComponent(mintFilters)}` + ) + const { nextCursor } = await minted.json() + expect(nextCursor).toEqual(expect.any(String)) + + mockListDocuments.mockClear() + const resumed = await list( + `workspaceId=${WORKSPACE_ID}&limit=1&tagFilters=${encodeURIComponent(replayFilters)}&cursor=${encodeURIComponent(nextCursor)}` + ) + + expect(resumed.status).toBe(200) + expect(mockListDocuments).toHaveBeenCalled() + }) + + it('resumes a cursor replayed under the filters it was minted with', async () => { + const minted = await list(`workspaceId=${WORKSPACE_ID}&limit=1&search=support`) + const { nextCursor } = await minted.json() + + mockListDocuments.mockClear() + const resumed = await list( + `workspaceId=${WORKSPACE_ID}&limit=1&search=support&cursor=${encodeURIComponent(nextCursor)}` + ) + + expect(resumed.status).toBe(200) + expect(mockListDocuments).toHaveBeenCalledWith({ + principal: PRINCIPAL, + input: expect.objectContaining({ search: 'support', offset: 1 }), + request: expect.anything(), + }) + }) +}) diff --git a/apps/sim/app/api/v2/knowledge/[id]/documents/route.ts b/apps/sim/app/api/v2/knowledge/[id]/documents/route.ts index 1f11166b8f8..c6440f890de 100644 --- a/apps/sim/app/api/v2/knowledge/[id]/documents/route.ts +++ b/apps/sim/app/api/v2/knowledge/[id]/documents/route.ts @@ -1,9 +1,11 @@ +import { omit } from '@sim/utils/object' import { parseV2KnowledgeTagFiltersParam, v2BulkUpdateKnowledgeDocumentsContract, v2ListKnowledgeDocumentsContract, v2UploadKnowledgeDocumentContract, } from '@/lib/api/contracts/v2/knowledge' +import { cursorScopeKey, unorderedScopeOf } from '@/lib/api/cursor-binding' import { defineV2BodyLifecycleRoute, defineV2JsonRoute, @@ -13,6 +15,7 @@ import { import { OrchestrationError } from '@/lib/core/orchestration/types' import { PlatformEvents } from '@/lib/core/telemetry' import { + isMultipartFieldValidationError, isPayloadSizeLimitError, MAX_MULTIPART_OVERHEAD_BYTES, readFileToBufferWithLimit, @@ -31,17 +34,42 @@ import { captureServerEvent } from '@/lib/posthog/server' import { MAX_KNOWLEDGE_DOCUMENT_FILE_SIZE } from '@/lib/uploads/shared/types' import { validateFileType } from '@/lib/uploads/utils/validation' import { toV2DocumentSummary, toV2TaggedDocument } from '@/app/api/v2/knowledge/utils' -import { - decodeOffsetCursor, - encodeOffsetCursor, - offsetCursorScope, -} from '@/app/api/v2/lib/response' +import { cursorSortKey, decodeOffsetCursor, encodeOffsetCursor } from '@/app/api/v2/lib/response' export const dynamic = 'force-dynamic' export const revalidate = 0 const MAX_FILE_SIZE = MAX_KNOWLEDGE_DOCUMENT_FILE_SIZE +/** + * Every param that changes which documents, in which order, this list returns. + * + * `tagFilters` binds through the contract's parser, not the raw query text: the + * schema defaults `operator` to `eq`, so `{tagName}` and `{tagName, operator}` + * are one filter to the query and must be one scope to the cursor. An + * unparseable value binds raw — that request is about to 400 anyway. + * + * `fieldType` is dropped: `resolveKnowledgeTagFilters` builds every structured + * filter with the stored definition's type and never reads the caller's, so + * stating it or omitting it selects the same documents. A scope part the query + * ignores refuses a cursor for a page that did not move. + */ +function documentCursorFilters( + knowledgeBaseId: string, + query: { workspaceId: string; enabledFilter?: string; search?: string; tagFilters?: string } +) { + const parsed = parseV2KnowledgeTagFiltersParam(query.tagFilters) + return cursorScopeKey({ + knowledgeBaseId, + workspaceId: query.workspaceId, + enabledFilter: query.enabledFilter, + search: query.search, + tagFilters: parsed.success + ? unorderedScopeOf(parsed.filters?.map((filter) => omit(filter, ['fieldType']))) + : query.tagFilters, + }) +} + /** GET /api/v2/knowledge/[id]/documents — List documents in a knowledge base. */ export const GET = defineV2JsonRoute({ contract: v2ListKnowledgeDocumentsContract, @@ -54,38 +82,31 @@ export const GET = defineV2JsonRoute({ if (!tagFilters.success) { throw new OrchestrationError('validation', tagFilters.message) } - /** - * The offset counts positions in the filtered, sorted document sequence, so - * every param that changes that sequence is stamped into the cursor and - * re-checked here. `limit` selects how much of the sequence to return, not - * what the sequence is, so it stays out. - */ - const cursorScope = offsetCursorScope({ - knowledgeBaseId: params.id, - enabledFilter: query.enabledFilter, - search: query.search, - sortBy: query.sortBy, - sortOrder: query.sortOrder, - tagFilters: query.tagFilters, - }) return { knowledgeBaseId: params.id, assertedWorkspaceId: query.workspaceId, enabledFilter: query.enabledFilter, search: query.search, limit: query.limit, - offset: decodeOffsetCursor(query.cursor, cursorScope), + offset: decodeOffsetCursor( + query.cursor, + cursorSortKey(query.sortBy, query.sortOrder), + documentCursorFilters(params.id, query) + ), sortBy: query.sortBy, sortOrder: query.sortOrder, tagNameFilters: tagFilters.filters, - cursorScope, } }, useCase: listKnowledgeDocuments, - present: ({ documents, tagDefinitions, pagination, cursorScope }) => ({ + present: ({ documents, tagDefinitions, pagination }, { params, query }) => ({ data: documents.map((document) => toV2TaggedDocument(document, tagDefinitions)), nextCursor: pagination.hasMore - ? encodeOffsetCursor(cursorScope ?? '', pagination.offset + pagination.limit) + ? encodeOffsetCursor( + cursorSortKey(query.sortBy, query.sortOrder), + documentCursorFilters(params.id, query), + pagination.offset + pagination.limit + ) : null, }), }) @@ -159,6 +180,9 @@ export const POST = defineV2BodyLifecycleRoute({ }) } catch (error) { if (isPayloadSizeLimitError(error)) throw error + if (isMultipartFieldValidationError(error)) { + throw new OrchestrationError('validation', error.message) + } throw new OrchestrationError('validation', 'Request body must be valid multipart form data') } diff --git a/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/[uploadId]/complete/route.test.ts b/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/[uploadId]/complete/route.test.ts index e1db73c90fe..96936470e88 100644 --- a/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/[uploadId]/complete/route.test.ts +++ b/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/[uploadId]/complete/route.test.ts @@ -69,7 +69,6 @@ vi.mock('@/app/api/v2/knowledge/[id]/documents/uploads/utils', () => ({ } : null, }), - v2KnowledgeDocumentUploadError: vi.fn(() => null), })) import { POST } from '@/app/api/v2/knowledge/[id]/documents/uploads/[uploadId]/complete/route' diff --git a/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/[uploadId]/complete/route.ts b/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/[uploadId]/complete/route.ts index aa9bf3e6751..930e3ec1d73 100644 --- a/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/[uploadId]/complete/route.ts +++ b/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/[uploadId]/complete/route.ts @@ -1,20 +1,18 @@ import { v2CompleteKnowledgeDocumentUploadContract } from '@/lib/api/contracts/v2/knowledge' import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' import { PlatformEvents } from '@/lib/core/telemetry' +import { v2KnowledgeErrorPolicies } from '@/lib/knowledge/api/route-policies' import { knowledgeOperations } from '@/lib/knowledge/application/operations' import { completeKnowledgeDocumentUpload } from '@/lib/knowledge/application/upload-sessions' import { captureServerEvent } from '@/lib/posthog/server' -import { - toV2KnowledgeDocumentUpload, - v2KnowledgeDocumentUploadError, -} from '@/app/api/v2/knowledge/[id]/documents/uploads/utils' +import { toV2KnowledgeDocumentUpload } from '@/app/api/v2/knowledge/[id]/documents/uploads/utils' export const POST = defineV2JsonRoute({ contract: v2CompleteKnowledgeDocumentUploadContract, auth: v2ApiKeyAuth, operation: knowledgeOperations.uploadComplete, rateLimit: v2RateLimits.publicApi, - errorPolicy: { render: v2KnowledgeDocumentUploadError }, + errorPolicy: v2KnowledgeErrorPolicies.concealKnowledgeBaseUploadAuthorization, mapInput: ({ params, query, headers }) => ({ knowledgeBaseId: params.id, assertedWorkspaceId: query.workspaceId, diff --git a/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/[uploadId]/parts/route.ts b/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/[uploadId]/parts/route.ts index 6640972b06b..8c7121c2af2 100644 --- a/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/[uploadId]/parts/route.ts +++ b/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/[uploadId]/parts/route.ts @@ -1,15 +1,15 @@ import { v2CreateKnowledgeDocumentUploadPartUrlsContract } from '@/lib/api/contracts/v2/knowledge' import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2KnowledgeErrorPolicies } from '@/lib/knowledge/api/route-policies' import { knowledgeOperations } from '@/lib/knowledge/application/operations' import { issueKnowledgeDocumentUploadParts } from '@/lib/knowledge/application/upload-sessions' -import { v2KnowledgeDocumentUploadError } from '@/app/api/v2/knowledge/[id]/documents/uploads/utils' export const POST = defineV2JsonRoute({ contract: v2CreateKnowledgeDocumentUploadPartUrlsContract, auth: v2ApiKeyAuth, operation: knowledgeOperations.uploadParts, rateLimit: v2RateLimits.publicApi, - errorPolicy: { render: v2KnowledgeDocumentUploadError }, + errorPolicy: v2KnowledgeErrorPolicies.concealKnowledgeBaseUploadAuthorization, mapInput: ({ params, query, headers, body }) => ({ knowledgeBaseId: params.id, assertedWorkspaceId: query.workspaceId, diff --git a/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/[uploadId]/route.ts b/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/[uploadId]/route.ts index 87194ec5d55..710eae7b8e5 100644 --- a/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/[uploadId]/route.ts +++ b/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/[uploadId]/route.ts @@ -1,18 +1,16 @@ import { v2AbortKnowledgeDocumentUploadContract } from '@/lib/api/contracts/v2/knowledge' import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2KnowledgeErrorPolicies } from '@/lib/knowledge/api/route-policies' import { knowledgeOperations } from '@/lib/knowledge/application/operations' import { cancelKnowledgeDocumentUpload } from '@/lib/knowledge/application/upload-sessions' -import { - toV2KnowledgeDocumentUpload, - v2KnowledgeDocumentUploadError, -} from '@/app/api/v2/knowledge/[id]/documents/uploads/utils' +import { toV2KnowledgeDocumentUpload } from '@/app/api/v2/knowledge/[id]/documents/uploads/utils' export const DELETE = defineV2JsonRoute({ contract: v2AbortKnowledgeDocumentUploadContract, auth: v2ApiKeyAuth, operation: knowledgeOperations.uploadCancel, rateLimit: v2RateLimits.publicApi, - errorPolicy: { render: v2KnowledgeDocumentUploadError }, + errorPolicy: v2KnowledgeErrorPolicies.concealKnowledgeBaseUploadAuthorization, mapInput: ({ params, query, headers }) => ({ knowledgeBaseId: params.id, assertedWorkspaceId: query.workspaceId, diff --git a/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/concealment.test.ts b/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/concealment.test.ts new file mode 100644 index 00000000000..b16d4788c37 --- /dev/null +++ b/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/concealment.test.ts @@ -0,0 +1,194 @@ +/** + * @vitest-environment node + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + authenticateV2ApiKey: vi.fn(), + cancel: vi.fn(), + checkRateLimitDirect: vi.fn(), + checkRateLimitDirectOrThrow: vi.fn(), + complete: vi.fn(), + create: vi.fn(), + gate: vi.fn(), + parts: vi.fn(), +})) + +function operation(id: string) { + return { id, minimumRole: 'write', workspaceApiKey: 'allow' } +} + +vi.mock('@/lib/knowledge/application/upload-sessions', () => ({ + KnowledgeDocumentUnsupportedMediaTypeError: class KnowledgeDocumentUnsupportedMediaTypeError extends Error {}, + createKnowledgeDocumentUpload: { + operation: operation('knowledge.documents.upload.create'), + execute: mocks.create, + }, + cancelKnowledgeDocumentUpload: { + operation: operation('knowledge.documents.upload.cancel'), + execute: mocks.cancel, + }, + issueKnowledgeDocumentUploadParts: { + operation: operation('knowledge.documents.upload.parts'), + execute: mocks.parts, + }, + completeKnowledgeDocumentUpload: { + operation: operation('knowledge.documents.upload.complete'), + execute: mocks.complete, + }, +})) + +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => ({ + authenticateV2ApiKey: mocks.authenticateV2ApiKey, + V2ApiKeyUnauthenticatedError: class V2ApiKeyUnauthenticatedError extends Error {}, +})) + +vi.mock('@/lib/core/rate-limiter', () => ({ + getRateLimit: () => ({ maxTokens: 100, refillRate: 50, refillIntervalMs: 60_000 }), + RateLimiter: class RateLimiter { + checkRateLimitDirect = mocks.checkRateLimitDirect + checkRateLimitDirectOrThrow = mocks.checkRateLimitDirectOrThrow + }, +})) + +vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mocks.gate })) +vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: vi.fn() })) + +import { + InsufficientWorkspacePermissionsError, + NoWorkspaceAccessError, +} from '@/lib/core/application' +import { POST as COMPLETE } from '@/app/api/v2/knowledge/[id]/documents/uploads/[uploadId]/complete/route' +import { POST as PARTS } from '@/app/api/v2/knowledge/[id]/documents/uploads/[uploadId]/parts/route' +import { DELETE as CANCEL } from '@/app/api/v2/knowledge/[id]/documents/uploads/[uploadId]/route' +import { POST as CREATE } from '@/app/api/v2/knowledge/[id]/documents/uploads/route' + +const WORKSPACE_ID = '6fc7631d-88cd-46f8-9f0a-d4764daef7f8' +const BASE = `http://localhost:3000/api/v2/knowledge/kb-1/documents/uploads` + +function context() { + return { params: Promise.resolve({ id: 'kb-1', uploadId: 'upload-1' }) } +} + +function controlHeaders() { + return { 'upload-token': 'token', 'x-api-key': 'secret' } +} + +/** + * Each entry pairs the route handler with the mocked use case behind it, so a + * case can make that one operation refuse and read the status the route + * renders. + */ +const routes = [ + { + name: 'create upload session', + useCase: mocks.create, + call: () => + CREATE( + new NextRequest(BASE, { + method: 'POST', + headers: { 'content-type': 'application/json', 'x-api-key': 'secret' }, + body: JSON.stringify({ + workspaceId: WORKSPACE_ID, + name: 'guide.pdf', + contentType: 'application/pdf', + size: 1024, + }), + }), + context() + ), + }, + { + name: 'abort upload session', + useCase: mocks.cancel, + call: () => + CANCEL( + new NextRequest(`${BASE}/upload-1?workspaceId=${WORKSPACE_ID}`, { + method: 'DELETE', + headers: controlHeaders(), + }), + context() + ), + }, + { + name: 'issue part urls', + useCase: mocks.parts, + call: () => + PARTS( + new NextRequest(`${BASE}/upload-1/parts?workspaceId=${WORKSPACE_ID}`, { + method: 'POST', + headers: { ...controlHeaders(), 'content-type': 'application/json' }, + body: JSON.stringify({ partNumbers: [1] }), + }), + context() + ), + }, + { + name: 'complete upload session', + useCase: mocks.complete, + call: () => + COMPLETE( + new NextRequest(`${BASE}/upload-1/complete?workspaceId=${WORKSPACE_ID}`, { + method: 'POST', + headers: controlHeaders(), + }), + context() + ), + }, +] as const + +/** + * The four knowledge upload routes are the only knowledge routes naming a + * knowledge base whose failures were not concealed, and their ordering made the + * gap an oracle: the use case resolves the knowledge-base context — which throws + * `not_found` when the base is absent *or* lives in another workspace — before + * workspace authorization runs. So an unconcealed 403 meant "this base exists in + * a workspace you cannot reach" and a 404 meant "it does not exist", while + * `GET /api/v2/knowledge/{id}` answers 404 to both. + */ +describe('v2 knowledge upload resource concealment', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.authenticateV2ApiKey.mockResolvedValue({ + principal: { kind: 'personal_api_key' as const, userId: 'user-1', keyId: 'key-1' }, + rolloutUserId: 'user-1', + rateLimitSubjectIds: ['api-key:key-1', 'user:user-1'], + rateLimitSubscription: null, + keyType: 'personal', + }) + mocks.gate.mockResolvedValue(null) + for (const limiter of [mocks.checkRateLimitDirect, mocks.checkRateLimitDirectOrThrow]) { + limiter.mockResolvedValue({ + allowed: true, + remaining: 99, + resetAt: new Date('2026-08-04T21:00:00.000Z'), + }) + } + }) + + it.each(routes)( + '$name reports a cross-tenant refusal as a missing knowledge base', + async ({ useCase, call }) => { + useCase.mockRejectedValue(new NoWorkspaceAccessError()) + + const response = await call() + + expect(response.status).toBe(404) + await expect(response.json()).resolves.toEqual({ + error: { code: 'NOT_FOUND', message: 'Knowledge base not found' }, + }) + } + ) + + it.each(routes)( + '$name still reports a same-workspace role denial as forbidden', + async ({ useCase, call }) => { + useCase.mockRejectedValue(new InsufficientWorkspacePermissionsError()) + + const response = await call() + + expect(response.status).toBe(403) + } + ) +}) diff --git a/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/control-routes.test.ts b/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/control-routes.test.ts index 8f90f4b030b..8a28f5c99c0 100644 --- a/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/control-routes.test.ts +++ b/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/control-routes.test.ts @@ -58,7 +58,6 @@ vi.mock('@/app/api/v2/knowledge/[id]/documents/uploads/utils', () => ({ error: null, document: null, }), - v2KnowledgeDocumentUploadError: vi.fn(() => null), })) import { POST as PARTS } from '@/app/api/v2/knowledge/[id]/documents/uploads/[uploadId]/parts/route' diff --git a/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/route.test.ts b/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/route.test.ts index 3f2e59f50c0..b8ab41afce9 100644 --- a/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/route.test.ts +++ b/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/route.test.ts @@ -50,7 +50,6 @@ vi.mock('@/app/api/v2/knowledge/[id]/documents/uploads/utils', () => ({ error: null, document: null, }), - v2KnowledgeDocumentUploadError: vi.fn(() => null), })) import { POST } from '@/app/api/v2/knowledge/[id]/documents/uploads/route' diff --git a/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/route.ts b/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/route.ts index 03f1ea7289d..aaf70318147 100644 --- a/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/route.ts +++ b/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/route.ts @@ -1,18 +1,16 @@ import { v2CreateKnowledgeDocumentUploadContract } from '@/lib/api/contracts/v2/knowledge' import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2KnowledgeErrorPolicies } from '@/lib/knowledge/api/route-policies' import { knowledgeOperations } from '@/lib/knowledge/application/operations' import { createKnowledgeDocumentUpload } from '@/lib/knowledge/application/upload-sessions' -import { - toV2KnowledgeDocumentUpload, - v2KnowledgeDocumentUploadError, -} from '@/app/api/v2/knowledge/[id]/documents/uploads/utils' +import { toV2KnowledgeDocumentUpload } from '@/app/api/v2/knowledge/[id]/documents/uploads/utils' export const POST = defineV2JsonRoute({ contract: v2CreateKnowledgeDocumentUploadContract, auth: v2ApiKeyAuth, operation: knowledgeOperations.uploadCreate, rateLimit: v2RateLimits.publicApi, - errorPolicy: { render: v2KnowledgeDocumentUploadError }, + errorPolicy: v2KnowledgeErrorPolicies.concealKnowledgeBaseUploadAuthorization, mapInput: ({ params, body }) => { const { workspaceId, name, contentType, size, ...metadata } = body return { diff --git a/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/utils.ts b/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/utils.ts index 0e48f8489e7..aedafc82f46 100644 --- a/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/utils.ts +++ b/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/utils.ts @@ -1,21 +1,7 @@ -import type { NextResponse } from 'next/server' import type { V2KnowledgeDocumentUpload } from '@/lib/api/contracts/v2/knowledge' -import { KnowledgeUsageLimitExceededError } from '@/lib/knowledge/application/billing' -import { KnowledgeDocumentUnsupportedMediaTypeError } from '@/lib/knowledge/application/upload-sessions' import type { CreatedKnowledgeDocument } from '@/lib/knowledge/orchestration/documents' import type { UploadSessionRecord } from '@/lib/uploads/upload-session/service' import { toV2DocumentSummary } from '@/app/api/v2/knowledge/utils' -import { v2CaughtOrchestrationError, v2Error } from '@/app/api/v2/lib/response' - -export function v2KnowledgeDocumentUploadError(error: unknown): NextResponse | null { - if (error instanceof KnowledgeDocumentUnsupportedMediaTypeError) { - return v2Error('UNSUPPORTED_MEDIA_TYPE', error.message) - } - if (error instanceof KnowledgeUsageLimitExceededError) { - return v2Error('USAGE_LIMIT_EXCEEDED', error.message) - } - return v2CaughtOrchestrationError(error) -} export function toV2KnowledgeDocumentUpload( session: UploadSessionRecord, diff --git a/apps/sim/app/api/v2/knowledge/route.test.ts b/apps/sim/app/api/v2/knowledge/route.test.ts index d2a9bf3e81a..8e2abe97bcb 100644 --- a/apps/sim/app/api/v2/knowledge/route.test.ts +++ b/apps/sim/app/api/v2/knowledge/route.test.ts @@ -60,6 +60,7 @@ vi.mock('@/lib/users/queries', () => ({ })) import { V2_DEFAULT_PAGE_SIZE } from '@/lib/api/contracts/v2/shared' +import { REFILTERED_CURSOR_MESSAGE } from '@/lib/api/cursor-binding' import { GET, POST } from '@/app/api/v2/knowledge/route' const WORKSPACE_ID = 'workspace-1' @@ -148,6 +149,76 @@ describe('/api/v2/knowledge route composition', () => { }) }) + /** + * Pins the binding end-to-end — the mint in `present` and the read in + * `mapInput` — because the contract-level sweep only checks a hand-maintained + * map of param names and stays green when a route drops the stamp entirely. + */ + it('refuses a cursor minted under a different filter', async () => { + mockList.mockResolvedValue({ + knowledgeBases: [{ knowledgeBase: buildKnowledgeBase(), folderPath: '/' }], + nextCursorKeys: ['Support docs', 'kb-1'], + sortBy: 'name', + sortOrder: 'desc', + }) + + const minted = await GET( + new NextRequest( + `http://localhost/api/v2/knowledge?workspaceId=${WORKSPACE_ID}&search=support`, + { headers: { 'x-api-key': 'secret' } } + ) + ) + const { nextCursor } = await minted.json() + expect(nextCursor).toEqual(expect.any(String)) + + mockList.mockClear() + const replayed = await GET( + new NextRequest( + `http://localhost/api/v2/knowledge?workspaceId=${WORKSPACE_ID}&search=billing&cursor=${encodeURIComponent(nextCursor)}`, + { headers: { 'x-api-key': 'secret' } } + ) + ) + + expect(replayed.status).toBe(400) + expect((await replayed.json()).error.message).toBe(REFILTERED_CURSOR_MESSAGE) + expect(mockList).not.toHaveBeenCalled() + }) + + it('resumes a cursor replayed under the filters it was minted with', async () => { + mockList.mockResolvedValue({ + knowledgeBases: [{ knowledgeBase: buildKnowledgeBase(), folderPath: '/' }], + nextCursorKeys: ['Support docs', 'kb-1'], + sortBy: 'name', + sortOrder: 'desc', + }) + + const minted = await GET( + new NextRequest( + `http://localhost/api/v2/knowledge?workspaceId=${WORKSPACE_ID}&search=support`, + { headers: { 'x-api-key': 'secret' } } + ) + ) + const { nextCursor } = await minted.json() + + mockList.mockClear() + const resumed = await GET( + new NextRequest( + `http://localhost/api/v2/knowledge?workspaceId=${WORKSPACE_ID}&search=support&cursor=${encodeURIComponent(nextCursor)}`, + { headers: { 'x-api-key': 'secret' } } + ) + ) + + expect(resumed.status).toBe(200) + expect(mockList).toHaveBeenCalledWith({ + principal: { kind: 'personal_api_key', userId: 'user-1', keyId: 'key-1' }, + input: expect.objectContaining({ + search: 'support', + cursorKeys: ['Support docs', 'kb-1'], + }), + request: expect.anything(), + }) + }) + it('returns 201 and keeps human analytics on the personal-key actor', async () => { const request = new NextRequest('http://localhost/api/v2/knowledge', { method: 'POST', diff --git a/apps/sim/app/api/v2/knowledge/route.ts b/apps/sim/app/api/v2/knowledge/route.ts index 789db35d934..07d9be95f16 100644 --- a/apps/sim/app/api/v2/knowledge/route.ts +++ b/apps/sim/app/api/v2/knowledge/route.ts @@ -2,6 +2,7 @@ import { v2CreateKnowledgeBaseContract, v2ListKnowledgeBasesContract, } from '@/lib/api/contracts/v2/knowledge' +import { cursorScopeKey } from '@/lib/api/cursor-binding' import { defineV2JsonRoute, v2ApiKeyAuth, @@ -16,11 +17,24 @@ import { import { knowledgeOperations } from '@/lib/knowledge/application/operations' import { captureServerEvent } from '@/lib/posthog/server' import { toV2KnowledgeBase, toV2KnowledgeBases } from '@/app/api/v2/knowledge/utils' -import { cursorSortKey, encodeSortedCursor, readSortedCursor } from '@/app/api/v2/lib/response' +import { readSortedCursor, writeSortedCursor } from '@/app/api/v2/lib/response' export const dynamic = 'force-dynamic' export const revalidate = 0 +/** Every param that changes which knowledge bases, in which order, this list returns. */ +function knowledgeCursorFilters(query: { + workspaceId: string + folderPath?: string + search?: string +}) { + return cursorScopeKey({ + workspaceId: query.workspaceId, + folderPath: query.folderPath, + search: query.search, + }) +} + /** GET /api/v2/knowledge — List knowledge bases in a workspace. */ export const GET = defineV2JsonRoute({ contract: v2ListKnowledgeBasesContract, @@ -35,14 +49,22 @@ export const GET = defineV2JsonRoute({ sortBy: query.sortBy, sortOrder: query.sortOrder, limit: query.limit, - cursorKeys: readSortedCursor(query.cursor, query.sortBy, query.sortOrder), + cursorKeys: readSortedCursor( + query.cursor, + query.sortBy, + query.sortOrder, + knowledgeCursorFilters(query) + ), }), useCase: listKnowledgeBases, - present: async ({ knowledgeBases, nextCursorKeys, sortBy, sortOrder }) => ({ + present: async ({ knowledgeBases, nextCursorKeys }, { query }) => ({ data: await toV2KnowledgeBases(knowledgeBases), - nextCursor: nextCursorKeys - ? encodeSortedCursor(cursorSortKey(sortBy, sortOrder), nextCursorKeys) - : null, + nextCursor: writeSortedCursor( + nextCursorKeys, + query.sortBy, + query.sortOrder, + knowledgeCursorFilters(query) + ), }), }) diff --git a/apps/sim/app/api/v2/knowledge/search/route.test.ts b/apps/sim/app/api/v2/knowledge/search/route.test.ts index e705128e562..dcba7d0d593 100644 --- a/apps/sim/app/api/v2/knowledge/search/route.test.ts +++ b/apps/sim/app/api/v2/knowledge/search/route.test.ts @@ -25,6 +25,7 @@ vi.mock('@/lib/knowledge/application/search', () => ({ })) import { KnowledgeUsageLimitExceededError } from '@/lib/knowledge/application/billing' +import { DEFAULT_RERANKER_MODEL } from '@/lib/knowledge/reranker-models' import { POST, V2_KNOWLEDGE_SEARCH_MAX_BODY_BYTES } from '@/app/api/v2/knowledge/search/route' const WORKSPACE_ID = 'workspace-1' @@ -69,6 +70,7 @@ describe('POST /api/v2/knowledge/search', () => { knowledgeBaseIds: ['kb-1'], topK: 10, totalResults: 1, + rerankerStatus: 'applied', }) }) @@ -96,7 +98,7 @@ describe('POST /api/v2/knowledge/search', () => { tagFilters: undefined, searchMode: 'hybrid', rerankerEnabled: undefined, - rerankerModel: undefined, + rerankerModel: DEFAULT_RERANKER_MODEL, rerankerInputCount: undefined, }, request, @@ -165,6 +167,76 @@ describe('POST /api/v2/knowledge/search', () => { expect(input).not.toHaveProperty('skipUsageBilling') }) + /** + * Without the default, `rerankerEnabled` alone satisfies the schema, fails the + * use case's model guard, and answers 200 in plain vector order — after paying + * for the widened candidate retrieval. + */ + it('defaults the reranker model so enabling reranking is enough to run it', async () => { + const response = await POST( + buildRequest( + JSON.stringify({ + workspaceId: WORKSPACE_ID, + knowledgeBaseIds: ['kb-1'], + query: 'hello', + topK: 5, + rerankerEnabled: true, + }) + ) + ) + + expect(response.status).toBe(200) + expect(mockSearch).toHaveBeenCalledWith( + expect.objectContaining({ + input: expect.objectContaining({ + rerankerEnabled: true, + rerankerModel: DEFAULT_RERANKER_MODEL, + }), + }) + ) + }) + + it('reports on the wire that a requested reranker did not run', async () => { + mockSearch.mockResolvedValueOnce({ + results: [ + { + embeddingId: 'embedding-1', + knowledgeBaseId: 'kb-1', + documentId: 'doc-1', + documentName: 'support.txt', + sourceUrl: null, + content: 'hello', + chunkIndex: 0, + metadata: {}, + similarity: 0.9, + }, + ], + query: 'hello', + knowledgeBaseIds: ['kb-1'], + topK: 5, + totalResults: 1, + rerankerStatus: 'unavailable', + }) + + const response = await POST( + buildRequest( + JSON.stringify({ + workspaceId: WORKSPACE_ID, + knowledgeBaseIds: ['kb-1'], + query: 'hello', + topK: 5, + rerankerEnabled: true, + rerankerModel: 'rerank-v4.0-pro', + }) + ) + ) + + expect(response.status).toBe(200) + const body = await response.json() + expect(body.data.rerankerStatus).toBe('unavailable') + expect(body.data.results[0]).not.toHaveProperty('rerankerScore') + }) + it('rejects an unsupported reranker model and an out-of-range candidate pool', async () => { const unsupportedModel = await POST( buildRequest( @@ -203,7 +275,15 @@ describe('POST /api/v2/knowledge/search', () => { expect(mockSearch).not.toHaveBeenCalled() }) - it('drops a caller-supplied reranker key instead of forwarding it', async () => { + /** + * The search body is strict, so an undeclared key is refused rather than + * stripped. That matters most for a bring-your-own reranker key: dropping it + * silently left the caller believing the secret it sent was in use. It + * matters for an ordinary mis-spelling too: a stripped `rerankerenabled` is a + * 200 with reranking off, and a stripped `topk` leaves `topK` at its default — + * both change what the search is billed. + */ + it('refuses a caller-supplied reranker key instead of silently dropping it', async () => { const response = await POST( buildRequest( JSON.stringify({ @@ -218,9 +298,8 @@ describe('POST /api/v2/knowledge/search', () => { ) ) - expect(response.status).toBe(200) - const [{ input }] = mockSearch.mock.calls[0] - expect(input).not.toHaveProperty('rerankerApiKey') + expect(response.status).toBe(400) + expect(mockSearch).not.toHaveBeenCalled() }) it('forwards an opted-in hybrid search mode to the application use case', async () => { diff --git a/apps/sim/app/api/v2/knowledge/search/route.ts b/apps/sim/app/api/v2/knowledge/search/route.ts index d22a4be4f8c..9ee4f6c9244 100644 --- a/apps/sim/app/api/v2/knowledge/search/route.ts +++ b/apps/sim/app/api/v2/knowledge/search/route.ts @@ -52,6 +52,7 @@ export const POST = defineV2JsonRoute({ knowledgeBaseIds: result.knowledgeBaseIds, topK: result.topK, totalResults: result.totalResults, + rerankerStatus: result.rerankerStatus, }, }), }) diff --git a/apps/sim/app/api/v2/knowledge/utils.ts b/apps/sim/app/api/v2/knowledge/utils.ts index 1751b673ee3..583368db64b 100644 --- a/apps/sim/app/api/v2/knowledge/utils.ts +++ b/apps/sim/app/api/v2/knowledge/utils.ts @@ -48,7 +48,7 @@ type V2DocumentProcessingStatus = (typeof PROCESSING_STATUSES)[number] * reads as `pending`, matching the column default; an unrecognised one is a * producer bug rather than a caller-reachable failure, so it throws. */ -export function toProcessingStatus(status: string | null | undefined): V2DocumentProcessingStatus { +function toProcessingStatus(status: string | null | undefined): V2DocumentProcessingStatus { if (status === null || status === undefined) return 'pending' const known = PROCESSING_STATUSES.find((candidate) => candidate === status) if (!known) throw new Error(`Unexpected knowledge document processing status: ${status}`) @@ -59,7 +59,7 @@ export function toProcessingStatus(status: string | null | undefined): V2Documen * The document columns every v2 document projection reads. `uploadedAt` is * accepted as nullable because the column is nullable in storage. */ -export interface V2DocumentSummarySource { +interface V2DocumentSummarySource { id: string knowledgeBaseId: string filename: string diff --git a/apps/sim/app/api/v2/lib/folders.ts b/apps/sim/app/api/v2/lib/folders.ts index 294bb00771e..12991cb897d 100644 --- a/apps/sim/app/api/v2/lib/folders.ts +++ b/apps/sim/app/api/v2/lib/folders.ts @@ -1,53 +1,12 @@ import type { folder } from '@sim/db/schema' -import type { NextResponse } from 'next/server' -import type { FolderResourceType } from '@/lib/api/contracts/folders' -import type { OrchestrationErrorCode } from '@/lib/core/orchestration/types' -import { withFolderTreeLock } from '@/lib/folders/locks' import { type FolderPathIndex, isFolderPathEffectivelyLocked, - ROOT_FOLDER_PATH, toFolderPathView, } from '@/lib/folders/paths' -import { loadActiveFolderPathIndex } from '@/lib/folders/queries' -import { v2ErrorForOrchestration } from '@/app/api/v2/lib/response' type FolderRow = typeof folder.$inferSelect -export function resolveFolderPathId( - index: FolderPathIndex, - path: string -): string | null | undefined { - return path === ROOT_FOLDER_PATH ? null : index.idByPath.get(path) -} - -export type ResolvedFolderPathIdentity = - | { found: false } - | { found: true; folderId: string | null; index: FolderPathIndex } - -/** Resolves a path to its stable internal identity under a short-lived folder tree lock. */ -export async function resolveFolderPathIdentity(params: { - workspaceId: string - resourceType: FolderResourceType - path: string -}): Promise { - return withFolderTreeLock(params.workspaceId, params.resourceType, async (tx) => { - const index = await loadActiveFolderPathIndex(params.workspaceId, params.resourceType, tx) - const folderId = resolveFolderPathId(index, params.path) - return folderId === undefined ? { found: false } : { found: true, folderId, index } - }) -} - -export function folderPathForId( - index: FolderPathIndex, - folderId: string | null | undefined -): string { - if (!folderId) return ROOT_FOLDER_PATH - const path = index.pathById.get(folderId) - if (!path) throw new Error('Resource references an inactive or missing folder') - return path -} - export function toV2PathFolder( row: FolderRow, index: FolderPathIndex, @@ -58,10 +17,3 @@ export function toV2PathFolder( const base = toFolderPathView(row, path) return includeLocked ? { ...base, locked: isFolderPathEffectivelyLocked(index, row.id) } : base } - -export function v2FolderPathMutationError( - errorCode: OrchestrationErrorCode | undefined, - message: string -): NextResponse { - return v2ErrorForOrchestration(errorCode, message) -} diff --git a/apps/sim/app/api/v2/lib/response.ts b/apps/sim/app/api/v2/lib/response.ts index 8228a8ee3d5..7ffd9fc9f88 100644 --- a/apps/sim/app/api/v2/lib/response.ts +++ b/apps/sim/app/api/v2/lib/response.ts @@ -1,5 +1,6 @@ import { NextResponse } from 'next/server' import type { ZodError } from 'zod' +import { REFILTERED_CURSOR_MESSAGE, UNREADABLE_CURSOR_MESSAGE } from '@/lib/api/cursor-binding' import { type CursorKey, INVALID_CURSOR_MESSAGE } from '@/lib/api/list-query' import { getValidationErrorMessage, serializeZodIssues } from '@/lib/api/server' import { ADMISSION_RETRY_AFTER_SECONDS } from '@/lib/core/admission/transient-failure' @@ -10,7 +11,7 @@ import { type OrchestrationErrorCode, } from '@/lib/core/orchestration/types' import type { HttpError } from '@/lib/core/utils/http-error' -import type { RateLimitResult, WorkspaceAccessError } from '@/app/api/v1/middleware' +import type { RateLimitResult } from '@/app/api/v1/middleware' /** * Runtime response helpers for the v2 API surface. Every v2 route renders its @@ -101,7 +102,7 @@ const RETRY_AFTER_SECONDS_BY_STATUS: Partial> = { type RateLimitHeaderSource = Pick -export function rateLimitHeaders(rateLimit?: RateLimitHeaderSource): Record { +function rateLimitHeaders(rateLimit?: RateLimitHeaderSource): Record { if (!rateLimit) return {} return { 'X-RateLimit-Limit': rateLimit.limit.toString(), @@ -121,15 +122,17 @@ function successHeaders(options: V2SuccessOptions): Record { } /** - * The bodiless 200 a `HEAD` receives from a route whose `GET` is not safe. + * The bodiless 200 a `HEAD` receives from a route whose `GET` is not safe, once + * that `HEAD` has been authorized. * * RFC 9110 §9.3.2 lets Next alias `HEAD` onto `GET` only because §9.2.1 defines * `HEAD` as safe — "essentially read-only". A `GET` that opens an outbound - * connection or writes a row breaks that assumption, and an uptime monitor or - * link checker walking the documented URL list would drive those effects - * invisibly on every probe. Such a route answers the authorization and - * rate-limit questions and stops there. `HEAD` carries no body in any case, so - * nothing the caller can observe is fabricated. + * connection or writes a row breaks that assumption, and an uptime monitor + * walking the documented URL list would drive those effects on every probe. + * + * The 200 is unconditional **by construction**: callers must only reach this + * after `useCase.authorize` has resolved, or it becomes the existence oracle + * the `headSafe` option on the v2 route builders documents. */ export function v2HeadNoEffect(options: V2SuccessOptions = {}): NextResponse { return new NextResponse(null, { status: options.status ?? 200, headers: successHeaders(options) }) @@ -143,18 +146,6 @@ export function v2Data(data: T, options: V2SuccessOptions = {}): NextResponse ) } -/** `{ data, nextCursor }` (+ rate-limit headers). */ -export function v2CursorList( - data: T[], - nextCursor: string | null, - options: V2SuccessOptions = {} -): NextResponse { - return NextResponse.json( - { data, nextCursor }, - { status: options.status ?? 200, headers: successHeaders(options) } - ) -} - interface V2ErrorOptions { status?: number details?: unknown @@ -206,6 +197,18 @@ export function v2HttpError(error: HttpError): NextResponse { return v2Error(code, error.message) } +/** + * The 500 of the local-storage upload data plane, in the canonical envelope. + * + * `PUT /api/v2/uploads/{uploadId}` and its `/parts/{partNumber}` sibling are + * undocumented but still v2 routes, and they do not run `admitV2Request`, so + * they cannot reuse the JSON builder's handler — this is the one piece of it + * they need. + */ +export function v2UploadDataPlaneError(): NextResponse { + return v2Error('INTERNAL_ERROR', 'Internal server error') +} + /** Render a contract `ZodError` as the v2 error envelope. */ export function v2ValidationError(error: ZodError): NextResponse { return v2Error('BAD_REQUEST', getValidationErrorMessage(error, 'Invalid request'), { @@ -213,11 +216,6 @@ export function v2ValidationError(error: ZodError): NextResponse { }) } -/** Render a shared {@link WorkspaceAccessError} as the v2 error envelope. */ -export function v2WorkspaceAccessError(failure: WorkspaceAccessError): NextResponse { - return v2Error(failure.code, failure.message, { status: failure.status }) -} - /** * Render a v1 rate-limit/auth failure (`checkRateLimit` result) as the v2 error * envelope: an auth failure becomes 401, a throttle becomes 429 with @@ -251,54 +249,55 @@ export function decodeCursor>(cursor: string): T | n } interface OffsetCursorPayload { - /** The query state the offset counts positions within. */ - scope: string + /** The ordering the offset counts positions within. */ + sort: string + /** Fingerprint of the filters the offset counts positions within. */ + filter?: string offset: number } -/** - * The filters and sort an offset cursor was minted under. - * - * An offset is only meaningful against one exact sequence, so everything that - * reorders or re-filters that sequence has to travel with it. Build the stamp - * from every such param; a value that does not affect ordering or membership - * (the page size itself) must stay out, or paging with a different `limit` - * would be rejected for no reason. - */ -export function offsetCursorScope(parts: Record): string { - return Object.keys(parts) - .sort() - .map((key) => `${key}=${parts[key] ?? ''}`) - .join('&') -} - -/** An offset cursor stamped with the query state that produced it. */ -export function encodeOffsetCursor(scope: string, offset: number): string { - return encodeCursor({ scope, offset } satisfies OffsetCursorPayload) +/** An offset cursor stamped with the sort and filters that produced it. */ +export function encodeOffsetCursor( + sort: string, + filter: string | undefined, + offset: number +): string { + return encodeCursor({ + sort, + ...(filter ? { filter } : {}), + offset, + } satisfies OffsetCursorPayload) } /** - * Reads back an offset cursor, refusing one minted under different filters or a - * different sort. + * Reads back an offset cursor, refusing one minted under a different sort or + * different filters. * * An absent cursor means page one. A cursor that is not valid base64-JSON, or * that does not carry a non-negative integer `offset`, is rejected rather than * coerced to 0: silently restarting at page one while the caller believes it is * paging forward makes a paging client loop over the first page forever. * - * The `scope` check is the offset counterpart of {@link decodeSortedCursor}'s - * sort stamp. A bare offset replayed against a newly filtered or re-sorted - * sequence names a different position in it, which silently skips rows, repeats - * them, or lands past the end and returns an empty page — the failure a keyset - * cursor is already protected from. The v2 error policies render the thrown - * validation error as the canonical 400. + * An offset is the weaker of the two schemes here: unlike a keyset it names an + * ordinal, not a position, so replaying it against a re-filtered or re-sorted + * sequence lands at an unrelated point in it — skipping rows, repeating them, or + * landing past the end and returning an empty page the caller reads as "no more + * matches". The v2 error policies render the thrown validation error as the + * canonical 400. */ -export function decodeOffsetCursor(cursor: string | undefined, scope: string): number { +export function decodeOffsetCursor( + cursor: string | undefined, + sort: string, + filter?: string | undefined +): number { if (!cursor) return 0 const decoded = decodeCursor>(cursor) - if (!decoded || decoded.scope !== scope) { + if (!decoded || decoded.sort !== sort) { throw new OrchestrationError('validation', INVALID_CURSOR_MESSAGE) } + if ((decoded.filter ?? undefined) !== (filter || undefined)) { + throw new OrchestrationError('validation', REFILTERED_CURSOR_MESSAGE) + } const { offset } = decoded if (typeof offset !== 'number' || !Number.isInteger(offset) || offset < 0) { throw new OrchestrationError('validation', 'Invalid cursor') @@ -318,40 +317,62 @@ export function cursorSortKey(sortBy: string, sortOrder: string): string { interface SortedCursorPayload { sort: string keys: CursorKey[] + /** Fingerprint of the filters the page was read under; absent = unfiltered. */ + filter?: string } /** - * A keyset cursor stamped with the sort that produced it. The keys are only - * meaningful under that exact ordering, so the stamp travels with them. + * A keyset cursor stamped with the sort AND the filters that produced it. The + * keys are only meaningful under that exact ordering, and only name a useful + * position within that exact row set, so both stamps travel with them. */ -export function encodeSortedCursor(sort: string, keys: CursorKey[]): string { - return encodeCursor({ sort, keys } satisfies SortedCursorPayload) +export function encodeSortedCursor( + sort: string, + keys: CursorKey[], + filter?: string | undefined +): string { + return encodeCursor({ sort, keys, ...(filter ? { filter } : {}) } satisfies SortedCursorPayload) } -export type DecodedSortedCursor = +type DecodedSortedCursor = | { status: 'absent' } | { status: 'ok'; keys: CursorKey[] } /** Malformed, or minted under a different sort — the page cannot be resumed. */ | { status: 'invalid' } + /** Minted under different filters — the position names another sequence. */ + | { status: 'refiltered' } /** * Reads a keyset cursor back, refusing one that does not belong to the - * requested sort. Resuming a `name`-ordered cursor under `createdAt` would - * compare the wrong column and silently duplicate or skip rows, so a mismatch - * is a client error rather than a best-effort page. A cursor that isn't valid - * base64-JSON is rejected for the same reason: ignoring it would restart from - * page one while the caller believes it is paging forward. + * requested query. + * + * Resuming a `name`-ordered cursor under `createdAt` would compare the wrong + * column and silently duplicate or skip rows, so a sort mismatch is a client + * error rather than a best-effort page. A cursor that isn't valid base64-JSON + * is rejected for the same reason: ignoring it would restart from page one + * while the caller believes it is paging forward. + * + * A filter mismatch is rejected too, even though a keyset does not corrupt the + * way an offset does: `(sortKey, id)` names an absolute position, so replaying + * it under a narrower filter returns a coherent, duplicate-free page that is + * silently missing every new match sorting before that position. The token is + * opaque, so a caller cannot tell that truncated page from a complete one. * * This checks the envelope only. The key VALUES are caller-controlled too, and * are type-checked against the sort's keys by `keysetAfter`, which is where a * bad arity or an unparseable timestamp is caught. */ -export function decodeSortedCursor(cursor: string | undefined, sort: string): DecodedSortedCursor { +export function decodeSortedCursor( + cursor: string | undefined, + sort: string, + filter?: string | undefined +): DecodedSortedCursor { if (!cursor) return { status: 'absent' } const decoded = decodeCursor>(cursor) if (!decoded || decoded.sort !== sort || !Array.isArray(decoded.keys)) { return { status: 'invalid' } } + if ((decoded.filter ?? undefined) !== (filter || undefined)) return { status: 'refiltered' } return { status: 'ok', keys: decoded.keys } } @@ -359,23 +380,97 @@ export function decodeSortedCursor(cursor: string | undefined, sort: string): De * The keyset a paged list should resume from, or `undefined` for page one. * * This is the `mapInput` half of every keyset list: it stamps the request's - * sort, reads the cursor back under it, and turns a cursor that was minted - * under a different sort into the canonical 400 rather than letting mismatched - * keys reach `keysetAfter`. Sharing it is what keeps "a bad cursor is a 400" - * from being re-decided per route. + * sort and filters, reads the cursor back under them, and turns a cursor minted + * under a different query into the canonical 400 rather than letting mismatched + * keys reach `keysetAfter` or a stale position reach a re-filtered read. Sharing + * it is what keeps "a bad cursor is a 400" from being re-decided per route. + * + * Build `filter` with `cursorScopeKey` from the same params on both + * sides of the request. A list with no filters at all passes nothing. */ export function readSortedCursor( cursor: string | undefined, sortBy: string, - sortOrder: string + sortOrder: string, + filter?: string | undefined ): CursorKey[] | undefined { - const decoded = decodeSortedCursor(cursor, cursorSortKey(sortBy, sortOrder)) + const decoded = decodeSortedCursor(cursor, cursorSortKey(sortBy, sortOrder), filter) if (decoded.status === 'invalid') { throw new OrchestrationError('validation', INVALID_CURSOR_MESSAGE) } + if (decoded.status === 'refiltered') { + throw new OrchestrationError('validation', REFILTERED_CURSOR_MESSAGE) + } return decoded.status === 'ok' ? decoded.keys : undefined } +/** + * The next page's cursor, or `null` on the last page. + * + * The `present` half of the pair {@link readSortedCursor} opens: it stamps the + * response token with the same sort and filters the request was read under, so + * a list cannot mint a token its own reader would reject. Pass the identical + * `sortBy`/`sortOrder`/`filter` triple both sides. + */ +export function writeSortedCursor( + keys: CursorKey[] | null | undefined, + sortBy: string, + sortOrder: string, + filter?: string | undefined +): string | null { + return keys ? encodeSortedCursor(cursorSortKey(sortBy, sortOrder), keys, filter) : null +} + +interface ScopedCursorPayload { + /** Fingerprint of the filters and sort the inner token was minted under. */ + scope?: string + /** The domain codec's own opaque token, passed through untouched. */ + inner: string +} + +/** + * Binds a cursor minted by a domain codec to the query it was minted under. + * + * `GET /logs`, `GET /audit-logs`, and `GET /billing/logs` page through readers + * that predate the shared v2 codecs and mint their own tokens, so the stamp + * cannot live inside the payload the way it does for {@link encodeSortedCursor}. + * Wrapping keeps the domain token opaque and untouched while still giving those + * lists the same binding as the rest of the surface — one rule for v2 callers + * rather than "some lists notice, some don't". + */ +export function encodeScopedCursor(scope: string | undefined, inner: string): string { + return encodeCursor({ ...(scope ? { scope } : {}), inner } satisfies ScopedCursorPayload) +} + +/** + * Unwraps a {@link encodeScopedCursor} token, yielding the domain codec's own + * cursor, or `undefined` for page one. A token that is malformed or was minted + * under a different query is the canonical 400 — the domain codec never sees it. + * + * An empty inner token is malformed, not "page one". Only an absent `cursor` + * param means page one; a present-but-empty inner passed the old + * `typeof === 'string'` envelope check and then read as falsy in every domain + * reader downstream, so no cursor condition was applied and the caller was + * handed page one again — with a `nextCursor` telling it to keep going. That is + * exactly the loop `UNKNOWN_CURSOR_MESSAGE` describes on the billing ledger, + * reached through the wrapper instead of through the token, and it slipped past + * the unresolvable-cursor 400 that exists to stop it. + */ +export function readScopedCursor( + cursor: string | undefined, + scope: string | undefined +): string | undefined { + if (!cursor) return undefined + const decoded = decodeCursor>(cursor) + if (!decoded || typeof decoded.inner !== 'string' || decoded.inner.length === 0) { + throw new OrchestrationError('validation', UNREADABLE_CURSOR_MESSAGE) + } + if ((decoded.scope ?? undefined) !== (scope || undefined)) { + throw new OrchestrationError('validation', REFILTERED_CURSOR_MESSAGE) + } + return decoded.inner +} + const V2_CODE_BY_ORCHESTRATION_ERROR: Record = { validation: 'BAD_REQUEST', unauthorized: 'UNAUTHORIZED', diff --git a/apps/sim/app/api/v2/logs/route.test.ts b/apps/sim/app/api/v2/logs/route.test.ts index 2b6bb177f72..277f05cd1f1 100644 --- a/apps/sim/app/api/v2/logs/route.test.ts +++ b/apps/sim/app/api/v2/logs/route.test.ts @@ -24,7 +24,9 @@ vi.mock('@/lib/logs/application/list-public-logs', () => ({ listPublicLogs: { operation: { id: 'logs.list' }, execute: mocks.execute }, })) +import { cursorScopeKey, UNREADABLE_CURSOR_MESSAGE } from '@/lib/api/cursor-binding' import { OrchestrationError } from '@/lib/core/orchestration/types' +import { encodeScopedCursor } from '@/app/api/v2/lib/response' import { GET } from '@/app/api/v2/logs/route' const WORKSPACE_ID = '6fc7631d-88cd-46f8-9f0a-d4764daef7f8' @@ -116,6 +118,71 @@ describe('GET /api/v2/logs', () => { expect(body.data[0]).toMatchObject({ runId: 'run-1', status: 'paused' }) }) + /** + * The run-log cursor is minted by the domain codec, so it carries only its own + * `(startedAt, id)` position and the requested order. Binding it to the filters + * is what stops a cursor taken from an unfiltered walk from resuming inside a + * `level=error` read at an unrelated point in that shorter sequence. + */ + it('refuses a cursor replayed under a different filter', async () => { + mocks.execute.mockResolvedValueOnce({ + items: [{ log, executionData: null }], + nextCursor: Buffer.from( + JSON.stringify({ startedAt: log.startedAt.toISOString(), id: 'run-1', order: 'desc' }) + ).toString('base64'), + includeFullDetails: false, + includeFinalOutput: false, + includeTraceSpans: false, + }) + const firstPage = await ( + await GET(new NextRequest(`http://localhost:3000/api/v2/logs?workspaceId=${WORKSPACE_ID}`)) + ).json() + expect(firstPage.nextCursor).toEqual(expect.any(String)) + mocks.execute.mockClear() + + const response = await GET( + new NextRequest( + `http://localhost:3000/api/v2/logs?workspaceId=${WORKSPACE_ID}&level=error&cursor=${encodeURIComponent(firstPage.nextCursor)}` + ) + ) + + expect(response.status).toBe(400) + expect(await response.json()).toMatchObject({ + error: { code: 'BAD_REQUEST', message: expect.stringContaining('requested filters') }, + }) + expect(mocks.execute).not.toHaveBeenCalled() + }) + + /** + * These three decide how much of each row is rendered, not which rows are in + * the sequence, so they must stay out of the binding. + */ + it.each([['details=full'], ['includeTraceSpans=true'], ['includeFinalOutput=true']])( + 'resumes a cursor across a changed %s', + async (param) => { + mocks.execute.mockResolvedValueOnce({ + items: [{ log, executionData: null }], + nextCursor: Buffer.from( + JSON.stringify({ startedAt: log.startedAt.toISOString(), id: 'run-1', order: 'desc' }) + ).toString('base64'), + includeFullDetails: false, + includeFinalOutput: false, + includeTraceSpans: false, + }) + const firstPage = await ( + await GET(new NextRequest(`http://localhost:3000/api/v2/logs?workspaceId=${WORKSPACE_ID}`)) + ).json() + + const response = await GET( + new NextRequest( + `http://localhost:3000/api/v2/logs?workspaceId=${WORKSPACE_ID}&${param}&cursor=${encodeURIComponent(firstPage.nextCursor)}` + ) + ) + + expect(response.status).toBe(200) + } + ) + it('rejects malformed cursors after admission and before protected reads', async () => { const response = await GET( new NextRequest( @@ -128,6 +195,160 @@ describe('GET /api/v2/logs', () => { expect(mocks.execute).not.toHaveBeenCalled() }) + /** + * An empty inner token reads as falsy in the domain codec, so no cursor + * condition is applied and the caller silently gets page one back, with a + * `nextCursor` inviting it to do the same thing forever. + */ + it('rejects a cursor whose inner token is empty instead of restarting at page one', async () => { + const cursor = encodeScopedCursor( + cursorScopeKey({ workspaceId: WORKSPACE_ID, order: 'desc' }), + '' + ) + + const response = await GET( + new NextRequest( + `http://localhost:3000/api/v2/logs?workspaceId=${WORKSPACE_ID}&limit=1&cursor=${encodeURIComponent(cursor)}` + ) + ) + + expect(response.status).toBe(400) + expect(mocks.execute).not.toHaveBeenCalled() + }) + + /** + * An undecodable token says nothing about which param changed, and this + * operation declares neither `sortBy` nor `sortOrder` under a `.strict()` + * query schema — so the sort-mismatch message would answer one 400 with + * advice that earns a second. The message is asserted exactly rather than by + * absence: "does not say sortBy" is satisfied by almost any wording, including + * one that tells the caller nothing at all. + */ + it('names the params a rejected cursor is actually bound to', async () => { + const response = await GET( + new NextRequest( + `http://localhost:3000/api/v2/logs?workspaceId=${WORKSPACE_ID}&cursor=not-a-cursor` + ) + ) + + const body = await response.json() + expect(body.error.message).toBe(UNREADABLE_CURSOR_MESSAGE) + expect(body.error.message).toContain('Restart pagination without a cursor') + expect(body.error.message).not.toContain('sortBy') + expect(body.error.message).not.toContain('sortOrder') + }) + + /** + * `total_duration_ms` is an `integer` column, so a value that is not + * representable as int4 is rejected by Postgres itself — the request has to + * fail at the contract instead of reaching the query. + */ + it.each([ + ['minDurationMs', '1.5'], + ['maxDurationMs', '1.5'], + ['maxDurationMs', '-0.5'], + ['minDurationMs', '1e30'], + ['minDurationMs', '2147483648'], + ['minDurationMs', '999999999999999999999'], + ['maxDurationMs', '-1'], + ])('rejects %s=%s before it can reach the query', async (field, value) => { + const response = await GET( + new NextRequest( + `http://localhost:3000/api/v2/logs?workspaceId=${WORKSPACE_ID}&${field}=${encodeURIComponent(value)}` + ) + ) + + expect(response.status).toBe(400) + expect(await response.json()).toMatchObject({ + error: { code: 'BAD_REQUEST', message: expect.stringContaining(field) }, + }) + expect(mocks.execute).not.toHaveBeenCalled() + }) + + it.each([ + ['minDurationMs', '0'], + ['maxDurationMs', '1000000'], + ['minDurationMs', '2147483647'], + ])('accepts %s=%s', async (field, value) => { + const response = await GET( + new NextRequest( + `http://localhost:3000/api/v2/logs?workspaceId=${WORKSPACE_ID}&${field}=${value}` + ) + ) + + expect(response.status).toBe(200) + }) + + /** + * `0000` satisfies the published `\d{4}` date-time pattern but names no + * instant Postgres can store — the proleptic Gregorian calendar has no year + * zero — so the value has to be refused before it becomes a bind parameter. + */ + it.each([['startDate'], ['endDate']])( + 'rejects a year-0000 %s before it can reach the query', + async (field) => { + const response = await GET( + new NextRequest( + `http://localhost:3000/api/v2/logs?workspaceId=${WORKSPACE_ID}&${field}=${encodeURIComponent('0000-01-01T00:00:00Z')}` + ) + ) + + expect(response.status).toBe(400) + expect(await response.json()).toMatchObject({ + error: { code: 'BAD_REQUEST', message: expect.stringContaining(field) }, + }) + expect(mocks.execute).not.toHaveBeenCalled() + } + ) + + it('accepts the earliest storable year', async () => { + const response = await GET( + new NextRequest( + `http://localhost:3000/api/v2/logs?workspaceId=${WORKSPACE_ID}&startDate=${encodeURIComponent('0001-01-01T00:00:00Z')}` + ) + ) + + expect(response.status).toBe(200) + }) + + /** + * `folderPaths=/,` was already a 400 while the sibling comma lists dropped + * the empty entry, so one endpoint answered two ways to the same mistake. + */ + it.each([ + ['workflowIds', 'workflow-1,,workflow-2'], + ['workflowIds', 'workflow-1,'], + ['triggers', 'manual,'], + ['folderPaths', '/,'], + ])('rejects an empty entry in %s=%s', async (field, value) => { + const response = await GET( + new NextRequest( + `http://localhost:3000/api/v2/logs?workspaceId=${WORKSPACE_ID}&${field}=${encodeURIComponent(value)}` + ) + ) + + expect(response.status).toBe(400) + expect(await response.json()).toMatchObject({ + error: { code: 'BAD_REQUEST', message: expect.stringContaining(field) }, + }) + expect(mocks.execute).not.toHaveBeenCalled() + }) + + /** A repeated param arrives as an array, which every v2 schema reads as a missing value. */ + it('names duplication when a query param is sent twice', async () => { + const response = await GET( + new NextRequest( + `http://localhost:3000/api/v2/logs?workspaceId=${WORKSPACE_ID}&workspaceId=${WORKSPACE_ID}` + ) + ) + + expect(response.status).toBe(400) + expect(await response.json()).toMatchObject({ + error: { code: 'BAD_REQUEST', message: expect.stringContaining('workspaceId was sent') }, + }) + expect(mocks.execute).not.toHaveBeenCalled() + }) + it.each([ ['abc', 'startDate'], ['2026-08-06', 'startDate'], diff --git a/apps/sim/app/api/v2/logs/route.ts b/apps/sim/app/api/v2/logs/route.ts index ea37bd87e30..5ecf1a474f3 100644 --- a/apps/sim/app/api/v2/logs/route.ts +++ b/apps/sim/app/api/v2/logs/route.ts @@ -4,16 +4,65 @@ import { v2ListLogsContract, v2LogStatusSchema, } from '@/lib/api/contracts/v2/logs' +import { + cursorScopeKey, + instantScopePart, + parseUnorderedList, + UNREADABLE_CURSOR_MESSAGE, + unorderedScopePart, +} from '@/lib/api/cursor-binding' import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' import { OrchestrationError } from '@/lib/core/orchestration/types' import { v2LogErrorPolicies } from '@/lib/logs/api/route-policies' import { listPublicLogs } from '@/lib/logs/application/list-public-logs' import { logOperations } from '@/lib/logs/application/operations' import { decodePublicLogCursor } from '@/lib/logs/public-queries' +import { encodeScopedCursor, readScopedCursor } from '@/app/api/v2/lib/response' export const dynamic = 'force-dynamic' export const revalidate = 0 +/** + * Every param that changes which logs, in which order, this list returns. + * + * `details`, `includeFinalOutput`, and `includeTraceSpans` are deliberately + * absent: they decide how much of each row is rendered, not which rows are in + * the sequence, so a caller may turn them on mid-walk. + */ +function logCursorFilters(query: { + workspaceId: string + workflowIds?: string + triggers?: string + level?: string + startDate?: string + endDate?: string + runId?: string + minDurationMs?: number + maxDurationMs?: number + minCost?: number + maxCost?: number + model?: string + folderPaths?: string + order?: string +}) { + return cursorScopeKey({ + workspaceId: query.workspaceId, + workflowIds: unorderedScopePart(query.workflowIds), + triggers: unorderedScopePart(query.triggers), + level: query.level, + startDate: instantScopePart(query.startDate), + endDate: instantScopePart(query.endDate), + runId: query.runId, + minDurationMs: query.minDurationMs, + maxDurationMs: query.maxDurationMs, + minCost: query.minCost, + maxCost: query.maxCost, + model: query.model, + folderPaths: unorderedScopePart(query.folderPaths), + order: query.order, + }) +} + export const GET = defineV2JsonRoute({ contract: v2ListLogsContract, auth: v2ApiKeyAuth, @@ -21,17 +70,16 @@ export const GET = defineV2JsonRoute({ rateLimit: v2RateLimits.publicApi, errorPolicy: v2LogErrorPolicies.default, mapInput: ({ query }) => { - const decodedCursor = query.cursor - ? decodePublicLogCursor(query.cursor, query.order ?? 'desc') - : null - if (query.cursor && !decodedCursor) { - throw new OrchestrationError('validation', 'Invalid cursor') + const inner = readScopedCursor(query.cursor, logCursorFilters(query)) + const decodedCursor = inner ? decodePublicLogCursor(inner, query.order ?? 'desc') : null + if (inner && !decodedCursor) { + throw new OrchestrationError('validation', UNREADABLE_CURSOR_MESSAGE) } return { workspaceId: query.workspaceId, filters: { - workflowIds: query.workflowIds?.split(',').filter(Boolean), - triggers: query.triggers?.split(',').filter(Boolean), + workflowIds: parseUnorderedList(query.workflowIds), + triggers: parseUnorderedList(query.triggers), level: query.level, startDate: query.startDate ? new Date(query.startDate) : undefined, endDate: query.endDate ? new Date(query.endDate) : undefined, @@ -44,7 +92,7 @@ export const GET = defineV2JsonRoute({ cursor: decodedCursor ?? undefined, order: query.order, }, - folderPaths: query.folderPaths?.split(',').filter(Boolean), + folderPaths: parseUnorderedList(query.folderPaths), limit: query.limit, includeFullDetails: query.details === 'full' || query.includeFinalOutput || query.includeTraceSpans, @@ -53,7 +101,10 @@ export const GET = defineV2JsonRoute({ } }, useCase: listPublicLogs, - present: ({ items, nextCursor, includeFullDetails, includeFinalOutput, includeTraceSpans }) => ({ + present: ( + { items, nextCursor, includeFullDetails, includeFinalOutput, includeTraceSpans }, + { query } + ) => ({ data: items.map(({ log, executionData }): V2LogListItem => { const item: V2LogListItem = { runId: log.executionId, @@ -86,6 +137,6 @@ export const GET = defineV2JsonRoute({ } return item }), - nextCursor, + nextCursor: nextCursor ? encodeScopedCursor(logCursorFilters(query), nextCursor) : null, }), }) diff --git a/apps/sim/app/api/v2/mcp-servers/[id]/route.test.ts b/apps/sim/app/api/v2/mcp-servers/[id]/route.test.ts index d1c0836afdb..536f5b0adb4 100644 --- a/apps/sim/app/api/v2/mcp-servers/[id]/route.test.ts +++ b/apps/sim/app/api/v2/mcp-servers/[id]/route.test.ts @@ -84,18 +84,22 @@ const server = { } as McpServerRow const context = { params: Promise.resolve({ id: server.id }) } +/** + * The read and delete verbs scope themselves with `?workspaceId=`; the write + * verb carries `workspaceId` in its body. Sending the query copy on a write is + * now a 400 rather than a silently dropped key, so the helper only appends it + * where the contract declares it. + */ function request(method: 'GET' | 'PATCH' | 'DELETE', body?: unknown) { - return new NextRequest( - `http://localhost:3000/api/v2/mcp-servers/${server.id}?workspaceId=${WORKSPACE_ID}`, - { - method, - headers: { - 'x-api-key': 'key', - ...(body === undefined ? {} : { 'Content-Type': 'application/json' }), - }, - ...(body === undefined ? {} : { body: JSON.stringify(body) }), - } - ) + const query = method === 'PATCH' ? '' : `?workspaceId=${WORKSPACE_ID}` + return new NextRequest(`http://localhost:3000/api/v2/mcp-servers/${server.id}${query}`, { + method, + headers: { + 'x-api-key': 'key', + ...(body === undefined ? {} : { 'Content-Type': 'application/json' }), + }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }) } describe('/api/v2/mcp-servers/[id]', () => { @@ -121,6 +125,25 @@ describe('/api/v2/mcp-servers/[id]', () => { }) }) + /** + * Every list in this family rejects a query param it does not implement, so + * the single-resource reads must too. A caller who mistypes a flag otherwise + * gets a 200 that silently ignored it, which reads as confirmation the flag + * exists and does nothing. + */ + it('rejects a query param it does not implement', async () => { + const response = await GET( + new NextRequest( + `http://localhost:3000/api/v2/mcp-servers/${server.id}?workspaceId=${WORKSPACE_ID}&includeTools=true`, + { method: 'GET', headers: { 'x-api-key': 'key' } } + ), + context + ) + + expect(response.status).toBe(400) + expect(mocks.get).not.toHaveBeenCalled() + }) + it('updates an MCP server through the strict semantic update operation', async () => { const response = await PATCH( request('PATCH', { workspaceId: WORKSPACE_ID, name: 'New docs' }), diff --git a/apps/sim/app/api/v2/mcp-servers/[id]/tools/route.test.ts b/apps/sim/app/api/v2/mcp-servers/[id]/tools/route.test.ts index cf39d34b6a8..e577d2428f5 100644 --- a/apps/sim/app/api/v2/mcp-servers/[id]/tools/route.test.ts +++ b/apps/sim/app/api/v2/mcp-servers/[id]/tools/route.test.ts @@ -15,6 +15,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ discover: vi.fn(), + authorizeDiscover: vi.fn(), })) vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => v2ApiKeyAuthModuleMock) @@ -32,11 +33,17 @@ vi.mock('@/lib/mcp/application/use-cases', () => ({ discoverMcpServerToolsUseCase: { operation: { id: 'mcp_servers.tools.discover' }, execute: mocks.discover, + authorize: mocks.authorizeDiscover, }, })) -import { WorkspaceApiKeyAuthorizationError } from '@/lib/core/application' -import { McpConnectionError, McpOauthAuthorizationRequiredError } from '@/lib/mcp/types' +import { NoWorkspaceAccessError, WorkspaceApiKeyAuthorizationError } from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { + McpConnectionError, + McpOauthAuthorizationRequiredError, + McpServerCooldownError, +} from '@/lib/mcp/types' import { GET } from '@/app/api/v2/mcp-servers/[id]/tools/route' const WORKSPACE_ID = 'workspace-1' @@ -78,6 +85,7 @@ describe('/api/v2/mcp-servers/[id]/tools', () => { v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) mocks.discover.mockResolvedValue({ tools: [TOOL] }) + mocks.authorizeDiscover.mockResolvedValue(undefined) }) it('returns a server tool inventory as a single page', async () => { @@ -116,6 +124,53 @@ describe('/api/v2/mcp-servers/[id]/tools', () => { expect(response.status).toBe(200) expect(await response.text()).toBe('') expect(mocks.discover).not.toHaveBeenCalled() + expect(mocks.authorizeDiscover).toHaveBeenCalledOnce() + }) + + /** + * A `HEAD` answered before the use case's resource authorization is an + * existence oracle: any valid API key draws a bodiless 200 for a server id in + * a workspace it cannot read, for one that does not exist, and for a principal + * kind this operation refuses outright. These four pin the probe to the answer + * the `GET` gives. + */ + it('does not confirm a server to a principal kind the operation refuses', async () => { + mocks.authorizeDiscover.mockRejectedValueOnce(new WorkspaceApiKeyAuthorizationError()) + + const response = await GET(request(`workspaceId=${WORKSPACE_ID}`, 'HEAD'), { ...context }) + + expect(response.status).toBe(403) + expect(mocks.discover).not.toHaveBeenCalled() + }) + + it('does not confirm a server id that does not exist', async () => { + mocks.authorizeDiscover.mockRejectedValueOnce( + new OrchestrationError('not_found', 'MCP server not found') + ) + + const response = await GET(request(`workspaceId=${WORKSPACE_ID}`, 'HEAD'), { ...context }) + + expect(response.status).toBe(404) + expect(mocks.discover).not.toHaveBeenCalled() + }) + + it('does not confirm a server in a workspace the caller cannot read', async () => { + mocks.authorizeDiscover.mockRejectedValueOnce(new NoWorkspaceAccessError()) + + const response = await GET(request('workspaceId=someone-elses-workspace', 'HEAD'), { + ...context, + }) + + expect(response.status).toBe(404) + expect(mocks.discover).not.toHaveBeenCalled() + }) + + it('rejects a HEAD missing the required workspaceId instead of answering 200', async () => { + const response = await GET(request('', 'HEAD'), { ...context }) + + expect(response.status).toBe(400) + expect(mocks.authorizeDiscover).not.toHaveBeenCalled() + expect(mocks.discover).not.toHaveBeenCalled() }) it('rejects a query param it does not implement', async () => { @@ -173,6 +228,62 @@ describe('/api/v2/mcp-servers/[id]/tools', () => { expect(body.error.code).toBe('INTERNAL_ERROR') }) + /** + * `inputSchema` below the `object` wrapper is authored by the third-party + * server, and the MCP SDK's own `ToolSchema` does not declare `description` + * there — its `.catchall(z.unknown())` lets any value through, so a server + * serializing an absent description as JSON `null` (what a Python `None` + * produces) reaches Sim unvalidated. Declaring the key more tightly than the + * upstream schema does made the builder's outbound `.parse()` throw, and + * discovery answered a bare 500 for a payload the protocol permits. + */ + it('publishes a tool whose server reported a non-string inputSchema description', async () => { + mocks.discover.mockResolvedValueOnce({ + tools: [ + { + ...TOOL, + inputSchema: { type: 'object' as const, description: null, properties: {} }, + }, + ], + }) + + const response = await GET(request(`workspaceId=${WORKSPACE_ID}`), { ...context }) + const body = await response.json() + + expect(response.status).toBe(200) + expect(body.data[0].inputSchema).toEqual({ + type: 'object', + description: null, + properties: {}, + }) + }) + + /** + * `McpConnectionError` interpolates the server's display name into its + * message, so selecting the 503 wording by searching that message for + * `cooldown` hands a server named after the word the negative-cache wording + * for a cooldown it was never in. + */ + it('does not read cooldown wording out of a server display name', async () => { + mocks.discover.mockRejectedValueOnce(new McpConnectionError('ECONNREFUSED', 'Cooldown Docs')) + + const response = await GET(request(`workspaceId=${WORKSPACE_ID}`), { ...context }) + const body = await response.json() + + expect(response.status).toBe(503) + expect(body.error.message).toBe('The MCP server could not be reached') + }) + + it('reports a server inside the discovery cooldown with its own wording', async () => { + mocks.discover.mockRejectedValueOnce(new McpServerCooldownError(SERVER_ID)) + + const response = await GET(request(`workspaceId=${WORKSPACE_ID}`), { ...context }) + const body = await response.json() + + expect(response.status).toBe(503) + expect(body.error.message).toBe('The MCP server recently failed and is in cooldown') + }) + it('rejects a workspace API key, which cannot supply the caller`s OAuth grant', async () => { mocks.discover.mockRejectedValueOnce(new WorkspaceApiKeyAuthorizationError()) diff --git a/apps/sim/app/api/v2/mcp-servers/[id]/tools/route.ts b/apps/sim/app/api/v2/mcp-servers/[id]/tools/route.ts index 137ef0fab8e..11343abb137 100644 --- a/apps/sim/app/api/v2/mcp-servers/[id]/tools/route.ts +++ b/apps/sim/app/api/v2/mcp-servers/[id]/tools/route.ts @@ -14,13 +14,8 @@ export const revalidate = 0 * as `mcp-` from the workspace and endpoint URL, and the registration * contract requires a URL. * - * Discovery is not a safe read: it opens a live connection to the registered - * endpoint and records the outcome on the server row. Next aliases `HEAD` onto - * `GET`, and RFC 9110 §9.2.1 defines `HEAD` as safe, so this route declares - * itself not head-safe — a `HEAD` is authenticated and rate-limited, then - * answered bodiless without connecting or writing. Without that, an uptime - * monitor or link checker walking the documented URL list would drive outbound - * third-party traffic and mutate rows on every probe. + * `headSafe: false` because discovery opens a live connection to the registered + * endpoint and records the outcome on the server row. */ export const GET = defineV2JsonRoute({ contract: v2ListMcpServerToolsContract, diff --git a/apps/sim/app/api/v2/mcp-servers/route.test.ts b/apps/sim/app/api/v2/mcp-servers/route.test.ts index 027ae7272ed..92b2d8cc184 100644 --- a/apps/sim/app/api/v2/mcp-servers/route.test.ts +++ b/apps/sim/app/api/v2/mcp-servers/route.test.ts @@ -37,6 +37,7 @@ vi.mock('@/lib/mcp/application/use-cases', () => ({ createMcpServerUseCase: { operation: { id: 'mcp_servers.create' }, execute: mocks.create }, })) +import { REFILTERED_CURSOR_MESSAGE } from '@/lib/api/cursor-binding' import { GET, POST } from '@/app/api/v2/mcp-servers/route' type McpServerRow = typeof mcpServers.$inferSelect @@ -189,6 +190,71 @@ describe('/api/v2/mcp-servers', () => { expect(response.status).toBe(400) }) + /** + * The sort case above is a separate stamp. This pins the filter half of the + * binding end-to-end — the mint in `present` and the read in `mapInput` — + * because the contract-level sweep only checks a hand-maintained map of param + * names and stays green when a route drops the stamp entirely. + */ + it('refuses a cursor minted under a different filter', async () => { + mocks.list.mockResolvedValue({ + servers: [server], + nextCursorKeys: [server.createdAt.toISOString(), server.id], + sortBy: 'createdAt', + sortOrder: 'desc', + }) + + const minted = await GET( + request('GET', `/api/v2/mcp-servers?workspaceId=${WORKSPACE_ID}&search=docs`) + ) + const { nextCursor } = await minted.json() + expect(nextCursor).toEqual(expect.any(String)) + + mocks.list.mockClear() + const replayed = await GET( + request( + 'GET', + `/api/v2/mcp-servers?workspaceId=${WORKSPACE_ID}&search=tickets&cursor=${encodeURIComponent(nextCursor)}` + ) + ) + + expect(replayed.status).toBe(400) + expect((await replayed.json()).error.message).toBe(REFILTERED_CURSOR_MESSAGE) + expect(mocks.list).not.toHaveBeenCalled() + }) + + it('resumes a cursor replayed under the filters it was minted with', async () => { + mocks.list.mockResolvedValue({ + servers: [server], + nextCursorKeys: [server.createdAt.toISOString(), server.id], + sortBy: 'createdAt', + sortOrder: 'desc', + }) + + const minted = await GET( + request('GET', `/api/v2/mcp-servers?workspaceId=${WORKSPACE_ID}&search=docs`) + ) + const { nextCursor } = await minted.json() + + mocks.list.mockClear() + const resumed = await GET( + request( + 'GET', + `/api/v2/mcp-servers?workspaceId=${WORKSPACE_ID}&search=docs&cursor=${encodeURIComponent(nextCursor)}` + ) + ) + + expect(resumed.status).toBe(200) + expect(mocks.list).toHaveBeenCalledWith({ + principal: PRINCIPAL, + input: expect.objectContaining({ + search: 'docs', + cursorKeys: [server.createdAt.toISOString(), server.id], + }), + request: expect.anything(), + }) + }) + it('rejects a fractional limit rather than paging on a fractional LIMIT', async () => { const response = await GET( request('GET', `/api/v2/mcp-servers?workspaceId=${WORKSPACE_ID}&limit=1.5`) diff --git a/apps/sim/app/api/v2/mcp-servers/route.ts b/apps/sim/app/api/v2/mcp-servers/route.ts index 53949d93738..37757f7824b 100644 --- a/apps/sim/app/api/v2/mcp-servers/route.ts +++ b/apps/sim/app/api/v2/mcp-servers/route.ts @@ -2,6 +2,7 @@ import { v2CreateMcpServerContract, v2ListMcpServersContract, } from '@/lib/api/contracts/v2/mcp-servers' +import { cursorScopeKey } from '@/lib/api/cursor-binding' import { defineV2JsonRoute, v2ApiKeyAuth, @@ -11,12 +12,20 @@ import { import { mcpServerOperations } from '@/lib/mcp/application/operations' import { createMcpServerUseCase, listMcpServersUseCase } from '@/lib/mcp/application/use-cases' import { captureServerEvent } from '@/lib/posthog/server' -import { cursorSortKey, encodeSortedCursor, readSortedCursor } from '@/app/api/v2/lib/response' +import { readSortedCursor, writeSortedCursor } from '@/app/api/v2/lib/response' import { toV2McpServer } from '@/app/api/v2/mcp-servers/utils' export const dynamic = 'force-dynamic' export const revalidate = 0 +/** Every param that changes which MCP servers, in which order, this list returns. */ +function mcpServerCursorFilters(query: { workspaceId: string; search?: string }) { + return cursorScopeKey({ + workspaceId: query.workspaceId, + search: query.search, + }) +} + /** GET /api/v2/mcp-servers — List MCP servers in a workspace. */ export const GET = defineV2JsonRoute({ contract: v2ListMcpServersContract, @@ -30,14 +39,22 @@ export const GET = defineV2JsonRoute({ sortBy: query.sortBy, sortOrder: query.sortOrder, limit: query.limit, - cursorKeys: readSortedCursor(query.cursor, query.sortBy, query.sortOrder), + cursorKeys: readSortedCursor( + query.cursor, + query.sortBy, + query.sortOrder, + mcpServerCursorFilters(query) + ), }), useCase: listMcpServersUseCase, - present: ({ servers, nextCursorKeys, sortBy, sortOrder }) => ({ + present: ({ servers, nextCursorKeys }, { query }) => ({ data: servers.map(toV2McpServer), - nextCursor: nextCursorKeys - ? encodeSortedCursor(cursorSortKey(sortBy, sortOrder), nextCursorKeys) - : null, + nextCursor: writeSortedCursor( + nextCursorKeys, + query.sortBy, + query.sortOrder, + mcpServerCursorFilters(query) + ), }), }) diff --git a/apps/sim/app/api/v2/mcp-servers/utils.ts b/apps/sim/app/api/v2/mcp-servers/utils.ts index a46f344ea57..c8ef47af52b 100644 --- a/apps/sim/app/api/v2/mcp-servers/utils.ts +++ b/apps/sim/app/api/v2/mcp-servers/utils.ts @@ -6,7 +6,11 @@ import { createV2ResourceConcealmentPolicy, type V2ErrorPolicy } from '@/lib/api import { isTimeoutError } from '@/lib/core/execution-limits' import { projectMcpHeaders } from '@/lib/mcp/projection' import type { McpServerRow } from '@/lib/mcp/queries' -import { McpConnectionError, McpOauthAuthorizationRequiredError } from '@/lib/mcp/types' +import { + McpConnectionError, + McpOauthAuthorizationRequiredError, + McpServerCooldownError, +} from '@/lib/mcp/types' import { v2Error } from '@/app/api/v2/lib/response' /** @@ -49,10 +53,14 @@ export const MCP_SERVER_REAUTHORIZATION_REQUIRED = 'MCP_SERVER_REAUTHORIZATION_R * * Every branch returns a constant, so an upstream message — which may quote a * hostname, a token endpoint, or a stack — never reaches the caller. + * + * Selection is typed, never matched on message text: `McpConnectionError` + * interpolates the server's display name into its message, so a server named + * after the word `cooldown` would select the cooldown branch it is not in. */ function unreachableServerMessage(error: unknown): string { if (isTimeoutError(error)) return 'The MCP server took too long to respond' - if (error instanceof McpConnectionError && error.message.toLowerCase().includes('cooldown')) { + if (error instanceof McpServerCooldownError) { return 'The MCP server recently failed and is in cooldown' } return 'The MCP server could not be reached' diff --git a/apps/sim/app/api/v2/secrets/[name]/route.test.ts b/apps/sim/app/api/v2/secrets/[name]/route.test.ts index d77c511e782..7f49d462fb8 100644 --- a/apps/sim/app/api/v2/secrets/[name]/route.test.ts +++ b/apps/sim/app/api/v2/secrets/[name]/route.test.ts @@ -86,19 +86,22 @@ const secret = { } const context = { params: Promise.resolve({ name: SECRET_NAME }) } +/** + * The read and delete verbs scope themselves with `?workspaceId=`; the write + * verb carries `workspaceId` in its body. Sending the query copy on a write is + * now a 400 rather than a silently dropped key, so the helper only appends it + * where the contract declares it. + */ function request(method: 'PUT' | 'DELETE', body?: unknown) { - const scope = method === 'DELETE' ? '&scope=workspace' : '' - return new NextRequest( - `http://localhost:3000/api/v2/secrets/${SECRET_NAME}?workspaceId=${WORKSPACE_ID}${scope}`, - { - method, - headers: { - 'x-api-key': 'key', - ...(body === undefined ? {} : { 'Content-Type': 'application/json' }), - }, - ...(body === undefined ? {} : { body: JSON.stringify(body) }), - } - ) + const query = method === 'DELETE' ? `?workspaceId=${WORKSPACE_ID}&scope=workspace` : '' + return new NextRequest(`http://localhost:3000/api/v2/secrets/${SECRET_NAME}${query}`, { + method, + headers: { + 'x-api-key': 'key', + ...(body === undefined ? {} : { 'Content-Type': 'application/json' }), + }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }) } describe('/api/v2/secrets/[name]', () => { @@ -157,6 +160,25 @@ describe('/api/v2/secrets/[name]', () => { }) }) + /** + * The secrets list rejects a query param it does not implement, so the delete + * must too. A caller who mistypes `scope` otherwise gets a 400 for the missing + * required param — but a caller who adds a param that does not exist at all + * would have had it silently ignored. + */ + it('rejects a query param it does not implement', async () => { + const response = await DELETE( + new NextRequest( + `http://localhost:3000/api/v2/secrets/${SECRET_NAME}?workspaceId=${WORKSPACE_ID}&scope=workspace&scopes=personal`, + { method: 'DELETE', headers: { 'x-api-key': 'key' } } + ), + context + ) + + expect(response.status).toBe(400) + expect(mocks.remove).not.toHaveBeenCalled() + }) + it('renders typed application errors without leaking raw errors', async () => { mocks.remove.mockRejectedValueOnce(new OrchestrationError('not_found', 'stored detail')) diff --git a/apps/sim/app/api/v2/secrets/route.test.ts b/apps/sim/app/api/v2/secrets/route.test.ts index 47147b25f91..ef740920143 100644 --- a/apps/sim/app/api/v2/secrets/route.test.ts +++ b/apps/sim/app/api/v2/secrets/route.test.ts @@ -47,6 +47,7 @@ vi.mock('@/lib/secrets/application/use-cases', () => ({ })) import { V2_DEFAULT_PAGE_SIZE } from '@/lib/api/contracts/v2/shared' +import { REFILTERED_CURSOR_MESSAGE } from '@/lib/api/cursor-binding' import { GET } from '@/app/api/v2/secrets/route' const WORKSPACE_ID = 'workspace-1' @@ -136,6 +137,78 @@ describe('GET /api/v2/secrets', () => { }) }) + /** + * Pins the binding end-to-end — the mint in `present` and the read in + * `mapInput` — because the contract-level sweep only checks a hand-maintained + * map of param names and stays green when a route drops the stamp entirely. + */ + it('refuses a cursor minted under a different filter', async () => { + mocks.list.mockResolvedValue({ + secrets: [secret], + userId: 'user-1', + nextCursorKeys: ['STRIPE_API_KEY', 'secret-1'], + sortBy: 'name', + sortOrder: 'asc', + }) + + const minted = await GET( + new NextRequest( + `http://localhost:3000/api/v2/secrets?workspaceId=${WORKSPACE_ID}&search=stripe`, + { headers: { 'x-api-key': 'key' } } + ) + ) + const { nextCursor } = await minted.json() + expect(nextCursor).toEqual(expect.any(String)) + + mocks.list.mockClear() + const replayed = await GET( + new NextRequest( + `http://localhost:3000/api/v2/secrets?workspaceId=${WORKSPACE_ID}&search=twilio&cursor=${encodeURIComponent(nextCursor)}`, + { headers: { 'x-api-key': 'key' } } + ) + ) + + expect(replayed.status).toBe(400) + expect((await replayed.json()).error.message).toBe(REFILTERED_CURSOR_MESSAGE) + expect(mocks.list).not.toHaveBeenCalled() + }) + + it('resumes a cursor replayed under the filters it was minted with', async () => { + mocks.list.mockResolvedValue({ + secrets: [secret], + userId: 'user-1', + nextCursorKeys: ['STRIPE_API_KEY', 'secret-1'], + sortBy: 'name', + sortOrder: 'asc', + }) + + const minted = await GET( + new NextRequest( + `http://localhost:3000/api/v2/secrets?workspaceId=${WORKSPACE_ID}&search=stripe`, + { headers: { 'x-api-key': 'key' } } + ) + ) + const { nextCursor } = await minted.json() + + mocks.list.mockClear() + const resumed = await GET( + new NextRequest( + `http://localhost:3000/api/v2/secrets?workspaceId=${WORKSPACE_ID}&search=stripe&cursor=${encodeURIComponent(nextCursor)}`, + { headers: { 'x-api-key': 'key' } } + ) + ) + + expect(resumed.status).toBe(200) + expect(mocks.list).toHaveBeenCalledWith({ + principal: PRINCIPAL, + input: expect.objectContaining({ + search: 'stripe', + cursorKeys: ['STRIPE_API_KEY', 'secret-1'], + }), + request: expect.anything(), + }) + }) + it('authenticates before validating list input', async () => { mocks.authenticate.mockRejectedValueOnce(new MockV2ApiKeyUnauthenticatedError()) diff --git a/apps/sim/app/api/v2/secrets/route.ts b/apps/sim/app/api/v2/secrets/route.ts index c0b64d7f338..591508f8be1 100644 --- a/apps/sim/app/api/v2/secrets/route.ts +++ b/apps/sim/app/api/v2/secrets/route.ts @@ -1,4 +1,5 @@ import { v2ListSecretsContract } from '@/lib/api/contracts/v2/secrets' +import { cursorScopeKey } from '@/lib/api/cursor-binding' import { defineV2JsonRoute, v2ApiKeyAuth, @@ -7,12 +8,21 @@ import { } from '@/lib/api/server/routes' import { secretOperations } from '@/lib/secrets/application/operations' import { listSecretsUseCase } from '@/lib/secrets/application/use-cases' -import { cursorSortKey, encodeSortedCursor, readSortedCursor } from '@/app/api/v2/lib/response' +import { readSortedCursor, writeSortedCursor } from '@/app/api/v2/lib/response' import { toV2Secret } from '@/app/api/v2/secrets/utils' export const dynamic = 'force-dynamic' export const revalidate = 0 +/** Every param that changes which secrets, in which order, this list returns. */ +function secretCursorFilters(query: { workspaceId: string; scope?: string; search?: string }) { + return cursorScopeKey({ + workspaceId: query.workspaceId, + scope: query.scope, + search: query.search, + }) +} + /** GET /api/v2/secrets — List secret names and metadata without reading their values. */ export const GET = defineV2JsonRoute({ contract: v2ListSecretsContract, @@ -22,13 +32,21 @@ export const GET = defineV2JsonRoute({ errorPolicy: v2OrchestrationErrorPolicy, mapInput: ({ query }) => ({ ...query, - cursorKeys: readSortedCursor(query.cursor, query.sortBy, query.sortOrder), + cursorKeys: readSortedCursor( + query.cursor, + query.sortBy, + query.sortOrder, + secretCursorFilters(query) + ), }), useCase: listSecretsUseCase, - present: ({ secrets, userId, nextCursorKeys, sortBy, sortOrder }) => ({ + present: ({ secrets, userId, nextCursorKeys }, { query }) => ({ data: secrets.map((secret) => toV2Secret(secret, userId)), - nextCursor: nextCursorKeys - ? encodeSortedCursor(cursorSortKey(sortBy, sortOrder), nextCursorKeys) - : null, + nextCursor: writeSortedCursor( + nextCursorKeys, + query.sortBy, + query.sortOrder, + secretCursorFilters(query) + ), }), }) diff --git a/apps/sim/app/api/v2/secrets/utils.ts b/apps/sim/app/api/v2/secrets/utils.ts index e92a1d472ce..498040cd920 100644 --- a/apps/sim/app/api/v2/secrets/utils.ts +++ b/apps/sim/app/api/v2/secrets/utils.ts @@ -1,4 +1,4 @@ -import type { V2Secret, V2SecretScope } from '@/lib/api/contracts/v2/secrets' +import type { V2Secret } from '@/lib/api/contracts/v2/secrets' import type { VisibleWorkspaceCredential } from '@/lib/credentials/queries' /** Serialize environment credential metadata as a secret without exposing its stored value. */ @@ -18,9 +18,3 @@ export function toV2Secret(row: VisibleWorkspaceCredential, userId: string): V2S updatedAt: row.updatedAt.toISOString(), } } - -export function secretCredentialTypes(scope?: V2SecretScope) { - if (scope === 'workspace') return ['env_workspace'] as const - if (scope === 'personal') return ['env_personal'] as const - return ['env_workspace', 'env_personal'] as const -} diff --git a/apps/sim/app/api/v2/skills/[id]/route.test.ts b/apps/sim/app/api/v2/skills/[id]/route.test.ts index 814159913b0..734d4b7da28 100644 --- a/apps/sim/app/api/v2/skills/[id]/route.test.ts +++ b/apps/sim/app/api/v2/skills/[id]/route.test.ts @@ -86,18 +86,22 @@ const skill = { } const context = { params: Promise.resolve({ id: skill.id }) } +/** + * The read and delete verbs scope themselves with `?workspaceId=`; the write + * verb carries `workspaceId` in its body. Sending the query copy on a write is + * now a 400 rather than a silently dropped key, so the helper only appends it + * where the contract declares it. + */ function request(method: 'GET' | 'PATCH' | 'DELETE', body?: unknown) { - return new NextRequest( - `http://localhost:3000/api/v2/skills/${skill.id}?workspaceId=${WORKSPACE_ID}`, - { - method, - headers: { - 'x-api-key': 'key', - ...(body === undefined ? {} : { 'Content-Type': 'application/json' }), - }, - ...(body === undefined ? {} : { body: JSON.stringify(body) }), - } - ) + const query = method === 'PATCH' ? '' : `?workspaceId=${WORKSPACE_ID}` + return new NextRequest(`http://localhost:3000/api/v2/skills/${skill.id}${query}`, { + method, + headers: { + 'x-api-key': 'key', + ...(body === undefined ? {} : { 'Content-Type': 'application/json' }), + }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }) } describe('/api/v2/skills/[id]', () => { @@ -124,6 +128,25 @@ describe('/api/v2/skills/[id]', () => { }) }) + /** + * Every list in this family rejects a query param it does not implement, so + * the single-resource reads must too. A caller who mistypes a flag otherwise + * gets a 200 that silently ignored it, which reads as confirmation the flag + * exists and does nothing. + */ + it('rejects a query param it does not implement', async () => { + const response = await GET( + new NextRequest( + `http://localhost:3000/api/v2/skills/${skill.id}?workspaceId=${WORKSPACE_ID}&includeContents=true`, + { method: 'GET', headers: { 'x-api-key': 'key' } } + ), + context + ) + + expect(response.status).toBe(400) + expect(mocks.get).not.toHaveBeenCalled() + }) + it('updates a skill and emits only surface analytics', async () => { const response = await PATCH( request('PATCH', { workspaceId: WORKSPACE_ID, content: '# Updated' }), diff --git a/apps/sim/app/api/v2/skills/route.test.ts b/apps/sim/app/api/v2/skills/route.test.ts index 6324aee87dc..e84de0dfcdb 100644 --- a/apps/sim/app/api/v2/skills/route.test.ts +++ b/apps/sim/app/api/v2/skills/route.test.ts @@ -50,20 +50,35 @@ vi.mock('@/lib/skills/application/use-cases', () => ({ createSkillUseCase: { operation: { id: 'skills.create' }, execute: mocks.create }, })) +import { cursorScopeKey } from '@/lib/api/cursor-binding' +import { PrincipalKindAuthorizationError } from '@/lib/core/application' +import { cursorSortKey, encodeOffsetCursor } from '@/app/api/v2/lib/response' import { GET, POST } from '@/app/api/v2/skills/route' const WORKSPACE_ID = 'workspace-1' /** - * The scope stamp the route mints for the default query. Written out rather - * than imported so the test pins the wire format a shipped cursor carries. + * A cursor exactly as this route mints one, built from the shared codec so the + * test exercises the real binding rather than a restatement of it. `search` is + * the only filter the skills list takes beyond its workspace. */ -const SCOPE = ({ - search = '', +function skillCursor({ + offset, + search, sortBy = 'createdAt', sortOrder = 'desc', -}: Record = {}) => - `search=${search}&sortBy=${sortBy}&sortOrder=${sortOrder}&workspaceId=${WORKSPACE_ID}` +}: { + offset: number + search?: string + sortBy?: string + sortOrder?: string +}): string { + return encodeOffsetCursor( + cursorSortKey(sortBy, sortOrder), + cursorScopeKey({ workspaceId: WORKSPACE_ID, search }), + offset + ) +} const PRINCIPAL = { kind: 'workspace_api_key' as const, workspaceId: WORKSPACE_ID, keyId: 'key-1' } const AUTH = { principal: PRINCIPAL, @@ -129,7 +144,6 @@ describe('/api/v2/skills', () => { limit: 50, cursor: undefined, offset: 0, - cursorScope: SCOPE(), }, request: expect.anything(), }) @@ -141,9 +155,8 @@ describe('/api/v2/skills', () => { hasMore: true, offset: 2, limit: 2, - cursorScope: SCOPE(), }) - const cursor = Buffer.from(JSON.stringify({ scope: SCOPE(), offset: 2 })).toString('base64') + const cursor = skillCursor({ offset: 2 }) const response = await GET( request( @@ -153,9 +166,7 @@ describe('/api/v2/skills', () => { ) expect(response.status).toBe(200) - expect((await response.json()).nextCursor).toBe( - Buffer.from(JSON.stringify({ scope: SCOPE(), offset: 4 })).toString('base64') - ) + expect((await response.json()).nextCursor).toBe(skillCursor({ offset: 4 })) expect(mocks.list).toHaveBeenCalledWith( expect.objectContaining({ input: expect.objectContaining({ limit: 2, offset: 2 }) }) ) @@ -166,7 +177,7 @@ describe('/api/v2/skills', () => { * cursor minted under one sort must not silently resume under another. */ it('rejects a cursor replayed under a different sort', async () => { - const cursor = Buffer.from(JSON.stringify({ scope: SCOPE(), offset: 2 })).toString('base64') + const cursor = skillCursor({ offset: 2 }) const response = await GET( request( @@ -179,6 +190,50 @@ describe('/api/v2/skills', () => { expect(mocks.list).not.toHaveBeenCalled() }) + /** + * `search` and `sortOrder` change the sequence the offset counts positions in + * just as `sortBy` does, so both are stamped into the scope and both must + * invalidate a replayed cursor. + */ + it.each([ + ['search', 'search=other'], + ['sortOrder', 'sortOrder=asc'], + ])('rejects a cursor replayed under a different %s', async (_field, param) => { + const cursor = skillCursor({ offset: 2 }) + + const response = await GET( + request( + 'GET', + `/api/v2/skills?workspaceId=${WORKSPACE_ID}&${param}&cursor=${encodeURIComponent(cursor)}` + ) + ) + + expect(response.status).toBe(400) + expect(mocks.list).not.toHaveBeenCalled() + }) + + /** + * `limit` is deliberately absent from the scope: it selects how much of the + * sequence to return, not what the sequence is. Stamping it would strand every + * cursor the moment a caller changed page size, for no correctness gain. + */ + it('resumes a cursor minted under a different page size', async () => { + mocks.list.mockResolvedValueOnce({ skills: [skill], hasMore: false, offset: 2, limit: 5 }) + const cursor = skillCursor({ offset: 2 }) + + const response = await GET( + request( + 'GET', + `/api/v2/skills?workspaceId=${WORKSPACE_ID}&limit=5&cursor=${encodeURIComponent(cursor)}` + ) + ) + + expect(response.status).toBe(200) + expect(mocks.list).toHaveBeenCalledWith( + expect.objectContaining({ input: expect.objectContaining({ limit: 5, offset: 2 }) }) + ) + }) + /** * The guard itself is unit-tested in `definition.test.ts`; this proves the * pairing end-to-end, on a real v2 read that used to reply 500 to a plain HEAD. @@ -199,7 +254,16 @@ describe('/api/v2/skills', () => { expect(mocks.list).not.toHaveBeenCalled() }) + /** + * A personal key, not the suite's default workspace key. `skills.create` denies + * a workspace key like every other skill write: the per-skill editor row that + * authorizes an update or a delete resolves against a human subject a workspace + * key cannot supply, so allowing it to create left rows it could never remove. + */ it('creates a skill with the v2 source and status', async () => { + const principal = { kind: 'personal_api_key' as const, userId: 'user-1', keyId: 'key-personal' } + mocks.authenticate.mockResolvedValueOnce({ ...AUTH, principal, keyType: 'personal' as const }) + const response = await POST( request('POST', '/api/v2/skills', { workspaceId: WORKSPACE_ID, @@ -212,7 +276,7 @@ describe('/api/v2/skills', () => { expect(response.status).toBe(201) expect((await response.json()).data.id).toBe(skill.id) expect(mocks.create).toHaveBeenCalledWith({ - principal: PRINCIPAL, + principal, input: { workspaceId: WORKSPACE_ID, name: skill.name, @@ -222,7 +286,6 @@ describe('/api/v2/skills', () => { }, request: expect.anything(), }) - expect(mocks.capture).not.toHaveBeenCalled() }) it('keeps skill analytics on the personal-key v2 surface', async () => { @@ -250,6 +313,30 @@ describe('/api/v2/skills', () => { ) }) + /** + * `skills.create` denies a workspace key outright, so what this pins is the + * surface's half: the refusal reaches the caller as the operation's own 403, + * and a create that never happened emits no analytics. + */ + it('refuses a workspace-key create and records no analytics for it', async () => { + mocks.create.mockRejectedValueOnce( + new PrincipalKindAuthorizationError('workspace_api_key', 'skills.create') + ) + + const response = await POST( + request('POST', '/api/v2/skills', { + workspaceId: WORKSPACE_ID, + name: skill.name, + description: skill.description, + content: skill.content, + }) + ) + + expect(response.status).toBe(403) + expect(mocks.create).toHaveBeenCalledWith(expect.objectContaining({ principal: PRINCIPAL })) + expect(mocks.capture).not.toHaveBeenCalled() + }) + it('authenticates before parsing skill input', async () => { mocks.authenticate.mockRejectedValueOnce(new MockV2ApiKeyUnauthenticatedError()) diff --git a/apps/sim/app/api/v2/skills/route.ts b/apps/sim/app/api/v2/skills/route.ts index e1356ef7ea0..a0bf8acdeb2 100644 --- a/apps/sim/app/api/v2/skills/route.ts +++ b/apps/sim/app/api/v2/skills/route.ts @@ -1,4 +1,5 @@ import { v2CreateSkillContract, v2ListSkillsContract } from '@/lib/api/contracts/v2/skills' +import { cursorScopeKey } from '@/lib/api/cursor-binding' import { defineV2JsonRoute, v2ApiKeyAuth, @@ -8,26 +9,12 @@ import { import { captureServerEvent } from '@/lib/posthog/server' import { skillOperations } from '@/lib/skills/application/operations' import { createSkillUseCase, listSkillsUseCase } from '@/lib/skills/application/use-cases' -import { - decodeOffsetCursor, - encodeOffsetCursor, - offsetCursorScope, -} from '@/app/api/v2/lib/response' +import { cursorSortKey, decodeOffsetCursor, encodeOffsetCursor } from '@/app/api/v2/lib/response' import { toV2Skill, toV2SkillSummary } from '@/app/api/v2/skills/utils' -/** The query state a skills offset cursor is only valid within. */ -function skillCursorScope(query: { - workspaceId: string - search?: string - sortBy: string - sortOrder: string -}): string { - return offsetCursorScope({ - workspaceId: query.workspaceId, - search: query.search, - sortBy: query.sortBy, - sortOrder: query.sortOrder, - }) +/** Every param that changes which skills, in which order, this list returns. */ +function skillCursorFilters(query: { workspaceId: string; search?: string }) { + return cursorScopeKey({ workspaceId: query.workspaceId, search: query.search }) } export const dynamic = 'force-dynamic' @@ -47,17 +34,25 @@ export const GET = defineV2JsonRoute({ * re-checked here. `limit` is deliberately absent — it selects how much of * the sequence to return, not what the sequence is. */ - const scope = skillCursorScope(query) return { ...query, - offset: decodeOffsetCursor(query.cursor, scope), - cursorScope: scope, + offset: decodeOffsetCursor( + query.cursor, + cursorSortKey(query.sortBy, query.sortOrder), + skillCursorFilters(query) + ), } }, useCase: listSkillsUseCase, - present: ({ skills, hasMore, offset, limit, cursorScope }) => ({ + present: ({ skills, hasMore, offset, limit }, { query }) => ({ data: skills.map(toV2SkillSummary), - nextCursor: hasMore ? encodeOffsetCursor(cursorScope, offset + limit) : null, + nextCursor: hasMore + ? encodeOffsetCursor( + cursorSortKey(query.sortBy, query.sortOrder), + skillCursorFilters(query), + offset + limit + ) + : null, }), }) diff --git a/apps/sim/app/api/v2/tables/[tableId]/columns/route.test.ts b/apps/sim/app/api/v2/tables/[tableId]/columns/route.test.ts index 5fedb51afac..58675d8e48f 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/columns/route.test.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/columns/route.test.ts @@ -83,7 +83,7 @@ describe('/api/v2/tables/[tableId]/columns', () => { }) const response = await POST(req, context) - expect(response.status).toBe(200) + expect(response.status).toBe(201) expect((await response.json()).data.columns).toEqual([ { id: 'col-1', name: 'Name', type: 'string', required: false, unique: false }, ]) diff --git a/apps/sim/app/api/v2/tables/[tableId]/groups/route.test.ts b/apps/sim/app/api/v2/tables/[tableId]/groups/route.test.ts index 94bfb5816c6..1d8c2b1fe43 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/groups/route.test.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/groups/route.test.ts @@ -87,7 +87,7 @@ describe('/api/v2/tables/[tableId]/groups', () => { v2RouteMocks.gate.mockResolvedValue(null) v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) - mocks.list.mockResolvedValue({ groups: [group] }) + mocks.list.mockResolvedValue({ table, groups: [group] }) mocks.create.mockResolvedValue({ table, group }) mocks.update.mockResolvedValue({ table, group, changed: true, startAutoRun: false }) mocks.remove.mockResolvedValue({ table, groupId: 'group-1' }) @@ -100,7 +100,10 @@ describe('/api/v2/tables/[tableId]/groups', () => { const response = await GET(req, context) expect(response.status).toBe(200) - expect(await response.json()).toEqual({ data: [group], nextCursor: null }) + expect(await response.json()).toEqual({ + data: [{ ...group, outputs: [{ ...group.outputs[0], columnName: 'Result' }] }], + nextCursor: null, + }) expect(mocks.list).toHaveBeenCalledWith({ principal, input: { tableId: 'table-1', workspaceId: WORKSPACE_ID }, @@ -136,6 +139,25 @@ describe('/api/v2/tables/[tableId]/groups', () => { expect(response.status).toBe(201) expect((await response.json()).data.group.id).toBe('group-1') + + /** + * `columnName` is sent as a column name and stored as a column id; reading + * back the id under the same field made the value un-round-trippable and + * unmatched by anything else on a surface that is otherwise name-keyed. + */ + const created = await POST( + writeRequest('POST', { + workspaceId: WORKSPACE_ID, + group: { + workflowId: 'workflow-1', + type: 'manual', + outputs: [{ blockId: 'block-1', path: 'result', columnName: 'Result' }], + }, + outputColumns: [{ name: 'Result', type: 'string' }], + }), + context + ) + expect((await created.json()).data.group.outputs[0].columnName).toBe('Result') expect(mocks.create).toHaveBeenCalledWith({ principal, input: expect.objectContaining({ diff --git a/apps/sim/app/api/v2/tables/[tableId]/groups/route.ts b/apps/sim/app/api/v2/tables/[tableId]/groups/route.ts index b92dfb69d5f..178e9564382 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/groups/route.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/groups/route.ts @@ -14,6 +14,7 @@ import { } from '@/lib/table/application/groups' import { tableOperations } from '@/lib/table/application/operations' import { normalizeColumn } from '@/lib/table/wire' +import { presentV2WorkflowGroup } from '@/app/api/v2/tables/presenters' export const dynamic = 'force-dynamic' export const revalidate = 0 @@ -26,7 +27,10 @@ export const GET = defineV2JsonRoute({ rateLimit: v2RateLimits.publicApi, errorPolicy: v2TableErrorPolicies.concealTableAuthorization, mapInput: ({ params, query }) => ({ tableId: params.tableId, workspaceId: query.workspaceId }), - present: ({ groups }) => ({ data: groups, nextCursor: null }), + present: ({ table, groups }) => ({ + data: groups.map((group) => presentV2WorkflowGroup(group, table.schema)), + nextCursor: null, + }), }) export const POST = defineV2JsonRoute({ @@ -38,7 +42,10 @@ export const POST = defineV2JsonRoute({ errorPolicy: v2TableErrorPolicies.concealTableAuthorization, mapInput: ({ params, body }) => ({ tableId: params.tableId, ...body }), present: ({ table, group }) => ({ - data: { group, columns: table.schema.columns.map(normalizeColumn) }, + data: { + group: presentV2WorkflowGroup(group, table.schema), + columns: table.schema.columns.map(normalizeColumn), + }, }), }) @@ -51,7 +58,10 @@ export const PATCH = defineV2JsonRoute({ errorPolicy: v2TableErrorPolicies.concealTableAuthorization, mapInput: ({ params, body }) => ({ tableId: params.tableId, ...body }), present: ({ table, group }) => ({ - data: { group, columns: table.schema.columns.map(normalizeColumn) }, + data: { + group: presentV2WorkflowGroup(group, table.schema), + columns: table.schema.columns.map(normalizeColumn), + }, }), }) diff --git a/apps/sim/app/api/v2/tables/[tableId]/rows/[rowId]/route.test.ts b/apps/sim/app/api/v2/tables/[tableId]/rows/[rowId]/route.test.ts index 276751f0723..387473a3935 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/rows/[rowId]/route.test.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/rows/[rowId]/route.test.ts @@ -131,6 +131,7 @@ describe('/api/v2/tables/[tableId]/rows/[rowId]', () => { rowId: 'row-1', assertedWorkspaceId: WORKSPACE_ID, data: { name: 'Ada' }, + strictWrite: true, }, request: req, }) diff --git a/apps/sim/app/api/v2/tables/[tableId]/rows/[rowId]/route.ts b/apps/sim/app/api/v2/tables/[tableId]/rows/[rowId]/route.ts index 446ecf0ee97..465577e099a 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/rows/[rowId]/route.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/rows/[rowId]/route.ts @@ -41,6 +41,7 @@ export const PATCH = defineV2JsonRoute({ rowId: params.rowId, assertedWorkspaceId: body.workspaceId, data: body.data, + strictWrite: true, }), useCase: updateTableRow, present: ({ table, row }) => ({ diff --git a/apps/sim/app/api/v2/tables/[tableId]/rows/route.test.ts b/apps/sim/app/api/v2/tables/[tableId]/rows/route.test.ts index 3d7ef8d5271..d36a43f1516 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/rows/route.test.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/rows/route.test.ts @@ -143,7 +143,10 @@ describe('/api/v2/tables/[tableId]/rows', () => { it('delegates single and batch creation through one semantic use case', async () => { const single = request('POST', { workspaceId: WORKSPACE_ID, data: { name: 'Ada' } }) - expect((await (await POST(single, CONTEXT)).json()).data.id).toBe('row-1') + const singleResponse = await POST(single, CONTEXT) + // 201 on both arms: every v2 create answers the same status, batch included. + expect(singleResponse.status).toBe(201) + expect((await singleResponse.json()).data.id).toBe('row-1') expect(mocks.createRows).toHaveBeenLastCalledWith({ principal: PRINCIPAL, input: { @@ -151,13 +154,19 @@ describe('/api/v2/tables/[tableId]/rows', () => { tableId: 'table-1', assertedWorkspaceId: WORKSPACE_ID, data: { name: 'Ada' }, + // v2 alone opts into the strict write contract: an unknown column name + // or a value the column cannot hold is a 400, not a dropped key or a + // nulled cell. Every first-party surface leaves this unset. + strictWrite: true, }, request: single, }) mocks.createRows.mockResolvedValue({ kind: 'batch', table: TABLE, rows: [ROW] }) const batch = request('POST', { workspaceId: WORKSPACE_ID, rows: [{ name: 'Ada' }] }) - expect((await (await POST(batch, CONTEXT)).json()).data.insertedCount).toBe(1) + const batchResponse = await POST(batch, CONTEXT) + expect(batchResponse.status).toBe(201) + expect((await batchResponse.json()).data.insertedCount).toBe(1) expect(mocks.createRows).toHaveBeenLastCalledWith({ principal: PRINCIPAL, input: { @@ -165,6 +174,7 @@ describe('/api/v2/tables/[tableId]/rows', () => { tableId: 'table-1', assertedWorkspaceId: WORKSPACE_ID, rows: [{ name: 'Ada' }], + strictWrite: true, }, request: batch, }) @@ -200,4 +210,38 @@ describe('/api/v2/tables/[tableId]/rows', () => { }, }) }) + /** + * A table cell is `z.unknown()` on the wire — its type is decided by the + * column, not the contract — so no string schema guards it. A `U+0000` in a + * cell value or a predicate value therefore travelled all the way to the + * driver and came back as `500 INTERNAL_ERROR`. + */ + describe('NUL bytes in table values', () => { + const NUL = '\u0000' + + it('rejects a NUL in a cell value before the row use case runs', async () => { + const response = await POST( + request('POST', { workspaceId: WORKSPACE_ID, data: { name: `a${NUL}b` } }), + CONTEXT + ) + + expect(response.status).toBe(400) + expect((await response.json()).error.code).toBe('BAD_REQUEST') + expect(mocks.createRows).not.toHaveBeenCalled() + }) + + it('rejects a NUL in a predicate value on the update-by-filter path', async () => { + const response = await PATCH( + request('PATCH', { + workspaceId: WORKSPACE_ID, + filter: { all: [{ field: 'name', op: 'contains', value: `a${NUL}b` }] }, + data: { name: 'Grace' }, + }), + CONTEXT + ) + + expect(response.status).toBe(400) + expect(mocks.updateRows).not.toHaveBeenCalled() + }) + }) }) diff --git a/apps/sim/app/api/v2/tables/[tableId]/rows/route.ts b/apps/sim/app/api/v2/tables/[tableId]/rows/route.ts index 2d7d9592135..e309607496f 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/rows/route.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/rows/route.ts @@ -54,6 +54,7 @@ export const POST = defineV2JsonRoute({ tableId: params.tableId, assertedWorkspaceId: body.workspaceId, rows: body.rows, + strictWrite: true, } : { kind: 'single' as const, @@ -62,6 +63,7 @@ export const POST = defineV2JsonRoute({ data: body.data, afterRowId: body.afterRowId, beforeRowId: body.beforeRowId, + strictWrite: true, }, useCase: createTableRows, present: (result) => { @@ -89,6 +91,7 @@ export const PATCH = defineV2JsonRoute({ filter: body.filter, data: body.data, limit: body.limit, + strictWrite: true, }), useCase: updateTableRows, present: ({ affectedCount, affectedRowIds }) => ({ diff --git a/apps/sim/app/api/v2/tables/[tableId]/rows/upsert/route.test.ts b/apps/sim/app/api/v2/tables/[tableId]/rows/upsert/route.test.ts index 96e633d0834..e17f2760632 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/rows/upsert/route.test.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/rows/upsert/route.test.ts @@ -102,6 +102,7 @@ describe('POST /api/v2/tables/[tableId]/rows/upsert', () => { assertedWorkspaceId: WORKSPACE_ID, data: { email: 'ada@example.com' }, conflictTarget: 'email', + strictWrite: true, }, request, }) diff --git a/apps/sim/app/api/v2/tables/[tableId]/rows/upsert/route.ts b/apps/sim/app/api/v2/tables/[tableId]/rows/upsert/route.ts index 6c2d84285de..26550374d43 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/rows/upsert/route.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/rows/upsert/route.ts @@ -20,6 +20,7 @@ export const POST = defineV2JsonRoute({ assertedWorkspaceId: body.workspaceId, data: body.data, conflictTarget: body.conflictTarget, + strictWrite: true, }), useCase: upsertTableRow, present: ({ table, row, operation }) => ({ diff --git a/apps/sim/app/api/v2/tables/[tableId]/views/[viewId]/route.test.ts b/apps/sim/app/api/v2/tables/[tableId]/views/[viewId]/route.test.ts index 9fdecfd9985..c680afffa9c 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/views/[viewId]/route.test.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/views/[viewId]/route.test.ts @@ -46,11 +46,19 @@ const auth = { rateLimitSubscription: null, keyType: 'workspace' as const, } +const columns = [ + { id: 'col_a', name: 'Status', type: 'text' as const }, + { id: 'col_b', name: 'Email', type: 'text' as const }, +] const view = { id: 'view-1', tableId: 'table-1', name: 'Active', - config: {}, + config: { + hiddenColumns: ['col_b'], + sort: [{ field: 'col_a', direction: 'desc' as const }], + filter: { all: [{ field: 'col_a', op: 'eq' as const, value: 'open' }] }, + }, isDefault: true, createdBy: 'user-1', createdAt: new Date('2026-01-01T00:00:00.000Z'), @@ -76,8 +84,8 @@ describe('/api/v2/tables/[tableId]/views/[viewId]', () => { v2RouteMocks.gate.mockResolvedValue(null) v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) - mocks.read.mockResolvedValue({ view }) - mocks.update.mockResolvedValue({ view, changed: false }) + mocks.read.mockResolvedValue({ view, columns }) + mocks.update.mockResolvedValue({ view, columns, changed: false }) mocks.remove.mockResolvedValue({ viewId: 'view-1' }) mocks.email.mockResolvedValue('user@example.com') }) @@ -95,6 +103,20 @@ describe('/api/v2/tables/[tableId]/views/[viewId]', () => { }) }) + /** + * Storage keys on stable column ids; this surface reads and writes column + * names, so a `col_…` id must never reach the caller. + */ + it('presents the saved config keyed by column name', async () => { + const response = await GET(request('GET'), context) + + expect((await response.json()).data.config).toEqual({ + hiddenColumns: ['Email'], + sort: [{ field: 'Status', direction: 'desc' }], + filter: { all: [{ field: 'Status', op: 'eq', value: 'open' }] }, + }) + }) + it('preserves no-op PATCH response compatibility', async () => { const response = await PATCH( request('PATCH', { workspaceId: WORKSPACE_ID, name: 'Active' }), diff --git a/apps/sim/app/api/v2/tables/[tableId]/views/[viewId]/route.ts b/apps/sim/app/api/v2/tables/[tableId]/views/[viewId]/route.ts index 9f9ccada5c4..e7d4a2eaed2 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/views/[viewId]/route.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/views/[viewId]/route.ts @@ -17,10 +17,17 @@ import { toApiView } from '@/app/api/v2/tables/utils' export const dynamic = 'force-dynamic' export const revalidate = 0 -async function presentView(result: { view: Parameters[0] }) { - const { view } = result +async function presentView(result: { + view: Parameters[0] + columns: Parameters[2] +}) { + const { view, columns } = result return { - data: toApiView(view, view.createdBy ? await getRequiredUserEmail(view.createdBy) : null), + data: toApiView( + view, + view.createdBy ? await getRequiredUserEmail(view.createdBy) : null, + columns + ), } } diff --git a/apps/sim/app/api/v2/tables/[tableId]/views/route.test.ts b/apps/sim/app/api/v2/tables/[tableId]/views/route.test.ts index e89fe4a4fa2..006383eac23 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/views/route.test.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/views/route.test.ts @@ -49,11 +49,12 @@ const auth = { rateLimitSubscription: null, keyType: 'workspace' as const, } +const columns = [{ id: 'col_a', name: 'Status', type: 'text' as const }] const view = { id: 'view-1', tableId: 'table-1', name: 'Active', - config: {}, + config: { sort: [{ field: 'col_a', direction: 'desc' as const }] }, isDefault: false, createdBy: 'user-1', createdAt: new Date('2026-01-01T00:00:00.000Z'), @@ -68,8 +69,8 @@ describe('/api/v2/tables/[tableId]/views', () => { v2RouteMocks.gate.mockResolvedValue(null) v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) - mocks.list.mockResolvedValue({ views: [view] }) - mocks.create.mockResolvedValue({ view }) + mocks.list.mockResolvedValue({ views: [view], columns }) + mocks.create.mockResolvedValue({ view, columns }) mocks.emails.mockResolvedValue(new Map([['user-1', 'user@example.com']])) mocks.email.mockResolvedValue('user@example.com') }) @@ -87,7 +88,7 @@ describe('/api/v2/tables/[tableId]/views', () => { id: 'view-1', tableId: 'table-1', name: 'Active', - config: {}, + config: { sort: [{ field: 'Status', direction: 'desc' }] }, isDefault: false, createdByEmail: 'user@example.com', createdAt: '2026-01-01T00:00:00.000Z', diff --git a/apps/sim/app/api/v2/tables/[tableId]/views/route.ts b/apps/sim/app/api/v2/tables/[tableId]/views/route.ts index e4eb50539e9..0daa6815561 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/views/route.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/views/route.ts @@ -21,7 +21,7 @@ export const GET = defineV2JsonRoute({ rateLimit: v2RateLimits.publicApi, errorPolicy: v2TableErrorPolicies.concealTableAuthorization, mapInput: ({ params, query }) => ({ tableId: params.tableId, workspaceId: query.workspaceId }), - present: async ({ views }) => { + present: async ({ views, columns }) => { const emailByUserId = await getUserEmailsByIds( views.flatMap((view) => (view.createdBy ? [view.createdBy] : [])) ) @@ -29,7 +29,8 @@ export const GET = defineV2JsonRoute({ data: views.map((view) => toApiView( view, - view.createdBy ? requireResolvedUserEmail(emailByUserId, view.createdBy) : null + view.createdBy ? requireResolvedUserEmail(emailByUserId, view.createdBy) : null, + columns ) ), nextCursor: null, @@ -45,7 +46,11 @@ export const POST = defineV2JsonRoute({ rateLimit: v2RateLimits.publicApi, errorPolicy: v2TableErrorPolicies.concealTableAuthorization, mapInput: ({ params, body }) => ({ tableId: params.tableId, ...body }), - present: async ({ view }) => ({ - data: toApiView(view, view.createdBy ? await getRequiredUserEmail(view.createdBy) : null), + present: async ({ view, columns }) => ({ + data: toApiView( + view, + view.createdBy ? await getRequiredUserEmail(view.createdBy) : null, + columns + ), }), }) diff --git a/apps/sim/app/api/v2/tables/imports/[importId]/route.ts b/apps/sim/app/api/v2/tables/imports/[importId]/route.ts index 7092782c602..5aa45c2a899 100644 --- a/apps/sim/app/api/v2/tables/imports/[importId]/route.ts +++ b/apps/sim/app/api/v2/tables/imports/[importId]/route.ts @@ -17,9 +17,10 @@ export const GET = defineV2JsonRoute({ auth: v2ApiKeyAuth, rateLimit: v2RateLimits.publicApi, errorPolicy: v2TableErrorPolicies.concealImportAuthorization, - mapInput: ({ params, query }) => ({ + mapInput: ({ params, query, headers }) => ({ importId: params.importId, workspaceId: query.workspaceId, + uploadToken: headers['upload-token'], }), useCase: readTableImportUseCase, present: ({ import: tableImport }) => presentV2TableImport(tableImport), diff --git a/apps/sim/app/api/v2/tables/imports/route.test.ts b/apps/sim/app/api/v2/tables/imports/route.test.ts index 44fe854fa3a..8c25906f209 100644 --- a/apps/sim/app/api/v2/tables/imports/route.test.ts +++ b/apps/sim/app/api/v2/tables/imports/route.test.ts @@ -88,7 +88,7 @@ describe('POST /api/v2/tables/imports', () => { session: { id: 'import-1', workspaceId: WORKSPACE_ID, - status: 'queued', + status: 'processing', source: { type: 'workspace_file', fileId: 'file-1' }, target: { type: 'new', name: 'imported_data' }, tableId: 'table-1', diff --git a/apps/sim/app/api/v2/tables/presenters.test.ts b/apps/sim/app/api/v2/tables/presenters.test.ts index da5f27bca2c..7b08ee059ac 100644 --- a/apps/sim/app/api/v2/tables/presenters.test.ts +++ b/apps/sim/app/api/v2/tables/presenters.test.ts @@ -3,10 +3,12 @@ */ import { describe, expect, it } from 'vitest' +import type { TableSchema, WorkflowGroup } from '@/lib/table/types' import { presentV2CreateTableImport, presentV2TableExport, presentV2TableImport, + presentV2WorkflowGroup, } from '@/app/api/v2/tables/presenters' const createdAt = new Date('2026-08-01T00:00:00.000Z') @@ -80,3 +82,59 @@ describe('v2 table presenters', () => { }) }) }) + +/** + * A group is created with column **names** and was read back with stored column + * **ids** under the same `columnName` field, on a surface every other row/data + * endpoint keys by name. The value could not be round-tripped into another + * create, and named nothing the caller could see elsewhere. + */ +describe('presentV2WorkflowGroup', () => { + const schema: TableSchema = { + columns: [ + { id: 'col_score', name: 'score', type: 'number' }, + { id: 'col_input', name: 'website', type: 'string' }, + ], + } + + const stored = { + id: 'group-1', + workflowId: 'workflow-1', + outputs: [{ blockId: 'block-1', path: 'result', columnName: 'col_score' }], + dependencies: { columns: ['col_input'] }, + inputMappings: [{ inputName: 'url', columnName: 'col_input' }], + } as WorkflowGroup + + it('presents every column reference as the column name', () => { + const presented = presentV2WorkflowGroup(stored, schema) + + expect(presented.outputs[0].columnName).toBe('score') + expect(presented.dependencies?.columns).toEqual(['website']) + expect(presented.inputMappings?.[0].columnName).toBe('website') + }) + + it('leaves the stored group untouched', () => { + presentV2WorkflowGroup(stored, schema) + expect(stored.outputs[0].columnName).toBe('col_score') + }) + + it('passes a reference naming no current column through unchanged', () => { + const orphaned = { + ...stored, + outputs: [{ blockId: 'block-1', path: 'result', columnName: 'col_deleted' }], + } as WorkflowGroup + + expect(presentV2WorkflowGroup(orphaned, schema).outputs[0].columnName).toBe('col_deleted') + }) + + it('leaves a legacy name-keyed group alone', () => { + const legacy = { + ...stored, + outputs: [{ blockId: 'block-1', path: 'result', columnName: 'score' }], + dependencies: undefined, + inputMappings: undefined, + } as unknown as WorkflowGroup + + expect(presentV2WorkflowGroup(legacy, schema).outputs[0].columnName).toBe('score') + }) +}) diff --git a/apps/sim/app/api/v2/tables/presenters.ts b/apps/sim/app/api/v2/tables/presenters.ts index d194cb5c3fa..10a624e0980 100644 --- a/apps/sim/app/api/v2/tables/presenters.ts +++ b/apps/sim/app/api/v2/tables/presenters.ts @@ -1,3 +1,4 @@ +import { buildNameById, remapGroupColumnRefs } from '@/lib/table/column-keys' import { type TableExportRecord, toV2TableExport } from '@/lib/table/orchestration/export-resource' import { type CreateTableImportResult, @@ -5,6 +6,7 @@ import { toV2CreateTableImport, toV2TableImport, } from '@/lib/table/orchestration/import-resource' +import type { TableSchema, WorkflowGroup } from '@/lib/table/types' export function presentV2CreateTableImport(result: CreateTableImportResult) { return { data: toV2CreateTableImport(result) } @@ -17,3 +19,20 @@ export function presentV2TableImport(record: TableImportResource) { export function presentV2TableExport(record: TableExportRecord, queued = false) { return { data: toV2TableExport(record, queued) } } + +/** + * A workflow group with its column references presented as column **names**. + * + * Groups store `outputs[].columnName`, `dependencies.columns[]`, and + * `inputMappings[].columnName` as stable column **ids** so a rename cannot + * orphan them — but the field is named for, documented as, and accepted on + * create as a name, and every other v2 row surface is keyed by name. Reading + * back a `col_…` id under `columnName` meant a group could not be round-tripped + * into a create, and the value did not correspond to anything else the caller + * could see. `remapGroupColumnRefs` is the same rewrite the write path uses, + * driven by the inverse map; a ref naming no current column is left as-is, so a + * legacy name-keyed group and a ref to a since-deleted column both survive. + */ +export function presentV2WorkflowGroup(group: WorkflowGroup, schema: TableSchema): WorkflowGroup { + return remapGroupColumnRefs(group, buildNameById(schema)) +} diff --git a/apps/sim/app/api/v2/tables/route.test.ts b/apps/sim/app/api/v2/tables/route.test.ts index e27af471956..89c64f6b4bd 100644 --- a/apps/sim/app/api/v2/tables/route.test.ts +++ b/apps/sim/app/api/v2/tables/route.test.ts @@ -36,6 +36,8 @@ vi.mock('@/lib/table/billing', () => ({ getMaxRowsPerTable: mocks.getMaxRowsPerTable, })) +import { REFILTERED_CURSOR_MESSAGE } from '@/lib/api/cursor-binding' +import { ForbiddenOperationError } from '@/lib/core/application/forbidden' import { GET, POST } from '@/app/api/v2/tables/route' const WORKSPACE_ID = 'workspace-1' @@ -94,6 +96,72 @@ describe('/api/v2/tables', () => { mocks.create.mockResolvedValue({ table, folderPath: '/' }) }) + /** + * The cursor a page mints is bound to the filters that produced it, so + * resuming it under a different `search` or `folderPath` is a 400 rather than + * a page silently sequenced against rows the new filter excludes. Pins the + * binding end-to-end — both the mint in `present` and the read in `mapInput` — + * because the contract-level sweep only checks a hand-maintained map of param + * names and stays green when a route drops the stamp entirely. + */ + it('refuses a cursor minted under a different filter', async () => { + mocks.list.mockResolvedValue({ + tables: [{ table, folderPath: '/' }], + nextKeys: ['Contacts', 'table-1'], + sortBy: 'name', + sortOrder: 'asc', + }) + + const minted = await GET( + new NextRequest( + `http://localhost:3000/api/v2/tables?workspaceId=${WORKSPACE_ID}&limit=25&search=alpha` + ) + ) + const { nextCursor } = await minted.json() + expect(nextCursor).toEqual(expect.any(String)) + + mocks.list.mockClear() + const replayed = await GET( + new NextRequest( + `http://localhost:3000/api/v2/tables?workspaceId=${WORKSPACE_ID}&limit=25&search=beta&cursor=${encodeURIComponent(nextCursor)}` + ) + ) + + expect(replayed.status).toBe(400) + expect((await replayed.json()).error.message).toBe(REFILTERED_CURSOR_MESSAGE) + expect(mocks.list).not.toHaveBeenCalled() + }) + + it('resumes a cursor replayed under the filters it was minted with', async () => { + mocks.list.mockResolvedValue({ + tables: [{ table, folderPath: '/' }], + nextKeys: ['Contacts', 'table-1'], + sortBy: 'name', + sortOrder: 'asc', + }) + + const minted = await GET( + new NextRequest( + `http://localhost:3000/api/v2/tables?workspaceId=${WORKSPACE_ID}&limit=25&search=alpha` + ) + ) + const { nextCursor } = await minted.json() + + mocks.list.mockClear() + const resumed = await GET( + new NextRequest( + `http://localhost:3000/api/v2/tables?workspaceId=${WORKSPACE_ID}&limit=25&search=alpha&cursor=${encodeURIComponent(nextCursor)}` + ) + ) + + expect(resumed.status).toBe(200) + expect(mocks.list).toHaveBeenCalledWith({ + principal, + input: expect.objectContaining({ after: ['Contacts', 'table-1'] }), + request: expect.anything(), + }) + }) + it('lists through the semantic use case and preserves the cursor envelope', async () => { const request = new NextRequest( `http://localhost:3000/api/v2/tables?workspaceId=${WORKSPACE_ID}&limit=25` @@ -204,6 +272,37 @@ describe('/api/v2/tables', () => { ) }) + /** + * A quota ceiling and a permission refusal share the `403` status but demand + * opposite caller behaviour — delete something and retry, versus stop and + * escalate — so the ceiling names itself rather than leaving a client to + * string-match the message. + */ + it('names a workspace table-quota refusal in error.details.code', async () => { + mocks.create.mockRejectedValueOnce( + new ForbiddenOperationError( + 'WORKSPACE_RESOURCE_LIMIT_REACHED', + 'Workspace has reached maximum table limit (100)' + ) + ) + + const request = new NextRequest('http://localhost:3000/api/v2/tables', { + method: 'POST', + headers: { 'content-type': 'application/json', 'x-api-key': 'secret' }, + body: JSON.stringify({ + workspaceId: WORKSPACE_ID, + name: 'Contacts', + schema: { columns: [{ name: 'Name', type: 'string', required: true }] }, + }), + }) + const response = await POST(request) + + expect(response.status).toBe(403) + expect(await response.json()).toMatchObject({ + error: { code: 'FORBIDDEN', details: { code: 'WORKSPACE_RESOURCE_LIMIT_REACHED' } }, + }) + }) + it('rejects an unrecognized key in a table column before calling the use case', async () => { const request = new NextRequest('http://localhost:3000/api/v2/tables', { method: 'POST', diff --git a/apps/sim/app/api/v2/tables/route.ts b/apps/sim/app/api/v2/tables/route.ts index 9099a1f51b9..d4d715fd7fd 100644 --- a/apps/sim/app/api/v2/tables/route.ts +++ b/apps/sim/app/api/v2/tables/route.ts @@ -1,14 +1,24 @@ import { v2CreateTableContract, v2ListTablesContract } from '@/lib/api/contracts/v2/tables' +import { cursorScopeKey } from '@/lib/api/cursor-binding' import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' import { v2TableErrorPolicies } from '@/lib/table/api' import { tableOperations } from '@/lib/table/application/operations' import { createTableUseCase, listTablesUseCase } from '@/lib/table/application/tables' -import { cursorSortKey, encodeSortedCursor, readSortedCursor } from '@/app/api/v2/lib/response' +import { readSortedCursor, writeSortedCursor } from '@/app/api/v2/lib/response' import { toApiTable, toApiTables } from '@/app/api/v2/tables/utils' export const dynamic = 'force-dynamic' export const revalidate = 0 +/** Every param that changes which tables, in which order, this list returns. */ +function tableCursorFilters(query: { workspaceId: string; folderPath?: string; search?: string }) { + return cursorScopeKey({ + workspaceId: query.workspaceId, + folderPath: query.folderPath, + search: query.search, + }) +} + export const GET = defineV2JsonRoute({ contract: v2ListTablesContract, operation: tableOperations.list, @@ -23,11 +33,16 @@ export const GET = defineV2JsonRoute({ sortBy: query.sortBy, sortOrder: query.sortOrder, limit: query.limit, - after: readSortedCursor(query.cursor, query.sortBy, query.sortOrder), + after: readSortedCursor(query.cursor, query.sortBy, query.sortOrder, tableCursorFilters(query)), }), - present: async ({ tables, nextKeys, sortBy, sortOrder }) => ({ + present: async ({ tables, nextKeys }, { query }) => ({ data: await toApiTables(tables), - nextCursor: nextKeys ? encodeSortedCursor(cursorSortKey(sortBy, sortOrder), nextKeys) : null, + nextCursor: writeSortedCursor( + nextKeys, + query.sortBy, + query.sortOrder, + tableCursorFilters(query) + ), }), }) diff --git a/apps/sim/app/api/v2/tables/utils.ts b/apps/sim/app/api/v2/tables/utils.ts index d262e015001..8a480854ca7 100644 --- a/apps/sim/app/api/v2/tables/utils.ts +++ b/apps/sim/app/api/v2/tables/utils.ts @@ -1,23 +1,11 @@ -import type { NextResponse } from 'next/server' import type { V2ApiTable } from '@/lib/api/contracts/v2/tables' -import type { OrchestrationErrorCode } from '@/lib/core/orchestration/types' -import type { MultipartError } from '@/lib/core/utils/multipart' -import type { RowData, TableDefinition, TablePredicate, TableSchema } from '@/lib/table' +import type { RowData, TableDefinition, TableSchema } from '@/lib/table' import { getMaxRowsPerTable } from '@/lib/table/billing' -import { getColumnId } from '@/lib/table/column-keys' -import { TableLockedError } from '@/lib/table/mutation-locks' -import { predicateToFilter } from '@/lib/table/query-builder/converters' -import { - validatePredicateShape, - validateStoragePredicate, -} from '@/lib/table/query-builder/validate' -import { predicateToStorage } from '@/lib/table/select-values' -import type { Filter, TableLockKind } from '@/lib/table/types' +import { buildColumnNameById, remapViewConfigColumnRefs } from '@/lib/table/column-keys' +import type { ColumnDefinition } from '@/lib/table/types' import type { TableView } from '@/lib/table/views/service' import { normalizeColumn } from '@/lib/table/wire' import { getUserEmailsByIds, requireResolvedUserEmail } from '@/lib/users/queries' -import { CSV_IMPORT_PROXY_BODY_CAP_BYTES } from '@/app/api/table/utils' -import { v2Error, v2ErrorForOrchestration } from '@/app/api/v2/lib/response' /** * Shared serialization + error helpers for the v2 tables surface. Every v2 @@ -50,21 +38,6 @@ function requireMaxRows( return maxRows } -/** - * Resolves a public v2 bulk-op predicate to the storage-id-keyed legacy `Filter` - * the row runners consume. The public wire is column-NAME-keyed: shape-check - * first (keying-agnostic), translate names → storage ids (including select - * operand names → option ids), then validate the RESULT against storage keys — - * on a destructive path an unresolved field must 400, not silently match - * nothing. - */ -export function v2BulkPredicateToFilter(predicate: TablePredicate, schema: TableSchema): Filter { - validatePredicateShape(predicate) - const translated = predicateToStorage(predicate, schema) - validateStoragePredicate(translated, schema.columns) - return predicateToFilter(translated) -} - /** * Normalized public table shape — the same subset of fields the v1 surface * exposes, with timestamps serialized to ISO strings. Shared by every v2 table @@ -144,15 +117,27 @@ export async function toApiTables( } /** - * Normalized public view shape. Identical to the stored view except that the - * timestamps are ISO strings, matching every other v2 payload. + * Normalized public view shape: ISO timestamps, and a `config` whose column + * references are presented as column **names**. + * + * A view stores every column reference as a stable id so a rename cannot orphan + * it — but the v2 surface is name-keyed everywhere else (row `data`, query + * predicates, sort fields, and workflow groups via `presentV2WorkflowGroup`), + * and a caller who never sees a `col_…` id cannot round-trip a config it reads + * back into a create. The write path translates in the other direction, so the + * pair is symmetric. A ref naming no current column (a since-deleted column in + * a saved filter) is left as-is. */ -export function toApiView(view: TableView, createdByEmail: string | null) { +export function toApiView( + view: TableView, + createdByEmail: string | null, + columns: ColumnDefinition[] +) { return { id: view.id, tableId: view.tableId, name: view.name, - config: view.config, + config: remapViewConfigColumnRefs(view.config, buildColumnNameById(columns)), isDefault: view.isDefault, createdByEmail, createdAt: toIso(view.createdAt), @@ -166,7 +151,7 @@ export function toApiView(view: TableView, createdByEmail: string | null) { * row `data`. Falls back to the id for a column that no longer exists. */ export function columnNameById(schema: TableSchema): (columnId: string) => string { - const nameById = new Map(schema.columns.map((column) => [getColumnId(column), column.name])) + const nameById = buildColumnNameById(schema.columns) return (columnId) => nameById.get(columnId) ?? columnId } @@ -195,105 +180,3 @@ export function toApiRow(row: ApiRowInput, toNamedRow: (data: RowData) => RowDat updatedAt: toIso(row.updatedAt), } } - -/** - * Maps a {@link MultipartError} from the streaming CSV reader to the v2 - * envelope. Mirrors v1's {@link multipartErrorResponse} — same classification, - * different envelope. - */ -export function v2MultipartError(error: MultipartError): NextResponse { - if (error.code === 'FILE_TOO_LARGE') { - return v2Error('PAYLOAD_TOO_LARGE', 'CSV import file exceeds maximum size') - } - return error.code === 'NO_FILE' - ? v2Error('BAD_REQUEST', 'CSV file is required') - : v2Error('BAD_REQUEST', `Invalid CSV upload: ${error.message}`) -} - -/** - * 413 when a synchronous CSV upload would exceed the proxy's body cap; `null` - * otherwise. Next buffers the request body for the proxy and silently - * TRUNCATES it past the cap, so an unchecked oversize upload imports a partial - * file and reports success — the failure this exists to prevent. - */ -export function v2CsvBodyCapError(request: { headers: Headers }): NextResponse | null { - const contentLength = Number(request.headers.get('content-length') ?? 0) - if (contentLength <= CSV_IMPORT_PROXY_BODY_CAP_BYTES) return null - return v2Error( - 'PAYLOAD_TOO_LARGE', - 'File too large to import through the server. Upload it to workspace storage and use the async import instead.' - ) -} - -/** - * Maps a delete/write rejected by a table lock to the v2 `LOCKED` envelope, - * mirroring v1's {@link tableLockErrorResponse}. Returns `null` for anything - * else so the caller falls through to its own classification. - * - * `details.lock` names the flag that rejected the write. A table carries four - * independent locks, so "locked" on its own does not tell a caller which one to - * clear — every 423 on the surface reports it. - */ -export function v2TableLockError( - error: unknown, - /** Merged into `details` — e.g. which operations of a composite write landed. */ - extraDetails?: Record -): NextResponse | null { - if (error instanceof TableLockedError) { - return v2Error('LOCKED', error.message, { details: { lock: error.lock, ...extraDetails } }) - } - return null -} - -/** The failure half of any `lib/table/orchestration` result. */ -export interface OrchestrationOutcome { - errorCode?: OrchestrationErrorCode - error?: string - lock?: TableLockKind -} - -/** - * Renders a `lib/table/orchestration` failure in the v2 envelope, naming the - * lock when one caused it. - * - * A lock rejection reaches a route two different ways — thrown and caught at - * the boundary ({@link v2TableLockError}), or returned as a classified - * `errorCode: 'locked'` outcome — and both must produce the same body. Plain - * {@link v2ErrorForOrchestration} cannot, because the `lock` kind lives on the - * outcome rather than the code, so every table route that renders an - * orchestration result goes through this instead. - */ -export function v2TableOrchestrationError( - outcome: OrchestrationOutcome, - fallback: string, - /** Merged into `details` — e.g. which operations of a composite write landed. */ - extraDetails?: Record -): NextResponse { - // `lock` is omitted rather than sent as null when the kind is unknown — a - // caller branching on `details.lock` should see absence, not a phantom value. - const details = { - ...(outcome.errorCode === 'locked' && outcome.lock ? { lock: outcome.lock } : {}), - ...extraDetails, - } - return v2ErrorForOrchestration( - outcome.errorCode, - outcome.error ?? fallback, - Object.keys(details).length > 0 ? details : undefined - ) -} - -/** - * Adapts a failed-row validation from the shared `validateRowData` / - * `validateBatchRows` helpers — which bake a v1-shaped `{ error, details }` 400 - * response — into the canonical v2 error envelope while preserving the - * structured `details` (per-field / per-row). The validators expose the failure - * only as a rendered response, so the body is read back rather than - * re-implementing the size/schema/unique checks. - */ -export async function v2RowValidationError(response: NextResponse): Promise { - const body = (await response - .clone() - .json() - .catch(() => ({}))) as { error?: string; details?: unknown } - return v2Error('BAD_REQUEST', body.error ?? 'Invalid row data', { details: body.details }) -} diff --git a/apps/sim/app/api/v2/uploads/[uploadId]/parts/[partNumber]/route.test.ts b/apps/sim/app/api/v2/uploads/[uploadId]/parts/[partNumber]/route.test.ts index f5cc527da7d..c63a810e141 100644 --- a/apps/sim/app/api/v2/uploads/[uploadId]/parts/[partNumber]/route.test.ts +++ b/apps/sim/app/api/v2/uploads/[uploadId]/parts/[partNumber]/route.test.ts @@ -70,7 +70,7 @@ describe('PUT /api/v2/uploads/[uploadId]/parts/[partNumber]', () => { expect(response.status).toBe(400) await expect(response.json()).resolves.toMatchObject({ - error: 'Part 1 has 2 bytes; expected 3', + error: { code: 'BAD_REQUEST', message: 'Part 1 has 2 bytes; expected 3' }, }) }) @@ -93,7 +93,9 @@ describe('PUT /api/v2/uploads/[uploadId]/parts/[partNumber]', () => { const response = await request() expect(response.status).toBe(409) - await expect(response.json()).resolves.toEqual({ error: 'Upload session has expired' }) + await expect(response.json()).resolves.toEqual({ + error: { code: 'CONFLICT', message: 'Upload session has expired' }, + }) expect(mockExpectedUploadPartSize).not.toHaveBeenCalled() expect(mockWriteLocalMultipartPart).not.toHaveBeenCalled() }) diff --git a/apps/sim/app/api/v2/uploads/[uploadId]/parts/[partNumber]/route.ts b/apps/sim/app/api/v2/uploads/[uploadId]/parts/[partNumber]/route.ts index 934e64d839e..91f03f278b7 100644 --- a/apps/sim/app/api/v2/uploads/[uploadId]/parts/[partNumber]/route.ts +++ b/apps/sim/app/api/v2/uploads/[uploadId]/parts/[partNumber]/route.ts @@ -1,6 +1,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { localUploadPartContract } from '@/lib/api/contracts/upload-sessions' import { parseRequest } from '@/lib/api/server' +import { V2_PARSE_DEFAULTS } from '@/lib/api/server/routes' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { LocalUploadBodyError, @@ -11,6 +12,12 @@ import { type UploadSessionRecord, verifyUploadSessionToken, } from '@/lib/uploads/upload-session/service' +import { + v2Error, + v2HttpError, + v2UploadDataPlaneError, + v2ValidationError, +} from '@/app/api/v2/lib/response' interface LocalPartRouteParams { params: Promise<{ uploadId: string; partNumber: string }> @@ -19,6 +26,15 @@ interface LocalPartRouteParams { /** * Local-storage data plane for signed multipart PUT URLs. Cloud deployments return provider URLs * instead, so this route is never in the cloud byte path. + * + * Raw `withRouteHandler` rather than a v2 builder, for the same reason as the + * whole-object PUT beside it: a signed token credential and a streamed body, + * with no `Principal` or semantic operation for a builder to act on. + * + * Absent from the public OpenAPI documents by design — see + * `UNDOCUMENTED_V2_ROUTES` in `scripts/check-openapi-specs.ts` — but it answers + * in the canonical `{ error: { code, message } }` envelope like the rest of the + * surface, for the reason given on the whole-object PUT beside it. */ export const PUT = withRouteHandler( async (request: NextRequest, context: LocalPartRouteParams): Promise => { @@ -28,45 +44,48 @@ export const PUT = withRouteHandler( try { session = await verifyUploadSessionToken(token) } catch { - return NextResponse.json({ error: 'Invalid or expired upload token' }, { status: 403 }) + return v2Error('FORBIDDEN', 'Invalid or expired upload token') } - const parsed = await parseRequest(localUploadPartContract, request, context) + const parsed = await parseRequest(localUploadPartContract, request, context, { + ...V2_PARSE_DEFAULTS, + }) if (!parsed.success) return parsed.response if (session.id !== uploadId || session.storageProvider !== 'local') { - return NextResponse.json({ error: 'Upload URL does not match this session' }, { status: 403 }) + return v2Error('FORBIDDEN', 'Upload URL does not match this session') } if (session.status !== 'uploading') { - return NextResponse.json({ error: `Upload session is ${session.status}` }, { status: 409 }) + return v2Error('CONFLICT', `Upload session is ${session.status}`) } if (session.expiresAt.getTime() <= Date.now()) { - return NextResponse.json({ error: 'Upload session has expired' }, { status: 409 }) + return v2Error('CONFLICT', 'Upload session has expired') } if (session.method !== 'multipart') { - return NextResponse.json({ error: 'PUT upload sessions do not have parts' }, { status: 409 }) + return v2Error('CONFLICT', 'PUT upload sessions do not have parts') } const { partNumber } = parsed.data.params const expectedSize = expectedUploadPartSize(session, partNumber) const contentLength = request.headers.get('content-length') if (contentLength !== null && Number(contentLength) !== expectedSize) { - return NextResponse.json( - { error: `Part ${partNumber} must contain exactly ${expectedSize} bytes` }, - { status: 400 } - ) + return v2Error('BAD_REQUEST', `Part ${partNumber} must contain exactly ${expectedSize} bytes`) } if (!request.body) { - return NextResponse.json({ error: 'Upload part body is required' }, { status: 400 }) + return v2Error('BAD_REQUEST', 'Upload part body is required') } try { await writeLocalMultipartPart({ uploadId, partNumber, body: request.body, expectedSize }) } catch (error) { if (error instanceof LocalUploadBodyError) { - return NextResponse.json({ error: error.message }, { status: 400 }) + return v2Error('BAD_REQUEST', error.message) } throw error } return new NextResponse(null, { status: 204 }) + }, + { + typedErrorResponse: ({ error }) => v2HttpError(error), + unhandledErrorResponse: () => v2UploadDataPlaneError(), } ) diff --git a/apps/sim/app/api/v2/uploads/[uploadId]/route.test.ts b/apps/sim/app/api/v2/uploads/[uploadId]/route.test.ts index 71d92a6d22d..31cd0e3c254 100644 --- a/apps/sim/app/api/v2/uploads/[uploadId]/route.test.ts +++ b/apps/sim/app/api/v2/uploads/[uploadId]/route.test.ts @@ -99,17 +99,27 @@ describe('PUT /api/v2/uploads/[uploadId]', () => { expect(response.status).toBe(400) await expect(response.json()).resolves.toMatchObject({ - error: 'Upload must contain exactly 3 bytes', + error: { code: 'BAD_REQUEST', message: 'Upload must contain exactly 3 bytes' }, }) expect(mockWriteLocalPut).not.toHaveBeenCalled() }) - it('rejects a URL whose token names a non-local or multipart session', async () => { + /** + * This route is deliberately absent from the OpenAPI documents, which is a + * statement about addressability rather than about behaviour. It used to + * answer with a bare `{ error: string }`, which made the one step of an + * upload that actually moves the bytes the one step a caller could not parse + * with its v2 error handling. + */ + it('rejects a URL whose token names a non-local or multipart session, in the v2 envelope', async () => { mockGetOwnedUploadSession.mockReturnValue({ ...SESSION, method: 'multipart' }) const response = await request() expect(response.status).toBe(403) + await expect(response.json()).resolves.toEqual({ + error: { code: 'FORBIDDEN', message: 'Upload URL does not match this session' }, + }) expect(mockWriteLocalPut).not.toHaveBeenCalled() }) @@ -119,7 +129,9 @@ describe('PUT /api/v2/uploads/[uploadId]', () => { const response = await request({ contentLength: null }) expect(response.status).toBe(400) - await expect(response.json()).resolves.toMatchObject({ error: 'Upload exceeds 3 bytes' }) + await expect(response.json()).resolves.toMatchObject({ + error: { code: 'BAD_REQUEST', message: 'Upload exceeds 3 bytes' }, + }) }) }) diff --git a/apps/sim/app/api/v2/uploads/[uploadId]/route.ts b/apps/sim/app/api/v2/uploads/[uploadId]/route.ts index 52ec40a4cdb..d53034de60f 100644 --- a/apps/sim/app/api/v2/uploads/[uploadId]/route.ts +++ b/apps/sim/app/api/v2/uploads/[uploadId]/route.ts @@ -1,21 +1,44 @@ import { type NextRequest, NextResponse } from 'next/server' import { localPutUploadContract } from '@/lib/api/contracts/upload-sessions' import { parseRequest } from '@/lib/api/server' +import { V2_PARSE_DEFAULTS } from '@/lib/api/server/routes' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { LocalUploadBodyError, writeLocalPutObject } from '@/lib/uploads/upload-session/provider' import { getOwnedUploadSession, uploadSessionObjectMetadata, } from '@/lib/uploads/upload-session/service' +import { + v2Error, + v2HttpError, + v2UploadDataPlaneError, + v2ValidationError, +} from '@/app/api/v2/lib/response' interface LocalPutRouteParams { params: Promise<{ uploadId: string }> } -/** Local-storage data plane for a signed whole-object PUT upload session. */ +/** + * Local-storage data plane for a signed whole-object PUT upload session. + * + * Raw `withRouteHandler` rather than a v2 builder: the signed `upload-token` + * header is the credential, so there is no API key, `Principal`, or semantic + * operation for a builder to authenticate and authorize against, and the body + * is streamed straight to storage rather than parsed. + * + * Absent from the public OpenAPI documents by design — see + * `UNDOCUMENTED_V2_ROUTES` in `scripts/check-openapi-specs.ts` — but the error + * envelope is not part of that exemption. This is the one step that moves the + * bytes, and a caller that cannot parse its failures the way it parses every + * other v2 failure has to special-case the whole upload flow, so it renders the + * canonical `{ error: { code, message } }` like the rest of the surface. + */ export const PUT = withRouteHandler( async (request: NextRequest, context: LocalPutRouteParams): Promise => { - const parsed = await parseRequest(localPutUploadContract, request, context) + const parsed = await parseRequest(localPutUploadContract, request, context, { + ...V2_PARSE_DEFAULTS, + }) if (!parsed.success) return parsed.response let session @@ -25,35 +48,29 @@ export const PUT = withRouteHandler( uploadToken: parsed.data.headers['upload-token'], }) } catch { - return NextResponse.json({ error: 'Invalid or expired upload token' }, { status: 403 }) + return v2Error('FORBIDDEN', 'Invalid or expired upload token') } if (session.storageProvider !== 'local' || session.method !== 'put') { - return NextResponse.json({ error: 'Upload URL does not match this session' }, { status: 403 }) + return v2Error('FORBIDDEN', 'Upload URL does not match this session') } if (session.status !== 'uploading') { - return NextResponse.json({ error: `Upload session is ${session.status}` }, { status: 409 }) + return v2Error('CONFLICT', `Upload session is ${session.status}`) } if (session.expiresAt.getTime() <= Date.now()) { - return NextResponse.json({ error: 'Upload session has expired' }, { status: 409 }) + return v2Error('CONFLICT', 'Upload session has expired') } const contentType = request.headers.get('content-type') if (contentType !== session.contentType) { - return NextResponse.json( - { error: `Content-Type must be ${session.contentType}` }, - { status: 400 } - ) + return v2Error('BAD_REQUEST', `Content-Type must be ${session.contentType}`) } const contentLength = request.headers.get('content-length') if (contentLength !== null && Number(contentLength) !== session.fileSize) { - return NextResponse.json( - { error: `Upload must contain exactly ${session.fileSize} bytes` }, - { status: 400 } - ) + return v2Error('BAD_REQUEST', `Upload must contain exactly ${session.fileSize} bytes`) } if (!request.body) { - return NextResponse.json({ error: 'Upload body is required' }, { status: 400 }) + return v2Error('BAD_REQUEST', 'Upload body is required') } try { @@ -67,10 +84,14 @@ export const PUT = withRouteHandler( }) } catch (error) { if (error instanceof LocalUploadBodyError) { - return NextResponse.json({ error: error.message }, { status: 400 }) + return v2Error('BAD_REQUEST', error.message) } throw error } return new NextResponse(null, { status: 204 }) + }, + { + typedErrorResponse: ({ error }) => v2HttpError(error), + unhandledErrorResponse: () => v2UploadDataPlaneError(), } ) diff --git a/apps/sim/app/api/v2/workflows/[id]/deployment/route.ts b/apps/sim/app/api/v2/workflows/[id]/deployment/route.ts index 52224636031..6999c325e3c 100644 --- a/apps/sim/app/api/v2/workflows/[id]/deployment/route.ts +++ b/apps/sim/app/api/v2/workflows/[id]/deployment/route.ts @@ -21,6 +21,9 @@ export const revalidate = 0 * fallback: it retains the timestamp of a deployment that has since been * undeployed, so reading it would report a deploy time alongside * `isDeployed: false`. + * + * Deliberately head-safe despite the migrate-on-read write, for the reasons on + * `GET /api/v2/workflows/[id]`. */ export const GET = defineV2JsonRoute({ contract: v2GetWorkflowDeploymentContract, diff --git a/apps/sim/app/api/v2/workflows/[id]/execute/route.ts b/apps/sim/app/api/v2/workflows/[id]/execute/route.ts index 8e44e5f35f5..59dea2ef5f9 100644 --- a/apps/sim/app/api/v2/workflows/[id]/execute/route.ts +++ b/apps/sim/app/api/v2/workflows/[id]/execute/route.ts @@ -45,7 +45,7 @@ import { hasAgentStreamPolicy, } from '@/lib/workflows/streaming/agent-stream-protocol' import { v2ApiGateError } from '@/app/api/v2/lib/gate' -import { type V2ErrorCode, v2Data, v2Error, v2ValidationError } from '@/app/api/v2/lib/response' +import { type V2ErrorCode, v2Data, v2Error } from '@/app/api/v2/lib/response' import { PublicApiNotAllowedError, validatePublicApiAllowed, @@ -191,7 +191,6 @@ export const POST = withRouteHandler( const parsed = await parseRequest(v2ExecuteWorkflowContract, req, context, { ...V2_PARSE_DEFAULTS, maxBodyBytes: 10 * 1024 * 1024, - validationErrorResponse: v2ValidationError, }) if (!parsed.success) return parsed.response const body = parsed.data.body diff --git a/apps/sim/app/api/v2/workflows/[id]/export/route.test.ts b/apps/sim/app/api/v2/workflows/[id]/export/route.test.ts index 96b5468d343..872932e6ba1 100644 --- a/apps/sim/app/api/v2/workflows/[id]/export/route.test.ts +++ b/apps/sim/app/api/v2/workflows/[id]/export/route.test.ts @@ -29,4 +29,26 @@ describe('/api/v2/workflows/[id]/export route definition', () => { errorPolicy: v2WorkflowErrorPolicies.concealWorkflowAuthorization, }) }) + + /** + * Next aliases a missing `HEAD` export onto `GET`, and RFC 9110 §9.2.1 defines + * `HEAD` as safe. This `GET` is not: the use case projects a + * `WORKFLOW_EXPORTED` audit event, so an uptime monitor or link checker + * probing the documented URL would file an export that never handed anyone + * the workflow. + */ + it('does not run the audited export for a HEAD probe', () => { + expect(GET).toMatchObject({ headSafe: false }) + }) + + /** + * Not running the export is only half of it. The `HEAD` must still resolve the + * workflow and check access, or the probe answers 200 for an id the caller's + * `GET` would conceal as a 404 — an existence oracle over every workspace's + * workflow ids. The builder refuses at definition time to pair + * `headSafe: false` with a use case that cannot answer that on its own. + */ + it('exposes an authorization phase the HEAD probe can run without exporting', () => { + expect(typeof exportWorkflow.authorize).toBe('function') + }) }) diff --git a/apps/sim/app/api/v2/workflows/[id]/export/route.ts b/apps/sim/app/api/v2/workflows/[id]/export/route.ts index d4011a4f64c..ad3af5d0518 100644 --- a/apps/sim/app/api/v2/workflows/[id]/export/route.ts +++ b/apps/sim/app/api/v2/workflows/[id]/export/route.ts @@ -7,11 +7,17 @@ import { workflowOperations } from '@/lib/workflows/application/operations' export const dynamic = 'force-dynamic' export const revalidate = 0 +/** + * `headSafe: false` because the use case projects a `WORKFLOW_EXPORTED` audit + * event. Letting Next alias `HEAD` onto this `GET` would record an export that + * handed the caller no bytes. + */ export const GET = defineV2JsonRoute({ contract: v2ExportWorkflowContract, auth: v2ApiKeyAuth, operation: workflowOperations.export, rateLimit: v2RateLimits.publicApi, + headSafe: false, errorPolicy: v2WorkflowErrorPolicies.concealWorkflowAuthorization, mapInput: ({ params }) => ({ workflowId: params.id }), useCase: exportWorkflow, diff --git a/apps/sim/app/api/v2/workflows/[id]/route.ts b/apps/sim/app/api/v2/workflows/[id]/route.ts index 43c40be983f..9e1d6be489e 100644 --- a/apps/sim/app/api/v2/workflows/[id]/route.ts +++ b/apps/sim/app/api/v2/workflows/[id]/route.ts @@ -12,6 +12,18 @@ import { updateWorkflow } from '@/lib/workflows/application/update-workflow' export const revalidate = 0 +/** + * Deliberately head-safe despite issuing a write. + * + * Reading a workflow can trigger a migrate-on-read `workflow_blocks` update when + * `applyBlockMigrations` upgrades a stored block. That write is convergent: it is + * conditional on a migration actually applying, idempotent, and would be issued by + * the next ordinary read regardless, so a `HEAD` only brings it forward. + * + * Declaring `headSafe: false` would also cost real capability: the bodiless + * `200` is unconditional, so a `HEAD` could no longer distinguish a workflow + * that exists from one that does not. + */ export const GET = defineV2JsonRoute({ contract: v2GetWorkflowContract, auth: v2ApiKeyAuth, diff --git a/apps/sim/app/api/v2/workflows/[id]/runs/[runId]/resume/route.ts b/apps/sim/app/api/v2/workflows/[id]/runs/[runId]/resume/route.ts index 5e459182179..d3e3410117e 100644 --- a/apps/sim/app/api/v2/workflows/[id]/runs/[runId]/resume/route.ts +++ b/apps/sim/app/api/v2/workflows/[id]/runs/[runId]/resume/route.ts @@ -19,7 +19,7 @@ import { v2WorkflowErrorPolicies } from '@/lib/workflows/api' import { workflowOperations } from '@/lib/workflows/application/operations' import { resumeWorkflowRun } from '@/lib/workflows/application/resume-run' import { ResumeWorkflowExecutionError } from '@/lib/workflows/executor/resume-execution' -import { type V2ErrorCode, v2Data, v2Error, v2ValidationError } from '@/app/api/v2/lib/response' +import { type V2ErrorCode, v2Data, v2Error } from '@/app/api/v2/lib/response' import { classifyExecutionError } from '@/executor/utils/errors' const logger = createLogger('V2WorkflowResumeAPI') @@ -55,7 +55,6 @@ export const POST = withRouteHandler( const parsed = await parseRequest(v2ResumeWorkflowContract, request, context, { ...V2_PARSE_DEFAULTS, maxBodyBytes: 10 * 1024 * 1024, - validationErrorResponse: v2ValidationError, }) if (!parsed.success) return parsed.response const { id: workflowId, runId } = parsed.data.params diff --git a/apps/sim/app/api/v2/workflows/[id]/runs/route.test.ts b/apps/sim/app/api/v2/workflows/[id]/runs/route.test.ts index de409d6a69b..eb11ee3c02e 100644 --- a/apps/sim/app/api/v2/workflows/[id]/runs/route.test.ts +++ b/apps/sim/app/api/v2/workflows/[id]/runs/route.test.ts @@ -28,6 +28,7 @@ vi.mock('@/lib/workflows/application/list-workflow-runs', () => ({ }, })) +import { REFILTERED_CURSOR_MESSAGE } from '@/lib/api/cursor-binding' import { NoWorkspaceAccessError, PersonalApiKeysDisabledError } from '@/lib/core/application' import { GET } from '@/app/api/v2/workflows/[id]/runs/route' @@ -139,9 +140,36 @@ describe('GET /api/v2/workflows/[id]/runs', () => { expect(JSON.parse(Buffer.from(body.nextCursor, 'base64').toString())).toEqual({ sort: 'startedAt:asc', keys: ['2026-08-05T00:01:00.000Z', 'row-1'], + filter: expect.any(String), }) }) + /** + * Resuming a cursor under a different filter is a 400, not a page sequenced + * against rows the new filter excludes. The assertion above pins that a filter + * is stamped at all; this pins that the stamp is read back and enforced. + */ + it('refuses a cursor minted under a different filter', async () => { + mocks.listRuns.mockResolvedValueOnce({ + data: EXECUTIONS, + nextCursor: { startedAt: EXECUTIONS[1].startedAt, rowId: 'row-1' }, + workflowId: 'workflow-1', + order: 'asc', + }) + + const { nextCursor } = await (await callGet('?order=asc&status=completed')).json() + expect(nextCursor).toEqual(expect.any(String)) + + mocks.listRuns.mockClear() + const replayed = await callGet( + `?order=asc&status=failed&cursor=${encodeURIComponent(nextCursor)}` + ) + + expect(replayed.status).toBe(400) + expect((await replayed.json()).error.message).toBe(REFILTERED_CURSOR_MESSAGE) + expect(mocks.listRuns).not.toHaveBeenCalled() + }) + it('rejects an invalid cursor after API-key admission without calling the use case', async () => { const response = await callGet('?cursor=not-a-cursor') diff --git a/apps/sim/app/api/v2/workflows/[id]/runs/route.ts b/apps/sim/app/api/v2/workflows/[id]/runs/route.ts index 6893f79f822..aa1528ef178 100644 --- a/apps/sim/app/api/v2/workflows/[id]/runs/route.ts +++ b/apps/sim/app/api/v2/workflows/[id]/runs/route.ts @@ -3,17 +3,32 @@ import { v2ListWorkflowRunsContract, v2WorkflowRunListStatusValueSchema, } from '@/lib/api/contracts/v2/workflows' +import { cursorScopeKey, instantScopePart } from '@/lib/api/cursor-binding' import { INVALID_CURSOR_MESSAGE } from '@/lib/api/list-query' import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' import { OrchestrationError } from '@/lib/core/orchestration/types' import { v2WorkflowErrorPolicies } from '@/lib/workflows/api' import { listWorkflowRuns } from '@/lib/workflows/application/list-workflow-runs' import { workflowOperations } from '@/lib/workflows/application/operations' -import { cursorSortKey, decodeSortedCursor, encodeSortedCursor } from '@/app/api/v2/lib/response' +import { readSortedCursor, writeSortedCursor } from '@/app/api/v2/lib/response' export const dynamic = 'force-dynamic' export const revalidate = 0 +/** Every param that changes which runs, in which order, this list returns. */ +function runCursorFilters( + workflowId: string, + query: { status?: string; trigger?: string; startDate?: string; endDate?: string } +) { + return cursorScopeKey({ + workflowId, + status: query.status, + trigger: query.trigger, + startDate: instantScopePart(query.startDate), + endDate: instantScopePart(query.endDate), + }) +} + /** List the durable runs belonging to one workflow. */ export const GET = defineV2JsonRoute({ contract: v2ListWorkflowRunsContract, @@ -23,16 +38,17 @@ export const GET = defineV2JsonRoute({ errorPolicy: v2WorkflowErrorPolicies.concealWorkflowAuthorization, mapInput: ({ params, query }) => { const { status, trigger, startDate, endDate, limit, cursor, order } = query - const sort = cursorSortKey('startedAt', order) - const decodedCursor = decodeSortedCursor(cursor, sort) - if (decodedCursor.status === 'invalid') { - throw new OrchestrationError('validation', INVALID_CURSOR_MESSAGE) - } - const [cursorStartedAt, cursorRowId] = decodedCursor.status === 'ok' ? decodedCursor.keys : [] + const cursorKeys = readSortedCursor( + cursor, + 'startedAt', + order, + runCursorFilters(params.id, query) + ) + const [cursorStartedAt, cursorRowId] = cursorKeys ?? [] const cursorDate = typeof cursorStartedAt === 'string' ? new Date(cursorStartedAt) : null if ( - decodedCursor.status === 'ok' && - (decodedCursor.keys.length !== 2 || + cursorKeys && + (cursorKeys.length !== 2 || !cursorDate || Number.isNaN(cursorDate.getTime()) || typeof cursorRowId !== 'string') @@ -48,14 +64,14 @@ export const GET = defineV2JsonRoute({ endDate: endDate ? new Date(endDate) : undefined, limit, cursor: - decodedCursor.status === 'ok' && cursorDate && typeof cursorRowId === 'string' + cursorKeys && cursorDate && typeof cursorRowId === 'string' ? { startedAt: cursorDate, rowId: cursorRowId } : undefined, order, } }, useCase: listWorkflowRuns, - present: (result) => { + present: (result, { params, query }) => { const data: V2WorkflowRunListItem[] = result.data.map((row) => ({ runId: row.executionId, workflowId: row.workflowId ?? result.workflowId, @@ -66,13 +82,14 @@ export const GET = defineV2JsonRoute({ durationMs: row.durationMs, cost: row.costTotal != null ? { total: Number(row.costTotal) } : null, })) - const sort = cursorSortKey('startedAt', result.order) - const nextCursor = result.nextCursor - ? encodeSortedCursor(sort, [ - result.nextCursor.startedAt.toISOString(), - result.nextCursor.rowId, - ]) - : null + const nextCursor = writeSortedCursor( + result.nextCursor + ? [result.nextCursor.startedAt.toISOString(), result.nextCursor.rowId] + : null, + 'startedAt', + result.order, + runCursorFilters(params.id, query) + ) return { data, nextCursor } }, }) diff --git a/apps/sim/app/api/v2/workflows/[id]/versions/route.test.ts b/apps/sim/app/api/v2/workflows/[id]/versions/route.test.ts index f039ed8ba39..c6cc6e65974 100644 --- a/apps/sim/app/api/v2/workflows/[id]/versions/route.test.ts +++ b/apps/sim/app/api/v2/workflows/[id]/versions/route.test.ts @@ -12,6 +12,7 @@ import { } from '@sim/testing' import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { UNREADABLE_CURSOR_MESSAGE } from '@/lib/api/cursor-binding' const mocks = vi.hoisted(() => ({ listVersions: vi.fn(), @@ -103,6 +104,52 @@ describe('GET /api/v2/workflows/[id]/versions', () => { expect(response.status).toBe(400) expect(mocks.listVersions).not.toHaveBeenCalled() + /** + * The undecodable-token message, not the sort-mismatch one: this list + * declares no `sortBy`/`sortOrder`, so naming them would answer a 400 with + * advice that earns a second. + */ + expect((await response.json()).error.message).toBe(UNREADABLE_CURSOR_MESSAGE) + }) + + /** + * A cursor is caller-controlled bytes, so its decoded payload is validated + * like any request field. `version` is compared against an `integer` column, + * where an out-of-range value overflows the comparison and 500s instead of + * returning an empty page. + */ + it.each([ + ['out of the integer range', { version: 2147483648 }], + ['at zero', { version: 0 }], + ['non-numeric', { version: 'two' }], + ['carrying an unknown key', { version: 2, sort: 'name' }], + ['missing its key', {}], + ])('rejects a forged cursor %s', async (_case, payload) => { + const cursor = Buffer.from(JSON.stringify(payload)).toString('base64') + const response = await GET( + new NextRequest( + `http://localhost/api/v2/workflows/workflow-1/versions?cursor=${encodeURIComponent(cursor)}` + ), + context + ) + + expect(response.status).toBe(400) + expect(mocks.listVersions).not.toHaveBeenCalled() + }) + + it('resumes from a well-formed cursor', async () => { + const cursor = Buffer.from(JSON.stringify({ version: 5 })).toString('base64') + const response = await GET( + new NextRequest( + `http://localhost/api/v2/workflows/workflow-1/versions?cursor=${encodeURIComponent(cursor)}` + ), + context + ) + + expect(response.status).toBe(200) + expect(mocks.listVersions).toHaveBeenCalledWith( + expect.objectContaining({ input: expect.objectContaining({ afterVersion: 5 }) }) + ) }) it('rejects an unauthenticated request', async () => { diff --git a/apps/sim/app/api/v2/workflows/[id]/versions/route.ts b/apps/sim/app/api/v2/workflows/[id]/versions/route.ts index 1fbb169fe33..f0c8088af85 100644 --- a/apps/sim/app/api/v2/workflows/[id]/versions/route.ts +++ b/apps/sim/app/api/v2/workflows/[id]/versions/route.ts @@ -1,5 +1,9 @@ import type { V2WorkflowVersion } from '@/lib/api/contracts/v2/workflows' -import { v2ListWorkflowVersionsContract } from '@/lib/api/contracts/v2/workflows' +import { + v2ListWorkflowVersionsContract, + v2WorkflowVersionCursorSchema, +} from '@/lib/api/contracts/v2/workflows' +import { UNREADABLE_CURSOR_MESSAGE } from '@/lib/api/cursor-binding' import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' import { OrchestrationError } from '@/lib/core/orchestration/types' import { v2WorkflowErrorPolicies } from '@/lib/workflows/api' @@ -10,10 +14,6 @@ import { decodeCursor, encodeCursor } from '@/app/api/v2/lib/response' export const dynamic = 'force-dynamic' export const revalidate = 0 -interface WorkflowVersionCursor { - version: number -} - export const GET = defineV2JsonRoute({ contract: v2ListWorkflowVersionsContract, auth: v2ApiKeyAuth, @@ -21,14 +21,16 @@ export const GET = defineV2JsonRoute({ rateLimit: v2RateLimits.publicApi, errorPolicy: v2WorkflowErrorPolicies.concealWorkflowAuthorization, mapInput: ({ params, query }) => { - const after = query.cursor ? decodeCursor(query.cursor) : null - if (query.cursor && (!after || !Number.isInteger(after.version) || after.version < 1)) { - throw new OrchestrationError('validation', 'Invalid cursor') + const decoded = query.cursor + ? v2WorkflowVersionCursorSchema.safeParse(decodeCursor(query.cursor)) + : undefined + if (decoded && !decoded.success) { + throw new OrchestrationError('validation', UNREADABLE_CURSOR_MESSAGE) } return { workflowId: params.id, limit: query.limit, - afterVersion: after?.version, + afterVersion: decoded?.data.version, } }, useCase: listWorkflowVersions, diff --git a/apps/sim/app/api/v2/workflows/route.test.ts b/apps/sim/app/api/v2/workflows/route.test.ts index c93ae286fa8..0eea5e18ef4 100644 --- a/apps/sim/app/api/v2/workflows/route.test.ts +++ b/apps/sim/app/api/v2/workflows/route.test.ts @@ -98,6 +98,71 @@ describe('/api/v2/workflows', () => { expect(mocks.listWorkflows).not.toHaveBeenCalled() }) + /** + * The reported defect: a cursor from an unfiltered page was accepted under + * `deployedOnly=true` or a changed `search`, and answered with whatever + * matched the new filter *after* the old position — every earlier match + * silently missing behind an opaque token. + */ + it.each([ + ['deployedOnly', 'deployedOnly=true'], + ['search', 'search=billing'], + ['folderPath', 'folderPath=/Ops'], + ])('refuses a cursor replayed under a different %s', async (_filter, param) => { + mocks.listWorkflows.mockResolvedValueOnce({ + workflows: [WORKFLOW], + nextCursorKeys: [1, WORKFLOW.id], + sortBy: 'position', + sortOrder: 'asc', + }) + const firstPage = await ( + await GET(new NextRequest(`http://localhost/api/v2/workflows?workspaceId=${WORKSPACE_ID}`)) + ).json() + expect(firstPage.nextCursor).toEqual(expect.any(String)) + mocks.listWorkflows.mockClear() + + const response = await GET( + new NextRequest( + `http://localhost/api/v2/workflows?workspaceId=${WORKSPACE_ID}&${param}&cursor=${encodeURIComponent(firstPage.nextCursor)}` + ) + ) + + expect(response.status).toBe(400) + expect(await response.json()).toMatchObject({ + error: { code: 'BAD_REQUEST', message: expect.stringContaining('requested filters') }, + }) + expect(mocks.listWorkflows).not.toHaveBeenCalled() + }) + + it('resumes a cursor whose filters are unchanged', async () => { + mocks.listWorkflows.mockResolvedValueOnce({ + workflows: [WORKFLOW], + nextCursorKeys: [1, WORKFLOW.id], + sortBy: 'position', + sortOrder: 'asc', + }) + const firstPage = await ( + await GET( + new NextRequest( + `http://localhost/api/v2/workflows?workspaceId=${WORKSPACE_ID}&deployedOnly=true` + ) + ) + ).json() + + const response = await GET( + new NextRequest( + `http://localhost/api/v2/workflows?workspaceId=${WORKSPACE_ID}&deployedOnly=true&cursor=${encodeURIComponent(firstPage.nextCursor)}` + ) + ) + + expect(response.status).toBe(200) + expect(mocks.listWorkflows).toHaveBeenLastCalledWith( + expect.objectContaining({ + input: expect.objectContaining({ cursorKeys: [1, WORKFLOW.id] }), + }) + ) + }) + it('lists through the workspace principal and preserves rate headers', async () => { const request = new NextRequest( `http://localhost/api/v2/workflows?workspaceId=${WORKSPACE_ID}`, @@ -173,4 +238,58 @@ describe('/api/v2/workflows', () => { expect(response.status).toBe(401) expect((await response.json()).error.code).toBe('UNAUTHORIZED') }) + + /** + * A `U+0000` in caller text is a driver-level throw on the way to a `text` + * column, and an unclassified throw is a `500 INTERNAL_ERROR`. The read case + * needed no write at all — a search term was enough — so it is asserted here + * against the real route, not only against the parser. + */ + describe('NUL bytes in caller text', () => { + const NUL = '\u0000' + + it('rejects a NUL search term with the v2 validation envelope, not a 500', async () => { + const response = await GET( + new NextRequest( + `http://localhost/api/v2/workflows?workspaceId=${WORKSPACE_ID}&search=${encodeURIComponent(`a${NUL}b`)}`, + { headers: { 'x-api-key': 'secret' } } + ) + ) + + expect(response.status).toBe(400) + expect((await response.json()).error.code).toBe('BAD_REQUEST') + expect(mocks.listWorkflows).not.toHaveBeenCalled() + }) + + it('rejects a NUL workflow name before the create use case runs', async () => { + const response = await POST( + new NextRequest('http://localhost/api/v2/workflows', { + method: 'POST', + headers: { 'content-type': 'application/json', 'x-api-key': 'secret' }, + body: JSON.stringify({ name: `a${NUL}b`, workspaceId: WORKSPACE_ID }), + }) + ) + + expect(response.status).toBe(400) + expect((await response.json()).error.code).toBe('BAD_REQUEST') + expect(mocks.createWorkflow).not.toHaveBeenCalled() + }) + + it('rejects a NUL description on the same body', async () => { + const response = await POST( + new NextRequest('http://localhost/api/v2/workflows', { + method: 'POST', + headers: { 'content-type': 'application/json', 'x-api-key': 'secret' }, + body: JSON.stringify({ + name: 'Daily digest', + description: `notes${NUL}`, + workspaceId: WORKSPACE_ID, + }), + }) + ) + + expect(response.status).toBe(400) + expect(mocks.createWorkflow).not.toHaveBeenCalled() + }) + }) }) diff --git a/apps/sim/app/api/v2/workflows/route.ts b/apps/sim/app/api/v2/workflows/route.ts index 8fd65da66ab..c8095d27ce2 100644 --- a/apps/sim/app/api/v2/workflows/route.ts +++ b/apps/sim/app/api/v2/workflows/route.ts @@ -1,5 +1,6 @@ import type { V2WorkflowListItem } from '@/lib/api/contracts/v2/workflows' import { v2CreateWorkflowContract, v2ListWorkflowsContract } from '@/lib/api/contracts/v2/workflows' +import { cursorScopeKey } from '@/lib/api/cursor-binding' import { defineV2JsonRoute, v2ApiKeyAuth, @@ -9,11 +10,26 @@ import { import { createWorkflow } from '@/lib/workflows/application/create-workflow' import { listWorkflows } from '@/lib/workflows/application/list-workflows' import { workflowOperations } from '@/lib/workflows/application/operations' -import { cursorSortKey, encodeSortedCursor, readSortedCursor } from '@/app/api/v2/lib/response' +import { readSortedCursor, writeSortedCursor } from '@/app/api/v2/lib/response' export const dynamic = 'force-dynamic' export const revalidate = 0 +/** Every param that changes which workflows, in which order, this list returns. */ +function workflowCursorFilters(query: { + workspaceId: string + folderPath?: string + deployedOnly: boolean + search?: string +}) { + return cursorScopeKey({ + workspaceId: query.workspaceId, + folderPath: query.folderPath, + deployedOnly: query.deployedOnly, + search: query.search, + }) +} + export const GET = defineV2JsonRoute({ contract: v2ListWorkflowsContract, auth: v2ApiKeyAuth, @@ -27,11 +43,16 @@ export const GET = defineV2JsonRoute({ search: query.search, sortBy: query.sortBy, sortOrder: query.sortOrder, - cursorKeys: readSortedCursor(query.cursor, query.sortBy, query.sortOrder), + cursorKeys: readSortedCursor( + query.cursor, + query.sortBy, + query.sortOrder, + workflowCursorFilters(query) + ), limit: query.limit, }), useCase: listWorkflows, - present: ({ workflows, nextCursorKeys, sortBy, sortOrder }) => ({ + present: ({ workflows, nextCursorKeys }, { query }) => ({ data: workflows.map( (workflow): V2WorkflowListItem => ({ id: workflow.id, @@ -47,9 +68,12 @@ export const GET = defineV2JsonRoute({ updatedAt: workflow.updatedAt.toISOString(), }) ), - nextCursor: nextCursorKeys - ? encodeSortedCursor(cursorSortKey(sortBy, sortOrder), nextCursorKeys) - : null, + nextCursor: writeSortedCursor( + nextCursorKeys, + query.sortBy, + query.sortOrder, + workflowCursorFilters(query) + ), }), }) diff --git a/apps/sim/app/api/v2/workspaces/[workspaceId]/members/route.ts b/apps/sim/app/api/v2/workspaces/[workspaceId]/members/route.ts index b07898ffdb1..332a08c6456 100644 --- a/apps/sim/app/api/v2/workspaces/[workspaceId]/members/route.ts +++ b/apps/sim/app/api/v2/workspaces/[workspaceId]/members/route.ts @@ -2,6 +2,7 @@ import { v2ListWorkspaceMembersContract, v2WorkspaceMemberCursorSchema, } from '@/lib/api/contracts/v2/workspaces' +import { UNREADABLE_CURSOR_MESSAGE } from '@/lib/api/cursor-binding' import { defineV2JsonRoute, v2ApiKeyAuth, @@ -25,7 +26,7 @@ export const GET = defineV2JsonRoute({ ? v2WorkspaceMemberCursorSchema.safeParse(decodeCursor(query.cursor)) : undefined if (decoded && !decoded.success) { - throw new OrchestrationError('validation', 'Invalid cursor') + throw new OrchestrationError('validation', UNREADABLE_CURSOR_MESSAGE) } return { workspaceId: params.workspaceId, diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/mermaid-diagram.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/mermaid-diagram.tsx index 2085b046840..1eb77d5bafe 100644 Binary files a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/mermaid-diagram.tsx and b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/mermaid-diagram.tsx differ diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/utils.test.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/utils.test.ts index 5945be6c388..146fb11cc23 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/utils.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/utils.test.ts @@ -2,6 +2,7 @@ * @vitest-environment node */ import { describe, expect, it } from 'vitest' +import { columnTypeOf } from '@/lib/table/column-types' import { cleanCellValue, dateValueToLocalParts, @@ -157,6 +158,32 @@ describe('cleanCellValue', () => { expect(cleanCellValue('Bug, Bug', column)).toEqual(['opt_a']) expect(cleanCellValue('Nope', column)).toEqual([]) }) + + /** + * The grid writes through a first-party route, which runs the `null` policy — + * a member the paste names that resolves to no option is dropped, and the ones + * that do resolve are kept. Erasing the cell instead would lose two live + * options over one deleted one. The registry pairing is asserted rather than + * described so a helper that stops consulting `salvage` fails here. + */ + it('keeps the members of a partial multiselect paste that still resolve', () => { + const column = { + name: 'tags', + type: 'select', + multiple: true, + options: [ + { id: 'opt_a', name: 'Bug' }, + { id: 'opt_b', name: 'Docs' }, + ], + } as const + + expect(columnTypeOf(column).coerce('Bug, Nope', column)).toEqual({ ok: false }) + expect(columnTypeOf(column).salvage?.('Bug, Nope', column)).toEqual({ + ok: true, + value: ['opt_a'], + }) + expect(cleanCellValue('Bug, Nope', column)).toEqual(['opt_a']) + }) }) describe('formatValueForInput', () => { diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/utils.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/utils.ts index d4c3fc5b3ce..69f7722d11d 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/utils.ts +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/utils.ts @@ -19,8 +19,16 @@ export function generateColumnName(columns: ReadonlyArray<{ name: string }>): st } /** - * Coerce a raw input value to the appropriate type for a column. - * Throws on invalid JSON. + * Coerce a value a person typed or pasted into a cell to that column's type. + * Throws on invalid JSON, and answers `null` for everything else the column + * type can read nothing from. + * + * The result is what the server would store for the same value, which is the + * point: the optimistic cache and the row that comes back agree. The grid + * writes through a first-party route, which runs the `null` policy — so a + * refused value falls back to `ColumnTypeDefinition.salvage` here exactly as it + * does there, and a multiselect paste naming one live option and one deleted + * one keeps the live one instead of erasing the cell. */ export function cleanCellValue( value: unknown, @@ -46,8 +54,11 @@ export function cleanCellValue( // Everything else runs the SAME coercion the server will run, so the // optimistic cache holds exactly the value that gets persisted. - const coerced = columnTypeOf(column).coerce(value as JsonValue, column) - return coerced.ok ? coerced.value : null + const columnType = columnTypeOf(column) + const coerced = columnType.coerce(value as JsonValue, column) + if (coerced.ok) return coerced.value + const salvaged = columnType.salvage?.(value as JsonValue, column) + return salvaged?.ok ? salvaged.value : null } /** diff --git a/apps/sim/hooks/queries/mcp.ts b/apps/sim/hooks/queries/mcp.ts index f0be5614ba7..ac3ab3e12ae 100644 --- a/apps/sim/hooks/queries/mcp.ts +++ b/apps/sim/hooks/queries/mcp.ts @@ -306,12 +306,21 @@ export function useCreateMcpServer() { return { ...safeServerData, id: serverId, - connectionStatus: authType === 'oauth' ? ('disconnected' as const) : ('connected' as const), + /** Mirrors what registration writes: no connection has been verified yet. */ + connectionStatus: 'disconnected' as const, serverId, updated: wasUpdated, authType, } }, + /** + * Both caches are dropped, so neither waits out its stale time — but the + * refetched row still reads `disconnected`, because the discovery that + * moves it runs on the tools query this same invalidation kicks off, after + * the list has already come back. The status catches up on the next list + * refetch; the tools do not wait for it, since + * {@link isServerEligibleForDiscovery} gates only OAuth rows on `connected`. + */ onSettled: (_data, _error, variables) => { queryClient.invalidateQueries({ queryKey: mcpKeys.serversList(variables.workspaceId) }) queryClient.invalidateQueries({ diff --git a/apps/sim/lib/api/contracts/deployments.test.ts b/apps/sim/lib/api/contracts/deployments.test.ts index 9384fd65172..13bc65f2d20 100644 --- a/apps/sim/lib/api/contracts/deployments.test.ts +++ b/apps/sim/lib/api/contracts/deployments.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from 'vitest' -import { deploymentVersionOrActiveParamsSchema } from '@/lib/api/contracts/deployments' +import { + DEPLOYMENT_VERSION_MAX, + deploymentVersionOrActiveParamsSchema, + deploymentVersionParamsSchema, +} from '@/lib/api/contracts/deployments' describe('deployment version route params', () => { it('coerces numeric path params from the server boundary', () => { @@ -19,4 +23,21 @@ describe('deployment version route params', () => { deploymentVersionOrActiveParamsSchema.safeParse({ id: 'workflow-1', version }).success ).toBe(false) }) + + /** + * `workflow_deployment_version.version` is a Postgres `integer`. A larger + * value has no row to miss — it overflows the comparison, which surfaces as + * an unclassifiable 500 on a request the caller could have been told was bad. + */ + it.each([deploymentVersionParamsSchema, deploymentVersionOrActiveParamsSchema])( + 'bounds the path version to the integer column range', + (schema) => { + expect( + schema.safeParse({ id: 'workflow-1', version: String(DEPLOYMENT_VERSION_MAX) }).success + ).toBe(true) + expect( + schema.safeParse({ id: 'workflow-1', version: String(DEPLOYMENT_VERSION_MAX + 1) }).success + ).toBe(false) + } + ) }) diff --git a/apps/sim/lib/api/contracts/deployments.ts b/apps/sim/lib/api/contracts/deployments.ts index f3e165dbd1f..343af5e82a1 100644 --- a/apps/sim/lib/api/contracts/deployments.ts +++ b/apps/sim/lib/api/contracts/deployments.ts @@ -21,14 +21,42 @@ export const deployedWorkflowStateSchema = z additionalProperties: true, }) +/** + * Upper bound of `workflow_deployment_version.version`, whose column is a + * Postgres `integer`. A larger value has no row to address and overflows the + * comparison instead of missing, so every schema carrying a deployment version + * — path param, request body, or cursor payload — must be bounded by this. + */ +export const DEPLOYMENT_VERSION_MAX = 2147483647 + +/** A deployment version number, bounded to the range its column can hold. */ +export const deploymentVersionNumberSchema = z + .number() + .int('version must be an integer') + .min(1, 'version must be a positive integer') + .max(DEPLOYMENT_VERSION_MAX, 'version is out of range') + +/** + * {@link deploymentVersionNumberSchema} for a path segment, which arrives as a + * string. Spelled out rather than piped through the body schema because a + * `ZodPipe` publishes none of its constraints to the generated OpenAPI document, + * which would leave the documented parameter unbounded even though the runtime + * check holds. + */ +const deploymentVersionPathSchema = z.coerce + .number() + .int() + .positive() + .max(DEPLOYMENT_VERSION_MAX, 'version is out of range') + export const deploymentVersionParamsSchema = z.object({ id: z.string().min(1, 'Invalid workflow ID'), - version: z.coerce.number().int().positive(), + version: deploymentVersionPathSchema, }) export const deploymentVersionOrActiveParamsSchema = z.object({ id: z.string().min(1, 'Invalid workflow ID'), - version: z.union([z.coerce.number().int().positive(), z.literal('active')]), + version: z.union([deploymentVersionPathSchema, z.literal('active')]), }) export const deploymentVersionRouteParamsSchema = z.object({ diff --git a/apps/sim/lib/api/contracts/primitives.ts b/apps/sim/lib/api/contracts/primitives.ts index c05dc4679fa..39b7d6492a8 100644 --- a/apps/sim/lib/api/contracts/primitives.ts +++ b/apps/sim/lib/api/contracts/primitives.ts @@ -234,6 +234,22 @@ export const organizationIdSchema = requiredFieldSchema('Organization ID is requ /** Non-empty `workflowId` field with a stable, human-readable message. */ export const workflowIdSchema = requiredFieldSchema('Workflow ID is required') +/** + * A workflow run identifier, shared by the run resources, the caller-supplied + * `X-Run-Id` claim, and the log resources keyed on the same value. One + * identifier gets one schema: the log surfaces address the very rows the run + * surfaces mint, so a bound enforced on one and not the other decides nothing + * except which endpoint an oversized value reaches the database through. + */ +export const runIdSchema = z + .string() + .min(1, 'Invalid run ID') + .max(128, 'Run ID too long') + .regex( + /^[A-Za-z0-9._:-]+$/, + 'Run ID can only contain letters, numbers, dots, underscores, colons, and hyphens' + ) + /** * A `folder.id` value. Not `.uuid()`: the column is free-form `text` and the * legacy `workflow_folder` rows migrated onto it keep their original id shape. diff --git a/apps/sim/lib/api/contracts/tables-predicate.test.ts b/apps/sim/lib/api/contracts/tables-predicate.test.ts index b5daf33c638..fb273a38a9d 100644 --- a/apps/sim/lib/api/contracts/tables-predicate.test.ts +++ b/apps/sim/lib/api/contracts/tables-predicate.test.ts @@ -6,6 +6,7 @@ * (unknown field, json-op) runs server-side in `validate.ts`. */ import { describe, expect, it } from 'vitest' +import { z } from 'zod' import { deleteTableRowsBodySchema, predicateInputSchema, @@ -15,8 +16,13 @@ import { tableViewConfigSchema, updateRowsByFilterBodySchema, } from '@/lib/api/contracts/tables' +import { FILTER_OPS } from '@/lib/table/constants' +import { MAX_PREDICATE_GROUP_SIZE } from '@/lib/table/query-builder/predicate' import { validatePredicate } from '@/lib/table/query-builder/validate' +/** Loose view of the generated JSON Schema, which is untyped by construction. */ +type JsonSchemaNode = Record & Record + describe('rowQueryBodySchema', () => { it('accepts a root condition and normalizes it to the canonical all group', () => { const parsed = rowQueryBodySchema.parse({ @@ -291,3 +297,59 @@ function rowQueryStringSchemaProbe(input: Record) { if (!result.success) throw new Error(JSON.stringify(result.error.issues[0])) return result.data } + +/** + * The predicate is a `pipe` over `z.unknown()`, so `z.toJSONSchema` documents + * it from an input side that carries no shape: the leaf keys `field`, `op`, and + * `value` were named nowhere in the published contract and were discoverable + * only by reading an example. The shape is now supplied through `.meta()`, + * which means it is hand-written beside a runtime schema that can move without + * it. These assertions are the join. + */ +describe('the published predicate schema', () => { + const published = z.toJSONSchema(predicateSchema, { io: 'input', unrepresentable: 'any' }) + const leaf = (published.oneOf as JsonSchemaNode[])[0].properties.all.items.anyOf[1] + + it('names the leaf keys the server actually requires', () => { + expect(Object.keys(leaf.properties)).toEqual(['field', 'op', 'value']) + expect(leaf.required).toEqual(['field', 'op']) + expect(leaf.additionalProperties).toBe(false) + }) + + it('publishes exactly the operators the server accepts', () => { + expect(leaf.properties.op.enum).toEqual([...FILTER_OPS]) + }) + + it('publishes the group keys and their size bound', () => { + const [all, any] = published.oneOf as JsonSchemaNode[] + expect(Object.keys(all.properties)).toEqual(['all']) + expect(Object.keys(any.properties)).toEqual(['any']) + expect(all.properties.all.minItems).toBe(1) + expect(all.properties.all.maxItems).toBe(MAX_PREDICATE_GROUP_SIZE) + }) + + it('rejects the v1-shaped leaf a caller would guess without the schema', () => { + expect( + predicateSchema.safeParse({ all: [{ column: 'a', operator: 'eq', value: 1 }] }).success + ).toBe(false) + expect(predicateSchema.safeParse({ all: [{ field: 'a', op: 'eq', value: 1 }] }).success).toBe( + true + ) + }) + + /** + * The description published a null rule the compiler never implemented: + * multi-select `ncontains` was called "the exception" that excludes nulls, + * while `sql.ts` emits a bare `NOT (data @> …)` — TRUE for an absent key, + * exactly like every other negation. A wrong null rule is worse than none: + * it reads as deliberate, so a caller writes a predicate that silently + * returns rows it was told were excluded. + */ + it('does not claim a multi-select null exception the compiler never had', () => { + const description = String(published.description) + + expect(description).toContain('The negating operators include nulls') + expect(description).not.toMatch(/exception/i) + expect(description).toMatch(/multi-select included/i) + }) +}) diff --git a/apps/sim/lib/api/contracts/tables.ts b/apps/sim/lib/api/contracts/tables.ts index 2c5ed7ad829..0186c186e69 100644 --- a/apps/sim/lib/api/contracts/tables.ts +++ b/apps/sim/lib/api/contracts/tables.ts @@ -29,6 +29,7 @@ import type { import { COLUMN_TYPES, FILTER_OPS, + MAX_RUN_TARGET_ROW_IDS, MAX_SELECT_OPTIONS, NAME_PATTERN, SORT_DIRECTIONS, @@ -37,7 +38,9 @@ import { import { CSV_SYNC_MAX_FILE_SIZE_BYTES, CSV_SYNC_MAX_FILE_SIZE_MESSAGE } from '@/lib/table/import' import { getTablePredicateTreeSizeError, + MAX_PREDICATE_DEPTH, MAX_PREDICATE_GROUP_SIZE, + MAX_PREDICATE_NODES, normalizeTablePredicate, } from '@/lib/table/query-builder/predicate' @@ -468,6 +471,23 @@ export const TABLE_QUERY_MAX_BODY_BYTES = 1024 * 1024 /** Max sort keys — more than a few is already a smell. */ const MAX_SORT_KEYS = 16 +/** + * The published predicate grammar. + * + * Without it a caller reads an untyped operand and an operator enum with no + * semantics, and the natural guess — SQL's own `%` wildcard — matches zero rows + * under a 200 with nothing saying why. Stated on the operator and on the tree so + * it reaches the OpenAPI description of every endpoint taking a predicate. + */ +const PREDICATE_OPERATOR_GRAMMAR = [ + 'Comparison: `eq`, `ne`, `gt`, `gte`, `lt`, `lte`.', + 'Membership: `in`, `nin` (array operand).', + 'Emptiness: `isEmpty`, `isNotEmpty`, `isNull`, `isNotNull` (no operand).', + 'Substring, always case-insensitive, operand matched literally: `contains`, `ncontains`, `startsWith`, `endsWith`.', + 'Pattern: `like`/`nlike` (case-sensitive), `ilike`/`nilike` (case-insensitive). **`*` is the only wildcard** and stands for any run of characters; `%`, `_`, and backslash match themselves. Use `like: "Hi*"`, not `like: "Hi%"`.', + 'A `select` column compares by option id and restricts its operators: single-select accepts `eq`, `ne`, `in`, `nin`; multi-select accepts `contains`, `ncontains`. Option names are accepted as operands and resolved to ids.', +].join(' ') + /** * v2 filter wire format: the typed `{ all | any: [...] }` predicate tree (same * shape the engine consumes). Structure is validated here; schema-awareness @@ -485,9 +505,20 @@ const MAX_SORT_KEYS = 16 * would just fall through to the leaf branch, which is the more dangerous reading. */ const predicateLeafObjectSchema = z.strictObject({ - field: z.string().min(1, 'field is required').max(128), - op: z.enum(FILTER_OPS), - value: z.unknown().optional(), + field: z + .string() + .min(1, 'field is required') + .max(128) + .describe( + 'Column name to compare, or one of the system fields `id`, `createdAt`, `updatedAt`.' + ), + op: z.enum(FILTER_OPS).describe(PREDICATE_OPERATOR_GRAMMAR), + value: z + .unknown() + .optional() + .describe( + 'Operand. A scalar for the comparison operators, an array for `in`/`nin`, a pattern for the matching operators, and omitted for `isEmpty`/`isNotEmpty`/`isNull`/`isNotNull`.' + ), }) // double-cast-allowed: `z.unknown()` keeps the runtime permissive (a leaf value @@ -524,16 +555,114 @@ const predicateBoundarySchema = z.unknown().superRefine((value, ctx) => { if (problem) ctx.addIssue({ code: 'custom', message: problem }) }) +/** + * The published JSON Schema for a predicate tree. + * + * `predicateSchema` is a `pipe` whose input side is `z.unknown()` — the size + * guard has to run before the recursive union so pathological input is a `400` + * rather than a stack overflow — and `z.toJSONSchema` documents a pipe from its + * input. That published the most consequential shape in the API as a bare + * description: the leaf keys `field`/`op`/`value` were named nowhere in the + * contract and were discoverable only by reading an example, so a caller + * guessing `{column, operator, value}` got a `400` with nothing to correct + * against. This object is merged in through `.meta()` so the shape is published + * without moving the guard. + * + * Every bound below is read from the runtime constant that enforces it, and + * `tables-predicate.test.ts` pins the published operator set against + * `FILTER_OPS`, so the two cannot drift. + */ +const PREDICATE_LEAF_JSON_SCHEMA = { + type: 'object', + title: 'Predicate condition', + description: 'One column comparison.', + properties: { + field: { + type: 'string', + minLength: 1, + maxLength: 128, + description: + 'Column name to compare, or one of the system fields `id`, `createdAt`, `updatedAt`.', + }, + op: { + type: 'string', + enum: [...FILTER_OPS], + description: + 'Comparison operator. The `TablePredicate` schema description carries the grammar for all of them.', + }, + value: { + description: + 'Operand. A scalar for the comparison operators, an array of at most 1000 entries for `in`/`nin`, a pattern for the matching operators, and omitted for `isEmpty`/`isNotEmpty`/`isNull`/`isNotNull`.', + }, + }, + required: ['field', 'op'], + additionalProperties: false, +} as const + +/** + * A group's members are the schema itself, so each component carries a `$ref` + * to its own id. The self-reference is what makes the recursion resolvable from + * inside a single `$defs` entry — a reference to a sibling component would be + * dangling wherever only one of the two is reachable, which is exactly the + * shape the table-view body has. + */ +const predicateGroupJsonSchema = (key: 'all' | 'any', conjunction: string, selfRef: string) => + ({ + type: 'object', + description: `Matches a row when ${conjunction} member matches.`, + properties: { + [key]: { + type: 'array', + minItems: 1, + maxItems: MAX_PREDICATE_GROUP_SIZE, + description: `Members combined with ${key === 'all' ? 'AND' : 'OR'}. An empty group is rejected, because it would compile to no filter at all.`, + items: { + description: 'A nested group, or a single condition.', + anyOf: [{ $ref: selfRef }, PREDICATE_LEAF_JSON_SCHEMA], + }, + }, + }, + required: [key], + additionalProperties: false, + }) as const + +const predicateGroupsJsonSchema = (selfRef: string) => + [ + predicateGroupJsonSchema('all', 'every', selfRef), + predicateGroupJsonSchema('any', 'at least one', selfRef), + ] as const + +/** + * Stated once here rather than on each operation that accepts a predicate. + * The NULL clause is the surprising half: `ncontains`, `nlike`, and `nilike` + * emit an explicit `IS NULL OR NOT …` arm, and `ne`/`nin` negate a JSONB + * containment test that is false for an absent key, so all of them return rows + * whose column is null. + * + * Multi-select is not an exception to that, though the published sentence used + * to claim it was: its `ncontains` is `NOT (data @> '{"tags":["opt"]}')`, and + * `data` is never NULL, so an absent or null cell makes the containment test + * false and the negation true — the same include-nulls behaviour as every other + * negation. Pinned by `__tests__/sql.test.ts`. + */ +const PREDICATE_TREE_DESCRIPTION = [ + `Recursive predicate tree. Each group node is exactly one non-empty \`all\` or \`any\` array whose members are further groups or \`{ field, op, value }\` conditions; the root must be a group, not a bare condition. At most ${MAX_PREDICATE_GROUP_SIZE} members per group, ${MAX_PREDICATE_DEPTH} levels of nesting, and ${MAX_PREDICATE_NODES} nodes in total.`, + 'The negating operators include nulls: `ne`, `nin`, `ncontains`, `nlike`, and `nilike` match rows whose column is null or absent, so "not X" is not the complement of "X" over a nullable column. That holds for every column type, multi-select included. To exclude nulls, `all`-combine the negation with `isNotEmpty` (multi-select) or `isNotNull`.', + PREDICATE_OPERATOR_GRAMMAR, +].join(' ') + /** * The canonical grouped predicate schema for dual-grammar boundaries. Keeping * its root group-only prevents a legacy filter with columns named `field`, * `op`, and `value` from being reinterpreted as a v2 predicate. */ -const documentedPredicateSchema = predicateBoundarySchema - .pipe(predicateTreeSchema) - .describe( - 'Recursive predicate tree with exactly one non-empty `all` or `any` group at each group node.' - ) +const documentedPredicateSchema = predicateBoundarySchema.pipe(predicateTreeSchema).meta({ + id: 'TablePredicate', + title: 'Table predicate', + description: PREDICATE_TREE_DESCRIPTION, + type: 'object', + oneOf: [...predicateGroupsJsonSchema('#/$defs/TablePredicate')], +}) // double-cast-allowed: the pipe's inferred input is `unknown`, and letting TS widen the recursive lazy union through it makes typecheck OOM export const predicateSchema = documentedPredicateSchema as unknown as z.ZodType @@ -547,9 +676,16 @@ export const predicateSchema = documentedPredicateSchema as unknown as z.ZodType export const predicateInputSchema = predicateBoundarySchema .pipe(predicateNodeSchema) .transform(normalizeTablePredicate) - .describe( - 'Recursive predicate condition or group, normalized to a grouped predicate after validation.' - ) as z.ZodType + .meta({ + id: 'TablePredicateInput', + title: 'Table predicate input', + description: + 'A single `{ field, op, value }` condition or a group, normalized to a grouped predicate after validation. Same grammar and limits as `TablePredicate`.', + oneOf: [ + ...predicateGroupsJsonSchema('#/$defs/TablePredicateInput'), + PREDICATE_LEAF_JSON_SCHEMA, + ], + }) as z.ZodType /** * v2 sort wire format: an ordered list of `{ field, direction }`. @@ -1737,7 +1873,12 @@ export const runColumnBodyBaseSchema = z.object({ .enum(['all', 'incomplete']) .default('all') .describe('Whether to run all or only incomplete cells.'), - rowIds: z.array(z.string().min(1)).min(1).optional().describe('Explicit row subset to run.'), + rowIds: z + .array(z.string().min(1)) + .min(1) + .max(MAX_RUN_TARGET_ROW_IDS, `Cannot target more than ${MAX_RUN_TARGET_ROW_IDS} rows`) + .optional() + .describe('Explicit row subset to run.'), /** "Select all under a filter" — run every row matching this filter instead of `rowIds`. The * dispatcher walks only matching rows (paginated), so no id list is materialized. */ filter: bulkFilterSchema.optional(), @@ -1873,11 +2014,33 @@ export const tableEventStreamContract = defineRouteContract({ /** * A saved view's stored shape: `TableMetadata`'s column layout plus the row - * predicate and sort. Every column reference is a stable column id, so a rename - * never invalidates a view. + * predicate and sort. + * + * Every column reference is STORED as a stable column id, so a rename never + * invalidates a view — but a write may reference a column either way, and + * `normalizeViewConfigForStorage` resolves a name to its id before the config is + * persisted. That is what lets the name-keyed v2 surface and the id-keyed + * first-party UI write the same blob. A read is presented in the reading + * surface's own vocabulary. */ export const tableViewConfigSchema = tableMetadataSchema .extend({ + columnWidths: z + .record(z.string(), z.number().positive()) + .optional() + .describe('Column widths keyed by column name or stable column identifier.'), + columnOrder: z + .array(z.string()) + .optional() + .describe('Columns in display order, by name or stable identifier.'), + pinnedColumns: z + .array(z.string()) + .optional() + .describe('Pinned columns, by name or stable identifier.'), + hiddenColumns: z + .array(z.string()) + .optional() + .describe('Hidden columns, by name or stable identifier.'), // The v2 predicate/sort grammar — same wire as the query routes, so a saved // view gets the same strictness and depth bounds as a live filter, and its // config can later feed the v2 surfaces without conversion. diff --git a/apps/sim/lib/api/contracts/types.ts b/apps/sim/lib/api/contracts/types.ts index 91d756f0907..713801f75f6 100644 --- a/apps/sim/lib/api/contracts/types.ts +++ b/apps/sim/lib/api/contracts/types.ts @@ -79,6 +79,23 @@ export type AnyApiRouteContract = ApiRouteContract< ApiSchema | undefined > +/** + * A `/api/v2/` contract must always declare `query`, because `parseRequest` + * validates the query slice only when one is present — an omitted `query` means + * "never look at the query string", not "this endpoint takes no query params", + * and `?bogus=1` then answers 200 for a request the server did not honour. An + * endpoint that genuinely takes none says so with `query: noInputSchema` + * (`z.object({}).strict()`) from `./primitives`. + * + * That rule is enforced by the `query-declaration` sweep under + * `contracts/v2/__tests__`, not by this signature. Making `query` conditionally + * required on a `/api/v2/` path needs the parameter type to become an + * intersection, and the intersection collapses inference of the sibling + * generics: `TParams`, `TBody`, and `THeaders` start resolving to `undefined`, + * which breaks the OpenAPI documents that read them back off the contract. The + * sweep is also the broader guarantee — it walks every contract in the tree, + * including ones a caller never passes through this function directly. + */ export function defineRouteContract< TParams extends ApiSchema | undefined = undefined, TQuery extends ApiSchema | undefined = undefined, diff --git a/apps/sim/lib/api/contracts/upload-sessions.ts b/apps/sim/lib/api/contracts/upload-sessions.ts index 2f4fdbbe1ac..a60749ac704 100644 --- a/apps/sim/lib/api/contracts/upload-sessions.ts +++ b/apps/sim/lib/api/contracts/upload-sessions.ts @@ -1,5 +1,10 @@ import { z } from 'zod' -import { folderIdSchema, workflowIdSchema, workspaceIdSchema } from '@/lib/api/contracts/primitives' +import { + folderIdSchema, + noInputSchema, + workflowIdSchema, + workspaceIdSchema, +} from '@/lib/api/contracts/primitives' import { defineRouteContract } from '@/lib/api/contracts/types' import { v2FileSchema } from '@/lib/api/contracts/v2/files' import { v2DataResponse } from '@/lib/api/contracts/v2/shared' @@ -198,9 +203,11 @@ export const localUploadPartParamsSchema = z.object({ partNumber: z.coerce.number().int().min(1), }) -export const localUploadPartQuerySchema = z.object({ - token: z.string().min(1, 'token is required'), -}) +export const localUploadPartQuerySchema = z + .object({ + token: z.string().min(1, 'token is required'), + }) + .strict() export const localUploadPartContract = defineRouteContract({ method: 'PUT', @@ -213,6 +220,7 @@ export const localUploadPartContract = defineRouteContract({ export const localPutUploadContract = defineRouteContract({ method: 'PUT', path: '/api/v2/uploads/[uploadId]', + query: noInputSchema, params: internalFileUploadParamsSchema, headers: v2UploadTokenHeadersSchema, response: { mode: 'empty', status: 204 }, diff --git a/apps/sim/lib/api/contracts/v1/workflows.ts b/apps/sim/lib/api/contracts/v1/workflows.ts index 8f15312ffaf..e055ddd9df4 100644 --- a/apps/sim/lib/api/contracts/v1/workflows.ts +++ b/apps/sim/lib/api/contracts/v1/workflows.ts @@ -3,6 +3,7 @@ import { activeDeploymentSummarySchema, deploymentOperationSummarySchema, deploymentVersionMetadataFieldsSchema, + deploymentVersionNumberSchema, } from '@/lib/api/contracts/deployments' import { booleanQueryFlagSchema, workspaceIdSchema } from '@/lib/api/contracts/primitives' import { defineRouteContract } from '@/lib/api/contracts/types' @@ -71,13 +72,6 @@ export const v1DeployWorkflowBodySchema = z.object({ export type V1DeployWorkflowBody = z.input -/** Bounded to the Postgres `integer` range of `workflow_deployment_version.version`. */ -const deploymentVersionNumberSchema = z - .number() - .int('version must be an integer') - .min(1, 'version must be a positive integer') - .max(2147483647, 'version is out of range') - /** * Optional rollback target accepted by the v1 rollback endpoint. When * `version` is omitted the route rolls back to the deployment version that diff --git a/apps/sim/lib/api/contracts/v2/__tests__/contract-sweep.ts b/apps/sim/lib/api/contracts/v2/__tests__/contract-sweep.ts new file mode 100644 index 00000000000..4d6c695e410 --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/__tests__/contract-sweep.ts @@ -0,0 +1,84 @@ +import { readdirSync } from 'node:fs' +import path from 'node:path' +import type { z } from 'zod' + +/** + * Shared enumeration of every route contract the v2 surface publishes. + * + * The sweeps that assert a cross-cutting v2 promise all need the same thing + * first: every contract, found by walking the tree rather than by a hand-kept + * list. A hand-kept list is what let the original fractional-`limit` defect + * survive on the one endpoint nobody remembered to add, and the same reasoning + * applies to anything else asserted "for every v2 contract". + * + * Contracts are keyed by `METHOD /path`, so a contract re-exported from a barrel + * is counted once. + */ + +const CONTRACTS_DIR = path.resolve(import.meta.dirname, '..', '..') + +export interface SweptContract { + method: string + path: string + params?: z.ZodType + query?: z.ZodType + body?: z.ZodType + headers?: z.ZodType + response?: { mode: string; schema?: z.ZodType } +} + +export interface SweptContractEntry { + /** `METHOD /path`, the identity a route is documented under. */ + key: string + /** The exported binding name, so a failure names the symbol to edit. */ + name: string + contract: SweptContract +} + +function isContract(value: unknown): value is SweptContract { + return ( + !!value && + typeof value === 'object' && + typeof (value as SweptContract).method === 'string' && + typeof (value as SweptContract).path === 'string' && + typeof (value as SweptContract).response === 'object' + ) +} + +/** Every non-test `.ts` file under `lib/api/contracts`, deterministically ordered. */ +export function listContractFiles(dir: string = CONTRACTS_DIR): string[] { + const files: string[] = [] + for (const entry of readdirSync(dir, { withFileTypes: true }).sort((a, b) => + a.name.localeCompare(b.name) + )) { + const full = path.join(dir, entry.name) + if (entry.isDirectory()) { + if (entry.name === '__tests__') continue + files.push(...listContractFiles(full)) + } else if (entry.name.endsWith('.ts') && !entry.name.endsWith('.test.ts')) { + files.push(full) + } + } + return files +} + +/** + * Every contract whose path is under `/api/v2/`, first occurrence per key. + * + * Costs a few hundred dynamic imports, so callers memoize it for the file rather + * than repeating it per test. + */ +export async function sweepV2Contracts(): Promise { + const found = new Map() + for (const file of listContractFiles()) { + const mod = (await import(file)) as Record + for (const [name, value] of Object.entries(mod)) { + if (!isContract(value)) continue + if (!value.path.startsWith('/api/v2/')) continue + const key = `${value.method.toUpperCase()} ${value.path}` + if (found.has(key)) continue + found.set(key, { key, name, contract: value }) + } + } + return [...found.values()].sort((a, b) => a.key.localeCompare(b.key)) +} diff --git a/apps/sim/lib/api/contracts/v2/__tests__/cross-cutting.test.ts b/apps/sim/lib/api/contracts/v2/__tests__/cross-cutting.test.ts index 56be75963fc..c80d5e66491 100644 --- a/apps/sim/lib/api/contracts/v2/__tests__/cross-cutting.test.ts +++ b/apps/sim/lib/api/contracts/v2/__tests__/cross-cutting.test.ts @@ -3,8 +3,12 @@ */ import { describe, expect, it } from 'vitest' import { sortSpecSchema, tableViewConfigSchema } from '@/lib/api/contracts/tables' +import { rejectsUnknownKeys } from '@/lib/api/contracts/v2/__tests__/schema-introspection' import { v2ListAuditLogsContract } from '@/lib/api/contracts/v2/audit-logs' +import { filesAuditOpenApiDocument } from '@/lib/api/contracts/v2/openapi/files-audit' +import { knowledgeOpenApiDocument } from '@/lib/api/contracts/v2/openapi/knowledge' import { ERROR_RESPONSES } from '@/lib/api/contracts/v2/openapi/shared' +import { v2ErrorResponseSchema } from '@/lib/api/contracts/v2/shared' import { v2CreateTableViewContract, v2QueryRowsBodySchema } from '@/lib/api/contracts/v2/tables' import { v2GetWorkflowRunContract } from '@/lib/api/contracts/v2/workflows' import { @@ -17,12 +21,12 @@ import { * therefore have nowhere else to be asserted. */ describe('v2 403 cause codes', () => { - it('publishes every code in the generated OpenAPI 403 description', () => { + it("publishes every code on the error envelope's details field", () => { + const details = v2ErrorResponseSchema.shape.error.shape.details + const published = details.description ?? '' for (const code of FORBIDDEN_DETAIL_CODES) { - expect(ERROR_RESPONSES.Forbidden.description).toContain(code) - expect(ERROR_RESPONSES.Forbidden.description).toContain( - FORBIDDEN_DETAIL_CODE_DESCRIPTIONS[code] - ) + expect(published).toContain(code) + expect(published).toContain(FORBIDDEN_DETAIL_CODE_DESCRIPTIONS[code]) } }) @@ -131,3 +135,95 @@ describe('tables nested strictness', () => { ).toBe(true) }) }) + +/** + * Every caller-authored knowledge and files/audit request slice must reject the + * keys it does not declare. + * + * These two families held the last non-strict *body* slices in v2: four single-field + * `{ workspaceId }` query objects reused across 15 operations, and the knowledge + * search body. Zod strips what it does not declare, so a caller that mis-spelt a + * parameter got a 200 for a request the server never honoured — and on + * `POST /knowledge/search` the stripped keys were the ones that decide how many + * search units the call is billed. The strictness was already there on + * `GET /knowledge/{id}/tags`, which is what made the divergence visible: + * `?foo=1` was a 400 on that one route and a 200 on its siblings. + * + * Only `query` and `body` are swept. `params` are produced by the router from + * the path pattern and `headers` are projected from the schema's own keys, so + * neither carries a key the caller chose and neither can strip one. + * + * The `query` half is now also covered surface-wide by the query-declaration + * sweep, which walks the contracts tree rather than these two OpenAPI documents. + * This one stays because it is the only sweep over `body`, and because it is + * scoped to the families whose strictness regressed. + */ +describe('knowledge and files request-slice strictness', () => { + const documents = [ + ['knowledge', knowledgeOpenApiDocument], + ['files & audit', filesAuditOpenApiDocument], + ] as const + + const slices = documents.flatMap(([family, document]) => + document.routes.flatMap((route) => + (['query', 'body'] as const) + .filter((slice) => route.contract[slice] !== undefined) + .map( + (slice) => + [ + `${family} ${route.operation.operationId} ${slice}`, + route.contract[slice] as unknown, + ] as const + ) + ) + ) + + /** + * A count, so a document that stopped listing its routes cannot make every + * assertion below pass vacuously. It rises when a route gains a slice: it went + * 45 → 63 when the knowledge and files endpoints that take no query params + * started saying so with `noInputSchema` instead of omitting `query`. + */ + it('sweeps every documented query and body slice', () => { + expect(slices.length).toBe(63) + }) + + it.each(slices)('%s rejects an undeclared key', (_name, schema) => { + expect(rejectsUnknownKeys(schema)).toBe(true) + }) +}) + +/** + * `GET /api/v2/audit-logs` was the only v2 query param declaring a workspace + * identifier as a bare string, so `?workspaceId=` parsed and reached + * `buildFilterConditions` as a real filter — an empty page rather than the 400 + * an empty required identifier gets on every other v2 read. + */ +describe('v2 audit-log filter bounds', () => { + const query = v2ListAuditLogsContract.query + + it('rejects an empty workspaceId instead of filtering on it', () => { + const parsed = query?.safeParse({ organizationId: 'org-1', workspaceId: '' }) + expect(parsed?.success).toBe(false) + }) + + it('still accepts an omitted workspaceId', () => { + expect(query?.safeParse({ organizationId: 'org-1' }).success).toBe(true) + }) + + it.each(['startDate', 'endDate'])( + '%s takes the shared UTC run-window form rather than a loose Date.parse', + (field) => { + const dateOnly = query?.safeParse({ organizationId: 'org-1', [field]: '2026-08-06' }) + const offsetBearing = query?.safeParse({ + organizationId: 'org-1', + [field]: '2026-08-06T00:00:00+02:00', + }) + const utc = query?.safeParse({ organizationId: 'org-1', [field]: '2026-08-06T00:00:00Z' }) + + expect(dateOnly?.success).toBe(false) + expect(offsetBearing?.success).toBe(false) + expect(utc?.success).toBe(true) + } + ) +}) diff --git a/apps/sim/lib/api/contracts/v2/__tests__/knowledge.test.ts b/apps/sim/lib/api/contracts/v2/__tests__/knowledge.test.ts index 23960193484..a66b13d857c 100644 --- a/apps/sim/lib/api/contracts/v2/__tests__/knowledge.test.ts +++ b/apps/sim/lib/api/contracts/v2/__tests__/knowledge.test.ts @@ -3,10 +3,15 @@ import { v2CreateKnowledgeBaseContract, v2CreateKnowledgeDocumentUploadContract, v2CreateKnowledgeFolderContract, + v2ListKnowledgeDocumentsContract, v2SearchKnowledgeContract, v2UploadKnowledgeDocumentContract, } from '@/lib/api/contracts/v2/knowledge' +function issueMessages(result: { error?: { issues: readonly { message: string }[] } }): string[] { + return (result.error?.issues ?? []).map((issue) => issue.message) +} + describe('v2 knowledge contracts', () => { it('declares 201 for every resource-creation response', () => { expect(v2CreateKnowledgeBaseContract.response.status).toBe(201) @@ -39,4 +44,72 @@ describe('v2 knowledge contracts', () => { expect(tooManyKnowledgeBases?.success).toBe(false) expect(excessiveTopK?.success).toBe(false) }) + + /** + * A dropped key here is not a cosmetic difference: `rerankerEnabled` and + * `topK` both decide how many search units the request is billed, so a + * mis-cased key that parses to 200 charges the caller for a search they did + * not ask for and returns results they did not configure. + */ + it.each(['rerankerenabled', 'topk', 'rerankermodel', 'searchmode'])( + 'rejects the mis-cased billing-relevant key %s instead of dropping it', + (key) => { + const parsed = v2SearchKnowledgeContract.body?.safeParse({ + workspaceId: 'workspace-1', + knowledgeBaseIds: ['kb-1'], + query: 'support', + [key]: key === 'topk' ? 50 : true, + }) + expect(parsed?.success).toBe(false) + } + ) + + it('still accepts the correctly spelled reranking fields', () => { + const parsed = v2SearchKnowledgeContract.body?.safeParse({ + workspaceId: 'workspace-1', + knowledgeBaseIds: ['kb-1'], + query: 'support', + topK: 50, + rerankerEnabled: true, + }) + expect(parsed?.success).toBe(true) + }) +}) + +/** + * The document list inherited `limit` and `search` from the v1 shape, so it was + * the one v2 list whose bounds and messages diverged from every sibling. An + * empty `search` reaching an unindexed `LOWER(filename) LIKE` scan is the + * concrete cost: `?search=` answered 200 with the full page while the sibling + * `GET /knowledge?search=` answered 400. + */ +describe('v2 knowledge document list query', () => { + const query = v2ListKnowledgeDocumentsContract.query + + it('rejects an empty search term', () => { + const parsed = query?.safeParse({ workspaceId: 'ws-1', search: '' }) + expect(parsed?.success).toBe(false) + expect(issueMessages(parsed as never)).toContain('search cannot be empty') + }) + + it('rejects a search term past the shared 200-character bound', () => { + const parsed = query?.safeParse({ workspaceId: 'ws-1', search: 'a'.repeat(201) }) + expect(parsed?.success).toBe(false) + expect(issueMessages(parsed as never)).toContain('search is too long') + }) + + it('trims a search term the way every other v2 list does', () => { + const parsed = query?.safeParse({ workspaceId: 'ws-1', search: ' invoice ' }) + expect(parsed?.success).toBe(true) + expect((parsed?.data as { search?: string } | undefined)?.search).toBe('invoice') + }) + + it.each([ + [0, 'limit must be at least 1'], + [101, 'limit cannot exceed 100'], + ])('names the failing bound for limit %s', (limit, message) => { + const parsed = query?.safeParse({ workspaceId: 'ws-1', limit }) + expect(parsed?.success).toBe(false) + expect(issueMessages(parsed as never)).toContain(message) + }) }) diff --git a/apps/sim/lib/api/contracts/v2/__tests__/list-pagination.test.ts b/apps/sim/lib/api/contracts/v2/__tests__/list-pagination.test.ts index 9a5c2fcb5bb..b6034e3af3b 100644 --- a/apps/sim/lib/api/contracts/v2/__tests__/list-pagination.test.ts +++ b/apps/sim/lib/api/contracts/v2/__tests__/list-pagination.test.ts @@ -1,10 +1,9 @@ /** * @vitest-environment node */ -import { readdirSync } from 'node:fs' -import path from 'node:path' import { describe, expect, it } from 'vitest' import { z } from 'zod' +import { listContractFiles } from '@/lib/api/contracts/v2/__tests__/contract-sweep' import { MAX_SCHEMA_DEPTH, rejectsUnknownKeys, @@ -44,8 +43,6 @@ import { * no JSON envelope to classify. */ -const CONTRACTS_DIR = path.resolve(import.meta.dirname, '..', '..') - /** Lists that accept `limit` + `cursor` and can return a non-null `nextCursor`. */ const PAGED_LISTS = [ 'GET /api/v2/audit-logs', @@ -72,17 +69,18 @@ const PAGED_LISTS = [ * Lists that accept neither param and always return `nextCursor: null`, because * the set is small and bounded per workspace, per table, or per server. * - * Every folder list is capped where the tree is loaded - * (`MAX_*_FOLDERS_PER_WORKSPACE`), and one MCP server's tool inventory is capped - * by tool discovery itself (`LIST_TOOLS_MAX_TOOLS` / `LIST_TOOLS_MAX_BYTES`) no - * matter what the upstream server reports — bounded by construction rather than - * by a caller's `limit`. The MCP *server* list is not: nothing caps how many - * servers a workspace registers, which is why it is paged. - * Every remaining entry but the MCP server list and the knowledge tag list is a - * *folder* list, and a folder tree is already capped where it is loaded - * (`MAX_*_FOLDERS_PER_WORKSPACE`) — bounded by construction rather than by a - * caller's `limit`. The knowledge tag list is bounded the same way: a knowledge - * base has a fixed number of tag slots, so its vocabulary cannot grow past them. + * Every entry is bounded by construction rather than by a caller's `limit`: + * + * - The four folder lists are capped where the tree is loaded + * (`MAX_*_FOLDERS_PER_WORKSPACE`). + * - One MCP server's tool inventory is capped by tool discovery itself + * (`LIST_TOOLS_MAX_TOOLS` / `LIST_TOOLS_MAX_BYTES`), whatever the upstream + * server reports. The MCP *server* list is not bounded that way — nothing caps + * how many servers a workspace registers — which is why it is paged and does + * not appear here. + * - A knowledge base has a fixed number of tag slots, so its tag vocabulary + * cannot grow past them. + * - A table's saved views and its dispatchable groups are capped per table. */ const FULL_SET_LISTS = [ 'GET /api/v2/files/folders', @@ -95,6 +93,116 @@ const FULL_SET_LISTS = [ 'GET /api/v2/workflows/folders', ] as const +/** + * Which of each paged list's params its cursor is bound to. + * + * A cursor names a position in ONE sequence, and every param that reorders or + * re-filters that sequence decides which sequence that is. Replay a cursor + * across a change to any of them and the reply is wrong in a way the caller + * cannot see: an offset lands at an unrelated ordinal, and a keyset — which + * stays internally coherent — silently drops every match that sorts before its + * position. So all of them are stamped into the token and re-checked on the way + * back in, and a mismatch is a 400 telling the caller to restart paging. + * + * The stamp is applied by the route through `cursorScopeKey` + + * `cursorSortKey` (`app/api/v2/lib/response.ts`), or, for the three lists whose + * token is minted by a domain codec, by wrapping it with `encodeScopedCursor`. + * The table-row lists bind inside their own codec (`lib/table/rows/cursor.ts`) + * against the same fingerprint. + * + * This map is the declaration; the tests below check it against what each + * contract actually accepts, in both directions. A list that gains a filter + * therefore fails here until someone decides whether the cursor is bound to it. + */ +const CURSOR_BINDINGS: Record = { + 'GET /api/v2/audit-logs': [ + 'organizationId', + 'includeDeparted', + 'action', + 'resourceType', + 'resourceId', + 'workspaceId', + 'actorEmail', + 'startDate', + 'endDate', + ], + 'GET /api/v2/billing/logs': ['source', 'workspaceId', 'period', 'startDate', 'endDate'], + 'GET /api/v2/credentials': ['workspaceId', 'type', 'providerId', 'search', 'sortBy', 'sortOrder'], + 'GET /api/v2/custom-tools': ['workspaceId', 'search', 'sortBy', 'sortOrder'], + 'GET /api/v2/files': ['workspaceId', 'scope', 'folderPath', 'search', 'sortBy', 'sortOrder'], + 'GET /api/v2/knowledge': ['workspaceId', 'folderPath', 'search', 'sortBy', 'sortOrder'], + 'GET /api/v2/knowledge/[id]/documents': [ + 'workspaceId', + 'enabledFilter', + 'search', + 'tagFilters', + 'sortBy', + 'sortOrder', + ], + 'GET /api/v2/logs': [ + 'workspaceId', + 'workflowIds', + 'triggers', + 'level', + 'startDate', + 'endDate', + 'runId', + 'minDurationMs', + 'maxDurationMs', + 'minCost', + 'maxCost', + 'model', + 'folderPaths', + 'order', + ], + 'GET /api/v2/mcp-servers': ['workspaceId', 'search', 'sortBy', 'sortOrder'], + 'GET /api/v2/secrets': ['workspaceId', 'scope', 'search', 'sortBy', 'sortOrder'], + 'GET /api/v2/skills': ['workspaceId', 'search', 'sortBy', 'sortOrder'], + 'GET /api/v2/tables': ['workspaceId', 'folderPath', 'search', 'sortBy', 'sortOrder'], + 'GET /api/v2/tables/[tableId]/rows': [], + 'POST /api/v2/tables/[tableId]/query': ['predicate', 'sort'], + 'GET /api/v2/workflows': [ + 'workspaceId', + 'folderPath', + 'deployedOnly', + 'search', + 'sortBy', + 'sortOrder', + ], + 'GET /api/v2/workflows/[id]/runs': ['status', 'trigger', 'startDate', 'endDate', 'order'], + 'GET /api/v2/workflows/[id]/versions': [], + 'GET /api/v2/workspaces/[workspaceId]/members': [], +} + +/** + * Params a paged list accepts that its cursor is deliberately NOT bound to, + * with the reason. Anything not listed here and not in {@link CURSOR_BINDINGS} + * fails the sweep. + * + * `limit` is excluded globally rather than per list: it selects how much of the + * sequence to return, not what the sequence is, so a caller is free to change + * page size mid-walk and binding it would strand every cursor for no + * correctness gain. + */ +const UNBOUND_PARAMS: Record> = { + 'GET /api/v2/logs': { + details: 'Selects how much of each row is rendered, not which rows are in the sequence.', + includeTraceSpans: 'Response shaping only; the row set and its order are unchanged.', + includeFinalOutput: 'Response shaping only; the row set and its order are unchanged.', + }, + 'GET /api/v2/tables/[tableId]/rows': { + workspaceId: + 'Asserted scope, not a filter: the sequence is one table, named by the path. A mismatched workspace is refused by authorization before paging.', + }, + 'POST /api/v2/tables/[tableId]/query': { + workspaceId: + 'Asserted scope, not a filter: the sequence is one table, named by the path. A mismatched workspace is refused by authorization before paging.', + }, +} + +/** Never part of a binding, on any list. */ +const NEVER_BOUND = new Set(['limit', 'cursor']) + /** * Lists that deliberately truncate a fractional `limit` instead of rejecting it. * @@ -252,30 +360,34 @@ function rejectsFractionalLimit(contract: ContractLike): boolean { return false } -function listContractFiles(dir: string): string[] { - const files: string[] = [] - for (const entry of readdirSync(dir, { withFileTypes: true }).sort((a, b) => - a.name.localeCompare(b.name) - )) { - const full = path.join(dir, entry.name) - if (entry.isDirectory()) { - if (entry.name === '__tests__') continue - files.push(...listContractFiles(full)) - } else if (entry.name.endsWith('.ts') && !entry.name.endsWith('.test.ts')) { - files.push(full) - } - } - return files -} - interface V2ListContract { key: string name: string params: { any: string[]; all: string[] } + /** Every param name the contract accepts, across `query` and `body`. */ + inputKeys: string[] /** `undefined` when the contract has no `query`; `null` when it could not be introspected. */ strictQuery: boolean | null | undefined /** Whether a fractional `limit` draws a validation issue on `limit` itself. */ rejectsFractionalLimit: boolean + /** Published description of `nextCursor`, as a caller reads it in the spec. */ + nextCursorDescription: string +} + +/** + * The `nextCursor` description the generated spec carries. + * + * Read off the JSON Schema rather than the Zod node because that is the + * artifact a caller and a generated client actually see — an envelope that is + * right in TypeScript but publishes the wrong sentence is exactly the + * divergence this exists to catch. + */ +function nextCursorDescription(schema: z.ZodType | undefined): string { + if (!schema) return '' + const published = z.toJSONSchema(schema, { io: 'output', unrepresentable: 'any' }) as { + properties?: Record + } + return published.properties?.nextCursor?.description ?? '' } /** @@ -290,7 +402,7 @@ function loadV2ListContracts(): Promise { async function sweepV2ListContracts(): Promise { const found = new Map() - for (const file of listContractFiles(CONTRACTS_DIR)) { + for (const file of listContractFiles()) { const mod = (await import(file)) as Record for (const [name, value] of Object.entries(mod)) { if (!isContract(value)) continue @@ -300,12 +412,15 @@ async function sweepV2ListContracts(): Promise { const label = `${name} (${key})` if (!isListResponse(label, value.response?.schema)) continue if (found.has(key)) continue + const variants = inputVariants(label, value) found.set(key, { key, name, - params: paginationParams(inputVariants(label, value)), + params: paginationParams(variants), + inputKeys: [...new Set(variants.flat())].sort(), strictQuery: value.query ? rejectsUnknownKeys(value.query) : undefined, rejectsFractionalLimit: rejectsFractionalLimit(value), + nextCursorDescription: nextCursorDescription(value.response?.schema), }) } } @@ -360,6 +475,32 @@ describe('v2 list pagination split', () => { } }) + /** + * The envelope is shared by both kinds of list, so its `nextCursor` sentence + * has to say which one the caller is holding. Both kinds published the paged + * sentence — "Send it back as `cursor`" — on lists whose `.strict()` query + * declares no `cursor`, so following the response's own instruction is a 400, + * and `nextCursor` is `null` by construction anyway. The description is the + * only part of the envelope that can carry the difference. + */ + it('documents nextCursor as the kind of cursor the list actually has', async () => { + const contracts = await loadV2ListContracts() + const byKey = new Map(contracts.map((c) => [c.key, c])) + + for (const key of FULL_SET_LISTS) { + expect( + byKey.get(key)?.nextCursorDescription, + `${key} returns its whole set but publishes the paged nextCursor sentence, which sends a caller to replay a token its query rejects. Build the response with v2CursorListResponse(item, { paged: false }).` + ).not.toMatch(/send it back as/i) + } + for (const key of PAGED_LISTS) { + expect( + byKey.get(key)?.nextCursorDescription, + `${key} is paged, so its nextCursor must document how to fetch the next page.` + ).toMatch(/send it back as/i) + } + }) + it('makes every v2 list query reject a param it does not implement', async () => { const contracts = await loadV2ListContracts() const byKey = new Map(contracts.map((c) => [c.key, c])) @@ -404,6 +545,74 @@ describe('v2 list pagination split', () => { } }) + it('makes every paged list declare what its cursor is bound to', async () => { + const contracts = await loadV2ListContracts() + const declared = new Set(Object.keys(CURSOR_BINDINGS)) + + expect( + contracts.filter((c) => PAGED_LISTS.includes(c.key as never) && !declared.has(c.key)), + 'A paged v2 list must declare its cursor binding in CURSOR_BINDINGS. A cursor names a position in one sequence; every param that decides that sequence has to be stamped into the token, or replaying it across a filter change answers from a sequence the caller never asked for.' + ).toEqual([]) + expect([...declared].sort()).toEqual([...PAGED_LISTS].sort()) + }) + + it('binds every sequence-affecting param a paged list accepts', async () => { + const contracts = await loadV2ListContracts() + const byKey = new Map(contracts.map((c) => [c.key, c])) + + for (const key of PAGED_LISTS) { + const contract = byKey.get(key) + if (!contract) throw new Error(`${key} was not discovered by the contract sweep`) + const accounted = new Set([ + ...CURSOR_BINDINGS[key], + ...Object.keys(UNBOUND_PARAMS[key] ?? {}), + ...NEVER_BOUND, + ]) + + expect( + contract.inputKeys.filter((param) => !accounted.has(param)), + `${key} accepts a param its cursor neither binds nor exempts. Add it to CURSOR_BINDINGS and stamp it in the route, or record why it cannot change the sequence in UNBOUND_PARAMS.` + ).toEqual([]) + } + }) + + it('never declares a binding on a param the contract does not accept', async () => { + const contracts = await loadV2ListContracts() + const byKey = new Map(contracts.map((c) => [c.key, c])) + + for (const key of PAGED_LISTS) { + const accepted = new Set(byKey.get(key)?.inputKeys ?? []) + + expect( + [...CURSOR_BINDINGS[key], ...Object.keys(UNBOUND_PARAMS[key] ?? {})].filter( + (param) => !accepted.has(param) + ), + `${key} declares a cursor binding for a param it no longer accepts. A renamed filter leaves the stamp reading undefined on both sides, which silently restores the mid-walk filter change this map exists to prevent.` + ).toEqual([]) + } + }) + + /** + * The one param that must never be bound. Binding it looks harmless and + * breaks every caller that changes page size mid-walk. + */ + it('never binds the page size', () => { + for (const [key, bound] of Object.entries(CURSOR_BINDINGS)) { + expect( + bound.filter((param) => NEVER_BOUND.has(param)), + `${key} binds limit or cursor` + ).toEqual([]) + } + }) + + it('gives every unbound param a non-empty reason', () => { + for (const [key, exemptions] of Object.entries(UNBOUND_PARAMS)) { + for (const [param, reason] of Object.entries(exemptions)) { + expect(reason.trim(), `${key}.${param} is exempted without a reason`).not.toBe('') + } + } + }) + it('sees a pagination param hidden in a single union member', () => { const unionQuery = z.union([ z.object({ workspaceId: z.string(), limit: z.coerce.number().default(50) }), diff --git a/apps/sim/lib/api/contracts/v2/__tests__/pagination-limit.test.ts b/apps/sim/lib/api/contracts/v2/__tests__/pagination-limit.test.ts index 497c4a71c55..a8bc3d77253 100644 --- a/apps/sim/lib/api/contracts/v2/__tests__/pagination-limit.test.ts +++ b/apps/sim/lib/api/contracts/v2/__tests__/pagination-limit.test.ts @@ -2,6 +2,7 @@ * @vitest-environment node */ import { describe, expect, it } from 'vitest' +import { z } from 'zod' import { v2ListBillingLogsContract } from '@/lib/api/contracts/v2/billing' import { v2ListCredentialsContract } from '@/lib/api/contracts/v2/credentials' import { v2ListCustomToolsContract } from '@/lib/api/contracts/v2/custom-tools' @@ -108,5 +109,26 @@ describe('v2 limit validation', () => { expect(clamped.parse('-5')).toBe(1) expect(clamped.parse('99999')).toBe(1000) }) + + /** + * The published schema must not carry `minimum`/`maximum`. In JSON Schema + * they mean "rejected outside", so publishing them beside a description + * that promises clamping made a generated SDK refuse locally a `limit` this + * branch would have accepted and corrected. The rejecting branch keeps its + * bounds, because there they are true. + */ + it('publishes no numeric bounds, because it clamps rather than rejects', () => { + const published = z.toJSONSchema(clamped, { io: 'input', unrepresentable: 'any' }) + expect(published).not.toHaveProperty('minimum') + expect(published).not.toHaveProperty('maximum') + expect(published.description).toContain('clamped') + }) + + it('keeps the bounds on the rejecting branch, where they are enforced', () => { + const rejecting = v2LimitSchema({ max: 1000, fallback: 100 }) + const published = z.toJSONSchema(rejecting, { io: 'input', unrepresentable: 'any' }) + expect(published).toMatchObject({ minimum: 1, maximum: 1000 }) + expect(rejecting.safeParse('99999').success).toBe(false) + }) }) }) diff --git a/apps/sim/lib/api/contracts/v2/__tests__/query-declaration.test.ts b/apps/sim/lib/api/contracts/v2/__tests__/query-declaration.test.ts new file mode 100644 index 00000000000..d053463c45e --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/__tests__/query-declaration.test.ts @@ -0,0 +1,100 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { + type SweptContractEntry, + sweepV2Contracts, +} from '@/lib/api/contracts/v2/__tests__/contract-sweep' +import { rejectsUnknownKeys } from '@/lib/api/contracts/v2/__tests__/schema-introspection' + +/** + * Every v2 contract must declare a `query` schema, and it must be `.strict()`. + * + * `parseRequest` validates the query slice only when the contract declares one + * (`contract.query ? validate : skip`). A contract with no `query` therefore + * never validates the query string at all: `GET /api/v2/workflows/{id}?bogus=1` + * answered 200 while every v2 list answered 400 for the same shape. The caller + * learns nothing about the param the server ignored, which is the failure the + * lists' `.strict()` rule already exists to prevent — a request the server did + * not honour must not come back 200. + * + * The endpoints that take no query say so with `noInputSchema` + * (`z.object({}).strict()`) rather than by omission, because omission and "takes + * nothing" were indistinguishable — which is exactly how 69 of them ended up + * unvalidated without anyone deciding they should be. + * + * This sweep is the enforcement, not the 69-contract edit that accompanied it. A + * one-time edit leaves number 70 free to regress; walking the tree means a new + * contract fails here the moment it is written, and names itself in the failure. + * + * A compile-time gate on `defineRouteContract` was tried first and rejected: + * making `query` conditionally required on a `/api/v2/` path turns the parameter + * into an intersection, and the intersection collapses inference of `TParams`, + * `TBody`, and `THeaders` to `undefined`, breaking every OpenAPI document that + * reads them back off the contract. The sweep is also the wider net — it sees + * every contract in the tree, including any a type on that one function would + * never be asked about. + */ + +const SWEEP_TIMEOUT_MS = 60_000 + +let contractsPromise: Promise | null = null +function loadContracts(): Promise { + contractsPromise ??= sweepV2Contracts() + return contractsPromise +} + +describe('v2 query declaration', () => { + it( + 'declares a query schema on every contract, so no v2 endpoint skips query validation', + async () => { + const contracts = await loadContracts() + expect(contracts.length).toBeGreaterThan(100) + + const undeclared = contracts + .filter((entry) => !entry.contract.query) + .map((entry) => `${entry.key} (${entry.name})`) + + expect( + undeclared, + 'A v2 contract without a `query` schema accepts any query param silently: parseRequest skips the slice entirely when the contract declares none. Use `noInputSchema` from @/lib/api/contracts/primitives when the endpoint takes no query params. See .agents/skills/v2-api-conventions/SKILL.md.' + ).toEqual([]) + }, + SWEEP_TIMEOUT_MS + ) + + it('makes every declared v2 query reject a param it does not implement', async () => { + const contracts = await loadContracts() + + const nonStrict = contracts + .filter((entry) => entry.contract.query && rejectsUnknownKeys(entry.contract.query) !== true) + .map((entry) => `${entry.key} (${entry.name})`) + + expect( + nonStrict, + 'Zod strips unknown keys by default, so a non-strict query answers 200 for a request the server did not honour. Declare the query object `.strict()`. A `null` from the walk means the schema could not be introspected, which must fail rather than pass silently.' + ).toEqual([]) + }) + + /** + * The endpoints that take no query must accept a bare request and reject a + * decorated one. Both halves matter: a schema that rejected the empty query + * would break every existing caller, and one that accepted an unknown key + * would be the omission this rule replaced, just spelled out. + */ + it('lets an endpoint that takes no query accept none and refuse an invented one', async () => { + const contracts = await loadContracts() + const takesNoQuery = contracts.filter( + (entry) => entry.contract.query?.safeParse({}).success === true + ) + + expect(takesNoQuery.length).toBeGreaterThan(0) + for (const entry of takesNoQuery) { + expect( + entry.contract.query?.safeParse({ bogus: '1' }).success, + `${entry.key} accepts an undeclared query param instead of rejecting it` + ).toBe(false) + } + }) +}) diff --git a/apps/sim/lib/api/contracts/v2/__tests__/run-accounting.test.ts b/apps/sim/lib/api/contracts/v2/__tests__/run-accounting.test.ts new file mode 100644 index 00000000000..7a97aab0d1e --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/__tests__/run-accounting.test.ts @@ -0,0 +1,58 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { logsOpenApiDocument } from '@/lib/api/contracts/v2/openapi/logs' +import { workflowsOpenApiDocument } from '@/lib/api/contracts/v2/openapi/workflows' +import { v2WorkflowListItemSchema } from '@/lib/api/contracts/v2/workflows' +import type { OpenApiDocumentDefinition } from '@/lib/api/openapi/types' + +/** + * The two documented facts about run accounting that a caller cannot discover + * from a response, and that a wrong description therefore turns into a silent + * wrong answer. + * + * `runCount` is a monotonic column on the workflow row, incremented only for a + * run that finished successfully and was not left paused, and never decremented + * by log retention. `GET /workflows/{id}/runs` reads the execution-log table, + * which lists every recorded run *and* is hard-deleted on the workspace's + * retention window. The two therefore disagree in both directions, and each + * operation has to say so where a caller reads it. + */ +function operationDescription(document: OpenApiDocumentDefinition, operationId: string): string { + const route = document.routes.find((entry) => entry.operation.operationId === operationId) + if (!route) throw new Error(`No documented operation ${operationId}`) + return route.operation.description +} + +function fieldDescription(field: string): string { + const shape = v2WorkflowListItemSchema.shape as Record + return shape[field]?.description ?? '' +} + +describe('v2 run accounting descriptions', () => { + it('discloses that runCount excludes runs that did not succeed', () => { + const description = fieldDescription('runCount') + + expect(description).toMatch(/succe/i) + expect(description).toMatch(/fail/i) + }) + + it('discloses that runCount is not the length of the runs list', () => { + expect(fieldDescription('runCount')).toMatch(/retention/i) + }) + + it('discloses that lastRunAt tracks the same successful-run population', () => { + expect(fieldDescription('lastRunAt')).toMatch(/succe/i) + }) + + it.each([ + ['workflows', () => operationDescription(workflowsOpenApiDocument, 'listWorkflowRunsV2')], + ['logs', () => operationDescription(logsOpenApiDocument, 'listLogs')], + ])('documents the run retention window on the %s list', (_name, read) => { + const description = read() + + expect(description).toMatch(/retention/i) + expect(description).toMatch(/30 days/i) + }) +}) diff --git a/apps/sim/lib/api/contracts/v2/__tests__/shared.test.ts b/apps/sim/lib/api/contracts/v2/__tests__/shared.test.ts index e440b051abd..892e681a97f 100644 --- a/apps/sim/lib/api/contracts/v2/__tests__/shared.test.ts +++ b/apps/sim/lib/api/contracts/v2/__tests__/shared.test.ts @@ -1,7 +1,10 @@ import { describe, expect, it } from 'vitest' +import { z } from 'zod' import { traceSpansSchema } from '@/lib/api/contracts/logs' import { v2ListLogsQuerySchema } from '@/lib/api/contracts/v2/logs' import { + V2_FALSE_VALUES, + V2_TRUE_VALUES, v2DeleteFolderQuerySchema, v2FolderPathInputSchema, v2FolderPathSchema, @@ -9,6 +12,7 @@ import { v2NonRootFolderPathSchema, v2RelocateFolderBodySchema, } from '@/lib/api/contracts/v2/shared' +import { MAX_FOLDER_PATH_BYTES, MAX_FOLDER_PATH_SEGMENTS } from '@/lib/folders/paths' const WORKSPACE_ID = '6fc7631d-88cd-46f8-9f0a-d4764daef7f8' @@ -45,6 +49,69 @@ describe('v2 folder path contracts', () => { ).toBe(false) }) + /** + * The published `enum` is a restatement of `z.stringbool()`'s internal + * vocabulary, which contributes nothing to JSON Schema. Parsing every listed + * spelling here is what keeps the restatement honest through a Zod upgrade — + * and this is a destructive switch, so a spelling the spec advertises but the + * server rejects is worse than an undocumented one — and on a destructive + * switch so is the reverse: a spelling the server honours but the spec does + * not list is a recursive delete a generated client would have refused to + * send. `recursive` is therefore case-SENSITIVE, accepting exactly the twelve + * published spellings and nothing else. + */ + it('accepts every spelling of `recursive` it publishes, and only those', () => { + const published = z.toJSONSchema(v2DeleteFolderQuerySchema, { + io: 'input', + unrepresentable: 'any', + }) + const declared = (published.properties as Record).recursive.enum + + expect(declared).toEqual([...V2_TRUE_VALUES, ...V2_FALSE_VALUES]) + for (const value of V2_TRUE_VALUES) { + expect( + v2DeleteFolderQuerySchema.parse({ workspaceId: WORKSPACE_ID, path: '/R', recursive: value }) + .recursive + ).toBe(true) + } + for (const value of V2_FALSE_VALUES) { + expect( + v2DeleteFolderQuerySchema.parse({ workspaceId: WORKSPACE_ID, path: '/R', recursive: value }) + .recursive + ).toBe(false) + } + for (const value of ['True', 'TRUE', 'YES', 'On', 'Y', 'ENABLED', 'maybe']) { + expect( + v2DeleteFolderQuerySchema.safeParse({ + workspaceId: WORKSPACE_ID, + path: '/R', + recursive: value, + }).success + ).toBe(false) + } + }) + + /** + * The canonical-path rule is enforced in a `superRefine`, which publishes + * nothing, so it lived only in the implementation until it was written onto + * these two components. Pinning the published text against the constants that + * enforce the caps keeps the prose from outliving a bound change. + */ + it('publishes the canonical-path rule and the byte cap on every path schema', () => { + for (const schema of [ + v2FolderPathSchema, + v2NonRootFolderPathSchema, + v2FolderPathInputSchema, + v2NonRootFolderPathInputSchema, + ]) { + const published = z.toJSONSchema(schema, { io: 'input', unrepresentable: 'any' }) + expect(published.maxLength).toBe(MAX_FOLDER_PATH_BYTES) + expect(published.description).toContain('percent-encoded') + expect(published.description).toContain(String(MAX_FOLDER_PATH_SEGMENTS)) + expect(published.description).toContain(String(MAX_FOLDER_PATH_BYTES)) + } + }) + it('defaults folder deletion to non-recursive', () => { expect(v2DeleteFolderQuerySchema.parse({ workspaceId: WORKSPACE_ID, path: 'Reports' })).toEqual( { workspaceId: WORKSPACE_ID, path: '/Reports', recursive: false } diff --git a/apps/sim/lib/api/contracts/v2/__tests__/tables.test.ts b/apps/sim/lib/api/contracts/v2/__tests__/tables.test.ts index 8aa700675ad..f21685a4f1e 100644 --- a/apps/sim/lib/api/contracts/v2/__tests__/tables.test.ts +++ b/apps/sim/lib/api/contracts/v2/__tests__/tables.test.ts @@ -1,10 +1,13 @@ import { describe, expect, it } from 'vitest' -import type { z } from 'zod' +import { z } from 'zod' +import { runColumnBodyBaseSchema, TABLE_QUERY_MAX_BODY_BYTES } from '@/lib/api/contracts/tables' import { issueCodes, type SchemaLike, strictnessTargets, } from '@/lib/api/contracts/v2/__tests__/schema-introspection' +import { tablesOpenApiDocument } from '@/lib/api/contracts/v2/openapi/tables' +import { V2_SEARCH_MAX_LENGTH } from '@/lib/api/contracts/v2/shared' import * as tableContracts from '@/lib/api/contracts/v2/tables' import { V2_TABLE_IMPORT_OPTIONS_MAX_BYTES, @@ -15,12 +18,16 @@ import { v2CreateTableRowsBodySchema, v2CsvImportCreateColumnsSchema, v2CsvImportMappingSchema, + v2FindRowsBodySchema, + v2FindRowsDataSchema, + v2GetTableImportContract, v2QueryRowsBodySchema, + v2TableImportStatusSchema, v2TableUploadImportSourceSchema, v2UpdateTableColumnBodySchema, } from '@/lib/api/contracts/v2/tables' import { getValidationErrorMessage } from '@/lib/api/server/validation' -import { TABLE_LIMITS } from '@/lib/table/constants' +import { MAX_RUN_TARGET_ROW_IDS, TABLE_LIMITS } from '@/lib/table/constants' import { CSV_DURABLE_MAX_FILE_SIZE_BYTES } from '@/lib/table/import' const WORKSPACE_ID = '6fc7631d-88cd-46f8-9f0a-d4764daef7f8' @@ -283,3 +290,151 @@ describe('v2 table import contracts', () => { } }) }) + +/** + * The published error set has to match what a route can actually emit. The v2 + * JSON builder reads every request body under a byte ceiling BEFORE schema + * validation, so `413` is reachable on every body-carrying operation — and the + * two table query reads set a tighter ceiling of their own on top of that. An + * undocumented status is an unhandled branch in a generated client. + * + * One-directional on purpose: several bodyless reads publish `413` for the + * folder-tree materialization ceiling, so the converse is not asserted. + */ +describe('v2 table operation error sets', () => { + const operationsById = new Map( + tablesOpenApiDocument.routes.map((route) => [route.operation.operationId, route.operation]) + ) + + it('publishes 413 on every operation that accepts a request body', () => { + const missing = tablesOpenApiDocument.routes + .filter((route) => route.contract.body && !route.operation.errors.includes('PayloadTooLarge')) + .map((route) => route.operation.operationId) + + expect(missing).toEqual([]) + }) + + it.each(['queryTableRows', 'countTableRows'])( + 'names the tighter query-body ceiling on %s', + (operationId) => { + expect(operationsById.get(operationId)?.errors).toContain('PayloadTooLarge') + expect(operationsById.get(operationId)?.description).toContain('413') + } + ) + + it('keeps the body ceiling the two query operations share declared once', () => { + expect(TABLE_QUERY_MAX_BODY_BYTES).toBe(1024 * 1024) + }) +}) + +/** + * The import status enum is the client's exhaustive switch. A state the reads + * can never return is a dead branch every caller has to write; a phase the read + * cannot reach at all is worse. + */ +describe('v2 table import lifecycle surface', () => { + it('publishes only states an import read can return', () => { + expect(v2TableImportStatusSchema.options).toEqual([ + 'uploading', + 'processing', + 'completed', + 'failed', + 'canceled', + 'expired', + ]) + }) + + it('accepts the upload control token on the read, as the cancel already does', () => { + expect( + v2GetTableImportContract.headers?.safeParse({ 'upload-token': 'signed-token' }) + ).toMatchObject({ success: true, data: { 'upload-token': 'signed-token' } }) + expect(v2GetTableImportContract.headers?.safeParse({}).success).toBe(true) + }) +}) + +/** + * Caller-supplied input that reaches an unindexed scan or a large id list has to + * carry a declared ceiling; an undeclared one is enforced by the domain as a + * surprise, or not at all. + */ +describe('v2 table request bounds', () => { + const findBody = { workspaceId: WORKSPACE_ID, q: 'x' } + + it('caps the Find search term at the shared v2 search length', () => { + expect( + v2FindRowsBodySchema.safeParse({ ...findBody, q: 'a'.repeat(V2_SEARCH_MAX_LENGTH) }).success + ).toBe(true) + expect( + v2FindRowsBodySchema.safeParse({ ...findBody, q: 'a'.repeat(V2_SEARCH_MAX_LENGTH + 1) }) + .success + ).toBe(false) + }) + + it('publishes the Find match cap the truncated flag is derived from', () => { + expect( + v2FindRowsDataSchema.safeParse({ + matches: Array.from({ length: TABLE_LIMITS.MAX_FIND_MATCHES + 1 }, () => ({ + ordinal: 0, + rowId: 'row-1', + column: 'name', + })), + truncated: true, + }).success + ).toBe(false) + expect(JSON.stringify(z.toJSONSchema(v2FindRowsDataSchema))).toContain( + String(TABLE_LIMITS.MAX_FIND_MATCHES) + ) + }) + + it('declares the run row-id ceiling the domain already enforces', () => { + const rowIds = z.toJSONSchema(runColumnBodyBaseSchema.shape.rowIds) as { + anyOf?: Array<{ maxItems?: number; minItems?: number }> + maxItems?: number + minItems?: number + } + const bounds = rowIds.anyOf?.find((entry) => entry.maxItems !== undefined) ?? rowIds + + expect(bounds.maxItems).toBe(MAX_RUN_TARGET_ROW_IDS) + expect(bounds.minItems).toBe(1) + }) + + /** + * The shared group shape defaults `workflowId` to `''`, so the published + * schema advertised `default: ""` while `refineGroupSource` 400s any manual + * group that omits it — a documented fallback that always fails. + */ + it('does not advertise a workflowId default the create refuses to honor', () => { + const json = z.toJSONSchema(tableContracts.v2AddWorkflowGroupBodySchema, { + io: 'input', + unrepresentable: 'any', + }) as { + properties?: { group?: { properties?: { workflowId?: { default?: unknown } } } } + } + + expect(json.properties?.group?.properties?.workflowId?.default).toBeUndefined() + expect( + tableContracts.v2AddWorkflowGroupBodySchema.safeParse({ + workspaceId: '6fc7631d-88cd-46f8-9f0a-d4764daef7f8', + group: { + type: 'manual', + outputs: [{ blockId: 'block-1', path: 'result', columnName: 'Result' }], + }, + outputColumns: [{ name: 'Result', type: 'string' }], + }).success + ).toBe(false) + }) + + /** + * `*` is the wildcard, not `%`. Nothing published said so, so `like: "Hi%"` + * matched zero rows with a 200 while `like: "Hi*"` matched 1358. + */ + it('publishes the predicate operator grammar, including the wildcard', () => { + const published = JSON.stringify( + z.toJSONSchema(v2QueryRowsBodySchema, { io: 'input', unrepresentable: 'any' }) + ) + + expect(published).toContain('`*` is the only wildcard') + expect(published).toContain('single-select accepts `eq`, `ne`, `in`, `nin`') + expect(published).toContain('isEmpty') + }) +}) diff --git a/apps/sim/lib/api/contracts/v2/audit-logs.ts b/apps/sim/lib/api/contracts/v2/audit-logs.ts index 05da3ec2d0b..916688c0c32 100644 --- a/apps/sim/lib/api/contracts/v2/audit-logs.ts +++ b/apps/sim/lib/api/contracts/v2/audit-logs.ts @@ -1,5 +1,9 @@ import { z } from 'zod' -import { booleanQueryFlagSchema, organizationIdSchema } from '@/lib/api/contracts/primitives' +import { + booleanQueryFlagSchema, + organizationIdSchema, + workspaceIdSchema, +} from '@/lib/api/contracts/primitives' import { defineRouteContract } from '@/lib/api/contracts/types' import { v1AuditLogParamsSchema, @@ -9,6 +13,7 @@ import { v2CursorListResponse, v2DataResponse, v2PaginationFields, + v2RunWindowBoundSchema, } from '@/lib/api/contracts/v2/shared' /** @@ -78,13 +83,22 @@ export const v2ListAuditLogsQuerySchema = v1ListAuditLogsQuerySchema resourceId: v1ListAuditLogsQuerySchema.shape.resourceId.describe( 'Filter by exact resource identifier.' ), - workspaceId: v1ListAuditLogsQuerySchema.shape.workspaceId.describe( - 'Filter to actions in one workspace.' - ), - startDate: v1ListAuditLogsQuerySchema.shape.startDate.describe( - 'Inclusive ISO 8601 start timestamp.' - ), - endDate: v1ListAuditLogsQuerySchema.shape.endDate.describe('Inclusive ISO 8601 end timestamp.'), + /** + * The one v2 query param still declared as a bare `z.string()` rather than + * the shared identifier schema, so `?workspaceId=` parsed and was forwarded + * as a real filter — a page of zero rows where every sibling answers 400. + */ + workspaceId: workspaceIdSchema.optional().describe('Filter to actions in one workspace.'), + /** + * The shared run-window bound rather than the v1 `Date.parse` refine, which + * accepts partial and locale-dependent forms whose meaning varies by + * runtime. Both bounds are turned into `Date`s before they reach the query, + * so the strict UTC form is what keeps an unrepresentable value a 400 + * instead of a driver-level 500. `GET /logs` and `GET /workflows/{id}/runs` + * already share it, and an audit trail is read alongside them. + */ + startDate: v2RunWindowBoundSchema('startDate').optional(), + endDate: v2RunWindowBoundSchema('endDate').optional(), /** * Declared with the shared boolean flag rather than reused from the v1 * shape: v1 spells it as a `'true'`/`'false'` string enum, and every other diff --git a/apps/sim/lib/api/contracts/v2/billing.ts b/apps/sim/lib/api/contracts/v2/billing.ts index c86d1224ce1..a9dc48e60e6 100644 --- a/apps/sim/lib/api/contracts/v2/billing.ts +++ b/apps/sim/lib/api/contracts/v2/billing.ts @@ -6,6 +6,7 @@ import { v2CursorListResponse, v2DataResponse, v2PaginationFields, + v2RunWindowBoundSchema, } from '@/lib/api/contracts/v2/shared' /** @@ -18,12 +19,6 @@ import { * = $5) — raw dollar costs and rate-limit internals are never on this wire. */ -/** `Date`-constructor-parseable string; validates parseability, not a wire format. */ -const parseableDateSchema = z - .string() - .min(1) - .refine((value) => !Number.isNaN(Date.parse(value)), { error: 'Invalid date' }) - /** * `.strict()` carries more weight here than on an ordinary read. `workspaceId` is * optional and selects *which payer* is reported, so a key Zod would otherwise strip — @@ -157,15 +152,21 @@ export const v2BillingLogsQuerySchema = z period: usageLogPeriodSchema .optional() .default('30d') - .describe('Relative window, all history, or a custom date range.'), - /** Required when `period` is `'custom'`. */ - startDate: parseableDateSchema - .optional() - .describe('Start of a custom window as a Date-parseable string.'), - /** Defaults to now when omitted for `'custom'`. */ - endDate: parseableDateSchema - .optional() - .describe('End of a custom window as a Date-parseable string; defaults to now.'), + .describe( + 'Relative window, all history, or a custom date range. `startDate` and `endDate` are accepted only with `custom`; every other value computes its own window.' + ), + /** Required when `period` is `'custom'`, and rejected otherwise. */ + startDate: v2RunWindowBoundSchema('startDate') + .describe( + 'Only include usage events recorded at or after this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. Requires `period=custom`. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected, as is year `0000`, which names no storable instant.' + ) + .optional(), + /** Defaults to now when omitted for `'custom'`; rejected for every other period. */ + endDate: v2RunWindowBoundSchema('endDate') + .describe( + 'Only include usage events recorded at or before this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. Requires `period=custom`, and defaults to now when omitted. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected, as is year `0000`, which names no storable instant.' + ) + .optional(), ...v2PaginationFields({ description: 'Maximum usage events per page.' }), }) .strict() @@ -173,6 +174,41 @@ export const v2BillingLogsQuerySchema = z error: 'startDate is required when period is "custom"', path: ['startDate'], }) + /** + * `.strict()` only rejects keys the schema does not declare. Both bounds *are* + * declared, and `resolveDateRange` reads them in the `'custom'` branch alone, so + * a bound sent with any other period parsed, was accepted, and was then dropped — + * the query answered 200 over the default 30-day window. On a ledger a caller + * reconciles charges against, that is the worst shape of wrong answer: the rows + * are real, they are simply not the rows that were asked for, and nothing in the + * response distinguishes the two. Rejecting names the escape hatch instead. + */ + .superRefine((query, ctx) => { + if (query.period === 'custom') return + for (const field of ['startDate', 'endDate'] as const) { + if (query[field] === undefined) continue + ctx.addIssue({ + code: 'custom', + message: `${field} is only accepted when period=custom; period="${query.period}" computes its own window`, + path: [field], + }) + } + }) + /** + * Parity with `GET /logs` and `GET /workflows/{id}/runs`, which reject an + * inverted window rather than answering with the empty page an unsatisfiable + * `createdAt >= start AND createdAt <= end` produces. + */ + .refine( + (query) => + !query.startDate || + !query.endDate || + Date.parse(query.startDate) <= Date.parse(query.endDate), + { + error: 'startDate must be before or equal to endDate', + path: ['startDate'], + } + ) /** * One credit-consuming usage event. `creditCost` is apportioned across the diff --git a/apps/sim/lib/api/contracts/v2/custom-tools.ts b/apps/sim/lib/api/contracts/v2/custom-tools.ts index ae764afba70..601b301c789 100644 --- a/apps/sim/lib/api/contracts/v2/custom-tools.ts +++ b/apps/sim/lib/api/contracts/v2/custom-tools.ts @@ -1,5 +1,5 @@ import { z } from 'zod' -import { nonEmptyIdSchema, workspaceIdSchema } from '@/lib/api/contracts/primitives' +import { noInputSchema, nonEmptyIdSchema, workspaceIdSchema } from '@/lib/api/contracts/primitives' import { customToolFunctionParametersSchema, customToolSchemaSchema, @@ -106,13 +106,15 @@ export const v2CustomToolDeleteDataSchema = z export type V2CustomToolDeleteData = z.output export const v2CustomToolParamsSchema = z.object({ - id: nonEmptyIdSchema.describe('Custom tool to retrieve, update, or delete.'), + id: nonEmptyIdSchema.describe('Unique custom tool identifier.'), }) export type V2CustomToolParams = z.output -export const v2CustomToolWorkspaceQuerySchema = z.object({ - workspaceId: workspaceIdSchema.describe('Workspace that owns the custom tool.'), -}) +export const v2CustomToolWorkspaceQuerySchema = z + .object({ + workspaceId: workspaceIdSchema.describe('Workspace that owns the custom tool.'), + }) + .strict() export type V2CustomToolWorkspaceQuery = z.output /** A custom tool's natural name field is `title`, so that is what `search` matches. */ @@ -180,6 +182,7 @@ export const v2ListCustomToolsContract = defineRouteContract({ export const v2CreateCustomToolContract = defineRouteContract({ method: 'POST', path: '/api/v2/custom-tools', + query: noInputSchema, body: v2CreateCustomToolBodySchema, response: { mode: 'json', @@ -202,6 +205,7 @@ export const v2GetCustomToolContract = defineRouteContract({ export const v2UpdateCustomToolContract = defineRouteContract({ method: 'PATCH', path: '/api/v2/custom-tools/[id]', + query: noInputSchema, params: v2CustomToolParamsSchema, body: v2UpdateCustomToolBodySchema, response: { diff --git a/apps/sim/lib/api/contracts/v2/files.ts b/apps/sim/lib/api/contracts/v2/files.ts index 8c8b5b7231d..30fdfcc42cc 100644 --- a/apps/sim/lib/api/contracts/v2/files.ts +++ b/apps/sim/lib/api/contracts/v2/files.ts @@ -1,6 +1,7 @@ import { z } from 'zod' import { isCanonicalBase64, + noInputSchema, workspaceFileIdSchema, workspaceFileNameSchema, workspaceIdSchema, @@ -8,6 +9,7 @@ import { import { shareAuthTypeSchema, shareRecordSchema } from '@/lib/api/contracts/public-shares' import { defineRouteContract } from '@/lib/api/contracts/types' import { + V2_FOLDER_FILTER_MISS, v2CreateFolderBodySchema, v2CursorListResponse, v2DataResponse, @@ -59,13 +61,13 @@ export const v2FileSchema = z .number() .nonnegative() .describe( - 'Size in bytes of the stored file. For a generated document (docx, pptx, pdf, xlsx) the stored file is the generation source rather than the rendered document, so this does not predict how many bytes `GET /files/{fileId}` returns — that endpoint serves the compiled artifact, which is typically much larger.' + 'Size in bytes of the stored file. For a generated document (docx, pptx, pdf, xlsx) this is the generation source, not the rendered document, so it does not predict how many bytes `GET /files/{fileId}` returns.' ) .meta({ examples: [1024] }), type: z .string() .describe( - 'MIME type of the stored file. For a generated document (docx, pptx, pdf, xlsx) the stored file is the generation source, so this describes the source and not what `GET /files/{fileId}` serves — that endpoint returns the compiled artifact under the rendered document type.' + 'MIME type of the stored file. For a generated document (docx, pptx, pdf, xlsx) this is the generation source type, not the rendered document type `GET /files/{fileId}` serves.' ) .meta({ examples: ['text/csv'] }), key: z @@ -171,9 +173,11 @@ export const v2CreateFileUploadBodySchema = z .strict() export type V2CreateFileUploadBody = z.input -export const v2FileUploadWorkspaceQuerySchema = z.object({ - workspaceId: workspaceIdSchema.describe('Workspace that owns the upload session.'), -}) +export const v2FileUploadWorkspaceQuerySchema = z + .object({ + workspaceId: workspaceIdSchema.describe('Workspace that owns the upload session.'), + }) + .strict() export type V2FileUploadWorkspaceQuery = z.output export const v2FileUploadSchema = z @@ -253,7 +257,7 @@ export const v2CreateFileBodySchema = z .max(70_000_000, 'content is too large') .default('') .describe( - 'Initial file content. Omit or send an empty string for a zero-byte file. The 70,000,000-character bound is a JSON-envelope guard, not the file-size limit: the decoded bytes must be at most 50 MiB, so a longer base64 payload is admitted here and then rejected with 413. Use an upload session for anything larger.' + 'Initial file content. Omit or send an empty string for a zero-byte file. The 70,000,000-character bound guards the JSON envelope; the decoded bytes must be at most 50 MiB, and a longer base64 payload is rejected with `413`. Use an upload session for anything larger.' ), encoding: z .enum(['utf-8', 'base64']) @@ -302,11 +306,11 @@ export const v2ListFilesQuerySchema = z /** Restrict to one file folder. Omit to list the whole workspace. */ folderPath: v2FolderPathInputSchema .optional() - .describe('Restrict results to files directly inside this folder.'), + .describe(`Restrict results to files directly inside this folder. ${V2_FOLDER_FILTER_MISS}`), scope: v2FileScopeSchema .default('active') .describe( - 'Which lifecycle set to list: `active` (default) for live files, `archived` for files a `DELETE` soft-deleted and that `POST /files/{fileId}/restore` can bring back. `folderPath` resolves against active folders only, so combining it with `scope=archived` returns 404 when the containing folder was archived too.' + 'Which lifecycle set to list: `active` (default) for live files, `archived` for files a `DELETE` soft-deleted. `folderPath` resolves against active folders only, so pairing it with `scope=archived` returns an empty page when the containing folder was archived too.' ), search: v2SearchSchema.describe('Case-insensitive substring match against the file name.'), ...v2SortFields(v2FileSortFields, { sortBy: 'uploadedAt', sortOrder: 'asc' }), @@ -322,9 +326,11 @@ export const v2ListFilesQuerySchema = z export type V2ListFilesQuery = z.output /** Download/delete both target a single file within a workspace-scoped query. */ -export const v2FileWorkspaceQuerySchema = z.object({ - workspaceId: workspaceIdSchema.describe('Workspace that owns the file.'), -}) +export const v2FileWorkspaceQuerySchema = z + .object({ + workspaceId: workspaceIdSchema.describe('Workspace that owns the file.'), + }) + .strict() export type V2FileWorkspaceQuery = z.output @@ -428,12 +434,13 @@ export const v2ListFileFoldersContract = defineRouteContract({ method: 'GET', path: '/api/v2/files/folders', query: v2ListFoldersQuerySchema, - response: { mode: 'json', schema: v2CursorListResponse(v2FolderSchema) }, + response: { mode: 'json', schema: v2CursorListResponse(v2FolderSchema, { paged: false }) }, }) export const v2CreateFileFolderContract = defineRouteContract({ method: 'POST', path: '/api/v2/files/folders', + query: noInputSchema, body: v2CreateFolderBodySchema, response: { mode: 'json', schema: v2DataResponse(v2FolderSchema), status: 201 }, }) @@ -441,6 +448,7 @@ export const v2CreateFileFolderContract = defineRouteContract({ export const v2RelocateFileFolderContract = defineRouteContract({ method: 'PATCH', path: '/api/v2/files/folders', + query: noInputSchema, body: v2RelocateFolderBodySchema, response: { mode: 'json', schema: v2DataResponse(v2FolderSchema) }, }) @@ -469,19 +477,31 @@ export type V2NullableFileShare = z.output export const v2UpsertFileShareBodySchema = z .object({ workspaceId: workspaceIdSchema.describe('Workspace that owns the file.'), - isActive: z.boolean().describe('Whether the share should resolve.'), - authType: shareAuthTypeSchema.optional().describe('How access to the share is gated.'), + isActive: z + .boolean() + .describe( + 'Whether the share should resolve. Disabling preserves the token and the whole access configuration, so re-enabling restores the share as it was; enabling rewrites the credentials the resulting mode does not use.' + ), + authType: shareAuthTypeSchema + .optional() + .describe( + 'How access to the share is gated. The stored mode is kept when omitted. Enabling `public` clears the stored password and empties `allowedEmails`; `password` empties `allowedEmails`; `email` and `sso` clear the stored password.' + ), password: z .string() .min(1, 'password cannot be empty') .max(1024, 'password is too long') .optional() - .describe('Password for a password-gated share.'), + .describe( + 'Password for a password-gated share. Kept when omitted; enabling `password` with neither a supplied nor a stored password is a 400.' + ), allowedEmails: z .array(z.string().min(1, 'allowedEmails entries cannot be empty').max(320)) .max(200, 'Too many allowed emails') .optional() - .describe('Allowed addresses or @domain patterns for email and SSO shares.'), + .describe( + 'Allowed addresses or `@domain` patterns for email and SSO shares. Kept when omitted; enabling `email` or `sso` with an empty resulting list is a 400.' + ), }) .strict() @@ -498,7 +518,7 @@ export const v2UpdateFileContentBodySchema = z .string() .max(70_000_000, 'content is too large') .describe( - 'Complete replacement content for the file. The 70,000,000-character bound is a JSON-envelope guard, not the file-size limit: the decoded bytes must be at most 50 MiB, so a longer base64 payload is admitted here and then rejected with 413.' + 'Complete replacement content for the file. The 70,000,000-character bound guards the JSON envelope; the decoded bytes must be at most 50 MiB, and a longer base64 payload is rejected with `413`.' ), encoding: z .enum(['utf-8', 'base64']) @@ -531,6 +551,7 @@ export const v2ListFilesContract = defineRouteContract({ export const v2CreateFileContract = defineRouteContract({ method: 'POST', path: '/api/v2/files', + query: noInputSchema, body: v2CreateFileBodySchema, response: { mode: 'json', @@ -542,6 +563,7 @@ export const v2CreateFileContract = defineRouteContract({ export const v2CreateFileUploadContract = defineRouteContract({ method: 'POST', path: '/api/v2/files/uploads', + query: noInputSchema, body: v2CreateFileUploadBodySchema, response: { mode: 'json', schema: v2DataResponse(v2CreateFileUploadDataSchema), status: 201 }, }) @@ -598,6 +620,7 @@ export const v2GetFileContract = defineRouteContract({ export const v2RenameFileContract = defineRouteContract({ method: 'PATCH', path: '/api/v2/files/[fileId]', + query: noInputSchema, params: v2FileParamsSchema, body: v2RenameFileBodySchema, response: { @@ -620,6 +643,7 @@ export const v2DeleteFileContract = defineRouteContract({ export const v2RestoreFileContract = defineRouteContract({ method: 'POST', path: '/api/v2/files/[fileId]/restore', + query: noInputSchema, params: v2FileParamsSchema, body: v2RestoreFileBodySchema, response: { @@ -631,6 +655,7 @@ export const v2RestoreFileContract = defineRouteContract({ export const v2MoveFileItemsContract = defineRouteContract({ method: 'POST', path: '/api/v2/files/move', + query: noInputSchema, body: v2MoveFileItemsBodySchema, response: { mode: 'json', @@ -641,6 +666,7 @@ export const v2MoveFileItemsContract = defineRouteContract({ export const v2BulkDeleteFilesContract = defineRouteContract({ method: 'POST', path: '/api/v2/files/bulk-delete', + query: noInputSchema, body: v2BulkDeleteFilesBodySchema, response: { mode: 'json', @@ -672,6 +698,7 @@ export const v2GetFileShareContract = defineRouteContract({ export const v2UpsertFileShareContract = defineRouteContract({ method: 'PATCH', path: '/api/v2/files/[fileId]/share', + query: noInputSchema, params: v2FileParamsSchema, body: v2UpsertFileShareBodySchema, response: { @@ -683,6 +710,7 @@ export const v2UpsertFileShareContract = defineRouteContract({ export const v2UpdateFileContentContract = defineRouteContract({ method: 'PUT', path: '/api/v2/files/[fileId]/content', + query: noInputSchema, params: v2FileParamsSchema, body: v2UpdateFileContentBodySchema, response: { diff --git a/apps/sim/lib/api/contracts/v2/knowledge.ts b/apps/sim/lib/api/contracts/v2/knowledge.ts index 6e3078d2c37..85d8f794eaa 100644 --- a/apps/sim/lib/api/contracts/v2/knowledge.ts +++ b/apps/sim/lib/api/contracts/v2/knowledge.ts @@ -6,7 +6,7 @@ import { knowledgeDocumentParamsSchema, nullableWireDateSchema, } from '@/lib/api/contracts/knowledge/shared' -import { workspaceIdSchema } from '@/lib/api/contracts/primitives' +import { noInputSchema, workspaceIdSchema } from '@/lib/api/contracts/primitives' import { defineRouteContract } from '@/lib/api/contracts/types' import { v1ChunkingConfigSchema, @@ -17,6 +17,8 @@ import { v1SearchTagFilterSchema, } from '@/lib/api/contracts/v1/knowledge' import { + nameSortCollation, + V2_FOLDER_FILTER_MISS, v2CreateFolderBodySchema, v2CursorListResponse, v2DataResponse, @@ -39,7 +41,11 @@ import { v2UploadTransferSchema, } from '@/lib/api/contracts/v2/uploads' import { DEFAULT_CHUNKING_CONFIG } from '@/lib/knowledge/constants' -import { rerankerModelSchema } from '@/lib/knowledge/reranker-models' +import { + DEFAULT_RERANKER_MODEL, + rerankerModelSchema, + rerankerStatusSchema, +} from '@/lib/knowledge/reranker-models' import { knowledgeDocumentUploadMetadataSchema } from '@/lib/knowledge/upload-metadata' import { MAX_KNOWLEDGE_DOCUMENT_FILE_SIZE } from '@/lib/uploads/shared/types' @@ -393,6 +399,18 @@ export const v2KnowledgeSearchDataSchema = z .nonnegative() .describe('Number of results returned.') .meta({ examples: [4] }), + /** + * Required, not optional. Reranking degrades to vector ordering on a provider + * failure or an unconfigured credential, and that fallback was previously + * indistinguishable from a reranker that ran — same 200, same order, no + * `rerankerScore` on any result. A field a caller has to remember to look for + * would reproduce the same gap for anyone who does not. + */ + rerankerStatus: rerankerStatusSchema + .describe( + 'What the reranker did on this search. `applied` means it ordered the results, which carry `rerankerScore`. `unavailable` means it was attempted but could not complete, so results are in vector order with no `rerankerScore` — the search still succeeded, and is worth retrying. `skipped` means there was nothing to rank. `not_requested` means `rerankerEnabled` was absent or false.' + ) + .meta({ examples: ['applied'] }), }) .meta({ id: 'V2KnowledgeSearchData', @@ -402,9 +420,11 @@ export const v2KnowledgeSearchDataSchema = z export type V2KnowledgeSearchData = z.output /** Upload carries the workspace as a query param so auth runs before the multipart body is buffered. */ -export const v2UploadKnowledgeDocumentQuerySchema = z.object({ - workspaceId: workspaceIdSchema.describe('Workspace that owns the knowledge base.'), -}) +export const v2UploadKnowledgeDocumentQuerySchema = z + .object({ + workspaceId: workspaceIdSchema.describe('Workspace that owns the knowledge base.'), + }) + .strict() export type V2UploadKnowledgeDocumentQuery = z.output export const v2KnowledgeBaseParamsSchema = knowledgeBaseParamsSchema.extend({ @@ -560,7 +580,7 @@ export const v2ListKnowledgeBasesQuerySchema = z workspaceId: workspaceIdSchema.describe('Workspace whose knowledge bases should be listed.'), folderPath: v2FolderPathInputSchema .optional() - .describe('Restrict results to knowledge bases in this folder.'), + .describe(`Restrict results to knowledge bases in this folder. ${V2_FOLDER_FILTER_MISS}`), search: v2SearchSchema, ...v2SortFields(v2KnowledgeBaseSortFields, { sortBy: 'createdAt', sortOrder: 'asc' }), ...v2PaginationFields({ description: 'Maximum knowledge bases to return per page.' }), @@ -658,6 +678,7 @@ export const v2ListKnowledgeBasesContract = defineRouteContract({ export const v2CreateKnowledgeBaseContract = defineRouteContract({ method: 'POST', path: '/api/v2/knowledge', + query: noInputSchema, body: v2CreateKnowledgeBaseBodySchema, response: { mode: 'json', @@ -670,11 +691,13 @@ export const v2GetKnowledgeBaseContract = defineRouteContract({ method: 'GET', path: '/api/v2/knowledge/[id]', params: v2KnowledgeBaseParamsSchema, - query: v1KnowledgeWorkspaceQuerySchema.extend({ - workspaceId: v1KnowledgeWorkspaceQuerySchema.shape.workspaceId.describe( - 'Workspace that owns the knowledge base.' - ), - }), + query: v1KnowledgeWorkspaceQuerySchema + .extend({ + workspaceId: v1KnowledgeWorkspaceQuerySchema.shape.workspaceId.describe( + 'Workspace that owns the knowledge base.' + ), + }) + .strict(), response: { mode: 'json', schema: v2DataResponse(v2KnowledgeBaseSchema), @@ -688,6 +711,7 @@ export const v2GetKnowledgeBaseContract = defineRouteContract({ export const v2UpdateKnowledgeBaseContract = defineRouteContract({ method: 'PATCH', path: '/api/v2/knowledge/[id]', + query: noInputSchema, params: v2KnowledgeBaseParamsSchema, body: v2UpdateKnowledgeBaseBodySchema, response: { @@ -700,11 +724,13 @@ export const v2DeleteKnowledgeBaseContract = defineRouteContract({ method: 'DELETE', path: '/api/v2/knowledge/[id]', params: v2KnowledgeBaseParamsSchema, - query: v1KnowledgeWorkspaceQuerySchema.extend({ - workspaceId: v1KnowledgeWorkspaceQuerySchema.shape.workspaceId.describe( - 'Workspace that owns the knowledge base.' - ), - }), + query: v1KnowledgeWorkspaceQuerySchema + .extend({ + workspaceId: v1KnowledgeWorkspaceQuerySchema.shape.workspaceId.describe( + 'Workspace that owns the knowledge base.' + ), + }) + .strict(), response: { mode: 'json', schema: v2DataResponse(v2KnowledgeDeleteDataSchema), @@ -736,12 +762,13 @@ export const v2ListKnowledgeFoldersContract = defineRouteContract({ method: 'GET', path: '/api/v2/knowledge/folders', query: v2ListFoldersQuerySchema, - response: { mode: 'json', schema: v2CursorListResponse(v2FolderSchema) }, + response: { mode: 'json', schema: v2CursorListResponse(v2FolderSchema, { paged: false }) }, }) export const v2CreateKnowledgeFolderContract = defineRouteContract({ method: 'POST', path: '/api/v2/knowledge/folders', + query: noInputSchema, body: v2CreateFolderBodySchema, response: { mode: 'json', schema: v2DataResponse(v2FolderSchema), status: 201 }, }) @@ -749,6 +776,7 @@ export const v2CreateKnowledgeFolderContract = defineRouteContract({ export const v2RelocateKnowledgeFolderContract = defineRouteContract({ method: 'PATCH', path: '/api/v2/knowledge/folders', + query: noInputSchema, body: v2RelocateFolderBodySchema, response: { mode: 'json', schema: v2DataResponse(v2FolderSchema) }, }) @@ -782,52 +810,73 @@ export const v2KnowledgeSearchTagFilterSchema = v1SearchTagFilterSchema description: 'A structured tag filter applied to knowledge search.', }) -export const v2KnowledgeSearchBodySchema = v1KnowledgeSearchBodySchema.safeExtend({ - workspaceId: v1KnowledgeSearchBodySchema.shape.workspaceId.describe( - 'Workspace that owns the knowledge bases.' - ), - knowledgeBaseIds: v1KnowledgeSearchBodySchema.shape.knowledgeBaseIds - .describe('One knowledge base identifier or an array of up to 20 identifiers.') - .meta({ examples: [['7c9e6679-7425-40de-944b-e07fc1f90ae7']] }), - query: v1KnowledgeSearchBodySchema.shape.query - .describe('Natural-language query; required when tag filters are omitted.') - .meta({ examples: ['How do I reset my password?'] }), - topK: v1KnowledgeSearchBodySchema.shape.topK.describe( - 'Maximum number of search results to return. Must be a whole number between 1 and 100; the boundary schema only bounds the range, so a fractional value is admitted here and then rejected with 400 during search.' - ), - tagFilters: z - .array(v2KnowledgeSearchTagFilterSchema) - .optional() - .describe( - 'Structured tag filters. Supported across multiple knowledge bases, but each filtered tag must resolve to the same slot and field type in every knowledge base selected; a tag missing from one of them, or defined inconsistently across them, is rejected and those knowledge bases must be searched separately. A tag name defined in none of the selected knowledge bases is rejected, never ignored; list the available names with GET /api/v2/knowledge/{id}/tags.' +export const v2KnowledgeSearchBodySchema = v1KnowledgeSearchBodySchema + .safeExtend({ + workspaceId: v1KnowledgeSearchBodySchema.shape.workspaceId.describe( + 'Workspace that owns the knowledge bases.' ), - searchMode: v1KnowledgeSearchBodySchema.shape.searchMode.describe( - 'Retrieval strategy: vector is semantic-only, while hybrid also runs full-text search.' - ), - rerankerEnabled: z - .boolean() - .optional() - .describe( - 'Re-order retrieved chunks with a reranking model before truncating to `topK`. Ignored for a tag-only search, which has no query to rank against. Reranking is billed as an additional search unit.' + knowledgeBaseIds: v1KnowledgeSearchBodySchema.shape.knowledgeBaseIds + .describe('One knowledge base identifier or an array of up to 20 identifiers.') + .meta({ examples: [['7c9e6679-7425-40de-944b-e07fc1f90ae7']] }), + query: v1KnowledgeSearchBodySchema.shape.query + .describe('Natural-language query; required when tag filters are omitted.') + .meta({ examples: ['How do I reset my password?'] }), + topK: v1KnowledgeSearchBodySchema.shape.topK.describe( + 'Maximum number of search results to return. Must be a whole number between 1 and 100; the boundary schema only bounds the range, so a fractional value is admitted here and then rejected with 400 during search.' ), - rerankerModel: rerankerModelSchema - .optional() - .describe('Reranking model to use; required for reranking to run.'), - rerankerInputCount: z - .number() - .int('rerankerInputCount must be a whole number') - .min(1, 'rerankerInputCount must be at least 1') - .max(100, 'rerankerInputCount cannot exceed 100') - .optional() - .describe( - 'How many candidate chunks to retrieve before reranking. Defaults to four times `topK`, capped at 100. A larger pool costs more retrieval work but gives the reranker more to choose from.' + tagFilters: z + .array(v2KnowledgeSearchTagFilterSchema) + .optional() + .describe( + 'Structured tag filters. Each filtered tag must resolve to the same slot and field type in every knowledge base selected; one missing from any of them, or defined inconsistently across them, is rejected rather than ignored, and those knowledge bases must be searched separately. List the available names with `GET /api/v2/knowledge/{id}/tags`.' + ), + searchMode: v1KnowledgeSearchBodySchema.shape.searchMode.describe( + 'Retrieval strategy: vector is semantic-only, while hybrid also runs full-text search.' ), -}) + rerankerEnabled: z + .boolean() + .optional() + .describe( + 'Re-order retrieved chunks with a reranking model before truncating to `topK`. Ignored for a tag-only search, and billed as an additional search unit. Reranking is best-effort — a provider failure falls back to vector ordering, so check `rerankerStatus` on the response.' + ), + /** + * Defaulted, matching the internal search contract this one otherwise + * mirrors. Without it, `rerankerEnabled: true` on its own satisfied the + * schema, failed the use case's `input.rerankerModel` guard, and returned a + * 200 in plain vector order — while still paying for the four-times-`topK` + * candidate retrieval that reranking widens. The old description, "required + * for reranking to run", documented the trap instead of removing it. + */ + rerankerModel: rerankerModelSchema + .optional() + .default(DEFAULT_RERANKER_MODEL) + .describe( + `Reranking model to use when \`rerankerEnabled\` is true. Defaults to \`${DEFAULT_RERANKER_MODEL}\`.` + ), + rerankerInputCount: z + .number() + .int('rerankerInputCount must be a whole number') + .min(1, 'rerankerInputCount must be at least 1') + .max(100, 'rerankerInputCount cannot exceed 100') + .optional() + .describe( + 'How many candidate chunks to retrieve before reranking. Defaults to four times `topK`, capped at 100. A larger pool costs more retrieval work but gives the reranker more to choose from.' + ), + }) + /** + * Strict because the dropped keys are the billed ones. Zod strips what it + * does not declare, so a mis-cased `rerankerenabled` or `topk` returned 200 + * with reranking off and `topK` silently back at its default — the caller was + * charged for a search it did not configure and had no signal that its + * parameters never arrived. + */ + .strict() export type V2KnowledgeSearchBody = z.input export const v2SearchKnowledgeContract = defineRouteContract({ method: 'POST', path: '/api/v2/knowledge/search', + query: noInputSchema, body: v2KnowledgeSearchBodySchema, response: { mode: 'json', @@ -883,9 +932,15 @@ export function parseV2KnowledgeTagFiltersParam( } /** - * Document list query: the v1 search/filter/sort/limit shape with `offset` - * swapped for an opaque `cursor`. Total doc count is available as `docCount` on - * the knowledge base. + * Document list query: the v1 filter and sort shape, with `offset` swapped for + * an opaque `cursor` and with `limit`, `cursor`, and `search` taken from the + * shared v2 schemas rather than v1. Total doc count is available as `docCount` + * on the knowledge base. + * + * Sharing `search` is what closed the last gap: the v1 shape was an unbounded, + * empty-accepting string, so `?search=` answered 200 with the full page here + * while the sibling `GET /knowledge?search=` answered 400, and the term reached + * an unindexed filename `LIKE` scan with no length ceiling. */ export const v2ListKnowledgeDocumentsQuerySchema = v1ListKnowledgeDocumentsQuerySchema .omit({ offset: true }) @@ -893,20 +948,17 @@ export const v2ListKnowledgeDocumentsQuerySchema = v1ListKnowledgeDocumentsQuery workspaceId: v1ListKnowledgeDocumentsQuerySchema.shape.workspaceId.describe( 'Workspace that owns the knowledge base.' ), - limit: v1ListKnowledgeDocumentsQuerySchema.shape.limit.describe( - 'Maximum documents to return, between 1 and 100.' - ), - search: v1ListKnowledgeDocumentsQuerySchema.shape.search.describe( - 'Case-insensitive filename search.' + ...v2PaginationFields({ description: 'Maximum documents to return per page.' }), + search: v2SearchSchema.describe( + 'Case-insensitive substring match against the document filename.' ), enabledFilter: v1ListKnowledgeDocumentsQuerySchema.shape.enabledFilter.describe( 'Filter by whether documents are enabled for search.' ), sortBy: v1ListKnowledgeDocumentsQuerySchema.shape.sortBy.describe( - 'Document field used to sort results.' + `Field used to sort the result. ${nameSortCollation('filename')}` ), sortOrder: v1ListKnowledgeDocumentsQuerySchema.shape.sortOrder.describe('Sort direction.'), - cursor: z.string().min(1).optional().describe('Opaque cursor returned by the previous page.'), tagFilters: z .string() .optional() @@ -944,6 +996,7 @@ export const v2UploadKnowledgeDocumentContract = defineRouteContract({ export const v2CreateKnowledgeDocumentUploadContract = defineRouteContract({ method: 'POST', path: '/api/v2/knowledge/[id]/documents/uploads', + query: noInputSchema, params: v2KnowledgeBaseParamsSchema, body: v2CreateKnowledgeDocumentUploadBodySchema, response: { @@ -985,11 +1038,13 @@ export const v2GetKnowledgeDocumentContract = defineRouteContract({ method: 'GET', path: '/api/v2/knowledge/[id]/documents/[documentId]', params: v2KnowledgeDocumentParamsSchema, - query: v1KnowledgeWorkspaceQuerySchema.extend({ - workspaceId: v1KnowledgeWorkspaceQuerySchema.shape.workspaceId.describe( - 'Workspace that owns the knowledge base.' - ), - }), + query: v1KnowledgeWorkspaceQuerySchema + .extend({ + workspaceId: v1KnowledgeWorkspaceQuerySchema.shape.workspaceId.describe( + 'Workspace that owns the knowledge base.' + ), + }) + .strict(), response: { mode: 'json', schema: v2DataResponse(v2KnowledgeDocumentSchema), @@ -1043,7 +1098,7 @@ export const v2ListKnowledgeTagsContract = defineRouteContract({ .strict(), response: { mode: 'json', - schema: v2CursorListResponse(v2KnowledgeTagSchema), + schema: v2CursorListResponse(v2KnowledgeTagSchema, { paged: false }), }, }) @@ -1153,7 +1208,7 @@ export const v2UpdateKnowledgeDocumentBodySchema = z .literal(true) .optional() .describe( - 'Requeue the document for processing. Send it alone: no other field may accompany it.' + 'Requeue a failed or stuck document for processing. Send it alone — no other field may accompany it — and it answers with a queue acknowledgement rather than the document.' ), }) .strict() @@ -1216,6 +1271,7 @@ const v2UpdateKnowledgeDocumentDataSchema = z.union([ export const v2UpdateKnowledgeDocumentContract = defineRouteContract({ method: 'PATCH', path: '/api/v2/knowledge/[id]/documents/[documentId]', + query: noInputSchema, params: v2KnowledgeDocumentParamsSchema, body: v2UpdateKnowledgeDocumentBodySchema, response: { @@ -1318,6 +1374,7 @@ export const v2BulkKnowledgeDocumentsDataSchema = z export const v2BulkUpdateKnowledgeDocumentsContract = defineRouteContract({ method: 'PATCH', path: '/api/v2/knowledge/[id]/documents', + query: noInputSchema, params: v2KnowledgeBaseParamsSchema, body: v2BulkKnowledgeDocumentsBodySchema, response: { @@ -1330,11 +1387,13 @@ export const v2DeleteKnowledgeDocumentContract = defineRouteContract({ method: 'DELETE', path: '/api/v2/knowledge/[id]/documents/[documentId]', params: v2KnowledgeDocumentParamsSchema, - query: v1KnowledgeWorkspaceQuerySchema.extend({ - workspaceId: v1KnowledgeWorkspaceQuerySchema.shape.workspaceId.describe( - 'Workspace that owns the knowledge base.' - ), - }), + query: v1KnowledgeWorkspaceQuerySchema + .extend({ + workspaceId: v1KnowledgeWorkspaceQuerySchema.shape.workspaceId.describe( + 'Workspace that owns the knowledge base.' + ), + }) + .strict(), response: { mode: 'json', schema: v2DataResponse(v2KnowledgeDeleteDataSchema), diff --git a/apps/sim/lib/api/contracts/v2/logs.ts b/apps/sim/lib/api/contracts/v2/logs.ts index 97ab3e76e0d..58683c9e8c8 100644 --- a/apps/sim/lib/api/contracts/v2/logs.ts +++ b/apps/sim/lib/api/contracts/v2/logs.ts @@ -1,14 +1,21 @@ import { z } from 'zod' import { traceSpansSchema } from '@/lib/api/contracts/logs' -import { booleanQueryFlagSchema, workspaceIdSchema } from '@/lib/api/contracts/primitives' +import { + booleanQueryFlagSchema, + noInputSchema, + runIdSchema, + workspaceIdSchema, +} from '@/lib/api/contracts/primitives' import { defineRouteContract } from '@/lib/api/contracts/types' import { v1ListLogsQuerySchema } from '@/lib/api/contracts/v1/logs' import { + V2_FOLDER_FILTER_MISS, v2CursorListResponse, v2DataResponse, v2FolderPathInputSchema, v2FolderPathSchema, v2PaginationFields, + v2RunOrderSchema, v2RunWindowBoundSchema, v2TimestampSchema, } from '@/lib/api/contracts/v2/shared' @@ -45,7 +52,7 @@ const v2LogCostSchema = z export const v2LogStatusSchema = z .enum(PERSISTED_WORKFLOW_EXECUTION_STATUSES) .describe( - 'Current execution status, reported as persisted. `redacting` is transient while run output is scrubbed. `paused` is reported only when a resume attempt did not run to completion and the run is waiting to be resumed again. **This differs from the run resources for the same run:** `GET /api/v2/workflows/{id}/runs` and `GET /api/v2/workflows/{id}/runs/{runId}` additionally report `paused` for a run held at a human-in-the-loop pause point, which this field reports as `pending`. Use the run resources when the pause state matters.' + 'Current execution status, reported as persisted. `redacting` is transient while run output is scrubbed. `paused` is reported only when a resume attempt did not complete; a run held at a human-in-the-loop pause point reads `pending` here, and `paused` on the workflow run resources. Use those when the pause state matters.' ) /** Execution `files` is a per-run jsonb array of attachment metadata. */ @@ -75,7 +82,7 @@ const v2LogWorkflowStateSchema = z ) .nullable() .describe( - 'Workflow graph snapshot captured for the run, with credential values redacted: `oauth-input`, `password: true`, and table sub-block values are null; sensitive nested tool parameters and every parameter without authoritative codec metadata are null; and `{{VAR}}` references in non-opaque fields are preserved. Null when no snapshot is retained.' + 'Workflow graph snapshot captured for the run, or null when none is retained. Credential-bearing values are redacted to null: `oauth-input`, `password: true`, table sub-block values, sensitive nested tool parameters, and any parameter without authoritative codec metadata. `{{VAR}}` references in non-opaque fields are preserved.' ) const v2LogWorkflowSummarySchema = z.object({ @@ -150,7 +157,9 @@ export const v2LogDetailSchema = z description: z.string().nullable().describe('Workflow description, or null when unset.'), folderPath: v2FolderPathSchema .nullable() - .describe('Workflow folder path, or null when unavailable.'), + .describe( + 'Canonical folder path of the workflow, in the same form `folderPaths` accepts as a filter: `/` for a workflow at the workspace root. Null only when the path cannot be resolved — the folder has been deleted, or the workflow itself no longer exists.' + ), ownerEmail: z .email() .nullable() @@ -189,48 +198,103 @@ export const v2LogDetailSchema = z export type V2LogDetail = z.output export const v2LogParamsSchema = z.object({ - runId: z - .string() - .min(1, 'runId cannot be empty') - .describe('The unique run identifier shared by lifecycle and diagnostic resources.'), + runId: runIdSchema.describe('Unique workflow run identifier.'), }) +/** + * Upper bound of `workflow_execution_logs.total_duration_ms`, whose column is a + * Postgres `integer`. + * + * The same rule `DEPLOYMENT_VERSION_MAX` states for deployment versions: a + * comparison against an `integer` column is an `integer` comparison, so a bound + * outside int4 — or one carrying a fractional part — is not a filter that + * matches nothing, it is a value Postgres refuses to parse. `1.5`, + * `2147483648`, and `1e30` each reached the query as a bind parameter and came + * back as a 500 on a read the caller had every reason to believe was well + * formed. + */ +const V2_DURATION_MS_MAX = 2147483647 + +/** + * A duration bound, in the units and range its column can hold. + * + * Whole milliseconds rather than a coerced `number`, because the column is + * `integer`: publishing `number` invited exactly the fractional value Postgres + * cannot compare. Non-negative for the same reason the column is — a run cannot + * last less than no time — so a negative bound is a caller mistake rather than a + * filter that happens to match everything or nothing. + */ +function v2DurationBoundSchema( + field: 'minDurationMs' | 'maxDurationMs', + bound: 'Minimum' | 'Maximum' +) { + return z.coerce + .number() + .int(`${field} must be a whole number of milliseconds`) + .min(0, `${field} must not be negative`) + .max(V2_DURATION_MS_MAX, `${field} must be at most ${V2_DURATION_MS_MAX}`) + .describe( + `${bound} total execution duration in milliseconds. Whole milliseconds from 0 to ${V2_DURATION_MS_MAX}; the stored duration is a 32-bit integer, so a fractional or out-of-range bound is rejected.` + ) +} + +/** + * A comma-separated filter list, with an empty entry rejected rather than dropped. + * + * `folderPaths` already refused `/,` while its two siblings on the same operation + * silently discarded the empty entry, so one endpoint answered two ways to one + * mistake. Rejecting is the half that matches the surface-wide rule for a blank + * value (`V2_PARSE_DEFAULTS.rejectBlankQueryValues`): dropping it turns a + * malformed list into a narrower filter and reports nothing, which on a log + * search reads as "those runs do not exist". + */ +function v2CommaListSchema(field: 'workflowIds' | 'triggers', description: string) { + return z + .string() + .describe(description) + .refine((value) => value.split(',').every((entry) => entry.length > 0), { + error: `${field} must not contain an empty entry`, + }) +} + export const v2ListLogsQuerySchema = v1ListLogsQuerySchema .omit({ executionId: true, folderIds: true }) .extend({ workspaceId: workspaceIdSchema.describe('Workspace whose execution logs should be returned.'), - workflowIds: z.string().describe('Comma-separated workflow identifiers to include.').optional(), - triggers: z.string().describe('Comma-separated trigger types to include.').optional(), + workflowIds: v2CommaListSchema( + 'workflowIds', + 'Comma-separated workflow identifiers to include. An empty entry is rejected.' + ).optional(), + triggers: v2CommaListSchema( + 'triggers', + 'Comma-separated trigger types to include. An empty entry is rejected. The literal value `all` is a sentinel that disables this filter entirely, so a list containing it returns runs of every trigger type; no real trigger type is named `all`.' + ).optional(), level: z.enum(['info', 'error']).describe('Severity level to include.').optional(), startDate: v2RunWindowBoundSchema('startDate').optional(), endDate: v2RunWindowBoundSchema('endDate').optional(), - runId: z - .string() - .min(1, 'runId cannot be empty') - .describe('Exact run identifier to match.') - .optional(), - minDurationMs: z.coerce - .number() - .describe('Minimum total execution duration in milliseconds.') - .optional(), - maxDurationMs: z.coerce - .number() - .describe('Maximum total execution duration in milliseconds.') - .optional(), + runId: runIdSchema.describe('Exact run identifier to match.').optional(), + minDurationMs: v2DurationBoundSchema('minDurationMs', 'Minimum').optional(), + maxDurationMs: v2DurationBoundSchema('maxDurationMs', 'Maximum').optional(), minCost: z.coerce.number().describe('Minimum execution cost in USD.').optional(), maxCost: z.coerce.number().describe('Maximum execution cost in USD.').optional(), model: z.string().describe('AI model used during execution.').optional(), details: z .enum(['basic', 'full']) - .describe('Response detail level.') + .describe( + 'Response detail level. `full` adds the `workflow` summary to every item. `includeTraceSpans=true` and `includeFinalOutput=true` each imply `full`, so either one adds `workflow` even when `details=basic` is sent explicitly.' + ) .optional() .default('basic'), includeTraceSpans: booleanQueryFlagSchema - .describe('Whether to include block-level trace spans.') + .describe( + 'Whether to include block-level trace spans. Implies `details=full`. Spans are pruned on their own retention schedule, so a run whose spans have aged out returns `traceSpans: []` rather than an error.' + ) .optional() .default(false), includeFinalOutput: booleanQueryFlagSchema - .describe('Whether to include the final workflow output.') + .describe( + 'Whether to include the final workflow output. Implies `details=full`, so the `workflow` summary is present regardless of what `details` is set to.' + ) .optional() .default(false), ...v2PaginationFields({ @@ -247,17 +311,14 @@ export const v2ListLogsQuerySchema = v1ListLogsQuerySchema * would break every caller, while accepting `sortOrder` as an alias would * add a second spelling of one thing with undefined precedence when both * arrive — so the split is documented rather than papered over. + * + * Shared with `GET /workflows/{id}/runs` so the two spell the enum the same + * way in the generated specs. */ - order: z - .enum(['desc', 'asc']) - .describe( - 'Sort direction by execution start time. This operation deviates from the v2 `sortBy` + `sortOrder` convention: logs are sortable only by start time, so the direction is carried by this single `order` param and `sortBy`/`sortOrder` are not accepted.' - ) - .optional() - .default('desc'), + order: v2RunOrderSchema('execution'), folderPaths: z .string() - .describe('Comma-separated workflow folder paths to include.') + .describe(`Comma-separated workflow folder paths to include. ${V2_FOLDER_FILTER_MISS}`) .optional() .transform((value, ctx) => { if (value === undefined) return undefined @@ -311,6 +372,7 @@ export const v2ListLogsContract = defineRouteContract({ export const v2GetLogContract = defineRouteContract({ method: 'GET', path: '/api/v2/logs/[runId]', + query: noInputSchema, params: v2LogParamsSchema, response: { mode: 'json', diff --git a/apps/sim/lib/api/contracts/v2/mcp-servers.ts b/apps/sim/lib/api/contracts/v2/mcp-servers.ts index 46fdd9b03b5..2ce5c3369d0 100644 --- a/apps/sim/lib/api/contracts/v2/mcp-servers.ts +++ b/apps/sim/lib/api/contracts/v2/mcp-servers.ts @@ -2,6 +2,7 @@ import { z } from 'zod' import { mcpAuthTypeSchema, mcpServerSchema, mcpTransportSchema } from '@/lib/api/contracts/mcp' import { booleanQueryFlagSchema, + noInputSchema, nonEmptyIdSchema, workspaceIdSchema, } from '@/lib/api/contracts/primitives' @@ -60,7 +61,9 @@ const v2McpServerUrlSchema = z }, { error: 'url must be an absolute http or https URL' } ) - .describe('Absolute HTTP or HTTPS endpoint URL without `{{ENV_VAR}}` references.') + .describe( + 'Absolute HTTP or HTTPS endpoint URL without `{{ENV_VAR}}` references. It determines server identity and is immutable: delete and recreate the server to change endpoints.' + ) const v2McpServerHeadersSchema = z.record( z.string().min(1, 'Header names cannot be empty'), @@ -113,11 +116,16 @@ export const v2McpServerSchema = z enabled: mcpServerSchema.shape.enabled.describe( 'Whether the server tools are available to workflows.' ), + /** + * These three are written only by a real discovery. Registration stores a + * configuration without contacting the endpoint, so it leaves all three at + * their defaults rather than asserting a connection nothing has verified. + */ connectionStatus: mcpServerSchema.shape.connectionStatus.describe( - 'Result of the most recent connection attempt.' + 'Result of the most recent connection attempt. Registration and re-registration store a configuration without contacting the endpoint, so a server begins — and returns to — `disconnected` until a tool discovery runs.' ), lastError: mcpServerSchema.shape.lastError.describe( - 'Message from the most recent failed connection, or null when absent.' + 'Message from the most recent failed connection, or null when absent. A re-registration clears it, since the configuration it described no longer applies.' ), toolCount: mcpServerSchema.shape.toolCount.describe( 'Number of tools discovered on the server.' @@ -126,7 +134,7 @@ export const v2McpServerSchema = z 'ISO 8601 timestamp of the most recent tool-list refresh.' ), lastConnected: mcpServerSchema.shape.lastConnected.describe( - 'ISO 8601 timestamp of the most recent successful connection.' + 'ISO 8601 timestamp of the most recent successful connection. Absent until the server completes one; registering a server does not set it.' ), createdAt: mcpServerSchema.shape.createdAt.describe( 'ISO 8601 timestamp when the server was registered.' @@ -168,13 +176,15 @@ export const v2McpServerDeleteDataSchema = z export type V2McpServerDeleteData = z.output export const v2McpServerParamsSchema = z.object({ - id: nonEmptyIdSchema.describe('MCP server the operation acts on.'), + id: nonEmptyIdSchema.describe('Unique MCP server identifier.'), }) export type V2McpServerParams = z.output -export const v2McpServerWorkspaceQuerySchema = z.object({ - workspaceId: workspaceIdSchema.describe('Workspace that owns the MCP server.'), -}) +export const v2McpServerWorkspaceQuerySchema = z + .object({ + workspaceId: workspaceIdSchema.describe('Workspace that owns the MCP server.'), + }) + .strict() export type V2McpServerWorkspaceQuery = z.output export const v2McpServerSortFields = ['name', 'createdAt', 'updatedAt'] as const @@ -213,11 +223,16 @@ export const v2CreateMcpServerBodySchema = z url: v2McpServerUrlSchema, authType: mcpAuthTypeSchema .optional() - .describe('Authentication method. Sim detects it from the server when omitted.'), + .describe( + 'Authentication method. Applied server-side as `headers` when omitted; registration never contacts the server, so an omitted value is never detected from it.' + ) + .meta({ default: 'headers' }), /** Write-only. Reads expose `hasHeaders` and `headerNames` instead. */ headers: v2McpServerHeadersSchema .optional() - .describe('Write-only request headers sent to the server.') + .describe( + 'Write-only request headers sent to the server. Replaced wholesale rather than merged on update: sending this field drops every stored header it does not repeat.' + ) .meta({ writeOnly: true }), timeout: z .number() @@ -249,14 +264,18 @@ export const v2CreateMcpServerBodySchema = z .max(512, 'oauthClientId is too long') .nullable() .optional() - .describe('Pre-registered OAuth client identifier.'), + .describe( + 'Pre-registered OAuth client identifier. Changing it on update revokes the stored OAuth grant and forces reauthorization.' + ), /** Write-only. Reads expose `hasOauthClientSecret` instead. */ oauthClientSecret: z .string() .max(2048, 'oauthClientSecret is too long') .nullable() .optional() - .describe('Write-only pre-registered OAuth client secret.') + .describe( + 'Write-only pre-registered OAuth client secret. Sending it on update as null or a new value revokes the stored OAuth grant and forces reauthorization, as does switching away from OAuth authentication.' + ) .meta({ writeOnly: true }), }) .strict() @@ -288,7 +307,18 @@ export type V2UpdateMcpServerBody = z.input * declaration gives an OpenAI function's `parameters`. `type` can be pinned to * the literal because the MCP SDK's own `ListToolsResult` schema already rejects * a tool whose `inputSchema.type` is anything else, so a server cannot make this - * response fail its own validation. + * response fail its own validation. `properties` and `required` are pinned on + * the same ground, and the SDK is the stricter of the two on `properties`. + * + * The rule that keeps this safe is that a key may only be declared here when the + * SDK declares it at least as tightly. `description` may not: the SDK's + * `ToolSchema.inputSchema` does not declare it at all, so its own + * `.catchall(z.unknown())` admits any value — including the JSON `null` a Python + * server emits for an absent description. Declaring it `z.string().optional()` + * made the builder's outbound `.parse()` throw on a payload the protocol + * permits, and discovery answered a bare 500. It is left to the `catchall` + * below, which publishes as `additionalProperties` and passes the value through + * untouched. */ const v2McpToolInputSchema = z .object({ @@ -303,7 +333,6 @@ const v2McpToolInputSchema = z .array(z.string().describe('Name of a required argument.')) .optional() .describe('Names of the arguments the tool requires.'), - description: z.string().optional().describe('Description of the argument object.'), }) .catchall(z.unknown().describe('Additional JSON Schema keyword reported by the server.')) .describe("JSON Schema for the tool's arguments, as reported by the server.") @@ -331,7 +360,7 @@ export const v2ListMcpServerToolsQuerySchema = v2McpServerWorkspaceQuerySchema .optional() .default(false) .describe( - 'Bypass the cached tool list and reconnect to the server. Slower, and the only way to pick up a tool added since the last refresh.' + 'Bypass the short-lived per-workspace tool cache and reconnect under your own credentials. A cached result reflects whichever workspace member last ran discovery, so this is the only way to pick up a tool added since then; it costs a live round trip.' ), }) .strict() @@ -357,6 +386,7 @@ export const v2ListMcpServersContract = defineRouteContract({ export const v2CreateMcpServerContract = defineRouteContract({ method: 'POST', path: '/api/v2/mcp-servers', + query: noInputSchema, body: v2CreateMcpServerBodySchema, response: { mode: 'json', @@ -379,6 +409,7 @@ export const v2GetMcpServerContract = defineRouteContract({ export const v2UpdateMcpServerContract = defineRouteContract({ method: 'PATCH', path: '/api/v2/mcp-servers/[id]', + query: noInputSchema, params: v2McpServerParamsSchema, body: v2UpdateMcpServerBodySchema, response: { @@ -411,6 +442,6 @@ export const v2ListMcpServerToolsContract = defineRouteContract({ query: v2ListMcpServerToolsQuerySchema, response: { mode: 'json', - schema: v2CursorListResponse(v2McpToolSchema), + schema: v2CursorListResponse(v2McpToolSchema, { paged: false }), }, }) diff --git a/apps/sim/lib/api/contracts/v2/openapi/billing.ts b/apps/sim/lib/api/contracts/v2/openapi/billing.ts index d282d7625cd..556a6875f45 100644 --- a/apps/sim/lib/api/contracts/v2/openapi/billing.ts +++ b/apps/sim/lib/api/contracts/v2/openapi/billing.ts @@ -7,11 +7,11 @@ import { ERROR_RESPONSES, type ErrorResponseId, RATE_LIMIT_HEADERS, + RESOURCE_ERRORS, V2_API_KEY_SECURITY, V2_API_KEY_SECURITY_SCHEMES, V2_COMMON_HEADERS, V2_ERROR_SCHEMA, - WORKSPACE_ERRORS, } from '@/lib/api/contracts/v2/openapi/shared' import { defineOpenApiDocument, @@ -80,8 +80,8 @@ const routes = [ operationId: 'getBillingStatus', summary: 'Get Billing Status', description: - "Return the current plan, billing standing, credit allowance, and storage quota. `credits` and `storage` report the payer's pooled allowances and are null unless the caller can manage that payer's billing; they are always null for a workspace API key. Billing history lives at `GET /api/v2/billing/logs`. Without a Stripe subscription — notably on the free plan — there is no real billing period: `period` is the open interval 1970-01-01 to 9999-12-31 and `credits.used` is lifetime consumption, not consumption since a period start.", - errors: [...WORKSPACE_ERRORS, 'NotFound'], + "Return the current plan, billing standing, credit allowance, and storage quota. `credits` and `storage` report the payer's pooled allowances and are null unless the caller can manage that payer's billing; they are always null for a workspace API key. Billing history lives at `GET /api/v2/billing/logs`.", + errors: RESOURCE_ERRORS, success: { description: 'The current billing and storage status.' }, }), { @@ -106,8 +106,8 @@ const routes = [ operationId: 'listBillingLogs', summary: 'List Billing Logs', description: - 'List the credit-denominated billing ledger with source filtering and opaque cursor pagination. `period` defaults to `30d`, so an unqualified request covers only the last 30 days: paginating to `nextCursor: null` exhausts that window, not the whole ledger. Pass `period=all` for full history, or `period=custom` with `startDate` and `endDate` for a specific range.', - errors: [...WORKSPACE_ERRORS, 'NotFound'], + 'List the credit-denominated billing ledger with source filtering and opaque cursor pagination. `period` defaults to `30d`, so an unqualified request covers only the last 30 days: paginating to `nextCursor: null` exhausts that window, not the whole ledger. An inverted custom window is a 400 rather than an empty page.', + errors: RESOURCE_ERRORS, success: { description: 'A page of usage events.' }, }), { diff --git a/apps/sim/lib/api/contracts/v2/openapi/files-audit.ts b/apps/sim/lib/api/contracts/v2/openapi/files-audit.ts index 5a875a657f8..32074b7ed7e 100644 --- a/apps/sim/lib/api/contracts/v2/openapi/files-audit.ts +++ b/apps/sim/lib/api/contracts/v2/openapi/files-audit.ts @@ -26,7 +26,10 @@ import { documentedSchema, ERROR_RESPONSES, type ErrorResponseId, + FOLDER_TREE_TOO_LARGE, FULL_SET_LIST, + HEAD_MIRRORS_GET, + HEAD_OMITS_PAYLOAD_HEADERS, RATE_LIMIT_HEADERS, RESOURCE_CONFLICT_ERRORS, RESOURCE_ERRORS, @@ -35,7 +38,8 @@ import { V2_COMMON_HEADERS, V2_ERROR_SCHEMA, WORKSPACE_API_KEY_DENIED, - WORKSPACE_API_KEY_DENIED_AS_NOT_FOUND, + WORKSPACE_ERRORS, + withRequestBodyErrors, } from '@/lib/api/contracts/v2/openapi/shared' import { defineOpenApiDocument, @@ -115,15 +119,14 @@ function auditOperation( } } -const routes = [ +const declaredRoutes = [ defineOpenApiRoute( v2ListFilesContract, filesOperation({ operationId: 'listFiles', summary: 'List Files', - description: - 'List workspace files with search, sorting, folder filtering, and opaque cursor pagination. Defaults to active files; pass `scope=archived` to page over soft-deleted files, whose `deletedAt` is non-null and which `POST /files/{fileId}/restore` can bring back.', - errors: RESOURCE_ERRORS, + description: `List workspace files with search, sorting, folder filtering, and opaque cursor pagination. Defaults to active files; pass \`scope=archived\` to page over soft-deleted ones. ${FOLDER_TREE_TOO_LARGE}`, + errors: [...RESOURCE_ERRORS, 'PayloadTooLarge'], success: { description: 'A page of workspace files.' }, }), { @@ -153,6 +156,7 @@ const routes = [ success: { description: 'The created file.' }, }), { + query: v2CreateFileContract.query, body: documentedSchema( v2CreateFileContract.body, 'CreateFileRequest', @@ -182,10 +186,11 @@ const routes = [ summary: 'Create File Upload', description: 'Create a resumable upload session and receive either a signed PUT URL or multipart instructions.', - errors: RESOURCE_ERRORS, + errors: [...RESOURCE_ERRORS, 'PayloadTooLarge'], success: { description: 'The created upload session and transfer instructions.' }, }), { + query: v2CreateFileUploadContract.query, body: documentedSchema( v2CreateFileUploadContract.body, 'CreateFileUploadRequest', @@ -250,7 +255,7 @@ const routes = [ operationId: 'createFileUploadPartUrls', summary: 'Create File Upload Part URLs', description: 'Create signed URLs for a bounded set of multipart upload part numbers.', - errors: RESOURCE_CONFLICT_ERRORS, + errors: [...RESOURCE_CONFLICT_ERRORS, 'PayloadTooLarge'], success: { description: 'Signed URLs for the requested upload parts.' }, }), { @@ -329,8 +334,7 @@ const routes = [ filesOperation({ operationId: 'downloadFile', summary: 'Download File', - description: - 'Download the current file bytes from a workspace. A generated document is served as its compiled artifact, so it returns `409` while that artifact is still compiling and `413` if it renders past the size ceiling.', + description: `Download the current file bytes from a workspace. A generated document is served as its compiled artifact, so it answers \`409\` while that artifact is still compiling and \`413\` if it renders past the size ceiling. Downloading records an audit event, so it is not a safe read. ${HEAD_MIRRORS_GET} ${HEAD_OMITS_PAYLOAD_HEADERS}`, errors: [...RESOURCE_CONFLICT_ERRORS, 'PayloadTooLarge'], success: { description: 'The file bytes.', @@ -359,8 +363,8 @@ const routes = [ operationId: 'deleteFile', summary: 'Delete File', description: - 'Archive a workspace file. This is a soft delete: the row is retained with a deletion timestamp, the file stops appearing in the default listing and is no longer readable through the API, and its stored bytes are never removed. List archived files with `GET /files?scope=archived` and reverse the delete with `POST /files/{fileId}/restore`.', - errors: RESOURCE_CONFLICT_ERRORS, + 'Archive a workspace file. This is a soft delete: the file stops appearing in the default listing and is no longer readable through the API, but its stored bytes are never removed. Archiving an already-archived file is a `404`, not a no-op. List archived files with `GET /files?scope=archived`, and reverse the delete with `POST /files/{fileId}/restore`.', + errors: RESOURCE_ERRORS, success: { description: 'Deletion confirmation.' }, }), { @@ -390,10 +394,11 @@ const routes = [ operationId: 'renameFile', summary: 'Rename File', description: 'Rename a workspace file without changing its containing folder.', - errors: RESOURCE_CONFLICT_ERRORS, + errors: [...RESOURCE_CONFLICT_ERRORS, 'PayloadTooLarge'], success: { description: 'The renamed file.' }, }), { + query: v2RenameFileContract.query, params: documentedSchema( v2RenameFileContract.params, 'RenameFileParams', @@ -427,11 +432,12 @@ const routes = [ operationId: 'restoreFile', summary: 'Restore File', description: - 'Reverse a soft delete and return the file to the workspace. Restore is not a pure undo: the file comes back at the workspace root regardless of the folder it was deleted from, and it gains a `_restored` suffix when another file at the root already holds its name — so read `folderPath` and `name` off the response rather than assuming the pre-delete values. Restoring a file that is already active is a no-op that returns that file, so a retry is safe. Returns 400 when the workspace itself has been archived, and 409 when no free restore name could be found.', - errors: RESOURCE_CONFLICT_ERRORS, + 'Reverse a soft delete and return the file to the workspace. Not a pure undo: the file comes back at the workspace root, and gains a `_restored` suffix when another file there already holds its name, so read `folderPath` and `name` off the response. Restoring an already-active file returns it unchanged, so a retry is safe. An archived workspace is a `400`, and a name the restore could not free is a `409`.', + errors: [...RESOURCE_CONFLICT_ERRORS, 'PayloadTooLarge'], success: { description: 'The file as it exists after the restore.' }, }), { + query: v2RestoreFileContract.query, params: documentedSchema( v2RestoreFileContract.params, 'RestoreFileParams', @@ -494,7 +500,7 @@ const routes = [ operationId: 'listAuditLogs', summary: 'List Audit Logs', description: `List an organization audit trail with filters and opaque cursor pagination. Requires an Enterprise subscription and organization admin or owner access. ${WORKSPACE_API_KEY_DENIED}`, - errors: RESOURCE_ERRORS, + errors: WORKSPACE_ERRORS, success: { description: 'A page of audit-log entries.' }, }), { @@ -550,10 +556,11 @@ const routes = [ operationId: 'moveFileItems', summary: 'Move Files', description: 'Move up to 1,000 files to a canonical folder path or the workspace root.', - errors: RESOURCE_CONFLICT_ERRORS, + errors: [...RESOURCE_CONFLICT_ERRORS, 'PayloadTooLarge'], success: { description: 'Count of moved files.' }, }), { + query: v2MoveFileItemsContract.query, body: documentedSchema( v2MoveFileItemsContract.body, 'MoveFileItemsRequest', @@ -613,11 +620,12 @@ const routes = [ filesOperation({ operationId: 'upsertFileShare', summary: 'Enable or Disable File Share', - description: `Create or partially update a server-tokenized public share. Only isActive is required, and an omitted authType keeps the stored auth mode. What happens to password and allowedEmails depends on the resulting mode, because enabling a share always rewrites the credentials the chosen mode does not use: 'public' clears the stored password and empties allowedEmails; 'password' keeps the stored password when password is omitted but empties allowedEmails; 'email' and 'sso' clear the stored password and keep the stored allowedEmails when the field is omitted. Only disabling with isActive false preserves the whole access configuration untouched — it also retains the token, so re-enabling restores the share as it was. Two enabling combinations are rejected outright with a 400 instead of being partially applied: 'password' when neither a password is supplied nor one is already stored, and 'email' or 'sso' when the resulting allowedEmails would be empty because none was supplied and none is stored. On a file that has never been shared there is nothing stored to fall back on, so enabling any mode other than 'public' must carry its credential in the same request. ${WORKSPACE_API_KEY_DENIED_AS_NOT_FOUND}`, - errors: RESOURCE_ERRORS, + description: `Create or partially update a server-tokenized public share. Only \`isActive\` is required; each other field states what enabling a mode does to it. Enabling any mode other than \`public\` on a file that has never been shared must carry its credential in the same request. ${WORKSPACE_API_KEY_DENIED}`, + errors: [...RESOURCE_ERRORS, 'PayloadTooLarge'], success: { description: 'The updated file share.' }, }), { + query: v2UpsertFileShareContract.query, params: documentedSchema( v2UpsertFileShareContract.params, 'UpsertFileShareParams', @@ -660,6 +668,7 @@ const routes = [ success: { description: 'The updated file.' }, }), { + query: v2UpdateFileContentContract.query, params: documentedSchema( v2UpdateFileContentContract.params, 'UpdateFileContentParams', @@ -693,10 +702,11 @@ const routes = [ operationId: 'bulkDeleteFiles', summary: 'Delete Files', description: 'Delete up to 1,000 workspace files in one operation.', - errors: RESOURCE_CONFLICT_ERRORS, + errors: [...RESOURCE_CONFLICT_ERRORS, 'PayloadTooLarge'], success: { description: 'Count of deleted files.' }, }), { + query: v2BulkDeleteFilesContract.query, body: documentedSchema( v2BulkDeleteFilesContract.body, 'BulkDeleteFilesRequest', @@ -748,10 +758,11 @@ const routes = [ operationId: 'createFilesFolder', summary: 'Create Folder', description: 'Create a canonical folder path in a workspace.', - errors: RESOURCE_CONFLICT_ERRORS, + errors: [...RESOURCE_CONFLICT_ERRORS, 'PayloadTooLarge'], success: { description: 'The created folder.' }, }), { + query: v2CreateFileFolderContract.query, body: documentedSchema( v2CreateFileFolderContract.body, 'CreateFileFolderRequest', @@ -778,10 +789,11 @@ const routes = [ operationId: 'relocateFilesFolder', summary: 'Rename or Move Folder', description: 'Rename or move a folder and atomically rewrite descendant canonical paths.', - errors: RESOURCE_CONFLICT_ERRORS, + errors: [...RESOURCE_CONFLICT_ERRORS, 'PayloadTooLarge'], success: { description: 'The relocated folder.' }, }), { + query: v2RelocateFileFolderContract.query, body: documentedSchema( v2RelocateFileFolderContract.body, 'RelocateFileFolderRequest', @@ -829,12 +841,14 @@ const routes = [ ), ] as const +const routes = declaredRoutes.map(withRequestBodyErrors) + export const filesAuditOpenApiDocument = defineOpenApiDocument({ output: 'apps/docs/openapi-v2-files-audit.json', info: { title: 'Sim API v2 — Files & Audit Logs', description: - 'Version 2 of the Sim REST API for workspace files and organization audit logs. Lists use opaque cursors, and rate-limit state is returned in response headers. Download File streams raw bytes as `application/octet-stream`; every other response uses the canonical v2 data, cursor-list, or error envelope.', + 'Version 2 of the Sim REST API for workspace files, resumable uploads, public shares, and organization audit logs.', version: '2.0.0', contact: { name: 'Sim Support', diff --git a/apps/sim/lib/api/contracts/v2/openapi/head-not-safe.test.ts b/apps/sim/lib/api/contracts/v2/openapi/head-not-safe.test.ts new file mode 100644 index 00000000000..a125096027e --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/openapi/head-not-safe.test.ts @@ -0,0 +1,89 @@ +/** + * @vitest-environment node + */ +import { existsSync, readFileSync } from 'node:fs' +import path from 'node:path' +import { describe, expect, it } from 'vitest' +import { filesAuditOpenApiDocument } from '@/lib/api/contracts/v2/openapi/files-audit' +import { resourcesOpenApiDocument } from '@/lib/api/contracts/v2/openapi/resources' +import { HEAD_MIRRORS_GET, HEAD_OMITS_PAYLOAD_HEADERS } from '@/lib/api/contracts/v2/openapi/shared' +import { tablesOpenApiDocument } from '@/lib/api/contracts/v2/openapi/tables' +import { workflowsOpenApiDocument } from '@/lib/api/contracts/v2/openapi/workflows' +import type { OpenApiDocumentDefinition, OpenApiRouteDefinition } from '@/lib/api/openapi/types' + +const APP_ROOT = path.resolve(import.meta.dirname, '../../../../../app') + +const DOCUMENTS: readonly OpenApiDocumentDefinition[] = [ + filesAuditOpenApiDocument, + resourcesOpenApiDocument, + tablesOpenApiDocument, + workflowsOpenApiDocument, +] + +/** + * Reads the route module's source rather than importing it: importing an + * `app/api/**` route pulls the whole server graph into a contract-layer test, + * and `headSafe` is a literal on the builder call, so the source is where it is + * unambiguously visible. + */ +function declaresHeadNotSafe(route: OpenApiRouteDefinition): boolean { + if (route.contract.method !== 'GET') return false + const file = path.join(APP_ROOT, route.contract.path, 'route.ts') + if (!existsSync(file)) return false + return readFileSync(file, 'utf8') + .split('\n') + .some((line) => line.trim() === 'headSafe: false,') +} + +/** + * What a `HEAD` answers on a `headSafe: false` route is a security claim, and a + * published description that drifts from the builder tells callers a probe on a + * forbidden or nonexistent id is a `200`. That is worth a standing check rather + * than a one-time correction. + */ +describe('operations whose GET declares headSafe: false', () => { + it('document that HEAD is authorized exactly as GET is', () => { + const routes = DOCUMENTS.flatMap((document) => document.routes).filter(declaresHeadNotSafe) + + expect(routes.length).toBeGreaterThan(0) + expect( + routes + .filter((route) => !route.operation.description.includes(HEAD_MIRRORS_GET)) + .map((route) => `${route.operation.operationId} (GET ${route.contract.path})`) + ).toEqual([]) + }) + + /** + * The `200` documents `Content-Type`, `Content-Length`, and + * `Content-Disposition`, and the `HEAD` short-circuit answers before the read + * that produces any of them — so all three are absent on a `HEAD` the spec's + * own success object appears to promise them for. A caller sizing a download + * from `Content-Length` gets nothing back and no way to have known that. + */ + it('says a HEAD omits the payload headers its 200 documents', () => { + /** Rate-limit headers ARE emitted on a HEAD; only these three are not. */ + const payloadHeaders = ['Content-Type', 'Content-Length', 'Content-Disposition'] + const withPayloadHeaders = DOCUMENTS.flatMap((document) => document.routes) + .filter(declaresHeadNotSafe) + .filter((route) => + (route.operation.success?.headers ?? []).some((header) => payloadHeaders.includes(header)) + ) + + expect(withPayloadHeaders.length).toBeGreaterThan(0) + expect( + withPayloadHeaders + .filter((route) => !route.operation.description.includes(HEAD_OMITS_PAYLOAD_HEADERS)) + .map((route) => `${route.operation.operationId} (GET ${route.contract.path})`) + ).toEqual([]) + }) + + it('never claims a HEAD is answered without an authorization check', () => { + const claiming = DOCUMENTS.flatMap((document) => document.routes) + .filter((route) => + /`?HEAD`? request is answered with an empty/i.test(route.operation.description) + ) + .map((route) => route.operation.operationId) + + expect(claiming).toEqual([]) + }) +}) diff --git a/apps/sim/lib/api/contracts/v2/openapi/knowledge.ts b/apps/sim/lib/api/contracts/v2/openapi/knowledge.ts index 9ab7f2aa4d3..fa8e2e06453 100644 --- a/apps/sim/lib/api/contracts/v2/openapi/knowledge.ts +++ b/apps/sim/lib/api/contracts/v2/openapi/knowledge.ts @@ -37,6 +37,7 @@ import { V2_ERROR_SCHEMA, WORKSPACE_API_KEY_DENIED, WORKSPACE_ERRORS, + withRequestBodyErrors, } from '@/lib/api/contracts/v2/openapi/shared' import { defineOpenApiDocument, @@ -64,13 +65,13 @@ function knowledgeOperation( } } -const routes = [ +const declaredRoutes = [ defineOpenApiRoute( v2ListKnowledgeBasesContract, knowledgeOperation({ operationId: 'listKnowledgeBases', summary: 'List Knowledge Bases', - description: `List knowledge bases in a workspace with folder filtering, search, and sorting. Paginate with \`limit\` and \`cursor\`, stopping when \`nextCursor\` is null. An unknown \`folderPath\` is a 404. ${FOLDER_TREE_TOO_LARGE}`, + description: `List knowledge bases in a workspace with folder filtering, search, sorting, and opaque cursor pagination. ${FOLDER_TREE_TOO_LARGE}`, errors: [...RESOURCE_ERRORS, 'PayloadTooLarge'], success: { description: 'A page of knowledge bases.' }, }), @@ -94,11 +95,12 @@ const routes = [ knowledgeOperation({ operationId: 'createKnowledgeBase', summary: 'Create Knowledge Base', - description: `Create a knowledge base in a workspace with optional folder placement and chunking configuration. An unknown \`folderPath\` is a 404. ${FOLDER_TREE_TOO_LARGE}`, + description: `Create a knowledge base in a workspace with optional folder placement and chunking configuration. An unknown \`folderPath\` is a \`404\`. ${FOLDER_TREE_TOO_LARGE}`, errors: [...RESOURCE_CONFLICT_ERRORS, 'PayloadTooLarge'], success: { description: 'The created knowledge base.' }, }), { + query: v2CreateKnowledgeBaseContract.query, body: documentedSchema( v2CreateKnowledgeBaseContract.body, 'CreateKnowledgeBaseRequest', @@ -154,6 +156,7 @@ const routes = [ success: { description: 'The updated knowledge base.' }, }), { + query: v2UpdateKnowledgeBaseContract.query, params: documentedSchema( v2UpdateKnowledgeBaseContract.params, 'UpdateKnowledgeBaseParams', @@ -211,11 +214,12 @@ const routes = [ operationId: 'searchKnowledge', summary: 'Search Knowledge', description: - 'Search one or more knowledge bases with semantic vector retrieval, optional hybrid full-text retrieval, and structured tag filters. Set `rerankerEnabled` with a `rerankerModel` to re-order the retrieved chunks with a reranking model before truncating to `topK`; reranked results carry a `rerankerScore` and are ordered by it, and reranking is billed as an additional search unit. Every result names the `knowledgeBaseId` it came from. The request body is capped at 2 MiB; a larger body is a 413.', + 'Search one or more knowledge bases with semantic vector retrieval, optional hybrid full-text retrieval, and structured tag filters. Every result names the `knowledgeBaseId` it came from. A request body over 2 MiB is a `413`.', errors: [...WORKSPACE_ERRORS, 'UsageLimitExceeded', 'NotFound', 'PayloadTooLarge'], success: { description: 'Matching document chunks ordered by relevance.' }, }), { + query: v2SearchKnowledgeContract.query, body: documentedSchema( v2SearchKnowledgeContract.body, 'SearchKnowledgeRequest', @@ -243,7 +247,7 @@ const routes = [ knowledgeOperation({ operationId: 'listKnowledgeTags', summary: 'List Tags', - description: `List the knowledge base's tag vocabulary: each tag's display name, the slot it is stored in, and its field type. Display names are what tag filters and the tag values on document reads use; slots are what document writes set. Every slot listed here is writable, in its declared type: \`tag1\`..\`tag7\` take a string, \`number1\`..\`number5\` a number, \`date1\`..\`date2\` a \`YYYY-MM-DD\` string, and \`boolean1\`..\`boolean3\` a boolean. The vocabulary is bounded by the fixed slot table. ${FULL_SET_LIST}`, + description: `List the knowledge base's tag vocabulary: each tag's display name, the slot it is stored in, and its field type. Filters and document reads use display names; document writes address slots. ${FULL_SET_LIST}`, errors: RESOURCE_ERRORS, success: { description: 'The knowledge base tag vocabulary.' }, }), @@ -274,7 +278,7 @@ const routes = [ operationId: 'listKnowledgeDocuments', summary: 'List Documents', description: - 'List documents in a knowledge base with filename search, state filtering, tag filtering, sorting, and opaque cursor pagination. Each document carries its tag values keyed by tag display name; resolve those names to write slots with `GET /api/v2/knowledge/{id}/tags`.', + 'List documents in a knowledge base with filename search, state filtering, tag filtering, sorting, and opaque cursor pagination. Tag values are keyed by display name; resolve those to write slots with `GET /api/v2/knowledge/{id}/tags`.', errors: RESOURCE_ERRORS, success: { description: 'A page of knowledge documents.' }, }), @@ -304,11 +308,12 @@ const routes = [ knowledgeOperation({ operationId: 'bulkUpdateKnowledgeDocuments', summary: 'Bulk Enable or Disable Documents', - description: `Enable or disable many documents in one request, either by identifier (up to 100) or, with \`selectAll\`, every document in the knowledge base optionally narrowed by \`enabledFilter\`. Disabling keeps a document indexed but excludes it from search. Bulk delete is deliberately not offered: the bulk path records no audit entries, so deletions go through \`DELETE /api/v2/knowledge/{id}/documents/{documentId}\`, which audits each one. An identifier request echoes the documents it changed in \`documentIds\`; a \`selectAll\` request omits that field because the selection is unbounded, and reports \`updatedCount\` alone. ${WORKSPACE_API_KEY_DENIED}`, - errors: RESOURCE_ERRORS, + description: `Enable or disable many documents in one request, either by identifier or, with \`selectAll\`, every document in the knowledge base. Bulk delete is not offered; delete documents one at a time with \`DELETE /api/v2/knowledge/{id}/documents/{documentId}\`. ${WORKSPACE_API_KEY_DENIED}`, + errors: [...RESOURCE_ERRORS, 'PayloadTooLarge'], success: { description: 'The number and identifiers of the documents that changed.' }, }), { + query: v2BulkUpdateKnowledgeDocumentsContract.query, params: documentedSchema( v2BulkUpdateKnowledgeDocumentsContract.params, 'BulkUpdateKnowledgeDocumentsParams', @@ -399,6 +404,7 @@ const routes = [ success: { description: 'The created upload session and transfer instructions.' }, }), { + query: v2CreateKnowledgeDocumentUploadContract.query, params: documentedSchema( v2CreateKnowledgeDocumentUploadContract.params, 'CreateKnowledgeDocumentUploadParams', @@ -469,7 +475,7 @@ const routes = [ operationId: 'createKnowledgeDocumentUploadPartUrls', summary: 'Create Document Upload Part URLs', description: 'Issue short-lived signed PUT URLs for up to 100 multipart part numbers.', - errors: RESOURCE_CONFLICT_ERRORS, + errors: [...RESOURCE_CONFLICT_ERRORS, 'PayloadTooLarge'], success: { description: 'Signed URLs for the requested upload parts.' }, }), { @@ -578,11 +584,12 @@ const routes = [ knowledgeOperation({ operationId: 'updateKnowledgeDocument', summary: 'Update Document', - description: `Rename a document, enable or disable it for search, set any of its 17 tag slots, or requeue it for processing. A tag slot takes its declared type — a string for \`tag1\`..\`tag7\`, a number for \`number1\`..\`number5\`, a \`YYYY-MM-DD\` string for \`date1\`..\`date2\`, a boolean for \`boolean1\`..\`boolean3\` — and a value that is not valid for the slot is a \`400\` rather than a silently cleared tag. Resolve a display name to its slot with \`GET /api/v2/knowledge/{id}/tags\`. Absent fields are unchanged. Only caller-owned fields are accepted: derived indexing state (\`chunkCount\`, \`tokenCount\`, \`characterCount\`, \`processingStatus\`, \`processingError\`) is written by the processing pipeline and cannot be asserted here. \`retryProcessing: true\` re-queues a failed or stuck document and must be sent on its own — it runs instead of, not alongside, the field updates — and it answers with a queue acknowledgement rather than the document. Otherwise the updated document is returned; it omits the connector provenance the detail read carries, so re-read with GET when that is needed. ${WORKSPACE_API_KEY_DENIED}`, - errors: RESOURCE_ERRORS, + description: `Rename a document, enable or disable it for search, set any of its 17 tag slots, or requeue it for processing. Absent fields are unchanged, and derived indexing state is read-only. Resolve a tag display name to its slot with \`GET /api/v2/knowledge/{id}/tags\`. The returned document omits the connector provenance the detail read carries. ${WORKSPACE_API_KEY_DENIED}`, + errors: [...RESOURCE_ERRORS, 'PayloadTooLarge'], success: { description: 'The updated document, or the requeue acknowledgement.' }, }), { + query: v2UpdateKnowledgeDocumentContract.query, params: documentedSchema( v2UpdateKnowledgeDocumentContract.params, 'UpdateKnowledgeDocumentParams', @@ -610,7 +617,7 @@ const routes = [ operationId: 'deleteKnowledgeDocument', summary: 'Delete Document', description: - 'Remove one document from a knowledge base. What that means depends on the document. A directly uploaded document is deleted outright along with its indexed chunks. A connector-backed document is instead excluded: its row survives, marked excluded and disabled so it stops being searchable and a later connector sync does not re-add it, and its embeddings are not deleted. Either way the document no longer appears in listings or search results.', + 'Remove one document from a knowledge base. An uploaded document is deleted outright with its indexed chunks. A connector-backed document is instead excluded — its row and embeddings survive, but it stops being searchable and a later sync does not re-add it. Either way it no longer appears in listings or search results.', errors: RESOURCE_ERRORS, success: { description: 'Knowledge document deletion acknowledgement.' }, }), @@ -655,7 +662,7 @@ const routes = [ v2ListKnowledgeFoldersContract.response.schema, 'V2KnowledgeFolderListResponse', 'Knowledge folder list response', - 'A cursor-paginated page of knowledge-base folders.' + 'The whole bounded set of knowledge-base folders, in one page.' ), } ), @@ -669,6 +676,7 @@ const routes = [ success: { description: 'The created knowledge-base folder.' }, }), { + query: v2CreateKnowledgeFolderContract.query, body: documentedSchema( v2CreateKnowledgeFolderContract.body, 'CreateKnowledgeFolderRequest', @@ -694,6 +702,7 @@ const routes = [ success: { description: 'The relocated knowledge-base folder.' }, }), { + query: v2RelocateKnowledgeFolderContract.query, body: documentedSchema( v2RelocateKnowledgeFolderContract.body, 'RelocateKnowledgeFolderRequest', @@ -741,6 +750,8 @@ const routes = [ ), ] as const +const routes = declaredRoutes.map(withRequestBodyErrors) + export const knowledgeOpenApiDocument = defineOpenApiDocument({ output: 'apps/docs/openapi-v2-knowledge.json', info: { diff --git a/apps/sim/lib/api/contracts/v2/openapi/logs.ts b/apps/sim/lib/api/contracts/v2/openapi/logs.ts index fb473759d8f..ee9eeb4053b 100644 --- a/apps/sim/lib/api/contracts/v2/openapi/logs.ts +++ b/apps/sim/lib/api/contracts/v2/openapi/logs.ts @@ -4,11 +4,12 @@ import { ERROR_RESPONSES, type ErrorResponseId, RATE_LIMIT_HEADERS, + RESOURCE_ERRORS, + RUN_RETENTION, V2_API_KEY_SECURITY, V2_API_KEY_SECURITY_SCHEMES, V2_COMMON_HEADERS, V2_ERROR_SCHEMA, - WORKSPACE_ERRORS, } from '@/lib/api/contracts/v2/openapi/shared' import { defineOpenApiDocument, @@ -94,9 +95,8 @@ const routes = [ logsOperation({ operationId: 'listLogs', summary: 'List Logs', - description: - 'List workflow execution logs for a workspace with filters, selectable detail, and opaque cursor pagination. This list predates the shared sort convention: it has no `sortBy` (the sort column is fixed to execution start time) and spells the direction `order` rather than `sortOrder`. Trace spans are stored separately from the log row and are pruned on their own retention schedule: `includeTraceSpans=true` on a run whose stored spans have aged out returns `traceSpans: []` rather than an error, so an empty array does not mean the run recorded no spans.', - errors: [...WORKSPACE_ERRORS, 'NotFound'], + description: `List workflow execution logs for a workspace with filters, selectable detail, and opaque cursor pagination. ${RUN_RETENTION}`, + errors: RESOURCE_ERRORS, success: { description: 'A page of execution logs matching the filters.' }, }), { @@ -121,11 +121,12 @@ const routes = [ operationId: 'getLog', summary: 'Get Log', description: - 'Retrieve the diagnostic representation of a run, including its workflow snapshot, trace spans, final output, and cost. The returned `workflowState` snapshot has credential values redacted: OAuth credential references and secret (`password`) sub-block values are null, while `{{VAR}}` environment-variable references are preserved so consecutive snapshots stay diffable. Trace spans are stored separately from the log row and are pruned on their own retention schedule: a run whose stored spans have aged out returns `traceSpans: []` rather than an error, so an empty array does not mean the run recorded no spans.', - errors: [...WORKSPACE_ERRORS, 'NotFound'], + 'Retrieve the diagnostic representation of a run, including its workflow snapshot, trace spans, final output, and cost. Trace spans are pruned on their own retention schedule, so an empty `traceSpans` array does not mean the run recorded none.', + errors: RESOURCE_ERRORS, success: { description: 'The requested diagnostic log representation.' }, }), { + query: v2GetLogContract.query, params: documentedSchema( v2GetLogContract.params, 'GetLogParams', diff --git a/apps/sim/lib/api/contracts/v2/openapi/resources.ts b/apps/sim/lib/api/contracts/v2/openapi/resources.ts index 9a527057188..a87fa3f3294 100644 --- a/apps/sim/lib/api/contracts/v2/openapi/resources.ts +++ b/apps/sim/lib/api/contracts/v2/openapi/resources.ts @@ -19,14 +19,16 @@ import { ERROR_RESPONSES, type ErrorResponseId, FULL_SET_LIST, + HEAD_MIRRORS_GET, RATE_LIMIT_HEADERS, RESOURCE_CONFLICT_ERRORS, + RESOURCE_ERRORS, V2_API_KEY_SECURITY, V2_API_KEY_SECURITY_SCHEMES, V2_COMMON_HEADERS, V2_ERROR_SCHEMA, WORKSPACE_API_KEY_DENIED, - WORKSPACE_ERRORS, + withRequestBodyErrors, } from '@/lib/api/contracts/v2/openapi/shared' import { v2DeleteSecretContract, @@ -93,6 +95,16 @@ const MCP_SERVER_EXAMPLE = { hasOauthClientSecret: false, } as const +/** + * What registration actually returns, as distinct from {@link MCP_SERVER_EXAMPLE}, + * which shows a server a discovery has already reached. Reusing the discovered + * example on the create response advertised a connection the call does not make. + */ +const MCP_SERVER_REGISTERED_EXAMPLE = (() => { + const { lastToolsRefresh: _refresh, lastConnected: _connected, ...rest } = MCP_SERVER_EXAMPLE + return { ...rest, connectionStatus: 'disconnected', toolCount: 0 } as const +})() + const MCP_TOOL_EXAMPLE = { name: 'search_docs', description: 'Search the internal documentation', @@ -201,7 +213,7 @@ function resourceOperation( } } -const routes = [ +const declaredRoutes = [ defineOpenApiRoute( v2GetWorkspaceContract, resourceOperation('Workspaces', { @@ -209,10 +221,11 @@ const routes = [ summary: 'Get Workspace', description: 'Return public metadata for one accessible workspace. Governance identities, billing identities, and internal membership identifiers are intentionally omitted.', - errors: [...WORKSPACE_ERRORS, 'NotFound'], + errors: RESOURCE_ERRORS, success: { description: 'Public workspace metadata.' }, }), { + query: v2GetWorkspaceContract.query, params: documentedSchema( v2GetWorkspaceContract.params, 'GetWorkspaceParams', @@ -235,7 +248,7 @@ const routes = [ summary: 'List Workspace Members', description: "List the workspace's effective members ordered by email. Explicit workspace grants and inherited organization-administrator grants are merged; internal membership and billing identities are omitted.", - errors: [...WORKSPACE_ERRORS, 'NotFound'], + errors: RESOURCE_ERRORS, success: { description: 'An email-ordered page of effective workspace members.' }, }), { @@ -266,8 +279,8 @@ const routes = [ operationId: 'listMcpServers', summary: 'List MCP Servers', description: - 'List MCP servers registered in a workspace. Request-header values and OAuth client secrets are never returned. Nothing caps how many servers a workspace registers, so this list is paginated: paginate with `limit` and `cursor`, stopping when `nextCursor` is null. `connectionStatus`, `toolCount`, `lastError`, and `lastToolsRefresh` describe the most recent tool discovery and stay at their registration defaults until one runs — call `GET /api/v2/mcp-servers/{id}/tools` to run it.', - errors: [...WORKSPACE_ERRORS, 'NotFound'], + 'List MCP servers registered in a workspace. Request-header values and OAuth client secrets are never returned. The discovery fields stay at their registration defaults until `GET /api/v2/mcp-servers/{id}/tools` runs a discovery.', + errors: RESOURCE_ERRORS, success: { description: 'MCP servers registered in the workspace.' }, }), { @@ -292,11 +305,12 @@ const routes = [ operationId: 'createMcpServer', summary: 'Create MCP Server', description: - 'Register an MCP server in a workspace. The endpoint URL determines server identity, must be absolute HTTP or HTTPS, and cannot contain environment-variable references. Header values and OAuth client secrets are write-only. `transport`, `timeout`, `retries`, and `enabled` are applied server-side when omitted; the effective values are in the response.', - errors: [...WORKSPACE_ERRORS, 'NotFound', 'Conflict'], + 'Register an MCP server in a workspace. The endpoint URL is the server identity, so a URL already registered here is a `409` — reconfigure that server with `PATCH /api/v2/mcp-servers/{id}` instead. Registration never connects to the endpoint: the server comes back `disconnected` and stays unavailable until `GET /api/v2/mcp-servers/{id}/tools` succeeds.', + errors: RESOURCE_CONFLICT_ERRORS, success: { description: 'The MCP server was registered.' }, }), { + query: v2CreateMcpServerContract.query, body: documentedSchema( v2CreateMcpServerContract.body, 'CreateMcpServerRequest', @@ -317,7 +331,7 @@ const routes = [ 'CreateMcpServerResponse', 'Create MCP server response', 'The registered MCP server without write-only credentials.', - [{ data: MCP_SERVER_EXAMPLE }] + [{ data: MCP_SERVER_REGISTERED_EXAMPLE }] ), } ), @@ -328,7 +342,7 @@ const routes = [ summary: 'Get MCP Server', description: 'Fetch one MCP server by identifier. Request-header values and OAuth client secrets are never returned.', - errors: [...WORKSPACE_ERRORS, 'NotFound'], + errors: RESOURCE_ERRORS, success: { description: 'The MCP server.' }, }), { @@ -359,11 +373,12 @@ const routes = [ operationId: 'updateMcpServer', summary: 'Update MCP Server', description: - 'Update the supplied MCP server fields. The URL is immutable because it determines server identity; delete and recreate the server to change endpoints. Two fields do not follow the omitted-fields-are-retained rule. `headers` is replaced wholesale rather than merged: sending it drops every stored header it does not repeat, and the only way to keep a header is to resend it. Changing `oauthClientId`, or sending `oauthClientSecret` as null or a new value, revokes the stored OAuth grant and forces reauthorization; switching away from OAuth authentication revokes it too.', - errors: [...WORKSPACE_ERRORS, 'NotFound'], + 'Update the supplied MCP server fields. Omitted fields are retained, except where a field says otherwise. Any change that invalidates authentication revokes the stored OAuth grant, resets `connectionStatus` to `disconnected`, and clears `lastConnected` and `lastError`, so the server must be rediscovered.', + errors: RESOURCE_ERRORS, success: { description: 'The updated MCP server.' }, }), { + query: v2UpdateMcpServerContract.query, params: documentedSchema( v2UpdateMcpServerContract.params, 'UpdateMcpServerParams', @@ -393,7 +408,7 @@ const routes = [ summary: 'Delete MCP Server', description: "Remove an MCP server and revoke its OAuth tokens. Workflows retain blocks that referenced the server's tools, but those tools can no longer be called.", - errors: [...WORKSPACE_ERRORS, 'NotFound'], + errors: RESOURCE_ERRORS, success: { description: 'The MCP server was deleted.' }, }), { @@ -423,7 +438,7 @@ const routes = [ resourceOperation('MCP Servers', { operationId: 'listMcpServerTools', summary: 'List MCP Server Tools', - description: `Connect to a registered MCP server and return the tools it exposes. Unlike most reads this one has side effects: it opens a live connection to the third-party server and writes \`connectionStatus\`, \`toolCount\`, \`lastError\`, and \`lastToolsRefresh\` on the server resource, so registering a server and then calling this completes onboarding without opening the Sim UI. Because the pass is not a safe read, a \`HEAD\` request is answered with an empty \`200\` without connecting or writing, so it reports only that the endpoint exists and the caller is authorized. Results are served from a short-lived per-workspace cache, so an uncached call reflects whichever workspace member last ran discovery; pass \`refresh=true\` to reconnect under your own credentials and pick up tools added since the last pass, at the cost of a live round trip to the server. The set is bounded by discovery itself — at most 1,000 tools and 5 MB of tool payload per server. ${FULL_SET_LIST} An unreachable, slow, or cooling-down server is a \`503\`; a server whose stored OAuth grant no longer works is a \`409\` with \`error.details.code\` \`MCP_SERVER_REAUTHORIZATION_REQUIRED\`, meaning the registration is intact but a human must reauthorize it in Sim — your API key is fine and re-issuing it changes nothing. ${WORKSPACE_API_KEY_DENIED} Discovery resolves the calling user's own OAuth credentials for the server, which a workspace key cannot supply — so a workspace key that can register a server cannot list its tools.`, + description: `Connect to a registered MCP server and return the tools it exposes. This read has side effects: it opens a live connection to the third-party server and writes \`connectionStatus\`, \`toolCount\`, \`lastError\`, and \`lastToolsRefresh\`. ${HEAD_MIRRORS_GET} Discovery is bounded at 1,000 tools and 5 MB of tool payload per server. ${FULL_SET_LIST} An unreachable, slow, or cooling-down server is a \`503\`; a stored OAuth grant that no longer works is a \`409\` with \`error.details.code\` \`MCP_SERVER_REAUTHORIZATION_REQUIRED\`, which only a human reauthorizing in Sim can clear. ${WORKSPACE_API_KEY_DENIED}`, errors: RESOURCE_CONFLICT_ERRORS, success: { description: 'Tools exposed by the MCP server.' }, }), @@ -455,8 +470,8 @@ const routes = [ operationId: 'listSkills', summary: 'List Skills', description: - 'List workspace and built-in skills. Built-ins are marked read-only. The list omits skill bodies; fetch one skill to read its content. Paginate with `limit` and `cursor`, stopping when `nextCursor` is null.', - errors: [...WORKSPACE_ERRORS, 'NotFound'], + 'List workspace and built-in skills with opaque cursor pagination. Built-ins are marked read-only. The list omits skill bodies; fetch one skill to read its content.', + errors: RESOURCE_ERRORS, success: { description: 'Skills available in the workspace.' }, }), { @@ -480,12 +495,12 @@ const routes = [ resourceOperation('Skills', { operationId: 'createSkill', summary: 'Create Skill', - description: - 'Create one skill in a workspace. Its kebab-case name must be unique and cannot be reserved by a built-in skill. Note that a workspace API key may create a skill but may not later update or delete it.', - errors: [...WORKSPACE_ERRORS, 'NotFound', 'Conflict'], + description: `Create one skill in a workspace. Its kebab-case name must be unique and cannot be reserved by a built-in skill. ${WORKSPACE_API_KEY_DENIED}`, + errors: RESOURCE_CONFLICT_ERRORS, success: { description: 'The skill was created.' }, }), { + query: v2CreateSkillContract.query, body: documentedSchema( v2CreateSkillContract.body, 'CreateSkillRequest', @@ -516,7 +531,7 @@ const routes = [ summary: 'Get Skill', description: 'Fetch one workspace or built-in skill, including its full content. Built-in skills are marked read-only.', - errors: [...WORKSPACE_ERRORS, 'NotFound'], + errors: RESOURCE_ERRORS, success: { description: 'The skill.' }, }), { @@ -547,10 +562,11 @@ const routes = [ operationId: 'updateSkill', summary: 'Update Skill', description: `Update the supplied fields on a workspace skill. Omitted fields retain their stored values. Built-in skills are read-only. ${WORKSPACE_API_KEY_DENIED}`, - errors: [...WORKSPACE_ERRORS, 'NotFound', 'Conflict'], + errors: RESOURCE_CONFLICT_ERRORS, success: { description: 'The updated skill.' }, }), { + query: v2UpdateSkillContract.query, params: documentedSchema( v2UpdateSkillContract.params, 'UpdateSkillParams', @@ -579,7 +595,7 @@ const routes = [ operationId: 'deleteSkill', summary: 'Delete Skill', description: `Delete a workspace skill. Built-in skills are read-only and cannot be deleted. ${WORKSPACE_API_KEY_DENIED}`, - errors: [...WORKSPACE_ERRORS, 'NotFound'], + errors: RESOURCE_ERRORS, success: { description: 'The skill was deleted.' }, }), { @@ -610,8 +626,8 @@ const routes = [ operationId: 'listCustomTools', summary: 'List Custom Tools', description: - 'List code-backed custom tools defined in a workspace. Legacy personal tools are excluded. Paginate with `limit` and `cursor`, stopping when `nextCursor` is null.', - errors: [...WORKSPACE_ERRORS, 'NotFound'], + 'List code-backed custom tools defined in a workspace, with opaque cursor pagination. Legacy personal tools are excluded.', + errors: RESOURCE_ERRORS, success: { description: 'Custom tools defined in the workspace.' }, }), { @@ -637,10 +653,11 @@ const routes = [ summary: 'Create Custom Tool', description: 'Create a code-backed custom tool in a workspace. Its title must be unique because tools resolve by title at call time.', - errors: [...WORKSPACE_ERRORS, 'NotFound', 'Conflict'], + errors: RESOURCE_CONFLICT_ERRORS, success: { description: 'The custom tool was created.' }, }), { + query: v2CreateCustomToolContract.query, body: documentedSchema( v2CreateCustomToolContract.body, 'CreateCustomToolRequest', @@ -670,7 +687,7 @@ const routes = [ operationId: 'getCustomTool', summary: 'Get Custom Tool', description: 'Fetch one custom tool by identifier, scoped to its workspace.', - errors: [...WORKSPACE_ERRORS, 'NotFound'], + errors: RESOURCE_ERRORS, success: { description: 'The custom tool.' }, }), { @@ -702,10 +719,11 @@ const routes = [ summary: 'Update Custom Tool', description: 'Update the supplied custom tool fields. Omitted fields retain their stored values, and titles must remain unique within the workspace.', - errors: [...WORKSPACE_ERRORS, 'NotFound', 'Conflict'], + errors: RESOURCE_CONFLICT_ERRORS, success: { description: 'The updated custom tool.' }, }), { + query: v2UpdateCustomToolContract.query, params: documentedSchema( v2UpdateCustomToolContract.params, 'UpdateCustomToolParams', @@ -735,7 +753,7 @@ const routes = [ summary: 'Delete Custom Tool', description: 'Delete a custom tool. Agent blocks retain their configuration but can no longer call the deleted tool.', - errors: [...WORKSPACE_ERRORS, 'NotFound'], + errors: RESOURCE_ERRORS, success: { description: 'The custom tool was deleted.' }, }), { @@ -766,8 +784,8 @@ const routes = [ operationId: 'listCredentials', summary: 'List Credentials', description: - 'List OAuth and service-account connections visible to the caller. Secret material is never returned. Credential mutations and single-resource reads are intentionally not exposed. Paginate with `limit` and `cursor`, stopping when `nextCursor` is null.', - errors: [...WORKSPACE_ERRORS, 'NotFound'], + 'List OAuth and service-account connections visible to the caller. Secret material is never returned. Credential mutations and single-resource reads are not exposed.', + errors: RESOURCE_ERRORS, success: { description: 'Credentials visible to the caller.' }, }), { @@ -791,8 +809,8 @@ const routes = [ resourceOperation('Secrets', { operationId: 'listSecrets', summary: 'List Secrets', - description: `List workspace and caller-owned personal secret metadata. Only names, scope, role, and timestamps are returned; secret values are never read or returned. Paginate with \`limit\` and \`cursor\`, stopping when \`nextCursor\` is null. ${WORKSPACE_API_KEY_DENIED}`, - errors: [...WORKSPACE_ERRORS, 'NotFound'], + description: `List workspace and caller-owned personal secret metadata with opaque cursor pagination. Only names, scope, role, and timestamps are returned; secret values are never returned. ${WORKSPACE_API_KEY_DENIED}`, + errors: RESOURCE_ERRORS, success: { description: 'Secret metadata visible to the caller.' }, }), { @@ -817,7 +835,7 @@ const routes = [ operationId: 'setSecret', summary: 'Set Secret', description: `Create or replace a workspace or caller-owned personal secret. The value is encrypted at rest, is write-only, and is never included in the response. ${WORKSPACE_API_KEY_DENIED}`, - errors: [...WORKSPACE_ERRORS, 'NotFound'], + errors: RESOURCE_ERRORS, success: { byStatus: { 200: { description: 'The existing secret value was replaced.' }, @@ -826,6 +844,7 @@ const routes = [ }, }), { + query: v2SetSecretContract.query, params: documentedSchema( v2SetSecretContract.params, 'SetSecretParams', @@ -860,7 +879,7 @@ const routes = [ operationId: 'deleteSecret', summary: 'Delete Secret', description: `Delete a workspace or caller-owned personal secret without reading or returning its stored value. ${WORKSPACE_API_KEY_DENIED}`, - errors: [...WORKSPACE_ERRORS, 'NotFound'], + errors: RESOURCE_ERRORS, success: { description: 'The secret was deleted.' }, }), { @@ -895,6 +914,8 @@ const routes = [ ), ] as const +const routes = declaredRoutes.map(withRequestBodyErrors) + export const resourcesOpenApiDocument = defineOpenApiDocument({ output: 'apps/docs/openapi-v2-resources.json', info: { diff --git a/apps/sim/lib/api/contracts/v2/openapi/shared.ts b/apps/sim/lib/api/contracts/v2/openapi/shared.ts index cb376ef7039..74804f1053a 100644 --- a/apps/sim/lib/api/contracts/v2/openapi/shared.ts +++ b/apps/sim/lib/api/contracts/v2/openapi/shared.ts @@ -3,12 +3,9 @@ import { v2ErrorResponseSchema } from '@/lib/api/contracts/v2/shared' import type { OpenApiErrorResponse, OpenApiHeader, + OpenApiRouteDefinition, OpenApiSecurityScheme, } from '@/lib/api/openapi/types' -import { - FORBIDDEN_DETAIL_CODE_DESCRIPTIONS, - FORBIDDEN_DETAIL_CODES, -} from '@/lib/core/application/forbidden' export const RATE_LIMIT_HEADERS = [ 'X-RateLimit-Limit', @@ -34,24 +31,26 @@ export const WORKSPACE_ERRORS = [ * 403 a caller can do something about names its cause in `error.details.code`. * * The wording is deliberately "where the cause is one a caller can act on" - * rather than "always". Nine domain refusals still throw a bare - * `OrchestrationError('forbidden', …)` and reach the wire without a code — - * `GET /api/v2/billing/status` with a personal key against a workspace that - * disallows them is one. Reparenting those onto `ForbiddenOperationError` is - * worth doing, but one of them is a cross-tenant refusal that belongs in the - * codeless class and would change its status, so it is a deliberate change - * rather than a sweep. Until then this description must not over-claim. + * rather than "always", and it must stay that way. The billing, secret, table- + * quota, credential-list, and public-sharing refusals have been reparented onto + * `ForbiddenOperationError` (the one cross-tenant refusal among them became a + * concealed `404` instead, which is a status change rather than a code), but a + * handful of domain refusals still throw a bare + * `OrchestrationError('forbidden', …)` and reach the wire with no code — the + * knowledge-base file-ownership guard deliberately, others because nothing in + * the closed set fits them yet. Do not restate this as "every 403 names its + * cause": the audit that produced these codes found the claim false, and it will + * be false again the moment a domain adds a refusal without one. */ -const FORBIDDEN_DESCRIPTION = [ - 'The caller lacks the rights this operation requires. Where the cause is one a caller can act on, `error.details.code` names it, drawn from a closed set:', - ...FORBIDDEN_DETAIL_CODES.map( - (code) => `- \`${code}\` — ${FORBIDDEN_DETAIL_CODE_DESCRIPTIONS[code]}` - ), - 'A resource in a workspace the caller cannot reach at all answers `404`, not `403`, so absence and denial are indistinguishable to a caller who was never entitled to tell them apart.', -].join('\n') +const FORBIDDEN_DESCRIPTION = + 'The caller lacks the rights this operation requires. When the cause is one a caller can act on, `error.details.code` names it. A resource in a workspace the caller cannot reach at all answers `404` instead, so absence and denial are indistinguishable.' export const ERROR_RESPONSES = { - BadRequest: { status: 400, description: 'The request is invalid.' }, + BadRequest: { + status: 400, + description: + 'The request is invalid. This includes a query parameter sent with no value (`?limit=`, `?search=`), which is rejected rather than read as zero, empty, or the parameter default — omit the parameter instead.', + }, Unauthorized: { status: 401, description: 'The API key is missing or invalid.' }, UsageLimitExceeded: { status: 402, @@ -63,14 +62,13 @@ export const ERROR_RESPONSES = { RunIdConflict: { status: 409, description: - 'The run cannot be started. Two causes share this status, distinguished by `error.details.code`: `RUN_ID_CONFLICT` when the supplied `X-Run-Id` is already associated with a different request, and `CALL_CHAIN_DEPTH_EXCEEDED` when the incoming `X-Sim-Via` chain has already reached the maximum workflow-to-workflow call depth.', + 'The run cannot be started, for one of two causes named by `error.details.code`: `RUN_ID_CONFLICT` when the supplied `X-Run-Id` is already claimed, and `CALL_CHAIN_DEPTH_EXCEEDED` when the incoming `X-Sim-Via` chain has reached the maximum workflow-to-workflow call depth.', headers: ['X-Run-Id'], }, - Gone: { status: 410, description: 'The requested generated resource has expired.' }, PayloadTooLarge: { status: 413, description: - 'The request, or a resource collection it must materialize, exceeds the allowed size. Besides an oversized request body, this covers a generated artifact that renders past the download ceiling and a workspace folder tree too large to load in full.', + 'The request, or a resource collection it must materialize, exceeds the allowed size: an oversized request body, a generated artifact past the download ceiling, or a workspace folder tree too large to load in full.', }, UnsupportedMediaType: { status: 415, @@ -82,15 +80,34 @@ export const ERROR_RESPONSES = { description: 'The caller exceeded the request rate limit.', headers: ['Retry-After'], }, + /** + * Published on exactly one operation, and deliberately not on the rest. + * + * Every v2 JSON route can *emit* a 499: `defineV2JsonRoute` renders an + * aborted request as `CLIENT_CLOSED_REQUEST`. But a 499 is written to a socket + * the caller has already closed, so no conforming client ever reads it — it is + * an observability record for Sim's own logs and its proxies, not a response + * an SDK can branch on. Publishing it on every operation would add a branch to + * every generated client that can never be taken. + * + * `POST /workflows/{id}/execute` is the exception because there an abort + * leaves *residue*: the run may keep going and bill, so the response carries + * `error.details.runId` for the caller to reconcile against once it reconnects. + * That is caller-actionable information about state that outlives the + * connection, which is what makes it worth documenting. Anywhere else an abort + * leaves nothing behind to reconcile. Publish a 499 on a new operation only + * when the same is true of it. + */ ClientClosedRequest: { status: 499, - description: 'The client closed the connection before the response was produced.', + description: + 'The client closed the connection before the response was produced. An abort can leave the run going, so `error.details.runId` carries the run id — reconcile against the runs resource rather than starting another run.', }, InternalError: { status: 500, description: 'An unexpected server error occurred.' }, ServiceUnavailable: { status: 503, description: - 'A required service is temporarily unavailable. The condition is transient, so the response normally carries `Retry-After` with the number of seconds to wait; treat that value as a floor and add jitter before retrying. One case deliberately omits the header: when `error.details.code` is `ASYNC_ENQUEUE_AMBIGUOUS`, the run may already have started, so retrying could start and bill a second run. Reconcile against the returned run id instead of retrying.', + 'A required service is temporarily unavailable. `Retry-After` carries the seconds to wait; treat it as a floor and add jitter. The header is omitted when `error.details.code` is `ASYNC_ENQUEUE_AMBIGUOUS`, because the run may already have started — reconcile against the returned run id instead of retrying.', headers: ['Retry-After'], }, } as const satisfies Readonly> @@ -136,6 +153,27 @@ export const RESOURCE_MUTATION_ERRORS = [ 'Locked', ] as const satisfies readonly ErrorResponseId[] +/** + * Adds the `413` a body-carrying operation can emit. + * + * `parseRequest` reads the JSON body under `DEFAULT_MAX_JSON_BODY_BYTES` before + * schema validation, and the v2 builders supply + * `V2_PARSE_DEFAULTS.payloadTooLargeResponse`, so an oversized body is a real + * `413` on any route whose contract declares one — and a status a caller can + * receive but the spec omits is an unhandled branch in every generated client. + * + * Derived from the contract rather than chosen per operation, so a new body + * route cannot forget it. One-directional: it never removes a `413` from a + * bodyless read, several of which publish one for the folder-tree ceiling. + */ +export function withRequestBodyErrors(route: OpenApiRouteDefinition): OpenApiRouteDefinition { + if (!route.contract.body || route.operation.errors.includes('PayloadTooLarge')) return route + return { + ...route, + operation: { ...route.operation, errors: [...route.operation.errors, 'PayloadTooLarge'] }, + } +} + export const V2_API_KEY_SECURITY = [{ apiKey: [] }] as const export const V2_API_KEY_SECURITY_SCHEMES = { @@ -144,7 +182,7 @@ export const V2_API_KEY_SECURITY_SCHEMES = { in: 'header', name: 'X-API-Key', description: - 'Your Sim API key, personal or workspace-scoped. Generate one under Settings > API Keys. Operations that reject workspace keys say so in their own description.', + 'Your Sim API key, personal or workspace-scoped. Generate one under Settings, then API Keys. Operations that reject workspace keys say so in their own description.', }, } as const satisfies Readonly> @@ -157,19 +195,40 @@ export const V2_API_KEY_SECURITY_SCHEMES = { * rendering one back do not need this sentence: the shared `413` response * description already covers them. */ -export const FOLDER_TREE_TOO_LARGE = - 'A workspace whose folder tree exceeds 10,000 folders is a 413, because the response needs the whole tree to render folder paths.' +export const FOLDER_TREE_TOO_LARGE = 'A workspace folder tree over 10,000 folders is a `413`.' /** * Appended to a list whose result set is bounded by construction, so it answers * in one page. * * Every v2 list returns `{ data, nextCursor }`, so a caller cannot tell a - * single-page list from a paged one by shape alone. Saying so once keeps the six - * such operations from drifting into six paraphrases of the same promise. + * single-page list from a paged one by shape alone. Saying so once keeps the + * eight such operations from drifting into eight paraphrases of the same + * promise. The authoritative membership is pinned in + * `contracts/v2/__tests__/list-pagination.test.ts` as `FULL_SET_LISTS`. */ -export const FULL_SET_LIST = - 'The bounded set is returned in one page with `nextCursor` always null; there is no second page to fetch.' +export const FULL_SET_LIST = 'The bounded set is returned in one page; `nextCursor` is always null.' + +/** + * Appended to a `GET` whose route declares `headSafe: false` because the read + * has an effect — an outbound connection, or an audit event. + * + * Pinned by `contracts/v2/openapi/head-not-safe.test.ts`. + */ +export const HEAD_MIRRORS_GET = + 'A `HEAD` skips the effect but is authorized exactly as the `GET` is, so it answers `400`, `401`, `403`, or `404` wherever the `GET` would and an empty `200` otherwise. Skipping the effect means skipping the read that produces the payload, so that `200` carries none of the response headers documented below — it answers whether the `GET` would be allowed, not what the `GET` would return.' + +/** + * Appended where the skipped payload headers are the ones a caller is most + * likely to have wanted from a `HEAD`. + * + * `Content-Length` on a `HEAD` is the standard way to size a download before + * fetching it, and this surface cannot serve it: the byte length comes from the + * same read that records the download audit event, which is the effect + * `headSafe: false` exists to skip. + */ +export const HEAD_OMITS_PAYLOAD_HEADERS = + 'In particular a `HEAD` does not report `Content-Length`, so it cannot be used to size a download in advance; read the size from the file resource instead.' /** * Appended to an operation whose semantic operation sets `workspaceApiKey: 'deny'`. @@ -177,15 +236,45 @@ export const FULL_SET_LIST = * so it is not something a workspace owner can grant around. */ export const WORKSPACE_API_KEY_DENIED = - 'A workspace API key cannot call this operation and is rejected with `403`; use a personal API key.' + 'A workspace API key is rejected with `403`; use a personal API key.' /** * {@link WORKSPACE_API_KEY_DENIED} for an operation behind the resource-concealment * error policy, which rewrites the authorization failure to a not-found response so * the caller learns nothing about the resource. + * + * Published on no operation today: every one audited so far refuses a workspace + * key through its principal-kind list, which raises an error the concealment + * policy does not rewrite, so all of them say 403. Kept because a concealed + * operation that denies the key through the policy itself would need this exact + * wording, and because `scripts/openapi/documents.test.ts` asserts the file-share + * description does not carry it — inlining the string there would let the guard + * and the wording it guards drift apart. */ export const WORKSPACE_API_KEY_DENIED_AS_NOT_FOUND = - 'A workspace API key cannot call this operation. Because unauthorized resources are concealed, the rejection is reported as `404` rather than `403`; use a personal API key.' + 'A workspace API key is rejected as `404` rather than `403`, because unauthorized resources are concealed; use a personal API key.' + +/** + * Appended to the two reads over `workflow_execution_logs`, which is the only + * store of a run and is hard-deleted — rows and execution files both — by the + * `cleanup-logs` background task once a run passes the payer's window. + * + * The window itself is `CLEANUP_CONFIG['cleanup-logs'].defaults` in + * `lib/billing/cleanup-dispatcher.ts`: 30 days on the free plan, and `null` + * — meaning the plan is skipped entirely and nothing is deleted — on Pro and + * Team. Enterprise resolves per organization through + * `resolveEffectiveRetentionHours`, with a per-workspace override, and is + * likewise unbounded until someone configures it. Self-hosted classifies every + * workspace as enterprise and dispatches nothing unless data retention is + * enabled. + * + * Stated because deletion is otherwise invisible: an aged-out run is not a + * tombstone or a 404, it is simply absent. The matching `runCount` caveat lives + * on that field rather than here. Kept as one constant so the two sibling reads + * cannot drift into two paraphrases of one window. + */ +export const RUN_RETENTION = + "Runs are hard-deleted once they pass the payer's log retention window, so an older run is simply absent rather than reported as removed. The window is 30 days from run start on the free plan, unbounded on Pro and Team, and set per organization on Enterprise with an optional per-workspace override." export const V2_COMMON_HEADERS = { 'X-RateLimit-Limit': { @@ -214,7 +303,7 @@ export const V2_COMMON_HEADERS = { id: 'RetryAfterHeader', title: 'Retry after', description: - 'Seconds to wait before retrying. Sent on `429` (derived from the caller rate-limit window) and on `503` (a fixed transient-failure floor). Add jitter rather than retrying at exactly this offset.', + 'Seconds to wait before retrying, sent on `429` and `503`. Add jitter rather than retrying at exactly this offset.', }), }, 'X-Run-Id': { diff --git a/apps/sim/lib/api/contracts/v2/openapi/tables.ts b/apps/sim/lib/api/contracts/v2/openapi/tables.ts index c23448e95ce..d83ca48fbaa 100644 --- a/apps/sim/lib/api/contracts/v2/openapi/tables.ts +++ b/apps/sim/lib/api/contracts/v2/openapi/tables.ts @@ -13,6 +13,7 @@ import { V2_COMMON_HEADERS, V2_ERROR_SCHEMA, WORKSPACE_ERRORS, + withRequestBodyErrors, } from '@/lib/api/contracts/v2/openapi/shared' import { v2AddTableColumnContract, @@ -93,10 +94,11 @@ const TABLE_MUTATION_ERRORS = [ ] as const satisfies readonly ErrorResponseId[] /** - * The two table query reads declare `maxBodyBytes`, which the route builder - * turns into a real `413`, so their set is the base plus that status. Every - * other table read carries its input in the query string and has no body - * ceiling to exceed. + * The two table query reads declare their own `maxBodyBytes` — 1 MiB, far below + * the 50 MB default every JSON body is held to — so their `413` is a routine + * answer to an oversized predicate rather than an abuse ceiling, and it is named + * here and in their descriptions. Every other table read carries its input in + * the query string and has no body ceiling to exceed. */ const TABLE_QUERY_ERRORS = [ ...RESOURCE_ERRORS, @@ -119,7 +121,7 @@ function tableOperation( } } -const routes = [ +const declaredRoutes = [ defineOpenApiRoute( v2ListTablesContract, tableOperation({ @@ -154,6 +156,7 @@ const routes = [ success: { description: 'The created table.' }, }), { + query: v2CreateTableContract.query, body: documentedSchema( v2CreateTableContract.body, 'CreateTableRequest', @@ -246,11 +249,12 @@ const routes = [ tableOperation({ operationId: 'updateTable', summary: 'Update Table', - description: `Rename a table, edit its description, or move it to a canonical folder. At least one mutable field is required; lock flags remain read-only.\n\nThis operation is NOT atomic. The name, description, and folder changes are written independently in that order, so a failure part-way through leaves the earlier writes committed — a 4xx does NOT mean nothing changed. When at least one field landed before the failure, the error body carries \`details.applied\`: the list of fields (\`name\`, \`description\`, \`folderPath\`) that were successfully written. Re-read the table, or retry with only the fields missing from \`details.applied\`.\n\n${FOLDER_TREE_TOO_LARGE}`, + description: `Rename a table, edit its description, or move it to a canonical folder. At least one mutable field is required; lock flags remain read-only.\n\nNOT atomic: name, description, and folder are written independently, so a 4xx does not mean nothing changed. The error body carries \`details.applied\` naming the fields that landed — retry with only the ones missing from it.\n\n${FOLDER_TREE_TOO_LARGE}`, errors: [...RESOURCE_CONFLICT_ERRORS, 'PayloadTooLarge'], success: { description: 'The updated table.' }, }), { + query: v2UpdateTableContract.query, params: documentedSchema( v2UpdateTableContract.params, 'UpdateTableParams', @@ -282,6 +286,7 @@ const routes = [ success: { description: 'The updated table columns.' }, }), { + query: v2AddTableColumnContract.query, params: documentedSchema( v2AddTableColumnContract.params, 'AddTableColumnParams', @@ -313,6 +318,7 @@ const routes = [ success: { description: 'The updated table columns.' }, }), { + query: v2UpdateTableColumnContract.query, params: documentedSchema( v2UpdateTableColumnContract.params, 'UpdateTableColumnParams', @@ -344,6 +350,7 @@ const routes = [ success: { description: 'The surviving table columns.' }, }), { + query: v2DeleteTableColumnContract.query, params: documentedSchema( v2DeleteTableColumnContract.params, 'DeleteTableColumnParams', @@ -407,6 +414,7 @@ const routes = [ success: { description: 'The inserted row or rows.' }, }), { + query: v2CreateTableRowsContract.query, params: documentedSchema( v2CreateTableRowsContract.params, 'CreateTableRowsParams', @@ -438,6 +446,7 @@ const routes = [ success: { description: 'The bulk update result.' }, }), { + query: v2UpdateRowsByFilterContract.query, params: documentedSchema( v2UpdateRowsByFilterContract.params, 'UpdateTableRowsParams', @@ -476,6 +485,7 @@ const routes = [ success: { description: 'The bulk deletion result.' }, }), { + query: v2DeleteTableRowsContract.query, params: documentedSchema( v2DeleteTableRowsContract.params, 'DeleteTableRowsParams', @@ -537,6 +547,7 @@ const routes = [ success: { description: 'The updated table row.' }, }), { + query: v2UpdateTableRowContract.query, params: documentedSchema( v2UpdateTableRowContract.params, 'UpdateTableRowParams', @@ -594,11 +605,12 @@ const routes = [ operationId: 'upsertTableRow', summary: 'Upsert Row', description: - 'Insert a row or update the existing row that conflicts on a selected unique column.\n\nWARNING — the update branch REPLACES the row, it does not merge. `data` is treated as the complete new row value, so every column you omit is cleared on the matched row. Upserting 2 of 10 columns blanks the other 8. This differs from `PATCH /api/v2/tables/{tableId}/rows/{rowId}`, which merges the patch into the existing row data. Send the full row here, or use PATCH when you only mean to change a subset.', + 'Insert a row or update the existing row that conflicts on a selected unique column.\n\nWARNING — the update branch REPLACES the row, it does not merge. `data` is the complete new row value, so every column you omit is cleared on the matched row. Send the full row here, or use `PATCH /api/v2/tables/{tableId}/rows/{rowId}` to change a subset.', errors: TABLE_MUTATION_ERRORS, success: { description: 'The upserted row and operation performed.' }, }), { + query: v2UpsertTableRowContract.query, params: documentedSchema( v2UpsertTableRowContract.params, 'UpsertTableRowParams', @@ -632,11 +644,12 @@ const routes = [ operationId: 'queryTableRows', summary: 'Query Rows', description: - 'Query rows with a typed predicate, ordered sort specification, and opaque cursor pagination. Bounded pages are capped at 5MB by default and may contain fewer rows than the requested limit; continue until nextCursor is null.', - errors: RESOURCE_ERRORS, + 'Query rows with a typed predicate, ordered sort specification, and opaque cursor pagination. Bounded pages are capped at 5MB by default and may contain fewer rows than the requested limit; continue until nextCursor is null. A predicate larger than the request-body ceiling is a `413`.', + errors: TABLE_QUERY_ERRORS, success: { description: 'A page of matching table rows.' }, }), { + query: v2QueryRowsContract.query, params: documentedSchema( v2QueryRowsContract.params, 'QueryTableRowsParams', @@ -671,11 +684,12 @@ const routes = [ operationId: 'countTableRows', summary: 'Count Rows', description: - 'Count the rows matching a typed predicate across the entire table. The paged reads carry no total, and rowCount on the table resource counts every row rather than the predicate matches. Omit the predicate to count the whole table. A predicate larger than the request-body ceiling is a `413`.', + 'Count the rows matching a typed predicate across the entire table. The paged reads carry no total, and `rowCount` on the table resource counts every row rather than the matches. Omit the predicate to count the whole table. A predicate larger than the request-body ceiling is a `413`.', errors: TABLE_QUERY_ERRORS, success: { description: 'The number of matching table rows.' }, }), { + query: v2QueryRowsCountContract.query, params: documentedSchema( v2QueryRowsCountContract.params, 'CountTableRowsParams', @@ -742,6 +756,7 @@ const routes = [ success: { description: 'The created table view.' }, }), { + query: v2CreateTableViewContract.query, params: documentedSchema( v2CreateTableViewContract.params, 'CreateTableViewParams', @@ -814,6 +829,7 @@ const routes = [ success: { description: 'The updated table view.' }, }), { + query: v2UpdateTableViewContract.query, params: documentedSchema( v2UpdateTableViewContract.params, 'UpdateTableViewParams', @@ -906,6 +922,7 @@ const routes = [ success: { description: 'The created workflow group and resulting columns.' }, }), { + query: v2AddWorkflowGroupContract.query, params: documentedSchema( v2AddWorkflowGroupContract.params, 'AddTableWorkflowGroupParams', @@ -943,11 +960,12 @@ const routes = [ operationId: 'updateTableWorkflowGroup', summary: 'Update Workflow Group', description: - 'Restructure a workflow group, its producer, outputs, or execution behavior.\n\nOutput leaf types are resolved against the group\u2019s workflow outside the write lock. If the group is repointed at a different workflow concurrently, that snapshot is invalidated and the request returns `409` — retry the update.', + 'Restructure a workflow group, its producer, outputs, or execution behavior. Repointing the group at a different workflow concurrently invalidates the resolved output types and returns `409` — retry the update.', errors: RESOURCE_MUTATION_ERRORS, success: { description: 'The updated workflow group and resulting columns.' }, }), { + query: v2UpdateWorkflowGroupContract.query, params: documentedSchema( v2UpdateWorkflowGroupContract.params, 'UpdateTableWorkflowGroupParams', @@ -979,6 +997,7 @@ const routes = [ success: { description: 'Workflow-group deletion acknowledgement and surviving columns.' }, }), { + query: v2DeleteWorkflowGroupContract.query, params: documentedSchema( v2DeleteWorkflowGroupContract.params, 'DeleteTableWorkflowGroupParams', @@ -1011,6 +1030,7 @@ const routes = [ success: { description: 'The accepted table-column dispatch.' }, }), { + query: v2RunTableColumnContract.query, params: documentedSchema( v2RunTableColumnContract.params, 'RunTableColumnsParams', @@ -1042,6 +1062,7 @@ const routes = [ success: { description: 'The accepted row enrichment dispatch.' }, }), { + query: v2RunRowEnrichmentContract.query, params: documentedSchema( v2RunRowEnrichmentContract.params, 'RunRowEnrichmentParams', @@ -1074,6 +1095,7 @@ const routes = [ success: { description: 'The matching table cells.' }, }), { + query: v2FindTableRowsContract.query, params: documentedSchema( v2FindTableRowsContract.params, 'FindTableRowsParams', @@ -1112,6 +1134,7 @@ const routes = [ success: { description: 'The created table import and optional transfer instructions.' }, }), { + query: v2CreateTableImportContract.query, body: documentedSchema( v2CreateTableImportContract.body, 'CreateTableImportRequest', @@ -1139,7 +1162,8 @@ const routes = [ tableOperation({ operationId: 'getTableImport', summary: 'Get Table Import', - description: 'Read progress and terminal state for a durable table import.', + description: + 'Read progress and terminal state for a durable table import.\n\nAn upload-backed import has no durable record until its upload completes, so send the signed upload control token to read it during the `uploading` phase; without the token that phase is a `404`.', errors: RESOURCE_ERRORS, success: { description: 'The requested table import.' }, }), @@ -1157,6 +1181,12 @@ const routes = [ 'Get table import query', 'Workspace scope for the import.' ), + headers: documentedSchema( + v2GetTableImportContract.headers, + 'GetTableImportHeaders', + 'Get table import headers', + 'Optional signed upload control token for an upload-backed import.' + ), response: documentedSchema( v2GetTableImportContract.response.schema, 'V2TableImportResponse', @@ -1171,7 +1201,7 @@ const routes = [ operationId: 'cancelTableImport', summary: 'Cancel Table Import', description: - 'Cancel an upload or processing import without rolling back committed row batches.\n\nCanceling an import that is not in a cancelable state returns `409` naming the current status, and that includes an expired import — `expired` is a terminal import status, not a `410`. An import id that never existed, or one whose retention window already purged the record, returns `404`.', + 'Cancel an upload or processing import without rolling back committed row batches.\n\nAn import that is not in a cancelable state, including an `expired` one, is a `409` naming the current status. An unknown or already-purged import id is a `404`.', errors: RESOURCE_CONFLICT_ERRORS, success: { description: 'The canceled table import.' }, }), @@ -1208,7 +1238,7 @@ const routes = [ operationId: 'createTableImportPartUrls', summary: 'Create Table Import Part URLs', description: - 'Issue short-lived signed PUT URLs for a bounded set of multipart part numbers.\n\nThe import must still be in the `uploading` state. An import that has moved on — including one that has `expired` — returns `409` naming the current status; a purged or unknown import id returns `404`.', + 'Issue short-lived signed PUT URLs for a bounded set of multipart part numbers.\n\nThe import must still be `uploading`; one that has moved on, including an `expired` one, is a `409` naming the current status. An unknown or already-purged import id is a `404`.', errors: RESOURCE_CONFLICT_ERRORS, success: { description: 'The signed multipart upload URLs.' }, }), @@ -1252,7 +1282,7 @@ const routes = [ operationId: 'completeTableImportUpload', summary: 'Complete Table Import Upload', description: - 'Verify or assemble the uploaded CSV and begin processing with the same import id.\n\nCompleting an import that is no longer awaiting an upload — including one that has `expired` — returns `409` naming the current status; a purged or unknown import id returns `404`.', + 'Verify or assemble the uploaded CSV and begin processing with the same import id.\n\nAn import no longer awaiting an upload, including an `expired` one, is a `409` naming the current status. An unknown or already-purged import id is a `404`.', errors: [...RESOURCE_CONFLICT_ERRORS, 'Locked'], success: { description: 'The table import after upload completion.' }, }), @@ -1294,6 +1324,7 @@ const routes = [ success: { description: 'The created table export.' }, }), { + query: v2CreateTableExportContract.query, params: documentedSchema( v2CreateTableExportContract.params, 'CreateTableExportParams', @@ -1382,7 +1413,7 @@ const routes = [ operationId: 'downloadTableExport', summary: 'Download Table Export', description: - 'Return a short-lived signed download URL for a completed table export.\n\nThe export must have reached the `completed` status. An export still processing, or one that failed or was canceled, returns `409` naming the current status. An export whose generated file is no longer available — the retention window elapsed, or the object was purged — returns `404` (`Export file is no longer available`), not `410`.', + 'Return a short-lived signed download URL for a completed table export.\n\nThe export must have reached `completed`; one still processing, failed, or canceled is a `409` naming the current status. An export whose file is no longer available is a `404`, not a `410`.', errors: RESOURCE_CONFLICT_ERRORS, success: { description: 'Signed table-export download information.' }, }), @@ -1418,6 +1449,7 @@ const routes = [ success: { description: 'The number of canceled cell runs.' }, }), { + query: v2CancelTableRunsContract.query, params: documentedSchema( v2CancelTableRunsContract.params, 'CancelTableRunsParams', @@ -1473,6 +1505,7 @@ const routes = [ success: { description: 'The created table folder.' }, }), { + query: v2CreateTableFolderContract.query, body: documentedSchema( v2CreateTableFolderContract.body, 'CreateTableFolderRequest', @@ -1498,6 +1531,7 @@ const routes = [ success: { description: 'The relocated table folder.' }, }), { + query: v2RelocateTableFolderContract.query, body: documentedSchema( v2RelocateTableFolderContract.body, 'RelocateTableFolderRequest', @@ -1546,12 +1580,14 @@ const routes = [ ), ] as const +const routes = declaredRoutes.map(withRequestBodyErrors) + export const tablesOpenApiDocument = defineOpenApiDocument({ output: 'apps/docs/openapi-v2-tables.json', info: { title: 'Sim Tables API v2', description: - 'Manage tables, typed columns, rows, saved views, workflow groups, folders, imports, and exports through the public v2 API. Row data is keyed by column name.', + 'Version 2 of the Sim REST API for tables, typed columns, rows, saved views, workflow groups, folders, imports, and exports. Row data is keyed by column name.', version: '2.0.0', contact: { name: 'Sim Support', email: 'help@sim.ai', url: 'https://www.sim.ai' }, license: { name: 'Apache 2.0', url: 'https://www.apache.org/licenses/LICENSE-2.0.html' }, diff --git a/apps/sim/lib/api/contracts/v2/openapi/workflows.ts b/apps/sim/lib/api/contracts/v2/openapi/workflows.ts index 30536db8fec..ef62bb6fcdf 100644 --- a/apps/sim/lib/api/contracts/v2/openapi/workflows.ts +++ b/apps/sim/lib/api/contracts/v2/openapi/workflows.ts @@ -4,13 +4,19 @@ import { type ErrorResponseId, FOLDER_TREE_TOO_LARGE, FULL_SET_LIST, + HEAD_MIRRORS_GET, RATE_LIMIT_HEADERS, RESOURCE_CONFLICT_ERRORS, + RESOURCE_ERRORS, + RESOURCE_MUTATION_ERRORS, + RUN_RETENTION, V2_API_KEY_SECURITY, V2_API_KEY_SECURITY_SCHEMES, V2_COMMON_HEADERS, V2_ERROR_SCHEMA, - WORKSPACE_API_KEY_DENIED_AS_NOT_FOUND, + WORKSPACE_API_KEY_DENIED, + WORKSPACE_ERRORS, + withRequestBodyErrors, } from '@/lib/api/contracts/v2/openapi/shared' import { EXECUTE_OPTION_CONSTRAINTS, @@ -105,25 +111,6 @@ const QUEUED_RUN_EXAMPLE = { }, } as const -const WORKSPACE_ERRORS = [ - 'BadRequest', - 'Unauthorized', - 'Forbidden', - 'RateLimited', - 'InternalError', - 'ServiceUnavailable', -] as const satisfies readonly ErrorResponseId[] - -const RESOURCE_ERRORS = [ - ...WORKSPACE_ERRORS, - 'NotFound', -] as const satisfies readonly ErrorResponseId[] -const RESOURCE_MUTATION_ERRORS = [ - ...RESOURCE_ERRORS, - 'Conflict', - 'Locked', -] as const satisfies readonly ErrorResponseId[] - type WorkflowOperationInput = Omit & { errors: readonly ErrorResponseId[] } @@ -172,7 +159,7 @@ const resumeQueuedResponseSchema = documentedSchema( [QUEUED_RUN_EXAMPLE] ) -const routes = [ +const declaredRoutes = [ defineOpenApiRoute( v2ListWorkflowsContract, workflowOperation({ @@ -203,6 +190,7 @@ const routes = [ success: jsonSuccess('The created workflow.'), }), { + query: v2CreateWorkflowContract.query, body: v2CreateWorkflowContract.body, response: documentedSchema( v2CreateWorkflowContract.response.schema, @@ -224,6 +212,7 @@ const routes = [ }), { params: v2GetWorkflowContract.params, + query: v2GetWorkflowContract.query, response: documentedSchema( v2GetWorkflowContract.response.schema, 'WorkflowDetailResponse', @@ -243,6 +232,7 @@ const routes = [ success: jsonSuccess('The updated workflow.'), }), { + query: v2UpdateWorkflowContract.query, params: v2UpdateWorkflowContract.params, body: v2UpdateWorkflowContract.body, response: documentedSchema( @@ -264,6 +254,7 @@ const routes = [ success: jsonSuccess('The workflow was deleted.'), }), { + query: v2DeleteWorkflowContract.query, params: v2DeleteWorkflowContract.params, response: documentedSchema( v2DeleteWorkflowContract.response.schema, @@ -305,6 +296,7 @@ const routes = [ success: jsonSuccess('The requested deployment version.'), }), { + query: v2GetWorkflowVersionContract.query, params: v2GetWorkflowVersionContract.params, response: documentedSchema( v2GetWorkflowVersionContract.response.schema, @@ -333,11 +325,12 @@ const routes = [ operationId: 'getWorkflowDeployment', summary: 'Get Workflow Deployment', description: - 'Read the current deployment state of a workflow: whether a version is live, when it went live, the most recent deployment attempt with its readiness and failure payload, and whether the editable draft has since diverged from the live version. This is the only place `needsRedeployment` is published — the deploy, undeploy, and rollback responses cannot carry it, because they answer at the moment the draft and the live version are equal.', + 'Read the current deployment state of a workflow: whether a version is live, when it went live, the most recent deployment attempt with its readiness and failure payload, and whether the editable draft has since diverged from the live version. This is the only operation that publishes `needsRedeployment`.', errors: RESOURCE_ERRORS, success: jsonSuccess('The current deployment state.'), }), { + query: v2GetWorkflowDeploymentContract.query, params: v2GetWorkflowDeploymentContract.params, response: documentedSchema( v2GetWorkflowDeploymentContract.response.schema, @@ -380,11 +373,12 @@ const routes = [ workflowOperation({ operationId: 'deployWorkflow', summary: 'Deploy Workflow', - description: `Create and asynchronously activate a deployment version. This request is not idempotent: it accepts no idempotency key and every call mints a new deployment version, so retrying after a timeout creates a second version rather than returning the first. The response carries \`latestDeploymentAttempt\` for the accepted attempt, but \`GET /workflows/{id}\` does not expose that field — poll activation with \`isDeployed\` and \`deployedAt\` on the workflow, or with \`isActive\` on \`GET /workflows/{id}/versions\`. Returns 409 when the deployment would conflict with an existing webhook path. ${WORKSPACE_API_KEY_DENIED_AS_NOT_FOUND}`, + description: `Create and asynchronously activate a deployment version. Not idempotent: every call mints a new version, so a retry after a timeout creates a second one. A deployment that would conflict with an existing webhook path is a \`409\`. ${WORKSPACE_API_KEY_DENIED}`, errors: [...RESOURCE_ERRORS, 'Conflict', 'PayloadTooLarge', 'Locked'], success: jsonSuccess('The accepted deployment attempt.'), }), { + query: v2DeployWorkflowContract.query, params: v2DeployWorkflowContract.params, body: v2DeployWorkflowContract.body, response: documentedSchema( @@ -424,11 +418,12 @@ const routes = [ workflowOperation({ operationId: 'undeployWorkflow', summary: 'Undeploy Workflow', - description: `Deactivate the currently serving workflow version. ${WORKSPACE_API_KEY_DENIED_AS_NOT_FOUND}`, + description: `Deactivate the currently serving workflow version. ${WORKSPACE_API_KEY_DENIED}`, errors: [...RESOURCE_ERRORS, 'Locked'], success: jsonSuccess('The workflow was undeployed.'), }), { + query: v2UndeployWorkflowContract.query, params: v2UndeployWorkflowContract.params, response: documentedSchema( v2UndeployWorkflowContract.response.schema, @@ -455,11 +450,12 @@ const routes = [ workflowOperation({ operationId: 'rollbackWorkflow', summary: 'Rollback Workflow', - description: `Asynchronously reactivate a previous deployment version, selecting the preceding active version when no version is supplied. ${WORKSPACE_API_KEY_DENIED_AS_NOT_FOUND}`, - errors: [...RESOURCE_ERRORS, 'PayloadTooLarge', 'Locked'], + description: `Asynchronously reactivate a previous deployment version, selecting the preceding active version when no version is supplied. ${WORKSPACE_API_KEY_DENIED}`, + errors: [...RESOURCE_ERRORS, 'Conflict', 'PayloadTooLarge', 'Locked'], success: jsonSuccess('The accepted rollback attempt.'), }), { + query: v2RollbackWorkflowContract.query, params: v2RollbackWorkflowContract.params, body: v2RollbackWorkflowContract.body, response: documentedSchema( @@ -499,11 +495,12 @@ const routes = [ workflowOperation({ operationId: 'exportWorkflow', summary: 'Export Workflow', - description: `Export a portable, secret-sanitized workflow. Workspace-scoped bindings must be selected again after import. ${FOLDER_TREE_TOO_LARGE}`, + description: `Export a portable, secret-sanitized workflow. Workspace-scoped bindings must be selected again after import. Exporting records an audit event, so it is not a safe read. ${HEAD_MIRRORS_GET} ${FOLDER_TREE_TOO_LARGE}`, errors: [...RESOURCE_ERRORS, 'PayloadTooLarge'], success: jsonSuccess('The workflow export payload.'), }), { + query: v2ExportWorkflowContract.query, params: v2ExportWorkflowContract.params, response: documentedSchema( v2ExportWorkflowContract.response.schema, @@ -534,11 +531,12 @@ const routes = [ workflowOperation({ operationId: 'importWorkflow', summary: 'Import Workflow', - description: 'Create a workflow from a portable export object, bare state, or JSON string.', + description: `Create a workflow from a portable export object, bare state, or JSON string. ${FOLDER_TREE_TOO_LARGE}`, errors: [...RESOURCE_MUTATION_ERRORS, 'PayloadTooLarge'], success: jsonSuccess('The imported workflow.'), }), { + query: v2ImportWorkflowContract.query, body: v2ImportWorkflowContract.body, response: documentedSchema( v2ImportWorkflowContract.response.schema, @@ -566,7 +564,7 @@ const routes = [ workflowOperation({ operationId: 'executeWorkflowV2', summary: 'Execute Workflow', - description: `Execute a deployed workflow synchronously, asynchronously, or as Server-Sent Events. Public workflows permit anonymous synchronous and streaming execution; asynchronous execution requires an API key. A synchronous run that exceeds its execution timeout returns HTTP 200 with \`status: "failed"\` and \`error.code: "TIMEOUT"\` rather than an HTTP error, so branch on \`status\`. The optional \`X-Run-Id\` header is a one-shot uniqueness claim, not an idempotency key: reusing a value returns 409 with \`error.details.code: "RUN_ID_CONFLICT"\` and never replays the earlier run. ${EXECUTE_OPTION_CONSTRAINTS}`, + description: `Execute a deployed workflow synchronously, asynchronously, or as Server-Sent Events. Public workflows permit anonymous synchronous and streaming execution; asynchronous execution requires an API key. A synchronous run that exceeds its execution timeout returns HTTP 200 with \`status: "failed"\` and \`error.code: "TIMEOUT"\` rather than an HTTP error, so branch on \`status\`. ${EXECUTE_OPTION_CONSTRAINTS}`, errors: [ 'BadRequest', 'Unauthorized', @@ -596,6 +594,7 @@ const routes = [ }, }), { + query: v2ExecuteWorkflowContract.query, params: v2ExecuteWorkflowContract.params, headers: v2ExecuteWorkflowContract.headers, body: v2ExecuteWorkflowContract.body, @@ -608,8 +607,7 @@ const routes = [ workflowRunOperation({ operationId: 'listWorkflowRunsV2', summary: 'List Workflow Runs', - description: - 'List recorded runs of a workflow with filtering and opaque cursor pagination. Ordering deviates from the v2 `sortBy` + `sortOrder` convention: runs are sortable only by start time, so direction is carried by the single `order` param.', + description: `List recorded runs of a workflow with filtering and opaque cursor pagination. ${RUN_RETENTION}`, errors: RESOURCE_ERRORS, success: jsonSuccess('A page of workflow runs.'), }), @@ -701,6 +699,7 @@ const routes = [ }, }), { + query: v2ResumeWorkflowContract.query, params: v2ResumeWorkflowContract.params, body: v2ResumeWorkflowContract.body, response: v2ResumeWorkflowContract.response.schema, @@ -713,11 +712,12 @@ const routes = [ operationId: 'cancelRunV2', summary: 'Cancel Workflow Run', description: - 'Request cancellation of a running, queued, or paused workflow run. Cancelling a run that has already reached a terminal state succeeds with no effect rather than returning an error. The `reason` field is present on every response, including full successes — `recorded` is the success value; it is not a partial-failure marker. A run produced by a table workflow group is a 409 when its cell can no longer accept the cancellation, because the run and its cell must reach the cancelled state together.', + 'Request cancellation of a running, queued, or paused workflow run. Cancelling a run already in a terminal state succeeds with no effect. A run produced by a table workflow group is a `409` when its cell can no longer accept the cancellation.', errors: RESOURCE_CONFLICT_ERRORS, success: jsonSuccess('The cancellation outcome.'), }), { + query: v2CancelWorkflowRunContract.query, params: v2CancelWorkflowRunContract.params, response: documentedSchema( v2CancelWorkflowRunContract.response.schema, @@ -745,7 +745,7 @@ const routes = [ workflowOperation({ operationId: 'listWorkflowsFolders', summary: 'List Workflow Folders', - description: `List canonical workflow folders in a workspace. ${FULL_SET_LIST}`, + description: `List canonical workflow folders in a workspace. ${FULL_SET_LIST} ${FOLDER_TREE_TOO_LARGE}`, errors: [...WORKSPACE_ERRORS, 'NotFound', 'PayloadTooLarge'], success: jsonSuccess('A list of workflow folders.'), }), @@ -770,11 +770,12 @@ const routes = [ workflowOperation({ operationId: 'createWorkflowsFolder', summary: 'Create Workflow Folder', - description: 'Create a canonical workflow folder in a workspace.', + description: `Create a canonical workflow folder in a workspace. ${FOLDER_TREE_TOO_LARGE}`, errors: [...RESOURCE_MUTATION_ERRORS, 'PayloadTooLarge'], success: jsonSuccess('The created workflow folder.'), }), { + query: v2CreateWorkflowFolderContract.query, body: documentedSchema( v2CreateWorkflowFolderContract.body, 'CreateWorkflowFolderRequest', @@ -796,11 +797,12 @@ const routes = [ workflowOperation({ operationId: 'relocateWorkflowsFolder', summary: 'Rename or Move Workflow Folder', - description: 'Rename or move a workflow folder and its descendants to a canonical path.', + description: `Rename or move a workflow folder and its descendants to a canonical path. ${FOLDER_TREE_TOO_LARGE}`, errors: [...RESOURCE_MUTATION_ERRORS, 'PayloadTooLarge'], success: jsonSuccess('The relocated workflow folder.'), }), { + query: v2RelocateWorkflowFolderContract.query, body: documentedSchema( v2RelocateWorkflowFolderContract.body, 'RelocateWorkflowFolderRequest', @@ -859,6 +861,8 @@ const routes = [ ), ] as const +const routes = declaredRoutes.map(withRequestBodyErrors) + export const workflowsOpenApiDocument = defineOpenApiDocument({ output: 'apps/docs/openapi-v2-workflows.json', info: { diff --git a/apps/sim/lib/api/contracts/v2/run-id.test.ts b/apps/sim/lib/api/contracts/v2/run-id.test.ts new file mode 100644 index 00000000000..e869d7a3412 --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/run-id.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest' +import { v2ListLogsQuerySchema, v2LogParamsSchema } from '@/lib/api/contracts/v2/logs' +import { v2WorkflowRunIdSchema } from '@/lib/api/contracts/v2/workflows' + +const OVERSIZED_RUN_ID = 'r'.repeat(129) + +/** + * `runId` names the same rows on the run resources and on the log resources, so + * a bound enforced on one and not the other only decides which endpoint an + * unbounded value reaches the database through. These pin both surfaces to the + * single shared primitive. + */ +describe('v2 run identifier', () => { + it.each([ + ['run resource', (value: string) => v2WorkflowRunIdSchema.safeParse(value).success], + ['log path param', (value: string) => v2LogParamsSchema.safeParse({ runId: value }).success], + [ + 'log list filter', + (value: string) => + v2ListLogsQuerySchema.safeParse({ workspaceId: 'workspace-1', runId: value }).success, + ], + ])('bounds the run identifier on the %s', (_surface, accepts) => { + expect(accepts('run_8f14e45f-ceea-467f-a')).toBe(true) + expect(accepts(OVERSIZED_RUN_ID)).toBe(false) + expect(accepts('')).toBe(false) + }) +}) diff --git a/apps/sim/lib/api/contracts/v2/secrets.ts b/apps/sim/lib/api/contracts/v2/secrets.ts index 13744bf83cb..a4177e8b4df 100644 --- a/apps/sim/lib/api/contracts/v2/secrets.ts +++ b/apps/sim/lib/api/contracts/v2/secrets.ts @@ -1,6 +1,6 @@ import { z } from 'zod' import { workspaceCredentialRoleSchema } from '@/lib/api/contracts/credentials' -import { workspaceIdSchema } from '@/lib/api/contracts/primitives' +import { noInputSchema, workspaceIdSchema } from '@/lib/api/contracts/primitives' import { defineRouteContract } from '@/lib/api/contracts/types' import { v2CursorListResponse, @@ -88,10 +88,12 @@ export const v2SetSecretBodySchema = z .strict() export type V2SetSecretBody = z.input -export const v2DeleteSecretQuerySchema = z.object({ - workspaceId: workspaceIdSchema.describe('Workspace in which the secret is available.'), - scope: v2SecretScopeSchema, -}) +export const v2DeleteSecretQuerySchema = z + .object({ + workspaceId: workspaceIdSchema.describe('Workspace in which the secret is available.'), + scope: v2SecretScopeSchema, + }) + .strict() export type V2DeleteSecretQuery = z.output /** @@ -112,6 +114,7 @@ export const v2ListSecretsContract = defineRouteContract({ export const v2SetSecretContract = defineRouteContract({ method: 'PUT', path: '/api/v2/secrets/[name]', + query: noInputSchema, params: v2SecretParamsSchema, body: v2SetSecretBodySchema, response: { diff --git a/apps/sim/lib/api/contracts/v2/shared.ts b/apps/sim/lib/api/contracts/v2/shared.ts index 17a9e83073e..5b8ff796ddf 100644 --- a/apps/sim/lib/api/contracts/v2/shared.ts +++ b/apps/sim/lib/api/contracts/v2/shared.ts @@ -1,7 +1,17 @@ import { z } from 'zod' import { workspaceIdSchema } from '@/lib/api/contracts/primitives' import { LIST_SORT_ORDERS, type ListSortOrder } from '@/lib/api/list-query' -import { FolderPathError, parseFolderPath, requireNonRootFolderPath } from '@/lib/folders/paths' +import { + FORBIDDEN_DETAIL_CODE_DESCRIPTIONS, + FORBIDDEN_DETAIL_CODES, +} from '@/lib/core/application/forbidden' +import { + FolderPathError, + MAX_FOLDER_PATH_BYTES, + MAX_FOLDER_PATH_SEGMENTS, + parseFolderPath, + requireNonRootFolderPath, +} from '@/lib/folders/paths' /** * Shared building blocks for the v2 API contract surface. @@ -11,15 +21,17 @@ import { FolderPathError, parseFolderPath, requireNonRootFolderPath } from '@/li * - list: `{ data: T[], nextCursor: string | null }` * - error: `{ error: { code, message, details? } }` * - * Every documented v2 operation uses that family. The two exceptions are the - * local-storage upload data plane — `PUT /api/v2/uploads/{uploadId}` and - * `PUT /api/v2/uploads/{uploadId}/parts/{partNumber}` — which emit a bare - * `{ error: string }` body. They are authenticated by a short-lived upload - * token rather than an API key, are deliberately absent from the public - * OpenAPI specs (see `UNDOCUMENTED_V2_ROUTES` in - * `scripts/check-openapi-specs.ts`), and are only ever reached through a URL - * handed back by a documented operation, so no caller writes against them - * from docs. + * Every v2 route uses that error family, including the two that are not + * published: the local-storage upload data plane — `PUT /api/v2/uploads/{uploadId}` + * and `PUT /api/v2/uploads/{uploadId}/parts/{partNumber}`. Those two are + * authenticated by a short-lived upload token rather than an API key and are + * deliberately absent from the public OpenAPI specs (see + * `UNDOCUMENTED_V2_ROUTES` in `scripts/check-openapi-specs.ts`), because their + * URL is signed, short-lived, and only ever reached through a documented + * operation's response. Not being in the document is a reason not to publish a + * route; it is not a reason to answer in a different shape. What that step + * promises — method, headers, `204`, and which codes mean what — is published on + * `transfer.url` in `contracts/v2/uploads.ts`. * * Every list returns the opaque-cursor envelope (Stripe/Slack-style) * `{ data, nextCursor }`, but not every list is *paged*. A paged list also @@ -68,7 +80,16 @@ import { FolderPathError, parseFolderPath, requireNonRootFolderPath } from '@/li * from the `sortOrder` *param* on purpose. * - **Filters** — resource-specific and enumerated, reusing the names already * on the surface (`scope`, `folderPath`, `deployedOnly`, `type`, `providerId`, - * `resourceType`). No generic filter expression. + * `resourceType`). No generic filter expression. A filter value that matches + * nothing is an empty page, never an error — including a `folderPath` naming + * no folder ({@link V2_FOLDER_FILTER_MISS}). + * + * ## Blank query values + * + * A param sent with no value (`?limit=`, `?search=`, `?limit=%20`) is a 400 + * naming it, enforced for every param at the surface by + * `V2_PARSE_DEFAULTS.rejectBlankQueryValues` — see + * `blankQueryValueValidationError` for why a schema cannot see the difference. * * Every one of these is pushed into SQL, except on `GET /skills` (which narrows the * static builtin registry with the same search term, merges it into the DB rows, @@ -83,35 +104,53 @@ import { FolderPathError, parseFolderPath, requireNonRootFolderPath } from '@/li * response — its OpenAPI description says so explicitly, so a caller never * writes a pagination loop that can only ever run once. * - * Every list whose result set grows with workspace content is now paged. What - * remains full-set is the folder lists, whose trees are already capped where - * they load, plus `GET /mcp-servers`. + * Every list whose result set grows with workspace content is now paged — + * including `GET /mcp-servers`, since nothing caps how many servers a workspace + * registers. What remains full-set is bounded by construction rather than by a + * caller's `limit`: the four folder lists, whose trees are capped where they + * load; `GET /knowledge/{id}/tags`, capped by the fixed tag-slot table; + * `GET /mcp-servers/{id}/tools`, capped by tool discovery itself; and + * `GET /tables/{tableId}/views` and `GET /tables/{tableId}/groups`, capped per + * table. * * Adding `limit`/`cursor` to a full-set list is additive, but giving it a - * *default* `limit` truncates callers reading the whole set today, so it is a - * breaking change. Five lists took exactly that change while `v2-api` was off - * in production and enabled only for a staging cohort — the window in which it - * costs nothing. Once v2 is generally available, moving a shipped full-set list - * to a defaulted page size needs a version bump. - * - * Both cursor schemes are opaque base64-JSON from `app/api/v2/lib/response.ts`, - * and which one a list uses is decided by what its read can express rather than - * by preference: a keyset (`encodeSortedCursor`) wherever the page comes from - * one ordered SQL read, and an offset (`encodeCursor({ offset })`) only where it - * cannot — `GET /skills`, which merges the static builtin registry into the DB - * rows and re-sorts in JS, and `GET /knowledge/{id}/documents`, whose underlying - * query is limit/offset. Prefer the keyset; an offset needs that kind of reason. - * - * ## Sort and the opaque cursor - * - * Lists using the shared keyset codec (`encodeSortedCursor` / - * `decodeSortedCursor` in `app/api/v2/lib/response.ts`) carry a cursor that is - * a keyset over the *active* sort, so its keys change when the sort does. The - * sort is therefore encoded into the cursor and re-checked on the way back in: - * replaying a cursor under a different `sortBy`/`sortOrder` is a 400, not a - * silently duplicated or skipped page. Change the sort by restarting pagination - * without a cursor. The rest delegate to their domain's own cursor codec, which - * is opaque in exactly the same way. + * *default* `limit` truncates callers reading the whole set today, so once v2 is + * generally available that change needs a version bump. + * + * Three cursor schemes are in use. Two are shared codecs in + * `app/api/v2/lib/response.ts`, and which of them a list uses is decided by what + * its read can express rather than by preference: a keyset + * (`encodeSortedCursor`) wherever the page comes from one ordered SQL read, and + * an offset (`encodeOffsetCursor`) only where it cannot — `GET /skills`, which + * merges the static builtin registry into the DB rows and re-sorts in JS, and + * `GET /knowledge/{id}/documents`, whose underlying query is limit/offset. + * Prefer the keyset; an offset needs that kind of reason. + * + * The third is per-domain: a list whose read predates the shared codecs, or + * whose page boundary is not expressible as one, mints its own — a bare + * `encodeCursor({ version })` on `GET /workflows/{id}/versions` and + * `encodeCursor({ email })` on the workspace member list, the audit-log and run-log + * codecs in `lib/audit-logs/query.ts` and `lib/logs/list-logs.ts`, the table-row + * codec in `lib/table/rows/cursor.ts`, and a usage-event id passed straight + * through by `GET /billing/logs`. Those tokens stay opaque and untouched, but the + * three whose sequence a caller can re-filter are wrapped in + * `encodeScopedCursor` at the surface so they carry the same query binding as + * the shared schemes. A new list should still reach for one of the two shared + * codecs rather than adding a fourth. + * + * ## Query binding and the opaque cursor + * + * Every paged list stamps its sort and its filters into the token it returns and + * re-checks them on the way back in; replaying a cursor under a different + * `sortBy`/`sortOrder` or a changed filter is a 400 naming which half changed. + * What belongs in a stamp, and why `limit` and response-shaping params do not, + * is documented on `cursorScopeKey` in `lib/api/cursor-binding.ts`. + * + * The authoritative per-list binding is pinned in + * `v2/__tests__/list-pagination.test.ts`, which fails when a list gains a param + * that is neither bound nor explicitly exempted. The three lists whose token is + * minted by a domain codec (`GET /logs`, `GET /audit-logs`, `GET /billing/logs`) + * get the same binding by wrapping that token in a query-stamped envelope. */ /** @@ -143,7 +182,17 @@ export const v2ErrorResponseSchema = z.object({ .object({ code: z.string().describe('Stable machine-readable error code.'), message: z.string().describe('Human-readable explanation of the error.'), - details: z.unknown().optional().describe('Optional structured error details.'), + details: z + .unknown() + .optional() + .describe( + [ + 'Structured error details. On a `403` whose cause a caller can act on, this carries a `code` from a closed set:', + ...FORBIDDEN_DETAIL_CODES.map( + (code) => `- \`${code}\` — ${FORBIDDEN_DETAIL_CODE_DESCRIPTIONS[code]}` + ), + ].join('\n') + ), }) .describe('Canonical error details.'), }) @@ -154,15 +203,38 @@ export type V2ErrorResponse = z.output export const v2DataResponse = (dataSchema: T) => z.object({ data: dataSchema.describe('Response data.') }) -/** `{ data: T[], nextCursor: string | null }` — the v2 list envelope. */ -export const v2CursorListResponse = (itemSchema: T) => +interface V2ListResponseOptions { + /** + * `false` for a full-set list — one that shares the envelope but declares no + * `cursor`/`limit` and always answers `null`. Defaults to `true`. + */ + paged?: boolean +} + +/** + * `{ data: T[], nextCursor: string | null }` — the v2 list envelope. + * + * `paged` selects the `nextCursor` documentation, and exists because the two + * cases had been publishing the same sentence. A full-set list accepts no + * `cursor` param — its query schema is `.strict()`, so the token the envelope + * told the caller to "send back as `cursor`" is a `400` — and its `nextCursor` + * is `null` by construction, so the instruction described a loop that could + * never run. The envelope stays shared either way: that is what lets a + * full-set list gain real pages later without a contract change. + */ +export const v2CursorListResponse = ( + itemSchema: T, + options: V2ListResponseOptions = {} +) => z.object({ data: z.array(itemSchema).describe('Items in the current page.'), nextCursor: z .string() .nullable() .describe( - 'Opaque cursor for the next page: send it back as `cursor` to continue, and stop when it is null. Most v2 lists page, so null means the last page was reached. A few are full-set lists that return their whole bounded result in one response and therefore always report null; those say so in the operation description. Either way, null means there is nothing further to fetch — never construct a cursor yourself.' + options.paged === false + ? 'Always `null` — this list has no `cursor` or `limit` param and returns its whole bounded set in one page. Present so the list can gain pages later without a shape change.' + : 'Opaque cursor for the next page. Send it back as `cursor`; `null` means there is nothing further to fetch. Never construct one yourself.' ), }) @@ -236,12 +308,21 @@ export function v2LimitSchema(options: V2LimitOptions = {}) { const base = z.coerce.number({ error: 'limit must be a number' }) if (outOfRange === 'clamp') { - return base - .optional() - .default(fallback) - .transform((value) => Math.min(Math.max(1, Math.trunc(value)), max)) - .describe(described) - .meta({ type: 'integer', minimum: 1, maximum: max }) + return ( + base + .optional() + .default(fallback) + .transform((value) => Math.min(Math.max(1, Math.trunc(value)), max)) + .describe(described) + /** + * `minimum`/`maximum` are deliberately absent. In JSON Schema they mean + * "rejected outside", and this branch clamps instead — publishing them + * made a generated SDK refuse locally a `limit` the server would have + * accepted and silently corrected. The range lives in the description, + * which is where a clamped bound belongs. + */ + .meta({ type: 'integer' }) + ) } return base @@ -253,23 +334,26 @@ export function v2LimitSchema(options: V2LimitOptions = {}) { .describe(described) } -/** - * The v2 `cursor` param: the opaque token a previous page returned as - * `nextCursor`. Empty is rejected rather than treated as "start over", so a - * caller that accidentally forwards an empty string learns about it instead of - * looping on page one. - */ -export function v2CursorSchema(description = 'Opaque cursor returned by the previous page.') { - return z.string().min(1, 'cursor must be a non-empty token').optional().describe(description) -} - /** * The `limit` + `cursor` pair for a paged v2 list. Spread into a query object; * a list that returns `nextCursor` must accept both, and must actually apply * them. + * + * `cursor` is the opaque token a previous page returned as `nextCursor`. Empty + * is rejected rather than treated as "start over", so a caller that accidentally + * forwards an empty string learns about it instead of looping on page one. */ export function v2PaginationFields(options: V2LimitOptions = {}) { - return { limit: v2LimitSchema(options), cursor: v2CursorSchema() } + return { + limit: v2LimitSchema(options), + cursor: z + .string() + .min(1, 'cursor must be a non-empty token') + .optional() + .describe( + 'Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.' + ), + } } /** @@ -292,28 +376,109 @@ export function v2PaginationFields(options: V2LimitOptions = {}) { * reads over the same runs, so the same timestamp must work on both — sharing * the schema is what makes that true rather than merely intended, and it is why * the descriptions say "UTC ISO 8601" instead of overpromising "ISO 8601". + * + * Format alone is not enough, which is why the year is checked on top of it. + * `date-time` publishes a four-digit year, so `0000-01-01T00:00:00Z` is a + * spec-valid value that `Date` parses happily — but the proleptic Gregorian + * calendar Postgres implements has no year zero, so the resulting bind parameter + * is refused by the server rather than by anything in the request path, and the + * caller sees a 500 for a request the published schema told it to send. Year + * `0001` upward is storable and stays accepted, which leaves `0000` the single + * value the format admits and the column cannot hold. */ export function v2RunWindowBoundSchema(field: 'startDate' | 'endDate') { const boundary = field === 'startDate' ? 'at or after' : 'at or before' return z .string() .datetime({ error: `${field} must be a UTC ISO 8601 timestamp, e.g. 2026-08-06T00:00:00Z` }) + .refine((value) => new Date(value).getUTCFullYear() >= 1, { + error: `${field} must name a storable instant; there is no year 0000`, + }) .describe( - `Only include runs started ${boundary} this UTC ISO 8601 timestamp, e.g. \`2026-08-06T00:00:00Z\`. A date without a time, or a timestamp carrying a UTC offset instead of \`Z\`, is rejected.` + `Only include runs started ${boundary} this UTC ISO 8601 timestamp, e.g. \`2026-08-06T00:00:00Z\`. A date without a time, or a timestamp carrying a UTC offset instead of \`Z\`, is rejected, as is year \`0000\`, which names no storable instant.` ) .meta({ format: 'date-time' }) } +/** + * The single `order` param the two run-window reads take in place of + * `sortBy` + `sortOrder`, for the same reason they share + * {@link v2RunWindowBoundSchema}: `GET /logs` and `GET /workflows/{id}/runs` are + * sibling reads over the same runs, so a value that works on one must work on + * the other. + * + * Sharing it also keeps the *published* member order identical. Two hand-written + * `z.enum([...])` literals spelled the same set in opposite orders, which the + * generated specs faithfully reproduced — harmless to a parser, but it reads as + * two APIs rather than one, and a caller comparing the two pages has no way to + * tell an ordering accident from a meaningful difference. The order is + * {@link LIST_SORT_ORDERS}, the same one `sortOrder` publishes everywhere else. + */ +export function v2RunOrderSchema(subject: 'execution' | 'run') { + return z + .enum(LIST_SORT_ORDERS) + .optional() + .default('desc') + .describe( + `Sort direction by ${subject} start time. This list is sortable only by ${subject} start time, so it takes \`order\` in place of \`sortBy\`/\`sortOrder\`, which it rejects.` + ) +} + +/** + * Longest caller-supplied substring any v2 search accepts. Every one of them + * compiles to an unindexed `ILIKE` scan, so the term itself has to be bounded + * wherever it is accepted — including the searches that are not name searches. + */ +export const V2_SEARCH_MAX_LENGTH = 200 + +/** + * Added to `sortBy` wherever a text name column is sortable. + * + * Name ordering is `ORDER BY` on the stored text with no `COLLATE` and no + * `lower()`, so it is whatever the server database's collation does — under a + * `C`-collated deployment that is byte order, which puts every capitalized name + * ahead of every lowercase one. Nothing in the API pins the collation, so the + * spec must not promise one; what it can promise is that Sim does not case-fold, + * which is the part a caller gets wrong. + */ +export function nameSortCollation(field = 'name') { + return `Sorting by \`${field}\` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order.` +} + export const v2SearchSchema = z .string() .trim() .min(1, 'search cannot be empty') - .max(200, 'search is too long') + .max(V2_SEARCH_MAX_LENGTH, 'search is too long') .optional() - .describe('Case-insensitive substring search on the resource name.') + .describe('Case-insensitive substring match against the resource name.') + +/** + * Appended to every list folder-filter description. + * + * A folder filter is a filter: a path naming no active folder narrows the result + * to nothing, exactly as `workflowIds` naming no workflow does. These lists used + * to answer `404 Folder not found` instead, which reported a missing collection + * for a collection that exists, broke a pagination walk when a folder was + * deleted mid-walk, and made a list a folder-existence oracle. The sibling + * folder lists already answered a non-matching `parentPath` with an empty page. + * Mutations keep their 404 — creating into or moving to a folder that does not + * exist has no empty-set reading. + */ +export const V2_FOLDER_FILTER_MISS = + 'A path that names no folder narrows the result to nothing, so the response is an empty page rather than an error.' export const v2SortOrderSchema = z.enum(LIST_SORT_ORDERS).describe('Sort direction.') +/** + * The closed vocabulary `z.stringbool()` accepts, restated here only so the + * generated spec can publish it — Zod's defaults are internal to the library + * and contribute nothing to the JSON Schema. `shared.test.ts` pins each spelling + * against the schema so a Zod upgrade that changes the set fails here. + */ +export const V2_TRUE_VALUES = ['true', '1', 'yes', 'on', 'y', 'enabled'] as const +export const V2_FALSE_VALUES = ['false', '0', 'no', 'off', 'n', 'disabled'] as const + export type V2SortOrder = ListSortOrder function canonicalFolderPathSchema(parser: (path: string) => string[]) { @@ -330,16 +495,34 @@ function canonicalFolderPathSchema(parser: (path: string) => string[]) { }) } +/** + * The canonical-path rule, published once on the two folder-path components + * every folder family references rather than restated per operation. + * + * `canonicalFolderPathSchema` validates through `superRefine`, which + * contributes nothing to JSON Schema, so a folder path shipped as an + * unconstrained `string`: the percent-encoding, the rejections, and both caps + * were invisible to a spec-driven client. `maxLength` is the byte cap measured + * on the *encoded* form, so it is an upper bound on characters rather than a + * character count — a name outside the unreserved set spends up to twelve + * bytes per source character. + */ +const FOLDER_PATH_FORMAT = `Segments are percent-encoded, so a folder shown as "New folder" is \`/New%20folder\`: everything outside \`A-Z a-z 0-9 - _ . ~\` is escaped as uppercase hex, and only that exact encoding is accepted. A trailing slash, an empty segment, and a literal \`.\` or \`..\` segment are rejected. At most ${MAX_FOLDER_PATH_SEGMENTS} segments and ${MAX_FOLDER_PATH_BYTES} encoded bytes.` + /** Canonical slash-prefixed folder path. `/` is the workspace root. */ -export const v2FolderPathSchema = canonicalFolderPathSchema(parseFolderPath).describe( - 'Canonical slash-prefixed folder path. `/` is the workspace root.' -) +export const v2FolderPathSchema = canonicalFolderPathSchema(parseFolderPath).meta({ + title: 'Folder path', + description: `Canonical slash-prefixed folder path. \`/\` is the workspace root. ${FOLDER_PATH_FORMAT}`, + maxLength: MAX_FOLDER_PATH_BYTES, +}) export type V2FolderPath = z.output /** Canonical path that identifies a real folder rather than the virtual root. */ -export const v2NonRootFolderPathSchema = canonicalFolderPathSchema( - requireNonRootFolderPath -).describe('Canonical slash-prefixed path identifying a real folder rather than the root.') +export const v2NonRootFolderPathSchema = canonicalFolderPathSchema(requireNonRootFolderPath).meta({ + title: 'Non-root folder path', + description: `Canonical slash-prefixed path identifying a real folder rather than the root. ${FOLDER_PATH_FORMAT}`, + maxLength: MAX_FOLDER_PATH_BYTES, +}) function normalizeFolderPathInput(path: string): string { return path.length === 0 || path.startsWith('/') ? path : `/${path}` @@ -350,14 +533,24 @@ export const v2FolderPathInputSchema = z .string() .transform(normalizeFolderPathInput) .pipe(v2FolderPathSchema) - .describe('Folder path. A missing leading slash is normalized before validation.') + .meta({ + id: 'FolderPathInput', + title: 'Folder path input', + description: `Folder path. A missing leading slash is normalized before validation. ${FOLDER_PATH_FORMAT}`, + maxLength: MAX_FOLDER_PATH_BYTES, + }) /** Non-root input path that accepts an omitted leading slash and emits the canonical form. */ export const v2NonRootFolderPathInputSchema = z .string() .transform(normalizeFolderPathInput) .pipe(v2NonRootFolderPathSchema) - .describe('Non-root folder path. A missing leading slash is normalized before validation.') + .meta({ + id: 'NonRootFolderPathInput', + title: 'Non-root folder path input', + description: `Non-root folder path. A missing leading slash is normalized before validation. ${FOLDER_PATH_FORMAT}`, + maxLength: MAX_FOLDER_PATH_BYTES, + }) export const v2FolderSchema = z .object({ @@ -425,10 +618,29 @@ export const v2DeleteFolderQuerySchema = z .object({ workspaceId: workspaceIdSchema.describe('Workspace containing the folder.'), path: v2NonRootFolderPathInputSchema.describe('Path of the folder to delete.'), + /** + * Published as an enum rather than the bare `type: string` `z.stringbool()` + * emits. This is the difference between deleting one empty folder and + * deleting a subtree, and the accepted vocabulary is closed — an + * out-of-vocabulary value is a `400`, not a silent `false` — so leaving it + * undeclared hid a destructive switch behind a guess. + * + * `case: 'sensitive'` is what makes "closed" true. `z.stringbool()` folds + * case by default, so the server honoured `recursive=True`, `TRUE`, `YES` + * and `ENABLED` as a recursive delete while publishing only the twelve + * lowercase spellings — a generated client validates against the `enum` and + * would reject a request the server would have executed destructively. + * Accepting exactly what is published is the safe direction to close that + * gap: an unpublished spelling now fails the request instead of deleting a + * subtree. + */ recursive: z - .stringbool() + .stringbool({ case: 'sensitive' }) .prefault('false') - .describe('Delete nested files and folders when true.'), + .describe( + "Delete the folder's nested files and folders too. An empty folder deletes either way; a non-empty one needs this. The listed spellings are the whole accepted vocabulary and are case-sensitive; any other value is rejected." + ) + .meta({ enum: [...V2_TRUE_VALUES, ...V2_FALSE_VALUES] }), }) .strict() @@ -441,8 +653,11 @@ export function v2SortFields( fields: F, defaults: { sortBy: F[number]; sortOrder: V2SortOrder } ) { + const sortByDescription = fields.includes('name') + ? `Field used to sort the result. ${nameSortCollation()}` + : 'Field used to sort the result.' return { - sortBy: z.enum(fields).default(defaults.sortBy).describe('Field used to sort the result.'), + sortBy: z.enum(fields).default(defaults.sortBy).describe(sortByDescription), sortOrder: v2SortOrderSchema.default(defaults.sortOrder).describe('Sort direction.'), } } diff --git a/apps/sim/lib/api/contracts/v2/skills.ts b/apps/sim/lib/api/contracts/v2/skills.ts index 31a182d3b67..fe2a8cd026d 100644 --- a/apps/sim/lib/api/contracts/v2/skills.ts +++ b/apps/sim/lib/api/contracts/v2/skills.ts @@ -1,5 +1,5 @@ import { z } from 'zod' -import { nonEmptyIdSchema, workspaceIdSchema } from '@/lib/api/contracts/primitives' +import { noInputSchema, nonEmptyIdSchema, workspaceIdSchema } from '@/lib/api/contracts/primitives' import { skillContentSchema, skillDescriptionSchema, @@ -36,7 +36,11 @@ import { /** List item — everything but the skill body. */ export const v2SkillSummarySchema = z .object({ - id: z.string().describe('Unique skill identifier. Built-in skills use their name as the id.'), + id: z + .string() + .describe( + 'Unique skill identifier. A built-in skill is `builtin-` followed by its name, for example `builtin-research`.' + ), name: z.string().describe('Kebab-case name that agents use to reference the skill.'), description: z.string().describe('One-line summary of when the skill applies.'), /** True for built-in template skills, which ship with Sim and cannot be written to. */ @@ -83,14 +87,16 @@ export type V2SkillDeleteData = z.output export const v2SkillParamsSchema = z.object({ id: nonEmptyIdSchema.describe( - 'Skill to retrieve, update, or delete. Built-in skills use their name as the id.' + 'Unique skill identifier. A built-in skill is `builtin-` followed by its name, for example `builtin-research`.' ), }) export type V2SkillParams = z.output -export const v2SkillWorkspaceQuerySchema = z.object({ - workspaceId: workspaceIdSchema.describe('Workspace that owns the skill.'), -}) +export const v2SkillWorkspaceQuerySchema = z + .object({ + workspaceId: workspaceIdSchema.describe('Workspace that owns the skill.'), + }) + .strict() export type V2SkillWorkspaceQuery = z.output export const v2SkillSortFields = ['name', 'createdAt', 'updatedAt'] as const @@ -166,6 +172,7 @@ export const v2ListSkillsContract = defineRouteContract({ export const v2CreateSkillContract = defineRouteContract({ method: 'POST', path: '/api/v2/skills', + query: noInputSchema, body: v2CreateSkillBodySchema, response: { mode: 'json', @@ -188,6 +195,7 @@ export const v2GetSkillContract = defineRouteContract({ export const v2UpdateSkillContract = defineRouteContract({ method: 'PATCH', path: '/api/v2/skills/[id]', + query: noInputSchema, params: v2SkillParamsSchema, body: v2UpdateSkillBodySchema, response: { diff --git a/apps/sim/lib/api/contracts/v2/tables.ts b/apps/sim/lib/api/contracts/v2/tables.ts index 391e10b0758..a0ff1b6984a 100644 --- a/apps/sim/lib/api/contracts/v2/tables.ts +++ b/apps/sim/lib/api/contracts/v2/tables.ts @@ -1,5 +1,5 @@ import { z } from 'zod' -import { workspaceIdSchema } from '@/lib/api/contracts/primitives' +import { noInputSchema, workspaceIdSchema } from '@/lib/api/contracts/primitives' import { addWorkflowGroupBodySchema, cancelTableRunsBodyBaseSchema, @@ -43,6 +43,8 @@ import { v1ListTablesQuerySchema, } from '@/lib/api/contracts/v1/tables' import { + V2_FOLDER_FILTER_MISS, + V2_SEARCH_MAX_LENGTH, v2CreateFolderBodySchema, v2CursorListResponse, v2DataResponse, @@ -337,7 +339,7 @@ export const v2ListTablesQuerySchema = z workspaceId: workspaceIdSchema.describe('Workspace whose tables should be listed.'), folderPath: v2FolderPathInputSchema .optional() - .describe('Restrict results to tables in this folder.'), + .describe(`Restrict results to tables in this folder. ${V2_FOLDER_FILTER_MISS}`), search: v2SearchSchema, ...v2SortFields(v2TableSortFields, { sortBy: 'createdAt', sortOrder: 'asc' }), ...v2PaginationFields({ @@ -440,6 +442,7 @@ export const v2ListTablesContract = defineRouteContract({ export const v2CreateTableContract = defineRouteContract({ method: 'POST', path: '/api/v2/tables', + query: noInputSchema, body: v2CreateTableBodySchema, response: { mode: 'json', @@ -500,6 +503,7 @@ export const v2UpdateTableBodySchema = z export const v2UpdateTableContract = defineRouteContract({ method: 'PATCH', path: '/api/v2/tables/[tableId]', + query: noInputSchema, params: tableIdParamsSchema, body: v2UpdateTableBodySchema, response: { @@ -530,12 +534,13 @@ export const v2ListTableFoldersContract = defineRouteContract({ method: 'GET', path: '/api/v2/tables/folders', query: v2ListFoldersQuerySchema, - response: { mode: 'json', schema: v2CursorListResponse(v2FolderSchema) }, + response: { mode: 'json', schema: v2CursorListResponse(v2FolderSchema, { paged: false }) }, }) export const v2CreateTableFolderContract = defineRouteContract({ method: 'POST', path: '/api/v2/tables/folders', + query: noInputSchema, body: v2CreateFolderBodySchema, response: { mode: 'json', schema: v2DataResponse(v2FolderSchema), status: 201 }, }) @@ -543,6 +548,7 @@ export const v2CreateTableFolderContract = defineRouteContract({ export const v2RelocateTableFolderContract = defineRouteContract({ method: 'PATCH', path: '/api/v2/tables/folders', + query: noInputSchema, body: v2RelocateFolderBodySchema, response: { mode: 'json', schema: v2DataResponse(v2FolderSchema) }, }) @@ -618,20 +624,24 @@ export const v2UpdateTableColumnBodySchema = z export type V2UpdateTableColumnBody = z.input +/** `201`, like every other v2 create; the body is the table's full column set. */ export const v2AddTableColumnContract = defineRouteContract({ method: 'POST', path: '/api/v2/tables/[tableId]/columns', + query: noInputSchema, params: tableIdParamsSchema, body: v2CreateTableColumnBodySchema, response: { mode: 'json', schema: v2DataResponse(v2TableColumnsDataSchema), + status: 201, }, }) export const v2UpdateTableColumnContract = defineRouteContract({ method: 'PATCH', path: '/api/v2/tables/[tableId]/columns', + query: noInputSchema, params: tableIdParamsSchema, body: v2UpdateTableColumnBodySchema, response: { @@ -652,6 +662,7 @@ export type V2DeleteTableColumnBody = z.input @@ -782,6 +797,7 @@ export type V2QueryRowsCountData = z.output export const v2QueryRowsContract = defineRouteContract({ method: 'POST', path: '/api/v2/tables/[tableId]/query', + query: noInputSchema, params: tableIdParamsSchema, body: v2QueryRowsBodySchema, response: { @@ -803,6 +819,7 @@ export const v2QueryRowsContract = defineRouteContract({ export const v2QueryRowsCountContract = defineRouteContract({ method: 'POST', path: '/api/v2/tables/[tableId]/query/count', + query: noInputSchema, params: tableIdParamsSchema, body: v2QueryRowsCountBodySchema, response: { @@ -851,14 +868,23 @@ export const v2CreateTableRowsBodySchema = z.union( } ) +/** + * `201` on both arms of the union. The batch arm returns a count rather than one + * created resource and neither arm carries a `Location`, but no v2 create does — + * the status describes what happened to the server, and rows were created. A + * caller that has to read the body to learn whether its POST created anything is + * exactly what a uniform create status prevents. + */ export const v2CreateTableRowsContract = defineRouteContract({ method: 'POST', path: '/api/v2/tables/[tableId]/rows', + query: noInputSchema, params: tableIdParamsSchema, body: v2CreateTableRowsBodySchema, response: { mode: 'json', schema: z.union([v2CreateSingleTableRowResponseSchema, v2CreateBatchTableRowsResponseSchema]), + status: 201, }, }) @@ -880,6 +906,7 @@ export type V2UpdateRowsByPredicateBody = z.input export const v2DeleteTableRowsContract = defineRouteContract({ method: 'DELETE', path: '/api/v2/tables/[tableId]/rows', + query: noInputSchema, params: tableIdParamsSchema, body: v2DeleteTableRowsBodySchema, response: { @@ -948,7 +976,7 @@ export const v2UpsertTableRowBodySchema = upsertTableRowBodySchema .omit(OMIT_PRIVATE_PROVENANCE) .extend({ data: v2RowDataSchema.describe( - 'Complete set of row cells keyed by column name. On the update branch this REPLACES the matched row: any column not present here is cleared, unlike the merging PATCH /rows/{rowId}.' + 'Complete set of row cells keyed by column name. On the update branch this REPLACES the matched row: any column not present here is cleared, unlike the merging `PATCH /api/v2/tables/{tableId}/rows/{rowId}`.' ), }) .strict() @@ -967,6 +995,7 @@ export const v2GetTableRowContract = defineRouteContract({ export const v2UpdateTableRowContract = defineRouteContract({ method: 'PATCH', path: '/api/v2/tables/[tableId]/rows/[rowId]', + query: noInputSchema, params: tableRowParamsSchema, body: v2UpdateTableRowBodySchema, response: { @@ -989,6 +1018,7 @@ export const v2DeleteTableRowContract = defineRouteContract({ export const v2UpsertTableRowContract = defineRouteContract({ method: 'POST', path: '/api/v2/tables/[tableId]/rows/upsert', + query: noInputSchema, params: tableIdParamsSchema, body: v2UpsertTableRowBodySchema, response: { @@ -1018,8 +1048,22 @@ const v2TableViewPredicateOutputSchema = z 'Recursive saved predicate tree. Runtime validation uses the canonical predicate schema.' ) as z.ZodType> +/** + * Every column reference in a v2 view config — the layout keys, `sort[].field`, + * and each `filter` leaf `field` — is a column **NAME**, like row `data`, query + * predicates, and workflow groups on this surface. The stored blob keys on + * stable column ids so a rename cannot orphan a view; the route translates in + * both directions, so a config reads back in the vocabulary it was written in. + */ export const v2TableViewConfigSchema = tableMetadataSchema .extend({ + columnWidths: z + .record(z.string(), z.number().positive()) + .optional() + .describe('Column widths keyed by column name.'), + columnOrder: z.array(z.string()).optional().describe('Column names in display order.'), + pinnedColumns: z.array(z.string()).optional().describe('Names of pinned columns.'), + hiddenColumns: z.array(z.string()).optional().describe('Names of hidden columns.'), filter: v2TableViewPredicateOutputSchema .nullable() .optional() @@ -1083,9 +1127,10 @@ export const v2DeleteTableViewDataSchema = z export type V2DeleteTableViewData = z.output /** - * Every saved view on a table, oldest first. A table carries a small bounded - * set of views, so this is a single full page (`nextCursor` is always `null`); - * the cursor envelope keeps the v2 list surface uniform. + * Every saved view on a table, oldest first. The create path enforces + * `TABLE_LIMITS.MAX_VIEWS_PER_TABLE`, so the set is bounded and this is a single + * full page (`nextCursor` is always `null`); the cursor envelope keeps the v2 + * list surface uniform. */ export const v2ListTableViewsContract = defineRouteContract({ method: 'GET', @@ -1094,7 +1139,7 @@ export const v2ListTableViewsContract = defineRouteContract({ query: v2TableWorkspaceQuerySchema, response: { mode: 'json', - schema: v2CursorListResponse(v2ApiViewSchema), + schema: v2CursorListResponse(v2ApiViewSchema, { paged: false }), }, }) @@ -1114,6 +1159,7 @@ export type V2UpdateTableViewBody = z.input export const v2CreateTableViewContract = defineRouteContract({ method: 'POST', path: '/api/v2/tables/[tableId]/views', + query: noInputSchema, params: tableIdParamsSchema, body: v2CreateTableViewBodySchema, response: { @@ -1137,6 +1183,7 @@ export const v2GetTableViewContract = defineRouteContract({ export const v2UpdateTableViewContract = defineRouteContract({ method: 'PATCH', path: '/api/v2/tables/[tableId]/views/[viewId]', + query: noInputSchema, params: tableViewParamsSchema, body: v2UpdateTableViewBodySchema, response: { @@ -1184,7 +1231,7 @@ export const v2WorkflowGroupSchema = z blockId: z.string().describe('Workflow block producing this output.'), path: z.string().describe('Path to the value in the workflow block output.'), outputId: z.string().optional().describe('Registry enrichment output identifier.'), - columnName: z.string().describe('Table column receiving the output.'), + columnName: z.string().describe('Name of the table column receiving the output.'), }) ) .describe('Workflow outputs mapped to table columns.'), @@ -1192,7 +1239,7 @@ export const v2WorkflowGroupSchema = z .array( z.object({ inputName: z.string().describe('Workflow input name.'), - columnName: z.string().describe('Source table column name.'), + columnName: z.string().describe('Name of the source table column.'), }) ) .optional() @@ -1219,7 +1266,7 @@ export const v2ListWorkflowGroupsContract = defineRouteContract({ query: v2TableWorkspaceQuerySchema, response: { mode: 'json', - schema: v2CursorListResponse(v2WorkflowGroupSchema), + schema: v2CursorListResponse(v2WorkflowGroupSchema, { paged: false }), }, }) @@ -1283,6 +1330,21 @@ export const v2AddWorkflowGroupBodySchema = z .min(1) .optional() .describe('Optional client-provided workflow-group identifier.'), + /** + * The first-party shape defaults this to `''`, which published a + * `default: ""` the surface does not honor: a `manual` group — the + * type you get by omitting `type` — that omits `workflowId` is refused + * by `refineGroupSource`, so the spec promised a fallback that always + * 400s. Optional with no default and a description that names the + * condition is what is actually true. + */ + workflowId: z + .string() + .min(1, 'workflowId cannot be empty') + .optional() + .describe( + 'Backing workflow identifier. Required when `type` is `manual` (which is also the default when `type` is omitted); omit it for an `enrichment` group.' + ), }) .describe('Workflow or enrichment producer definition.'), outputColumns: z @@ -1350,6 +1412,7 @@ export type V2DeleteWorkflowGroupData = z.output export const v2RunTableColumnContract = defineRouteContract({ method: 'POST', path: '/api/v2/tables/[tableId]/columns/run', + query: noInputSchema, params: tableIdParamsSchema, body: v2RunColumnBodySchema, response: { @@ -1441,6 +1507,7 @@ export type V2RowEnrichmentParams = z.output export const v2RunRowEnrichmentContract = defineRouteContract({ method: 'POST', path: '/api/v2/tables/[tableId]/rows/[rowId]/enrichment/[groupId]', + query: noInputSchema, params: v2RowEnrichmentParamsSchema, body: v2WorkspaceScopedBodySchema, response: { @@ -1460,6 +1527,7 @@ export const v2FindRowsBodySchema = z q: z .string() .min(1, 'q must be a non-empty search string') + .max(V2_SEARCH_MAX_LENGTH, 'q is too long') .describe('Case-insensitive cell substring to find.'), predicate: predicateSchema.optional(), sort: sortSpecSchema.optional().describe('Ordered table-row sort specification.'), @@ -1487,14 +1555,21 @@ export const v2RowMatchSchema = z export type V2RowMatch = z.output /** - * Match set. `truncated` is `true` when the search hit the server-side cap and - * more cells match than were returned — narrow the predicate rather than - * paging, since matches have no cursor. + * Match set. `truncated` is `true` when the search hit the server-side cap of + * {@link TABLE_LIMITS.MAX_FIND_MATCHES} and more cells match than were returned + * — narrow the predicate rather than paging, since matches have no cursor. */ export const v2FindRowsDataSchema = z .object({ - matches: z.array(v2RowMatchSchema).describe('Matching table cells.'), - truncated: z.boolean().describe('Whether more matches exist beyond the server cap.'), + matches: z + .array(v2RowMatchSchema) + .max(TABLE_LIMITS.MAX_FIND_MATCHES) + .describe(`Matching table cells, at most ${TABLE_LIMITS.MAX_FIND_MATCHES}.`), + truncated: z + .boolean() + .describe( + `Whether more than ${TABLE_LIMITS.MAX_FIND_MATCHES} cells matched, so the list was cut.` + ), }) .meta({ id: 'V2FindRowsData', @@ -1506,6 +1581,7 @@ export type V2FindRowsData = z.output export const v2FindTableRowsContract = defineRouteContract({ method: 'POST', path: '/api/v2/tables/[tableId]/rows/find', + query: noInputSchema, params: tableIdParamsSchema, body: v2FindRowsBodySchema, response: { @@ -1520,9 +1596,11 @@ export const v2TableImportParamsSchema = z.object({ export const v2TableExportParamsSchema = z.object({ exportId: z.string().min(1).describe('Unique table-export identifier.'), }) -export const v2TableTransferWorkspaceQuerySchema = z.object({ - workspaceId: workspaceIdSchema.describe('Workspace that owns the transfer resource.'), -}) +export const v2TableTransferWorkspaceQuerySchema = z + .object({ + workspaceId: workspaceIdSchema.describe('Workspace that owns the transfer resource.'), + }) + .strict() export const v2TableOptionalUploadTokenHeadersSchema = v2OptionalUploadTokenHeadersSchema.extend({ 'upload-token': v2OptionalUploadTokenHeadersSchema.shape['upload-token'].describe( @@ -1669,9 +1747,17 @@ export const v2CreateTableImportBodySchema = z }) export type V2CreateTableImportBody = z.input +/** + * Every state an import can be read in, and nothing else. + * + * `uploading` and `expired` come from the upload session that backs an + * upload-sourced import; the other four are projections of the durable job's + * status. There is deliberately no `queued`: a job row exists only once its + * runner has started it, so an import is never observable between creation and + * `processing`. + */ export const v2TableImportStatusSchema = z.enum([ 'uploading', - 'queued', 'processing', 'completed', 'failed', @@ -1737,8 +1823,12 @@ export const v2CreateTableImportDataSchema = z session: v2WorkspaceFileTableImportSchema.describe( 'Created workspace-file import session.' ), - uploadToken: z.null().describe('Always null for workspace-file imports.'), - transfer: z.null().describe('Always null for workspace-file imports.'), + uploadToken: z + .null() + .describe('Always null; a workspace-file import has no upload to authorize.'), + transfer: z + .null() + .describe('Always null; a workspace-file import has no bytes to transfer.'), }) .strict(), ]) @@ -1752,6 +1842,7 @@ export type V2CreateTableImportData = z.output export const v2CancelTableRunsContract = defineRouteContract({ method: 'POST', path: '/api/v2/tables/[tableId]/cancel-runs', + query: noInputSchema, params: tableIdParamsSchema, body: v2CancelTableRunsBodySchema, response: { diff --git a/apps/sim/lib/api/contracts/v2/uploads.ts b/apps/sim/lib/api/contracts/v2/uploads.ts index 8cb2d1803eb..feb2f6543bf 100644 --- a/apps/sim/lib/api/contracts/v2/uploads.ts +++ b/apps/sim/lib/api/contracts/v2/uploads.ts @@ -25,10 +25,28 @@ export const v2OptionalUploadTokenHeadersSchema = z.object({ 'upload-token': z.string().min(1, 'upload-token header cannot be empty').optional(), }) +/** + * What a caller needs about the transfer step, stated in the published document + * rather than only in the source. + * + * The URL a transfer hands back can point at object storage or, on a + * self-hosted deployment, at Sim's own local data plane — so the endpoint is + * described by this field rather than by an operation of its own, and no + * OpenAPI document declares it. That is deliberate (the URL is signed, + * short-lived, and never constructed from docs), but it left the one step that + * actually moves the bytes with no published status codes at all. This is that + * contract. + */ +const TRANSFER_STEP_CONTRACT = + 'Send the bytes with `PUT` to this URL, including exactly the headers in `headers` and nothing that alters the body. Success is `204` with an empty body. A failure is the same `{ "error": { "code", "message" } }` envelope as every other v2 response: `400` when the body does not match the size or content type the session was created for, `403` when the token is invalid, expired, or belongs to another session, and `409` when the session is no longer accepting bytes. The URL is signed and self-describing — construct it from this field only, never by hand.' + export const v2PutUploadTransferSchema = z .object({ method: z.literal('put').describe('Upload strategy discriminator.'), - url: z.string().url().describe('Signed URL to which the file bytes are uploaded.'), + url: z + .string() + .url() + .describe(`Signed URL to which the file bytes are uploaded. ${TRANSFER_STEP_CONTRACT}`), headers: z .record(z.string(), z.string()) .describe('Headers that must be included with the upload request.'), @@ -82,7 +100,7 @@ export type V2PartUrlsBody = z.input export const v2UploadPartUrlSchema = z .object({ partNumber: z.number().int().min(1).describe('Multipart part number.'), - url: z.string().url().describe('Signed URL for this upload part.'), + url: z.string().url().describe(`Signed URL for this upload part. ${TRANSFER_STEP_CONTRACT}`), headers: z .record(z.string(), z.string()) .describe('Headers that must be included with the part upload.'), diff --git a/apps/sim/lib/api/contracts/v2/workflow-deployment-requests.test.ts b/apps/sim/lib/api/contracts/v2/workflow-deployment-requests.test.ts new file mode 100644 index 00000000000..28d76c57727 --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/workflow-deployment-requests.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from 'vitest' +import { DEPLOYMENT_VERSION_MAX } from '@/lib/api/contracts/deployments' +import { + v2DeployWorkflowContract, + v2GetWorkflowRunContract, + v2RollbackWorkflowContract, + v2WorkflowVersionCursorSchema, +} from '@/lib/api/contracts/v2/workflows' + +/** + * The v2 deployment requests carry at most one meaningful field each, and every + * one of them has a legitimate "omitted" meaning. A stripping schema therefore + * cannot tell a deliberate omission from a misspelled key, so it answers 200 + * having done something other than what the caller asked for. These pin the + * strictness that makes the two distinguishable, plus the `integer` bound every + * caller-supplied deployment version has to respect before it reaches SQL. + */ +describe('v2 deployment request contracts', () => { + const deployBody = v2DeployWorkflowContract.body + const rollbackBody = v2RollbackWorkflowContract.body + + it('rejects a misspelled deploy metadata field instead of deploying unnamed', () => { + expect(deployBody.safeParse({ nmae: 'Escalation routing' }).success).toBe(false) + }) + + it('accepts the deploy fields it documents', () => { + expect(deployBody.parse({ name: 'Escalation routing', description: 'note' })).toEqual({ + name: 'Escalation routing', + description: 'note', + }) + }) + + it('rejects a misspelled rollback version instead of rolling back to the previous one', () => { + expect(rollbackBody.safeParse({ versoin: 5 }).success).toBe(false) + }) + + /** + * The one behavior strictness must not take away: rollback with no body at + * all still means "reactivate the version preceding the active one". + */ + it('keeps an omitted rollback version meaning the previous version', () => { + expect(rollbackBody.parse(undefined)).toEqual({}) + expect(rollbackBody.parse({})).toEqual({}) + }) + + it('rejects a rollback version past the range its column can hold', () => { + expect(rollbackBody.safeParse({ version: DEPLOYMENT_VERSION_MAX }).success).toBe(true) + expect(rollbackBody.safeParse({ version: DEPLOYMENT_VERSION_MAX + 1 }).success).toBe(false) + }) + + it('bounds the version a forged versions cursor can carry into the query', () => { + expect(v2WorkflowVersionCursorSchema.safeParse({ version: 2 }).success).toBe(true) + expect( + v2WorkflowVersionCursorSchema.safeParse({ version: DEPLOYMENT_VERSION_MAX + 1 }).success + ).toBe(false) + expect(v2WorkflowVersionCursorSchema.safeParse({ version: 0 }).success).toBe(false) + expect(v2WorkflowVersionCursorSchema.safeParse({ version: 'two' }).success).toBe(false) + expect(v2WorkflowVersionCursorSchema.safeParse({}).success).toBe(false) + }) + + /** + * `includeOutputs` is the plural typo of `includeOutput`. Stripped, it makes + * the run read answer 200 with `output: null`, which is indistinguishable + * from a run that genuinely produced nothing. + */ + it('rejects a misspelled run-output flag instead of reporting a null output', () => { + const query = v2GetWorkflowRunContract.query + expect(query.safeParse({ includeOutputs: 'true' }).success).toBe(false) + expect(query.parse({ includeOutput: 'true' })).toMatchObject({ includeOutput: true }) + }) +}) diff --git a/apps/sim/lib/api/contracts/v2/workflows.ts b/apps/sim/lib/api/contracts/v2/workflows.ts index 167e8244e2a..eb075e9ddc7 100644 --- a/apps/sim/lib/api/contracts/v2/workflows.ts +++ b/apps/sim/lib/api/contracts/v2/workflows.ts @@ -3,10 +3,16 @@ import { activeDeploymentSummarySchema, deployedWorkflowStateSchema, deploymentOperationSummarySchema, + deploymentVersionNumberSchema, deploymentVersionParamsSchema, deploymentVersionSchema, } from '@/lib/api/contracts/deployments' -import { booleanQueryFlagSchema, workspaceIdSchema } from '@/lib/api/contracts/primitives' +import { + booleanQueryFlagSchema, + noInputSchema, + runIdSchema, + workspaceIdSchema, +} from '@/lib/api/contracts/primitives' import { defineRouteContract } from '@/lib/api/contracts/types' import { V1_IMPORT_DESCRIPTION_MAX_LENGTH, @@ -17,6 +23,7 @@ import { v1WorkflowExportPayloadSchema, } from '@/lib/api/contracts/v1/workflows' import { + V2_FOLDER_FILTER_MISS, v2CreateFolderBodySchema, v2CursorListResponse, v2DataResponse, @@ -27,6 +34,7 @@ import { v2ListFoldersQuerySchema, v2PaginationFields, v2RelocateFolderBodySchema, + v2RunOrderSchema, v2RunWindowBoundSchema, v2SearchSchema, v2SortFields, @@ -42,14 +50,7 @@ import { PERSISTED_WORKFLOW_EXECUTION_STATUSES } from '@/lib/logs/types' export const V2_WORKFLOW_RUN_ID_HEADER = 'X-Run-Id' -export const v2WorkflowRunIdSchema = z - .string() - .min(1, 'Invalid run ID') - .max(128, 'Run ID too long') - .regex( - /^[A-Za-z0-9._:-]+$/, - 'Run ID can only contain letters, numbers, dots, underscores, colons, and hyphens' - ) +export const v2WorkflowRunIdSchema = runIdSchema .describe('Unique workflow run identifier.') .meta({ examples: ['run_8f14e45f-ceea-467f-a'] }) @@ -62,10 +63,10 @@ export const v2WorkflowRunIdSchema = z * double-executes (fresh id per attempt) or hard-fails (same id per attempt). */ const X_RUN_ID_DESCRIPTION = - 'Caller-supplied run identifier, available only to API-key callers. This is a one-shot uniqueness claim, NOT an idempotency key: the first request to use a value starts a run, and any later request reusing it fails with 409 and `error.details.code: "RUN_ID_CONFLICT"` instead of replaying the original result. To retry safely, generate a fresh value per attempt and reconcile duplicates yourself, or omit the header and let the server allocate the run identifier.' + 'Caller-supplied run identifier, available only to API-key callers. A one-shot uniqueness claim, NOT an idempotency key: reusing a value fails with `409` and `error.details.code: "RUN_ID_CONFLICT"` rather than replaying the original result. To retry safely, send a fresh value per attempt, or omit the header and let the server allocate one.' const X_SIM_VIA_DESCRIPTION = - 'Comma-separated workflow identifiers describing the workflow-to-workflow call chain that led to this request. Each hop appends its own workflow id, and Sim sets it automatically when one workflow calls another; supply it yourself only when relaying an existing chain. A chain already at the maximum depth is rejected with 409 and `error.details.code: "CALL_CHAIN_DEPTH_EXCEEDED"`, which is how runaway recursion between workflows is stopped.' + 'Comma-separated workflow identifiers naming the workflow-to-workflow call chain that led to this request. Each hop appends its own workflow id, and Sim sets it automatically; supply it yourself only when relaying an existing chain. A chain at the maximum depth is rejected with `409` and `error.details.code: "CALL_CHAIN_DEPTH_EXCEEDED"`.' export const v2ExecuteWorkflowHeadersSchema = z .object({ @@ -76,7 +77,7 @@ export const v2ExecuteWorkflowHeadersSchema = z id: 'ExecuteWorkflowHeaders', title: 'Execute workflow headers', description: - 'Optional one-shot run-identifier claim and workflow call-chain marker for a workflow execution. Reusing an `X-Run-Id` returns 409 and `error.details.code: "RUN_ID_CONFLICT"`; it does not replay the earlier run. An `X-Sim-Via` chain at maximum depth returns 409 and `error.details.code: "CALL_CHAIN_DEPTH_EXCEEDED"`.', + 'Optional one-shot run-identifier claim and workflow call-chain marker for a workflow execution.', }) export type V2ExecuteWorkflowHeaders = z.input @@ -132,7 +133,7 @@ export const v2ListWorkflowsQuerySchema = z workspaceId: workspaceIdSchema.describe('Workspace whose workflows should be listed.'), folderPath: v2FolderPathInputSchema .optional() - .describe('Restrict results to workflows in this folder path.'), + .describe(`Restrict results to workflows in this folder path. ${V2_FOLDER_FILTER_MISS}`), deployedOnly: booleanQueryFlagSchema .optional() .default(false) @@ -171,11 +172,31 @@ export const v2WorkflowListItemSchema = z .nullable() .describe('ISO 8601 activation timestamp, or null when not deployed.') .meta({ format: 'date-time' }), - runCount: z.number().int().nonnegative().describe('Total recorded workflow runs.'), + /** + * A monotonic column on the workflow row, not an aggregate over the run + * list. `updateWorkflowRunCounts` is called from exactly one place — + * `executeWorkflowCore`'s post-execution hook, under + * `result.success && result.status !== 'paused'` — and nothing ever + * decrements it, so the two ways it disagrees with + * `GET /workflows/{id}/runs` point in opposite directions and both are + * reachable at once. The description is what makes that legible; the + * counter itself is left alone because its stored values already carry the + * narrow meaning and no backfill can recover runs whose logs retention has + * already deleted. + */ + runCount: z + .number() + .int() + .nonnegative() + .describe( + 'Runs that finished successfully. Failed, cancelled, and paused runs are not counted, and the counter is never reduced when a run ages out of log retention — so it does not match the size of `GET /api/v2/workflows/{id}/runs`, in either direction.' + ), lastRunAt: z .string() .nullable() - .describe('ISO 8601 timestamp of the latest run, or null when never run.') + .describe( + 'ISO 8601 timestamp of the latest run counted by `runCount`, or null when none has been. Stamped by the same successful-run path, so a workflow whose only runs failed reports null here.' + ) .meta({ format: 'date-time' }), createdAt: z .string() @@ -322,7 +343,7 @@ export const v2DeployWorkflowDataSchema = v2DeploymentStateSchema id: 'DeployResult', title: 'Deploy result', description: - 'Deployment attempt accepted for processing. Activation is asynchronous; `latestDeploymentAttempt` on this response is the attempt handle. The request is NOT idempotent — every POST mints a new deployment version, so a retry after a timeout creates a second version rather than returning the first. `latestDeploymentAttempt` is returned only here: `GET /workflows/{id}` does not carry it, so poll activation with `isDeployed` and `deployedAt` on the workflow, or with `isActive` on `GET /workflows/{id}/versions`.', + 'Deployment attempt accepted for processing. Activation is asynchronous, and `latestDeploymentAttempt` is the attempt handle — returned only here. Poll activation with `isDeployed` and `deployedAt` on the workflow, or `isActive` on `GET /workflows/{id}/versions`.', }) export type V2DeployWorkflowData = z.output @@ -359,6 +380,7 @@ export const v2ListWorkflowsContract = defineRouteContract({ export const v2GetWorkflowContract = defineRouteContract({ method: 'GET', path: '/api/v2/workflows/[id]', + query: noInputSchema, params: v2WorkflowIdParamsSchema, response: { mode: 'json', @@ -462,6 +484,7 @@ export type V2DeleteWorkflowData = z.output export const v2CreateWorkflowContract = defineRouteContract({ method: 'POST', path: '/api/v2/workflows', + query: noInputSchema, body: v2CreateWorkflowBodySchema, response: { mode: 'json', @@ -473,6 +496,7 @@ export const v2CreateWorkflowContract = defineRouteContract({ export const v2UpdateWorkflowContract = defineRouteContract({ method: 'PATCH', path: '/api/v2/workflows/[id]', + query: noInputSchema, params: v2WorkflowIdParamsSchema, body: v2UpdateWorkflowBodySchema, response: { @@ -484,6 +508,7 @@ export const v2UpdateWorkflowContract = defineRouteContract({ export const v2DeleteWorkflowContract = defineRouteContract({ method: 'DELETE', path: '/api/v2/workflows/[id]', + query: noInputSchema, params: v2WorkflowIdParamsSchema, response: { mode: 'json', @@ -521,12 +546,16 @@ export const v2ListWorkflowFoldersContract = defineRouteContract({ method: 'GET', path: '/api/v2/workflows/folders', query: v2ListFoldersQuerySchema, - response: { mode: 'json', schema: v2CursorListResponse(v2WorkflowFolderSchema) }, + response: { + mode: 'json', + schema: v2CursorListResponse(v2WorkflowFolderSchema, { paged: false }), + }, }) export const v2CreateWorkflowFolderContract = defineRouteContract({ method: 'POST', path: '/api/v2/workflows/folders', + query: noInputSchema, body: v2CreateFolderBodySchema, response: { mode: 'json', schema: v2DataResponse(v2WorkflowFolderSchema), status: 201 }, }) @@ -534,6 +563,7 @@ export const v2CreateWorkflowFolderContract = defineRouteContract({ export const v2RelocateWorkflowFolderContract = defineRouteContract({ method: 'PATCH', path: '/api/v2/workflows/folders', + query: noInputSchema, body: v2RelocateFolderBodySchema, response: { mode: 'json', schema: v2DataResponse(v2WorkflowFolderSchema) }, }) @@ -599,6 +629,19 @@ export const v2ListWorkflowVersionsQuerySchema = z }) export type V2ListWorkflowVersionsQuery = z.output +/** + * Payload of the opaque cursor this list mints. A cursor is caller-controlled + * bytes, so its decoded `version` is validated exactly like a request field — + * it is compared against the `integer` column, where an out-of-range value + * overflows the comparison rather than matching nothing. + */ +export const v2WorkflowVersionCursorSchema = z + .object({ + version: deploymentVersionNumberSchema.describe('Version at which the next page begins.'), + }) + .strict() +export type V2WorkflowVersionCursor = z.output + /** * A single version plus the workflow state it pins. `state` is the deployed * graph snapshot — the same portable blob the internal deployment reader @@ -620,7 +663,7 @@ export const v2WorkflowVersionDetailSchema = z .describe('ISO 8601 timestamp when this version was created.') .meta({ format: 'date-time' }), state: deployedWorkflowStateSchema.describe( - 'Deployed workflow graph snapshot pinned by this version. Credential-bearing values are redacted: `oauth-input`, `password: true`, and table sub-block values are null; sensitive nested tool parameters and every parameter without authoritative codec metadata are null.' + 'Deployed workflow graph snapshot pinned by this version, with credential-bearing values redacted to null: `oauth-input`, `password: true`, table sub-block values, sensitive nested tool parameters, and any parameter without authoritative codec metadata.' ), }) .meta({ @@ -644,6 +687,7 @@ export const v2ListWorkflowVersionsContract = defineRouteContract({ export const v2GetWorkflowVersionContract = defineRouteContract({ method: 'GET', path: '/api/v2/workflows/[id]/versions/[version]', + query: noInputSchema, params: v2DeploymentVersionParamsSchema, response: { mode: 'json', @@ -654,6 +698,7 @@ export const v2GetWorkflowVersionContract = defineRouteContract({ export const v2GetWorkflowDeploymentContract = defineRouteContract({ method: 'GET', path: '/api/v2/workflows/[id]/deployment', + query: noInputSchema, params: v2WorkflowIdParamsSchema, response: { mode: 'json', @@ -664,6 +709,7 @@ export const v2GetWorkflowDeploymentContract = defineRouteContract({ export const v2DeployWorkflowContract = defineRouteContract({ method: 'POST', path: '/api/v2/workflows/[id]/deploy', + query: noInputSchema, params: v2WorkflowIdParamsSchema, body: v1DeployWorkflowBodySchema .extend({ @@ -674,6 +720,7 @@ export const v2DeployWorkflowContract = defineRouteContract({ 'Optional release note for the deployment version.' ), }) + .strict() .optional() .default({}) .meta({ @@ -693,6 +740,7 @@ export const v2DeployWorkflowContract = defineRouteContract({ export const v2UndeployWorkflowContract = defineRouteContract({ method: 'DELETE', path: '/api/v2/workflows/[id]/deploy', + query: noInputSchema, params: v2WorkflowIdParamsSchema, response: { mode: 'json', @@ -700,9 +748,18 @@ export const v2UndeployWorkflowContract = defineRouteContract({ }, }) +/** + * Rollback carries a single optional field, and omitting it is a legitimate + * request meaning "reactivate the version preceding the active one". A + * stripping body schema therefore cannot distinguish an intentional omission + * from a misspelled `version`, and silently performs the wrong rollback while + * answering `200`. `.strict()` is what makes the two distinguishable, so it is + * load-bearing here rather than hygiene. + */ export const v2RollbackWorkflowContract = defineRouteContract({ method: 'POST', path: '/api/v2/workflows/[id]/rollback', + query: noInputSchema, params: v2WorkflowIdParamsSchema, body: v1RollbackWorkflowBodySchema .extend({ @@ -710,6 +767,7 @@ export const v2RollbackWorkflowContract = defineRouteContract({ 'Deployment version to reactivate. Omit to select the previous active version.' ), }) + .strict() .optional() .default({}) .meta({ @@ -758,13 +816,16 @@ export const v2ExecutionErrorSchema = z export type V2ExecutionError = z.output /** - * The mutually-exclusive execute option matrix, mirrored from the route's - * post-parse checks in `app/api/v2/workflows/[id]/execute/route.ts`. Kept as one + * That the execute options constrain each other, said once. Kept as one * exported string so the request-body description and the OpenAPI operation * description cannot drift from each other. + * + * It deliberately does not enumerate the combinations the route rejects: a + * caller reads a constraint where it applies, so each lives on `async`, + * `stream`, `executionTimeoutSeconds`, `includeThinking`, or `includeToolCalls`. */ export const EXECUTE_OPTION_CONSTRAINTS = - 'Option constraints — each is a 400: (1) `async: true` requires an API key; anonymous public-workflow callers may only execute synchronously or as a stream. (2) `async` and `stream` cannot both be true. (3) `executionTimeoutSeconds` is accepted only when `async: true`. (4) `async: true` rejects every streaming and output-shaping option — `selectedOutputs`, `includeThinking`, `includeToolCalls`, `includeFileBase64`, and `base64MaxBytes`. (5) `includeThinking` and `includeToolCalls` require `stream: true`. (6) `includeThinking` and `includeToolCalls` require the `X-Sim-Stream-Protocol: agent-events-v1` request header, which declares that the client understands agent-event frames.' + 'Each option carries the modes it requires and the modes that reject it; a violated combination is a 400.' /** * Strict public execute body. Async is body-selected (`async: true`) — v2 has @@ -772,8 +833,9 @@ export const EXECUTE_OPTION_CONSTRAINTS = * (triggerType, draft state, deployment pinning) are NEVER wire fields; they * are typed options on the execution service. * - * The six rejected option combinations are enumerated in - * {@link EXECUTE_OPTION_CONSTRAINTS} and enforced by the route after parsing. + * The rejected option combinations are enforced by the route after parsing and + * described on the fields they constrain; {@link EXECUTE_OPTION_CONSTRAINTS} + * only tells a caller that the options constrain each other. */ export const v2ExecuteWorkflowBodySchema = z .object({ @@ -801,7 +863,7 @@ export const v2ExecuteWorkflowBodySchema = z .max(MAX_WORKFLOW_EXECUTION_TIMEOUT_SECONDS) .optional() .describe( - "Requested server-side timeout for an asynchronous run, in seconds. This is an upper bound on the request, not the effective timeout: the run uses the smaller of this value and the account plan's execution timeout, so requesting more than the plan allows silently yields the plan timeout with no warning. Rejected with 400 unless `async` is true." + "Requested server-side timeout for an asynchronous run, in seconds. An upper bound, not the effective timeout: the run uses the smaller of this value and the plan's execution timeout, so requesting more than the plan allows silently yields the plan timeout. Rejected with `400` unless `async` is true." ), stream: z .boolean() @@ -834,7 +896,7 @@ export const v2ExecuteWorkflowBodySchema = z includeFileBase64: z .boolean() .optional() - .describe('Inline eligible output files as base64 content.'), + .describe('Inline eligible output files as base64 content. Rejected when `async` is true.'), /** Caps inline base64 file hydration; bounded (v1 leaves it unbounded). */ base64MaxBytes: z .number() @@ -842,7 +904,9 @@ export const v2ExecuteWorkflowBodySchema = z .positive() .max(10 * 1024 * 1024) .optional() - .describe('Maximum total bytes of file content to inline as base64.'), + .describe( + 'Maximum total bytes of file content to inline as base64. Rejected when `async` is true.' + ), }) .strict() .meta({ @@ -890,7 +954,7 @@ export const v2ExecuteWorkflowDataSchema = z id: 'WorkflowRunResult', title: 'Workflow run result', description: - 'Synchronous workflow run output and in-band execution status. Run failures are reported in band, not as HTTP errors — a synchronous run that exceeds its execution timeout returns HTTP 200 with `status: "failed"` and `error.code: "TIMEOUT"`, so always branch on `status` rather than on the HTTP status alone.', + 'Synchronous workflow run output and in-band execution status. Run failures are reported in band, not as HTTP errors — a run that exceeds its execution timeout returns HTTP 200 with `status: "failed"` and `error.code: "TIMEOUT"`, so branch on `status`.', }) export type V2ExecuteWorkflowData = z.output @@ -921,6 +985,7 @@ export const v2ExecuteWorkflowSuccessSchema = z export const v2ExecuteWorkflowContract = defineRouteContract({ method: 'POST', path: '/api/v2/workflows/[id]/execute', + query: noInputSchema, params: v2WorkflowIdParamsSchema, headers: v2ExecuteWorkflowHeadersSchema, body: v2ExecuteWorkflowBodySchema, @@ -984,6 +1049,7 @@ export type V2ResumeWorkflowResponse = z.output export const v2CancelWorkflowRunContract = defineRouteContract({ method: 'POST', path: '/api/v2/workflows/[id]/runs/[runId]/cancel', + query: noInputSchema, params: v2WorkflowRunParamsSchema, response: { mode: 'json', @@ -1377,6 +1442,7 @@ export const v2ImportWorkflowDataSchema = z export const v2ExportWorkflowContract = defineRouteContract({ method: 'GET', path: '/api/v2/workflows/[id]/export', + query: noInputSchema, params: v2WorkflowIdParamsSchema, response: { mode: 'json', @@ -1387,6 +1453,7 @@ export const v2ExportWorkflowContract = defineRouteContract({ export const v2ImportWorkflowContract = defineRouteContract({ method: 'POST', path: '/api/v2/workflows/import', + query: noInputSchema, body: v2ImportWorkflowBodySchema, response: { mode: 'json', diff --git a/apps/sim/lib/api/contracts/v2/workspaces.ts b/apps/sim/lib/api/contracts/v2/workspaces.ts index 6624ff5b075..c85fbcdd001 100644 --- a/apps/sim/lib/api/contracts/v2/workspaces.ts +++ b/apps/sim/lib/api/contracts/v2/workspaces.ts @@ -1,5 +1,5 @@ import { z } from 'zod' -import { workspaceIdSchema } from '@/lib/api/contracts/primitives' +import { noInputSchema, workspaceIdSchema } from '@/lib/api/contracts/primitives' import { defineRouteContract } from '@/lib/api/contracts/types' import { v2CursorListResponse, @@ -46,7 +46,7 @@ export const v2WorkspaceMemberSchema = z isExternal: z .boolean() .describe( - "Whether the member belongs to a different organization than the workspace. True for an explicitly granted member whose own organization differs from the workspace's; false for the workspace owner and for a member sharing the workspace organization. Inherited organization-administrator access is always reported as false, so this is not a signal that access came from outside the explicit member list." + 'Whether the member belongs to a different organization than the workspace. True only for an explicitly granted member whose own organization differs; inherited organization-administrator access is always reported as false, so this does not detect every outside caller.' ), joinedAt: v2TimestampSchema.describe('ISO 8601 timestamp when access was granted.'), }) @@ -72,6 +72,7 @@ export type V2WorkspaceMemberCursor = z.output { + const sort = cursorSortKey('name', 'asc') + const filters = { workspaceId: 'ws-1', search: undefined as string | undefined } + const scope = cursorScopeKey(filters) + + describe('offset cursor', () => { + it('resumes a cursor replayed under the same query state', () => { + expect(decodeOffsetCursor(encodeOffsetCursor(sort, scope, 40), sort, scope)).toBe(40) + }) + + it('rejects a cursor replayed under a different sort', () => { + const cursor = encodeOffsetCursor(sort, scope, 40) + + expect(() => decodeOffsetCursor(cursor, cursorSortKey('createdAt', 'asc'), scope)).toThrow( + /sortBy\/sortOrder/ + ) + expect(() => decodeOffsetCursor(cursor, cursorSortKey('name', 'desc'), scope)).toThrow( + /sortBy\/sortOrder/ + ) + }) + + it('rejects a cursor replayed under a different filter', () => { + const cursor = encodeOffsetCursor(sort, scope, 40) + + expect(() => + decodeOffsetCursor(cursor, sort, cursorScopeKey({ ...filters, search: 'deploy' })) + ).toThrow(/requested filters/) + expect(() => + decodeOffsetCursor(cursor, sort, cursorScopeKey({ ...filters, workspaceId: 'ws-2' })) + ).toThrow(/requested filters/) + }) + + it('treats an absent cursor as page one', () => { + expect(decodeOffsetCursor(undefined, sort, scope)).toBe(0) + }) + + it('rejects a cursor that is not valid base64-JSON', () => { + expect(() => decodeOffsetCursor('not-a-cursor', sort, scope)).toThrow() + }) + + it('rejects an offset that is not a non-negative integer', () => { + expect(() => decodeOffsetCursor(encodeOffsetCursor(sort, scope, -1), sort, scope)).toThrow( + 'Invalid cursor' + ) + expect(() => decodeOffsetCursor(encodeOffsetCursor(sort, scope, 1.5), sort, scope)).toThrow( + 'Invalid cursor' + ) + }) + }) + + describe('keyset cursor', () => { + const keys = ['notes.md', 'file-1'] + + it('resumes a cursor replayed under the same query state', () => { + expect(readSortedCursor(encodeSortedCursor(sort, keys, scope), 'name', 'asc', scope)).toEqual( + keys + ) + }) + + /** + * A keyset position stays coherent under a changed filter — that is exactly + * why it is dangerous. The page it returns is duplicate-free and correctly + * ordered, and silently missing every match that sorts before the cursor. + */ + it('rejects a cursor replayed under a different filter', () => { + const cursor = encodeSortedCursor(sort, keys, scope) + const narrowed = cursorScopeKey({ ...filters, search: 'deploy' }) + + expect(decodeSortedCursor(cursor, sort, narrowed)).toEqual({ status: 'refiltered' }) + expect(() => readSortedCursor(cursor, 'name', 'asc', narrowed)).toThrow(/requested filters/) + }) + + /** + * The two stamps are checked separately so the 400 names the half that + * actually changed, rather than telling a caller who narrowed a search term + * to go re-read the sort documentation. + */ + it('names the sort when the sort is what changed', () => { + expect(() => + readSortedCursor(encodeSortedCursor(sort, keys, scope), 'createdAt', 'asc', scope) + ).toThrow(/sortBy\/sortOrder/) + }) + + it('refuses an unfiltered cursor replayed under a filter, and the reverse', () => { + const unfiltered = encodeSortedCursor(sort, keys, undefined) + + expect(() => readSortedCursor(unfiltered, 'name', 'asc', scope)).toThrow(/requested filters/) + expect(() => + readSortedCursor(encodeSortedCursor(sort, keys, scope), 'name', 'asc', undefined) + ).toThrow(/requested filters/) + }) + + it('treats an absent cursor as page one', () => { + expect(readSortedCursor(undefined, 'name', 'asc', scope)).toBeUndefined() + }) + }) + + describe('scoped wrapper for domain-minted cursors', () => { + it('round-trips the domain token untouched', () => { + expect(readScopedCursor(encodeScopedCursor(scope, 'domain-token'), scope)).toBe( + 'domain-token' + ) + }) + + it('rejects a token replayed under different filters', () => { + const cursor = encodeScopedCursor(scope, 'domain-token') + + expect(() => + readScopedCursor(cursor, cursorScopeKey({ ...filters, search: 'deploy' })) + ).toThrow(/requested filters/) + }) + + it('treats an absent cursor as page one', () => { + expect(readScopedCursor(undefined, scope)).toBeUndefined() + }) + + /** + * The three lists that mint their own tokens do not share a sort knob — + * `GET /audit-logs` declares neither `sortBy` nor `sortOrder`, and its query + * schema is `.strict()` — so an undecodable token must not send the caller + * to adjust params that would themselves be rejected. + */ + it('rejects a token that is not valid base64-JSON without naming a sort param', () => { + expect(() => readScopedCursor('not-a-cursor', scope)).toThrow(/not a valid pagination cursor/) + expect(() => readScopedCursor('not-a-cursor', scope)).not.toThrow(/sort/i) + }) + }) + + describe('scope fingerprint', () => { + /** + * `limit` selects how much of the sequence to return, not what the sequence + * is, so it is never a scope part and paging with a different page size must + * keep working. + */ + it('is unaffected by the page size', () => { + expect(decodeOffsetCursor(encodeOffsetCursor(sort, scope, 40), sort, scope)).toBe(40) + }) + + it('does not depend on the order the parts are written', () => { + expect(cursorScopeKey({ b: '2', a: '1' })).toBe(cursorScopeKey({ a: '1', b: '2' })) + }) + + it('treats an omitted part and an undefined part as the same scope', () => { + expect(cursorScopeKey({ a: '1', b: undefined })).toBe(cursorScopeKey({ a: '1' })) + }) + + it('has no fingerprint at all when nothing is filtered', () => { + expect(cursorScopeKey({ a: undefined })).toBeUndefined() + }) + + /** + * Distinct queries must not collide across part boundaries: `{a:'1',b:'2'}` + * and `{a:'1|2'}` are different reads and must fingerprint differently. + */ + it('separates parts rather than concatenating their values', () => { + expect(cursorScopeKey({ a: '1', b: '2' })).not.toBe(cursorScopeKey({ a: '1|2' })) + expect(cursorScopeKey({ a: '1' })).not.toBe(cursorScopeKey({ b: '1' })) + }) + + it('stays short enough to sit inside an opaque token', () => { + expect(cursorScopeKey({ search: 'x'.repeat(200) })).toHaveLength(22) + }) + }) +}) + +describe('unordered filter scope parts', () => { + it('fingerprints a reordered set identically', () => { + const a = cursorScopeKey({ workflowIds: unorderedScopePart('A,B') }) + const b = cursorScopeKey({ workflowIds: unorderedScopePart('B,A') }) + expect(a).toBe(b) + }) + it('fingerprints a duplicate-bearing set identically', () => { + // The filters compile to `inArray`, which is set membership, so A,A,B + // selects exactly what A,B does and must resume the same page. + expect(cursorScopeKey({ workflowIds: unorderedScopePart('A,A,B') })).toBe( + cursorScopeKey({ workflowIds: unorderedScopePart('A,B') }) + ) + expect(unorderedScopePart('B,A,B')).toBe('A,B') + }) + + /** + * The scope and the query must read one parse. When the scope trimmed members + * and the route split the raw value itself, `A,B` and `A, B` shared a + * fingerprint while selecting different rows — a cursor accepted across a + * change that moved the sequence, which is the failure the binding prevents. + */ + it('parses the members it fingerprints', () => { + expect(parseUnorderedList('A, B')).toEqual(['A', 'B']) + expect(parseUnorderedList('A,B')).toEqual(parseUnorderedList('A, B')) + expect(unorderedScopePart('A, B')).toBe(parseUnorderedList('A, B')?.join(',')) + }) + + /** + * Tag filters compile to `and(...)`, so reordering the clauses selects the + * same documents. Binding to the order a caller happened to write them in + * refused a cursor for a page that was genuinely the next one. + */ + it('treats an AND-conjoined filter array as a set', () => { + const ab = [ + { name: 'a', value: '1' }, + { name: 'b', value: '2' }, + ] + const ba = [ + { name: 'b', value: '2' }, + { name: 'a', value: '1' }, + ] + + expect(unorderedScopeOf(ab)).toBe(unorderedScopeOf(ba)) + expect(unorderedScopeOf([{ name: 'a' }, { name: 'a' }])).toBe(unorderedScopeOf([{ name: 'a' }])) + expect(unorderedScopeOf(ab)).not.toBe(unorderedScopeOf([{ name: 'a', value: '1' }])) + }) + + /** + * The scope binds the parsed filter, so a field the caller omitted and the + * schema default it parses to are one value by the time they are hashed. + * Fingerprinting the raw query text instead refused a cursor whenever a + * caller spelled a default explicitly. Asserted through the contract's own + * parser, since the defaulting is what makes the two equal. + */ + it('binds a defaulted field and its omission alike', () => { + const omitted = parseV2KnowledgeTagFiltersParam('[{"tagName":"a","value":"1"}]') + const explicit = parseV2KnowledgeTagFiltersParam( + '[{"tagName":"a","value":"1","operator":"eq"}]' + ) + const different = parseV2KnowledgeTagFiltersParam( + '[{"tagName":"a","value":"1","operator":"gt"}]' + ) + + expect(omitted.success && explicit.success && different.success).toBe(true) + expect(unorderedScopeOf(omitted.success ? omitted.filters : null)).toBe( + unorderedScopeOf(explicit.success ? explicit.filters : null) + ) + expect(unorderedScopeOf(omitted.success ? omitted.filters : null)).not.toBe( + unorderedScopeOf(different.success ? different.filters : null) + ) + }) + + it('has no scope for an absent filter', () => { + expect(unorderedScopeOf(undefined)).toBeUndefined() + }) + + /** + * A window bound selects by instant, and `z.string().datetime()` admits every + * sub-second spelling of one, so binding the text refused a cursor for the + * same window written a different way. + */ + it('binds a window bound by its instant, not its spelling', () => { + expect(instantScopePart('2026-01-01T00:00:00Z')).toBe( + instantScopePart('2026-01-01T00:00:00.000Z') + ) + expect(instantScopePart('2026-01-01T00:00:00Z')).not.toBe( + instantScopePart('2026-01-01T00:00:01Z') + ) + expect(instantScopePart('not-a-date')).toBe('not-a-date') + expect(instantScopePart(undefined)).toBeUndefined() + }) + + it('still separates genuinely different sets', () => { + expect(cursorScopeKey({ workflowIds: unorderedScopePart('A,B') })).not.toBe( + cursorScopeKey({ workflowIds: unorderedScopePart('A,C') }) + ) + }) + it('treats an all-empty list as absent, matching the parsers', () => { + expect(unorderedScopePart(',,')).toBeUndefined() + expect(unorderedScopePart('A,,B')).toBe('A,B') + }) +}) diff --git a/apps/sim/lib/api/cursor-binding.ts b/apps/sim/lib/api/cursor-binding.ts new file mode 100644 index 00000000000..c093ef0b68c --- /dev/null +++ b/apps/sim/lib/api/cursor-binding.ts @@ -0,0 +1,162 @@ +import { createHash } from 'node:crypto' +import { filterUndefined } from '@sim/utils/object' + +/** + * Caller-facing message for a cursor replayed under different filters. Separate + * from the sort-mismatch message on purpose: both mean "restart pagination", + * but naming the half that actually changed is the difference between a caller + * finding the bug in its own code and re-reading the sort docs. + */ +export const REFILTERED_CURSOR_MESSAGE = + 'cursor does not match the requested filters. Restart pagination without a cursor after changing a filter.' + +/** + * Caller-facing message for a token that cannot be decoded at all. + * + * Distinct from {@link REFILTERED_CURSOR_MESSAGE} and from + * `INVALID_CURSOR_MESSAGE` for the same reason those two are distinct from each + * other: an undecodable token says nothing about which param changed, and the + * lists that raise it do not all have a sort to name. `GET /audit-logs` + * declares neither `sortBy` nor `sortOrder` and its query schema is `.strict()`, + * so sending a caller to adjust them answers one 400 with advice that earns a + * second. + */ +export const UNREADABLE_CURSOR_MESSAGE = + 'cursor is not a valid pagination cursor. Restart pagination without a cursor.' + +/** A scalar a list filter can be expressed as, before canonicalization. */ +type CursorScopePart = string | number | boolean | Date | readonly string[] | null | undefined + +/** + * Canonical form of a filter the query treats as an unordered SET. + * + * A comma-separated list and a JSON object both have a spelling the caller + * chose and a meaning the query acts on: `workflowIds=A,B` and `B,A` select the + * same runs, and two `tagFilters` objects differing only in key order match the + * same documents. Fingerprinting the raw spelling binds the cursor to the + * spelling, so a caller who reorders an equivalent filter mid-walk gets a 400 + * for a page that is genuinely the next one. + * + * Members are de-duplicated as well as sorted: the filters compile to + * `inArray`, which is set membership, so `A,A,B` selects exactly what `A,B` does + * and must not bind to a different page. + * + * Derived from {@link parseUnorderedList} rather than parsing again, so the + * members this fingerprints are exactly the members the query filters on. A + * route that split the raw value itself would give `A,B` and `A, B` one + * fingerprint and two different result sets. + */ +export function unorderedScopePart(raw: string | undefined): string | undefined { + const members = parseUnorderedList(raw) + return members && members.length > 0 ? members.join(',') : undefined +} + +/** + * The members of a comma-separated filter, trimmed, de-duplicated, and sorted. + * + * The one parse for both halves of a bound list filter: pass the array to the + * query and {@link unorderedScopePart} to the cursor scope. Callers must not + * re-split the raw value for one half — that is what lets the two drift. + */ +export function parseUnorderedList(raw: string | undefined): string[] | undefined { + if (raw === undefined) return undefined + return [ + ...new Set( + raw + .split(',') + .map((member) => member.trim()) + .filter((member) => member.length > 0) + ), + ].sort() +} + +/** + * Canonical form of an AND-conjoined filter set. + * + * Takes the value the query acts on, never the caller's raw text. Two spellings + * that parse to one filter — an omitted field and its schema default, a + * different key order — must fingerprint alike, and only the parsed value knows + * that. Pass the output of the contract's own parser. + * + * {@link canonicalJson} preserves array order, which is right for a sequence and + * wrong for a set: clauses compiled into `and(...)` select the same rows in any + * order. Members are canonicalized, then de-duplicated and sorted, so `A AND A` + * binds like `A` and clause order stops mattering. + */ +export function unorderedScopeOf(value: unknown): string | undefined { + if (value === undefined) return undefined + if (!Array.isArray(value)) return canonicalJson(value) + return `[${[...new Set(value.map(canonicalJson))].sort().join(',')}]` +} + +/** + * Canonical form of a timestamp filter: the instant, not the caller's spelling. + * + * A window bound selects rows by the instant it names, and one instant has many + * valid ISO 8601 spellings — `…00Z` and `…00.000Z` differ only in sub-second + * precision, and both pass `z.string().datetime()`. Binding the text refuses a + * cursor for the same window written a different way. + * + * An unparseable value binds by its spelling; that request fails validation. + */ +export function instantScopePart(raw: string | undefined): string | undefined { + if (raw === undefined) return undefined + const parsed = Date.parse(raw) + return Number.isNaN(parsed) ? raw : new Date(parsed).toISOString() +} + +/** + * Deterministic JSON: object keys sorted so two structurally equal values + * serialize identically regardless of the key order they arrived in, and + * `undefined` members dropped so an omitted param and an absent one agree. + * + * Array order is preserved, because an array is a sequence in the general case. + * A filter whose array is really a set must canonicalize it first — see + * {@link parseUnorderedList} and {@link unorderedScopeOf} — or equivalent + * queries fingerprint differently and a valid cursor is refused. + */ +export function canonicalJson(value: unknown): string { + if (value instanceof Date) return JSON.stringify(value.toISOString()) + if (value === null || typeof value !== 'object') return JSON.stringify(value) ?? 'null' + if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]` + const entries = Object.entries(value as Record) + .filter(([, entry]) => entry !== undefined) + .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) + return `{${entries.map(([key, entry]) => `${JSON.stringify(key)}:${canonicalJson(entry)}`).join(',')}}` +} + +/** + * Fingerprint of a canonical form, short enough to sit inside an opaque token. + * + * Hashed rather than embedded because the bound state can be large — a table + * predicate runs to the request-body ceiling, and a v2 `search` term to 200 + * characters — while the cursor has to stay a token a caller can put in a query + * string. SHA-256 also means a caller cannot cheaply construct a second filter + * that collides with another sequence's stamp. + */ +export function fingerprint(canonical: string): string { + return createHash('sha256').update(canonical).digest('base64url').slice(0, 22) +} + +/** + * The fingerprint of a list's sequence-affecting params, or `undefined` when + * the caller supplied none of them. + * + * A cursor names a position in *one* sequence, so everything that reorders or + * re-filters that sequence has to travel with it — otherwise replaying the token + * against a re-filtered read silently answers from a sequence the caller never + * asked for. Pass every param that changes *which rows, in which order*. Keep + * `limit` out: it selects how much of the sequence to return, not what the + * sequence is, so a caller may change page size mid-walk. Response-shaping + * params (whether to inline trace spans, say) stay out for the same reason. + * + * `undefined` is a real state rather than an empty hash: an unstamped cursor is + * an unfiltered one, so it stays short, and replaying it under a filter still + * mismatches (`undefined !== `). Params whose value is `undefined` are + * dropped, so omitting a filter and never having sent it are the same scope. + */ +export function cursorScopeKey(parts: Record): string | undefined { + const present = filterUndefined(parts) + if (Object.keys(present).length === 0) return undefined + return fingerprint(canonicalJson(present)) +} diff --git a/apps/sim/lib/api/list-convention.test.ts b/apps/sim/lib/api/list-convention.test.ts index 83640d97c87..80a8257f2a8 100644 --- a/apps/sim/lib/api/list-convention.test.ts +++ b/apps/sim/lib/api/list-convention.test.ts @@ -55,6 +55,7 @@ vi.mock('@/lib/workflows/skills/builtin-skills', () => ({ import { listVisibleWorkspaceCredentials } from '@/lib/credentials/queries' import { listFoldersForWorkspace } from '@/lib/folders/queries' +import { getDocuments } from '@/lib/knowledge/documents/service' import { getKnowledgeBases } from '@/lib/knowledge/service' import { listWorkspaceMcpServers } from '@/lib/mcp/queries' import { listTables } from '@/lib/table/service' @@ -172,6 +173,22 @@ const CASES: ListCase[] = [ columns: [schemaMock.customTools.title, schemaMock.customTools.id], }, }, + { + name: 'knowledge documents', + column: schemaMock.document.filename, + table: schemaMock.document, + run: ({ search, sortBy, sortOrder }) => + getDocuments( + 'knowledge-1', + { search, sortBy: sortBy as never, sortOrder: sortOrder as never }, + 'request-1' + ), + sort: { + sortBy: 'fileSize', + sortOrder: 'asc', + columns: [schemaMock.document.fileSize, schemaMock.document.filename], + }, + }, { name: 'skills', column: schemaMock.skill.name, diff --git a/apps/sim/lib/api/offset-cursor-scope.test.ts b/apps/sim/lib/api/offset-cursor-scope.test.ts deleted file mode 100644 index b81cfb1dffe..00000000000 --- a/apps/sim/lib/api/offset-cursor-scope.test.ts +++ /dev/null @@ -1,80 +0,0 @@ -/** - * @vitest-environment node - */ -import { describe, expect, it } from 'vitest' -import { - decodeOffsetCursor, - encodeOffsetCursor, - offsetCursorScope, -} from '@/app/api/v2/lib/response' - -/** - * An offset names a position in one exact sequence. Replay it against a - * differently filtered or sorted sequence and it names a different row — - * silently skipping rows, repeating them, or landing past the end and returning - * an empty page while `nextCursor` implied more. - * - * The keyset cursor has always been protected from this by its sort stamp - * (`decodeSortedCursor`). These assertions hold the offset cursor — used by - * `GET /skills` and `GET /knowledge/{id}/documents` — to the same rule. - */ -describe('offset cursor scope', () => { - const base = { workspaceId: 'ws-1', search: undefined, sortBy: 'name', sortOrder: 'asc' } - - it('resumes a cursor replayed under the same query state', () => { - const scope = offsetCursorScope(base) - expect(decodeOffsetCursor(encodeOffsetCursor(scope, 40), scope)).toBe(40) - }) - - it('rejects a cursor replayed under a different sort', () => { - const cursor = encodeOffsetCursor(offsetCursorScope(base), 40) - - expect(() => - decodeOffsetCursor(cursor, offsetCursorScope({ ...base, sortBy: 'createdAt' })) - ).toThrow(/does not match the requested/) - expect(() => - decodeOffsetCursor(cursor, offsetCursorScope({ ...base, sortOrder: 'desc' })) - ).toThrow(/does not match the requested/) - }) - - it('rejects a cursor replayed under a different filter', () => { - const cursor = encodeOffsetCursor(offsetCursorScope(base), 40) - - expect(() => - decodeOffsetCursor(cursor, offsetCursorScope({ ...base, search: 'deploy' })) - ).toThrow(/does not match the requested/) - expect(() => - decodeOffsetCursor(cursor, offsetCursorScope({ ...base, workspaceId: 'ws-2' })) - ).toThrow(/does not match the requested/) - }) - - it('treats an absent cursor as page one', () => { - expect(decodeOffsetCursor(undefined, offsetCursorScope(base))).toBe(0) - }) - - it('rejects a cursor that is not valid base64-JSON', () => { - expect(() => decodeOffsetCursor('not-a-cursor', offsetCursorScope(base))).toThrow() - }) - - it('rejects an offset that is not a non-negative integer', () => { - const scope = offsetCursorScope(base) - expect(() => decodeOffsetCursor(encodeOffsetCursor(scope, -1), scope)).toThrow('Invalid cursor') - expect(() => decodeOffsetCursor(encodeOffsetCursor(scope, 1.5), scope)).toThrow( - 'Invalid cursor' - ) - }) - - /** - * `limit` selects how much of the sequence to return, not what the sequence - * is, so paging with a different page size must keep working. - */ - it('is unaffected by the page size', () => { - expect(offsetCursorScope({ ...base, sortBy: 'name' })).toBe(offsetCursorScope(base)) - }) - - it('does not depend on the order the parts are written', () => { - expect(offsetCursorScope({ sortBy: 'name', workspaceId: 'ws-1', sortOrder: 'asc' })).toBe( - offsetCursorScope({ sortOrder: 'asc', workspaceId: 'ws-1', sortBy: 'name' }) - ) - }) -}) diff --git a/apps/sim/lib/api/server/blank-query-values.test.ts b/apps/sim/lib/api/server/blank-query-values.test.ts new file mode 100644 index 00000000000..514e8879bc4 --- /dev/null +++ b/apps/sim/lib/api/server/blank-query-values.test.ts @@ -0,0 +1,129 @@ +/** + * @vitest-environment node + */ +import { NextRequest } from 'next/server' +import { describe, expect, it } from 'vitest' +import { z } from 'zod' +import { defineRouteContract } from '@/lib/api/contracts' +import { + blankQueryValueValidationError, + duplicateQueryValueValidationError, +} from '@/lib/api/server/blank-query-values' +import { V2_PARSE_DEFAULTS } from '@/lib/api/server/routes/v2-json-route' +import { parseRequest } from '@/lib/api/server/validation' +import { v2ValidationError } from '@/app/api/v2/lib/response' + +/** + * A query parameter that is present but blank is a different request from one + * that was omitted, and no schema can tell the difference on its own: coercion + * has already turned `''` into `0`, `false`, or a default before validation + * runs. `?limit=` on the lists that clamp reached SQL as `LIMIT 1`, and + * `?minCost=` on `/logs` became a live `cost >= 0` filter — both silently wrong + * pages rather than errors. + */ +describe('blank query values', () => { + it('rejects an empty value and names the parameter', () => { + const error = blankQueryValueValidationError({ workspaceId: 'workspace-1', limit: '' }) + + expect(error?.issues[0]).toMatchObject({ + path: ['limit'], + message: 'limit cannot be empty; omit the parameter instead', + }) + }) + + it('treats a whitespace-only value the same way', () => { + expect(blankQueryValueValidationError({ limit: ' ' })?.issues[0]?.path).toEqual(['limit']) + expect(blankQueryValueValidationError({ limit: '\t' })?.issues[0]?.path).toEqual(['limit']) + }) + + it('rejects a repeated parameter where any occurrence is blank', () => { + expect(blankQueryValueValidationError({ folderPaths: ['/live', ''] })?.issues[0]?.path).toEqual( + ['folderPaths'] + ) + }) + + it('accepts a query with no blank values, including a literal zero', () => { + expect( + blankQueryValueValidationError({ limit: '0', search: 'a', folderPaths: ['/a', '/b'] }) + ).toBeNull() + expect(blankQueryValueValidationError({})).toBeNull() + }) + + /** + * The rule is a v2-surface default rather than something each route opts into, + * for the same reason the malformed-body envelope is: an opt-in is applied by + * whoever remembered it, and the params this protects are exactly the ones + * nobody thought about. + */ + it('is on for every v2 route through the shared parse defaults', () => { + expect(V2_PARSE_DEFAULTS.rejectBlankQueryValues).toBe(true) + }) +}) + +const listContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/widgets', + query: z.object({ workspaceId: z.string().min(1, 'Workspace ID is required') }), + response: { mode: 'json', schema: z.object({ data: z.array(z.string()) }) }, +}) + +function listRequest(search: string): NextRequest { + return new NextRequest(`http://localhost/api/v2/widgets?${search}`, { method: 'GET' }) +} + +async function parseListRequest(search: string) { + return parseRequest( + listContract, + listRequest(search), + {}, + { + ...V2_PARSE_DEFAULTS, + validationErrorResponse: v2ValidationError, + } + ) +} + +/** + * A repeated parameter reaches the schema as an array, and no v2 query param is + * declared as one — so without this rule the caller is told the param is + * *missing* for a request that plainly sent it twice. + */ +describe('duplicate query values', () => { + it('names the duplication rather than the schema type failure', () => { + const error = duplicateQueryValueValidationError({ workspaceId: ['w-1', 'w-1'] }) + + expect(error?.issues[0]).toMatchObject({ + path: ['workspaceId'], + message: 'workspaceId was sent 2 times; send it at most once', + }) + }) + + it('accepts a query where every parameter appears once', () => { + expect(duplicateQueryValueValidationError({ workspaceId: 'w-1', limit: '10' })).toBeNull() + }) + + it('rejects a repeated parameter through parseRequest under the v2 defaults', async () => { + const parsed = await parseListRequest('workspaceId=w-1&workspaceId=w-1') + + expect(parsed.success).toBe(false) + if (parsed.success) return + expect(parsed.response.status).toBe(400) + await expect(parsed.response.json()).resolves.toMatchObject({ + error: expect.objectContaining({ + message: expect.stringContaining('workspaceId was sent 2 times; send it at most once'), + }), + }) + }) + + it('lets a query sending each parameter once through parseRequest', async () => { + const parsed = await parseListRequest('workspaceId=w-1') + + expect(parsed.success).toBe(true) + if (!parsed.success) return + expect(parsed.data.query).toEqual({ workspaceId: 'w-1' }) + }) + + it('is on for every v2 route through the shared parse defaults', () => { + expect(V2_PARSE_DEFAULTS.rejectDuplicateQueryValues).toBe(true) + }) +}) diff --git a/apps/sim/lib/api/server/blank-query-values.ts b/apps/sim/lib/api/server/blank-query-values.ts new file mode 100644 index 00000000000..f8857829d2c --- /dev/null +++ b/apps/sim/lib/api/server/blank-query-values.ts @@ -0,0 +1,65 @@ +import { ZodError } from 'zod' + +/** + * Rejects a query parameter that is present but carries no value — + * `?limit=`, `?limit=%20`, `?search=`. + * + * A blank value is not the same request as an omitted parameter, but nothing in + * a schema makes that true on its own. `z.coerce.number()` reads `''` as `0` + * (`Number('') === 0`), so `?limit=` on the lists that clamp became `LIMIT 1` — + * one row where the omitted param gives a hundred — and `?minCost=` became a + * live `cost >= 0` filter. Each is a different result set from the one the + * caller believed they asked for, and none of them is reported. + * + * It runs on the *raw* query, before schema validation, because that is the only + * place the blank still exists: coercion has already turned it into `0`, `false`, + * or a default by the time a parsed value is available. Applying it at the + * surface rather than per schema is what makes a parameter added later inherit + * the rule. + */ +export function blankQueryValueValidationError( + rawQuery: Record +): ZodError | null { + for (const [name, value] of Object.entries(rawQuery)) { + const values = Array.isArray(value) ? value : [value] + if (!values.some((entry) => entry.trim().length === 0)) continue + return new ZodError([ + { + code: 'custom', + path: [name], + message: `${name} cannot be empty; omit the parameter instead`, + input: undefined, + }, + ]) + } + return null +} + +/** + * Rejects a query parameter sent more than once — `?workspaceId=X&workspaceId=X`. + * + * A repeated parameter reaches the schema as an array, and no v2 query parameter + * is declared as one: every list this surface accepts is a single + * comma-separated string. The array therefore fails the declared type, and the + * caller is told whatever that type's own message says — `workspaceId` answers + * "Workspace ID is required" for a request that sent it twice, which points at + * the wrong problem. Naming the duplication is the whole fix, and like the blank + * scan above it belongs at the boundary: by the time a schema sees the value, + * the array is indistinguishable from any other wrong type. + */ +export function duplicateQueryValueValidationError( + rawQuery: Record +): ZodError | null { + for (const [name, value] of Object.entries(rawQuery)) { + if (!Array.isArray(value)) continue + return new ZodError([ + { + code: 'custom', + path: [name], + message: `${name} was sent ${value.length} times; send it at most once`, + input: undefined, + }, + ]) + } + return null +} diff --git a/apps/sim/lib/api/server/nul-byte-boundary.test.ts b/apps/sim/lib/api/server/nul-byte-boundary.test.ts new file mode 100644 index 00000000000..bd1ec14832e --- /dev/null +++ b/apps/sim/lib/api/server/nul-byte-boundary.test.ts @@ -0,0 +1,132 @@ +/** + * @vitest-environment node + */ +import { createMockRequest } from '@sim/testing' +import { describe, expect, it } from 'vitest' +import { z } from 'zod' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { parseRequest } from '@/lib/api/server/validation' + +/** + * The character under test, written as an escape rather than a literal. + * + * A raw U+0000 in the source makes git classify the whole file as binary — the + * diff renders as `Bin 0 -> 4102 bytes` and a reviewer sees none of the + * assertions below — and editors and formatters silently strip or normalize the + * byte. The escape produces an identical string at runtime. Do not inline it. + * `scripts/check-source-text.ts` fails the build if one comes back. + */ +const NUL = '\u0000' + +const bodyContract = defineRouteContract({ + method: 'POST', + path: '/api/test', + body: z.object({ + name: z.string().min(1), + nested: z.object({ values: z.array(z.string()) }).optional(), + cells: z.record(z.string(), z.unknown()).optional(), + }), + response: { mode: 'json', schema: z.object({ ok: z.boolean() }) }, +}) + +const queryContract = defineRouteContract({ + method: 'GET', + path: '/api/test', + query: z.object({ search: z.string().min(1) }), + response: { mode: 'json', schema: z.object({ ok: z.boolean() }) }, +}) + +const paramsContract = defineRouteContract({ + method: 'GET', + path: '/api/test/{slug}', + params: z.object({ slug: z.string().min(1) }), + response: { mode: 'json', schema: z.object({ ok: z.boolean() }) }, +}) + +async function parseBody(body: unknown) { + return parseRequest(bodyContract, createMockRequest('POST', body), {}) +} + +/** + * A `U+0000` in any caller-supplied string is a hard Postgres error on the way + * to a `text` column, and the driver's throw carries no classifiable code — so + * every case below reached the caller as `500 INTERNAL_ERROR` rather than a + * 400, on reads as well as writes. + */ +describe('NUL byte rejection at the contract boundary', () => { + it('rejects a NUL in a top-level body string', async () => { + const parsed = await parseBody({ name: `a${NUL}b` }) + + expect(parsed.success).toBe(false) + if (parsed.success) return + expect(parsed.response.status).toBe(400) + expect(await parsed.response.json()).toMatchObject({ + details: [{ path: ['name'] }], + }) + }) + + it('rejects a NUL nested inside an object and an array', async () => { + const parsed = await parseBody({ name: 'ok', nested: { values: ['fine', `bad${NUL}`] } }) + + expect(parsed.success).toBe(false) + if (parsed.success) return + expect(await parsed.response.json()).toMatchObject({ + details: [{ path: ['nested', 'values', 1] }], + }) + }) + + it('rejects a NUL in an unenumerated free-form record value', async () => { + const parsed = await parseBody({ name: 'ok', cells: { anyColumn: `a${NUL}b` } }) + + expect(parsed.success).toBe(false) + if (parsed.success) return + expect(await parsed.response.json()).toMatchObject({ + details: [{ path: ['cells', 'anyColumn'] }], + }) + }) + + it('rejects a NUL in an object key, not only in values', async () => { + const parsed = await parseBody({ name: 'ok', cells: { [`bad${NUL}key`]: 'value' } }) + + expect(parsed.success).toBe(false) + if (parsed.success) return + expect(parsed.response.status).toBe(400) + }) + + it('rejects a NUL in a query parameter on a pure read', async () => { + const request = createMockRequest( + 'GET', + undefined, + {}, + `http://localhost:3000/api/test?search=${encodeURIComponent(`a${NUL}b`)}` + ) + const parsed = await parseRequest(queryContract, request, {}) + + expect(parsed.success).toBe(false) + if (parsed.success) return + expect(parsed.response.status).toBe(400) + expect(await parsed.response.json()).toMatchObject({ details: [{ path: ['search'] }] }) + }) + + it('rejects a NUL in a route path parameter', async () => { + const parsed = await parseRequest(paramsContract, createMockRequest('GET'), { + params: Promise.resolve({ slug: `a${NUL}b` }), + }) + + expect(parsed.success).toBe(false) + if (parsed.success) return + expect(parsed.response.status).toBe(400) + }) + + it('accepts the control characters Postgres stores without complaint', async () => { + const parsed = await parseBody({ name: 'line one\nline two\tcol\r\nbell' }) + + expect(parsed.success).toBe(true) + }) + + it('accepts an escaped literal backslash-zero, which is not a NUL', async () => { + const parsed = await parseBody({ name: String.raw`a\0b` }) + + expect(parsed.success).toBe(true) + }) +}) diff --git a/apps/sim/lib/api/server/nul-bytes.ts b/apps/sim/lib/api/server/nul-bytes.ts new file mode 100644 index 00000000000..2ed60a1878f --- /dev/null +++ b/apps/sim/lib/api/server/nul-bytes.ts @@ -0,0 +1,112 @@ +import { isPlainRecord } from '@sim/utils/object' +import { containsNulCharacter } from '@sim/utils/string' +import { ZodError } from 'zod' + +/** + * Cheap existence scan used on every request. Descends only into arrays and + * plain records, so a `Buffer`, `Uint8Array`, or `Date` in a parsed payload is + * treated as a leaf — a zero *byte* in binary content is legitimate and must + * not be confused with a NUL *character* in text. + */ +function containsNulByte(root: unknown): boolean { + const stack: unknown[] = [root] + while (stack.length > 0) { + const value = stack.pop() + if (typeof value === 'string') { + if (containsNulCharacter(value)) return true + continue + } + if (Array.isArray(value)) { + for (const entry of value) stack.push(entry) + continue + } + if (isPlainRecord(value)) { + for (const key of Object.keys(value)) { + if (containsNulCharacter(key)) return true + stack.push(value[key]) + } + } + } + return false +} + +/** A visited node, linked to its parent so a path is only ever built on a hit. */ +interface NulScanFrame { + value: unknown + key: PropertyKey | null + parent: NulScanFrame | null +} + +/** Walks parent links back to the root. Runs once, only for the offending node. */ +function framePath(frame: NulScanFrame): PropertyKey[] { + const path: PropertyKey[] = [] + for (let node: NulScanFrame | null = frame; node?.parent; node = node.parent) { + if (node.key !== null) path.push(node.key) + } + return path.reverse() +} + +/** + * Second pass, run only once a NUL is known to be present. Returns the path of + * the first offending string, matching the shape Zod reports for a failed field. + * + * Frames carry a parent link rather than a copied path. Copying `[...path, key]` + * per child costs O(nodes x depth), which a caller controls directly: v2 row + * cell values are `z.unknown()`, so a 200KB body of nested arrays reaches this + * scan at depth 100k and blocked the event loop for ~28s. Parent links make it + * linear, and the path is materialized once for the node actually reported. + */ +function findNulBytePath(root: unknown): PropertyKey[] { + const stack: NulScanFrame[] = [{ value: root, key: null, parent: null }] + while (stack.length > 0) { + const frame = stack.pop() + if (!frame) break + const { value } = frame + if (typeof value === 'string') { + if (containsNulCharacter(value)) return framePath(frame) + continue + } + if (Array.isArray(value)) { + for (let index = value.length - 1; index >= 0; index -= 1) { + stack.push({ value: value[index], key: index, parent: frame }) + } + continue + } + if (isPlainRecord(value)) { + const keys = Object.keys(value) + for (let index = keys.length - 1; index >= 0; index -= 1) { + const key = keys[index] + if (containsNulCharacter(key)) return [...framePath(frame), key] + stack.push({ value: value[key], key, parent: frame }) + } + } + } + return [] +} + +/** + * Rejects any `U+0000` reaching the application from a request — see + * {@link containsNulCharacter} for why Postgres cannot carry one — as a + * `ZodError`, so it renders through each surface's existing validation-error + * projection with no per-route wiring. + * + * A boundary scan rather than a shared string schema, because the values that + * reached the driver have no string schema to opt into: a table cell and a + * predicate `value` are `z.unknown()` by contract, their type decided by the + * column rather than the wire. + * + * It runs on the *parsed* value, so a NUL in a property the contract strips is + * not a spurious 400. Headers are not scanned: HTTP forbids NUL in a field + * value and the server's own parser rejects it first. + */ +export function nulByteValidationError(value: unknown): ZodError | null { + if (!containsNulByte(value)) return null + return new ZodError([ + { + code: 'custom', + path: findNulBytePath(value), + message: 'Value cannot contain a NUL character (U+0000)', + input: undefined, + }, + ]) +} diff --git a/apps/sim/lib/api/server/routes/definition.ts b/apps/sim/lib/api/server/routes/definition.ts index 248eb1b8557..2d3642e85d2 100644 --- a/apps/sim/lib/api/server/routes/definition.ts +++ b/apps/sim/lib/api/server/routes/definition.ts @@ -1,7 +1,7 @@ import type { AnyApiRouteContract } from '@/lib/api/contracts' import type { ApplicationOperation } from '@/lib/core/application' -export interface JsonRouteDefinitionMetadata { +interface JsonRouteDefinitionMetadata { successStatus: number successStatuses: readonly number[] } diff --git a/apps/sim/lib/api/server/routes/internal-json-route.ts b/apps/sim/lib/api/server/routes/internal-json-route.ts index bc0af861a23..f9102d57217 100644 --- a/apps/sim/lib/api/server/routes/internal-json-route.ts +++ b/apps/sim/lib/api/server/routes/internal-json-route.ts @@ -53,7 +53,7 @@ export const internalSessionAuth = { }, } as const -export interface InternalSessionOrExecutorAuthOptions { +interface InternalSessionOrExecutorAuthOptions { audience: string resourceScope?( params: Record diff --git a/apps/sim/lib/api/server/routes/resource-concealment.test.ts b/apps/sim/lib/api/server/routes/resource-concealment.test.ts index 54670f57b5c..e7c0a5475fe 100644 --- a/apps/sim/lib/api/server/routes/resource-concealment.test.ts +++ b/apps/sim/lib/api/server/routes/resource-concealment.test.ts @@ -70,6 +70,21 @@ const policies: Array<{ policy: v2KnowledgeErrorPolicies.concealKnowledgeBaseAuthorization, notFoundMessage: 'Knowledge base not found', }, + { + domain: 'knowledge base upload', + policy: v2KnowledgeErrorPolicies.concealKnowledgeBaseUploadAuthorization, + notFoundMessage: 'Knowledge base not found', + }, + { + domain: 'knowledge base search', + policy: v2KnowledgeErrorPolicies.concealKnowledgeBaseUsageAuthorization, + notFoundMessage: 'Knowledge base not found', + }, + { + domain: 'file upload', + policy: v2FileErrorPolicies.concealUploadAuthorization, + notFoundMessage: 'Upload session not found', + }, ] const crossTenantAuthorizationErrors = [ diff --git a/apps/sim/lib/api/server/routes/types.ts b/apps/sim/lib/api/server/routes/types.ts index f2411298f38..90b9522dddd 100644 --- a/apps/sim/lib/api/server/routes/types.ts +++ b/apps/sim/lib/api/server/routes/types.ts @@ -46,7 +46,21 @@ export interface JsonRouteDefinition< operation: O mapInput(input: ParsedRequest): I useCase: OperationUseCase, I, R> - present(result: R): ContractJsonResponse | Promise> + /** + * Renders the surface body. The parsed request is passed alongside the result + * so a presenter can read the request's own params without the use case + * having to carry them back out. + * + * That second argument exists for pagination: a `nextCursor` is stamped with + * the sort and filters the page was read under, and those live in the query, + * not in the domain result. Threading them through the use case instead would + * make an application service carry an HTTP cursor-encoding concern purely so + * the presenter can see it again. + */ + present( + result: R, + request: ParsedRequest + ): ContractJsonResponse | Promise> } export type JsonNextRouteHandler = ( diff --git a/apps/sim/lib/api/server/routes/v2-binary-route.test.ts b/apps/sim/lib/api/server/routes/v2-binary-route.test.ts new file mode 100644 index 00000000000..c72e36433f3 --- /dev/null +++ b/apps/sim/lib/api/server/routes/v2-binary-route.test.ts @@ -0,0 +1,188 @@ +/** + * @vitest-environment node + */ +import type { PersonalApiKeyPrincipal } from '@sim/auth/principal' +import { + v2ApiKeyAuthModuleMock, + v2GateModuleMock, + v2RateLimiterModuleMock, + v2RouteMocks, +} from '@sim/testing' +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { z } from 'zod' +import { defineRouteContract } from '@/lib/api/contracts' +import { NoWorkspaceAccessError, type OperationUseCase } from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' + +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => v2ApiKeyAuthModuleMock) +vi.mock('@/lib/core/rate-limiter', () => v2RateLimiterModuleMock) +vi.mock('@/app/api/v2/lib/gate', () => v2GateModuleMock) + +import { v2ApiKeyAuth, v2OrchestrationErrorPolicy, v2RateLimits } from '@/lib/api/server/routes' +import type { V2ApiKeyAuthContext } from '@/lib/api/server/routes/v2-api-key-auth' +import { defineV2BinaryRoute } from '@/lib/api/server/routes/v2-binary-route' + +const operation = { id: 'widgets.download' } as const +const principal: PersonalApiKeyPrincipal = { + kind: 'personal_api_key', + userId: 'user-1', + keyId: 'key-1', +} +const auth = { + principal, + rolloutUserId: 'user-1', + rateLimitSubjectIds: ['api-key:key-1', 'user:user-1'], + rateLimitSubscription: null, + keyType: 'personal', +} satisfies V2ApiKeyAuthContext +const resetAt = new Date('2026-08-08T20:00:00.000Z') +const allowedRate = { allowed: true, remaining: 99, resetAt } + +const contract = defineRouteContract({ + method: 'GET', + path: '/api/v2/widgets/[widgetId]', + params: z.object({ widgetId: z.string() }), + query: z.object({ workspaceId: z.string().min(1) }).strict(), + response: { mode: 'binary' }, +}) + +interface Input { + widgetId: string +} + +interface Result { + bytes: string +} + +function createHandler(options: { + headSafe?: boolean + execute: () => Promise + authorize?: () => Promise + omitAuthorize?: boolean +}) { + const useCase: OperationUseCase = { + operation, + execute: options.execute, + authorize: options.omitAuthorize ? undefined : (options.authorize ?? (async () => {})), + } + return defineV2BinaryRoute({ + contract, + auth: v2ApiKeyAuth, + operation, + headSafe: options.headSafe, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2OrchestrationErrorPolicy, + mapInput: ({ params }) => params, + useCase, + present: (result) => ({ body: result.bytes, contentType: 'application/octet-stream' }), + }) +} + +function request(method: 'GET' | 'HEAD', query = 'workspaceId=workspace-1'): NextRequest { + return new NextRequest(`http://localhost/api/v2/widgets/widget-1?${query}`, { + method, + headers: { 'x-api-key': 'secret' }, + }) +} + +const context = { params: Promise.resolve({ widgetId: 'widget-1' }) } + +describe('defineV2BinaryRoute', () => { + beforeEach(() => { + vi.clearAllMocks() + v2RouteMocks.authenticate.mockResolvedValue(auth) + v2RouteMocks.gate.mockResolvedValue(null) + v2RouteMocks.preauthRate.mockResolvedValue({ allowed: true, remaining: 599, resetAt }) + v2RouteMocks.operationRate.mockResolvedValue(allowedRate) + }) + + it('streams the descriptor on GET', async () => { + const execute = vi.fn(async () => ({ bytes: 'payload' })) + const response = await createHandler({ execute })(request('GET'), context) + + expect(response.status).toBe(200) + expect(await response.text()).toBe('payload') + expect(execute).toHaveBeenCalledOnce() + }) + + it('runs the use case for a HEAD when the route is head-safe', async () => { + const execute = vi.fn(async () => ({ bytes: 'payload' })) + const response = await createHandler({ execute })(request('HEAD'), context) + + expect(response.status).toBe(200) + expect(execute).toHaveBeenCalledOnce() + }) + + it('answers a HEAD bodiless without executing when the route is not head-safe', async () => { + const execute = vi.fn(async () => ({ bytes: 'payload' })) + const response = await createHandler({ headSafe: false, execute })(request('HEAD'), context) + + expect(response.status).toBe(200) + expect(await response.text()).toBe('') + expect(execute).not.toHaveBeenCalled() + }) + + it('still authenticates and rate-limits a HEAD on a route that is not head-safe', async () => { + const execute = vi.fn(async () => ({ bytes: 'payload' })) + const handler = createHandler({ headSafe: false, execute }) + + await handler(request('HEAD'), context) + + expect(v2RouteMocks.authenticate).toHaveBeenCalledOnce() + expect(v2RouteMocks.operationRate).toHaveBeenCalled() + }) + + /** + * A download `HEAD` answered from admission alone lets any valid API key + * enumerate file ids across every workspace, while the `GET` beside it answers + * 403. Running the use case's authorization phase and rendering the refusal + * through the route's error policy keeps the probe from saying more than the + * download would. + */ + it('answers a denied HEAD with the status its GET would produce', async () => { + const execute = vi.fn(async () => ({ bytes: 'payload' })) + const response = await createHandler({ + headSafe: false, + execute, + authorize: async () => { + throw new NoWorkspaceAccessError() + }, + })(request('HEAD'), context) + + expect(response.status).toBe(403) + expect(execute).not.toHaveBeenCalled() + }) + + it('answers a HEAD for a nonexistent resource with 404, not 200', async () => { + const execute = vi.fn(async () => ({ bytes: 'payload' })) + const response = await createHandler({ + headSafe: false, + execute, + authorize: async () => { + throw new OrchestrationError('not_found', 'Widget not found') + }, + })(request('HEAD'), context) + + expect(response.status).toBe(404) + expect(execute).not.toHaveBeenCalled() + }) + + it('rejects a HEAD missing a required param instead of answering 200', async () => { + const execute = vi.fn(async () => ({ bytes: 'payload' })) + const authorize = vi.fn(async () => {}) + const response = await createHandler({ headSafe: false, execute, authorize })( + request('HEAD', ''), + context + ) + + expect(response.status).toBe(400) + expect(authorize).not.toHaveBeenCalled() + }) + + it('refuses at definition time to build a not-head-safe route that cannot authorize', () => { + expect(() => + createHandler({ headSafe: false, omitAuthorize: true, execute: async () => ({ bytes: '' }) }) + ).toThrow(/authorize/) + }) +}) diff --git a/apps/sim/lib/api/server/routes/v2-binary-route.ts b/apps/sim/lib/api/server/routes/v2-binary-route.ts index 6709d65e48d..c650fe2acb3 100644 --- a/apps/sim/lib/api/server/routes/v2-binary-route.ts +++ b/apps/sim/lib/api/server/routes/v2-binary-route.ts @@ -11,16 +11,18 @@ import type { } from '@/lib/api/server/routes/types' import { admitV2Request, + requireHeadAuthorizableUseCase, V2_PARSE_DEFAULTS, type V2ErrorPolicy, type V2RateLimitPolicy, V2RouteInfrastructureError, type v2ApiKeyAuth, + v2HeadAuthorizationResponse, } from '@/lib/api/server/routes/v2-json-route' import { parseRequest } from '@/lib/api/server/validation' import type { ApplicationOperation } from '@/lib/core/application' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { v2Error, v2HttpError, v2ValidationError } from '@/app/api/v2/lib/response' +import { v2Error, v2HeadNoEffect, v2HttpError } from '@/app/api/v2/lib/response' interface V2BinaryRouteOptions< C extends BinaryApiRouteContract, @@ -31,6 +33,13 @@ interface V2BinaryRouteOptions< auth: typeof v2ApiKeyAuth rateLimit: V2RateLimitPolicy errorPolicy: V2ErrorPolicy + /** + * As on {@link defineV2JsonRoute}, whose `headSafe` option carries the + * rationale; the bodiless answer is {@link v2HeadNoEffect}. A binary `GET` is + * a download — the archetypal read that records that it happened — so it is + * the common case here rather than the exception. + */ + headSafe?: boolean } export function defineV2BinaryRoute< @@ -44,6 +53,7 @@ export function defineV2BinaryRoute< options.operation, options.useCase.operation ) + requireHeadAuthorizableUseCase(options.contract, options.headSafe, options.useCase) const wrapped = withRouteHandler( async (request: NextRequest, context) => { @@ -63,10 +73,27 @@ export function defineV2BinaryRoute< const parsed = await parseRequest(options.contract, request, context ?? {}, { ...V2_PARSE_DEFAULTS, - validationErrorResponse: v2ValidationError, }) if (!parsed.success) return parsed.response + if (request.method === 'HEAD' && options.headSafe === false) { + let input: I + try { + input = options.mapInput(parsed.data) + } catch (error) { + const response = options.errorPolicy.render(error) + if (response) return response + throw error + } + return v2HeadAuthorizationResponse({ + useCase: options.useCase, + principal: admission.auth.principal, + input, + request, + errorPolicy: options.errorPolicy, + }) + } + try { const result = await options.useCase.execute({ principal: admission.auth.principal, diff --git a/apps/sim/lib/api/server/routes/v2-json-route.test.ts b/apps/sim/lib/api/server/routes/v2-json-route.test.ts index 909418135e5..45dfd51000a 100644 --- a/apps/sim/lib/api/server/routes/v2-json-route.test.ts +++ b/apps/sim/lib/api/server/routes/v2-json-route.test.ts @@ -14,7 +14,11 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { z } from 'zod' import { defineRouteContract } from '@/lib/api/contracts' import type { ParsedRequest, ParseRequestOptions } from '@/lib/api/server/validation' -import type { OperationUseCase } from '@/lib/core/application' +import { + NoWorkspaceAccessError, + type OperationUseCase, + PrincipalKindAuthorizationError, +} from '@/lib/core/application' import { OrchestrationError } from '@/lib/core/orchestration/types' import { HttpError } from '@/lib/core/utils/http-error' @@ -31,6 +35,7 @@ import { defineV2JsonRoute, type V2ErrorPolicy, v2ApiKeyAuth, + v2HeadAuthorizationResponse, v2OrchestrationErrorPolicy, v2RateLimits, } from '@/lib/api/server/routes/v2-json-route' @@ -489,3 +494,222 @@ describe('defineV2JsonRoute', () => { }) }) }) + +/** + * A `HEAD` on a route whose `GET` is not safe must answer the question the `GET` + * would answer, minus the effect — not merely the question admission can answer. + * + * Returning {@link v2HeadNoEffect} straight after authenticate + rate-limit is + * an existence oracle: any valid API key draws a bodiless 200 for a denied + * principal kind, a nonexistent id, another tenant's workspace, and even a + * request missing a required param, while the `GET` beside it answers 403. These + * pin the builder to running the authorization phase and stopping before the + * business phase. + */ +describe('defineV2JsonRoute HEAD on a route that is not head-safe', () => { + const headContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/widgets/[widgetId]', + params: z.object({ widgetId: z.string() }).strict(), + query: z.object({ workspaceId: z.string().min(1) }).strict(), + response: { mode: 'json', schema: z.object({ data: z.object({ value: z.string() }) }) }, + }) + + type HeadInput = { widgetId: string; workspaceId: string } + + function createHeadHandler(overrides: { + authorize?: (args: { input: HeadInput }) => Promise + execute?: () => Promise + omitAuthorize?: boolean + }) { + const useCase: OperationUseCase = { + operation, + execute: overrides.execute ?? (async () => ({ value: 'ok' })), + authorize: overrides.omitAuthorize ? undefined : (overrides.authorize ?? (async () => {})), + } + return defineV2JsonRoute({ + contract: headContract, + auth: v2ApiKeyAuth, + operation, + headSafe: false, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2OrchestrationErrorPolicy, + mapInput: ({ params, query }) => ({ widgetId: params.widgetId, ...query }), + useCase, + present: (result) => ({ data: result }), + }) + } + + const headContext = { params: Promise.resolve({ widgetId: 'widget-1' }) } + + function headRequest(query = 'workspaceId=workspace-1'): NextRequest { + return new NextRequest(`http://localhost/api/v2/widgets/widget-1?${query}`, { + method: 'HEAD', + headers: { 'x-api-key': 'secret' }, + }) + } + + beforeEach(() => { + vi.clearAllMocks() + v2RouteMocks.authenticate.mockResolvedValue(auth) + v2RouteMocks.gate.mockResolvedValue(null) + v2RouteMocks.preauthRate.mockResolvedValue({ allowed: true, remaining: 599, resetAt }) + v2RouteMocks.operationRate.mockResolvedValue(allowedRate) + }) + + it('answers a denied principal kind with the status its GET would produce', async () => { + const execute = vi.fn(async () => ({ value: 'ok' })) + const response = await createHeadHandler({ + execute, + authorize: async () => { + throw new PrincipalKindAuthorizationError('workspace_api_key', operation.id) + }, + })(headRequest(), headContext) + + expect(response.status).toBe(403) + expect(execute).not.toHaveBeenCalled() + }) + + it('answers a nonexistent resource with 404 rather than confirming it exists', async () => { + const execute = vi.fn(async () => ({ value: 'ok' })) + const response = await createHeadHandler({ + execute, + authorize: async () => { + throw new OrchestrationError('not_found', 'Widget not found') + }, + })(headRequest(), headContext) + + expect(response.status).toBe(404) + expect(execute).not.toHaveBeenCalled() + }) + + it('answers an unauthorized workspace with the GET`s own refusal status', async () => { + const execute = vi.fn(async () => ({ value: 'ok' })) + const response = await createHeadHandler({ + execute, + authorize: async () => { + throw new NoWorkspaceAccessError() + }, + })(headRequest('workspaceId=someone-elses-workspace'), headContext) + + expect(response.status).toBe(403) + expect(execute).not.toHaveBeenCalled() + }) + + it('rejects a missing required param instead of answering 200', async () => { + const authorize = vi.fn(async () => {}) + const response = await createHeadHandler({ authorize })(headRequest(''), headContext) + + expect(response.status).toBe(400) + expect(authorize).not.toHaveBeenCalled() + }) + + it('answers an authorized probe bodiless without running the business phase', async () => { + const execute = vi.fn(async () => ({ value: 'ok' })) + const authorize = vi.fn(async () => {}) + const response = await createHeadHandler({ execute, authorize })(headRequest(), headContext) + + expect(response.status).toBe(200) + expect(await response.text()).toBe('') + expect(authorize).toHaveBeenCalledWith( + expect.objectContaining({ + principal, + input: { widgetId: 'widget-1', workspaceId: 'workspace-1' }, + }) + ) + expect(execute).not.toHaveBeenCalled() + }) + + it('refuses at definition time to build the route when the use case cannot authorize', () => { + expect(() => createHeadHandler({ omitAuthorize: true })).toThrow(/authorize/) + }) + + /** + * The definition-time guard is what a route hits, and it covers both builders + * that answer a `HEAD` this way. This pins the responder's own behaviour if it + * is ever reached another way: a missing authorization phase has to fail, + * because skipping it hands back the bodiless 200 for a resource nothing + * authorized — the leak the guard exists to prevent, restored. + */ + it('refuses to answer 200 when the authorization phase is missing', async () => { + await expect( + v2HeadAuthorizationResponse({ + useCase: { authorize: undefined }, + principal, + input: { widgetId: 'widget-1', workspaceId: 'workspace-1' }, + request: headRequest(), + errorPolicy: v2OrchestrationErrorPolicy, + }) + ).rejects.toThrow(/authorize/) + }) +}) + +const presenterContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/widgets/[widgetId]/pages', + params: z.object({ widgetId: z.string() }).strict(), + query: z.object({ sort: z.string(), workspaceId: z.string() }).strict(), + body: z.object({ value: z.string() }).strict(), + response: { + mode: 'json', + status: 201, + schema: z.object({ data: z.object({ value: z.string() }), nextCursor: z.string() }), + }, +}) + +/** + * A `nextCursor` is stamped with the sort and filters the page was read under, + * and those live in the request rather than the domain result — so a presenter + * that cannot see the parsed request forces the use case to carry an HTTP + * cursor-encoding concern back out. + */ +describe('defineV2JsonRoute presentation', () => { + beforeEach(() => { + vi.clearAllMocks() + v2RouteMocks.authenticate.mockResolvedValue(auth) + v2RouteMocks.gate.mockResolvedValue(null) + v2RouteMocks.preauthRate.mockResolvedValue({ allowed: true, remaining: 599, resetAt }) + v2RouteMocks.operationRate.mockResolvedValue(allowedRate) + }) + + it('hands the presenter the parsed request alongside the result', async () => { + const present = vi.fn((result: Result, parsed: ParsedRequest) => ({ + data: result, + nextCursor: `${parsed.params.widgetId}:${parsed.query.sort}:${parsed.body.value}`, + })) + + const handler = defineV2JsonRoute({ + contract: presenterContract, + auth: v2ApiKeyAuth, + operation, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2OrchestrationErrorPolicy, + mapInput: ({ body }) => body, + useCase: { operation, execute: async ({ input }) => input }, + present, + }) + + const response = await handler( + new NextRequest('http://localhost/api/v2/widgets/widget-1/pages?sort=asc&workspaceId=ws-1', { + method: 'POST', + headers: { 'content-type': 'application/json', 'x-api-key': 'secret' }, + body: JSON.stringify({ value: 'ok' }), + }), + { params: Promise.resolve({ widgetId: 'widget-1' }) } + ) + + expect(response.status).toBe(201) + await expect(response.json()).resolves.toEqual({ + data: { value: 'ok' }, + nextCursor: 'widget-1:asc:ok', + }) + expect(present).toHaveBeenCalledWith( + { value: 'ok' }, + expect.objectContaining({ + params: { widgetId: 'widget-1' }, + query: { sort: 'asc', workspaceId: 'ws-1' }, + body: { value: 'ok' }, + }) + ) + }) +}) diff --git a/apps/sim/lib/api/server/routes/v2-json-route.ts b/apps/sim/lib/api/server/routes/v2-json-route.ts index 41f1e89b0f1..1c284358dd1 100644 --- a/apps/sim/lib/api/server/routes/v2-json-route.ts +++ b/apps/sim/lib/api/server/routes/v2-json-route.ts @@ -17,7 +17,7 @@ import { V2ApiKeyUnauthenticatedError, } from '@/lib/api/server/routes/v2-api-key-auth' import { type ParseRequestOptions, parseRequest } from '@/lib/api/server/validation' -import type { ApplicationOperation } from '@/lib/core/application' +import type { ApplicationOperation, OperationUseCase } from '@/lib/core/application' import { getRateLimit, RateLimiter, type SubscriptionPlan } from '@/lib/core/rate-limiter' import { getClientIp } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -112,20 +112,16 @@ export const v2RateLimits = { * response sets. Declared here so a route only has to set `parseOptions.maxBodyBytes` to * get a correct 413; a route that supplies its own `payloadTooLargeResponse` still wins. */ -export const v2PayloadTooLargeResponse = () => - v2Error('PAYLOAD_TOO_LARGE', 'Request body is too large') +const v2PayloadTooLargeResponse = () => v2Error('PAYLOAD_TOO_LARGE', 'Request body is too large') /** * Default `400` for a body that is absent or not valid JSON, for the same * reason as {@link v2PayloadTooLargeResponse}: `parseRequest`'s fallback is a * bare `{ "error": "Request body must be valid JSON" }` carrying no * `error.code`, so a client reading `error.code` off every other v2 failure - * gets `undefined` exactly when its request was malformed. - * - * It is a default rather than a per-route opt-in because the opt-in *was* the - * bug: only 8 of the 77 v2 routes remembered to pass it, so the envelope held - * for validation errors and broke for transport-level ones. A route supplying - * its own `invalidJsonResponse` still wins. + * gets `undefined` exactly when its request was malformed. A default rather + * than a per-route opt-in, because an opt-in only holds where somebody + * remembered it. A route supplying its own `invalidJsonResponse` still wins. */ export const v2InvalidJsonResponse = () => v2Error('BAD_REQUEST', 'Request body must be valid JSON') @@ -134,18 +130,82 @@ export const v2InvalidJsonResponse = () => v2Error('BAD_REQUEST', 'Request body * * The builders spread this, and so must the handful of raw `withRouteHandler` * v2 routes that call `parseRequest` directly — they are exactly the routes a - * builder default cannot reach, and leaving them out is what kept the bare - * `{ "error": string }` body alive on two of the busiest v2 POSTs. + * builder default cannot reach. */ export const V2_PARSE_DEFAULTS = { payloadTooLargeResponse: v2PayloadTooLargeResponse, invalidJsonResponse: v2InvalidJsonResponse, + validationErrorResponse: v2ValidationError, + /** See {@link blankQueryValueValidationError}. */ + rejectBlankQueryValues: true, + /** See {@link duplicateQueryValueValidationError}. */ + rejectDuplicateQueryValues: true, } as const export interface V2ErrorPolicy { render(error: unknown): NextResponse | null } +/** + * Refuses at module load to build a `headSafe: false` route whose use case + * cannot answer the authorization question on its own — see the `headSafe` + * option below. A use case with no `authorize` leaves the builder nothing but + * admission to answer a `HEAD` from, so the gap is a boot failure rather than a + * silent 200. + */ +export function requireHeadAuthorizableUseCase( + contract: { method: string; path: string }, + headSafe: boolean | undefined, + useCase: Pick, 'authorize'> +): void { + if (headSafe !== false) return + if (typeof useCase.authorize === 'function') return + throw new Error( + `V2 route ${contract.method} ${contract.path} declares headSafe: false but its use case has no authorize(); a HEAD would have to answer from authentication alone and would leak the resource's existence.` + ) +} + +/** + * The bodiless answer a `HEAD` gets on a route whose `GET` is not safe. + * + * Authorization runs first and its failures render through the route's own error + * policy, so the status a caller sees is the status their `GET` would have + * produced — 400, 401, 403, 404, 429 — and only an authorized caller reaches the + * 200. What a `HEAD` never reaches is the use case's business phase, so the + * outbound connection, the row write, and the audit event stay unfired. + * + * A use case with no `authorize` throws here rather than being skipped, even + * though {@link requireHeadAuthorizableUseCase} already refuses such a route at + * module load: treating the phase as optional would silently degrade a missing + * one into exactly the bodiless 200 this function exists to stop. + */ +export async function v2HeadAuthorizationResponse(args: { + useCase: Pick, 'authorize'> + principal: V2ApiKeyAuthContext['principal'] + input: unknown + request: NextRequest + errorPolicy: V2ErrorPolicy +}): Promise { + const { authorize } = args.useCase + if (typeof authorize !== 'function') { + throw new Error( + 'HEAD on a route that is not head-safe reached a use case with no authorize(); answering 200 would leak the existence of a resource the GET never authorized.' + ) + } + try { + await authorize({ + principal: args.principal, + input: args.input, + request: args.request, + }) + } catch (error) { + const response = args.errorPolicy.render(error) + if (response) return response + throw error + } + return v2HeadNoEffect() +} + export const v2OrchestrationErrorPolicy = { render(error) { return v2CaughtOrchestrationError(error) @@ -230,9 +290,17 @@ interface V2JsonRouteOptions @@ -260,6 +328,7 @@ export function defineV2JsonRoute< options.operation, options.useCase.operation ) + requireHeadAuthorizableUseCase(options.contract, options.headSafe, options.useCase) const wrapped = withRouteHandler( async (request, context) => { @@ -278,10 +347,6 @@ export function defineV2JsonRoute< if (!admission.success) return admission.response const { auth } = admission - if (request.method === 'HEAD' && options.headSafe === false) { - return v2HeadNoEffect() - } - if (options.beforeParse) { const rawParams = context?.params ? await context.params : {} try { @@ -300,6 +365,24 @@ export function defineV2JsonRoute< }) if (!parsed.success) return parsed.response + if (request.method === 'HEAD' && options.headSafe === false) { + let input: I + try { + input = options.mapInput(parsed.data) + } catch (error) { + const response = options.errorPolicy.render(error) + if (response) return response + throw error + } + return v2HeadAuthorizationResponse({ + useCase: options.useCase, + principal: auth.principal, + input, + request, + errorPolicy: options.errorPolicy, + }) + } + try { const input = options.mapInput(parsed.data) const result = await options.useCase.execute({ @@ -307,7 +390,7 @@ export function defineV2JsonRoute< input, request, }) - const body = await options.present(result) + const body = await options.present(result, parsed.data) const responseSchema = options.contract.response if (responseSchema.mode !== 'json') { throw new Error('V2 JSON route response mode changed after initialization') diff --git a/apps/sim/lib/api/server/validation.ts b/apps/sim/lib/api/server/validation.ts index cc9f327d7bb..9e9fdb50286 100644 --- a/apps/sim/lib/api/server/validation.ts +++ b/apps/sim/lib/api/server/validation.ts @@ -8,6 +8,11 @@ import type { ContractParams, ContractQuery, } from '@/lib/api/contracts' +import { + blankQueryValueValidationError, + duplicateQueryValueValidationError, +} from '@/lib/api/server/blank-query-values' +import { nulByteValidationError } from '@/lib/api/server/nul-bytes' import { env } from '@/lib/core/config/env' import { assertContentLengthWithinLimit, @@ -61,6 +66,16 @@ export interface ParseRequestOptions { maxBodyBytes?: number /** Treat an absent or whitespace-only body as `undefined` before contract validation. */ optionalJsonBody?: boolean + /** + * See {@link blankQueryValueValidationError}. Opt-in, so the internal surface — + * whose own clients send blanks today — is unaffected. + */ + rejectBlankQueryValues?: boolean + /** + * See {@link duplicateQueryValueValidationError}. Opt-in on the same terms, + * and only sound where no query parameter is declared as an array. + */ + rejectDuplicateQueryValues?: boolean } export function serializeZodIssues(error: z.ZodError): z.core.$ZodIssue[] { @@ -276,6 +291,20 @@ export async function parseRequest( body = parsedBody.data } + if (options?.rejectDuplicateQueryValues) { + const duplicated = duplicateQueryValueValidationError(rawQuery) + if (duplicated) { + return { success: false, response: projectValidationError(duplicated, options) } + } + } + + if (options?.rejectBlankQueryValues) { + const blank = blankQueryValueValidationError(rawQuery) + if (blank) { + return { success: false, response: projectValidationError(blank, options) } + } + } + const params = contract.params ? validateRequestSchema(contract.params, rawParams, options) : undefined @@ -294,6 +323,12 @@ export async function parseRequest( const parsedBody = contract.body ? validateRequestSchema(contract.body, body, options) : undefined if (parsedBody && !parsedBody.success) return parsedBody + const nulBytes = + rejectNulBytes(params?.data, options) ?? + rejectNulBytes(query?.data, options) ?? + rejectNulBytes(parsedBody?.data, options) + if (nulBytes) return nulBytes + return { success: true, data: { @@ -305,6 +340,30 @@ export async function parseRequest( } } +/** + * Applies {@link nulByteValidationError} to one validated request slice and + * projects a hit through the same error renderer the schema failures use, so a + * NUL is a 400 in every surface's own envelope instead of a driver-level 500. + */ +function rejectNulBytes( + data: unknown, + options?: ParseRequestOptions +): { success: false; response: NextResponse } | null { + const error = nulByteValidationError(data) + if (!error) return null + return { success: false, response: projectValidationError(error, options) } +} + +/** Renders a validation failure through the caller's envelope when it supplies one. */ +function projectValidationError( + error: z.ZodError, + options?: ParseRequestOptions +): NextResponse { + return options?.validationErrorResponse + ? options.validationErrorResponse(error) + : validationErrorResponse(error) +} + function validateRequestSchema( schema: S, data: unknown, @@ -314,9 +373,7 @@ function validateRequestSchema( if (!result.success) { return { success: false, - response: options?.validationErrorResponse - ? options.validationErrorResponse(result.error) - : validationErrorResponse(result.error), + response: projectValidationError(result.error, options), error: result.error, } } diff --git a/apps/sim/lib/billing/api/route-policies.ts b/apps/sim/lib/billing/api/route-policies.ts new file mode 100644 index 00000000000..1ccecdfbbae --- /dev/null +++ b/apps/sim/lib/billing/api/route-policies.ts @@ -0,0 +1,13 @@ +import { createV2ResourceConcealmentPolicy } from '@/lib/api/server/routes' + +/** + * Billing reads are workspace-scoped, so a caller naming a workspace it cannot + * reach must not be able to tell that refusal apart from a workspace that does + * not exist. Both answer `404 "Workspace not found"`, which is the message the + * unknown-workspace path already uses. + */ +export const v2BillingErrorPolicies = { + concealWorkspaceAuthorization: createV2ResourceConcealmentPolicy({ + notFoundMessage: 'Workspace not found', + }), +} as const diff --git a/apps/sim/lib/billing/application/authorized-billing-read-use-case.ts b/apps/sim/lib/billing/application/authorized-billing-read-use-case.ts index 2377044a181..0d4cc811e62 100644 --- a/apps/sim/lib/billing/application/authorized-billing-read-use-case.ts +++ b/apps/sim/lib/billing/application/authorized-billing-read-use-case.ts @@ -8,6 +8,13 @@ import type { BillingReadPrincipal, } from '@/lib/billing/application/operations' import type { OperationUseCase } from '@/lib/core/application' +import { + InsufficientWorkspacePermissionsError, + NoWorkspaceAccessError, + PersonalApiKeysDisabledError, + PrincipalKindAuthorizationError, + WorkspaceApiKeyScopeAuthorizationError, +} from '@/lib/core/application/workspace-authorization' import { OrchestrationError } from '@/lib/core/orchestration/types' import { type ActiveWorkspaceApplicationContext, @@ -36,10 +43,7 @@ function requireBillingReadPrincipal( operation: BillingReadOperation ): asserts principal is BillingReadPrincipal { if (!operation.principalKinds.some((kind) => kind === principal.kind)) { - throw new OrchestrationError( - 'forbidden', - `Principal kind ${principal.kind} cannot perform operation ${operation.id}` - ) + throw new PrincipalKindAuthorizationError(principal.kind, operation.id) } } @@ -50,7 +54,14 @@ async function resolveBillingReadScope( ): Promise { if (principal.kind === 'workspace_api_key') { if (requestedWorkspaceId && requestedWorkspaceId !== principal.workspaceId) { - throw new OrchestrationError('forbidden', 'API key is not authorized for this workspace') + /** + * A cross-tenant refusal, so it must not explain itself: naming the cause + * would confirm the named workspace exists to a key that was never scoped + * to it. The billing routes conceal this class as a `404`, which is also + * the answer a workspace id that does not exist gets, so the two are + * indistinguishable — see `createV2ResourceConcealmentPolicy`. + */ + throw new WorkspaceApiKeyScopeAuthorizationError() } } else if (!requestedWorkspaceId) { return { kind: 'account', userId: principal.userId } @@ -65,15 +76,16 @@ async function resolveBillingReadScope( if (principal.kind === 'personal_api_key') { if (!workspace.allowPersonalApiKeys) { - throw new OrchestrationError('forbidden', 'Personal API keys are disabled for this workspace') + throw new PersonalApiKeysDisabledError() } const permission = await resolveEffectiveWorkspacePermission( principal.userId, workspace.workspaceId, workspace.workspaceOrganizationId ) + if (permission === null) throw new NoWorkspaceAccessError() if (!permissionSatisfies(permission, operation.workspaceMinimumRole)) { - throw new OrchestrationError('forbidden', 'Access denied') + throw new InsufficientWorkspacePermissionsError() } } diff --git a/apps/sim/lib/copilot/tools/handlers/deployment/custom-block.ts b/apps/sim/lib/copilot/tools/handlers/deployment/custom-block.ts index 58f1ac5464b..a7ddfb72721 100644 --- a/apps/sim/lib/copilot/tools/handlers/deployment/custom-block.ts +++ b/apps/sim/lib/copilot/tools/handlers/deployment/custom-block.ts @@ -11,6 +11,7 @@ import { import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' import { canonicalizeVfsPath } from '@/lib/copilot/vfs/path-utils' import { isFeatureEnabled } from '@/lib/core/config/feature-flags' +import { buildStorageKeySegment } from '@/lib/uploads/core/storage-key' import { uploadFile } from '@/lib/uploads/core/storage-service' import { isImageFileType } from '@/lib/uploads/utils/file-utils' import { @@ -77,13 +78,12 @@ async function resolveIconUrl( { fileId: record.id, assertedWorkspaceId: workspaceId, maxBytes: MAX_ICON_BYTES }, { fileId: record.id } ) - const safeFileName = record.name.replace(/[^a-zA-Z0-9.-]/g, '_') const uploaded = await uploadFile({ file: buffer, fileName: record.name, contentType: record.type, context: 'workspace-logos', - customKey: `workspace-logos/${Date.now()}-${generateShortId()}-${safeFileName}`, + customKey: `workspace-logos/${buildStorageKeySegment(`${Date.now()}-${generateShortId()}-`, record.name)}`, preserveKey: true, metadata: { workspaceId, userId: context.userId, originalName: record.name }, }) diff --git a/apps/sim/lib/copilot/tools/server/table/user-table.ts b/apps/sim/lib/copilot/tools/server/table/user-table.ts index aa03686ccba..da3fee1d8ce 100644 --- a/apps/sim/lib/copilot/tools/server/table/user-table.ts +++ b/apps/sim/lib/copilot/tools/server/table/user-table.ts @@ -285,6 +285,7 @@ export const userTableServerTool: BaseServerTool kind: 'single', tableId: args.tableId, assertedWorkspaceId: workspaceId, + strictWrite: false, data: args.data, position: args.position as number | undefined, secretProvenance: createExactEmptyTableRowSecretProvenance(args.data), @@ -327,6 +328,7 @@ export const userTableServerTool: BaseServerTool kind: 'batch', tableId: args.tableId, assertedWorkspaceId: workspaceId, + strictWrite: false, rows: sourceRows, secretProvenance: sourceRows.map(createExactEmptyTableRowSecretProvenance), }, @@ -473,6 +475,7 @@ export const userTableServerTool: BaseServerTool { tableId: args.tableId, assertedWorkspaceId: workspaceId, + strictWrite: false, rowId: args.rowId, data: args.data, secretProvenance: createExactEmptyTableRowSecretProvenance(args.data), diff --git a/apps/sim/lib/core/application/authorized-workspace-use-case.ts b/apps/sim/lib/core/application/authorized-workspace-use-case.ts index bc3d20c14bb..a3286535edb 100644 --- a/apps/sim/lib/core/application/authorized-workspace-use-case.ts +++ b/apps/sim/lib/core/application/authorized-workspace-use-case.ts @@ -1,5 +1,5 @@ import { type AuditActionType, type AuditResourceTypeValue, recordAudit } from '@sim/audit' -import type { PrincipalAuditAttribution } from '@sim/auth/principal' +import type { Principal, PrincipalAuditAttribution } from '@sim/auth/principal' import { resolvePrincipalAuditAttribution } from '@sim/auth/principal' import type { OperationUseCase } from '@/lib/core/application/operation' import { @@ -109,27 +109,53 @@ export function defineAuthorizedWorkspaceUseCase< C extends WorkspaceAuthorizationContext, R, >(definition: AuthorizedWorkspaceUseCaseDefinition): OperationUseCase { + /** + * Everything that runs before the business transaction: allowed-principal + * check, canonical load, asserted-scope comparison, current access check. + * + * `execute` and `authorize` share it rather than each spelling it out, so a + * `HEAD` probe cannot answer a different question from the `GET` it stands + * for. It hands back the context it already loaded so the two phases together + * cost the same reads `execute` alone used to. + */ + async function authorizePhase({ + principal, + input, + request, + }: { + principal: Principal + input: I + request?: OrchestrationRequestContext + }): Promise> { + requireAllowedWorkspacePrincipal(principal, definition.operation) + const context = await definition.resolveContext({ principal, input }) + const executionContext: AuthorizedWorkspaceUseCaseContext = { + principal, + input, + context, + request, + } + const authorizationOptions = isAuthorizationOptionsResolver(definition.authorizationOptions) + ? await definition.authorizationOptions(executionContext) + : definition.authorizationOptions + + await authorizeWorkspaceOperation( + principal, + definition.operation, + context, + authorizationOptions + ) + return executionContext + } + return { operation: definition.operation, - async execute({ principal, input, request }) { - requireAllowedWorkspacePrincipal(principal, definition.operation) - const context = await definition.resolveContext({ principal, input }) - const executionContext: AuthorizedWorkspaceUseCaseContext = { - principal, - input, - context, - request, - } - const authorizationOptions = isAuthorizationOptionsResolver(definition.authorizationOptions) - ? await definition.authorizationOptions(executionContext) - : definition.authorizationOptions - - await authorizeWorkspaceOperation( - principal, - definition.operation, - context, - authorizationOptions - ) + async authorize(args) { + await authorizePhase(args) + }, + async execute(args) { + const executionContext = await authorizePhase(args) + const { principal, context, request } = executionContext const result = await definition.execute(executionContext) const resultContext = { ...executionContext, result } const projectedAudit = definition.projectAudit?.(resultContext) diff --git a/apps/sim/lib/core/application/forbidden.ts b/apps/sim/lib/core/application/forbidden.ts index 9c99901591d..5ac8b5c55cc 100644 --- a/apps/sim/lib/core/application/forbidden.ts +++ b/apps/sim/lib/core/application/forbidden.ts @@ -42,6 +42,12 @@ export const FORBIDDEN_DETAIL_CODES = [ 'AUDIT_LOGS_DISABLED', /** The caller holds workspace write but is not an editor of this skill. */ 'SKILL_EDITOR_ACCESS_REQUIRED', + /** The caller holds workspace write but is not an admin of this secret. */ + 'SECRET_ADMIN_ACCESS_REQUIRED', + /** The workspace is already at its ceiling for this kind of resource. */ + 'WORKSPACE_RESOURCE_LIMIT_REACHED', + /** The workspace's organization does not permit public sharing. */ + 'PUBLIC_SHARING_NOT_ALLOWED', /** The MCP server URL is outside the allowed domains or resolves internally. */ 'MCP_SERVER_URL_NOT_ALLOWED', ] as const @@ -71,6 +77,12 @@ export const FORBIDDEN_DETAIL_CODE_DESCRIPTIONS: Record { input: I request?: OrchestrationRequestContext }): Promise + /** + * Runs everything {@link execute} does up to and including resource + * authorization, then stops — allowed-principal check, canonical load, + * asserted-scope comparison, current access check — but not the business + * transaction, the audit projection, or the after-success effects. + * + * It exists for one caller: a surface that must answer *"would this principal + * be allowed?"* without causing what the answer would cause. `HEAD` on a route + * whose `GET` is not safe is that surface — see the `headSafe` option on the + * v2 route builders for why answering it any earlier leaks an existence + * oracle. + * + * Optional because most use cases have no such caller; the v2 builders reject + * a `headSafe: false` route that omits it at definition time. + */ + authorize?(args: { + principal: Principal + input: I + request?: OrchestrationRequestContext + }): Promise } diff --git a/apps/sim/lib/core/application/workspace-authorization.test.ts b/apps/sim/lib/core/application/workspace-authorization.test.ts index 3f37d3bcf8e..ac6c4ab6886 100644 --- a/apps/sim/lib/core/application/workspace-authorization.test.ts +++ b/apps/sim/lib/core/application/workspace-authorization.test.ts @@ -21,6 +21,8 @@ import { defineWorkspaceOperation, InsufficientWorkspacePermissionsError, NoWorkspaceAccessError, + PrincipalKindAuthorizationError, + WorkspaceApiKeyAuthorizationError, WorkspaceApiKeyScopeAuthorizationError, } from '@/lib/core/application' @@ -90,4 +92,27 @@ describe('authorizeWorkspaceOperation', () => { authorizeWorkspaceOperation(workspaceKeyPrincipal, workspaceKeyOperation, context) ).rejects.toBeInstanceOf(WorkspaceApiKeyScopeAuthorizationError) }) + + /** + * An operation that denies workspace keys necessarily omits + * `workspace_api_key` from `principalKinds`, so the kind guard is the only + * place this refusal can be raised. Reported as a generic kind refusal, the + * published `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` code is unmatchable by any + * client. + */ + it('names a workspace key refused by an operation that denies workspace keys', async () => { + await expect( + authorizeWorkspaceOperation( + { ...workspaceKeyPrincipal, workspaceId: context.workspaceId }, + writeOperation, + context + ) + ).rejects.toBeInstanceOf(WorkspaceApiKeyAuthorizationError) + }) + + it('still reports another disallowed principal kind as a kind refusal', async () => { + await expect( + authorizeWorkspaceOperation(principal, workspaceKeyOperation, context) + ).rejects.toBeInstanceOf(PrincipalKindAuthorizationError) + }) }) diff --git a/apps/sim/lib/core/application/workspace-authorization.ts b/apps/sim/lib/core/application/workspace-authorization.ts index bc3a61be9f8..4014afb158f 100644 --- a/apps/sim/lib/core/application/workspace-authorization.ts +++ b/apps/sim/lib/core/application/workspace-authorization.ts @@ -109,6 +109,20 @@ export function requireAllowedWorkspacePrincipal( operation: O ): asserts principal is PrincipalForOperation { if (!operation.principalKinds.some((kind) => kind === principal.kind)) { + /** + * A workspace key refused because the operation does not delegate to one is + * the case {@link WorkspaceApiKeyAuthorizationError} exists to name, and the + * one the `WORKSPACE_API_KEY_DENIED` OpenAPI sentence promises. It has to be + * separated here rather than left to `authorizeWorkspaceOperation`: an + * operation that denies workspace keys also omits `workspace_api_key` from + * `principalKinds` — `defineWorkspaceOperation` enforces that the two agree + * — so this guard always fires first and the later branch can never see such + * a principal. Reported as the generic kind refusal, a client branching on + * the published `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` never matched. + */ + if (principal.kind === 'workspace_api_key' && operation.workspaceApiKey === 'deny') { + throw new WorkspaceApiKeyAuthorizationError() + } throw new PrincipalKindAuthorizationError(principal.kind, operation.id) } if (principal.kind !== 'delegated') return diff --git a/apps/sim/lib/core/utils/stream-limits.test.ts b/apps/sim/lib/core/utils/stream-limits.test.ts index ae6e9b425d5..65d789887b1 100644 --- a/apps/sim/lib/core/utils/stream-limits.test.ts +++ b/apps/sim/lib/core/utils/stream-limits.test.ts @@ -6,6 +6,7 @@ import { Readable } from 'stream' import { describe, expect, it, vi } from 'vitest' import { assertContentLengthWithinLimit, + MultipartFieldValidationError, PayloadSizeLimitError, readFileToBufferWithLimit, readFormDataWithLimit, @@ -30,6 +31,42 @@ function streamFromChunks(chunks: Uint8Array[]): ReadableStream { }) } +/** + * Builds a raw multipart body by hand. `FormData` percent-escapes a NUL out of + * a filename on serialization, so a hand-written part is the only way to put + * the byte on the wire exactly as a real client can. + */ +function multipartRequest( + disposition: string, + value: string, + options: { declareContentLength?: boolean } = {} +): Request { + const boundary = 'streamlimitsboundary' + const body = + `--${boundary}\r\n` + + `Content-Disposition: form-data; ${disposition}\r\n` + + `Content-Type: text/plain\r\n\r\n${value}\r\n` + + `--${boundary}--\r\n` + const bytes = new TextEncoder().encode(body) + const requestHeaders = new Headers({ + 'content-type': `multipart/form-data; boundary=${boundary}`, + }) + if (options.declareContentLength) { + requestHeaders.set('content-length', String(bytes.byteLength)) + } + return new Request('http://localhost/upload', { + method: 'POST', + headers: requestHeaders, + body: bytes, + }) +} + +const NUL_MULTIPART_PARTS = [ + ['a NUL in a file name', 'name="file"; filename="apitest_\u0000x.txt"', 'hello'], + ['a NUL in a text field value', 'name="label"', 'apitest_\u0000x'], + ['a NUL in a field name', 'name="apitest_\u0000x"', 'hello'], +] as const + function headers(contentLength?: string): Headers { const headers = new Headers() if (contentLength !== undefined) headers.set('content-length', contentLength) @@ -197,6 +234,34 @@ describe('stream limits', () => { expect(formData.get('name')).toBe('example') }) + it.each(NUL_MULTIPART_PARTS)( + 'rejects a streamed multipart body carrying %s', + async (_label, disposition, value) => { + await expect( + readFormDataWithLimit(multipartRequest(disposition, value), { + maxBytes: 1024 * 1024, + label: 'multipart body', + }) + ).rejects.toBeInstanceOf(MultipartFieldValidationError) + } + ) + + /** + * A declared `content-length` takes the reader's other branch — the one every + * ordinary browser and curl upload takes — and it scans fields separately. + */ + it.each(NUL_MULTIPART_PARTS)( + 'rejects a content-length multipart body carrying %s', + async (_label, disposition, value) => { + const request = multipartRequest(disposition, value, { declareContentLength: true }) + expect(request.headers.get('content-length')).not.toBeNull() + + await expect( + readFormDataWithLimit(request, { maxBytes: 1024 * 1024, label: 'multipart body' }) + ).rejects.toBeInstanceOf(MultipartFieldValidationError) + } + ) + it('rejects multipart streams without content-length once bytes exceed the limit', async () => { const request = new Request('http://localhost/upload', { method: 'POST', diff --git a/apps/sim/lib/core/utils/stream-limits.ts b/apps/sim/lib/core/utils/stream-limits.ts index ebbfe4463f8..0c5e75c8715 100644 --- a/apps/sim/lib/core/utils/stream-limits.ts +++ b/apps/sim/lib/core/utils/stream-limits.ts @@ -1,4 +1,5 @@ import { toError } from '@sim/utils/errors' +import { containsNulCharacter } from '@sim/utils/string' export const DEFAULT_MAX_ERROR_BODY_BYTES = 64 * 1024 @@ -71,6 +72,65 @@ export interface ReadFormDataWithLimitRequest { formData: () => Promise } +/** + * A multipart field whose text a downstream store cannot represent. Distinct + * from {@link PayloadSizeLimitError} so a surface can project it as its own 400 + * with the reason intact, and never as a size failure. + */ +export class MultipartFieldValidationError extends Error { + constructor(message: string) { + super(message) + this.name = 'MultipartFieldValidationError' + } +} + +export function isMultipartFieldValidationError( + error: unknown +): error is MultipartFieldValidationError { + return error instanceof MultipartFieldValidationError +} + +/** + * Multipart is the second request boundary, and `parseRequest`'s NUL scan + * cannot reach it: a multipart route declares no body contract, so its fields + * never pass through contract validation at all. A NUL in a `filename` therefore + * reached the knowledge-document insert directly, and because the storage key is + * sanitized while `original_name`/`display_name` are not, the object landed in + * object storage *before* the insert threw — a 500 plus an orphan. + * + * So the scan belongs on the shared multipart reader, which is what every + * multipart route already funnels through, rather than on each route's own + * field extraction. Rejecting here also runs before the caller has a `File` to + * hand to a storage write, which is what removes the orphan rather than + * cleaning it up afterwards. + * + * Only field names, text values, and file names are scanned. A `File`'s bytes + * are deliberately not: a zero *byte* in binary content is legitimate, and the + * bytes never become a text column. + */ +function assertMultipartFieldsAreStorable(formData: FormData): void { + for (const [name, value] of formData.entries()) { + if (containsNulCharacter(name)) { + throw new MultipartFieldValidationError( + 'Multipart field names cannot contain a NUL character (U+0000)' + ) + } + if (typeof value === 'string') { + if (containsNulCharacter(value)) { + throw new MultipartFieldValidationError( + `Multipart field "${name}" cannot contain a NUL character (U+0000)` + ) + } + continue + } + if (containsNulCharacter(value.name)) { + throw new MultipartFieldValidationError( + `Multipart file name for field "${name}" cannot contain a NUL character (U+0000)` + ) + } + } +} + export async function readFormDataWithLimit( request: ReadFormDataWithLimitRequest, options: { maxBytes: number; label: string } @@ -78,7 +138,9 @@ export async function readFormDataWithLimit( assertContentLengthWithinLimit(request.headers, options.maxBytes, options.label) if (request.headers?.get('content-length') || !request.body) { - return request.formData() + const formData = await request.formData() + assertMultipartFieldsAreStorable(formData) + return formData } const body = await readStreamToBufferWithLimit(request.body, options) @@ -87,7 +149,9 @@ export async function readFormDataWithLimit( headers: request.headers, body: new Uint8Array(body), }) - return boundedRequest.formData() + const formData = await boundedRequest.formData() + assertMultipartFieldsAreStorable(formData) + return formData } export interface ReadStreamWithLimitOptions { diff --git a/apps/sim/lib/credentials/application/list-workspace-credentials.ts b/apps/sim/lib/credentials/application/list-workspace-credentials.ts index 45f2eee5c67..28658304025 100644 --- a/apps/sim/lib/credentials/application/list-workspace-credentials.ts +++ b/apps/sim/lib/credentials/application/list-workspace-credentials.ts @@ -1,5 +1,6 @@ import type { CursorKey, ListSortOrder } from '@/lib/api/list-query' import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' +import { NoWorkspaceAccessError } from '@/lib/core/application/workspace-authorization' import { OrchestrationError } from '@/lib/core/orchestration/types' import { credentialOperations } from '@/lib/credentials/application/operations' import { @@ -24,12 +25,6 @@ export interface ListWorkspaceCredentialsInput { export interface ListWorkspaceCredentialsResult { credentials: VisibleWorkspaceCredential[] nextCursorKeys: CursorKey[] | null - /** - * Echoed back because the route's presenter receives only this result, and the - * cursor it hands out has to be stamped with the sort that produced it. - */ - sortBy: ListWorkspaceCredentialsInput['sortBy'] - sortOrder: ListSortOrder } export const listWorkspaceCredentials = defineAuthorizedWorkspaceUseCase({ @@ -56,12 +51,19 @@ export const listWorkspaceCredentials = defineAuthorizedWorkspaceUseCase({ limit: input.limit, cursorKeys: input.cursorKeys, }) - return { credentials: page.data, nextCursorKeys: page.nextCursorKeys, ...sort } + return { credentials: page.data, nextCursorKeys: page.nextCursorKeys } } const workspaceAccess = await checkWorkspaceAccess(context.workspaceId, principal.userId) if (!workspaceAccess.hasAccess) { - throw new OrchestrationError('forbidden', 'Access denied') + /** + * `hasAccess` is `permission !== null` — the same condition + * `requirePermission` classifies as no reach into the workspace at all — + * so it raises the canonical error rather than a bare `forbidden`. It + * stays codeless deliberately: this is the concealed cross-tenant class, + * not one a caller can act on. + */ + throw new NoWorkspaceAccessError() } const page = await listVisibleWorkspaceCredentials({ workspaceId: context.workspaceId, @@ -74,6 +76,6 @@ export const listWorkspaceCredentials = defineAuthorizedWorkspaceUseCase({ limit: input.limit, cursorKeys: input.cursorKeys, }) - return { credentials: page.data, nextCursorKeys: page.nextCursorKeys, ...sort } + return { credentials: page.data, nextCursorKeys: page.nextCursorKeys } }, }) diff --git a/apps/sim/lib/folders/application-folder-caps.test.ts b/apps/sim/lib/folders/application-folder-caps.test.ts index 08c63d8798a..6324ff75646 100644 --- a/apps/sim/lib/folders/application-folder-caps.test.ts +++ b/apps/sim/lib/folders/application-folder-caps.test.ts @@ -8,7 +8,9 @@ const mocks = vi.hoisted(() => ({ listTables: vi.fn(), listWorkflows: vi.fn(), loadFolderIndex: vi.fn(), + queryWorkspaceFiles: vi.fn(), resolvePermission: vi.fn(), + resolveWorkspaceFileWorkspace: vi.fn(), resolveTableWorkspace: vi.fn(), resolveWorkflowWorkspace: vi.fn(), })) @@ -22,6 +24,12 @@ vi.mock('@/lib/folders/queries', () => ({ loadActiveFolderPathIndex: mocks.loadFolderIndex, resolveFolderPathFromIndex: (index: { idByPath: Map }, path: string) => path === '/' ? null : index.idByPath.get(path), + resolveFolderPathFilter: (index: { idByPath: Map }, path: string | undefined) => { + if (path === undefined) return { kind: 'unfiltered' } + if (path === '/') return { kind: 'folder', folderId: null } + const folderId = index.idByPath.get(path) + return folderId === undefined ? { kind: 'noMatch' } : { kind: 'folder', folderId } + }, })) vi.mock('@/lib/workflows/application/context', () => ({ resolveActiveWorkspaceApplicationContext: mocks.resolveWorkflowWorkspace, @@ -43,12 +51,19 @@ vi.mock('@/lib/table', () => ({ updateTableDescription: vi.fn(), })) vi.mock('@/lib/table/events', () => ({ signalTableSchemaChanged: vi.fn() })) +vi.mock('@/lib/uploads/contexts/workspace', () => ({ + listWorkspaceFiles: vi.fn(), + loadActiveWorkspaceContext: mocks.resolveWorkspaceFileWorkspace, + queryWorkspaceFiles: mocks.queryWorkspaceFiles, +})) +vi.mock('@/lib/public-shares/share-manager', () => ({ getWorkspaceShares: vi.fn() })) import { MAX_FOLDERS_PER_WORKSPACE } from '@/lib/folders/constants' import { listTableFoldersUseCase } from '@/lib/table/application/folders' import { listTablesUseCase } from '@/lib/table/application/tables' import { listWorkflows } from '@/lib/workflows/application/list-workflows' import { listWorkflowFolders } from '@/lib/workflows/application/workflow-folders' +import { queryWorkspaceFilePage } from '@/lib/workspace-files/application/list-workspace-files' const context = { workspaceId: 'workspace-1', @@ -73,6 +88,8 @@ describe('workflow and table application folder caps', () => { mocks.listFolderRows.mockResolvedValue([]) mocks.listWorkflows.mockResolvedValue({ data: [], nextCursorKeys: null }) mocks.listTables.mockResolvedValue({ tables: [], nextKeys: null }) + mocks.resolveWorkspaceFileWorkspace.mockResolvedValue(context) + mocks.queryWorkspaceFiles.mockResolvedValue({ files: [], nextKeys: null }) }) it.each([ @@ -140,6 +157,20 @@ describe('workflow and table application folder caps', () => { }, }), ], + [ + 'file', + () => + queryWorkspaceFilePage.execute({ + principal, + input: { + workspaceId: context.workspaceId, + sortBy: 'name', + sortOrder: 'asc', + limit: 25, + cursorSort: 'name:asc', + }, + }), + ], ] as const)('bounds the %s paged-resource folder index', async (resourceType, execute) => { await execute() @@ -151,3 +182,73 @@ describe('workflow and table application folder caps', () => { ) }) }) + +/** + * A `folderPath` that names no active folder is a filter nothing satisfies, not + * a missing collection. Answering `404 Folder not found` made the folder filter + * the only one of each list's filters whose miss was an error rather than an + * empty page, and turned a folder deleted mid-walk into a failed pagination + * loop. The row query must not run at all: without a folder id there is nothing + * to constrain it, so issuing it would return the whole unfiltered set. + */ +describe('a list folder filter that matches no folder', () => { + const MISSING = '/does-not-exist' + + beforeEach(() => { + vi.clearAllMocks() + mocks.resolvePermission.mockResolvedValue('read') + mocks.resolveWorkflowWorkspace.mockResolvedValue(context) + mocks.resolveTableWorkspace.mockResolvedValue(context) + mocks.resolveWorkspaceFileWorkspace.mockResolvedValue(context) + mocks.loadFolderIndex.mockResolvedValue(folderIndex) + }) + + it('returns an empty workflow page without querying rows', async () => { + const result = await listWorkflows.execute({ + principal, + input: { + workspaceId: context.workspaceId, + folderPath: MISSING, + deployedOnly: false, + sortBy: 'name', + sortOrder: 'asc', + limit: 25, + }, + }) + + expect(result).toMatchObject({ workflows: [], nextCursorKeys: null }) + expect(mocks.listWorkflows).not.toHaveBeenCalled() + }) + + it('returns an empty table page without querying rows', async () => { + const result = await listTablesUseCase.execute({ + principal, + input: { + workspaceId: context.workspaceId, + folderPath: MISSING, + sortBy: 'name', + sortOrder: 'asc', + limit: 25, + }, + }) + + expect(result).toMatchObject({ tables: [], nextKeys: null }) + expect(mocks.listTables).not.toHaveBeenCalled() + }) + + it('returns an empty file page without querying rows', async () => { + const result = await queryWorkspaceFilePage.execute({ + principal, + input: { + workspaceId: context.workspaceId, + folderPath: MISSING, + sortBy: 'name', + sortOrder: 'asc', + limit: 25, + }, + }) + + expect(result).toMatchObject({ files: [], nextKeys: null }) + expect(mocks.queryWorkspaceFiles).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/folders/paths.test.ts b/apps/sim/lib/folders/paths.test.ts index d8273169194..4071f5c1fd8 100644 --- a/apps/sim/lib/folders/paths.test.ts +++ b/apps/sim/lib/folders/paths.test.ts @@ -9,6 +9,7 @@ import { buildFolderPath, buildFolderPathIndex, encodeFolderPathSegment, + FolderPathError, MAX_FOLDER_PATH_SEGMENTS, parseFolderPath, ROOT_FOLDER_PATH, @@ -40,6 +41,18 @@ describe('canonical folder paths', () => { expect(() => parseFolderPath(path)).toThrow() }) + it.each(['/apitest_%00x', '/%00', '/Reports/Q1%00'])( + 'rejects a percent-encoded NUL in path %s', + (path) => { + expect(() => parseFolderPath(path)).toThrow(FolderPathError) + } + ) + + it('rejects a NUL in a folder name before it can be encoded into a path', () => { + expect(() => encodeFolderPathSegment('apitest_\u0000x')).toThrow(FolderPathError) + expect(() => buildFolderPath(['apitest_\u0000x'])).toThrow(FolderPathError) + }) + it('builds a bidirectional index and rejects corrupt hierarchies', () => { const rows = [ { id: 'a', name: 'Reports', parentId: null }, diff --git a/apps/sim/lib/folders/paths.ts b/apps/sim/lib/folders/paths.ts index 45886963053..5de99d4c523 100644 --- a/apps/sim/lib/folders/paths.ts +++ b/apps/sim/lib/folders/paths.ts @@ -1,4 +1,5 @@ import type { folder } from '@sim/db/schema' +import { containsNulCharacter } from '@sim/utils/string' import { OrchestrationError } from '@/lib/core/orchestration/types' export const ROOT_FOLDER_PATH = '/' @@ -54,9 +55,27 @@ function encodedByteLength(value: string): number { return new TextEncoder().encode(value).length } -/** Encodes one stored folder name without normalizing its case or Unicode form. */ +/** + * Encodes one stored folder name without normalizing its case or Unicode form. + * + * This is the single chokepoint for what a folder name may contain: every path + * built from names passes through it, and {@link parseFolderPath} re-encodes + * each decoded segment through it to prove canonicality. So the NUL rejection + * belongs here rather than at either caller. + * + * The request-level scan in `@/lib/api/server/nul-bytes` cannot cover this. A + * folder path arrives percent-encoded, so the scan sees `%00` — three ordinary + * characters — and passes it, and the NUL only exists after this module decodes + * it. Reads happened to survive (an unmatched path is a 404); writers carried + * the decoded name into an `INSERT` and the driver threw a 500. Validating at + * the decode boundary covers every percent-encoded escape a caller can spell, + * not just the one that was reported. + */ export function encodeFolderPathSegment(name: string): string { if (name.length === 0) throw new FolderPathError('Folder names cannot be empty') + if (containsNulCharacter(name)) { + throw new FolderPathError('Folder names cannot contain a NUL character (U+0000)') + } if (name === '.') return '%2E' if (name === '..') return '%2E%2E' diff --git a/apps/sim/lib/folders/queries.test.ts b/apps/sim/lib/folders/queries.test.ts index 74dfa1604dc..6992e317072 100644 --- a/apps/sim/lib/folders/queries.test.ts +++ b/apps/sim/lib/folders/queries.test.ts @@ -17,6 +17,7 @@ import { listActiveFolderRows, listFoldersForWorkspace, loadActiveFolderPathIndex, + resolveFolderPathFilter, resolveRestoredFolderId, toFolderApi, wouldCreateFolderCycle, @@ -318,6 +319,40 @@ describe('folder queries', () => { }) }) + /** + * The one place the real helper is exercised. Every list use case that filters + * by `folderPath` mocks this module out and stands a reimplementation in for + * it, so a defect here — a miss widening to unfiltered, a root path that stops + * resolving — would leave all of those suites green while every filtered list + * answered with the wrong rows. + */ + describe('resolveFolderPathFilter', () => { + const index = { + pathById: new Map([['f-1', 'Reports']]), + idByPath: new Map([['Reports', 'f-1']]), + } + + it('treats an omitted path as no filter at all', () => { + expect(resolveFolderPathFilter(index, undefined)).toEqual({ kind: 'unfiltered' }) + }) + + it('resolves the root path to the workspace root rather than to a folder id', () => { + expect(resolveFolderPathFilter(index, '/')).toEqual({ kind: 'folder', folderId: null }) + }) + + it('resolves a named path to its folder id', () => { + expect(resolveFolderPathFilter(index, 'Reports')).toEqual({ kind: 'folder', folderId: 'f-1' }) + }) + + /** + * A path naming no active folder narrows the list to nothing. Widening it to + * `unfiltered` would answer a scoped read with every row in the workspace. + */ + it('narrows to nothing for a path that names no active folder', () => { + expect(resolveFolderPathFilter(index, 'Archive')).toEqual({ kind: 'noMatch' }) + }) + }) + describe('toFolderApi', () => { it('serializes timestamps to ISO strings and preserves a null deletedAt', () => { expect(toFolderApi(ROW)).toMatchObject({ diff --git a/apps/sim/lib/folders/queries.ts b/apps/sim/lib/folders/queries.ts index 6bc402b68aa..dda1f63b436 100644 --- a/apps/sim/lib/folders/queries.ts +++ b/apps/sim/lib/folders/queries.ts @@ -265,6 +265,44 @@ export function resolveFolderPathFromIndex( return path === ROOT_FOLDER_PATH ? null : index.idByPath.get(path) } +/** + * A list's `folderPath` filter, resolved against the workspace's active folders. + * + * `unfiltered` is an omitted param, `folder` names one folder (`null` being the + * workspace root), and `noMatch` is a path that names no active folder. + */ +export type FolderPathFilter = + | { kind: 'unfiltered' } + | { kind: 'folder'; folderId: string | null } + | { kind: 'noMatch' } + +/** + * Resolves a list's `folderPath` filter, treating a path that names no active + * folder as a filter nothing satisfies rather than as a missing resource. + * + * A list is a collection, and every other filter it accepts answers a value + * nothing matches with an empty page — `workflowIds` naming no workflow and + * `model` naming no model both return zero rows. Answering `404 Folder not + * found` only on the folder filter made one filter's miss a different kind of + * event from all the others, told a caller its *collection* was missing when it + * was not, turned a folder deleted mid-walk into a failed pagination loop, and + * answered whether a path exists on an endpoint that was not asked. The sibling + * folder lists already answer a non-matching `parentPath` with an empty page, so + * this is the family's existing behavior applied to the resource lists too. + * + * A path that could not name a folder at all is still rejected by the contract, + * as a 400, before any of this runs. Mutations keep their 404: creating into or + * moving to a folder that does not exist has no empty-set reading. + */ +export function resolveFolderPathFilter( + index: FolderPathIndex, + path: string | undefined +): FolderPathFilter { + if (path === undefined) return { kind: 'unfiltered' } + const folderId = resolveFolderPathFromIndex(index, path) + return folderId === undefined ? { kind: 'noMatch' } : { kind: 'folder', folderId } +} + export async function listActiveFolderRows( workspaceId: string, resourceType: FolderResourceType, diff --git a/apps/sim/lib/knowledge/application/add-workspace-files.ts b/apps/sim/lib/knowledge/application/add-workspace-files.ts index c0400c14647..e4af11f53db 100644 --- a/apps/sim/lib/knowledge/application/add-workspace-files.ts +++ b/apps/sim/lib/knowledge/application/add-workspace-files.ts @@ -35,7 +35,10 @@ import { type WorkspaceFileRecord, } from '@/lib/uploads/contexts/workspace/workspace-file-manager' import { getBoundWorkspaceFileSecretProvenance } from '@/lib/uploads/contexts/workspace/workspace-file-secret-provenance' -import { MAX_KNOWLEDGE_DOCUMENT_FILE_SIZE } from '@/lib/uploads/shared/types' +import { + EMPTY_KNOWLEDGE_DOCUMENT_MESSAGE, + MAX_KNOWLEDGE_DOCUMENT_FILE_SIZE, +} from '@/lib/uploads/shared/types' import { validateFileType } from '@/lib/uploads/utils/validation' const logger = createLogger('AddWorkspaceFilesToKnowledgeBase') @@ -94,6 +97,9 @@ async function prepareWorkspaceFile( if (file.size < 0 || file.size > MAX_KNOWLEDGE_DOCUMENT_FILE_SIZE) { throw new OrchestrationError('payload_too_large', 'Knowledge document exceeds the 100MB limit') } + if (file.size === 0) { + throw new OrchestrationError('validation', EMPTY_KNOWLEDGE_DOCUMENT_MESSAGE) + } const fileTypeError = validateFileType(file.name, file.type) if (fileTypeError) throw new OrchestrationError('validation', fileTypeError.message) diff --git a/apps/sim/lib/knowledge/application/documents.test.ts b/apps/sim/lib/knowledge/application/documents.test.ts index 8df17daf2ee..122f4788957 100644 --- a/apps/sim/lib/knowledge/application/documents.test.ts +++ b/apps/sim/lib/knowledge/application/documents.test.ts @@ -370,6 +370,30 @@ describe('knowledge document application use cases', () => { expect(mocks.createDocument).toHaveBeenCalledOnce() }) + /** + * The size guard was upper-bound only, so a zero-byte file was admitted, put + * in storage, and registered — even though every parser refuses an empty + * buffer outright, so the document could only ever end up `failed`. An input + * the system provably cannot process belongs to the caller, not to storage. + */ + it('rejects a zero-byte upload before it reaches storage', async () => { + await expect( + uploadKnowledgeDocument.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { + knowledgeBaseId: 'knowledge-1', + assertedWorkspaceId: 'workspace-1', + file: { ...uploadFile, buffer: Buffer.alloc(0), fileSize: 0 }, + usageAdmission: 'pre_admitted', + }, + }) + ).rejects.toMatchObject({ code: 'validation' }) + + expect(mocks.uploadStoredFile).not.toHaveBeenCalled() + expect(mocks.recordKnowledgeBaseFileOwnership).not.toHaveBeenCalled() + expect(mocks.createDocument).not.toHaveBeenCalled() + }) + it('leaves only a sweepable knowledge-base binding when final authorization fails', async () => { mocks.resolvePermission.mockResolvedValueOnce('write').mockResolvedValueOnce(null) diff --git a/apps/sim/lib/knowledge/application/documents.ts b/apps/sim/lib/knowledge/application/documents.ts index 50cf0ce86c1..2d8136678a6 100644 --- a/apps/sim/lib/knowledge/application/documents.ts +++ b/apps/sim/lib/knowledge/application/documents.ts @@ -65,7 +65,10 @@ import { getDocumentTagDefinitions } from '@/lib/knowledge/tags/service' import { StorageService } from '@/lib/uploads' import { generateKnowledgeBaseFileKey } from '@/lib/uploads/contexts/knowledge-base/knowledge-base-file-manager' import { recordKnowledgeBaseFileOwnership } from '@/lib/uploads/server/metadata' -import { MAX_KNOWLEDGE_DOCUMENT_FILE_SIZE } from '@/lib/uploads/shared/types' +import { + EMPTY_KNOWLEDGE_DOCUMENT_MESSAGE, + MAX_KNOWLEDGE_DOCUMENT_FILE_SIZE, +} from '@/lib/uploads/shared/types' import { validateFileType } from '@/lib/uploads/utils/validation' const logger = createLogger('KnowledgeDocumentApplication') @@ -87,12 +90,6 @@ export interface ListKnowledgeDocumentsInput { * document filtering and search speak one tag vocabulary. */ tagNameFilters?: KnowledgeTagNameFilter[] - /** - * The query state `offset` counts positions within, echoed back so a surface - * presenter can stamp the next cursor with it. Surface-only; the read itself - * does not use it. - */ - cursorScope?: string } export interface ReadKnowledgeDocumentInput { @@ -259,7 +256,6 @@ export const listKnowledgeDocuments = defineAuthorizedKnowledgeUseCase({ resolvedNameFilters?.definitionsByKnowledgeBase.get(context.knowledgeBaseId) ?? (await getDocumentTagDefinitions(context.knowledgeBaseId)), workspaceId: context.workspaceId, - cursorScope: input.cursorScope, } }, }) @@ -311,6 +307,9 @@ export const uploadKnowledgeDocument = defineAuthorizedKnowledgeUseCase({ if (input.file.fileSize !== input.file.buffer.byteLength) { throw new Error('Knowledge document upload size does not match its buffered bytes') } + if (input.file.fileSize === 0) { + throw new OrchestrationError('validation', EMPTY_KNOWLEDGE_DOCUMENT_MESSAGE) + } const fileTypeError = validateFileType(input.file.filename, input.file.mimeType) if (fileTypeError) throw new OrchestrationError('validation', fileTypeError.message) if (input.usageAdmission !== 'pre_admitted') { diff --git a/apps/sim/lib/knowledge/application/knowledge-bases.test.ts b/apps/sim/lib/knowledge/application/knowledge-bases.test.ts index f3328157bae..678f5205498 100644 --- a/apps/sim/lib/knowledge/application/knowledge-bases.test.ts +++ b/apps/sim/lib/knowledge/application/knowledge-bases.test.ts @@ -52,6 +52,12 @@ vi.mock('@/lib/core/telemetry', () => ({ vi.mock('@/lib/folders/queries', () => ({ loadActiveFolderPathIndex: mocks.loadFolderIndex, + resolveFolderPathFilter: (index: { idByPath: Map }, path: string | undefined) => { + if (path === undefined) return { kind: 'unfiltered' } + if (path === '/') return { kind: 'folder', folderId: null } + const folderId = index.idByPath.get(path) + return folderId === undefined ? { kind: 'noMatch' } : { kind: 'folder', folderId } + }, })) vi.mock('@/lib/knowledge/application/contexts', () => ({ @@ -94,6 +100,7 @@ import { listArchivedKnowledgeBases, listInternalKnowledgeBases, listKnowledgeBaseCatalog, + listKnowledgeBases, readInternalKnowledgeBase, readKnowledgeBase, restoreInternalKnowledgeBase, @@ -141,7 +148,7 @@ describe('knowledge base application use cases', () => { folderId: null, index: { pathById: new Map(), idByPath: new Map(), rowById: new Map() }, }) - mocks.loadFolderIndex.mockResolvedValue({ pathById: new Map() }) + mocks.loadFolderIndex.mockResolvedValue({ pathById: new Map(), idByPath: new Map() }) mocks.createRecord.mockResolvedValue(knowledgeBase) mocks.listRecords.mockResolvedValue({ data: [], nextCursorKeys: null }) mocks.listInternalRecords.mockResolvedValue([knowledgeBase]) @@ -157,6 +164,22 @@ describe('knowledge base application use cases', () => { mocks.deleteRecord.mockResolvedValue(undefined) }) + /** + * The folder filter is a filter like any other: a path naming no active folder + * narrows the list to nothing instead of failing it. Reaching the row query + * with no folder id would return every knowledge base in the workspace. + */ + it('returns an empty page for a folder path that matches no folder', async () => { + const result = await listKnowledgeBases.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { workspaceId: 'workspace-1', folderPath: '/does-not-exist' }, + }) + + expect(result).toMatchObject({ knowledgeBases: [], nextCursorKeys: null }) + expect(mocks.listRecords).not.toHaveBeenCalled() + expect(mocks.resolveFolderPath).not.toHaveBeenCalled() + }) + it('lists legacy personal knowledge bases through the explicit session-only operation', async () => { await expect( listInternalKnowledgeBases.execute({ diff --git a/apps/sim/lib/knowledge/application/knowledge-bases.ts b/apps/sim/lib/knowledge/application/knowledge-bases.ts index 9fd5ba27b57..88d80335a64 100644 --- a/apps/sim/lib/knowledge/application/knowledge-bases.ts +++ b/apps/sim/lib/knowledge/application/knowledge-bases.ts @@ -14,7 +14,7 @@ import { import { asOrchestrationError, OrchestrationError } from '@/lib/core/orchestration/types' import { PlatformEvents } from '@/lib/core/telemetry' import { generateRequestId } from '@/lib/core/utils/request' -import { loadActiveFolderPathIndex } from '@/lib/folders/queries' +import { loadActiveFolderPathIndex, resolveFolderPathFilter } from '@/lib/folders/queries' import { knowledgeDelegationPolicy } from '@/lib/knowledge/application/authorization' import { defineAuthorizedKnowledgeUseCase } from '@/lib/knowledge/application/authorized-knowledge-use-case' import { @@ -229,22 +229,27 @@ async function executeListKnowledgeBases(args: { context: KnowledgeWorkspaceContext }): Promise { /** - * The folder index renders each row's `folderPath` and the folder filter - * resolves the caller's `folderPath` to an id. Neither reads the other, so - * they run together rather than adding a serial round-trip to a list route. + * One index read serves both jobs: rendering each row's `folderPath` and + * resolving the caller's `folderPath` filter to an id, so the filter costs no + * second, lock-taking read of its own. */ - const [index, folderId] = await Promise.all([ - loadActiveFolderPathIndex(args.context.workspaceId, 'knowledge_base', undefined, { - maxRows: MAX_KNOWLEDGE_FOLDERS_PER_WORKSPACE, - }), - args.input.folderPath === undefined - ? undefined - : resolveKnowledgeFolderPath(args.context.workspaceId, args.input.folderPath).then( - (resolved) => resolved.folderId - ), - ]) + const index = await loadActiveFolderPathIndex( + args.context.workspaceId, + 'knowledge_base', + undefined, + { maxRows: MAX_KNOWLEDGE_FOLDERS_PER_WORKSPACE } + ) + const folderFilter = resolveFolderPathFilter(index, args.input.folderPath) + if (folderFilter.kind === 'noMatch') { + return { + knowledgeBases: [], + nextCursorKeys: null, + sortBy: args.input.sortBy ?? 'createdAt', + sortOrder: args.input.sortOrder ?? 'asc', + } + } const page = await getWorkspaceKnowledgeBases(args.context.workspaceId, 'active', { - folderId, + folderId: folderFilter.kind === 'folder' ? folderFilter.folderId : undefined, search: args.input.search, sortBy: args.input.sortBy, sortOrder: args.input.sortOrder, diff --git a/apps/sim/lib/knowledge/application/search.test.ts b/apps/sim/lib/knowledge/application/search.test.ts index e089eb98761..bd8990759c1 100644 --- a/apps/sim/lib/knowledge/application/search.test.ts +++ b/apps/sim/lib/knowledge/application/search.test.ts @@ -17,6 +17,11 @@ const mocks = vi.hoisted(() => ({ getTagDefinitions: vi.fn(), recordEmbeddingUsage: vi.fn(), importProvenance: vi.fn(), + rerank: vi.fn(), +})) + +vi.mock('@/lib/knowledge/reranker', () => ({ + rerank: mocks.rerank, })) vi.mock('@sim/platform-authz/workspace', () => ({ @@ -307,6 +312,91 @@ describe('knowledge search application use case', () => { }) }) + describe('reranker outcome reporting', () => { + const rerankedSearch = (rerankerEnabled?: boolean, query: string | undefined = 'answer') => + searchKnowledge.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { + workspaceId: 'workspace-1', + knowledgeBaseIds: ['knowledge-1'], + ...(query === undefined ? {} : { query }), + topK: 5, + ...(rerankerEnabled === undefined ? {} : { rerankerEnabled }), + rerankerModel: 'rerank-v4.0-pro' as const, + }, + }) + + it('reports applied when the reranker ordered the results', async () => { + mocks.rerank.mockResolvedValueOnce({ + results: [{ item: { id: 'embedding-1' }, relevanceScore: 0.93 }], + isBYOK: false, + }) + + const result = await rerankedSearch(true) + + expect(result.rerankerStatus).toBe('applied') + expect(result.results[0]).toMatchObject({ rerankerScore: 0.93 }) + }) + + /** + * The reproduced defect: a deployment with no Cohere credential threw inside + * `rerank`, the use case swallowed it, and the caller got a 200 whose results + * were byte-identical to an unreranked search with nothing to distinguish them. + */ + it('reports unavailable rather than silently falling back to vector ordering', async () => { + mocks.rerank.mockRejectedValueOnce(new Error('No Cohere API key configured.')) + + const result = await rerankedSearch(true) + + expect(result.rerankerStatus).toBe('unavailable') + expect(result.results[0]).not.toHaveProperty('rerankerScore') + }) + + /** + * A resolved call with an empty ordering leaves the caller in the same place a + * thrown one does — vector order, no `rerankerScore` — so it reports the same + * status. It is not "the reranker matched nothing": `rerank` sends a non-empty + * document list and asks for `top_n` of it, so an empty array means the + * response carried nothing usable rather than a legitimate empty ranking. + */ + it('reports unavailable when the call resolves without a usable ordering', async () => { + mocks.rerank.mockResolvedValueOnce({ results: [], isBYOK: false }) + + const result = await rerankedSearch(true) + + expect(result.rerankerStatus).toBe('unavailable') + expect(result.results[0]).not.toHaveProperty('rerankerScore') + }) + + it('reports skipped for a tag-only search, which has no query to rank against', async () => { + mocks.getTagDefinitions.mockResolvedValue([ + { tagSlot: 'tag1', displayName: 'team', fieldType: 'text' }, + ]) + + const result = await searchKnowledge.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { + workspaceId: 'workspace-1', + knowledgeBaseIds: ['knowledge-1'], + topK: 5, + tagFilters: [{ tagName: 'team', operator: 'eq', value: 'docs' }], + rerankerEnabled: true, + rerankerModel: 'rerank-v4.0-pro' as const, + }, + }) + + expect(result.rerankerStatus).toBe('skipped') + expect(mocks.rerank).not.toHaveBeenCalled() + }) + + it('reports not_requested when the caller did not ask for reranking', async () => { + const result = await rerankedSearch(undefined) + + expect(result.rerankerStatus).toBe('not_requested') + expect(mocks.rerank).not.toHaveBeenCalled() + }) + }) + it('propagates tag-definition infrastructure failures', async () => { const failure = new Error('tag database unavailable') mocks.getTagDefinitions.mockRejectedValueOnce(failure) diff --git a/apps/sim/lib/knowledge/application/search.ts b/apps/sim/lib/knowledge/application/search.ts index 131853d1c0a..310447d060b 100644 --- a/apps/sim/lib/knowledge/application/search.ts +++ b/apps/sim/lib/knowledge/application/search.ts @@ -30,6 +30,7 @@ import { ALL_TAG_SLOTS } from '@/lib/knowledge/constants' import { getEmbeddingModelInfo } from '@/lib/knowledge/embedding-models' import { runWithKnowledgeModelInputProvenance } from '@/lib/knowledge/model-input-provenance' import { rerank } from '@/lib/knowledge/reranker' +import type { RerankerStatus } from '@/lib/knowledge/reranker-models' import { executeKnowledgeSearch, generateSearchEmbedding, @@ -316,6 +317,35 @@ export const searchKnowledge = defineAuthorizedKnowledgeUseCase({ const rerankerScores = new Map() let rerankerBilled = false let rerankerIsBYOK = false + /** + * Returned on every search. The fallback to vector ordering is deliberate — a + * Cohere outage should not take knowledge search down with it — but until this + * was reported the fallback was also invisible: a 200 whose results were + * byte-identical to an unreranked search, with no `rerankerScore` anywhere and + * nothing to say why. + * + * It starts at the outcome that holds if the rerank call below never happens or + * never completes, so only the success path has to move it. A request with + * nothing to rank — no query text, or no candidate rows — is `skipped` rather + * than `unavailable`: the reranker was never the obstacle. Anything else that + * was asked for and did not produce a usable ordering is `unavailable`, + * including a request that reaches here with no model, which no HTTP contract + * can now produce. + * + * A call that returns without raising but hands back an empty ordering counts + * as `unavailable` too, and it is not the reranker "matching nothing": + * `rerank` asks for `top_n` over a non-empty document list, so a provider that + * ranked them returns one entry per document. Empty means the response carried + * nothing usable — no results, or only indices outside the batch, which + * `rerank` drops. The caller is left in vector order with no `rerankerScore`, + * which is exactly what `unavailable` promises, and retrying is exactly the + * right advice. + */ + let rerankerStatus: RerankerStatus = !input.rerankerEnabled + ? 'not_requested' + : !hasQuery || rows.length === 0 + ? 'skipped' + : 'unavailable' if (useReranker && input.rerankerModel && rows.length > 0) { const candidateCount = rows.length try { @@ -343,6 +373,7 @@ export const searchKnowledge = defineAuthorizedKnowledgeUseCase({ for (const ranked of reranked.results) { rerankerScores.set(ranked.item.id, ranked.relevanceScore) } + rerankerStatus = 'applied' } } catch (error) { if (registry?.isPermanentlyIncomplete()) throw error @@ -352,6 +383,7 @@ export const searchKnowledge = defineAuthorizedKnowledgeUseCase({ candidateCount, }) rows = rows.slice(0, input.topK) + rerankerStatus = 'unavailable' } } else if (useReranker) { rows = rows.slice(0, input.topK) @@ -505,6 +537,7 @@ export const searchKnowledge = defineAuthorizedKnowledgeUseCase({ knowledgeBaseId: knowledgeBaseIds[0], topK: input.topK, totalResults: results.length, + rerankerStatus, cost, ...(context.workspaceId ? { workspaceId: context.workspaceId } : {}), userId, diff --git a/apps/sim/lib/knowledge/application/upload-sessions.test.ts b/apps/sim/lib/knowledge/application/upload-sessions.test.ts index e4b43e9cf83..6f870c76254 100644 --- a/apps/sim/lib/knowledge/application/upload-sessions.test.ts +++ b/apps/sim/lib/knowledge/application/upload-sessions.test.ts @@ -11,6 +11,7 @@ const mocks = vi.hoisted(() => ({ createDocument: vi.fn(), createPartUrls: vi.fn(), createUpload: vi.fn(), + failUndispatched: vi.fn(), findBound: vi.fn(), getUpload: vi.fn(), processQueue: vi.fn(), @@ -48,6 +49,10 @@ vi.mock('@/lib/knowledge/application/contexts', () => ({ resolveActiveKnowledgeBaseContext: mocks.resolveContext, })) +vi.mock('@/lib/knowledge/documents/processing-claim', () => ({ + failUndispatchedDocumentProcessing: mocks.failUndispatched, +})) + vi.mock('@/lib/knowledge/documents/service', () => ({ createSingleDocument: mocks.createDocument, processDocumentsWithQueue: mocks.processQueue, @@ -195,6 +200,7 @@ describe('knowledge-document upload application lifecycle', () => { mocks.findBound.mockResolvedValue({ status: 'absent' }) mocks.createDocument.mockResolvedValue(DOCUMENT) mocks.processQueue.mockResolvedValue(undefined) + mocks.failUndispatched.mockResolvedValue(true) }) it('admits, binds, and records ownership before returning upload credentials', async () => { @@ -394,43 +400,185 @@ describe('knowledge-document upload application lifecycle', () => { expect(mocks.recordAudit).not.toHaveBeenCalled() }) - it('fails completion when document processing cannot be dispatched', async () => { - const failure = new Error('queue unavailable') - mocks.processQueue.mockRejectedValue(failure) + /** + * The registration is durable before indexing is queued, so a queue that is + * down cannot un-create the document. Failing the call reports a completion + * that did happen as a 500, and the only recovery a caller has — replaying the + * same request — answers `200 completed`. + */ + it('completes and audits the upload when processing cannot be dispatched', async () => { + mocks.processQueue.mockRejectedValue(new Error('queue unavailable')) mocks.completeUpload.mockImplementation( async (params: { session: UploadSessionRecord - finalize: (session: UploadSessionRecord) => Promise - }) => params.finalize(params.session) + finalize: (session: UploadSessionRecord) => Promise<{ + value: { document: typeof DOCUMENT; created: boolean; knowledgeBaseName: string | null } + completedFileId?: string + }> + }) => { + const finalized = await params.finalize(params.session) + return { + session: { ...params.session, status: 'completed' as const }, + value: finalized.value, + alreadyCompleted: false, + } + } ) - await expect( - completeKnowledgeDocumentUpload.execute({ - principal: PRINCIPAL, - input: { - knowledgeBaseId: 'knowledge-1', - assertedWorkspaceId: 'workspace-1', - uploadId: 'upload-1', - uploadToken: 'token', - source: 'api', - }, - request: REQUEST, - }) - ).rejects.toMatchObject({ - name: 'KnowledgeDocumentProcessingDispatchError', - message: 'Knowledge document processing dispatch failed', - cause: failure, + const result = await completeKnowledgeDocumentUpload.execute({ + principal: PRINCIPAL, + input: { + knowledgeBaseId: 'knowledge-1', + assertedWorkspaceId: 'workspace-1', + uploadId: 'upload-1', + uploadToken: 'token', + source: 'api', + }, + request: REQUEST, }) + + expect(result.value.created).toBe(true) + expect(result.value.document).toEqual(DOCUMENT) expect(mocks.createDocument).toHaveBeenCalledTimes(1) - expect(mocks.recordAudit).not.toHaveBeenCalled() + expect(mocks.processQueue).toHaveBeenCalledTimes(1) + expect(mocks.recordAudit).toHaveBeenCalledTimes(1) + }) + + /** + * A completed session with a `completedFileId` replays into `loadCompleted`, + * which never dispatches, and nothing sweeps `pending`. Leaving the document + * there strands it with no retry and no signal, so the failure is recorded on + * the row — the state `retryProcessing` accepts. + */ + it('marks the document failed when its processing dispatch never got off the ground', async () => { + mocks.processQueue.mockRejectedValue(new Error('queue unavailable')) + mocks.completeUpload.mockImplementation( + async (params: { + session: UploadSessionRecord + finalize: (session: UploadSessionRecord) => Promise<{ + value: { document: typeof DOCUMENT; created: boolean; knowledgeBaseName: string | null } + completedFileId?: string + }> + }) => { + const finalized = await params.finalize(params.session) + return { + session: { ...params.session, status: 'completed' as const }, + value: finalized.value, + alreadyCompleted: false, + } + } + ) + + await completeKnowledgeDocumentUpload.execute({ + principal: PRINCIPAL, + input: { + knowledgeBaseId: 'knowledge-1', + assertedWorkspaceId: 'workspace-1', + uploadId: 'upload-1', + uploadToken: 'token', + source: 'api', + }, + request: REQUEST, + }) + + expect(mocks.failUndispatched).toHaveBeenCalledWith({ + documentId: DOCUMENT.id, + knowledgeBaseId: 'knowledge-1', + error: 'queue unavailable', + }) + }) + + /** Recording the failure is itself best-effort; it must not resurface as a 500. */ + it('still completes when the dispatch failure cannot be recorded', async () => { + mocks.processQueue.mockRejectedValue(new Error('queue unavailable')) + mocks.failUndispatched.mockRejectedValue(new Error('database unavailable')) + mocks.completeUpload.mockImplementation( + async (params: { + session: UploadSessionRecord + finalize: (session: UploadSessionRecord) => Promise<{ + value: { document: typeof DOCUMENT; created: boolean; knowledgeBaseName: string | null } + completedFileId?: string + }> + }) => { + const finalized = await params.finalize(params.session) + return { + session: { ...params.session, status: 'completed' as const }, + value: finalized.value, + alreadyCompleted: false, + } + } + ) + + const result = await completeKnowledgeDocumentUpload.execute({ + principal: PRINCIPAL, + input: { + knowledgeBaseId: 'knowledge-1', + assertedWorkspaceId: 'workspace-1', + uploadId: 'upload-1', + uploadToken: 'token', + source: 'api', + }, + request: REQUEST, + }) + + expect(result.value.document).toEqual(DOCUMENT) + }) + + /** + * The dispatch is a follow-on to the completion, not a step inside it: a + * completion that cannot write its durable marker must not have queued + * indexing for a document the caller was told nothing about. + */ + it('queues processing only after the session is durably completed', async () => { + const order: string[] = [] + mocks.processQueue.mockImplementation(async () => { + order.push('dispatch') + }) + mocks.completeUpload.mockImplementation( + async (params: { + session: UploadSessionRecord + finalize: (session: UploadSessionRecord) => Promise<{ + value: { document: typeof DOCUMENT; created: boolean; knowledgeBaseName: string | null } + completedFileId?: string + }> + }) => { + const finalized = await params.finalize(params.session) + order.push('completed') + return { + session: { ...params.session, status: 'completed' as const }, + value: finalized.value, + alreadyCompleted: false, + } + } + ) + + await completeKnowledgeDocumentUpload.execute({ + principal: PRINCIPAL, + input: { + knowledgeBaseId: 'knowledge-1', + assertedWorkspaceId: 'workspace-1', + uploadId: 'upload-1', + uploadToken: 'token', + source: 'api', + }, + request: REQUEST, + }) + + expect(order).toEqual(['completed', 'dispatch']) }) - it('retries a failed processing dispatch before completing a bound registration', async () => { + /** + * The re-queue is decided by the document — a registration still `pending` was + * never picked up — rather than by the message a previous failure happened to + * leave on the session. The session no longer carries one: a dispatch failure + * completes the session and is logged, so keying recovery off `session.error` + * would leave a `pending` document with nothing to re-queue it. + */ + it('re-queues a bound registration whose document was never picked up', async () => { const recoveringSession = { ...SESSION, status: 'finalizing' as const, completedFileId: null, - error: 'Knowledge document processing dispatch failed', } mocks.getUpload.mockResolvedValue(recoveringSession) mocks.findBound.mockResolvedValue({ diff --git a/apps/sim/lib/knowledge/application/upload-sessions.ts b/apps/sim/lib/knowledge/application/upload-sessions.ts index 73526ddf1f5..748f8408838 100644 --- a/apps/sim/lib/knowledge/application/upload-sessions.ts +++ b/apps/sim/lib/knowledge/application/upload-sessions.ts @@ -1,5 +1,8 @@ import { AuditAction, AuditResourceType } from '@sim/audit' import type { Principal } from '@sim/auth/principal' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { truncate } from '@sim/utils/string' import { checkAttributedUsageLimits } from '@/lib/billing/core/billing-attribution' import { authorizeWorkspaceOperation, type WorkspaceOperation } from '@/lib/core/application' import { OrchestrationError } from '@/lib/core/orchestration/types' @@ -16,6 +19,7 @@ import { resolveActiveKnowledgeBaseContext, } from '@/lib/knowledge/application/contexts' import { knowledgeOperations } from '@/lib/knowledge/application/operations' +import { failUndispatchedDocumentProcessing } from '@/lib/knowledge/documents/processing-claim' import { createSingleDocument, type DocumentData, @@ -40,14 +44,12 @@ import { } from '@/lib/uploads/upload-session/service' import { validateFileType } from '@/lib/uploads/utils/validation' +const logger = createLogger('KnowledgeUploadSessions') + const PROCESSING_DISPATCH_FAILURE_MESSAGE = 'Knowledge document processing dispatch failed' -class KnowledgeDocumentProcessingDispatchError extends Error { - constructor(cause: unknown) { - super(PROCESSING_DISPATCH_FAILURE_MESSAGE, { cause }) - this.name = 'KnowledgeDocumentProcessingDispatchError' - } -} +/** Keeps a driver or provider message from filling the document row's error column. */ +const DISPATCH_FAILURE_MESSAGE_MAX_LENGTH = 500 export class KnowledgeDocumentUnsupportedMediaTypeError extends Error { constructor(message: string) { @@ -209,6 +211,13 @@ export const completeKnowledgeDocumentUpload = defineAuthorizedKnowledgeUseCase( const requestId = generateRequestId() const recoveringUnprojectedRegistration = session.status === 'finalizing' && session.completedFileId === null + /** + * Filled by whichever completion branch establishes a document that still + * needs indexing, and acted on only after the session is durably completed. + * Registration and dispatch are two different transactions: the first must + * commit even when the second cannot run. + */ + let pendingDispatch: PendingProcessingDispatch | null = null const result = await completeUploadSession({ session, loadCompleted: async (claimed) => { @@ -261,21 +270,13 @@ export const completeKnowledgeDocumentUpload = defineAuthorizedKnowledgeUseCase( ) } if (bound.status === 'bound') { - if ( - session.error === PROCESSING_DISPATCH_FAILURE_MESSAGE && - bound.document.processingStatus === 'pending' - ) { - const billingAttribution = await resolveKnowledgeBillingAttribution( - principal, - freshContext - ) - await dispatchKnowledgeDocumentProcessing( - bound.document, - freshContext.knowledgeBaseId, + if (bound.document.processingStatus === 'pending') { + pendingDispatch = { + document: bound.document, + knowledgeBaseId: freshContext.knowledgeBaseId, processingOptions, - requestId, - billingAttribution - ) + billingAttribution: await resolveKnowledgeBillingAttribution(principal, freshContext), + } } return { value: { @@ -339,13 +340,12 @@ export const completeKnowledgeDocumentUpload = defineAuthorizedKnowledgeUseCase( throw error } - await dispatchKnowledgeDocumentProcessing( - created, - registrationContext.knowledgeBaseId, + pendingDispatch = { + document: created, + knowledgeBaseId: registrationContext.knowledgeBaseId, processingOptions, - requestId, - billingAttribution - ) + billingAttribution, + } return { value: { document: created, @@ -356,6 +356,7 @@ export const completeKnowledgeDocumentUpload = defineAuthorizedKnowledgeUseCase( } }, }) + if (pendingDispatch) await queueKnowledgeDocumentProcessing(pendingDispatch, requestId) return { ...result, workspaceId: context.workspaceId, @@ -383,30 +384,74 @@ export const completeKnowledgeDocumentUpload = defineAuthorizedKnowledgeUseCase( }, }) -async function dispatchKnowledgeDocumentProcessing( - document: CreatedKnowledgeDocument, - knowledgeBaseId: string, - processingOptions: KnowledgeDocumentUploadMetadata['processingOptions'], - requestId: string, +/** Everything the indexing dispatch needs, captured while the completion still holds its lease. */ +interface PendingProcessingDispatch { + document: CreatedKnowledgeDocument + knowledgeBaseId: string + processingOptions: KnowledgeDocumentUploadMetadata['processingOptions'] billingAttribution: Awaited> +} + +/** + * Queues indexing for a document the completion has already made durable. + * + * It runs after `completeUploadSession` resolves, and a failure is recorded + * rather than raised, because by that point the caller's request has already + * succeeded: the object is stored, the document row exists, and the session is + * marked completed. A 500 raised after all of that has committed describes + * nothing the caller can act on — replaying the same request answers + * `200 completed`. + * + * The dispatch outcome is not lost by going unraised. `processDocumentsWithQueue` + * marks the document `failed` with its error when processing itself breaks. When + * the dispatch never got off the ground the document would instead be left at + * `pending`, which nothing sweeps and which `retryProcessing` refuses, so + * {@link failUndispatchedDocumentProcessing} records the failure on the row. + * Either way the error is visible on every subsequent read of the document, and + * the document can be re-queued through + * `PATCH /api/knowledge/{id}/documents/{documentId}` with `retryProcessing`. + */ +async function queueKnowledgeDocumentProcessing( + dispatch: PendingProcessingDispatch, + requestId: string ): Promise { const processingDocument: DocumentData = { - documentId: document.id, - filename: document.filename, - fileUrl: document.fileUrl, - fileSize: document.fileSize, - mimeType: document.mimeType, + documentId: dispatch.document.id, + filename: dispatch.document.filename, + fileUrl: dispatch.document.fileUrl, + fileSize: dispatch.document.fileSize, + mimeType: dispatch.document.mimeType, } try { await processDocumentsWithQueue( [processingDocument], - knowledgeBaseId, - processingOptions ?? {}, + dispatch.knowledgeBaseId, + dispatch.processingOptions ?? {}, requestId, - billingAttribution + dispatch.billingAttribution ) } catch (error) { - throw new KnowledgeDocumentProcessingDispatchError(error) + const failureMessage = getErrorMessage(error, 'Document processing dispatch failed') + logger.error(PROCESSING_DISPATCH_FAILURE_MESSAGE, { + requestId, + documentId: dispatch.document.id, + knowledgeBaseId: dispatch.knowledgeBaseId, + error: failureMessage, + }) + try { + await failUndispatchedDocumentProcessing({ + documentId: dispatch.document.id, + knowledgeBaseId: dispatch.knowledgeBaseId, + error: truncate(failureMessage, DISPATCH_FAILURE_MESSAGE_MAX_LENGTH), + }) + } catch (markError) { + logger.error('Failed to record a knowledge document dispatch failure', { + requestId, + documentId: dispatch.document.id, + knowledgeBaseId: dispatch.knowledgeBaseId, + error: getErrorMessage(markError), + }) + } } } diff --git a/apps/sim/lib/knowledge/connectors/sync-engine.ts b/apps/sim/lib/knowledge/connectors/sync-engine.ts index 109167e2edc..d3197f88362 100644 --- a/apps/sim/lib/knowledge/connectors/sync-engine.ts +++ b/apps/sim/lib/knowledge/connectors/sync-engine.ts @@ -22,6 +22,7 @@ import type { DocumentData } from '@/lib/knowledge/documents/service' import { hardDeleteDocuments, processDocumentsWithQueue } from '@/lib/knowledge/documents/service' import { refreshAccessTokenIfNeeded } from '@/lib/oauth/credential-service' import { StorageService } from '@/lib/uploads' +import { buildStorageKeySegment } from '@/lib/uploads/core/storage-key' import { deleteFile } from '@/lib/uploads/core/storage-service' import { deleteFileMetadata } from '@/lib/uploads/server/metadata' import { extractStorageKey } from '@/lib/uploads/utils/file-utils' @@ -1472,7 +1473,7 @@ async function addDocument( const documentId = generateId() const contentBuffer = Buffer.from(extDoc.content, 'utf-8') const safeTitle = sanitizeStorageTitle(extDoc.title) - const customKey = `kb/${Date.now()}-${documentId}-${safeTitle}.txt` + const customKey = `kb/${buildStorageKeySegment(`${Date.now()}-${documentId}-`, `${safeTitle}.txt`)}` const fileInfo = await StorageService.uploadFile({ file: contentBuffer, @@ -1561,7 +1562,7 @@ async function updateDocument( const contentBuffer = Buffer.from(extDoc.content, 'utf-8') const safeTitle = sanitizeStorageTitle(extDoc.title) - const customKey = `kb/${Date.now()}-${existingDocId}-${safeTitle}.txt` + const customKey = `kb/${buildStorageKeySegment(`${Date.now()}-${existingDocId}-`, `${safeTitle}.txt`)}` const fileInfo = await StorageService.uploadFile({ file: contentBuffer, diff --git a/apps/sim/lib/knowledge/documents/document-processor.ts b/apps/sim/lib/knowledge/documents/document-processor.ts index 7e93edeb808..5ec1a39bda7 100644 --- a/apps/sim/lib/knowledge/documents/document-processor.ts +++ b/apps/sim/lib/knowledge/documents/document-processor.ts @@ -25,6 +25,7 @@ import { getKnowledgeOpaqueModelInputRegistry, } from '@/lib/knowledge/model-input-provenance' import { StorageService } from '@/lib/uploads' +import { buildStorageKeySegment } from '@/lib/uploads/core/storage-key' import { isInternalFileUrl } from '@/lib/uploads/utils/file-utils' import { downloadFileFromUrl } from '@/lib/uploads/utils/file-utils.server' import { MAX_FILE_SIZE } from '@/lib/uploads/utils/validation' @@ -362,8 +363,7 @@ async function handleFileForOCR( const timestamp = Date.now() const uniqueId = randomBytes(8).toString('hex') - const safeFileName = filename.replace(/[^a-zA-Z0-9.-]/g, '_') - const customKey = `kb/${timestamp}-${uniqueId}-${safeFileName}` + const customKey = `kb/${buildStorageKeySegment(`${timestamp}-${uniqueId}-`, filename)}` const cloudResult = await StorageService.uploadFile({ file: buffer, @@ -659,8 +659,10 @@ async function processChunk( try { const timestamp = Date.now() const uniqueId = randomBytes(8).toString('hex') - const safeFileName = filename.replace(/[^a-zA-Z0-9.-]/g, '_') - const chunkKey = `kb/${timestamp}-${uniqueId}-chunk${chunkIndex + 1}-${safeFileName}` + const chunkKey = `kb/${buildStorageKeySegment( + `${timestamp}-${uniqueId}-chunk${chunkIndex + 1}-`, + filename + )}` // No metadata: these chunks are ephemeral OCR artifacts (deleted in the // finally below) that are fetched via a direct presigned URL, never through diff --git a/apps/sim/lib/knowledge/documents/processing-claim.test.ts b/apps/sim/lib/knowledge/documents/processing-claim.test.ts index 6d898f60c40..34d4df22149 100644 --- a/apps/sim/lib/knowledge/documents/processing-claim.test.ts +++ b/apps/sim/lib/knowledge/documents/processing-claim.test.ts @@ -2,10 +2,11 @@ * @vitest-environment node */ -import { dbChainMockFns, resetDbChainMock } from '@sim/testing' +import { dbChainMockFns, hasMockCondition, resetDbChainMock } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' import { failStaleDocumentProcessingClaim, + failUndispatchedDocumentProcessing, KNOWLEDGE_DOCUMENT_PROCESSING_STALE_THRESHOLD_MS, reclaimStaleDocumentProcessingClaim, } from '@/lib/knowledge/documents/processing-claim' @@ -127,3 +128,57 @@ describe('failStaleDocumentProcessingClaim', () => { expect(result.success).toBe(false) }) }) + +describe('failUndispatchedDocumentProcessing', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + }) + + it('fails the exact pending document', async () => { + dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'document-1' }]) + + const failed = await failUndispatchedDocumentProcessing({ + documentId: 'document-1', + knowledgeBaseId: 'knowledge-base-1', + error: 'Failed to start processing', + now: NOW, + }) + + expect(failed).toBe(true) + expect(dbChainMockFns.set).toHaveBeenCalledWith({ + processingStatus: 'failed', + processingError: 'Failed to start processing', + processingCompletedAt: NOW, + }) + }) + + /** + * The dispatch may have been accepted and only its acknowledgement lost, so a + * document a worker already claimed — or one already deleted — must survive + * this write untouched. + */ + it('scopes the write to a pending, undeleted document', async () => { + dbChainMockFns.returning.mockResolvedValueOnce([]) + + const failed = await failUndispatchedDocumentProcessing({ + documentId: 'document-1', + knowledgeBaseId: 'knowledge-base-1', + error: 'Failed to start processing', + now: NOW, + }) + + expect(failed).toBe(false) + + const where = dbChainMockFns.where.mock.calls[0]?.[0] + expect( + hasMockCondition( + where, + (node) => node.type === 'eq' && node.left === 'processingStatus' && node.right === 'pending' + ) + ).toBe(true) + expect( + hasMockCondition(where, (node) => node.type === 'isNull' && node.column === 'deletedAt') + ).toBe(true) + }) +}) diff --git a/apps/sim/lib/knowledge/documents/processing-claim.ts b/apps/sim/lib/knowledge/documents/processing-claim.ts index bfb4a2208c4..3a720bb9328 100644 --- a/apps/sim/lib/knowledge/documents/processing-claim.ts +++ b/apps/sim/lib/knowledge/documents/processing-claim.ts @@ -94,3 +94,51 @@ export async function failStaleDocumentProcessingClaim({ return { success: Boolean(failed), processingDuration } } + +interface FailUndispatchedDocumentProcessingParams { + documentId: string + knowledgeBaseId: string + error: string + now?: Date +} + +/** + * Marks a document whose indexing dispatch never got off the ground as `failed`. + * + * A document registered by a completed upload sits at `pending` until a worker + * claims it. Nothing sweeps `pending`, and `retryProcessing` only accepts a + * `failed` document, so a document left there after a failed dispatch is + * invisible and unrecoverable. Recording the failure puts it on the same path + * as any other processing failure: visible in the document list with its error, + * and re-queueable. + * + * Guarded on `pending` so it cannot overwrite a document a worker has already + * claimed — the dispatch may have been accepted and only its acknowledgement + * lost. A worker that starts late still moves the row to `processing` + * unconditionally, so this write never strands a job that does run. + */ +export async function failUndispatchedDocumentProcessing({ + documentId, + knowledgeBaseId, + error, + now = new Date(), +}: FailUndispatchedDocumentProcessingParams): Promise { + const [failed] = await db + .update(document) + .set({ + processingStatus: 'failed', + processingError: error, + processingCompletedAt: now, + }) + .where( + and( + eq(document.id, documentId), + eq(document.knowledgeBaseId, knowledgeBaseId), + eq(document.processingStatus, 'pending'), + isNull(document.deletedAt) + ) + ) + .returning({ id: document.id }) + + return Boolean(failed) +} diff --git a/apps/sim/lib/knowledge/documents/service.ts b/apps/sim/lib/knowledge/documents/service.ts index da51bd325df..105935e945c 100644 --- a/apps/sim/lib/knowledge/documents/service.ts +++ b/apps/sim/lib/knowledge/documents/service.ts @@ -26,6 +26,7 @@ import { type SQL, sql, } from 'drizzle-orm' +import { searchFilter } from '@/lib/api/list-query' import { checkActorUsageLimits } from '@/lib/billing/calculations/usage-monitor' import { assertBillingAttributionSnapshot, @@ -122,6 +123,13 @@ const logger = createLogger('DocumentService') * Thrown when a knowledge-base document's `fileUrl` references an internal * knowledge-base storage object not owned by the target knowledge base's workspace. * Routes map this to a 403. + * + * Deliberately carries no `details.code`. It belongs to the cross-tenant class + * the closed set in `lib/core/application/forbidden.ts` excludes: it fires + * identically for a key bound to another tenant and for a key bound to nothing + * at all, so the single fixed message is the whole of what a caller may learn, + * and a machine-readable name would only invite a client to read resource + * existence into it. */ export class KnowledgeBaseFileOwnershipError extends OrchestrationError { constructor(public readonly storageKey: string) { @@ -1676,7 +1684,7 @@ export async function getDocuments( } if (search) { - whereConditions.push(sql`LOWER(${document.filename}) LIKE LOWER(${`%${search}%`})`) + whereConditions.push(searchFilter(document.filename, search)) } if (tagFilters && tagFilters.length > 0) { diff --git a/apps/sim/lib/knowledge/reranker-models.ts b/apps/sim/lib/knowledge/reranker-models.ts index 352efc4deb3..203329bd5a4 100644 --- a/apps/sim/lib/knowledge/reranker-models.ts +++ b/apps/sim/lib/knowledge/reranker-models.ts @@ -16,3 +16,25 @@ export const DEFAULT_RERANKER_MODEL: RerankerModelId = 'rerank-v4.0-fast' export function isSupportedRerankerModel(model: string): model is RerankerModelId { return rerankerModelSchema.safeParse(model).success } + +/** + * What the reranker actually did on a search, reported on every response. + * + * Reranking is best-effort by design: a provider outage, a timeout, or a + * deployment with no Cohere credential falls back to vector ordering so the + * search still answers. Without this field that fallback is invisible — the + * caller gets a 200, results in plain vector order, and no `rerankerScore` on + * any of them, which is indistinguishable from a reranker that ran and happened + * to agree with the vector order. Reporting the outcome is what makes a + * degradation detectable instead of a silent lie. + * + * - `not_requested` — `rerankerEnabled` was absent or false. + * - `skipped` — requested, but there was nothing to rank: a tag-only search has + * no query text to rank against, and a search that matched nothing has no + * candidates. + * - `unavailable` — requested and attempted, but the reranker could not + * complete. Results are in vector order and carry no `rerankerScore`. + * - `applied` — the reranker ordered the results, which carry `rerankerScore`. + */ +export const rerankerStatusSchema = z.enum(['not_requested', 'skipped', 'unavailable', 'applied']) +export type RerankerStatus = z.output diff --git a/apps/sim/lib/logs/api/route-policies.ts b/apps/sim/lib/logs/api/route-policies.ts index aa1e8da8cf1..1f97c566656 100644 --- a/apps/sim/lib/logs/api/route-policies.ts +++ b/apps/sim/lib/logs/api/route-policies.ts @@ -3,6 +3,27 @@ import { v2OrchestrationErrorPolicy, } from '@/lib/api/server/routes' +/** + * `GET /logs` and `GET /billing/logs` both take a caller-named `workspaceId` and + * answer differently when the caller cannot reach it: this list projects the + * refusal as 403, while the billing family conceals it as 404. The split is + * deliberate rather than an oversight, and worth stating because it is visible + * to anyone probing both. + * + * 403 is the v2 default for a workspace-scoped read — every sibling list that + * takes a `workspaceId` answers this way. It costs an existence bit on a random + * workspace UUID, which is a weak oracle: a workspace API key cannot name a + * workspace other than its own at all, so only a personal key can ask the + * question, and it learns nothing beyond "this id exists". In exchange, a caller + * that genuinely lost access to a workspace it already knows is told so, instead + * of being sent to hunt for a resource sitting right there. + * + * The billing family declines that trade because what it reports is a payer + * rather than workspace content; its own policy states why. Moving this list to + * 404 to match would leave it disagreeing with `GET /workflows`, `GET /tables`, + * and every other read over the same workspace — trading one visible + * inconsistency for a larger one. + */ export const v2LogErrorPolicies = { default: v2OrchestrationErrorPolicy, concealDetailAuthorization: createV2ResourceConcealmentPolicy({ diff --git a/apps/sim/lib/logs/application/get-public-log.ts b/apps/sim/lib/logs/application/get-public-log.ts index 0accc6b76e0..c16fa10dc3b 100644 --- a/apps/sim/lib/logs/application/get-public-log.ts +++ b/apps/sim/lib/logs/application/get-public-log.ts @@ -1,5 +1,6 @@ import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' import { OrchestrationError } from '@/lib/core/orchestration/types' +import { ROOT_FOLDER_PATH } from '@/lib/folders/paths' import { loadActiveFolderPathIndex } from '@/lib/folders/queries' import { logOperations } from '@/lib/logs/application/operations' import { materializeExecutionDataForDisplay } from '@/lib/logs/execution/trace-store' @@ -26,10 +27,36 @@ export interface GetPublicLogResult { log: Omit & { workflowState: Record | null } + /** + * The run's workflow folder as a canonical path — `/` at the workspace root, + * matching what the workflow resources report for the same workflow — or + * `null` when no path can be resolved for it. + * + * The two must stay distinct: collapsing both into `null` leaves a caller + * unable to tell a root-level workflow from one whose folder aged out, and + * `null` is not a value `folderPaths` takes back as a filter. + */ workflowFolderPath: string | null executionData: Record } +/** + * A run's folder path, distinguishing the root from an unresolvable folder. + * + * Deliberately not `workflowFolderPathForId`, which throws on a folder missing + * from the index. That is right for a workflow read, where an unresolvable + * folder means the caller's own tree is inconsistent; it is wrong for a + * diagnostic log read, where the run may long outlive the folder it ran in and a + * 500 would withhold the whole run over one unresolvable field. + */ +function publicLogFolderPath( + pathById: ReadonlyMap, + folderId: string | null +): string | null { + if (!folderId) return ROOT_FOLDER_PATH + return pathById.get(folderId) ?? null +} + export const getPublicLog = defineAuthorizedWorkspaceUseCase({ operation: logOperations.readDetail, resolveContext: async ({ input }: { input: GetPublicLogInput }): Promise => { @@ -63,9 +90,7 @@ export const getPublicLog = defineAuthorizedWorkspaceUseCase({ } return { log: { ...log, workflowState: sanitizeExecutionSnapshotState(log.workflowState) }, - workflowFolderPath: log.workflowFolderId - ? (folderIndex.pathById.get(log.workflowFolderId) ?? null) - : null, + workflowFolderPath: publicLogFolderPath(folderIndex.pathById, log.workflowFolderId), executionData, } }, diff --git a/apps/sim/lib/logs/application/list-public-logs.ts b/apps/sim/lib/logs/application/list-public-logs.ts index 12b0c062c6a..6b5ce4e9ea4 100644 --- a/apps/sim/lib/logs/application/list-public-logs.ts +++ b/apps/sim/lib/logs/application/list-public-logs.ts @@ -1,8 +1,7 @@ import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' import { OrchestrationError } from '@/lib/core/orchestration/types' import { MATERIALIZE_CONCURRENCY, mapWithConcurrency } from '@/lib/core/utils/concurrency' -import { ROOT_FOLDER_PATH } from '@/lib/folders/paths' -import { loadActiveFolderPathIndex } from '@/lib/folders/queries' +import { loadActiveFolderPathIndex, resolveFolderPathFilter } from '@/lib/folders/queries' import { logOperations } from '@/lib/logs/application/operations' import { materializeExecutionDataForDisplay } from '@/lib/logs/execution/trace-store' import type { LogFilters } from '@/lib/logs/public-filters' @@ -46,12 +45,17 @@ export const listPublicLogs = defineAuthorizedWorkspaceUseCase({ const folderIndex = input.folderPaths ? await loadActiveFolderPathIndex(context.workspaceId, 'workflow') : null - const resolvedFolderIds = input.folderPaths?.map((path) => - path === ROOT_FOLDER_PATH ? null : folderIndex?.idByPath.get(path) - ) - if (resolvedFolderIds?.some((folderId) => folderId === undefined)) { - throw new OrchestrationError('not_found', 'Folder not found') - } + /** + * A path naming no active folder contributes nothing to the scope instead of + * failing the read, so `folderPaths=/live,/deleted` still returns the `/live` + * runs and `folderPaths=/deleted` alone returns an empty page. See + * {@link resolveFolderPathFilter} for why a filter's miss is an empty set. + */ + const resolvedFolderIds = input.folderPaths?.flatMap((path) => { + if (!folderIndex) return [] + const filter = resolveFolderPathFilter(folderIndex, path) + return filter.kind === 'folder' ? [filter.folderId] : [] + }) const folderIds = resolvedFolderIds?.filter( (folderId): folderId is string => typeof folderId === 'string' diff --git a/apps/sim/lib/logs/application/public-log-use-cases.test.ts b/apps/sim/lib/logs/application/public-log-use-cases.test.ts index 7c3e2df47bd..f0584ff6169 100644 --- a/apps/sim/lib/logs/application/public-log-use-cases.test.ts +++ b/apps/sim/lib/logs/application/public-log-use-cases.test.ts @@ -33,6 +33,12 @@ vi.mock('@/lib/logs/public-queries', () => ({ vi.mock('@/lib/folders/queries', () => ({ loadActiveFolderPathIndex: mocks.loadFolders, + resolveFolderPathFilter: (index: { idByPath: Map }, path: string | undefined) => { + if (path === undefined) return { kind: 'unfiltered' } + if (path === '/') return { kind: 'folder', folderId: null } + const folderId = index.idByPath.get(path) + return folderId === undefined ? { kind: 'noMatch' } : { kind: 'folder', folderId } + }, })) /** @@ -163,6 +169,33 @@ describe('public log application use cases', () => { expect(mocks.recordAudit).not.toHaveBeenCalled() }) + /** + * `null` must not stand for both "at the workspace root" and "the path could + * not be resolved" — a caller can tell neither apart nor feed it back to + * `folderPaths`. The root is `/`, exactly as the workflow resources report it. + */ + it('reports the workspace root as a path a folderPaths filter would accept', async () => { + mocks.getLog.mockResolvedValueOnce({ ...log, workflowFolderId: null }) + + const result = await getPublicLog.execute({ + principal: workspacePrincipal, + input: { runId: 'run-1' }, + }) + + expect(result.workflowFolderPath).toBe('/') + }) + + it('keeps null for a folder whose path cannot be resolved', async () => { + mocks.getLog.mockResolvedValueOnce({ ...log, workflowFolderId: 'folder-archived' }) + + const result = await getPublicLog.execute({ + principal: workspacePrincipal, + input: { runId: 'run-1' }, + }) + + expect(result.workflowFolderPath).toBeNull() + }) + it('redacts credential values from the run snapshot', async () => { mocks.getLog.mockResolvedValueOnce({ ...log, @@ -290,23 +323,49 @@ describe('public log application use cases', () => { expect(result.items).toHaveLength(1) }) - it('returns a typed not-found for a missing folder', async () => { - await expect( - listPublicLogs.execute({ - principal: workspacePrincipal, - input: { - workspaceId: 'workspace-1', - filters: {}, - folderPaths: ['/missing'], - limit: 50, - includeFullDetails: false, - includeFinalOutput: false, - includeTraceSpans: false, - }, - }) - ).rejects.toMatchObject({ code: 'not_found' }) + /** + * Every `/logs` filter answers a value nothing matches with an empty page; a + * `404 Folder not found` here would also make the list a folder-existence + * oracle. The scope must still reach the query: dropping the unresolved path + * and sending no scope would return the whole workspace's logs. + */ + it('returns an empty page for a folder path that matches nothing', async () => { + const result = await listPublicLogs.execute({ + principal: workspacePrincipal, + input: { + workspaceId: 'workspace-1', + filters: {}, + folderPaths: ['/missing'], + limit: 50, + includeFullDetails: false, + includeFinalOutput: false, + includeTraceSpans: false, + }, + }) + + expect(mocks.listLogs).toHaveBeenCalledWith( + expect.objectContaining({ folderScope: { includesRoot: false, folderIds: [] } }) + ) + expect(result.nextCursor).toBeNull() + }) + + it('keeps the folders that do resolve when one path in the set does not', async () => { + await listPublicLogs.execute({ + principal: workspacePrincipal, + input: { + workspaceId: 'workspace-1', + filters: {}, + folderPaths: ['/agents', '/missing'], + limit: 50, + includeFullDetails: false, + includeFinalOutput: false, + includeTraceSpans: false, + }, + }) - expect(mocks.listLogs).not.toHaveBeenCalled() + expect(mocks.listLogs).toHaveBeenCalledWith( + expect.objectContaining({ folderScope: { includesRoot: false, folderIds: ['folder-1'] } }) + ) }) it('propagates run-store failures', async () => { diff --git a/apps/sim/lib/logs/public-filters.ts b/apps/sim/lib/logs/public-filters.ts index 639ba77ca12..38a77ed15a9 100644 --- a/apps/sim/lib/logs/public-filters.ts +++ b/apps/sim/lib/logs/public-filters.ts @@ -6,6 +6,17 @@ export interface LogFilters { workspaceId: string workflowIds?: string[] folderIds?: string[] + /** + * Trigger types to include. `all` is a sentinel — a list containing it + * disables this filter entirely rather than matching a trigger of that name. + * + * It is safe because `all` is modelled as a sentinel rather than a value: + * `TriggerType` in `stores/logs/filters/types.ts` adds it alongside + * `CoreTriggerType`, which never contains it, so no run is recorded under it. + * It does mean the filterable vocabulary is one name smaller than the + * column's, which is why the public `triggers` param documents the sentinel + * instead of leaving a caller to discover it. + */ triggers?: string[] level?: 'info' | 'error' startDate?: Date diff --git a/apps/sim/lib/logs/public-queries.test.ts b/apps/sim/lib/logs/public-queries.test.ts index 499bac77ac0..014ae6f97c1 100644 --- a/apps/sim/lib/logs/public-queries.test.ts +++ b/apps/sim/lib/logs/public-queries.test.ts @@ -1,8 +1,19 @@ /** * @vitest-environment node */ -import { describe, expect, it } from 'vitest' -import { decodePublicLogCursor, encodePublicLogCursor } from '@/lib/logs/public-queries' +import { + dbChainMockFns, + flattenMockConditions, + queueTableRows, + resetDbChainMock, + schemaMock, +} from '@sim/testing' +import { beforeEach, describe, expect, it } from 'vitest' +import { + decodePublicLogCursor, + encodePublicLogCursor, + listPublicWorkflowLogs, +} from '@/lib/logs/public-queries' describe('public log cursor', () => { const cursor = { @@ -28,3 +39,48 @@ describe('public log cursor', () => { expect(decodePublicLogCursor(legacyCursor, 'asc')).toEqual({ ...cursor, order: 'asc' }) }) }) + +/** + * The folder scope is resolved by the adapter, so this query sees only ids. A + * scope that resolved to nothing has to be expressed as a predicate that matches + * nothing: `or(undefined, undefined)` is `undefined`, which silently drops the + * filter and returns the workspace's whole log set. + */ +describe('public workflow log folder scope', () => { + const lastWhere = () => flattenMockConditions(dbChainMockFns.where.mock.calls.at(-1)?.[0]) + const isUnsatisfiable = (node: Record) => + (node.strings as readonly string[] | undefined)?.[0] === 'false' + + beforeEach(() => { + resetDbChainMock() + queueTableRows(schemaMock.workflowExecutionLogs, []) + }) + + async function list(folderScope?: { includesRoot: boolean; folderIds: string[] }) { + await listPublicWorkflowLogs({ + filters: { workspaceId: 'workspace-1' }, + limit: 50, + includeExecutionData: false, + folderScope, + }) + } + + it('matches no rows when the scope names neither the root nor a folder', async () => { + await list({ includesRoot: false, folderIds: [] }) + + expect(lastWhere().some(isUnsatisfiable)).toBe(true) + }) + + it('constrains to the resolved folders when the scope names some', async () => { + await list({ includesRoot: false, folderIds: ['folder-1'] }) + + expect(lastWhere().some(isUnsatisfiable)).toBe(false) + expect(lastWhere().some((node) => node.type === 'inArray')).toBe(true) + }) + + it('adds no folder predicate when the caller sent no folder filter', async () => { + await list() + + expect(lastWhere().some(isUnsatisfiable)).toBe(false) + }) +}) diff --git a/apps/sim/lib/logs/public-queries.ts b/apps/sim/lib/logs/public-queries.ts index 25bc964f92d..039a8afe3d2 100644 --- a/apps/sim/lib/logs/public-queries.ts +++ b/apps/sim/lib/logs/public-queries.ts @@ -7,7 +7,7 @@ import { workflowExecutionLogs, workflowExecutionSnapshots, } from '@sim/db/schema' -import { and, eq, inArray, isNull, or, sql } from 'drizzle-orm' +import { and, eq, inArray, isNull, or, type SQL, sql } from 'drizzle-orm' import { workflowExecutionOriginSql } from '@/lib/logs/execution-origin' import { buildLogFilters, getOrderBy, type LogFilters } from '@/lib/logs/public-filters' @@ -21,6 +21,16 @@ export function encodePublicLogCursor(cursor: PublicLogCursor): string { return Buffer.from(JSON.stringify(cursor)).toString('base64') } +/** + * Reads the keyset this list resumes from, or `null` for a token that names no + * position. + * + * `id` is checked for content rather than only for type: it is one half of the + * `(startedAt, id)` tuple the query compares against, so an empty one is a + * position no row can sit after, and accepting it would answer a truncated page + * as though it were a complete one. It is the same looseness the wrapping + * envelope had — see `readScopedCursor` — one layer down. + */ export function decodePublicLogCursor( cursor: string, expectedOrder: 'asc' | 'desc' @@ -31,6 +41,7 @@ export function decodePublicLogCursor( if ( typeof parsed.startedAt !== 'string' || typeof parsed.id !== 'string' || + parsed.id.length === 0 || (order !== 'asc' && order !== 'desc') || order !== expectedOrder ) { @@ -54,6 +65,27 @@ export interface ListPublicWorkflowLogsInput { } } +/** + * The root/non-root predicate for a resolved folder scope. + * + * A scope carrying neither the root nor any folder id is a `folderPaths` filter + * that matched no active folder, and it must match no rows — hence the explicit + * unsatisfiable predicate. Building it by `or`-ing two optional halves instead + * would hand the empty case to `or(undefined, undefined)`, which is `undefined` + * in Drizzle: the filter drops out of the surrounding `and(...)` and the query + * returns the workspace's entire log set, the exact opposite of what was asked. + */ +function folderScopeCondition(scope: { includesRoot: boolean; folderIds: string[] }): SQL { + const parts = [ + scope.includesRoot ? isNull(workflow.folderId) : undefined, + scope.folderIds.length > 0 ? inArray(workflow.folderId, scope.folderIds) : undefined, + ].filter((part): part is SQL => part !== undefined) + + if (parts.length === 0) return sql`false` + if (parts.length === 1) return parts[0] + return or(...parts) ?? sql`false` +} + /** * Reads the workflow-execution log page shared by the v1 and v2 public * adapters. Folder path resolution remains an adapter concern; this query takes @@ -62,14 +94,7 @@ export interface ListPublicWorkflowLogsInput { export async function listPublicWorkflowLogs(input: ListPublicWorkflowLogsInput) { const filters = input.folderScope ? { ...input.filters, folderIds: undefined } : input.filters const conditions = buildLogFilters(filters) - const folderCondition = input.folderScope - ? or( - input.folderScope.includesRoot ? isNull(workflow.folderId) : undefined, - input.folderScope.folderIds.length > 0 - ? inArray(workflow.folderId, input.folderScope.folderIds) - : undefined - ) - : undefined + const folderCondition = input.folderScope ? folderScopeCondition(input.folderScope) : undefined const rows = await db .select({ diff --git a/apps/sim/lib/mcp/application/use-cases.test.ts b/apps/sim/lib/mcp/application/use-cases.test.ts index 3c99924a008..73eb5404a19 100644 --- a/apps/sim/lib/mcp/application/use-cases.test.ts +++ b/apps/sim/lib/mcp/application/use-cases.test.ts @@ -162,6 +162,57 @@ describe('MCP server application use cases', () => { expect(mocks.effects).not.toHaveBeenCalled() }) + /** + * A server id is derived from the workspace and endpoint URL, so re-registering + * a URL that was soft-deleted reuses the same row. That is a create from the + * caller's side — the resource they asked for did not exist a moment ago — so it + * must succeed with the create's 201 rather than collide with its own tombstone. + * The conflict guard therefore keys on the id state's `deleted` flag, not on + * whether the writer reported an update. + */ + it('creates over a soft-deleted registration rather than colliding with its tombstone', async () => { + mocks.idState.mockResolvedValueOnce({ deleted: true }) + mocks.create.mockResolvedValueOnce({ + success: true, + serverId: server.id, + server, + updated: true, + }) + + const result = await createMcpServerUseCase.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { workspaceId: workspace.workspaceId, name: server.name, url: server.url }, + }) + + expect(result.server.id).toBe(server.id) + expect(mocks.create).toHaveBeenCalledWith( + expect.objectContaining({ existingServerBehavior: 'reject' }) + ) + expect(events).toEqual(['audit', 'effects']) + }) + + /** + * The pre-check reads the id state outside the write, so two concurrent creates + * of the same URL can both pass it. The unique index is what actually decides, + * and its `23505` must surface as the same conflict the pre-check reports — + * otherwise the loser of the race gets a 500 for a condition the API defines. + */ + it('reports the unique-index loser of a concurrent create as a conflict', async () => { + mocks.create.mockRejectedValueOnce( + Object.assign(new Error('duplicate key value violates unique constraint'), { code: '23505' }) + ) + + await expect( + createMcpServerUseCase.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { workspaceId: workspace.workspaceId, name: server.name, url: server.url }, + }) + ).rejects.toMatchObject({ code: 'conflict' }) + + expect(mocks.audit).not.toHaveBeenCalled() + expect(mocks.effects).not.toHaveBeenCalled() + }) + it('rejects workspace-key tool discovery before protected loading', async () => { await expect( discoverMcpToolsUseCase.execute({ diff --git a/apps/sim/lib/mcp/orchestration/server-lifecycle.test.ts b/apps/sim/lib/mcp/orchestration/server-lifecycle.test.ts index e8fd921b883..f89751d23db 100644 --- a/apps/sim/lib/mcp/orchestration/server-lifecycle.test.ts +++ b/apps/sim/lib/mcp/orchestration/server-lifecycle.test.ts @@ -250,6 +250,171 @@ describe('MCP server lifecycle orchestration', () => { expect(mockRevokeOauthTokens).toHaveBeenCalledWith('server-1', 'workspace-1') }) + it('registers a new server as disconnected rather than stamping a connection it never made', async () => { + mockGenerateMcpServerId.mockReturnValue('server-1') + dbChainMockFns.limit.mockResolvedValueOnce([]) + dbChainMockFns.limit.mockResolvedValueOnce([ + { + id: 'server-1', + workspaceId: 'workspace-1', + name: 'Example', + transport: 'streamable-http', + url: 'https://example.com/anything', + authType: 'headers', + }, + ]) + + const result = await performCreateMcpServer({ + workspaceId: 'workspace-1', + userId: 'user-1', + name: 'Example', + url: 'https://example.com/anything', + headers: { authorization: 'Bearer token' }, + }) + + expect(result.success).toBe(true) + expect(dbChainMockFns.values).toHaveBeenCalledWith( + expect.objectContaining({ connectionStatus: 'disconnected', lastConnected: null }) + ) + }) + + it('leaves a re-registered server disconnected until discovery re-runs', async () => { + mockGenerateMcpServerId.mockReturnValue('server-1') + dbChainMockFns.limit.mockResolvedValueOnce([ + { + id: 'server-1', + deletedAt: null, + url: 'https://example.com/mcp', + transport: 'streamable-http', + headers: { authorization: 'Bearer original' }, + authType: 'headers', + oauthClientId: null, + oauthClientSecret: null, + }, + ]) + dbChainMockFns.limit.mockResolvedValueOnce([ + { + id: 'server-1', + workspaceId: 'workspace-1', + name: 'Example', + transport: 'streamable-http', + url: 'https://example.com/mcp', + authType: 'headers', + }, + ]) + + const result = await performCreateMcpServer({ + workspaceId: 'workspace-1', + userId: 'user-1', + name: 'Example', + url: 'https://example.com/mcp', + headers: { authorization: 'Bearer rotated' }, + }) + + expect(result.success).toBe(true) + expect(dbChainMockFns.set).toHaveBeenCalledWith( + expect.objectContaining({ + connectionStatus: 'disconnected', + lastConnected: null, + lastError: null, + }) + ) + }) + + /** + * `isServerEligibleForDiscovery` skips an OAuth row that is not `connected`, + * and only a real discovery can set `connected`. Clearing the status for an + * edit that changes nothing a connection is made from therefore removes every + * tool the server publishes, with no path back. + */ + it('keeps an OAuth server connected through a re-registration that only renames it', async () => { + mockGenerateMcpServerId.mockReturnValue('server-1') + dbChainMockFns.limit.mockResolvedValueOnce([ + { + id: 'server-1', + deletedAt: null, + url: 'https://example.com/mcp', + transport: 'streamable-http', + headers: {}, + authType: 'oauth', + oauthClientId: 'client-1', + oauthClientSecret: 'secret-1', + }, + ]) + dbChainMockFns.limit.mockResolvedValueOnce([ + { + id: 'server-1', + workspaceId: 'workspace-1', + name: 'Renamed', + transport: 'streamable-http', + url: 'https://example.com/mcp', + authType: 'oauth', + }, + ]) + + const result = await performCreateMcpServer({ + workspaceId: 'workspace-1', + userId: 'user-1', + name: 'Renamed', + description: 'Now with a description', + url: 'https://example.com/mcp', + }) + + expect(result.success).toBe(true) + expect(dbChainMockFns.set).toHaveBeenCalledWith( + expect.objectContaining({ name: 'Renamed', description: 'Now with a description' }) + ) + expect(dbChainMockFns.set).not.toHaveBeenCalledWith( + expect.objectContaining({ connectionStatus: 'disconnected' }) + ) + expect(result.updatedFields).not.toContain('connectionStatus') + // A rename invalidates nothing, so the stored OAuth grant must survive it too. + expect(mockRevokeOauthTokens).not.toHaveBeenCalled() + }) + + it('resets a re-registered server whose transport changes', async () => { + mockGenerateMcpServerId.mockReturnValue('server-1') + dbChainMockFns.limit.mockResolvedValueOnce([ + { + id: 'server-1', + deletedAt: null, + url: 'https://example.com/mcp', + transport: 'streamable-http', + headers: {}, + authType: 'oauth', + oauthClientId: 'client-1', + oauthClientSecret: 'secret-1', + }, + ]) + dbChainMockFns.limit.mockResolvedValueOnce([ + { + id: 'server-1', + workspaceId: 'workspace-1', + name: 'Example', + transport: 'sse', + url: 'https://example.com/mcp', + authType: 'oauth', + }, + ]) + + const result = await performCreateMcpServer({ + workspaceId: 'workspace-1', + userId: 'user-1', + name: 'Example', + url: 'https://example.com/mcp', + transport: 'sse', + }) + + expect(result.success).toBe(true) + expect(dbChainMockFns.set).toHaveBeenCalledWith( + expect.objectContaining({ + connectionStatus: 'disconnected', + lastConnected: null, + lastError: null, + }) + ) + }) + it('audits a re-registration that rewrites a live server as an update', async () => { mockGenerateMcpServerId.mockReturnValue('server-1') dbChainMockFns.limit.mockResolvedValueOnce([ diff --git a/apps/sim/lib/mcp/orchestration/server-lifecycle.ts b/apps/sim/lib/mcp/orchestration/server-lifecycle.ts index 32df6148dd9..ca9ecde3730 100644 --- a/apps/sim/lib/mcp/orchestration/server-lifecycle.ts +++ b/apps/sim/lib/mcp/orchestration/server-lifecycle.ts @@ -4,6 +4,7 @@ import { mcpServerOauth } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import { and, eq, isNull } from 'drizzle-orm' +import { isEqual } from 'es-toolkit' import type { NextRequest } from 'next/server' import { encryptSecret } from '@/lib/core/security/encryption' import { sanitizeUrlForLog } from '@/lib/core/utils/logging' @@ -157,6 +158,8 @@ export async function createMcpServer( id: mcpServers.id, deletedAt: mcpServers.deletedAt, url: mcpServers.url, + transport: mcpServers.transport, + headers: mcpServers.headers, authType: mcpServers.authType, oauthClientId: mcpServers.oauthClientId, oauthClientSecret: mcpServers.oauthClientSecret, @@ -204,10 +207,22 @@ export async function createMcpServer( currentEncryptedClientSecret: existingServer.oauthClientSecret, }) const isRevival = existingServer.deletedAt !== null - const authTypeChanged = existingServer.authType !== resolvedAuthType // Turning OAuth off orphans its tokens; revoke and delete them, mirroring the update path. const oauthDisabled = existingServer.authType === 'oauth' && resolvedAuthType !== 'oauth' const shouldClearOauth = urlChanged || credsChanged || isRevival || oauthDisabled + /** + * Everything a connection is established from. `name`, `description`, + * `timeout`, `retries`, and `enabled` are deliberately absent: none of + * them changes what the server answers to a discovery, so rewriting one + * must not invalidate a status a real discovery earned. + */ + const connectionInputsChanged = + isRevival || + urlChanged || + credsChanged || + existingServer.transport !== transport || + (existingServer.authType ?? 'headers') !== resolvedAuthType || + !isEqual(existingServer.headers ?? {}, params.headers || {}) if (shouldClearOauth) await revokeMcpOauthTokens(serverId, params.workspaceId) @@ -229,18 +244,25 @@ export async function createMcpServer( updatedAt: new Date(), deletedAt: null, } - if (authTypeChanged || (shouldClearOauth && resolvedAuthType === 'oauth')) { - // An auth-type flip, or an OAuth URL/creds change, invalidates any prior connection: - // reset to disconnected and clear the stale error so the UI never shows - // connected-with-error until re-discovery. Mirrors performUpdateMcpServer. + /** + * A re-registration must never stamp `connected` itself: the former + * `else` branch published a fresh `lastConnected` for any non-OAuth + * re-registration without contacting the endpoint, and left `lastError` + * alone, so `connected` could sit beside a stale error. + * `mcpService.updateServerStatus` is the only writer entitled to claim a + * connection, and it does so after a real discovery. + * + * Resetting is scoped to the inputs a connection is actually made from. + * A re-registration also rewrites `name` and `description`, and clearing + * the status for those strands an OAuth server: `isServerEligibleForDiscovery` + * skips an OAuth row that is not `connected`, so the only writer that can + * restore the status is gated on the status just cleared, and a rename + * silently removes every tool the server publishes. + */ + if (connectionInputsChanged) { updateValues.connectionStatus = 'disconnected' updateValues.lastConnected = null updateValues.lastError = null - } else if (resolvedAuthType !== 'oauth') { - // A non-OAuth (re-)registration with unchanged auth optimistically marks the server - // reachable; discovery corrects it if the endpoint is unhealthy. - updateValues.connectionStatus = 'connected' - updateValues.lastConnected = new Date() } if (params.oauthClientIdProvided) updateValues.oauthClientId = oauthClientId if (params.oauthClientSecretProvided) { @@ -291,8 +313,21 @@ export async function createMcpServer( timeout, retries, enabled, - connectionStatus: resolvedAuthType === 'oauth' ? 'disconnected' : 'connected', - lastConnected: resolvedAuthType === 'oauth' ? null : new Date(), + /** + * Registration stores a configuration; it does not open a connection. The + * only network touch on this path is `detectMcpAuthType`, an OAuth + * discovery probe whose failure is swallowed, so a URL serving static HTML + * — or nothing at all — reached this insert and was written as + * `connected` with `lastConnected` set to now. Both columns are contracted + * as the result of, and the time of, a real connection attempt, and + * `tool-validation.ts` gates tool availability on the first of them, so an + * unverified server read as healthy. The honest initial state is the + * column default; `mcpService.updateServerStatus` moves it once a + * discovery actually runs, which `isServerEligibleForDiscovery` allows for + * a non-OAuth server immediately. + */ + connectionStatus: 'disconnected', + lastConnected: null, createdAt: new Date(), updatedAt: new Date(), }) diff --git a/apps/sim/lib/mcp/service.ts b/apps/sim/lib/mcp/service.ts index 46276ca6d01..9c2475a9471 100644 --- a/apps/sim/lib/mcp/service.ts +++ b/apps/sim/lib/mcp/service.ts @@ -31,9 +31,9 @@ import { type McpCacheStorageAdapter, } from '@/lib/mcp/storage' import { - McpConnectionError, McpOauthAuthorizationRequiredError, type McpServerConfig, + McpServerCooldownError, type McpServerStatusConfig, type McpServerSummary, type McpTool, @@ -1060,10 +1060,7 @@ class McpService { if (refresh !== 'force' && (await this.isServerUnhealthy(workspaceId, serverId))) { logger.info(`[${requestId}] Skipping recently-failed server ${serverId} (negative-cache)`) - throw new McpConnectionError( - 'Server recently failed and is in cooldown — try again shortly.', - serverId - ) + throw new McpServerCooldownError(serverId) } for (let attempt = 0; attempt < maxRetries; attempt++) { diff --git a/apps/sim/lib/mcp/types.ts b/apps/sim/lib/mcp/types.ts index a6f7d1f9363..c6d4e584666 100644 --- a/apps/sim/lib/mcp/types.ts +++ b/apps/sim/lib/mcp/types.ts @@ -133,6 +133,24 @@ export class McpConnectionError extends McpError { } } +/** + * Thrown when discovery is refused because the server is inside the + * negative-cache cooldown that follows a recent failure. No connection was + * attempted, so the condition clears on its own. + * + * It is a distinct class rather than an `McpConnectionError` whose message + * happens to contain "cooldown" because `McpConnectionError` interpolates the + * server's display name into that message: a server a caller named after the + * word matched the substring test and borrowed this case's wording, telling them + * to wait out a cooldown that was never entered. + */ +export class McpServerCooldownError extends McpConnectionError { + constructor(serverName: string) { + super('Server recently failed and is in cooldown — try again shortly.', serverName) + this.name = 'McpServerCooldownError' + } +} + /** * Thrown when an OAuth-protected MCP server is reachable but the current * user has not yet authorized Sim. This is a benign "pending" state, not a diff --git a/apps/sim/lib/mcp/utils.test.ts b/apps/sim/lib/mcp/utils.test.ts index 30990f62d4a..3fa3d61806c 100644 --- a/apps/sim/lib/mcp/utils.test.ts +++ b/apps/sim/lib/mcp/utils.test.ts @@ -1,7 +1,11 @@ import { UnauthorizedError } from '@modelcontextprotocol/sdk/client/auth.js' import { describe, expect, it } from 'vitest' import { DEFAULT_EXECUTION_TIMEOUT_MS } from '@/lib/core/execution-limits' -import { McpConnectionError, McpOauthAuthorizationRequiredError } from '@/lib/mcp/types' +import { + McpConnectionError, + McpOauthAuthorizationRequiredError, + McpServerCooldownError, +} from '@/lib/mcp/types' import { categorizeError, createMcpToolId, @@ -319,12 +323,24 @@ describe('categorizeError', () => { expect(result.status).toBe(401) }) - it.concurrent('returns 503 for McpConnectionError with cooldown message', () => { - const error = new McpConnectionError('Server in cooldown — try again shortly.', 'mcp-a') + it.concurrent('returns 503 for the typed discovery-cooldown refusal', () => { + const error = new McpServerCooldownError('mcp-a') const result = categorizeError(error) expect(result.status).toBe(503) }) + /** + * `McpConnectionError` interpolates the server's display name into its + * message, so selecting the cooldown branch by searching that message reports + * a server named after the word as a transient 503 when its connection has + * genuinely failed. + */ + it.concurrent('does not read a cooldown out of a server display name', () => { + const error = new McpConnectionError('connect ECONNREFUSED', 'Cooldown Docs') + const result = categorizeError(error) + expect(result.status).toBe(502) + }) + it.concurrent('returns 502 for other McpConnectionError', () => { const error = new McpConnectionError('connect ECONNREFUSED', 'mcp-a') const result = categorizeError(error) diff --git a/apps/sim/lib/mcp/utils.ts b/apps/sim/lib/mcp/utils.ts index e1fd0eb801b..5f29e46acf8 100644 --- a/apps/sim/lib/mcp/utils.ts +++ b/apps/sim/lib/mcp/utils.ts @@ -5,6 +5,7 @@ import { type McpApiResponse, McpConnectionError, McpOauthAuthorizationRequiredError, + McpServerCooldownError, } from '@/lib/mcp/types' import { isMcpTool, MCP } from '@/executor/constants' @@ -167,10 +168,10 @@ export function categorizeError(error: unknown): { message: string; status: numb if (error instanceof McpOauthAuthorizationRequiredError || error instanceof UnauthorizedError) { return { message: 'Authentication required', status: 401 } } + if (error instanceof McpServerCooldownError) { + return { message: 'Server temporarily unavailable', status: 503 } + } if (error instanceof McpConnectionError) { - if (error.message.toLowerCase().includes('cooldown')) { - return { message: 'Server temporarily unavailable', status: 503 } - } return { message: 'Connection failed', status: 502 } } diff --git a/apps/sim/lib/mothership/inbox/executor.ts b/apps/sim/lib/mothership/inbox/executor.ts index dd114b8db85..02eb799d218 100644 --- a/apps/sim/lib/mothership/inbox/executor.ts +++ b/apps/sim/lib/mothership/inbox/executor.ts @@ -24,6 +24,7 @@ import * as agentmail from '@/lib/mothership/inbox/agentmail-client' import { formatEmailAsMessage } from '@/lib/mothership/inbox/format' import { sendInboxResponse } from '@/lib/mothership/inbox/response' import type { AgentMailAttachment } from '@/lib/mothership/inbox/types' +import { buildStorageKeySegment } from '@/lib/uploads/core/storage-key' import { uploadFile } from '@/lib/uploads/core/storage-service' import { createFileContent, type MessageContent } from '@/lib/uploads/utils/file-utils' import { checkWorkspaceAccess, getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' @@ -482,7 +483,10 @@ async function downloadAttachmentContents( const fileContent = createFileContent(buffer, attachment.content_type) if (!fileContent) return null - const storageKey = `copilot/${Date.now()}-${attachment.attachment_id}-${attachment.filename}` + const storageKey = `copilot/${buildStorageKeySegment( + `${Date.now()}-${attachment.attachment_id}-`, + attachment.filename + )}` const uploaded = await uploadFile({ file: buffer, fileName: attachment.filename, diff --git a/apps/sim/lib/secrets/application/use-cases.ts b/apps/sim/lib/secrets/application/use-cases.ts index 2fa1f1a5a07..5d8856629e7 100644 --- a/apps/sim/lib/secrets/application/use-cases.ts +++ b/apps/sim/lib/secrets/application/use-cases.ts @@ -2,6 +2,7 @@ import { AuditAction, AuditResourceType } from '@sim/audit' import type { Principal } from '@sim/auth/principal' import type { CursorKey, ListSortOrder } from '@/lib/api/list-query' import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' +import { ForbiddenOperationError } from '@/lib/core/application/forbidden' import { OrchestrationError } from '@/lib/core/orchestration/types' import { getWorkspaceEnvKeyAdminAccess } from '@/lib/credentials/environment' import { @@ -99,15 +100,18 @@ async function requireWorkspaceSecretMutationAccess(params: { if (keyAccess.knownKeys.has(params.name)) { if (!workspaceAccess.canAdmin && !keyAccess.adminKeys.has(params.name)) { - throw new OrchestrationError( - 'forbidden', + throw new ForbiddenOperationError( + 'SECRET_ADMIN_ACCESS_REQUIRED', 'Credential admin permission required for this secret' ) } return } if (!workspaceAccess.canWrite) { - throw new OrchestrationError('forbidden', 'Write permission required to set this secret') + throw new ForbiddenOperationError( + 'INSUFFICIENT_WORKSPACE_ROLE', + 'Write permission required to set this secret' + ) } } diff --git a/apps/sim/lib/skills/application/operations.test.ts b/apps/sim/lib/skills/application/operations.test.ts index 7c03a3c3ed8..1d85523dcc5 100644 --- a/apps/sim/lib/skills/application/operations.test.ts +++ b/apps/sim/lib/skills/application/operations.test.ts @@ -15,13 +15,40 @@ import { skillOperations } from '@/lib/skills/application/operations' * These tests exist so the next reader finds the reason instead of "fixing" it. */ describe('skill operation registry', () => { - it('gates creation on workspace role, which a workspace key can express', () => { + it('gates creation on a human subject, like every other write', () => { expect(skillOperations.create).toMatchObject({ minimumRole: 'write', - workspaceApiKey: 'allow', + workspaceApiKey: 'deny', + principalKinds: ['session', 'personal_api_key', 'delegated'], }) }) + /** + * The invariant the create/delete split violated. A principal kind that can + * create a skill must be able to remove it, or its only possible interaction + * with the resource is to accumulate rows it can never reach again. + * + * Symmetry alone is not the property. A lifecycle that uniformly ALLOWED a + * workspace key is just as symmetric and reopens the hole, because the edit + * paths cannot resolve an acting subject for one. So both halves are pinned: + * the writes agree on a policy, and the policy they agree on is the one every + * edit path can honour. The principal kinds are compared directly rather than + * left to the test's own name. + */ + it('admits the same principal kinds to every write in the lifecycle', () => { + const writes = [ + skillOperations.create, + skillOperations.update, + skillOperations.upsert, + skillOperations.delete, + ] + + expect(new Set(writes.map((operation) => operation.workspaceApiKey))).toEqual(new Set(['deny'])) + for (const operation of writes) { + expect(operation.principalKinds).toEqual(skillOperations.delete.principalKinds) + } + }) + it('gates every edit path on a human subject rather than workspace role', () => { for (const operation of [ skillOperations.update, diff --git a/apps/sim/lib/skills/application/operations.ts b/apps/sim/lib/skills/application/operations.ts index 5540ce2f2e8..aa13c8f9a93 100644 --- a/apps/sim/lib/skills/application/operations.ts +++ b/apps/sim/lib/skills/application/operations.ts @@ -10,19 +10,24 @@ const HUMAN_PRINCIPAL_POLICY = { } as const /** - * Skill operations split on workspace API keys, and the split is structural - * rather than an oversight. + * Every skill write is human-subject-only. Reads are not. * - * `create` is gated on workspace `write`, which a workspace key can express, so - * it allows one. `update`, `upsert`, and `delete` are not gated on workspace - * role at all — their floor is `read` because the real authority is the - * per-skill editor row that `resolveEditableSkill` checks against the acting - * user. A workspace key has no user subject to check, so those operations deny - * it: `requirePrincipalSubjectUserId` would otherwise throw an unclassified - * error and surface as a caller-reachable `500` instead of a `403`. + * `update`, `upsert`, and `delete` are not gated on workspace role at all — + * their floor is `read` because the real authority is the per-skill editor row + * that `resolveEditableSkill` checks against the acting user. A workspace key + * has no user subject to check, so those operations deny it: + * `requirePrincipalSubjectUserId` would otherwise throw an unclassified error + * and surface as a caller-reachable `500` instead of a `403`. Widening them is + * not a policy flip — it needs an authorization model for a keyless principal + * against per-skill editors, which does not exist. * - * Widening them therefore is not a policy flip — it needs an authorization model - * for a keyless principal against per-skill editors, which does not exist. + * `create` denies a workspace key too, even though workspace `write` is a role + * a key can express. A key that created a skill could never update or delete + * it, so it could only accumulate rows beyond its own reach — and the row would + * not be attributable to it either: `create` attributes through + * `resolvePrincipalAttribution`, which maps a workspace key to the workspace's + * billing owner, minting a `skill_member` editor grant for a human who did not + * act. Denying it keeps the whole lifecycle under one authorization model. * Pinned in `operations.test.ts`. */ export const skillOperations = { @@ -47,8 +52,8 @@ export const skillOperations = { create: defineWorkspaceOperation({ id: 'skills.create', minimumRole: 'write', - workspaceApiKey: 'allow', - ...ALL_PRINCIPAL_POLICY, + workspaceApiKey: 'deny', + ...HUMAN_PRINCIPAL_POLICY, }), update: defineWorkspaceOperation({ id: 'skills.update', diff --git a/apps/sim/lib/skills/application/use-cases.ts b/apps/sim/lib/skills/application/use-cases.ts index 8f54b55dd69..251a2dc95b3 100644 --- a/apps/sim/lib/skills/application/use-cases.ts +++ b/apps/sim/lib/skills/application/use-cases.ts @@ -61,11 +61,6 @@ export interface ListSkillsInput { limit: number /** Position in the merged built-in + workspace list, read from the cursor. */ offset: number - /** - * The query state that position is valid within, echoed back so the presenter - * can stamp the next cursor with it. - */ - cursorScope: string } /** @@ -92,7 +87,6 @@ export const listSkillsUseCase = defineAuthorizedWorkspaceUseCase({ hasMore: page.hasMore, offset: page.offset, limit: page.limit, - cursorScope: input.cursorScope, } }, }) diff --git a/apps/sim/lib/table/__tests__/column-keys.test.ts b/apps/sim/lib/table/__tests__/column-keys.test.ts index 493858e68f7..7ee8f621486 100644 --- a/apps/sim/lib/table/__tests__/column-keys.test.ts +++ b/apps/sim/lib/table/__tests__/column-keys.test.ts @@ -19,6 +19,7 @@ import { generateColumnId, getColumnId, remapGroupColumnRefs, + remapViewConfigColumnRefs, rowDataNameToId, sortNamesToIds, withGeneratedColumnIds, @@ -161,3 +162,55 @@ describe('remapGroupColumnRefs', () => { expect(out.dependencies!.columns).toEqual(['col_existing']) }) }) + +describe('remapViewConfigColumnRefs', () => { + const idByName = new Map([ + ['Name', 'col_a'], + ['Email', 'col_b'], + ]) + const config = { + columnOrder: ['Email', 'col_a'], + pinnedColumns: ['Email'], + hiddenColumns: ['Name'], + columnWidths: { Name: 180, col_b: 240 }, + sort: [{ field: 'Name', direction: 'asc' as const }], + filter: { all: [{ field: 'Email', op: 'eq' as const, value: 'x' }] }, + } + + it('rewrites every column reference and leaves an already-mapped ref alone', () => { + expect(remapViewConfigColumnRefs(config, idByName)).toEqual({ + columnOrder: ['col_b', 'col_a'], + pinnedColumns: ['col_b'], + hiddenColumns: ['col_a'], + columnWidths: { col_a: 180, col_b: 240 }, + sort: [{ field: 'col_a', direction: 'asc' }], + filter: { all: [{ field: 'col_b', op: 'eq', value: 'x' }] }, + }) + }) + + it('inverts cleanly, which is what makes the write/read pair symmetric', () => { + const nameById = new Map([...idByName].map(([name, id]) => [id, name])) + const stored = remapViewConfigColumnRefs(config, idByName) + expect(remapViewConfigColumnRefs(stored, nameById)).toEqual({ + columnOrder: ['Email', 'Name'], + pinnedColumns: ['Email'], + hiddenColumns: ['Name'], + columnWidths: { Name: 180, Email: 240 }, + sort: [{ field: 'Name', direction: 'asc' }], + filter: { all: [{ field: 'Email', op: 'eq', value: 'x' }] }, + }) + }) + + it('leaves a system row column and a since-deleted ref untouched', () => { + const out = remapViewConfigColumnRefs( + { sort: [{ field: 'createdAt', direction: 'desc' }], hiddenColumns: ['col_gone'] }, + idByName + ) + expect(out.sort).toEqual([{ field: 'createdAt', direction: 'desc' }]) + expect(out.hiddenColumns).toEqual(['col_gone']) + }) + + it('leaves absent keys absent rather than materializing empty ones', () => { + expect(remapViewConfigColumnRefs({}, idByName)).toEqual({}) + }) +}) diff --git a/apps/sim/lib/table/__tests__/column-type-registry.test.ts b/apps/sim/lib/table/__tests__/column-type-registry.test.ts index 73c5ffc424c..91ec369e31e 100644 --- a/apps/sim/lib/table/__tests__/column-type-registry.test.ts +++ b/apps/sim/lib/table/__tests__/column-type-registry.test.ts @@ -91,12 +91,11 @@ describe('conversion write-back', () => { // transformed value back — filters and sorts apply `jsonbCast` to whatever is // stored, so a value left in its old shape breaks every query on the column. it.each` - type | stored | expected - ${'date'} | ${1700000000000} | ${'2023-11-14T22:13:20.000Z'} - ${'date'} | ${'2024-01-01'} | ${'2024-01-01'} - ${'currency'} | ${'$1,234.56'} | ${1234.56} - ${'currency'} | ${'1.234,56'} | ${1234.56} - ${'number'} | ${'1999'} | ${1999} + type | stored | expected + ${'date'} | ${'2024-01-01'} | ${'2024-01-01'} + ${'currency'} | ${'$1,234.56'} | ${1234.56} + ${'currency'} | ${'1.234,56'} | ${1234.56} + ${'number'} | ${'1999'} | ${1999} `('$type coerces $stored to a value its jsonbCast can read', ({ type, stored, expected }) => { const column = { name: 'c', type } as ColumnDefinition const result = COLUMN_TYPE_REGISTRY[type as ColumnType].coerce(stored, column) @@ -104,11 +103,18 @@ describe('conversion write-back', () => { }) it('never leaves a numeric-cast type holding something Postgres cannot cast', () => { - // The concrete failure this guards: an epoch number left in a `date` - // column makes `(data->>'col')::timestamptz` throw on every query. + // The concrete failure this guards: a number left in a `date` column makes + // `(data->>'col')::timestamptz` throw on every query. A bare number is now + // refused outright rather than read as epoch milliseconds — the value + // cannot say whether it means seconds or milliseconds, and both readings + // are in range — so the column can never come to hold one either way. for (const definition of ALL_COLUMN_TYPES) { if (definition.jsonbCast !== 'timestamptz') continue - const coerced = definition.coerce(1700000000000, { name: 'c', type: definition.id }) + expect(definition.coerce(1700000000000, { name: 'c', type: definition.id }).ok).toBe(false) + const coerced = definition.coerce('2023-11-14T22:13:20.000Z', { + name: 'c', + type: definition.id, + }) expect(coerced.ok).toBe(true) expect(typeof (coerced as { value: unknown }).value).toBe('string') } @@ -135,16 +141,16 @@ describe('intentional divergences from the pre-registry behavior', () => { } }) - it('refuses to bulk-convert a number column to date', () => { - // `date.coerce` accepts an epoch for a single deliberate write, but - // reinterpreting a whole numeric column as epoch milliseconds is - // destructive and irreversible — 1, 5, 42 would become three timestamps in - // January 1970. The gate may be stricter than `coerce`, never looser. + it('refuses a number as a date, on the write path and the bulk gate alike', () => { + // A whole numeric column reinterpreted as epoch milliseconds turns 1, 5, 42 + // into three timestamps in January 1970, and one value at a time is no + // better — `1600000000` is September 2020 as seconds and January 1970 as + // milliseconds, both in range. `coerce` refuses a bare number, so the gate + // needs no override. const column: ColumnDefinition = { name: 'd', type: 'date' } for (const value of [0, 1, 42, 1700000000]) { expect(isValueCompatible(value, column)).toBe(false) - // The write path still accepts it. - expect(COLUMN_TYPE_REGISTRY.date.coerce(value as never, column).ok).toBe(true) + expect(COLUMN_TYPE_REGISTRY.date.coerce(value as never, column).ok).toBe(false) } expect(isValueCompatible('2024-01-01', column)).toBe(true) }) @@ -203,3 +209,53 @@ describe('metadata ownership', () => { } ) }) + +/** + * `salvage` is the escape hatch for the write paths that have no caller to + * answer: a computed cell, a CSV import row, the cell-write snapshot. There the + * alternative to a lossy reading is a blanked cell, so the registry may read + * looser than `coerce` — but only there, and only in that direction. + */ +describe('salvage — the machine-path reading', () => { + it('reads a bare epoch number as milliseconds for a date column', () => { + const column: ColumnDefinition = { name: 'd', type: 'date' } + expect(COLUMN_TYPE_REGISTRY.date.coerce(1700000000000 as never, column).ok).toBe(false) + expect(COLUMN_TYPE_REGISTRY.date.salvage?.(1700000000000 as never, column)).toEqual({ + ok: true, + value: '2023-11-14T22:13:20.000Z', + }) + }) + + it('refuses an out-of-range epoch rather than throwing on toISOString', () => { + const column: ColumnDefinition = { name: 'd', type: 'date' } + expect(COLUMN_TYPE_REGISTRY.date.salvage?.(1e20 as never, column)).toEqual({ ok: false }) + }) + + it('keeps the resolvable members of a multiselect and drops the rest', () => { + const column: ColumnDefinition = { + id: 'col_tags', + name: 'tags', + type: 'select', + multiple: true, + options: [ + { id: 'opt_a', name: 'Alpha' }, + { id: 'opt_b', name: 'Beta' }, + ], + } + expect(COLUMN_TYPE_REGISTRY.select.coerce(['Alpha', 'ghost'], column).ok).toBe(false) + expect(COLUMN_TYPE_REGISTRY.select.salvage?.(['Alpha', 'ghost'], column)).toEqual({ + ok: true, + value: ['opt_a'], + }) + }) + + it('has nothing partial to keep for a single select', () => { + const column: ColumnDefinition = { + id: 'col_status', + name: 'status', + type: 'select', + options: [{ id: 'opt_open', name: 'Open' }], + } + expect(COLUMN_TYPE_REGISTRY.select.salvage?.('ghost', column)).toEqual({ ok: false }) + }) +}) diff --git a/apps/sim/lib/table/__tests__/sql.test.ts b/apps/sim/lib/table/__tests__/sql.test.ts index dcca281da94..56a2118438c 100644 --- a/apps/sim/lib/table/__tests__/sql.test.ts +++ b/apps/sim/lib/table/__tests__/sql.test.ts @@ -383,6 +383,76 @@ describe('SQL Builder', () => { }) }) + /** + * The value's JS *type* was checked, but never its content: any string was + * bound straight into `::timestamptz`, so Postgres raised + * `invalid input syntax for type timestamp with time zone` — an unclassified + * driver throw the route layer rendered as `500 INTERNAL_ERROR`. + */ + describe('buildFilterClause > date bound must actually parse', () => { + const dateCols: ColumnDefinition[] = [{ name: 'birthDate', type: 'date' }] + + it.each(['not-a-date', '', 'abc', '2020-13-45', ' '])( + 'rejects %j as a range bound on a date column', + (bound) => { + expect(() => + buildFilterClause({ birthDate: { $gt: bound } } as Filter, TABLE, dateCols) + ).toThrow(/column "birthDate" \(date\) requires a parseable date string/) + } + ) + + it.each(['$gt', '$gte', '$lt', '$lte'])('rejects an unparseable bound for %s', (operator) => { + expect(() => + buildFilterClause({ birthDate: { [operator]: 'not-a-date' } } as Filter, TABLE, dateCols) + ).toThrow(/requires a parseable date string/) + }) + + it('still accepts the date shapes the column itself stores', () => { + for (const bound of ['2024-01-01', '2024-01-31T10:00:00Z', '2024-01-31T10:00:00+02:00']) { + expect(() => + buildFilterClause({ birthDate: { $lte: bound } }, TABLE, dateCols) + ).not.toThrow() + } + }) + }) + + describe('buildPredicateClause > system timestamp columns reject unparseable bounds', () => { + it.each(['gt', 'gte', 'lt', 'lte', 'eq', 'ne'])( + 'rejects an unparseable createdAt bound for %s', + (op) => { + expect(() => + buildPredicateClause( + { all: [{ field: 'createdAt', op, value: 'not-a-date' }] } as TablePredicate, + TABLE, + NO_COLUMNS + ) + ).toThrow(/column "createdAt" requires a parseable date string/) + } + ) + + it('rejects an unparseable member of an `in` list', () => { + expect(() => + buildPredicateClause( + { + all: [{ field: 'updatedAt', op: 'in', value: ['2024-01-01', 'not-a-date'] }], + } as TablePredicate, + TABLE, + NO_COLUMNS + ) + ).toThrow(/column "updatedAt" requires a parseable date string/) + }) + + it('still accepts a real timestamp bound', () => { + expect(() => + buildPredicateClause( + { all: [{ field: 'createdAt', op: 'gte', value: '2024-01-01T00:00:00Z' }] }, + TABLE, + NO_COLUMNS + ) + ).not.toThrow() + }) + }) + describe('buildSortClause', () => { it('returns undefined for empty sort', () => { expect(buildSortClause({}, TABLE, NO_COLUMNS)).toBeUndefined() @@ -495,10 +565,19 @@ describe('SQL Builder', () => { expect(out).not.toContain('ILIKE') }) - it('negates multiselect membership for $ncontains', () => { + /** + * Multi-select `$ncontains` keeps null and absent cells, like every other + * negation on the surface: `data` itself is never NULL, so containment is + * FALSE — not NULL — for a missing key, and the negation is therefore TRUE. + * Multi-select is not an exception that excludes nulls, and the published + * `TablePredicate` description says so. + */ + it('negates multiselect membership for $ncontains, keeping null and absent cells', () => { const out = render(buildFilterClause({ tags: { $ncontains: 'opt_a' } }, TABLE, [tagsCol])) expect(out).toContain('NOT (') expect(out).toContain('"tags":["opt_a"]') + expect(out).not.toContain('IS NOT NULL') + expect(out).not.toContain("? 'tags'") }) it('rejects explicit equality on a multiselect — it could never match', () => { diff --git a/apps/sim/lib/table/__tests__/update-row.test.ts b/apps/sim/lib/table/__tests__/update-row.test.ts index 91bdef534c9..cc2ed28df81 100644 --- a/apps/sim/lib/table/__tests__/update-row.test.ts +++ b/apps/sim/lib/table/__tests__/update-row.test.ts @@ -138,6 +138,41 @@ describe('updateRow — partial merge', () => { expect(data?.values).not.toContain(JSON.stringify({ name: 'Alice', age: 31 })) }) + it('blanks an uncoercible cell for a first-party caller, as it always has', async () => { + const { coerceRowToSchema } = await import('@/lib/table/validation') + await updateRow( + { tableId: 'tbl-1', rowId: 'row-1', data: { age: 31 }, workspaceId: 'ws-1' }, + TABLE, + 'req-1' + ) + + expect(coerceRowToSchema).toHaveBeenCalledWith( + { name: 'Alice', age: 31 }, + TABLE.schema, + undefined, + ['age'] + ) + }) + + it('holds only the patched keys to the strict policy a v2 caller opts into', async () => { + // The merged row carries cells this request never sent. A legacy value in one + // of them belongs to an earlier write and must not decide this one. + const { coerceRowToSchema } = await import('@/lib/table/validation') + await updateRow( + { tableId: 'tbl-1', rowId: 'row-1', data: { age: 31 }, workspaceId: 'ws-1' }, + TABLE, + 'req-1', + { uncoercibleValues: 'reject' } + ) + + expect(coerceRowToSchema).toHaveBeenCalledWith( + { name: 'Alice', age: 31 }, + TABLE.schema, + 'reject', + ['age'] + ) + }) + it('allows updating a single column without affecting others', async () => { const result = await updateRow( { tableId: 'tbl-1', rowId: 'row-1', data: { name: 'Bob' }, workspaceId: 'ws-1' }, @@ -265,6 +300,39 @@ describe('insertRow — position race safety (migration 0198 + advisory lock)', expect(findExecutedSqlContaining('pg_advisory_xact_lock')).toBe(false) }) + /** + * The v2 surface is column-NAME-keyed and resolves `conflictTarget` to its + * storage id before this call, so the rejection has to translate back — a + * caller that sent `email` cannot act on a `col_…` id it has never seen. + */ + it('upsertRow names the conflict column the caller does, not its storage id', async () => { + const table: TableDefinition = { + ...TABLE, + schema: { + columns: [ + { id: 'col_9934c202', name: 'email', type: 'string' }, + { id: 'col_2f1a', name: 'slug', type: 'string', unique: true }, + ], + }, + } + vi.mocked(getUniqueColumns).mockReturnValue([ + { id: 'col_2f1a', name: 'slug', type: 'string', unique: true }, + ]) + + await expect( + upsertRow( + { + tableId: 'tbl-1', + workspaceId: 'ws-1', + data: { col_9934c202: 'a@b.test' }, + conflictTarget: 'col_9934c202', + }, + table, + 'req-1' + ) + ).rejects.toThrow('Column "email" is not a unique column. Available unique columns: slug') + }) + it('upsertRow acquires the advisory lock on the insert path (no match)', async () => { vi.mocked(getUniqueColumns).mockReturnValue([{ name: 'name', type: 'string', unique: true }]) // Initial existing-row check + post-lock re-check both find no match. diff --git a/apps/sim/lib/table/__tests__/validation.test.ts b/apps/sim/lib/table/__tests__/validation.test.ts index fc3b77ed574..9d698c9d96b 100644 --- a/apps/sim/lib/table/__tests__/validation.test.ts +++ b/apps/sim/lib/table/__tests__/validation.test.ts @@ -358,7 +358,13 @@ describe('Validation', () => { expect(data.founded).toBe(1999) }) - it('nulls an un-coercible value for an optional number column', () => { + it('rejects an un-coercible value for an optional number column under `reject`', () => { + const data = { name: 'Acme', founded: 2000, age: 'unknown' } + const result = coerceRowToSchema(data, schema, 'reject') + expect(result.valid).toBe(false) + }) + + it('nulls an un-coercible optional value by default', () => { const data = { name: 'Acme', founded: 2000, age: 'unknown' } const result = coerceRowToSchema(data, schema) expect(result.valid).toBe(true) @@ -387,7 +393,13 @@ describe('Validation', () => { expect(data.active).toBe(false) }) - it('coerces an epoch number to an ISO date string', () => { + it('refuses a bare epoch number under `reject`, whose unit the value cannot state', () => { + const data = { name: 'Acme', founded: 2000, created: Date.parse('2024-01-15T00:00:00Z') } + const result = coerceRowToSchema(data, schema, 'reject') + expect(result.valid).toBe(false) + }) + + it('coerces an epoch number to an ISO date string by default', () => { const epoch = Date.parse('2024-01-15T00:00:00Z') const data = { name: 'Acme', founded: 2000, created: epoch } const result = coerceRowToSchema(data, schema) @@ -403,14 +415,14 @@ describe('Validation', () => { expect(data.created).toBe(date.toISOString()) }) - it('nulls an out-of-range epoch number for an optional date column without throwing', () => { + it('nulls an out-of-range epoch number without throwing', () => { const data = { name: 'Acme', founded: 2000, created: 1e20 } const result = coerceRowToSchema(data, schema) expect(result.valid).toBe(true) expect(data.created).toBeNull() }) - it('nulls an invalid Date instance for an optional date column without throwing', () => { + it('nulls an invalid Date instance without throwing', () => { const data = { name: 'Acme', founded: 2000, created: new Date('not-a-date') } const result = coerceRowToSchema(data, schema) expect(result.valid).toBe(true) @@ -447,7 +459,13 @@ describe('Validation', () => { expect(patch.age).toBe(42) }) - it('nulls an un-coercible optional value in a patch', () => { + it('leaves an un-coercible optional patch value in place under `reject`', () => { + const patch: { age: unknown } = { age: 'nope' } + coerceRowValues(patch as never, schema, 'reject') + expect(patch.age).toBe('nope') + }) + + it('nulls an un-coercible optional patch value by default', () => { const patch: { age: unknown } = { age: 'nope' } coerceRowValues(patch as never, schema) expect(patch.age).toBeNull() @@ -523,7 +541,13 @@ describe('Validation', () => { expect(patch.price).toBe(42) }) - it('nulls an unreadable amount on an optional column', () => { + it('leaves an unreadable amount in place on an optional column under `reject`', () => { + const patch: Record = { price: 'ask sales' } + coerceRowValues(patch as never, currencySchema, 'reject') + expect(patch.price).toBe('ask sales') + }) + + it('nulls an unreadable amount on an optional column by default', () => { const patch: Record = { price: 'ask sales' } coerceRowValues(patch as never, currencySchema) expect(patch.price).toBeNull() diff --git a/apps/sim/lib/table/application/groups.ts b/apps/sim/lib/table/application/groups.ts index b6f01039557..8252f5a939b 100644 --- a/apps/sim/lib/table/application/groups.ts +++ b/apps/sim/lib/table/application/groups.ts @@ -176,7 +176,10 @@ export const listTableGroupsUseCase = defineAuthorizedTableUseCase({ assertedWorkspaceId: input.workspaceId, }), async execute({ context }) { - return { groups: (context.table.schema as TableSchema).workflowGroups ?? [] } + return { + table: context.table, + groups: (context.table.schema as TableSchema).workflowGroups ?? [], + } }, }) diff --git a/apps/sim/lib/table/application/imports.test.ts b/apps/sim/lib/table/application/imports.test.ts index 79d3fae1691..c5226c24e5d 100644 --- a/apps/sim/lib/table/application/imports.test.ts +++ b/apps/sim/lib/table/application/imports.test.ts @@ -21,6 +21,7 @@ const mocks = vi.hoisted(() => ({ resolveWorkspaceContext: vi.fn(), startUploadedImport: vi.fn(), tableImportBodyFromUpload: vi.fn(), + resourceFromUpload: vi.fn(), })) vi.mock('@sim/platform-authz/workspace', () => ({ @@ -53,6 +54,7 @@ vi.mock('@/lib/table/orchestration/import-resource', () => ({ getTableImportResource: mocks.getResource, startUploadedTableImport: mocks.startUploadedImport, tableImportBodyFromUpload: mocks.tableImportBodyFromUpload, + tableImportResourceFromUpload: mocks.resourceFromUpload, })) vi.mock('@/lib/uploads/upload-session/application', () => ({ @@ -164,6 +166,7 @@ describe('table import application use cases', () => { mocks.startUploadedImport.mockResolvedValue({ ...record, status: 'ready' }) mocks.createResource.mockResolvedValue({ record, upload: null }) mocks.getWorkspaceFile.mockResolvedValue(workspaceFile) + mocks.resourceFromUpload.mockReturnValue(record) }) it('creates an import through the domain resource boundary without presenting a v2 DTO', async () => { @@ -249,6 +252,43 @@ describe('table import application use cases', () => { ) }) + /** + * The 201 that creates an upload-backed import reports `status: "uploading"` + * against an id that has no durable job row yet. Reading that id back is only + * possible through the upload session, so the token has to be honored here the + * same way `DELETE` honors it — otherwise the whole upload phase 404s. + */ + it('reads an upload-phase import through its upload token', async () => { + await expect( + readTableImportUseCase.execute({ + principal: workspaceKey, + input: { importId: 'import-1', workspaceId: 'workspace-1', uploadToken: 'signed-token' }, + }) + ).resolves.toEqual({ import: record }) + + expect(mocks.getUpload).toHaveBeenCalledWith({ + importId: 'import-1', + assertedWorkspaceId: 'workspace-1', + principal: workspaceKey, + uploadToken: 'signed-token', + }) + expect(mocks.getResource).not.toHaveBeenCalled() + expect(mocks.resourceFromUpload).toHaveBeenCalledWith(upload) + }) + + it('prefers the durable job once the upload has started one', async () => { + const running = { ...record, status: 'running' as const } + mocks.findResource.mockResolvedValue(running) + + await expect( + readTableImportUseCase.execute({ + principal: workspaceKey, + input: { importId: 'import-1', workspaceId: 'workspace-1', uploadToken: 'signed-token' }, + }) + ).resolves.toEqual({ import: running }) + expect(mocks.resourceFromUpload).not.toHaveBeenCalled() + }) + it('resolves a workspace-file source canonically inside the authorized import command', async () => { await createTableImportUseCase.execute({ principal: reader, diff --git a/apps/sim/lib/table/application/imports.ts b/apps/sim/lib/table/application/imports.ts index 3f27c72c9df..0b14531c299 100644 --- a/apps/sim/lib/table/application/imports.ts +++ b/apps/sim/lib/table/application/imports.ts @@ -28,6 +28,7 @@ import { startUploadedTableImport, type TableImportResource, tableImportBodyFromUpload, + tableImportResourceFromUpload, } from '@/lib/table/orchestration/import-resource' import { getWorkspaceFile, @@ -60,6 +61,10 @@ export interface CreateTableImportPartsInput extends TableImportUploadInput { partNumbers: number[] } +export interface ReadTableImportInput extends TableImportResourceInput { + uploadToken?: string +} + export interface CancelTableImportInput extends TableImportResourceInput { uploadToken?: string } @@ -196,12 +201,36 @@ export const createTableImportUseCase = defineAuthorizedTableUseCase({ }, }) +/** + * Reads an import, including while its upload is still in flight. + * + * An upload-sourced import has no durable job row until the upload completes, + * so a caller holding the upload token is resolved against the session instead — + * the same branch `cancelTableImportUseCase` takes. The job is still preferred + * once it exists: the upload session lingers in a completed state after the + * runner starts, and reporting `uploading` for an import that is already + * processing would strand a poller. + */ export const readTableImportUseCase = defineAuthorizedTableUseCase({ operation: tableOperations.readImport, - resolveContext: ({ input }: { input: TableImportResourceInput }) => - resolveTableImportContext(input), + async resolveContext({ + principal, + input, + }: { + principal: Principal + input: ReadTableImportInput + }) { + return input.uploadToken + ? resolveTableImportUploadContext(principal, { ...input, uploadToken: input.uploadToken }) + : resolveTableImportContext(input) + }, async execute({ context }): Promise { - return { import: context.record } + if (!('upload' in context)) return { import: context.record } + const started = await findTableImportResource({ + importId: context.upload.id, + assertedWorkspaceId: context.workspaceId, + }) + return { import: started ?? tableImportResourceFromUpload(context.upload) } }, }) diff --git a/apps/sim/lib/table/application/rows.test.ts b/apps/sim/lib/table/application/rows.test.ts index ea728d97881..2f8c9325562 100644 --- a/apps/sim/lib/table/application/rows.test.ts +++ b/apps/sim/lib/table/application/rows.test.ts @@ -475,7 +475,7 @@ describe('replaceTableRows application use case', () => { tableId: TABLE.id, assertedWorkspaceId: TABLE.workspaceId, requestId: 'request-1', - rows: [{ name: 'Ada', unknown: 'dropped' }], + rows: [{ name: 'Ada' }], }, }) @@ -493,12 +493,23 @@ describe('replaceTableRows application use case', () => { ], }, TABLE, - 'request-1' + 'request-1', + {} ) expect(result).toMatchObject({ deletedCount: 2, insertedCount: 1 }) expect(mockSignalRowsChanged).toHaveBeenCalledWith(TABLE.id) }) + it('refuses a replacement row naming an unknown column for a strict caller', async () => { + await expect( + replaceTableRows.execute({ + principal: PRINCIPAL, + input: { tableId: TABLE.id, rows: [{ name: 'Ada', unknown: 'x' }], strictWrite: true }, + }) + ).rejects.toThrow(/Row 1: Unknown column: unknown/) + expect(mockReplaceRowsPrimitive).not.toHaveBeenCalled() + }) + it('rejects more than 10,000 rows before opening the atomic primitive', async () => { await expect( replaceTableRows.execute({ @@ -629,6 +640,64 @@ describe('row query and upsert application semantics', () => { expect(result.nextCursor).toBe('native-next-cursor') }) + /** + * An offset cursor names a position in one filtered sequence. Replayed under a + * different predicate that ordinal belongs to a sequence the caller never asked + * for — page 2 of the archived rows, or an empty page the caller reads as "no + * more matches". It must be refused, exactly as a changed sort already is. + */ + it('refuses an offset cursor replayed under a different predicate', async () => { + const cursor = encodeCursor({ + lastRow: { id: 'row-100', orderKey: null }, + keysetValid: false, + nextOffset: 100, + predicate: { all: [{ field: 'column-name', op: 'eq', value: 'Ada' }] }, + }) + + await expect( + queryTableRows.execute({ + principal: PRINCIPAL, + input: { + tableId: TABLE.id, + cursor, + predicate: { all: [{ field: 'name', op: 'eq', value: 'Grace' }] }, + }, + }) + ).rejects.toMatchObject({ details: { code: 'CURSOR_FILTER_CONFLICT' } }) + expect(mockQueryRows).not.toHaveBeenCalled() + }) + + it('resumes the same offset page under the identical predicate', async () => { + const cursor = encodeCursor({ + lastRow: { id: 'row-100', orderKey: null }, + keysetValid: false, + nextOffset: 100, + predicate: { all: [{ field: 'column-name', op: 'eq', value: 'Ada' }] }, + }) + mockQueryRows.mockResolvedValueOnce({ + rows: [], + rowCount: 0, + totalCount: null, + nextCursor: null, + }) + + await expect( + queryTableRows.execute({ + principal: PRINCIPAL, + input: { + tableId: TABLE.id, + cursor, + predicate: { all: [{ field: 'name', op: 'eq', value: 'Ada' }] }, + }, + }) + ).resolves.toMatchObject({ rowCount: 0 }) + expect(mockQueryRows).toHaveBeenCalledWith( + TABLE, + expect.objectContaining({ offset: 100 }), + expect.any(String) + ) + }) + it('loads requested persisted provenance inside the authorized application query', async () => { const row = { id: 'row-1', @@ -735,7 +804,8 @@ describe('row query and upsert application semantics', () => { userId: PRINCIPAL.userId, }), TABLE, - 'request-1' + 'request-1', + {} ) }) }) @@ -782,7 +852,8 @@ describe('table row write secret provenance defaulting', () => { expect(mockInsertRow).toHaveBeenCalledWith( expect.objectContaining({ secretProvenance: EXACT_EMPTY_NAME }), TABLE, - expect.any(String) + expect.any(String), + {} ) }) @@ -797,7 +868,8 @@ describe('table row write secret provenance defaulting', () => { secretProvenance: [EXACT_EMPTY_NAME, EXACT_EMPTY_NAME], }), TABLE, - expect.any(String) + expect.any(String), + {} ) }) @@ -810,7 +882,8 @@ describe('table row write secret provenance defaulting', () => { expect(mockUpdateRow).toHaveBeenCalledWith( expect.objectContaining({ secretProvenance: EXACT_EMPTY_NAME }), TABLE, - expect.any(String) + expect.any(String), + {} ) }) @@ -845,7 +918,8 @@ describe('table row write secret provenance defaulting', () => { columns: { column_name: { version: 1, complete: true, entries: [] } }, }, }), - expect.any(String) + expect.any(String), + {} ) }) @@ -858,7 +932,8 @@ describe('table row write secret provenance defaulting', () => { expect(mockUpsertRow).toHaveBeenCalledWith( expect.objectContaining({ secretProvenance: EXACT_EMPTY_NAME }), TABLE, - expect.any(String) + expect.any(String), + {} ) }) @@ -871,7 +946,8 @@ describe('table row write secret provenance defaulting', () => { expect(mockReplaceRowsPrimitive).toHaveBeenCalledWith( expect.objectContaining({ secretProvenance: [EXACT_EMPTY_NAME] }), TABLE, - expect.any(String) + expect.any(String), + {} ) }) @@ -891,7 +967,156 @@ describe('table row write secret provenance defaulting', () => { expect(mockUpdateRow).toHaveBeenCalledWith( expect.objectContaining({ secretProvenance: unknown }), TABLE, - expect.any(String) + expect.any(String), + {} + ) + }) +}) + +/** + * The name→id remap drops keys naming no column, and nothing upstream had + * checked that there were none to drop. An insert of `{"nosuchcol":"x"}` + * therefore answered 201 having created an empty row, and a patch of + * `{"zzz":"x"}` answered `updatedCount: 0` — the same answer a predicate that + * matched nothing gives, so a caller could not tell a typo from an empty match. + * + * The refusal is scoped to `strictWrite`, which only `/api/v2` sets. A + * first-party caller still has the key dropped: Copilot feeds the model's raw + * arguments in unfiltered, so a hallucinated key, an echoed `id`, or a name + * left over from a rename would otherwise refuse the whole write. + */ +describe('unknown column names under strictWrite', () => { + beforeEach(() => { + vi.clearAllMocks() + mockResolvePermission.mockResolvedValue('write') + mockResolveContext.mockResolvedValue({ + tableId: TABLE.id, + table: TABLE, + workspaceId: TABLE.workspaceId, + workspaceOrganizationId: 'organization-1', + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', + }) + mockValidateRowData.mockResolvedValue({ valid: true }) + mockValidateBatchRows.mockResolvedValue({ valid: true }) + mockInsertRow.mockResolvedValue({ id: 'row-1', data: {} }) + mockBatchInsertRows.mockResolvedValue([{ id: 'row-1', data: {} }]) + mockUpdateRow.mockResolvedValue({ id: 'row-1', data: {} }) + mockUpdateRowsByFilter.mockResolvedValue({ affectedCount: 0 }) + mockUpsertRow.mockResolvedValue({ operation: 'insert', row: { id: 'row-1', data: {} } }) + }) + + it('refuses a single insert naming a column the table does not have', async () => { + await expect( + createTableRows.execute({ + principal: PRINCIPAL, + input: { kind: 'single', tableId: TABLE.id, data: { nosuchcol: 'x' }, strictWrite: true }, + }) + ).rejects.toThrow(/Unknown column: nosuchcol/) + expect(mockInsertRow).not.toHaveBeenCalled() + }) + + it('drops the same key for a first-party caller instead of refusing the write', async () => { + await expect( + createTableRows.execute({ + principal: PRINCIPAL, + input: { kind: 'single', tableId: TABLE.id, data: { name: 'Ada', nosuchcol: 'x' } }, + }) + ).resolves.toBeDefined() + expect(mockInsertRow).toHaveBeenCalledWith( + expect.objectContaining({ data: { 'column-name': 'Ada' } }), + TABLE, + expect.any(String), + {} ) }) + + it('names every unknown column at once', async () => { + await expect( + createTableRows.execute({ + principal: PRINCIPAL, + input: { + kind: 'single', + tableId: TABLE.id, + data: { zzz: 'x', qqq: 'y' }, + strictWrite: true, + }, + }) + ).rejects.toThrow(/Unknown columns: zzz, qqq/) + }) + + it('refuses a batch insert and says which row was wrong', async () => { + await expect( + createTableRows.execute({ + principal: PRINCIPAL, + input: { + kind: 'batch', + tableId: TABLE.id, + rows: [{ name: 'Ada' }, { zzz: 'x' }], + strictWrite: true, + }, + }) + ).rejects.toThrow(/Row 2: Unknown column: zzz/) + expect(mockBatchInsertRows).not.toHaveBeenCalled() + }) + + it('refuses a predicate update rather than reporting an empty match', async () => { + await expect( + updateTableRows.execute({ + principal: PRINCIPAL, + input: { + tableId: TABLE.id, + filter: { all: [{ field: 'name', op: 'eq', value: 'Ada' }] }, + data: { zzz: 'x' }, + strictWrite: true, + }, + }) + ).rejects.toThrow(/Unknown column: zzz/) + expect(mockUpdateRowsByFilter).not.toHaveBeenCalled() + }) + + it('refuses a single-row update naming an unknown column', async () => { + await expect( + updateTableRow.execute({ + principal: PRINCIPAL, + input: { tableId: TABLE.id, rowId: 'row-1', data: { zzz: 'x' }, strictWrite: true }, + }) + ).rejects.toThrow(/Unknown column: zzz/) + expect(mockUpdateRow).not.toHaveBeenCalled() + }) + + it('reports an empty match for the same first-party update instead of refusing', async () => { + await expect( + updateTableRow.execute({ + principal: PRINCIPAL, + input: { tableId: TABLE.id, rowId: 'row-1', data: { zzz: 'x' } }, + }) + ).resolves.toBeDefined() + expect(mockUpdateRow).toHaveBeenCalled() + }) + + it('still accepts a write naming only known columns', async () => { + await expect( + createTableRows.execute({ + principal: PRINCIPAL, + input: { kind: 'single', tableId: TABLE.id, data: { name: 'Ada' } }, + }) + ).resolves.toBeDefined() + }) + + it('carries the strict value policy to the primitive, and nothing without it', async () => { + await createTableRows.execute({ + principal: PRINCIPAL, + input: { kind: 'single', tableId: TABLE.id, data: { name: 'Ada' }, strictWrite: true }, + }) + expect(mockInsertRow).toHaveBeenLastCalledWith(expect.anything(), TABLE, expect.any(String), { + uncoercibleValues: 'reject', + }) + + await createTableRows.execute({ + principal: PRINCIPAL, + input: { kind: 'single', tableId: TABLE.id, data: { name: 'Ada' } }, + }) + expect(mockInsertRow).toHaveBeenLastCalledWith(expect.anything(), TABLE, expect.any(String), {}) + }) }) diff --git a/apps/sim/lib/table/application/rows.ts b/apps/sim/lib/table/application/rows.ts index 4681b39be14..5185c9b0c52 100644 --- a/apps/sim/lib/table/application/rows.ts +++ b/apps/sim/lib/table/application/rows.ts @@ -43,7 +43,7 @@ import { defineAuthorizedTableUseCase } from '@/lib/table/application/authorized import { resolveActiveTableContext } from '@/lib/table/application/context' import { tableOperations } from '@/lib/table/application/operations' import { assertRowCapacity, notifyTableRowUsage } from '@/lib/table/billing' -import { buildIdByName } from '@/lib/table/column-keys' +import { buildIdByName, unknownColumnNames } from '@/lib/table/column-keys' import { columnTypeOf } from '@/lib/table/column-types' import { TableQueryValidationError } from '@/lib/table/errors' import { signalTableRowsChanged } from '@/lib/table/events' @@ -54,14 +54,14 @@ import { validateSortSpec, validateStoragePredicate, } from '@/lib/table/query-builder/validate' -import { assertCursorSortBinding, decodeCursor } from '@/lib/table/rows/cursor' +import { assertCursorQueryBinding, decodeCursor } from '@/lib/table/rows/cursor' import { createExactEmptyTableRowSecretProvenance, createTableRowSecretProvenanceFromRegistry, createUnknownTableRowSecretProvenance, loadTableRowSecretProvenance, } from '@/lib/table/rows/secret-provenance' -import type { FindRowMatch } from '@/lib/table/rows/service' +import type { FindRowMatch, RowWriteOptions } from '@/lib/table/rows/service' import { replaceTableRowsWithTx } from '@/lib/table/rows/service' import { predicateToStorage } from '@/lib/table/select-values' import { coerceRowValues } from '@/lib/table/validation' @@ -81,6 +81,22 @@ interface TableScopedInput { tableId: string assertedWorkspaceId?: string requestId?: string + /** + * Whether the calling surface publishes the stricter `/api/v2` write contract: + * a row naming a column the table does not have is refused rather than having + * that key dropped, and a value the column's type cannot coerce is answered + * with a 400 rather than stored as `null`. + * + * Absent — every first-party surface, and the only behavior any of them has + * ever had: the workspace grid, the internal `/api/table` routes, `/api/v1`, + * the Copilot table tools, and the executor's Table block all drop the + * unknown key and blank the uncoercible cell. Read-only use cases ignore it. + */ +} + +/** The write policy `strictWrite` selects, for the row-service primitives. */ +function rowWriteOptions(input: { strictWrite: boolean }): RowWriteOptions { + return input.strictWrite ? { uncoercibleValues: 'reject' } : {} } interface TableResult { @@ -115,8 +131,34 @@ function actorUserId( }).attributedUserId } -function namedDataToStorage(data: RowData, table: TableDefinition): RowData { - return rowDataNameToId(data, buildIdByName(table.schema)) +/** + * Refuses a wire row naming a column the table does not have. Applied only to a + * `strictWrite` caller — see {@link TableScopedInput.strictWrite}. + * + * The name→id remap drops unrecognised keys, so without this an insert of + * `{"nosuchcol":"x"}` created an empty row under a 201, and a patch of + * `{"zzz":"x"}` answered `updatedCount: 0` — indistinguishable from a predicate + * that matched nothing, and in both cases the client is told the write + * succeeded. Naming the offending columns is the only answer that lets a caller + * tell a typo apart from an empty match. + */ +function assertKnownColumnNames( + data: RowData, + idByName: ReadonlyMap, + rowLabel?: string +): void { + const unknown = unknownColumnNames(data, idByName) + if (unknown.length === 0) return + const where = rowLabel ? `${rowLabel}: ` : '' + throw new TableRowsValidationError( + `${where}Unknown column${unknown.length > 1 ? 's' : ''}: ${unknown.join(', ')}` + ) +} + +function namedDataToStorage(data: RowData, table: TableDefinition, strict = false): RowData { + const idByName = buildIdByName(table.schema) + if (strict) assertKnownColumnNames(data, idByName) + return rowDataNameToId(data, idByName) } /** @@ -124,9 +166,16 @@ function namedDataToStorage(data: RowData, table: TableDefinition): RowData { * whole batch rather than per row — these paths run over up to * `MAX_BATCH_INSERT_SIZE` rows. */ -function namedRowsToStorage(rows: readonly RowData[], table: TableDefinition): RowData[] { +function namedRowsToStorage( + rows: readonly RowData[], + table: TableDefinition, + strict = false +): RowData[] { const idByName = buildIdByName(table.schema) - return rows.map((row) => rowDataNameToId(row, idByName)) + return rows.map((row, index) => { + if (strict) assertKnownColumnNames(row, idByName, `Row ${index + 1}`) + return rowDataNameToId(row, idByName) + }) } /** @@ -207,7 +256,7 @@ export const listTableRows = defineAuthorizedTableUseCase({ requireIntegerInRange(input.limit, 1, TABLE_LIMITS.MAX_QUERY_LIMIT, 'Limit') try { const cursor = input.cursor ? decodeCursor(input.cursor) : undefined - if (cursor) assertCursorSortBinding(cursor, undefined) + if (cursor) assertCursorQueryBinding(cursor, {}) const result = await queryRows( context.table, { @@ -269,7 +318,7 @@ export const queryTableRows = defineAuthorizedTableUseCase({ ? Object.fromEntries(sortSpec.map((item) => [item.field, item.direction])) : undefined const cursor = input.cursor ? decodeCursor(input.cursor) : undefined - if (cursor) assertCursorSortBinding(cursor, sort) + if (cursor) assertCursorQueryBinding(cursor, { sort, predicate }) const result = await queryRows( context.table, { @@ -369,6 +418,8 @@ export const readTableRow = defineAuthorizedTableUseCase({ }) interface CreateSingleTableRowInput extends TableScopedInput { + /** See {@link rowWriteOptions}. Required so a new write surface must choose. */ + strictWrite: boolean kind: 'single' data: RowData position?: number @@ -378,6 +429,8 @@ interface CreateSingleTableRowInput extends TableScopedInput { } interface CreateBatchTableRowsInput extends TableScopedInput { + /** See {@link rowWriteOptions}. Required so a new write surface must choose. */ + strictWrite: boolean kind: 'batch' rows: RowData[] orderKeys?: string[] @@ -405,12 +458,14 @@ export const createTableRows = defineAuthorizedTableUseCase({ ) { throw new TableRowsValidationError('Position must be 0 or greater') } - const data = namedDataToStorage(input.data, context.table) + const data = namedDataToStorage(input.data, context.table, input.strictWrite) + const writeOptions = rowWriteOptions(input) await throwValidationResponse( await validateRowData({ rowData: data, schema: context.table.schema, tableId: context.tableId, + uncoercibleValues: writeOptions.uncoercibleValues, }) ) const row = await insertRow( @@ -425,7 +480,8 @@ export const createTableRows = defineAuthorizedTableUseCase({ secretProvenance: defaultedRowSecretProvenance(data, input.secretProvenance), }, context.table, - requestId(input) + requestId(input), + writeOptions ) return { kind: 'single', table: context.table, row } } @@ -440,12 +496,14 @@ export const createTableRows = defineAuthorizedTableUseCase({ if (input.orderKeys && input.orderKeys.length !== input.rows.length) { throw new TableRowsValidationError('orderKeys must align one-to-one with rows') } - const rows = namedRowsToStorage(input.rows, context.table) + const rows = namedRowsToStorage(input.rows, context.table, input.strictWrite) + const batchWriteOptions = rowWriteOptions(input) await throwValidationResponse( await validateBatchRows({ rows, schema: context.table.schema, tableId: context.tableId, + uncoercibleValues: batchWriteOptions.uncoercibleValues, }) ) const created = await batchInsertRows( @@ -458,7 +516,8 @@ export const createTableRows = defineAuthorizedTableUseCase({ secretProvenance: defaultedRowsSecretProvenance(rows, input.secretProvenance), }, context.table, - requestId(input) + requestId(input), + batchWriteOptions ) return { kind: 'batch', table: context.table, rows: created } }, @@ -471,6 +530,8 @@ export const createTableRows = defineAuthorizedTableUseCase({ const MAX_REPLACE_TABLE_ROWS = 10_000 export interface ReplaceTableRowsInput extends TableScopedInput { + /** See {@link rowWriteOptions}. Required so a new write surface must choose. */ + strictWrite: boolean rows: RowData[] secretProvenance?: Array } @@ -490,7 +551,7 @@ export const replaceTableRows = defineAuthorizedTableUseCase({ throw new TableRowsValidationError('Secret provenance must align one-to-one with rows') } - const rows = namedRowsToStorage(input.rows, context.table) + const rows = namedRowsToStorage(input.rows, context.table, input.strictWrite) const result = await replaceTableRowsPrimitive( { tableId: context.tableId, @@ -500,7 +561,8 @@ export const replaceTableRows = defineAuthorizedTableUseCase({ secretProvenance: defaultedRowsSecretProvenance(rows, input.secretProvenance), }, context.table, - requestId(input) + requestId(input), + rowWriteOptions(input) ) return { table: context.table, ...result } }, @@ -678,6 +740,8 @@ export const replaceProjectedWireRows = defineAuthorizedTableUseCase({ }) export interface UpdateTableRowInput extends TableScopedInput { + /** See {@link rowWriteOptions}. Required so a new write surface must choose. */ + strictWrite: boolean rowId: string data: RowData secretProvenance?: TableRowSecretProvenanceWrite @@ -694,7 +758,7 @@ export const updateTableRow = defineAuthorizedTableUseCase({ operation: tableOperations.updateRow, resolveContext: ({ input }: { input: UpdateTableRowInput }) => resolveActiveTableContext(input), async execute({ principal, input, context }): Promise { - const data = namedDataToStorage(input.data, context.table) + const data = namedDataToStorage(input.data, context.table, input.strictWrite) const row = await updateRow( { tableId: context.tableId, @@ -705,7 +769,8 @@ export const updateTableRow = defineAuthorizedTableUseCase({ secretProvenance: defaultedRowSecretProvenance(data, input.secretProvenance), }, context.table, - requestId(input) + requestId(input), + rowWriteOptions(input) ) if (!row) throw new Error('Unconditional table row update was rejected') return { @@ -726,6 +791,8 @@ export const updateTableRow = defineAuthorizedTableUseCase({ }) export interface UpdateTableRowsInput extends TableScopedInput { + /** See {@link rowWriteOptions}. Required so a new write surface must choose. */ + strictWrite: boolean filter: TablePredicate data: RowData limit?: number @@ -742,7 +809,7 @@ export const updateTableRows = defineAuthorizedTableUseCase({ if (input.limit !== undefined) { requireIntegerInRange(input.limit, 1, TABLE_LIMITS.MAX_BULK_OPERATION_SIZE, 'Limit') } - const data = namedDataToStorage(input.data, context.table) + const data = namedDataToStorage(input.data, context.table, input.strictWrite) const result = await updateRowsByFilter( context.table, { @@ -752,7 +819,8 @@ export const updateTableRows = defineAuthorizedTableUseCase({ actorUserId: actorUserId(principal, context.billedAccountUserId), secretProvenance: defaultedRowSecretProvenance(data, input.secretProvenance), }, - requestId(input) + requestId(input), + rowWriteOptions(input) ) return { table: context.table, ...result } } catch (error) { @@ -848,6 +916,8 @@ export const deleteTableRows = defineAuthorizedTableUseCase({ }) export interface UpsertTableRowInput extends TableScopedInput { + /** See {@link rowWriteOptions}. Required so a new write surface must choose. */ + strictWrite: boolean data: RowData conflictTarget?: string secretProvenance?: TableRowSecretProvenanceWrite @@ -865,7 +935,7 @@ export const upsertTableRow = defineAuthorizedTableUseCase({ const conflictTarget = input.conflictTarget ? (buildIdByName(context.table.schema).get(input.conflictTarget) ?? input.conflictTarget) : undefined - const data = namedDataToStorage(input.data, context.table) + const data = namedDataToStorage(input.data, context.table, input.strictWrite) const result = await upsertRow( { tableId: context.tableId, @@ -876,7 +946,8 @@ export const upsertTableRow = defineAuthorizedTableUseCase({ secretProvenance: defaultedRowSecretProvenance(data, input.secretProvenance), }, context.table, - requestId(input) + requestId(input), + rowWriteOptions(input) ) return { table: context.table, row: result.row, operation: result.operation } }, diff --git a/apps/sim/lib/table/application/tables.ts b/apps/sim/lib/table/application/tables.ts index 8f904d3b22c..466463ddadb 100644 --- a/apps/sim/lib/table/application/tables.ts +++ b/apps/sim/lib/table/application/tables.ts @@ -5,7 +5,7 @@ import type { CursorKey, ListSortOrder } from '@/lib/api/list-query' import { OrchestrationError } from '@/lib/core/orchestration/types' import { generateRequestId } from '@/lib/core/utils/request' import { MAX_FOLDERS_PER_WORKSPACE } from '@/lib/folders/constants' -import { loadActiveFolderPathIndex } from '@/lib/folders/queries' +import { loadActiveFolderPathIndex, resolveFolderPathFilter } from '@/lib/folders/queries' import { createTable, deleteTable, @@ -45,18 +45,13 @@ export const listTablesUseCase = defineAuthorizedTableUseCase({ const folderIndex = await loadActiveFolderPathIndex(context.workspaceId, 'table', undefined, { maxRows: MAX_FOLDERS_PER_WORKSPACE, }) - const folderId = - input.folderPath === undefined - ? undefined - : input.folderPath === '/' - ? null - : folderIndex.idByPath.get(input.folderPath) - if (input.folderPath !== undefined && folderId === undefined) { - throw new OrchestrationError('not_found', 'Folder not found') + const folderFilter = resolveFolderPathFilter(folderIndex, input.folderPath) + if (folderFilter.kind === 'noMatch') { + return { tables: [], nextKeys: null, sortBy: input.sortBy, sortOrder: input.sortOrder } } const { tables, nextKeys } = await queryTables(context.workspaceId, { - folderId, + folderId: folderFilter.kind === 'folder' ? folderFilter.folderId : undefined, search: input.search, sortBy: input.sortBy, sortOrder: input.sortOrder, diff --git a/apps/sim/lib/table/application/views.ts b/apps/sim/lib/table/application/views.ts index 1df29d7cc95..af59d187e43 100644 --- a/apps/sim/lib/table/application/views.ts +++ b/apps/sim/lib/table/application/views.ts @@ -38,12 +38,9 @@ export const listTableViewsUseCase = defineAuthorizedTableUseCase({ assertedWorkspaceId: input.workspaceId, }), async execute({ context }) { - const views = await listTableViews( - context.table.id, - (context.table.schema as TableSchema).columns, - context.workspaceId - ) - return { views } + const columns = (context.table.schema as TableSchema).columns + const views = await listTableViews(context.table.id, columns, context.workspaceId) + return { views, columns } }, }) @@ -55,14 +52,10 @@ export const readTableViewUseCase = defineAuthorizedTableUseCase({ assertedWorkspaceId: input.workspaceId, }), async execute({ input, context }) { - const view = await getTableView( - input.viewId, - context.table.id, - (context.table.schema as TableSchema).columns, - context.workspaceId - ) + const columns = (context.table.schema as TableSchema).columns + const view = await getTableView(input.viewId, context.table.id, columns, context.workspaceId) if (!view) throw new OrchestrationError('not_found', 'View not found') - return { view } + return { view, columns } }, }) @@ -82,6 +75,7 @@ export const createTableViewUseCase = defineAuthorizedTableUseCase({ const attribution = resolvePrincipalAttribution(principal, { workspaceBillingOwnerUserId: context.billedAccountUserId, }) + const columns = (context.table.schema as TableSchema).columns try { const view = await createTableView({ tableId: context.table.id, @@ -89,9 +83,10 @@ export const createTableViewUseCase = defineAuthorizedTableUseCase({ name: input.name, config: input.config, userId: attribution.attributedUserId, - columns: (context.table.schema as TableSchema).columns, + columns, + strictRefs: true, }) - return { view, table: context.table } + return { view, table: context.table, columns } } catch (error) { rethrowViewError(error) } @@ -123,11 +118,12 @@ export const updateTableViewUseCase = defineAuthorizedTableUseCase({ assertedWorkspaceId: input.workspaceId, }), async execute({ input, context }) { + const columns = (context.table.schema as TableSchema).columns try { const existing = await getTableView( input.viewId, context.table.id, - (context.table.schema as TableSchema).columns, + columns, context.workspaceId ) if (!existing) throw new OrchestrationError('not_found', 'View not found') @@ -139,12 +135,14 @@ export const updateTableViewUseCase = defineAuthorizedTableUseCase({ config: input.config, configPatch: input.configPatch, isDefault: input.isDefault, - columns: (context.table.schema as TableSchema).columns, + columns, + strictRefs: true, }) if (!view) throw new OrchestrationError('not_found', 'View not found') return { view, table: context.table, + columns, changed: existing.name !== view.name || existing.isDefault !== view.isDefault || diff --git a/apps/sim/lib/table/billing.ts b/apps/sim/lib/table/billing.ts index 824b102d17b..ba931ad0c93 100644 --- a/apps/sim/lib/table/billing.ts +++ b/apps/sim/lib/table/billing.ts @@ -188,6 +188,13 @@ function cacheLimits(workspaceId: string, limits: TablePlanLimits): void { * 400 with the real reason — the message used to have to carry a lowercase * `row limit` token for a substring match to find it, which made the wording * load-bearing. + * + * The canonical record of the two table ceilings disagreeing on status: this one + * answers 400 and the workspace table ceiling answers 403 + * (`WORKSPACE_RESOURCE_LIMIT_REACHED`), where 409 arguably fits both. Both are + * left as shipped — this error is also reachable from the internal surface, + * which is not behind the v2 flag, so unifying them is a deliberate + * cross-surface change rather than part of a v2-only pass. */ export class TableRowLimitError extends OrchestrationError { constructor(readonly limit: number) { diff --git a/apps/sim/lib/table/cell-write.ts b/apps/sim/lib/table/cell-write.ts index 4f3d9f16839..cf2b4e34373 100644 --- a/apps/sim/lib/table/cell-write.ts +++ b/apps/sim/lib/table/cell-write.ts @@ -136,11 +136,13 @@ export async function writeWorkflowGroupState( // ("Open"), which the grid resolves as an option id, finds nothing, and // renders as an empty cell until the next refetch. Coerce a copy: the patch // object itself is identity-compared for the progress writer's retry - // bookkeeping, so it must not be mutated. + // bookkeeping, so it must not be mutated. The `null` policy mirrors what + // `updateRow` persists for a computed write, so the snapshot the client sees + // and the row on disk agree about a block output its column cannot hold. const rawEventOutputs = payload.eventOutputs ?? dataPatch const hasOutputs = rawEventOutputs && Object.keys(rawEventOutputs).length > 0 const eventOutputs = hasOutputs ? { ...rawEventOutputs } : rawEventOutputs - if (hasOutputs && eventOutputs) coerceRowValues(eventOutputs, table.schema) + if (hasOutputs && eventOutputs) coerceRowValues(eventOutputs, table.schema, 'null') const runningBlockIds = payload.executionState.runningBlockIds const blockErrors = payload.executionState.blockErrors void appendTableEvent({ diff --git a/apps/sim/lib/table/column-keys.ts b/apps/sim/lib/table/column-keys.ts index 71565101f4d..fc92ac7e9fb 100644 --- a/apps/sim/lib/table/column-keys.ts +++ b/apps/sim/lib/table/column-keys.ts @@ -18,6 +18,7 @@ import type { SortSpec, TablePredicate, TableSchema, + TableViewConfig, WorkflowGroup, } from '@/lib/table/types' @@ -122,24 +123,72 @@ export function remapGroupColumnRefs( } } -/** `name → id` for translating inbound wire data (v1 / mothership / CSV import). */ -export function buildIdByName(schema: TableSchema): Map { +/** `name → id` over a bare column list, for callers that hold no full schema. */ +export function buildColumnIdByName(columns: readonly ColumnDefinition[]): Map { const map = new Map() - for (const col of schema.columns) map.set(col.name, getColumnId(col)) + for (const col of columns) map.set(col.name, getColumnId(col)) return map } -/** `id → name` for translating outbound wire data (v1 / mothership / CSV export). */ -export function buildNameById(schema: TableSchema): Map { +/** `id → name` over a bare column list, for callers that hold no full schema. */ +export function buildColumnNameById(columns: readonly ColumnDefinition[]): Map { const map = new Map() - for (const col of schema.columns) map.set(getColumnId(col), col.name) + for (const col of columns) map.set(getColumnId(col), col.name) return map } +/** `name → id` for translating inbound wire data (v1 / mothership / CSV import). */ +export function buildIdByName(schema: TableSchema): Map { + return buildColumnIdByName(schema.columns) +} + +/** `id → name` for translating outbound wire data (v1 / mothership / CSV export). */ +export function buildNameById(schema: TableSchema): Map { + return buildColumnNameById(schema.columns) +} + +/** + * Rewrites every column reference in a saved-view config through `refMap`: the + * layout keys (`columnOrder`, `pinnedColumns`, `hiddenColumns`, and the keys of + * `columnWidths`), each `sort[].field`, and each `filter` leaf `field`. + * + * A ref the map does not know is left as-is, so the rewrite is safe in both + * directions and both vocabularies: call it with {@link buildColumnIdByName} to + * store a name-keyed config, and with {@link buildColumnNameById} to present a + * stored one. Pass-through is what keeps an already-id-keyed config (the + * first-party UI), a system row column (`createdAt`), and a ref to a + * since-deleted column intact. The saved-view analogue of + * {@link remapGroupColumnRefs}. + */ +export function remapViewConfigColumnRefs( + config: TableViewConfig, + refMap: ReadonlyMap +): TableViewConfig { + const remap = (ref: string) => refMap.get(ref) ?? ref + const next: TableViewConfig = { ...config } + if (config.columnOrder) next.columnOrder = config.columnOrder.map(remap) + if (config.pinnedColumns) next.pinnedColumns = config.pinnedColumns.map(remap) + if (config.hiddenColumns) next.hiddenColumns = config.hiddenColumns.map(remap) + if (config.columnWidths) { + const widths: Record = {} + for (const [ref, width] of Object.entries(config.columnWidths)) widths[remap(ref)] = width + next.columnWidths = widths + } + if (config.sort) next.sort = sortSpecNamesToIds(config.sort, refMap) + if (config.filter) next.filter = predicateNamesToIds(config.filter, refMap) + return next +} + /** * Remaps a wire row keyed by column **name** to the stored **id** keying. Used * at the name-translating boundaries on the way in. Keys not matching a known - * column are dropped (validation has already run against the schema). + * column are dropped. + * + * Dropping is only safe once someone has established that there are none to + * drop — a key that survives to here unrecognised is a cell the caller asked to + * write and the table never stored. Callers on a surface that can answer the + * client check {@link unknownColumnNames} first; see + * `namedDataToStorage` in `application/rows.ts`. */ export function rowDataNameToId(data: RowData, idByName: Map): RowData { const out: RowData = {} @@ -150,6 +199,18 @@ export function rowDataNameToId(data: RowData, idByName: Map): R return out } +/** + * Wire row keys naming no column in `idByName`, in the order they were sent. + * + * The v2 row surface is keyed by column **name**, so a stored column **id** is + * as unknown here as a typo — it names no key the caller could have read off a + * row, and letting it through would reinstate the silent drop for exactly the + * callers most likely to believe they had written something. + */ +export function unknownColumnNames(data: RowData, idByName: ReadonlyMap): string[] { + return Object.keys(data).filter((name) => !idByName.has(name)) +} + /** * Translates a filter's field names → column ids (recursing into `$or`/`$and`). * Fields with no matching column (e.g. `createdAt`) pass through unchanged. Used diff --git a/apps/sim/lib/table/column-types/date.ts b/apps/sim/lib/table/column-types/date.ts index 11b980eeacd..c78f4bbb082 100644 --- a/apps/sim/lib/table/column-types/date.ts +++ b/apps/sim/lib/table/column-types/date.ts @@ -5,7 +5,6 @@ import { normalizeDateCellValue, storedDateToEditable, } from '@/lib/table/dates' -import type { JsonValue } from '@/lib/table/types' export const dateColumnType: ColumnTypeDefinition = { id: 'date', @@ -27,24 +26,34 @@ export const dateColumnType: ColumnTypeDefinition = { const normalized = normalizeDateCellValue(value) return normalized === null ? { ok: false } : { ok: true, value: normalized } } - // Date instances and epoch numbers may still be out of the representable - // range (>±8.64e15ms) — guard `toISOString()`, which throws RangeError on - // an Invalid Date, so an over-range value degrades to `{ ok: false }` - // rather than crashing the write. - const date = value instanceof Date ? value : typeof value === 'number' ? new Date(value) : null - if (date && !Number.isNaN(date.getTime())) return { ok: true, value: date.toISOString() } + // A bare number is refused wherever there is a caller to tell. It is the + // one input whose meaning cannot be recovered from the value itself: `1600000000` is + // September 2020 read as Unix seconds and 19 January 1970 read as + // milliseconds, both readings are in range, and nothing on the wire says + // which was meant — picking either silently stores a timestamp 50 years off + // under a 200. An ISO-8601 string carries its own unit; that is what a date + // cell takes. `salvage` keeps the milliseconds reading for the machine + // paths, where the only other answer is a blank cell. + // + // A Date instance may still be out of the representable range (>±8.64e15ms), + // so `toISOString()` is guarded — it throws RangeError on an Invalid Date — + // and an over-range value degrades to `{ ok: false }` rather than crashing + // the write. + if (value instanceof Date && !Number.isNaN(value.getTime())) { + return { ok: true, value: value.toISOString() } + } return { ok: false } }, - isCompatibleWith(value) { - // Stricter than `coerce` on purpose. Writing a number into a date cell is a - // deliberate act — the caller means epoch milliseconds. Reinterpreting a - // whole NUMBER column as epochs is not: a column of 1, 5, 42 would become - // three timestamps in January 1970, irreversibly, and a Unix-seconds column - // would land in 1970 rather than the year it means. Refuse the bulk - // conversion; single writes still accept epochs. - if (typeof value === 'number') return false - return dateColumnType.coerce(value as JsonValue, { name: '', type: 'date' }).ok + salvage(value) { + // Milliseconds — the reading every caller got before — restored only where + // refusing would blank the cell rather than answer anyone. A machine + // emitting an epoch for a date column is overwhelmingly producing + // `Date.now()` or another JS timestamp, both milliseconds; guessing wrong + // there costs a wrong year, guessing not at all costs the value. + if (typeof value !== 'number') return { ok: false } + const date = new Date(value) + return Number.isNaN(date.getTime()) ? { ok: false } : { ok: true, value: date.toISOString() } }, validateCell(value, column) { diff --git a/apps/sim/lib/table/column-types/select.ts b/apps/sim/lib/table/column-types/select.ts index 88c3f65ec09..c282d681638 100644 --- a/apps/sim/lib/table/column-types/select.ts +++ b/apps/sim/lib/table/column-types/select.ts @@ -50,9 +50,32 @@ export const selectColumnType: ColumnTypeDefinition = { }, coerce(value, column) { + if (column.multiple) { + // `resolveSelectCellValue` DROPS parts that match no option, which is + // right for a display read of a cell whose option was since deleted, but + // is a silent discard on a write: `["green"]` would resolve to `[]` and + // store an empty cell for a value the caller asked to keep. A write only + // coerces when every part it named resolves — the same rule the single + // branch has always had, and the same rule `isCompatibleWith` uses for + // the bulk conversion. + const options = column.options ?? [] + const parts = splitMultiSelectInput(value) + if (parts.some((part) => resolveSelectOptionId(part, options) === null)) return { ok: false } + } + const resolved = resolveSelectCellValue(value, column) + // A single target that matches no option has nothing safe to store. + return resolved === null ? { ok: false } : { ok: true, value: resolved } + }, + + salvage(value, column) { + // Where the write cannot fail, a multi cell keeps the members that DO + // resolve rather than being blanked: `Alpha, opt_b, ghost` from a CSV or a + // block output stores `[opt_a, opt_b]`, which is what it stored before the + // write path started refusing partial matches. Dropping one unmatched name + // is a smaller loss than erasing the two that matched. A single cell holds + // one option and has nothing partial to keep, so it stays blanked. + if (!column.multiple) return { ok: false } const resolved = resolveSelectCellValue(value, column) - // A multi target always resolves (to `[]` at worst); a single target that - // matches no option has nothing safe to store. return resolved === null ? { ok: false } : { ok: true, value: resolved } }, diff --git a/apps/sim/lib/table/column-types/types.ts b/apps/sim/lib/table/column-types/types.ts index 0de148ac1d0..f481cea0a28 100644 --- a/apps/sim/lib/table/column-types/types.ts +++ b/apps/sim/lib/table/column-types/types.ts @@ -184,6 +184,21 @@ export interface ColumnTypeDefinition { */ isCompatibleWith?(value: unknown, target: ColumnDefinition): boolean + /** + * Last-resort reading of a value {@link coerce} refused, consulted **only** + * where the write may not fail: a machine-produced value on a path with no + * caller to answer with a 400 — a computed/enrichment cell, a CSV import row, + * the cell-write snapshot. The alternative there is not an error, it is a + * blanked cell, so a lossy-but-faithful reading beats losing the value + * outright. + * + * Omitted by types where nothing is salvageable. Because it never runs on a + * caller-supplied write it may be looser than `coerce` without weakening what + * the API refuses — the opposite direction from {@link isCompatibleWith}, + * which may only ever be stricter. + */ + salvage?(value: JsonValue, column: ColumnDefinition): CoerceResult + /** Stored value → display text (grid cell, CSV, clipboard, width measurement). */ formatForDisplay(value: unknown, column: ColumnDefinition): string diff --git a/apps/sim/lib/table/columns/option-locks.test.ts b/apps/sim/lib/table/columns/option-locks.test.ts new file mode 100644 index 00000000000..e8a126b1335 --- /dev/null +++ b/apps/sim/lib/table/columns/option-locks.test.ts @@ -0,0 +1,100 @@ +/** + * @vitest-environment node + * + * `updateColumnOptions` is the one column mutator whose lock gating depends on + * the payload: every call is a schema change, and a call that drops options also + * rewrites cells. Both halves are asserted here because an options-only payload + * is the shape that reaches this mutator from `PATCH .../columns`, and it used + * to be the single column write with no lock assert at all. + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { TableDefinition, TableLocks } from '@/lib/table/types' + +const { mockWithLockedTable } = vi.hoisted(() => ({ mockWithLockedTable: vi.fn() })) + +vi.mock('@/lib/table/service', () => ({ withLockedTable: mockWithLockedTable })) + +import { updateColumnOptions } from '@/lib/table/columns/service' + +const UNLOCKED: TableLocks = { + schemaLocked: false, + insertLocked: false, + updateLocked: false, + deleteLocked: false, +} + +const COLUMN = { + id: 'col_status', + name: 'Status', + type: 'select' as const, + options: [ + { id: 'opt_open', name: 'Open' }, + { id: 'opt_done', name: 'Done' }, + ], +} + +function makeTable(locks: Partial): TableDefinition { + return { + id: 'tbl_1', + name: 'Tasks', + schema: { columns: [COLUMN] }, + rowCount: 3, + maxRows: 100, + workspaceId: 'ws_1', + createdBy: 'user_1', + locks: { ...UNLOCKED, ...locks }, + createdAt: new Date(), + updatedAt: new Date(), + } as unknown as TableDefinition +} + +/** + * Any transaction use is a failure: every assert under test must fire before the + * mutator touches the database, so the stub has no usable surface. + */ +const FORBIDDEN_TRX = new Proxy( + {}, + { + get(_target, prop) { + throw new Error(`Transaction used after a lock assert should have refused: ${String(prop)}`) + }, + } +) + +function runWith(table: TableDefinition, options: Array<{ id: string; name: string }>) { + mockWithLockedTable.mockImplementation( + async (_tableId: string, mutate: (t: TableDefinition, trx: unknown) => Promise) => + mutate(table, FORBIDDEN_TRX) + ) + return updateColumnOptions({ tableId: 'tbl_1', columnName: 'Status', options }, 'req-1') +} + +describe('updateColumnOptions lock gating', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('refuses an options-only edit on a schema-locked table', async () => { + await expect( + runWith(makeTable({ schemaLocked: true }), [ + ...COLUMN.options, + { id: 'opt_new', name: 'New' }, + ]) + ).rejects.toMatchObject({ statusCode: 423, lock: 'schema' }) + }) + + it('refuses an option REMOVAL on a delete-locked table', async () => { + await expect( + runWith(makeTable({ deleteLocked: true }), [{ id: 'opt_open', name: 'Open' }]) + ).rejects.toMatchObject({ statusCode: 423, lock: 'delete' }) + }) + + it('lets a delete-locked table add an option, which clears no cell', async () => { + await expect( + runWith(makeTable({ deleteLocked: true }), [ + ...COLUMN.options, + { id: 'opt_new', name: 'New' }, + ]) + ).rejects.toThrow(/Transaction used/) + }) +}) diff --git a/apps/sim/lib/table/columns/service.ts b/apps/sim/lib/table/columns/service.ts index b0a67b8284d..ea010ad7b4c 100644 --- a/apps/sim/lib/table/columns/service.ts +++ b/apps/sim/lib/table/columns/service.ts @@ -1169,9 +1169,16 @@ export async function updateColumnConstraints( /** * Updates the option set (and optional single/multi mode) of a `select` column - * without changing its type. Existing cell values are left untouched — ids that - * no longer match an option render as a neutral fallback pill until reassigned; - * a single↔multi toggle is reconciled lazily on the next row write. + * without changing its type. + * + * Lock gating is split, because the payload decides how destructive the write + * is. Every call changes the schema, so `assertSchemaMutable` always runs. A + * payload that DROPS options additionally rewrites `user_table_rows.data` (see + * {@link clearRemovedSelectOptions}) — exactly the cell destruction the delete + * lock exists to refuse — so that case escalates to `assertColumnDestructive`. + * Adding, reordering, or renaming options and toggling `multiple` never clear a + * cell (a multi→single toggle refuses rather than truncates), so gating those on + * the delete lock would block a non-destructive edit. */ export async function updateColumnOptions( data: UpdateColumnOptionsData, @@ -1181,6 +1188,8 @@ export async function updateColumnOptions( return withLockedTable( data.tableId, async (table, trx) => { + assertSchemaMutable(table) + const schema = table.schema const columnIndex = schema.columns.findIndex((c) => columnMatchesRef(c, data.columnName)) if (columnIndex === -1) { @@ -1221,6 +1230,9 @@ export async function updateColumnOptions( // migrations; the checks in between need to read the target value. const targetRequired = !!(data.required ?? column.required) + // Dropping an option is a row-data rewrite, not a schema-only edit. + if (removedAny) assertColumnDestructive(table) + if (togglingCardinality || removedAny) { const timeoutMs = scaledStatementTimeoutMs(table.rowCount ?? 0, { baseMs: 60_000, diff --git a/apps/sim/lib/table/constants.ts b/apps/sim/lib/table/constants.ts index 7caf148ff2f..7ab2e7aa098 100644 --- a/apps/sim/lib/table/constants.ts +++ b/apps/sim/lib/table/constants.ts @@ -45,6 +45,29 @@ export const TABLE_LIMITS = { EXPORT_ASYNC_THRESHOLD_ROWS: 10000, /** Cap on the exclusion set ("select all, minus these") sent to an async delete job. */ MAX_EXCLUDE_ROW_IDS: 10000, + /** + * Matching cells one Find returns. The scan fetches one extra to decide + * `truncated`; matches carry no cursor, so a caller past the cap narrows its + * predicate instead of paging. Published in the response contract — a cap a + * caller cannot see is a cap it cannot plan around. + */ + MAX_FIND_MATCHES: 1000, + /** + * Saved views per table. The views list is a single unpaginated full-set read + * (`GET /tables/{id}/views` always answers `nextCursor: null`), so the write + * side is what keeps that set small — the same shape as the folder cap, which + * bounds every reader that materializes a workspace's folder tree. + */ + MAX_VIEWS_PER_TABLE: 100, + /** + * Workflow/enrichment groups per table. Same reason as + * {@link TABLE_LIMITS.MAX_VIEWS_PER_TABLE}: `GET /tables/{id}/groups` is a + * full-set read that always answers `nextCursor: null`, so its published + * "bounded set" claim is only true if the write side keeps it true. The + * indirect bound (every group must add at least one output column, and + * columns are capped) does not survive an update path that adds no columns. + */ + MAX_WORKFLOW_GROUPS_PER_TABLE: 100, } as const /** @@ -72,6 +95,14 @@ export const DEFAULT_TABLE_PLAN_LIMITS = { }, } as const +/** + * Explicit row ids one column run may target. The largest table any plan allows + * is the ceiling: a longer list necessarily names rows that do not exist, and + * the run command rejects it. Declared on the request contract so the refusal + * is a documented bound rather than a surprise from the domain. + */ +export const MAX_RUN_TARGET_ROW_IDS = DEFAULT_TABLE_PLAN_LIMITS.enterprise.maxRowsPerTable + /** * Byte budget at which a **bounded** page (one with an explicit `limit`) is cut * short. Defaults to the 5MB query-result budget and can be overridden with diff --git a/apps/sim/lib/table/errors.ts b/apps/sim/lib/table/errors.ts index 6b80b6a7260..d54cf0b4a69 100644 --- a/apps/sim/lib/table/errors.ts +++ b/apps/sim/lib/table/errors.ts @@ -6,6 +6,7 @@ export type TableQueryErrorCode = | 'TABLE_QUERY_RESULT_TOO_LARGE' | 'INVALID_CURSOR' | 'CURSOR_SORT_CONFLICT' + | 'CURSOR_FILTER_CONFLICT' | 'INVALID_FILTER' | 'INVALID_ORDER' diff --git a/apps/sim/lib/table/import-data.ts b/apps/sim/lib/table/import-data.ts index 593e79a513d..fce5491a03f 100644 --- a/apps/sim/lib/table/import-data.ts +++ b/apps/sim/lib/table/import-data.ts @@ -87,7 +87,10 @@ export async function bulkInsertImportBatch( `Row ${i + 1}: ${sizeValidation.errors.join(', ')}` ) } - const schemaValidation = coerceRowToSchema(data.rows[i], table.schema) + // A CSV cell that does not fit its mapped column blanks that cell rather + // than failing the file: the import has no caller waiting on a 400, and one + // malformed cell in a 100k-row upload must not reject the other 99,999. + const schemaValidation = coerceRowToSchema(data.rows[i], table.schema, 'null') if (!schemaValidation.valid) { throw new OrchestrationError( 'validation', diff --git a/apps/sim/lib/table/orchestration/import-resource.test.ts b/apps/sim/lib/table/orchestration/import-resource.test.ts index 7270f8d1674..32c37475146 100644 --- a/apps/sim/lib/table/orchestration/import-resource.test.ts +++ b/apps/sim/lib/table/orchestration/import-resource.test.ts @@ -10,6 +10,7 @@ const { mockGetUserSettings, mockGetWorkspaceFile, mockGetWorkspaceTableLimits, + mockAssertWorkspaceTableCapacity, mockRunDetached, } = vi.hoisted(() => ({ mockCreateTable: vi.fn(), @@ -18,6 +19,7 @@ const { mockGetUserSettings: vi.fn(), mockGetWorkspaceFile: vi.fn(), mockGetWorkspaceTableLimits: vi.fn(), + mockAssertWorkspaceTableCapacity: vi.fn(), mockRunDetached: vi.fn(), })) @@ -35,6 +37,7 @@ vi.mock('@/lib/core/utils/background', () => ({ runDetached: mockRunDetached })) vi.mock('@/lib/table/billing', () => ({ getWorkspaceTableLimits: mockGetWorkspaceTableLimits })) vi.mock('@/lib/table/import-runner', () => ({ runTableImport: vi.fn() })) vi.mock('@/lib/table/service', () => ({ + assertWorkspaceTableCapacity: mockAssertWorkspaceTableCapacity, createTable: mockCreateTable, getTableById: vi.fn(), })) @@ -47,7 +50,11 @@ vi.mock('@/lib/uploads/upload-session/service', () => ({ vi.mock('@/lib/users/queries', () => ({ getUserSettings: mockGetUserSettings })) import { CSV_DURABLE_MAX_FILE_SIZE_BYTES } from '@/lib/table/import' -import { createAuthorizedTableImportResource } from '@/lib/table/orchestration/import-resource' +import { + createAuthorizedTableImportResource, + findTableImportResource, + getTableImportResource, +} from '@/lib/table/orchestration/import-resource' const WORKSPACE_ID = '6fc7631d-88cd-46f8-9f0a-d4764daef7f8' const SOURCE = { type: 'workspace_file' as const, fileId: 'file-1' } @@ -131,6 +138,7 @@ describe('createAuthorizedTableImportResource workspace file size', () => { describe('createAuthorizedTableImportResource upload size', () => { beforeEach(() => { vi.clearAllMocks() + mockGetWorkspaceTableLimits.mockResolvedValue({ maxTables: 100, maxRowsPerTable: 10_000 }) mockCreateUploadSession.mockResolvedValue({ id: 'import-1', userId: 'user-1', @@ -179,3 +187,140 @@ describe('createAuthorizedTableImportResource upload size', () => { expect(mockCreateUploadSession).not.toHaveBeenCalled() }) }) + +/** + * `type = 'import'` rows are written by the first-party CSV paths too, without + * the v2 payload. Those ids are reachable from a v2 read — `GET /tables/{id}` + * hands the caller the running job's id — so an unreadable job must answer 404, + * not an unclassified error the v2 policy can only render as a 500. + */ +describe('findTableImportResource on a job that is not a v2 import resource', () => { + const IMPORT_ID = 'job-1' + + function job(overrides: Record) { + return { + id: IMPORT_ID, + tableId: 'table-1', + workspaceId: WORKSPACE_ID, + type: 'import', + status: 'running', + payload: null, + rowsProcessed: 0, + error: null, + startedAt: new Date('2026-08-04T12:00:00.000Z'), + updatedAt: new Date('2026-08-04T12:00:00.000Z'), + completedAt: null, + ...overrides, + } + } + + const PAYLOAD = { + kind: 'table_import', + userId: 'user-1', + source: SOURCE, + target: TARGET, + options: {}, + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + it('reads a first-party import job with a null payload as absent', async () => { + mockDbLimit.mockResolvedValue([job({})]) + + await expect(findTableImportResource({ importId: IMPORT_ID })).resolves.toBeNull() + await expect(getTableImportResource({ importId: IMPORT_ID })).rejects.toMatchObject({ + code: 'not_found', + }) + }) + + it('reads a job in a status the resource cannot represent as absent', async () => { + mockDbLimit.mockResolvedValue([job({ payload: PAYLOAD, status: 'queued' })]) + + await expect(findTableImportResource({ importId: IMPORT_ID })).resolves.toBeNull() + await expect(getTableImportResource({ importId: IMPORT_ID })).rejects.toMatchObject({ + code: 'not_found', + }) + }) + + it('still reads a well-formed v2 import job', async () => { + mockDbLimit.mockResolvedValue([job({ payload: PAYLOAD })]) + + await expect(findTableImportResource({ importId: IMPORT_ID })).resolves.toMatchObject({ + id: IMPORT_ID, + status: 'running', + source: SOURCE, + target: TARGET, + }) + }) +}) + +/** + * The table ceiling was enforced only by `createTable`, which for an + * upload-backed import does not run until the CSV has already been transferred. + * A workspace at its limit got a 201 and a presigned PUT for up to 5 GiB, and + * learned it was refused only at `complete` — by which point the bytes were paid + * for and an orphaned object was sitting in storage. + */ +describe('createAuthorizedTableImportResource table quota', () => { + const limitReached = Object.assign(new Error('Workspace has reached maximum table limit (5)'), { + code: 'WORKSPACE_RESOURCE_LIMIT_REACHED', + }) + + beforeEach(() => { + vi.clearAllMocks() + mockGetWorkspaceTableLimits.mockResolvedValue({ maxTables: 5, maxRowsPerTable: 10_000 }) + mockGetUserSettings.mockResolvedValue({ timezone: 'UTC' }) + mockCreateUploadSession.mockResolvedValue({ + id: 'import-1', + userId: 'user-1', + status: 'uploading', + uploadToken: 'signed-token', + transfer: { method: 'put', url: 'https://storage.example/upload', headers: {} }, + createdAt: new Date('2026-08-04T12:00:00.000Z'), + updatedAt: new Date('2026-08-04T12:00:00.000Z'), + completedAt: null, + }) + }) + + it('refuses an upload-backed import for a new table before handing out a transfer', async () => { + mockAssertWorkspaceTableCapacity.mockRejectedValue(limitReached) + + await expect( + createImport({ + workspaceId: WORKSPACE_ID, + source: { type: 'upload', name: 'data.csv', contentType: 'text/csv', size: 1024 }, + target: TARGET, + }) + ).rejects.toThrow(/maximum table limit/) + + expect(mockAssertWorkspaceTableCapacity).toHaveBeenCalledWith(WORKSPACE_ID, 5) + expect(mockCreateUploadSession).not.toHaveBeenCalled() + }) + + it('creates the session when the workspace still has room', async () => { + mockAssertWorkspaceTableCapacity.mockResolvedValue(undefined) + + await createImport({ + workspaceId: WORKSPACE_ID, + source: { type: 'upload', name: 'data.csv', contentType: 'text/csv', size: 1024 }, + target: TARGET, + }) + + expect(mockCreateUploadSession).toHaveBeenCalledOnce() + }) + + it('does not check the table ceiling when importing into an existing table', async () => { + mockAssertWorkspaceTableCapacity.mockResolvedValue(undefined) + mockGetWorkspaceFile.mockResolvedValue(workspaceFile(1024)) + + await createImport({ + workspaceId: WORKSPACE_ID, + source: { type: 'upload', name: 'data.csv', contentType: 'text/csv', size: 1024 }, + target: { type: 'existing', tableId: 'table-1', mode: 'append' }, + }).catch(() => undefined) + + expect(mockAssertWorkspaceTableCapacity).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/table/orchestration/import-resource.ts b/apps/sim/lib/table/orchestration/import-resource.ts index e49817440c3..71bf8370eca 100644 --- a/apps/sim/lib/table/orchestration/import-resource.ts +++ b/apps/sim/lib/table/orchestration/import-resource.ts @@ -29,7 +29,7 @@ import { import { runTableImport, type TableImportPayload } from '@/lib/table/import-runner' import { markTableJobRunningInWorkspace } from '@/lib/table/jobs/service' import { assertRowDelete, assertRowInsert } from '@/lib/table/mutation-locks' -import { createTable, getTableById } from '@/lib/table/service' +import { assertWorkspaceTableCapacity, createTable, getTableById } from '@/lib/table/service' import type { TableImportJobPayload } from '@/lib/table/types' import { getWorkspaceFile, type WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace' import { @@ -183,6 +183,15 @@ export async function getPrincipalTableImportUpload(params: { return upload } +/** + * The import resource an in-flight upload session stands for, without touching + * it. Callers that also mutate the session (abort, complete) build their + * resource from the post-mutation record instead. + */ +export function tableImportResourceFromUpload(upload: UploadSessionRecord): TableImportResource { + return resourceFromUpload(upload, tableImportBodyFromUpload(upload)) +} + export async function abortAuthorizedTableImportUpload( upload: UploadSessionRecord, principal: Principal @@ -201,6 +210,17 @@ export async function getTableImportResource(params: { return record } +/** + * The `table_jobs` row for an import id, or `null` when there is no import + * resource behind that id. + * + * `type = 'import'` is NOT sufficient to make a job one of these resources: the + * first-party CSV paths write import jobs with a null payload, and a job may + * carry a lifecycle status this resource has no public state for. Neither is a + * server fault — the id simply does not name a readable import — so both read + * back as `null` and surface as the 404 they are, rather than throwing an + * unclassified error that the v2 error policy can only render as a 500. + */ export async function findTableImportResource(params: { importId: string assertedWorkspaceId?: string @@ -220,15 +240,18 @@ export async function findTableImportResource(params: { .limit(1) if (!job) return null const payload = parseImportJobPayload(job.payload) + if (!payload) return null + const status = tableImportStatus(job.status) + if (!status) return null return { id: job.id, workspaceId: job.workspaceId, userId: payload.userId, - source: v2TableImportSourceSchema.parse(payload.source), - target: v2TableImportTargetSchema.parse(payload.target), + source: payload.source, + target: payload.target, options: payload.options, tableId: job.tableId, - status: tableImportStatus(job.status), + status, rowsProcessed: job.rowsProcessed, error: job.error, createdAt: job.startedAt, @@ -469,10 +492,21 @@ function importOptions(body: V2CreateTableImportBody): TableImportJobPayload['op } } -function parseImportJobPayload(payload: unknown): TableImportJobPayload { - if (!payload || typeof payload !== 'object' || Array.isArray(payload)) { - throw new Error('Table import job is missing its payload') - } +interface ParsedTableImportPayload { + userId: string + source: V2TableImportSource + target: V2TableImportTarget + options: TableImportJobPayload['options'] +} + +/** + * Reads a `table_jobs.payload` as an import-resource payload, or `null` when it + * is not one. A null payload is the normal shape for the first-party CSV import + * paths, which write `type = 'import'` jobs without one, so failing to parse is + * an ordinary "not this resource" answer rather than an error condition. + */ +function parseImportJobPayload(payload: unknown): ParsedTableImportPayload | null { + if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return null const candidate = payload as Partial if ( candidate.kind !== 'table_import' || @@ -480,13 +514,30 @@ function parseImportJobPayload(payload: unknown): TableImportJobPayload { !candidate.options || typeof candidate.options !== 'object' ) { - throw new Error('Table import job has an invalid payload') + return null + } + const source = v2TableImportSourceSchema.safeParse(candidate.source) + const target = v2TableImportTargetSchema.safeParse(candidate.target) + if (!source.success || !target.success) return null + return { + userId: candidate.userId, + source: source.data, + target: target.data, + options: candidate.options, } - v2TableImportSourceSchema.parse(candidate.source) - v2TableImportTargetSchema.parse(candidate.target) - return candidate as TableImportJobPayload } +/** + * Everything about a target that can be refused before the CSV moves. + * + * Runs at session creation AND again when the upload completes. The table + * ceiling in particular has to be checked in both places and for different + * reasons: at completion because the authoritative gate lives in `createTable`'s + * transaction and the quota can be reached while a large file uploads, and at + * creation because otherwise the only answer a full workspace ever gets is a 403 + * after it has already transferred up to 5 GiB to a presigned URL — leaving an + * orphaned object behind for a table that was never creatable. + */ async function validateTarget( workspaceId: string, target: V2TableImportTarget, @@ -496,6 +547,8 @@ async function validateTarget( if (resolvedFolderId && !(await findActiveFolder(resolvedFolderId, workspaceId, 'table'))) { throw new OrchestrationError('not_found', 'Folder not found in this workspace') } + const { maxTables } = await getWorkspaceTableLimits(workspaceId) + await assertWorkspaceTableCapacity(workspaceId, maxTables) return } await requireExistingTarget(workspaceId, target) @@ -582,9 +635,15 @@ function assertCsvFileName(fileName: string): void { } } -function tableImportStatus(status: string): TableImportStatus { +/** + * The public lifecycle state for a job status, or `null` when the job is in a + * state this resource cannot represent. `table_jobs.status` is an unconstrained + * text column shared by every job kind, so a value outside the four documented + * import states means "no readable import here" — a 404 — not a server fault. + */ +function tableImportStatus(status: string): TableImportStatus | null { if (status !== 'running' && status !== 'ready' && status !== 'failed' && status !== 'canceled') { - throw new Error(`Invalid table import job status: ${status}`) + return null } return status } diff --git a/apps/sim/lib/table/query-builder/predicate.ts b/apps/sim/lib/table/query-builder/predicate.ts index d3e730b73d0..dbaea52fcbc 100644 --- a/apps/sim/lib/table/query-builder/predicate.ts +++ b/apps/sim/lib/table/query-builder/predicate.ts @@ -3,8 +3,11 @@ import type { TablePredicate, TablePredicateInput } from '@/lib/table/types' /** Max members in one `all`/`any` group. */ export const MAX_PREDICATE_GROUP_SIZE = 100 -const MAX_PREDICATE_DEPTH = 10 -const MAX_PREDICATE_NODES = 500 +/** Max nesting levels; the root group counts as level 1. */ +export const MAX_PREDICATE_DEPTH = 10 + +/** Max total nodes — groups plus leaves — in one predicate tree. */ +export const MAX_PREDICATE_NODES = 500 /** * Returns the predicate size-limit violation for an untrusted tree, if any. diff --git a/apps/sim/lib/table/query-builder/validate.ts b/apps/sim/lib/table/query-builder/validate.ts index 39422ac1f05..264e082d9c3 100644 --- a/apps/sim/lib/table/query-builder/validate.ts +++ b/apps/sim/lib/table/query-builder/validate.ts @@ -47,6 +47,15 @@ const SYSTEM_COLUMN_TYPES: ReadonlyArray<[string, ColumnType]> = [ ['id', 'string'], ] +/** + * The system column names as a membership set, for the surfaces that decide + * whether a stored field still refers to something real — a check that would + * otherwise drop `createdAt` for the crime of not being in `schema.columns`. + */ +export const SYSTEM_COLUMN_FIELDS: ReadonlySet = new Set( + SYSTEM_COLUMN_TYPES.map(([name]) => name) +) + function buildTypeByName(columns: ColumnDefinition[]): Map { const typeByName = new Map(columns.map((c) => [c.name, c.type])) for (const [name, type] of SYSTEM_COLUMN_TYPES) typeByName.set(name, type) @@ -237,3 +246,20 @@ export function validateStoragePredicate( for (const [name, type] of SYSTEM_COLUMN_TYPES) typeById.set(name, type) validateNode(predicate, typeById) } + +/** + * Validates a STORAGE-keyed sort spec — fields are column ids (plus the system + * columns, which keep their names). The sort counterpart of + * {@link validateStoragePredicate}, for the same reason: after wire translation + * an unresolved field is a typo, and a typo must be refused rather than silently + * ordering by nothing. + */ +export function validateStorageSortSpec(spec: SortSpec, columns: ColumnDefinition[]): void { + const ids = new Set(columns.map(getColumnId)) + for (const { field } of spec) { + validateFieldName(field) + if (!ids.has(field) && !SYSTEM_COLUMN_FIELDS.has(field)) { + throw new TableQueryValidationError(`Unknown sort column "${field}"`, 'INVALID_ORDER') + } + } +} diff --git a/apps/sim/lib/table/rows/__tests__/ordering-anchor.test.ts b/apps/sim/lib/table/rows/__tests__/ordering-anchor.test.ts new file mode 100644 index 00000000000..0818d09a9d3 --- /dev/null +++ b/apps/sim/lib/table/rows/__tests__/ordering-anchor.test.ts @@ -0,0 +1,56 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import type { DbTransaction } from '@/lib/table/planner' +import { TableRowNotFoundError } from '@/lib/table/rows/errors' +import { resolveInsertByNeighbor } from '@/lib/table/rows/ordering' + +/** + * A transaction whose anchor lookup finds nothing — the shape a caller produces + * by naming a row id that does not exist in the table. + */ +function trxWithNoAnchor(): DbTransaction { + const chain = { + select: () => chain, + from: () => chain, + where: () => chain, + orderBy: () => chain, + limit: async () => [], + } + return chain as unknown as DbTransaction +} + +/** + * `afterRowId`/`beforeRowId` name a neighbor the caller can get wrong — a stale + * view, a concurrent delete, or a typo. The anchor lookup answered a miss with a + * bare `Error`, which no error policy classifies, so `POST /tables/{id}/rows` + * returned `500 INTERNAL_ERROR` for what is plainly a bad request. + */ +describe('resolveInsertByNeighbor > unknown anchor row', () => { + it('classifies a missing afterRowId as not found, not an internal fault', async () => { + const error = await resolveInsertByNeighbor( + trxWithNoAnchor(), + 'table-1', + 'row_doesnotexist' + ).catch((thrown: unknown) => thrown) + + expect(error).toBeInstanceOf(TableRowNotFoundError) + expect(error).toBeInstanceOf(OrchestrationError) + expect((error as OrchestrationError).code).toBe('not_found') + expect((error as Error).message).toContain('row_doesnotexist') + }) + + it('classifies a missing beforeRowId the same way', async () => { + const error = await resolveInsertByNeighbor( + trxWithNoAnchor(), + 'table-1', + undefined, + 'row_alsomissing' + ).catch((thrown: unknown) => thrown) + + expect(error).toBeInstanceOf(TableRowNotFoundError) + expect((error as OrchestrationError).code).toBe('not_found') + }) +}) diff --git a/apps/sim/lib/table/rows/cursor.test.ts b/apps/sim/lib/table/rows/cursor.test.ts index bae39a35c66..4f4d1439695 100644 --- a/apps/sim/lib/table/rows/cursor.test.ts +++ b/apps/sim/lib/table/rows/cursor.test.ts @@ -1,21 +1,25 @@ /** * @vitest-environment node * - * Opaque cursor encode/decode and the cursor↔sort binding. A cursor encodes a - * position in one specific ordering; replaying it under any other ordering - * silently pages the wrong sequence, so binding violations must throw - * CURSOR_SORT_CONFLICT rather than return wrong rows. + * Opaque cursor encode/decode and the cursor↔query binding. A cursor encodes a + * position in one specific ordering of one specific row set; replaying it under + * any other ordering or filter silently pages the wrong sequence, so binding + * violations must throw rather than return wrong rows. */ import { describe, expect, it } from 'vitest' import { TableQueryValidationError } from '@/lib/table/errors' import { - assertCursorSortBinding, + assertCursorQueryBinding, + canonicalFilterKey, canonicalSortKey, decodeCursor, encodeCursor, } from '@/lib/table/rows/cursor' +import type { TablePredicate } from '@/lib/table/types' const ROW = { id: 'row_1', orderKey: 'a1' } +const ACTIVE: TablePredicate = { all: [{ field: 'status', op: 'eq', value: 'active' }] } +const ARCHIVED: TablePredicate = { all: [{ field: 'status', op: 'eq', value: 'archived' }] } describe('cursor↔sort binding (bugbot round 2)', () => { it('stamps an offset cursor with the sort it was minted under', () => { @@ -32,15 +36,15 @@ describe('cursor↔sort binding (bugbot round 2)', () => { it('accepts replay under the identical sort', () => { const decoded = { offset: 100, sortKey: canonicalSortKey({ col_a: 'desc' }) } - expect(() => assertCursorSortBinding(decoded, { col_a: 'desc' })).not.toThrow() + expect(() => assertCursorQueryBinding(decoded, { sort: { col_a: 'desc' } })).not.toThrow() }) it('rejects replay under a DIFFERENT sort', () => { const decoded = { offset: 100, sortKey: canonicalSortKey({ col_a: 'desc' }) } for (const sort of [{ col_a: 'asc' as const }, { col_b: 'desc' as const }, undefined]) { - expect(() => assertCursorSortBinding(decoded, sort)).toThrow(TableQueryValidationError) + expect(() => assertCursorQueryBinding(decoded, { sort })).toThrow(TableQueryValidationError) try { - assertCursorSortBinding(decoded, sort) + assertCursorQueryBinding(decoded, { sort }) } catch (e) { expect((e as TableQueryValidationError).code).toBe('CURSOR_SORT_CONFLICT') } @@ -55,10 +59,10 @@ describe('cursor↔sort binding (bugbot round 2)', () => { }) const decoded = decodeCursor(token) expect(decoded.sortKey).toBeUndefined() - expect(() => assertCursorSortBinding(decoded, { col_a: 'asc' })).toThrow( + expect(() => assertCursorQueryBinding(decoded, { sort: { col_a: 'asc' } })).toThrow( /different sort|sorted query/ ) - expect(() => assertCursorSortBinding(decoded, undefined)).not.toThrow() + expect(() => assertCursorQueryBinding(decoded, {})).not.toThrow() }) it('keyset cursors stay default-order only and never carry a sort stamp', () => { @@ -66,8 +70,10 @@ describe('cursor↔sort binding (bugbot round 2)', () => { const decoded = decodeCursor(token) expect(decoded.after).toEqual({ orderKey: 'a1', id: 'row_1' }) expect(decoded.sortKey).toBeUndefined() - expect(() => assertCursorSortBinding(decoded, { col_a: 'asc' })).toThrow(/sorted query/) - expect(() => assertCursorSortBinding(decoded, undefined)).not.toThrow() + expect(() => assertCursorQueryBinding(decoded, { sort: { col_a: 'asc' } })).toThrow( + /sorted query/ + ) + expect(() => assertCursorQueryBinding(decoded, {})).not.toThrow() }) it('sort key order is significant (priority is part of the identity)', () => { @@ -76,3 +82,163 @@ describe('cursor↔sort binding (bugbot round 2)', () => { ) }) }) + +describe('cursor↔filter binding', () => { + it('stamps a sorted page with the predicate it was minted under', () => { + const decoded = decodeCursor( + encodeCursor({ + lastRow: { id: 'row_1', orderKey: null }, + keysetValid: false, + nextOffset: 100, + sort: { col_a: 'desc' }, + predicate: ACTIVE, + }) + ) + expect(decoded.offset).toBe(100) + expect(decoded.filterKey).toBe(canonicalFilterKey({ predicate: ACTIVE })) + }) + + it('rejects replaying a page-2 offset against a DIFFERENT predicate', () => { + const decoded = decodeCursor( + encodeCursor({ + lastRow: { id: 'row_1', orderKey: null }, + keysetValid: false, + nextOffset: 100, + sort: { name: 'asc' }, + predicate: ACTIVE, + }) + ) + + expect(() => + assertCursorQueryBinding(decoded, { sort: { name: 'asc' }, predicate: ARCHIVED }) + ).toThrow(TableQueryValidationError) + try { + assertCursorQueryBinding(decoded, { sort: { name: 'asc' }, predicate: ARCHIVED }) + } catch (e) { + expect((e as TableQueryValidationError).code).toBe('CURSOR_FILTER_CONFLICT') + } + expect(() => + assertCursorQueryBinding(decoded, { sort: { name: 'asc' }, predicate: ACTIVE }) + ).not.toThrow() + }) + + it('rejects dropping the predicate from a filtered offset cursor', () => { + const decoded = decodeCursor( + encodeCursor({ + lastRow: { id: 'row_1', orderKey: null }, + keysetValid: false, + nextOffset: 100, + predicate: ACTIVE, + }) + ) + expect(() => assertCursorQueryBinding(decoded, {})).toThrow(/different filter/) + }) + + it('rejects adding a predicate to an unfiltered offset cursor', () => { + const decoded = decodeCursor( + encodeCursor({ + lastRow: { id: 'row_1', orderKey: null }, + keysetValid: false, + nextOffset: 100, + }) + ) + expect(decoded.filterKey).toBeUndefined() + expect(() => assertCursorQueryBinding(decoded, { predicate: ACTIVE })).toThrow( + /different filter/ + ) + }) + + it('binds the compound cursor, whose offset also counts filtered rows', () => { + const decoded = decodeCursor( + encodeCursor({ + lastRow: { id: 'row_9', orderKey: null }, + keysetValid: true, + nextOffset: 40, + seekBase: { anchor: { orderKey: 'a1', id: 'row_1' }, offsetFromAnchor: 12 }, + predicate: ACTIVE, + }) + ) + expect(decoded.after).toEqual({ orderKey: 'a1', id: 'row_1' }) + expect(decoded.offset).toBe(12) + expect(() => assertCursorQueryBinding(decoded, { predicate: ARCHIVED })).toThrow( + /different filter/ + ) + expect(() => assertCursorQueryBinding(decoded, { predicate: ACTIVE })).not.toThrow() + }) + + it('binds a pure keyset cursor to its filter too', () => { + /** + * A keyset position is absolute in `(order_key, id)`, which is why this was + * once left unbound. Absolute ordering is not the same as completeness: + * replaying the cursor under a wider filter silently omits every match that + * sorts before it, and the caller reads the short page as the end of the + * sequence rather than as an error. + */ + const decoded = decodeCursor( + encodeCursor({ lastRow: ROW, keysetValid: true, nextOffset: 10, predicate: ACTIVE }) + ) + expect(decoded.filterKey).toBe(canonicalFilterKey({ predicate: ACTIVE })) + expect(() => assertCursorQueryBinding(decoded, { predicate: ACTIVE })).not.toThrow() + expect(() => assertCursorQueryBinding(decoded, {})).toThrow(/different filter/i) + }) + + it('fingerprints structurally equal predicates identically, key order aside', () => { + expect( + canonicalFilterKey({ + predicate: { all: [{ op: 'eq', field: 'status', value: 'active' }] } as TablePredicate, + }) + ).toBe(canonicalFilterKey({ predicate: ACTIVE })) + expect(canonicalFilterKey({})).toBeUndefined() + expect(canonicalFilterKey({ filter: {} })).toBeUndefined() + }) +}) + +/** + * The filter stamp is additive, and the payload version is deliberately not + * bumped for it (see `CURSOR_VERSION`). These pin what a token minted by the + * previous deploy does when it is replayed after this one. + */ +describe('tokens minted before the filter stamp', () => { + function legacyToken(payload: Record): string { + return Buffer.from(JSON.stringify({ ...payload, v: 1 })).toString('base64url') + } + + it('still decodes, and still resumes an unfiltered read', () => { + const decoded = decodeCursor(legacyToken({ k: 'a1', i: 'row_1' })) + expect(decoded.after).toEqual({ orderKey: 'a1', id: 'row_1' }) + expect(decoded.filterKey).toBeUndefined() + expect(() => assertCursorQueryBinding(decoded, {})).not.toThrow() + }) + + it('fails a filtered read with the filter conflict, not an unreadable cursor', () => { + const decoded = decodeCursor(legacyToken({ o: 100 })) + expect(() => assertCursorQueryBinding(decoded, { predicate: ACTIVE })).toThrow( + TableQueryValidationError + ) + expect(() => assertCursorQueryBinding(decoded, { predicate: ACTIVE })).toThrow( + /Restart paging without the cursor/ + ) + /** + * The code, not just the wording, is what a bumped `CURSOR_VERSION` would + * cost: every in-flight token would fail `INVALID_CURSOR` at decode instead, + * including the unfiltered ones that resume fine today. + */ + try { + assertCursorQueryBinding(decoded, { predicate: ACTIVE }) + expect.unreachable('a re-filtered replay must be refused') + } catch (e) { + expect((e as TableQueryValidationError).code).toBe('CURSOR_FILTER_CONFLICT') + } + }) + + /** + * The version a token minted today carries. Pinned so a bump is a deliberate + * edit here rather than a silent one that strands every cursor a running + * deploy already handed out. + */ + it('mints tokens at the version the previous deploy could already read', () => { + const token = encodeCursor({ lastRow: ROW, keysetValid: true, nextOffset: 10 }) + + expect(JSON.parse(Buffer.from(token, 'base64url').toString('utf8')).v).toBe(1) + }) +}) diff --git a/apps/sim/lib/table/rows/cursor.ts b/apps/sim/lib/table/rows/cursor.ts index 0d2ab4ab206..1350fbaa054 100644 --- a/apps/sim/lib/table/rows/cursor.ts +++ b/apps/sim/lib/table/rows/cursor.ts @@ -13,21 +13,46 @@ * the last keyed anchor, then OFFSET past the unkeyed rows consumed after it. * This only resolves correctly because the seek admits `order_key IS NULL` * rows; a bare `(order_key, id) > (…)` excludes them and strands the tail. + * + * Every shape is stamped with the query it was produced under — see + * {@link assertCursorQueryBinding}. */ +import { canonicalJson, fingerprint } from '@/lib/api/cursor-binding' import { TableQueryValidationError } from '@/lib/table/errors' -import type { Sort, TableRow, TableRowsCursor } from '@/lib/table/types' +import type { Filter, Sort, TablePredicate, TableRow, TableRowsCursor } from '@/lib/table/types' /** * Cursor payload version. Every encoded token carries `v`; decode rejects any * other value so a future shape change (new `v`) fails cleanly instead of being * misread against the current field set. + * + * Deliberately NOT bumped for the filter stamp. Adding `p` is additive: a token + * minted before it still decodes, and an unfiltered read — where the stamp is + * absent on both sides — resumes normally across the deploy. A pre-stamp token + * replayed against a filtered query is the only one that fails, and it fails + * with `CURSOR_FILTER_CONFLICT` and "Restart paging without the cursor", which + * is both accurate and actionable. Bumping the version would trade that for a + * generic unreadable-cursor 400 on EVERY in-flight token, including the + * unfiltered ones that would otherwise have kept working. */ const CURSOR_VERSION = 1 type CursorBody = { k: string; i: string } | { o: number } | { k: string; i: string; o: number } -type SortBinding = { s?: string } -type CursorPayload = CursorBody & SortBinding & { v: number } +type QueryBinding = { s?: string; p?: string } +type CursorPayload = CursorBody & QueryBinding & { v: number } + +/** + * The query state a page was produced under. Every shape is bound to the + * filters; only a shape carrying an offset is additionally bound to the sort. + */ +export interface CursorQueryScope { + sort?: Sort | null + /** v2 predicate tree, in the same storage form the query runs under. */ + predicate?: TablePredicate | null + /** Legacy `$`-operator filter, for the surfaces that still send one. */ + filter?: Filter | null +} /** * Canonical fingerprint of a sort for cursor binding. Entry order is the sort @@ -41,19 +66,35 @@ export function canonicalSortKey(sort: Sort | null | undefined): string | undefi } /** - * A cursor is only valid for the exact query shape it was minted under: - * keyset/compound cursors encode a position in the DEFAULT `(order_key, id)` - * order, and an offset cursor from a sorted view encodes a position in THAT - * sort. Replaying either against a different ordering silently pages the wrong - * sequence — rows skipped or duplicated with no error. Throws - * `CURSOR_SORT_CONFLICT` so callers restart paging without the cursor. + * Fingerprint of the filters a page was produced under, or `undefined` for an + * unfiltered read. Canonicalized and hashed through `lib/api/cursor-binding`, + * the same module the v2 list codecs bind through, so a filter stamp means the + * same thing on every paginated surface. */ -export function assertCursorSortBinding( - decoded: { after?: TableRowsCursor; offset?: number; sortKey?: string }, - sort: Sort | null | undefined +export function canonicalFilterKey( + scope: Pick +): string | undefined { + const predicate = scope.predicate ?? undefined + const filter = scope.filter && Object.keys(scope.filter).length > 0 ? scope.filter : undefined + if (!predicate && !filter) return undefined + return fingerprint(canonicalJson(predicate ? { predicate } : { filter })) +} + +/** + * A cursor is only valid for the exact query shape it was minted under — + * `lib/api/cursor-binding.ts` documents why. Here that means two distinct + * refusals: a keyset or compound cursor encodes a position in the DEFAULT + * `(order_key, id)` order and an offset cursor encodes a position in THAT sort, + * so an ordering mismatch throws `CURSOR_SORT_CONFLICT`; a filter mismatch + * throws `CURSOR_FILTER_CONFLICT` and applies to EVERY shape, since an absolute + * `(order_key, id)` position is still incomplete under a wider filter. + */ +export function assertCursorQueryBinding( + decoded: { after?: TableRowsCursor; offset?: number; sortKey?: string; filterKey?: string }, + scope: CursorQueryScope ): void { - const requested = canonicalSortKey(sort) - if (decoded.after && requested !== undefined) { + const requestedSort = canonicalSortKey(scope.sort) + if (decoded.after && requestedSort !== undefined) { throw new TableQueryValidationError( 'Cursor is not valid for a sorted query. Restart paging without the cursor.', 'CURSOR_SORT_CONFLICT' @@ -62,13 +103,19 @@ export function assertCursorSortBinding( if ( decoded.after === undefined && decoded.offset !== undefined && - decoded.sortKey !== requested + decoded.sortKey !== requestedSort ) { throw new TableQueryValidationError( 'Cursor was created under a different sort. Restart paging without the cursor.', 'CURSOR_SORT_CONFLICT' ) } + if (decoded.filterKey !== canonicalFilterKey(scope)) { + throw new TableQueryValidationError( + 'Cursor was created under a different filter. Restart paging without the cursor.', + 'CURSOR_FILTER_CONFLICT' + ) + } } function invalidCursor(): never { @@ -102,6 +149,10 @@ export function encodeCursor(args: { seekBase?: { anchor: TableRowsCursor; offsetFromAnchor: number } /** The sort the page was produced under — stamps offset cursors so they can't be replayed against a different ordering. */ sort?: Sort | null + /** The predicate the page was produced under — stamps any offset so it can't be replayed against a different row set. */ + predicate?: TablePredicate | null + /** The legacy filter the page was produced under, for surfaces that send one instead of a predicate. */ + filter?: Filter | null }): string { let body: CursorBody if (args.keysetValid && args.lastRow.orderKey) { @@ -120,11 +171,15 @@ export function encodeCursor(args: { body = { o: args.nextOffset } } const sortKey = canonicalSortKey(args.sort) + const filterKey = canonicalFilterKey(args) const payload: CursorPayload = { ...body, // Only the pure-offset shape can exist under a custom sort; keyset and // compound shapes are default-order by construction and carry no binding. ...('k' in body || sortKey === undefined ? {} : { s: sortKey }), + // Every offset — whole-view or offset-from-anchor — counts filtered rows, so + // both the pure-offset and compound shapes carry the filter stamp. + ...(filterKey !== undefined ? { p: filterKey } : {}), v: CURSOR_VERSION, } return toBase64Url(JSON.stringify(payload)) @@ -136,6 +191,8 @@ export function decodeCursor(token: string): { offset?: number /** Sort fingerprint an offset cursor was minted under; absent = default order. */ sortKey?: string + /** Filter fingerprint an offset cursor was minted under; absent = unfiltered. */ + filterKey?: string } { let payload: unknown try { @@ -152,19 +209,23 @@ export function decodeCursor(token: string): { const hasKeyset = typeof record.k === 'string' && typeof record.i === 'string' const hasOffset = typeof record.o === 'number' && Number.isInteger(record.o) && record.o >= 0 + const filterBinding = typeof record.p === 'string' ? { filterKey: record.p } : {} + if (hasKeyset && hasOffset) { return { after: { orderKey: record.k as string, id: record.i as string }, offset: record.o as number, + ...filterBinding, } } if (hasKeyset) { - return { after: { orderKey: record.k as string, id: record.i as string } } + return { after: { orderKey: record.k as string, id: record.i as string }, ...filterBinding } } if (hasOffset) { return { offset: record.o as number, ...(typeof record.s === 'string' ? { sortKey: record.s } : {}), + ...filterBinding, } } invalidCursor() diff --git a/apps/sim/lib/table/rows/errors.ts b/apps/sim/lib/table/rows/errors.ts index 480a42db148..c02e48fdb61 100644 --- a/apps/sim/lib/table/rows/errors.ts +++ b/apps/sim/lib/table/rows/errors.ts @@ -1,9 +1,14 @@ import { OrchestrationError } from '@/lib/core/orchestration/types' -/** Raised when a row disappears before an operation can mutate it. */ +/** + * Raised when a row an operation names is not in the table — either the target + * row disappeared before the mutation, or the caller named an `afterRowId` / + * `beforeRowId` anchor that does not exist. `rowId` is the caller's own input, + * so echoing it is the difference between a fixable error and a guess. + */ export class TableRowNotFoundError extends OrchestrationError { - constructor() { - super('not_found', 'Row not found') + constructor(rowId?: string) { + super('not_found', rowId ? `Row not found: ${rowId}` : 'Row not found') this.name = 'TableRowNotFoundError' } } diff --git a/apps/sim/lib/table/rows/ordering.ts b/apps/sim/lib/table/rows/ordering.ts index 25a429809ee..c776cc6e075 100644 --- a/apps/sim/lib/table/rows/ordering.ts +++ b/apps/sim/lib/table/rows/ordering.ts @@ -14,6 +14,7 @@ import { TABLE_LIMITS } from '@/lib/table/constants' import type { MutationProof } from '@/lib/table/mutation-locks' import { keyBetween, nKeysBetween } from '@/lib/table/order-key' import { type DbExecutor, type DbTransaction, withSeqscanOff } from '@/lib/table/planner' +import { TableRowNotFoundError } from '@/lib/table/rows/errors' import { mutateTableRowsWithSecretProvenance } from '@/lib/table/rows/secret-provenance' import { setTableTxTimeouts } from '@/lib/table/tx' import type { RowData, TableDefinition, TableRowSecretProvenanceWrite } from '@/lib/table/types' @@ -131,8 +132,10 @@ export async function resolveInsertByNeighbor( .where(and(eq(userTableRows.tableId, tableId), eq(userTableRows.id, anchorId))) .limit(1) // The client targets a specific neighbor; a missing one (concurrent delete / - // stale view) is an error, not a silent insert at the front. - if (!anchor) throw new Error(`Row not found: ${anchorId}`) + // stale view / an id the caller made up) is an error, not a silent insert at + // the front. It is caller-fixable, so it is classified: a bare `Error` here + // is unclassifiable by every layer above and surfaced as a 500 for a 404. + if (!anchor) throw new TableRowNotFoundError(anchorId) const anchorKey = anchor.orderKey ?? null // A null key on the anchor means the table isn't backfilled. order_key is // authoritative, so the adjacent-key lookup below can't work — fail loudly diff --git a/apps/sim/lib/table/rows/service.ts b/apps/sim/lib/table/rows/service.ts index ab9fbf6ea68..f6c770074f1 100644 --- a/apps/sim/lib/table/rows/service.ts +++ b/apps/sim/lib/table/rows/service.ts @@ -103,6 +103,7 @@ import { coerceRowToSchema, coerceRowValues, getUniqueColumns, + type UncoercibleValuePolicy, validateRowSize, } from '@/lib/table/validation' import { cancelWorkflowGroupRuns, runWorkflowColumn } from '@/lib/table/workflow-columns' @@ -118,10 +119,22 @@ const logger = createLogger('TableRowsService') * @returns Inserted row * @throws Error if validation fails or capacity exceeded */ +export interface RowWriteOptions { + /** + * What this write does with a value its column's type cannot coerce. Defaults + * to `null` — the cell is blanked and the write succeeds, which is what every + * first-party surface (the workspace grid, `/api/table`, `/api/v1`, the + * Copilot table tools, the executor's Table block) has always done. The + * `/api/v2` surface opts into `reject`. See {@link UncoercibleValuePolicy}. + */ + uncoercibleValues?: UncoercibleValuePolicy +} + export async function insertRow( data: InsertRowData, table: TableDefinition, - requestId: string + requestId: string, + options: RowWriteOptions = {} ): Promise { const insertProof = assertRowInsert(table) @@ -132,7 +145,7 @@ export async function insertRow( } // Validate against schema - const schemaValidation = coerceRowToSchema(data.data, table.schema) + const schemaValidation = coerceRowToSchema(data.data, table.schema, options.uncoercibleValues) if (!schemaValidation.valid) { throw new OrchestrationError( 'validation', @@ -226,7 +239,8 @@ export async function insertRow( export async function batchInsertRows( data: BatchInsertData, table: TableDefinition, - requestId: string + requestId: string, + options: RowWriteOptions = {} ): Promise { // Best-effort capacity check against the workspace's current plan limit. Import // paths call `batchInsertRowsWithTx` directly and gate capacity up front instead. @@ -236,7 +250,9 @@ export async function batchInsertRows( addedRows: data.rows.length, }) - const result = await db.transaction((trx) => batchInsertRowsWithTx(trx, data, table, requestId)) + const result = await db.transaction((trx) => + batchInsertRowsWithTx(trx, data, table, requestId, options) + ) notifyTableRowUsage({ workspaceId: table.workspaceId, currentRowCount: table.rowCount, @@ -260,7 +276,8 @@ export async function batchInsertRowsWithTx( trx: DbTransaction, data: BatchInsertData, table: TableDefinition, - requestId: string + requestId: string, + options: RowWriteOptions = {} ): Promise { assertRowInsert(table) @@ -275,7 +292,7 @@ export async function batchInsertRowsWithTx( ) } - const schemaValidation = coerceRowToSchema(row, table.schema) + const schemaValidation = coerceRowToSchema(row, table.schema, options.uncoercibleValues) if (!schemaValidation.valid) { throw new OrchestrationError( 'validation', @@ -400,7 +417,8 @@ export function dispatchAfterBatchInsert( export async function replaceTableRows( data: ReplaceRowsData, table: TableDefinition, - requestId: string + requestId: string, + options: RowWriteOptions = {} ): Promise { // All existing rows are deleted, so the footprint is just the new set. Checked // before the tx opens — never inside it (the plan lookup is a separate pool read). @@ -409,7 +427,9 @@ export async function replaceTableRows( currentRowCount: 0, addedRows: data.rows.length, }) - const result = await db.transaction((trx) => replaceTableRowsWithTx(trx, data, table, requestId)) + const result = await db.transaction((trx) => + replaceTableRowsWithTx(trx, data, table, requestId, options) + ) notifyTableRowUsage({ workspaceId: table.workspaceId, currentRowCount: 0, @@ -430,7 +450,8 @@ export async function replaceTableRowsWithTx( trx: DbTransaction, data: ReplaceRowsData, table: TableDefinition, - requestId: string + requestId: string, + options: RowWriteOptions = {} ): Promise { assertRowDelete(table) assertRowInsert(table) @@ -453,7 +474,7 @@ export async function replaceTableRowsWithTx( ) } - const schemaValidation = coerceRowToSchema(row, table.schema) + const schemaValidation = coerceRowToSchema(row, table.schema, options.uncoercibleValues) if (!schemaValidation.valid) { throw new OrchestrationError( 'validation', @@ -589,7 +610,8 @@ export async function replaceTableRowsWithTx( export async function upsertRow( data: UpsertRowData, table: TableDefinition, - requestId: string + requestId: string, + options: RowWriteOptions = {} ): Promise { const schema = table.schema const uniqueColumns = getUniqueColumns(schema) @@ -610,9 +632,19 @@ export async function upsertRow( (c) => getColumnId(c) === data.conflictTarget || c.name === data.conflictTarget ) if (!col) { + /** + * Name the column the way the caller does. A name-keyed surface resolves + * `conflictTarget` to its storage id before this call, so echoing the + * argument verbatim answers a request naming `email` with a `col_…` id + * the caller has never seen and cannot map back. Same rule as the + * missing-value branch below. + */ + const requested = + schema.columns.find((c) => getColumnId(c) === data.conflictTarget)?.name ?? + data.conflictTarget throw new OrchestrationError( 'validation', - `Column "${data.conflictTarget}" is not a unique column. Available unique columns: ${uniqueColumns.map((c) => c.name).join(', ')}` + `Column "${requested}" is not a unique column. Available unique columns: ${uniqueColumns.map((c) => c.name).join(', ')}` ) } targetColumnKey = getColumnId(col) @@ -631,7 +663,7 @@ export async function upsertRow( throw new OrchestrationError('validation', sizeValidation.errors.join(', ')) } - const schemaValidation = coerceRowToSchema(data.data, schema) + const schemaValidation = coerceRowToSchema(data.data, schema, options.uncoercibleValues) if (!schemaValidation.valid) { throw new OrchestrationError( 'validation', @@ -898,9 +930,6 @@ export interface FindRowMatch { column: string } -/** Max matching cells returned by {@link findRowMatches}; one extra is fetched to detect truncation. */ -const FIND_MATCH_LIMIT = 1000 - /** * Builds a SQL text expression that resolves a scanned select cell (`kv.value`, * keyed by `kv.key`) to its option **name(s)** — the label the user searches by, @@ -1019,13 +1048,13 @@ export async function findRowMatches( WHERE (kv.value ILIKE ${pattern}${nameMatchClause}) AND ${inArray(sql`kv.key`, columnIds)} ORDER BY o.ordinal - LIMIT ${FIND_MATCH_LIMIT + 1} + LIMIT ${TABLE_LIMITS.MAX_FIND_MATCHES + 1} `) }) const all = Array.from(result) - const truncated = all.length > FIND_MATCH_LIMIT - const sliced = truncated ? all.slice(0, FIND_MATCH_LIMIT) : all + const truncated = all.length > TABLE_LIMITS.MAX_FIND_MATCHES + const sliced = truncated ? all.slice(0, TABLE_LIMITS.MAX_FIND_MATCHES) : all const matches: FindRowMatch[] = sliced.map((r) => ({ ordinal: Number(r.ordinal), rowId: r.id, @@ -1190,6 +1219,8 @@ export async function queryRows( ? { anchor: fetched.anchor, offsetFromAnchor: fetched.anchorOffset } : undefined, sort, + predicate, + filter, }) : null @@ -1490,7 +1521,7 @@ class GuardRejected extends Error { * @returns Updated row * @throws Error if row not found or validation fails */ -export interface UpdateRowOptions { +export interface UpdateRowOptions extends RowWriteOptions { /** * Marks the write as the workflow/enrichment engine filling its own output * cells, which exempts it from the update lock. Set by `cell-write.ts` only — @@ -1557,7 +1588,12 @@ export async function updateRow( } // Validate against schema - const schemaValidation = coerceRowToSchema(mergedData, table.schema) + const schemaValidation = coerceRowToSchema( + mergedData, + table.schema, + options.uncoercibleValues, + Object.keys(data.data) + ) if (!schemaValidation.valid) { throw new OrchestrationError( 'validation', @@ -1741,13 +1777,14 @@ type BulkUpdateMatch = { id: string; data: RowData } function bulkUpdateValidationError( table: TableDefinition, row: BulkUpdateMatch, - patch: RowData + patch: RowData, + policy: UncoercibleValuePolicy | undefined ): string | null { const mergedData = { ...row.data, ...patch } const sizeValidation = validateRowSize(mergedData) if (!sizeValidation.valid) return sizeValidation.errors.join(', ') - const schemaValidation = coerceRowToSchema(mergedData, table.schema) + const schemaValidation = coerceRowToSchema(mergedData, table.schema, policy, Object.keys(patch)) return schemaValidation.valid ? null : schemaValidation.errors.join(', ') } @@ -1755,10 +1792,11 @@ function bulkUpdateValidationError( function validateBulkUpdateMatches( table: TableDefinition, rows: BulkUpdateMatch[], - patch: RowData + patch: RowData, + policy: UncoercibleValuePolicy | undefined ): void { for (const row of rows) { - const error = bulkUpdateValidationError(table, row, patch) + const error = bulkUpdateValidationError(table, row, patch, policy) if (error) throw new OrchestrationError('validation', `Row ${row.id}: ${error}`) } } @@ -1773,8 +1811,19 @@ async function persistBulkUpdateBatch(params: { now: Date secretProvenance: BulkUpdateData['secretProvenance'] requestId: string + uncoercibleValues: UncoercibleValuePolicy | undefined }): Promise<{ rows: BulkUpdateMatch[]; affectedRowIds: string[] }> { - const { table, rows, patch, patchJson, filterClause, now, secretProvenance, requestId } = params + const { + table, + rows, + patch, + patchJson, + filterClause, + now, + secretProvenance, + requestId, + uncoercibleValues, + } = params const ids = rows.map((row) => row.id) const persistedRows: BulkUpdateMatch[] = [] const affectedRowIds = await db.transaction(async (trx) => { @@ -1799,7 +1848,8 @@ async function persistBulkUpdateBatch(params: { const skippedRowIds: string[] = [] for (const currentRow of currentRows) { const row = { id: currentRow.id, data: currentRow.data as RowData } - if (bulkUpdateValidationError(table, row, patch)) skippedRowIds.push(row.id) + if (bulkUpdateValidationError(table, row, patch, uncoercibleValues)) + skippedRowIds.push(row.id) else persistedRows.push(row) } if (skippedRowIds.length > 0) { @@ -1892,7 +1942,8 @@ function dispatchBulkUpdateEffects( export async function updateRowsByFilter( table: TableDefinition, data: BulkUpdateData, - requestId: string + requestId: string, + options: RowWriteOptions = {} ): Promise { assertRowUpdate(table, patchColumnIds(data.data)) if (Object.keys(data.data).length === 0) { @@ -1911,7 +1962,7 @@ export async function updateRowsByFilter( eq(userTableRows.workspaceId, table.workspaceId) ) - coerceRowValues(data.data, table.schema) + coerceRowValues(data.data, table.schema, options.uncoercibleValues) const uniqueColumns = getUniqueColumns(table.schema) const uniqueColumnsInUpdate = uniqueColumns.filter((col) => getColumnId(col) in data.data) const patchJson = JSON.stringify(data.data) @@ -1935,7 +1986,7 @@ export async function updateRowsByFilter( }) if (page.length === 0) break - validateBulkUpdateMatches(table, page, data.data) + validateBulkUpdateMatches(table, page, data.data, options.uncoercibleValues) matchingRowCount += page.length singleMatchingRow ??= page[0] afterId = page[page.length - 1].id @@ -1995,6 +2046,7 @@ export async function updateRowsByFilter( now, secretProvenance: data.secretProvenance, requestId, + uncoercibleValues: options.uncoercibleValues, }) affectedRowIds.push(...persisted.affectedRowIds) dispatchBulkUpdateEffects( @@ -2027,7 +2079,7 @@ export async function updateRowsByFilter( return { affectedCount: 0, affectedRowIds: [] } } - validateBulkUpdateMatches(table, matchingRows, data.data) + validateBulkUpdateMatches(table, matchingRows, data.data, options.uncoercibleValues) if (uniqueColumnsInUpdate.length > 0) { if (matchingRows.length > 1) { throw new OrchestrationError( @@ -2062,6 +2114,7 @@ export async function updateRowsByFilter( now, secretProvenance: data.secretProvenance, requestId, + uncoercibleValues: options.uncoercibleValues, }) const { affectedRowIds } = persisted @@ -2082,7 +2135,7 @@ export async function updateRowsByFilter( } } -export interface BatchUpdateRowsOptions { +export interface BatchUpdateRowsOptions extends RowWriteOptions { /** * Marks the batch as workflow/enrichment output cells (the backfill runner), * exempting it from the update lock. See {@link assertRowUpdate}. @@ -2178,7 +2231,12 @@ export async function batchUpdateRows( ) } - const schemaValidation = coerceRowToSchema(merged, table.schema) + const schemaValidation = coerceRowToSchema( + merged, + table.schema, + options.uncoercibleValues, + Object.keys(update.data) + ) if (!schemaValidation.valid) { throw new OrchestrationError( 'validation', diff --git a/apps/sim/lib/table/service.ts b/apps/sim/lib/table/service.ts index 7da186caa17..49fb82c4ef5 100644 --- a/apps/sim/lib/table/service.ts +++ b/apps/sim/lib/table/service.ts @@ -28,6 +28,7 @@ import { textKey, timestampKey, } from '@/lib/api/list-query' +import { ForbiddenOperationError } from '@/lib/core/application/forbidden' import { OrchestrationError } from '@/lib/core/orchestration/types' import { generateRestoreName } from '@/lib/core/utils/restore-name' import type { DbOrTx } from '@/lib/db/types' @@ -463,6 +464,54 @@ export async function queryTables( return { tables, nextKeys: hasMore && last ? encodeKeyset(keys, last) : null } } +/** + * The refusal {@link createTable} raises when a workspace is at its table + * ceiling. Shared so the advisory pre-check answers with the identical code, + * status, and message as the authoritative one inside the transaction. + */ +function workspaceTableLimitReached(maxTables: number): ForbiddenOperationError { + /** + * A quota ceiling, not bad input — both create routes have always answered + * 403 for it. It names its cause so a client can tell a ceiling apart from a + * role or key-kind refusal: one is cleared by deleting a table, the other by + * changing who is calling. The status is left as it shipped, and it disagrees + * with the row ceiling's 400 — see `TableRowLimitError` in `lib/table/billing` + * for why both are recorded rather than unified here. + */ + return new ForbiddenOperationError( + 'WORKSPACE_RESOURCE_LIMIT_REACHED', + `Workspace has reached maximum table limit (${maxTables})` + ) +} + +/** + * Advisory table-quota check for a caller that is about to make the user pay + * for work before {@link createTable} would run. + * + * The authoritative check is the `FOR UPDATE` count inside `createTable`'s + * transaction and stays there — this one races, by construction, because the + * ceiling can be reached (or cleared) during whatever the caller does next. It + * exists so that "next" is not a multi-gigabyte upload: the CSV import used to + * hand out a presigned PUT for a table it already knew it could not create, and + * only answered 403 after the whole file had crossed the wire, leaving an + * orphaned object behind. + */ +export async function assertWorkspaceTableCapacity( + workspaceId: string, + maxTables: number +): Promise { + const [{ count: existingCount }] = await db + .select({ count: count() }) + .from(userTableDefinitions) + .where( + and( + eq(userTableDefinitions.workspaceId, workspaceId), + isNull(userTableDefinitions.archivedAt) + ) + ) + if (Number(existingCount) >= maxTables) throw workspaceTableLimitReached(maxTables) +} + /** * Creates a new table. * @@ -553,14 +602,7 @@ export async function createTable( ) ) - if (Number(existingCount) >= maxTables) { - // A quota ceiling, not bad input — both create routes have always - // answered 403 for it. - throw new OrchestrationError( - 'forbidden', - `Workspace has reached maximum table limit (${maxTables})` - ) - } + if (Number(existingCount) >= maxTables) throw workspaceTableLimitReached(maxTables) const duplicateName = await trx .select({ id: userTableDefinitions.id }) diff --git a/apps/sim/lib/table/sql.ts b/apps/sim/lib/table/sql.ts index 97dff495b3b..6a90b5280db 100644 --- a/apps/sim/lib/table/sql.ts +++ b/apps/sim/lib/table/sql.ts @@ -6,6 +6,7 @@ */ import { isRecordLike } from '@sim/utils/object' +import { truncate } from '@sim/utils/string' import type { SQL } from 'drizzle-orm' import { sql } from 'drizzle-orm' import { getColumnId } from '@/lib/table/column-keys' @@ -16,6 +17,7 @@ import { SINGLE_SELECT_OPERATORS, } from '@/lib/table/column-types' import { NAME_PATTERN } from '@/lib/table/constants' +import { normalizeDateCellValue } from '@/lib/table/dates' import { TableQueryValidationError } from '@/lib/table/errors' import type { ColumnDefinition, @@ -366,9 +368,37 @@ function validateComparisonValue( `Range operator on column "${field}" (${label}) requires a number, got ${typeof value}` ) } - if (cast === 'timestamptz' && typeof value !== 'string') { + if (cast === 'timestamptz') { + if (typeof value !== 'string') { + throw new TableQueryValidationError( + `Range operator on column "${field}" (date) requires a date string, got ${typeof value}` + ) + } + if (normalizeDateCellValue(value) === null) { + throw new TableQueryValidationError( + `Range operator on column "${field}" (date) requires a parseable date string, got "${truncate(value, 64)}"` + ) + } + } +} + +/** + * Guards a bound that is about to be bound into a `::timestamptz` cast on a + * system timestamp column (`createdAt`/`updatedAt`). + * + * The type check alone was not enough: any string went straight into the cast, + * so `not-a-date` raised `invalid input syntax for type timestamp with time + * zone` inside the driver. That throw carries no classification the route layer + * recognizes, so a malformed filter — caller input — surfaced as a 500. Parsing + * with the same normalizer the `date` column type uses to store cells keeps the + * filter grammar and the storage grammar in agreement. + */ +function assertParseableTimestampBound(field: string, value: JsonValue | undefined): void { + if (typeof value !== 'string' || normalizeDateCellValue(value) === null) { throw new TableQueryValidationError( - `Range operator on column "${field}" (date) requires a date string, got ${typeof value}` + `Operator on column "${field}" requires a parseable date string, got ${ + typeof value === 'string' ? `"${truncate(value, 64)}"` : typeof value + }` ) } } @@ -657,7 +687,10 @@ function buildSystemColumnClause( // `TimeZone` GUC, so identical queries return different rows per environment and // day-boundary ranges land off by the offset. Normalizing the bound to UTC wall // clock is session-independent and still honors an explicit offset in the input. - const ts = (v: JsonValue | undefined) => sql`${String(v)}::timestamptz AT TIME ZONE 'UTC'` + const ts = (v: JsonValue | undefined) => { + assertParseableTimestampBound(field, v) + return sql`${String(v)}::timestamptz AT TIME ZONE 'UTC'` + } const bind = spec.kind === 'timestamp' ? ts : (v: JsonValue | undefined) => sql`${String(v)}` /** * Mirrors the JSONB pattern builders: `*` is the caller's only wildcard, an diff --git a/apps/sim/lib/table/validation.test.ts b/apps/sim/lib/table/validation.test.ts index 5e2b28732a0..0616d3269ff 100644 --- a/apps/sim/lib/table/validation.test.ts +++ b/apps/sim/lib/table/validation.test.ts @@ -100,7 +100,14 @@ describe('coerceRowToSchema — select', () => { expect(data.col_status).toBe('opt_closed') }) - it('nulls an unmatched value on an optional column', () => { + it('rejects an unmatched value on an optional column under the `reject` policy', () => { + const data: RowData = { col_status: 'banana' } + const result = coerceRowToSchema(data, schemaWith(selectColumn), 'reject') + expect(result.valid).toBe(false) + expect(result.errors.join(' ')).toContain('status') + }) + + it('nulls an unmatched value by default', () => { const data: RowData = { col_status: 'banana' } const result = coerceRowToSchema(data, schemaWith(selectColumn)) expect(result.valid).toBe(true) @@ -109,13 +116,33 @@ describe('coerceRowToSchema — select', () => { }) describe('coerceRowToSchema — multiselect', () => { - it('resolves names and drops unmatched entries', () => { + it('resolves names and keeps the entries that resolve by default', () => { const data: RowData = { col_tags: ['Alpha', 'opt_b', 'ghost'] } const result = coerceRowToSchema(data, schemaWith(multiselectColumn)) expect(result.valid).toBe(true) expect(data.col_tags).toEqual(['opt_a', 'opt_b']) }) + it('rejects an entry matching no option instead of dropping it under `reject`', () => { + const data: RowData = { col_tags: ['Alpha', 'ghost'] } + const result = coerceRowToSchema(data, schemaWith(multiselectColumn), 'reject') + expect(result.valid).toBe(false) + }) + + it('rejects a lone unmatched entry rather than storing an empty list under `reject`', () => { + const data: RowData = { col_tags: ['green'] } + const result = coerceRowToSchema(data, schemaWith(multiselectColumn), 'reject') + expect(result.valid).toBe(false) + expect(data.col_tags).not.toEqual([]) + }) + + it('empties the cell by default only when nothing resolves', () => { + const data: RowData = { col_tags: ['ghost'] } + const result = coerceRowToSchema(data, schemaWith(multiselectColumn)) + expect(result.valid).toBe(true) + expect(data.col_tags).toEqual([]) + }) + it('wraps a single string into a one-element array', () => { const data: RowData = { col_tags: 'opt_a' as unknown as string[] } coerceRowToSchema(data, schemaWith(multiselectColumn)) @@ -123,6 +150,117 @@ describe('coerceRowToSchema — multiselect', () => { }) }) +/** + * The `reject` policy, which only `/api/v2` opts into. It answers a value it + * cannot store exactly with a 400 rather than a 200 whose cell is `null`, + * matching the read side, which already refuses the same mismatch in a filter + * predicate. Every first-party surface runs the `null` policy in the sibling + * `it.each` below, which is the default and what they have always done. + */ +describe('coerceRowToSchema — uncoercible values under the `reject` policy', () => { + const numberColumn: ColumnDefinition = { id: 'col_n', name: 'n', type: 'number' } + const booleanColumn: ColumnDefinition = { id: 'col_b', name: 'b', type: 'boolean' } + const dateColumn: ColumnDefinition = { id: 'col_d', name: 'd', type: 'date' } + const stringColumn: ColumnDefinition = { id: 'col_s', name: 's', type: 'string' } + + const cases: Array<[string, ColumnDefinition, RowData[string]]> = [ + ['string into number', numberColumn, 'abc'], + ['boolean into number', numberColumn, true], + ['array into number', numberColumn, [1]], + ['"NaN" into number', numberColumn, 'NaN'], + ['"yes" into boolean', booleanColumn, 'yes'], + ['1 into boolean', booleanColumn, 1], + ['object into boolean', booleanColumn, {}], + ['unparseable string into date', dateColumn, 'not-a-date'], + ['object into string', stringColumn, { a: 1 }], + ] + + it.each(cases)('rejects %s', (_label, column, value) => { + const data: RowData = { [column.id as string]: value } + const result = coerceRowToSchema(data, schemaWith(column), 'reject') + expect(result.valid).toBe(false) + expect(data[column.id as string]).not.toBeNull() + }) + + it.each(cases)( + 'nulls %s by default, as every first-party surface does', + (_label, column, value) => { + const data: RowData = { [column.id as string]: value } + const result = coerceRowToSchema(data, schemaWith(column)) + expect(result.valid).toBe(true) + expect(data[column.id as string]).toBeNull() + } + ) + + it('still applies unambiguous conversions', () => { + const data: RowData = { col_n: '1999' } + expect(coerceRowToSchema(data, schemaWith(numberColumn)).valid).toBe(true) + expect(data.col_n).toBe(1999) + }) + + /** + * A bare number cannot say whether it means seconds or milliseconds, and both + * readings land in range: guessing milliseconds stores `1600000000` — a + * Unix-seconds timestamp for September 2020 — as 19 January 1970 under a 200. + */ + it('refuses a bare epoch number rather than guessing its unit', () => { + const data: RowData = { col_d: 1600000000 } + const result = coerceRowToSchema(data, schemaWith(dateColumn), 'reject') + expect(result.valid).toBe(false) + expect(data.col_d).not.toBe('1970-01-19T12:26:40.000Z') + }) + + it('accepts an ISO-8601 string, which states its own unit', () => { + const data: RowData = { col_d: '2020-09-13T12:26:40Z' } + expect(coerceRowToSchema(data, schemaWith(dateColumn)).valid).toBe(true) + }) + + it('reads a bare epoch number as milliseconds by default', () => { + const data: RowData = { col_d: 1600000000000 } + const result = coerceRowToSchema(data, schemaWith(dateColumn)) + expect(result.valid).toBe(true) + expect(data.col_d).toBe('2020-09-13T12:26:40.000Z') + }) + + it('still nulls an out-of-range epoch number by default', () => { + const data: RowData = { col_d: 1e20 } + const result = coerceRowToSchema(data, schemaWith(dateColumn)) + expect(result.valid).toBe(true) + expect(data.col_d).toBeNull() + }) +}) + +/** + * A partial update coerces the caller's patch and then validates the MERGED + * row, so the merged pass sees cells this write never touched. Those are + * storage, not caller input — a legacy cell that no longer fits its column must + * not fail an update of a different column, and must not be persisted either + * (the write only sends the patched keys). + */ +describe('coerceRowToSchema — merged row', () => { + const numberColumn: ColumnDefinition = { id: 'col_n', name: 'n', type: 'number' } + const stringColumn: ColumnDefinition = { id: 'col_s', name: 's', type: 'string' } + const schema = schemaWith(numberColumn, stringColumn) + + it('does not fail an update over an untouched cell that no longer coerces', () => { + const merged: RowData = { col_n: 'legacy', col_s: 'new' } + const result = coerceRowToSchema(merged, schema, 'reject', ['col_s']) + expect(result.valid).toBe(true) + }) + + it('still refuses the same value when this write is the one supplying it', () => { + const merged: RowData = { col_n: 'legacy', col_s: 'new' } + const result = coerceRowToSchema(merged, schema, 'reject', ['col_n', 'col_s']) + expect(result.valid).toBe(false) + expect(merged.col_n).toBe('legacy') + }) + + it('treats every key as caller-supplied when no patch key set is given', () => { + const merged: RowData = { col_n: 'legacy', col_s: 'new' } + expect(coerceRowToSchema(merged, schema, 'reject').valid).toBe(false) + }) +}) + describe('resolveSelectOptionId', () => { const options = selectColumn.options ?? [] diff --git a/apps/sim/lib/table/validation.ts b/apps/sim/lib/table/validation.ts index 270ee5e3ee4..aa9a918beb8 100644 --- a/apps/sim/lib/table/validation.ts +++ b/apps/sim/lib/table/validation.ts @@ -62,6 +62,8 @@ export interface ValidateRowOptions { tableId: string excludeRowId?: string checkUnique?: boolean + /** See {@link UncoercibleValuePolicy}. Defaults to `null` — first-party behavior. */ + uncoercibleValues?: UncoercibleValuePolicy } /** Error information for a single row in batch validation. */ @@ -76,6 +78,8 @@ export interface ValidateBatchRowsOptions { schema: TableSchema tableId: string checkUnique?: boolean + /** See {@link UncoercibleValuePolicy}. Defaults to `null` — first-party behavior. */ + uncoercibleValues?: UncoercibleValuePolicy } /** @@ -85,7 +89,7 @@ export interface ValidateBatchRowsOptions { export async function validateRowData( options: ValidateRowOptions ): Promise { - const { rowData, schema, tableId, excludeRowId, checkUnique = true } = options + const { rowData, schema, tableId, excludeRowId, checkUnique = true, uncoercibleValues } = options const sizeValidation = validateRowSize(rowData) if (!sizeValidation.valid) { @@ -98,7 +102,7 @@ export async function validateRowData( } } - const schemaValidation = coerceRowToSchema(rowData, schema) + const schemaValidation = coerceRowToSchema(rowData, schema, uncoercibleValues) if (!schemaValidation.valid) { return { valid: false, @@ -134,7 +138,7 @@ export async function validateRowData( export async function validateBatchRows( options: ValidateBatchRowsOptions ): Promise { - const { rows, schema, tableId, checkUnique = true } = options + const { rows, schema, tableId, checkUnique = true, uncoercibleValues } = options const errors: BatchRowError[] = [] for (let i = 0; i < rows.length; i++) { @@ -146,7 +150,7 @@ export async function validateBatchRows( continue } - const schemaValidation = coerceRowToSchema(rowData, schema) + const schemaValidation = coerceRowToSchema(rowData, schema, uncoercibleValues) if (!schemaValidation.valid) { errors.push({ row: i, errors: schemaValidation.errors }) } @@ -274,17 +278,69 @@ function coerceValueToColumnType(value: JsonValue, column: ColumnDefinition): Co return columnTypeOf(column).coerce(value, column) } +/** + * What a write does with a value its column's type cannot coerce. + * + * - `null` — blank the cell rather than fail the row. **The default**, and what + * every first-party surface does: the workspace grid, the internal + * `/api/table` routes, `/api/v1`, the Copilot table tools, the executor's + * Table block, CSV import, and the workflow/enrichment writers. A tool + * returning `"unknown"` for a numeric column nulls that one cell rather than + * failing the entire row write. + * - `reject` — leave the value in place so the following + * {@link validateRowAgainstSchema} reports it and the write fails. Opted into + * by the `/api/v2` surface only, whose published contract is that a value it + * cannot store exactly is answered with a 400 rather than stored as `null`. + * + * Under `null` a value the column type can still read lossily is kept rather + * than blanked — see `ColumnTypeDefinition.salvage`, which is why a cell naming + * two live options and one deleted one stores the two rather than nothing. A + * `required` column is never blanked under either policy: a null would fail the + * required check immediately after. + */ +export type UncoercibleValuePolicy = 'reject' | 'null' + +/** + * The keys of `data` this write's caller actually supplied, for when `data` is a + * MERGED row (stored cells overlaid with a patch) rather than the patch alone. + * Keys outside the set are pre-existing storage, so they fall back to the `null` + * policy whatever the caller's policy is: a legacy cell that no longer fits its + * column was written by an earlier request, and failing this one over it refuses + * an unrelated column's update — and, on a paged bulk job, refuses it after the + * earlier pages have already committed. The blanking stays in the in-memory + * copy; every merged-row caller persists only the patched keys. + * + * Omit it when every key in `data` is caller-supplied — a whole-row insert, or a + * patch validated on its own. + */ +export type PatchedKeys = readonly string[] + +function policyResolver( + policy: UncoercibleValuePolicy, + patchedKeys: PatchedKeys | undefined +): (key: string) => UncoercibleValuePolicy { + if (patchedKeys === undefined) return () => policy + const patched = new Set(patchedKeys) + return (key) => (patched.has(key) ? policy : 'null') +} + /** * Coerces each present value in `data` toward its column's declared type **in * place**. Values that already match are untouched; unambiguous conversions - * (e.g. `"1999"` → `1999`) are applied; values that cannot be coerced are set to - * `null` when the column is optional, or left in place when required (so a - * subsequent {@link validateRowAgainstSchema} reports them). + * (e.g. `"1999"` → `1999`) are applied; values that cannot be coerced are + * handled per {@link UncoercibleValuePolicy}, narrowed per key by + * {@link PatchedKeys}. * * Operates per-present-column, so it is safe on a partial patch (columns absent * from `data` are skipped — it never invents a missing-required-field error). */ -export function coerceRowValues(data: RowData, schema: TableSchema): void { +export function coerceRowValues( + data: RowData, + schema: TableSchema, + policy: UncoercibleValuePolicy = 'null', + patchedKeys?: PatchedKeys +): void { + const policyFor = policyResolver(policy, patchedKeys) for (const column of schema.columns) { const key = getColumnId(column) const value = data[key] @@ -293,6 +349,13 @@ export function coerceRowValues(data: RowData, schema: TableSchema): void { const coerced = coerceValueToColumnType(value, column) if (coerced.ok) { data[key] = coerced.value + continue + } + if (policyFor(key) !== 'null') continue + + const salvaged = columnTypeOf(column).salvage?.(value, column) + if (salvaged?.ok) { + data[key] = salvaged.value } else if (!column.required) { data[key] = null } @@ -304,14 +367,20 @@ export function coerceRowValues(data: RowData, schema: TableSchema): void { * then validates the result. * * This is the write-path entry point — callers that persist a complete row use - * it instead of {@link validateRowAgainstSchema} so a single off-type field (a - * tool returning `"unknown"` for a numeric column, say) nulls that one cell - * rather than failing the entire row write. Callers persisting only a partial - * patch should use {@link coerceRowValues} on the patch and validate the merged - * row separately. + * it instead of {@link validateRowAgainstSchema} so the coercion and the check + * that follows it can never disagree about what a cell holds. + * + * A caller validating a MERGED row — stored cells overlaid with a patch — passes + * the patch's keys as {@link PatchedKeys} so the strict policy applies to what + * this request sent and not to what was already there. */ -export function coerceRowToSchema(data: RowData, schema: TableSchema): ValidationResult { - coerceRowValues(data, schema) +export function coerceRowToSchema( + data: RowData, + schema: TableSchema, + policy: UncoercibleValuePolicy = 'null', + patchedKeys?: PatchedKeys +): ValidationResult { + coerceRowValues(data, schema, policy, patchedKeys) return validateRowAgainstSchema(data, schema) } diff --git a/apps/sim/lib/table/views/service.test.ts b/apps/sim/lib/table/views/service.test.ts index f33affcef5d..1cc5c7ec5c0 100644 --- a/apps/sim/lib/table/views/service.test.ts +++ b/apps/sim/lib/table/views/service.test.ts @@ -13,6 +13,7 @@ vi.mock('@/lib/table/events', () => ({ signalTableViewsChanged: mockSignalTableViewsChanged, })) +import { TABLE_LIMITS } from '@/lib/table/constants' import { createTableView, deleteTableView, @@ -140,6 +141,7 @@ describe('table-view mutations signal collaborators', () => { }) it('createTableView signals the table after inserting', async () => { + queueTableRows(tableViews, [{ total: 0 }]) // the in-lock view-count check dbChainMockFns.returning.mockResolvedValueOnce([viewRow]) await createTableView({ @@ -255,3 +257,278 @@ describe('getTableView', () => { expect(await getTableView('view-elsewhere', 'table-1', columns)).toBeNull() }) }) + +/** + * `GET /tables/{id}/views` returns every view in one unpaginated page and + * declares the set bounded. Nothing made that true, so the ceiling is asserted + * on the write that could cross it. + */ +describe('saved-view ceiling', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + }) + + function create() { + return createTableView({ + tableId: 'table-1', + workspaceId: 'ws-1', + name: 'Another View', + config: {}, + userId: 'user-1', + columns: [], + }) + } + + it('refuses a create that would cross MAX_VIEWS_PER_TABLE', async () => { + queueTableRows(tableViews, [{ total: TABLE_LIMITS.MAX_VIEWS_PER_TABLE }]) + + await expect(create()).rejects.toMatchObject({ name: 'TableViewValidationError' }) + expect(dbChainMockFns.insert).not.toHaveBeenCalled() + expect(mockSignalTableViewsChanged).not.toHaveBeenCalled() + }) + + it('allows the create that lands exactly on the ceiling', async () => { + queueTableRows(tableViews, [{ total: TABLE_LIMITS.MAX_VIEWS_PER_TABLE - 1 }]) + dbChainMockFns.returning.mockResolvedValueOnce([ + { + id: 'view-100', + tableId: 'table-1', + workspaceId: 'ws-1', + name: 'Another View', + config: {}, + isDefault: false, + createdBy: 'user-1', + createdAt: new Date('2026-01-01T00:00:00.000Z'), + updatedAt: new Date('2026-01-01T00:00:00.000Z'), + }, + ]) + + await expect(create()).resolves.toMatchObject({ id: 'view-100' }) + }) +}) + +/** + * A saved config's column references are stored as stable column ids, but the + * v2 wire is column-NAME-keyed like every other v2 row/data surface. The write + * path translates; anything it cannot resolve is a caller mistake and must be + * refused rather than stored and quietly dropped on the next read. + */ +describe('view config column-reference normalization', () => { + const columns: ColumnDefinition[] = [ + { id: 'col_a', name: 'Name', type: 'text' }, + { id: 'col_b', name: 'Email', type: 'text' }, + ] + const storedRow = { + id: 'view-1', + tableId: 'table-1', + workspaceId: 'ws-1', + name: 'My View', + config: {}, + isDefault: false, + createdBy: 'user-1', + createdAt: new Date('2026-01-01T00:00:00.000Z'), + updatedAt: new Date('2026-01-01T00:00:00.000Z'), + } + + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + }) + + function insertedConfig(): TableViewConfig { + const [values] = dbChainMockFns.values.mock.calls.at(-1) as [{ config: TableViewConfig }] + return values.config + } + + function create(config: TableViewConfig, strictRefs = true) { + return createTableView({ + tableId: 'table-1', + workspaceId: 'ws-1', + name: 'My View', + config, + userId: 'user-1', + columns, + strictRefs, + }) + } + + it('stores a name-keyed sort as column ids instead of discarding it', async () => { + queueTableRows(tableViews, [{ total: 0 }]) + dbChainMockFns.returning.mockResolvedValueOnce([storedRow]) + + await create({ sort: [{ field: 'Name', direction: 'desc' }] }) + + expect(insertedConfig().sort).toEqual([{ field: 'col_a', direction: 'desc' }]) + }) + + it('stores a name-keyed filter and layout as column ids', async () => { + queueTableRows(tableViews, [{ total: 0 }]) + dbChainMockFns.returning.mockResolvedValueOnce([storedRow]) + + await create({ + filter: { all: [{ field: 'Email', op: 'eq', value: 'x@example.com' }] }, + columnOrder: ['Email', 'Name'], + hiddenColumns: ['Name'], + pinnedColumns: ['Email'], + columnWidths: { Name: 200 }, + }) + + expect(insertedConfig()).toEqual({ + filter: { all: [{ field: 'col_b', op: 'eq', value: 'x@example.com' }] }, + columnOrder: ['col_b', 'col_a'], + hiddenColumns: ['col_a'], + pinnedColumns: ['col_b'], + columnWidths: { col_a: 200 }, + }) + }) + + it('leaves an already id-keyed config untouched', async () => { + queueTableRows(tableViews, [{ total: 0 }]) + dbChainMockFns.returning.mockResolvedValueOnce([storedRow]) + + await create({ + sort: [{ field: 'col_b', direction: 'asc' }], + filter: { all: [{ field: 'col_a', op: 'eq', value: 'x' }] }, + }) + + expect(insertedConfig()).toEqual({ + sort: [{ field: 'col_b', direction: 'asc' }], + filter: { all: [{ field: 'col_a', op: 'eq', value: 'x' }] }, + }) + }) + + it('refuses a filter on a column that does not exist for a strict caller', async () => { + queueTableRows(tableViews, [{ total: 0 }]) + dbChainMockFns.returning.mockResolvedValueOnce([storedRow]) + + await expect( + create({ filter: { all: [{ field: 'ghost', op: 'eq', value: 'x' }] } }) + ).rejects.toMatchObject({ name: 'TableViewValidationError' }) + expect(dbChainMockFns.insert).not.toHaveBeenCalled() + }) + + it('refuses a sort on a column that does not exist for a strict caller', async () => { + queueTableRows(tableViews, [{ total: 0 }]) + dbChainMockFns.returning.mockResolvedValueOnce([storedRow]) + + await expect(create({ sort: [{ field: 'ghost', direction: 'asc' }] })).rejects.toMatchObject({ + name: 'TableViewValidationError', + }) + expect(dbChainMockFns.insert).not.toHaveBeenCalled() + }) + + /** + * "Save as view" hands back the filter the grid is displaying, dangling leaf + * and all — the same slice the Save chip sends to the update path, which has + * always tolerated it. Refusing one and accepting the other would 400 the two + * menu items against each other. + */ + it('stores the same dangling reference for a first-party caller', async () => { + queueTableRows(tableViews, [{ total: 0 }]) + dbChainMockFns.returning.mockResolvedValueOnce([storedRow]) + + await create( + { filter: { all: [{ field: 'col_gone', op: 'eq', value: 'x' }] }, sort: [] }, + false + ) + + expect(insertedConfig().filter).toEqual({ + all: [{ field: 'col_gone', op: 'eq', value: 'x' }], + }) + }) + + /** + * A user column may legally be named `createdAt`. The name→id rewrite would + * otherwise point the stored ref at that column's JSONB cell while every read + * still resolves the literal to `user_table_rows.created_at`. + */ + it('leaves a system field alone even when a user column carries its name', async () => { + queueTableRows(tableViews, [{ total: 0 }]) + dbChainMockFns.returning.mockResolvedValueOnce([storedRow]) + + await createTableView({ + tableId: 'table-1', + workspaceId: 'ws-1', + name: 'My View', + config: { sort: [{ field: 'createdAt', direction: 'desc' }] }, + userId: 'user-1', + columns: [...columns, { id: 'col_c', name: 'createdAt', type: 'string' }], + strictRefs: true, + }) + + expect(insertedConfig().sort).toEqual([{ field: 'createdAt', direction: 'desc' }]) + }) + + it('refuses a nonexistent filter column on a configPatch too', async () => { + queueTableRows(tableViews, [{ id: 'view-1' }]) + + await expect( + updateTableView({ + viewId: 'view-1', + tableId: 'table-1', + configPatch: { filter: { all: [{ field: 'ghost', op: 'eq', value: 'x' }] } }, + columns, + strictRefs: true, + }) + ).rejects.toMatchObject({ name: 'TableViewValidationError' }) + }) + + /** + * A column delete leaves the referencing views behind, and `pruneViewConfig` + * deliberately does not prune a filter. The write must therefore let the + * already-stored reference through — otherwise the first save of anything else + * on that view (a sort change, a hidden-column change, the Save chip's whole + * config) 400s on a condition the user did not touch. + */ + it('lets a save carry forward a stale filter reference the view already stored', async () => { + const stale = { all: [{ field: 'col_gone', op: 'eq' as const, value: 'x' }] } + queueTableRows(tableViews, [{ ...storedRow, config: { filter: stale } }]) + dbChainMockFns.returning.mockResolvedValueOnce([storedRow]) + + await expect( + updateTableView({ + viewId: 'view-1', + tableId: 'table-1', + config: { filter: stale, sort: [{ field: 'col_a', direction: 'asc' }] }, + columns, + }) + ).resolves.not.toBeNull() + }) + + it('still refuses a NEW unknown reference on a view that already had a stale one', async () => { + const stale = { all: [{ field: 'col_gone', op: 'eq' as const, value: 'x' }] } + queueTableRows(tableViews, [{ ...storedRow, config: { filter: stale } }]) + + await expect( + updateTableView({ + viewId: 'view-1', + tableId: 'table-1', + config: { filter: { all: [{ field: 'col_other_ghost', op: 'eq', value: 'x' }] } }, + columns, + strictRefs: true, + }) + ).rejects.toMatchObject({ name: 'TableViewValidationError' }) + }) + + it('accepts that same new reference from a first-party caller', async () => { + const stale = { all: [{ field: 'col_gone', op: 'eq' as const, value: 'x' }] } + queueTableRows(tableViews, [{ ...storedRow, config: { filter: stale } }]) + dbChainMockFns.returning.mockResolvedValueOnce([storedRow]) + + await expect( + updateTableView({ + viewId: 'view-1', + tableId: 'table-1', + config: { filter: { all: [{ field: 'col_other_ghost', op: 'eq', value: 'x' }] } }, + columns, + }) + ).resolves.not.toBeNull() + }) + + it('keeps a sort on a system row column, which is sortable but not in schema.columns', () => { + expect( + pruneViewConfig({ sort: [{ field: 'createdAt', direction: 'desc' }] }, columns).sort + ).toEqual([{ field: 'createdAt', direction: 'desc' }]) + }) +}) diff --git a/apps/sim/lib/table/views/service.ts b/apps/sim/lib/table/views/service.ts index 6f0a6c9b690..1cd7462091e 100644 --- a/apps/sim/lib/table/views/service.ts +++ b/apps/sim/lib/table/views/service.ts @@ -13,11 +13,23 @@ import { db } from '@sim/db' import { tableViews } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' -import { and, asc, eq, ne, sql } from 'drizzle-orm' -import { getColumnId } from '@/lib/table/column-keys' -import { NAME_PATTERN } from '@/lib/table/constants' +import { and, asc, count, eq, ne, sql } from 'drizzle-orm' +import { + buildColumnIdByName, + getColumnId, + remapViewConfigColumnRefs, +} from '@/lib/table/column-keys' +import { NAME_PATTERN, TABLE_LIMITS } from '@/lib/table/constants' +import { TableQueryValidationError } from '@/lib/table/errors' import { signalTableViewsChanged } from '@/lib/table/events' +import type { DbTransaction } from '@/lib/table/planner' import { filterRulesToPredicate, filterToRules } from '@/lib/table/query-builder/converters' +import { + SYSTEM_COLUMN_FIELDS, + validateStoragePredicate, + validateStorageSortSpec, +} from '@/lib/table/query-builder/validate' +import { setTableTxTimeouts } from '@/lib/table/tx' import type { ColumnDefinition, Filter, @@ -78,13 +90,127 @@ export function pruneViewConfig( pruned.columnWidths = widths } if (config.sort) { - const sort = config.sort.filter((s) => live.has(s.field)) + // `live` holds column ids only. `createdAt`/`updatedAt`/`id` are sortable + // row-level columns that are not in `schema.columns`, so without this a view + // sorted by one of them would prune to "no sort at all" on every read. + const sort = config.sort.filter((s) => live.has(s.field) || SYSTEM_COLUMN_FIELDS.has(s.field)) pruned.sort = sort.length > 0 ? sort : null } return pruned } +/** + * Every column reference the row-selecting half of a stored config holds — each + * `filter` leaf field and each `sort` field. Feeds the carried-forward exemption + * in {@link normalizeViewConfigForStorage}, so it lists refs whether or not they + * still resolve. + */ +function configColumnRefs(config: TableViewConfig): string[] { + const refs: string[] = [] + for (const { field } of config.sort ?? []) refs.push(field) + const visit = (node: PredicateNode): void => { + if (!node || typeof node !== 'object') return + if ('all' in node || 'any' in node) { + const members = 'all' in node ? node.all : node.any + if (Array.isArray(members)) for (const child of members) visit(child) + return + } + if ('field' in node && typeof node.field === 'string') refs.push(node.field) + } + if (config.filter) visit(config.filter) + return refs +} + +/** + * `name → id` for a view config, minus the names that already mean something + * else as a reference. + * + * The rewrite is a lookup with pass-through, so a name entry would otherwise + * beat the meaning a ref already has. A user column may legally be NAMED `id`, + * `createdAt`, or `updatedAt` — nothing reserves those — and a legacy column + * with no `id` has `getColumnId(col) === col.name`, so a rename can leave one + * column's id equal to another's name. In both cases the write would rewrite the + * ref to the user column while every read still resolves the literal to the + * system row column or the original column, so the saved view would silently + * sort or filter on something the caller did not name. + */ +function viewConfigRefMap(columns: readonly ColumnDefinition[]): Map { + const byName = buildColumnIdByName(columns) + const liveIds = new Set(columns.map(getColumnId)) + for (const name of byName.keys()) { + if (liveIds.has(name) || SYSTEM_COLUMN_FIELDS.has(name)) byName.delete(name) + } + return byName +} + +/** + * `columns` plus a placeholder for each exempt reference that no longer resolves, + * so the shared query validators accept it without being taught about views. The + * placeholders exist for the length of one validation call and are never stored. + */ +function tolerantColumns( + columns: ColumnDefinition[], + carriedForward: readonly string[] +): ColumnDefinition[] { + if (carriedForward.length === 0) return columns + const live = new Set(columns.map(getColumnId)) + const extra: ColumnDefinition[] = [] + for (const ref of carriedForward) { + if (live.has(ref)) continue + live.add(ref) + extra.push({ id: ref, name: ref, type: 'string' }) + } + return extra.length > 0 ? [...columns, ...extra] : columns +} + +/** + * Canonicalizes a caller-supplied config for storage: every column reference is + * rewritten to the column's stable **id**, then the row-selecting parts are + * validated against the live schema. + * + * Two vocabularies reach this write. The first-party UI authors ids; the v2 + * public surface is column-NAME-keyed like every other v2 row/data surface (see + * `presentV2WorkflowGroup`, which converts the same way on the way out). The + * rewrite is a lookup with pass-through, so an id, a system column, and a name + * all land on the one keying `pruneViewConfig` and the query layer read. + * + * `filter` and `sort` are then validated: a predicate or sort naming no column + * is one the query routes answer 400 for, so storing it would save a view that + * can never load. Column LAYOUT is deliberately not validated — it auto-saves as + * the user drags, and racing a concurrent column delete must self-heal through + * {@link pruneViewConfig}, not fail the drag. + * + * `carriedForward` names the references that are exempt from that refusal. + * Deleting a column leaves every view that filtered on it dangling — + * `pruneViewConfig` deliberately does not prune a filter — so without the + * exemption the view becomes unwritable: the Save chip sends the whole + * `{filter, sort, hiddenColumns}` slice, and a user changing the sort would be + * refused over a condition they did not touch, with no way to save the removal + * of anything else first. The v2 surface exempts only what the STORED config + * already held, so a reference the caller INTRODUCES is refused; a first-party + * caller exempts its own refs too, which is the behavior the grid has always + * had — see {@link CreateTableViewData.strictRefs}. + */ +export function normalizeViewConfigForStorage( + config: TableViewConfig, + columns: ColumnDefinition[], + carriedForward: readonly string[] = [] +): TableViewConfig { + const stored = remapViewConfigColumnRefs(config, viewConfigRefMap(columns)) + const known = tolerantColumns(columns, carriedForward) + try { + if (stored.filter) validateStoragePredicate(stored.filter, known) + if (stored.sort) validateStorageSortSpec(stored.sort, known) + } catch (error) { + if (error instanceof TableQueryValidationError) { + throw new TableViewValidationError(error.message) + } + throw error + } + return stored +} + /** * Migrates a config stored before the grammar switch. The feature never * released, so legacy-shaped rows exist only from pre-refactor testing: a @@ -217,6 +343,26 @@ function normalizeName(name: string): string { return trimmed } +/** + * Serializes the saved-view writers for one table on a transaction-scoped + * advisory lock of their own, so a count-then-insert cannot be raced. Keyed + * `user_table_views:`, deliberately distinct from the + * `user_table_schema:` key the column/row mutators hold: presentation + * state must not queue behind a schema rewrite. + */ +async function withTableViewsLock( + tableId: string, + write: (trx: DbTransaction) => Promise +): Promise { + return db.transaction(async (trx) => { + await setTableTxTimeouts(trx) + await trx.execute( + sql`SELECT pg_advisory_xact_lock(hashtextextended(${`user_table_views:${tableId}`}, 0))` + ) + return write(trx) + }) +} + export interface CreateTableViewData { tableId: string workspaceId: string @@ -224,22 +370,71 @@ export interface CreateTableViewData { config: TableViewConfig userId: string columns: ColumnDefinition[] + /** + * Whether to refuse a filter or sort reference naming no live column. Set by + * the `/api/v2` surface only, whose caller authored the config in this request + * and can be told which reference was wrong. + * + * Absent — the first-party grid, which does not author these refs so much as + * carry them: a view filtered on a since-deleted column keeps the dangling + * leaf through every read (`pruneViewConfig` spares filters) and hands it + * straight back on the next save. Refusing it would 400 "Save as view" on a + * config the Save chip accepts, one menu item apart, over a condition the user + * never touched. + */ + strictRefs?: boolean } +/** + * Creates a saved view, refusing one that would push the table past + * {@link TABLE_LIMITS.MAX_VIEWS_PER_TABLE}. + * + * The list read returns every view in one unpaginated page, so that promise only + * holds if the write side enforces it. The count and the insert share an advisory + * lock, which is what makes the count authoritative against a concurrent create + * rather than a check two racing writers can both pass. + * + * The lock is keyed to this table's VIEWS, not to its schema. A view is + * presentation state and contends only with another view create; taking the + * schema lock would queue it behind a column rewrite or a bulk row job, whose + * statement timeouts run far past this transaction's 3s `lock_timeout`, so + * creating a view would fail for the duration of an unrelated long mutation. + */ export async function createTableView(data: CreateTableViewData): Promise { const name = normalizeName(data.name) + const config = normalizeViewConfigForStorage( + data.config, + data.columns, + data.strictRefs ? [] : configColumnRefs(data.config) + ) + + const row = await withTableViewsLock(data.tableId, async (trx) => { + const [existing] = await trx + .select({ total: count() }) + .from(tableViews) + .where( + and(eq(tableViews.tableId, data.tableId), eq(tableViews.workspaceId, data.workspaceId)) + ) - const [row] = await db - .insert(tableViews) - .values({ - id: generateId(), - tableId: data.tableId, - workspaceId: data.workspaceId, - name, - config: data.config, - createdBy: data.userId, - }) - .returning() + if (Number(existing?.total ?? 0) >= TABLE_LIMITS.MAX_VIEWS_PER_TABLE) { + throw new TableViewValidationError( + `A table cannot have more than ${TABLE_LIMITS.MAX_VIEWS_PER_TABLE} saved views` + ) + } + + const [created] = await trx + .insert(tableViews) + .values({ + id: generateId(), + tableId: data.tableId, + workspaceId: data.workspaceId, + name, + config, + createdBy: data.userId, + }) + .returning() + return created + }) logger.info('Created table view', { tableId: data.tableId, viewId: row.id }) // Views are table-wide shared state, so every open reader refetches the list live. @@ -258,6 +453,8 @@ export interface UpdateTableViewData { configPatch?: TableViewConfig isDefault?: boolean columns: ColumnDefinition[] + /** See {@link CreateTableViewData.strictRefs}. */ + strictRefs?: boolean } /** @@ -268,16 +465,12 @@ export interface UpdateTableViewData { * `configPatch` merges in the database (`||`) rather than client-side, so two * overlapping partial writes — a column resize landing while a pin is in flight — * can't each replace the whole blob from their own stale snapshot. + * + * The config is normalized inside the transaction, against the stored row, so + * the references that row already carries stay writable — see + * {@link normalizeViewConfigForStorage}. */ export async function updateTableView(data: UpdateTableViewData): Promise { - const patch: Partial = { updatedAt: new Date() } - if (data.name !== undefined) patch.name = normalizeName(data.name) - if (data.config !== undefined) patch.config = data.config - if (data.configPatch !== undefined) { - patch.config = sql`${tableViews.config} || ${JSON.stringify(data.configPatch)}::jsonb` - } - if (data.isDefault !== undefined) patch.isDefault = data.isDefault - const outcome = await db.transaction(async (tx) => { // Confirm the target exists BEFORE demoting. The demotion has to run first — // the partial unique index rejects a second default — but on a PATCH naming a @@ -296,10 +489,34 @@ export async function updateTableView(data: UpdateTableViewData): Promise = { updatedAt: new Date() } + if (data.name !== undefined) patch.name = normalizeName(data.name) + if (config !== undefined) patch.config = config + if (configPatch !== undefined) { + patch.config = sql`${tableViews.config} || ${JSON.stringify(configPatch)}::jsonb` + } + if (data.isDefault !== undefined) patch.isDefault = data.isDefault + const nextName = data.name === undefined ? existing.name : normalizeName(data.name) const storedConfig = (existing.config ?? {}) as TableViewConfig - const nextConfig = - data.config ?? (data.configPatch ? { ...storedConfig, ...data.configPatch } : storedConfig) + const nextConfig = config ?? (configPatch ? { ...storedConfig, ...configPatch } : storedConfig) const nextIsDefault = data.isDefault ?? existing.isDefault const changed = nextName !== existing.name || diff --git a/apps/sim/lib/table/workflow-groups/service.test.ts b/apps/sim/lib/table/workflow-groups/service.test.ts new file mode 100644 index 00000000000..dd1f73cc2bf --- /dev/null +++ b/apps/sim/lib/table/workflow-groups/service.test.ts @@ -0,0 +1,103 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { TableDefinition, WorkflowGroup } from '@/lib/table/types' + +const { mockWithLockedTable, mockGetTableById } = vi.hoisted(() => ({ + mockWithLockedTable: vi.fn(), + mockGetTableById: vi.fn(), +})) + +vi.mock('@/lib/table/service', () => ({ + getTableById: mockGetTableById, + withLockedTable: mockWithLockedTable, +})) +vi.mock('@/lib/table/mutation-locks', () => ({ + assertColumnDestructive: vi.fn(), + assertSchemaMutable: vi.fn(), +})) +vi.mock('@/lib/table/rows/secret-provenance', () => ({ + updateTableRowsWithDerivedSecretProvenance: vi.fn(), +})) +vi.mock('@/lib/table/workflow-columns', () => ({ + assertValidSchema: vi.fn(), + runWorkflowColumn: vi.fn().mockResolvedValue(undefined), + stripGroupDeps: (schema: unknown) => schema, +})) + +import { TABLE_LIMITS } from '@/lib/table/constants' +import { addWorkflowGroup } from '@/lib/table/workflow-groups/service' + +function groupAt(index: number): WorkflowGroup { + return { + id: `group-${index}`, + workflowId: 'workflow-1', + outputs: [{ blockId: 'block-1', path: 'out', columnName: `out_${index}` }], + } as WorkflowGroup +} + +function tableWithGroups(count: number): TableDefinition { + return { + id: 'table-1', + name: 'People', + description: null, + schema: { + columns: [{ id: 'col_a', name: 'name', type: 'string' }], + workflowGroups: Array.from({ length: count }, (_unused, index) => groupAt(index)), + }, + metadata: null, + rowCount: 0, + maxRows: 10_000, + workspaceId: 'workspace-1', + createdBy: 'user-1', + archivedAt: null, + createdAt: new Date('2026-01-01'), + updatedAt: new Date('2026-01-01'), + } as TableDefinition +} + +/** + * `GET /tables/{id}/groups` is published as a full-set list — one page, always + * `nextCursor: null`. Nothing made that claim true: the group count had no cap + * of its own, and the indirect bound (a create must add at least one column, and + * columns are capped) does not survive an update path that adds none. + */ +describe('addWorkflowGroup group ceiling', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + function add(existingGroups: number) { + const table = tableWithGroups(existingGroups) + mockWithLockedTable.mockImplementation( + async (_tableId: string, mutate: (t: TableDefinition, trx: unknown) => Promise) => + mutate(table, { + update: () => ({ set: () => ({ where: () => Promise.resolve() }) }), + execute: () => Promise.resolve(), + }) + ) + mockGetTableById.mockResolvedValue(table) + return addWorkflowGroup( + { + tableId: 'table-1', + workspaceId: 'workspace-1', + group: groupAt(9999), + outputColumns: [{ name: 'out_9999', type: 'string', workflowGroupId: 'group-9999' }], + autoRun: false, + actorUserId: 'user-1', + } as Parameters[0], + 'request-1' + ) + } + + it('refuses a create that would cross MAX_WORKFLOW_GROUPS_PER_TABLE', async () => { + await expect(add(TABLE_LIMITS.MAX_WORKFLOW_GROUPS_PER_TABLE)).rejects.toThrow( + /maximum of \d+ workflow groups/ + ) + }) + + it('allows the create that lands exactly on the ceiling', async () => { + await expect(add(TABLE_LIMITS.MAX_WORKFLOW_GROUPS_PER_TABLE - 1)).resolves.toBeDefined() + }) +}) diff --git a/apps/sim/lib/table/workflow-groups/service.ts b/apps/sim/lib/table/workflow-groups/service.ts index 137372f1e68..638a3719163 100644 --- a/apps/sim/lib/table/workflow-groups/service.ts +++ b/apps/sim/lib/table/workflow-groups/service.ts @@ -144,6 +144,13 @@ export async function addWorkflowGroup( ) } + if (groups.length >= TABLE_LIMITS.MAX_WORKFLOW_GROUPS_PER_TABLE) { + throw new OrchestrationError( + 'validation', + `Table has reached the maximum of ${TABLE_LIMITS.MAX_WORKFLOW_GROUPS_PER_TABLE} workflow groups` + ) + } + const existingNames = new Set(schema.columns.map((c) => c.name.toLowerCase())) for (const col of data.outputColumns) { if (!NAME_PATTERN.test(col.name)) { diff --git a/apps/sim/lib/uploads/contexts/execution/utils.ts b/apps/sim/lib/uploads/contexts/execution/utils.ts index b426d0515b3..3cc4e1cf700 100644 --- a/apps/sim/lib/uploads/contexts/execution/utils.ts +++ b/apps/sim/lib/uploads/contexts/execution/utils.ts @@ -1,6 +1,7 @@ import { generateId } from '@sim/utils/id' import { randomFloat } from '@sim/utils/random' -import { isUuid, sanitizeFileName } from '@/executor/constants' +import { buildStorageKeySegment } from '@/lib/uploads/core/storage-key' +import { isUuid } from '@/executor/constants' import type { UserFile } from '@/executor/types' /** @@ -24,7 +25,7 @@ export interface ExecutionContext { */ export function generateLargeValuePayloadKey(context: ExecutionContext, id: string): string { const { workspaceId, workflowId, executionId } = context - const safeFileName = sanitizeFileName(`large-value-${id}.json`) + const safeFileName = buildStorageKeySegment('', `large-value-${id}.json`) return `execution/${workspaceId}/${workflowId}/${executionId}/${safeFileName}` } @@ -47,7 +48,7 @@ export function generateUniqueExecutionFileKey( fileName: string ): string { const { workspaceId, workflowId, executionId } = context - const safeFileName = sanitizeFileName(fileName) + const safeFileName = buildStorageKeySegment('', fileName) return `execution/${workspaceId}/${workflowId}/${executionId}/${generateId()}/${safeFileName}` } diff --git a/apps/sim/lib/uploads/contexts/knowledge-base/knowledge-base-file-manager.ts b/apps/sim/lib/uploads/contexts/knowledge-base/knowledge-base-file-manager.ts index fbaea2c0bf7..e572dc2a7c7 100644 --- a/apps/sim/lib/uploads/contexts/knowledge-base/knowledge-base-file-manager.ts +++ b/apps/sim/lib/uploads/contexts/knowledge-base/knowledge-base-file-manager.ts @@ -1,16 +1,14 @@ import { randomBytes } from 'crypto' -import { sanitizeFileName } from '@/executor/constants' +import { buildStorageKeySegment } from '@/lib/uploads/core/storage-key' /** * Generate a canonical knowledge-base storage key. * - * Direct/presigned uploads previously used the generic `${context}/...` key - * shape (`knowledge-base/...`). New KB uploads should use the same `kb/...` - * prefix as server-side uploads so key-derived context inference is consistent. + * Shares the `kb/...` prefix with server-side uploads so key-derived context + * inference is consistent. */ export function generateKnowledgeBaseFileKey(fileName: string): string { const timestamp = Date.now() const random = randomBytes(8).toString('hex') - const safeFileName = sanitizeFileName(fileName) - return `kb/${timestamp}-${random}-${safeFileName}` + return `kb/${buildStorageKeySegment(`${timestamp}-${random}-`, fileName)}` } diff --git a/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.test.ts b/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.test.ts index f867639089e..a5570d9a950 100644 --- a/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.test.ts +++ b/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.test.ts @@ -3,8 +3,10 @@ */ import { describe, expect, it } from 'vitest' +import { LOCAL_UPLOAD_METADATA_SUFFIX } from '@/lib/uploads/core/storage-key' import { findWorkspaceFileRecord, + generateWorkspaceFileKey, normalizeWorkspaceFileReference, type WorkspaceFileRecord, } from './workspace-file-manager' @@ -90,3 +92,28 @@ describe('workspace file reference normalization', () => { ) }) }) + +/** + * The only place the real key builder is measured — the upload-session suites + * stand a stub in for it — so the budget is asserted here the way the filesystem + * enforces it. Local storage writes a metadata sidecar beside the object under + * the object's own name, so `NAME_MAX` bounds the key's last component PLUS that + * suffix, not the component alone. Measuring the component alone passes with the + * sidecar reservation removed, and the overflow returns as an `ENAMETOOLONG` 500 + * on a name the contract already admitted. + */ +describe('workspace file storage keys', () => { + /** POSIX `NAME_MAX`, in bytes, for one path component. */ + const NAME_MAX = 255 + + it('leaves the longest admitted name room for its local sidecar', () => { + const key = generateWorkspaceFileKey('ws_123', `${'a'.repeat(251)}.txt`) + const lastSegment = key.slice(key.lastIndexOf('/') + 1) + + expect( + Buffer.byteLength(`${lastSegment}${LOCAL_UPLOAD_METADATA_SUFFIX}`, 'utf-8') + ).toBeLessThanOrEqual(NAME_MAX) + expect(key.startsWith('workspace/ws_123/')).toBe(true) + expect(lastSegment.endsWith('.txt')).toBe(true) + }) +}) diff --git a/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts b/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts index fad6d9750a9..daac8d062af 100644 --- a/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts +++ b/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts @@ -57,6 +57,7 @@ import { type WorkspaceFileSecretProvenance, type WorkspaceFileSecretProvenancePolicy, } from '@/lib/uploads/contexts/workspace/workspace-file-secret-provenance' +import { buildStorageKeySegment } from '@/lib/uploads/core/storage-key' import { deleteFile, downloadFile, @@ -67,7 +68,7 @@ import { import { MAX_WORKSPACE_FILE_SIZE, toLegacyWorkspaceFileSize } from '@/lib/uploads/shared/types' import { isMarkdownFile } from '@/lib/uploads/utils/file-utils' import { getWorkspaceWithOwner } from '@/lib/workspaces/permissions/utils' -import { isUuid, sanitizeFileName } from '@/executor/constants' +import { isUuid } from '@/executor/constants' import type { UserFile } from '@/executor/types' import type { WorkspaceFileFolderRecord } from './workspace-file-folder-manager' import { @@ -211,8 +212,7 @@ export function parseWorkspaceFileKey(key: string): string | null { export function generateWorkspaceFileKey(workspaceId: string, fileName: string): string { const timestamp = Date.now() const random = randomBytes(8).toString('hex') - const safeFileName = sanitizeFileName(fileName) - return `workspace/${workspaceId}/${timestamp}-${random}-${safeFileName}` + return `workspace/${workspaceId}/${buildStorageKeySegment(`${timestamp}-${random}-`, fileName)}` } const MAX_COPY_SUFFIX = 1000 diff --git a/apps/sim/lib/uploads/core/storage-key.test.ts b/apps/sim/lib/uploads/core/storage-key.test.ts new file mode 100644 index 00000000000..44e8670a23f --- /dev/null +++ b/apps/sim/lib/uploads/core/storage-key.test.ts @@ -0,0 +1,73 @@ +/** + * @vitest-environment node + */ + +import { describe, expect, it } from 'vitest' +import { generateUniqueExecutionFileKey } from '@/lib/uploads/contexts/execution/utils' +import { generateKnowledgeBaseFileKey } from '@/lib/uploads/contexts/knowledge-base/knowledge-base-file-manager' +import { + buildStorageKeySegment, + LOCAL_UPLOAD_METADATA_SUFFIX, + MAX_STORAGE_KEY_NAME_BYTES, +} from '@/lib/uploads/core/storage-key' + +/** Bytes in the last path component — what POSIX `NAME_MAX` actually bounds. */ +function lastSegmentBytes(key: string): number { + return Buffer.byteLength(key.slice(key.lastIndexOf('/') + 1), 'utf-8') +} + +/** Longest name the workspace-file and knowledge-document contracts admit. */ +const MAX_CONTRACT_NAME = `${'a'.repeat(251)}.txt` + +/** POSIX `NAME_MAX` — what every derived component is ultimately measured against. */ +const NAME_MAX = 255 + +describe('storage key segments', () => { + it('keeps the name when it already fits, sanitizing only', () => { + expect(buildStorageKeySegment('123-abc-', 'quarterly report.csv')).toBe( + '123-abc-quarterly-report.csv' + ) + }) + + it('reserves the prefix out of the segment budget', () => { + const segment = buildStorageKeySegment('123-abc-', MAX_CONTRACT_NAME) + + expect(Buffer.byteLength(segment, 'utf-8')).toBe(MAX_STORAGE_KEY_NAME_BYTES) + expect(segment.startsWith('123-abc-')).toBe(true) + expect(segment.endsWith('.txt')).toBe(true) + }) + + it('drops an extension that would consume the whole budget', () => { + const segment = buildStorageKeySegment('', `name.${'x'.repeat(300)}`) + + expect(Buffer.byteLength(segment, 'utf-8')).toBe(MAX_STORAGE_KEY_NAME_BYTES) + }) + + it('leaves room for the sidecar local storage writes beside the object', () => { + const segment = buildStorageKeySegment('123-abc-', MAX_CONTRACT_NAME) + + expect( + Buffer.byteLength(`${segment}${LOCAL_UPLOAD_METADATA_SUFFIX}`, 'utf-8') + ).toBeLessThanOrEqual(NAME_MAX) + }) + + it('refuses a prefix that leaves no room for a name', () => { + expect(() => buildStorageKeySegment('p'.repeat(255), 'a.txt')).toThrow('no room') + }) + + it.each([ + ['knowledge base', () => generateKnowledgeBaseFileKey(MAX_CONTRACT_NAME)], + [ + 'execution file', + () => + generateUniqueExecutionFileKey( + { workspaceId: 'ws', workflowId: 'wf', executionId: 'ex' }, + MAX_CONTRACT_NAME + ), + ], + ])('bounds the last component of a %s key, sidecar included', (_label, generate) => { + expect(lastSegmentBytes(generate()) + LOCAL_UPLOAD_METADATA_SUFFIX.length).toBeLessThanOrEqual( + NAME_MAX + ) + }) +}) diff --git a/apps/sim/lib/uploads/core/storage-key.ts b/apps/sim/lib/uploads/core/storage-key.ts new file mode 100644 index 00000000000..9188814905d --- /dev/null +++ b/apps/sim/lib/uploads/core/storage-key.ts @@ -0,0 +1,97 @@ +import { sanitizeFileName } from '@/executor/constants' + +/** POSIX `NAME_MAX`: bytes in one *path component*, not in the whole key. */ +const MAX_STORAGE_KEY_SEGMENT_BYTES = 255 + +/** Sidecar attached to local objects promoted through the upload-session transport. */ +export const LOCAL_UPLOAD_METADATA_SUFFIX = '.upload-metadata.json' + +/** + * Roots the local data plane owns inside the upload directory. + * + * Both hold work-in-progress rather than stored objects, so both are swept by + * the local cleanup job. They live beside the other local-artifact names rather + * than in the data-plane provider so the sweep can name what it reclaims + * without importing the transport that writes it; a root known only to its + * writer accumulates forever. + */ +export const LOCAL_MULTIPART_ROOT = '.multipart' +export const LOCAL_STAGING_ROOT = '.staging' + +/** + * Every suffix local storage appends to a stored object's own path component. + * + * `NAME_MAX` bounds those siblings too, so adding an entry here shrinks every + * key builder's budget at once — while a suffix invented at the write site + * silently reopens the overflow this module exists to close. Transient staging + * artifacts need no entry: they are named from the upload id alone. + * + * Every entry is ASCII, so `length` is its byte count. + */ +const MAX_SIDECAR_SUFFIX_BYTES = LOCAL_UPLOAD_METADATA_SUFFIX.length + +/** + * Bytes a key's last component may occupy, sidecars accounted for. + * + * Exported so a store-shaped test can assert the invariant end to end rather + * than restate the arithmetic. + */ +export const MAX_STORAGE_KEY_NAME_BYTES = MAX_STORAGE_KEY_SEGMENT_BYTES - MAX_SIDECAR_SUFFIX_BYTES + +/** + * Longest trailing `.ext` worth preserving through a truncation. Beyond this + * the dot is part of the name, not a type marker, and keeping it would eat the + * whole budget. + */ +const MAX_PRESERVED_EXTENSION_LENGTH = 16 + +/** + * Fits a sanitized name into `budget` characters, keeping its extension so a + * truncated key still reads as the same kind of file. + * + * `sanitizeFileName` maps every character outside `[A-Za-z0-9.-]` to `_`, so its + * output is pure ASCII and one character is one byte. That is what lets this + * measure the budget with `length` instead of re-encoding. + */ +function fitStorageKeyName(safeName: string, budget: number): string { + if (safeName.length <= budget) return safeName + + const dotIndex = safeName.lastIndexOf('.') + const extension = dotIndex > 0 ? safeName.slice(dotIndex) : '' + if (extension.length === 0 || extension.length > MAX_PRESERVED_EXTENSION_LENGTH) { + return safeName.slice(0, budget) + } + if (extension.length >= budget) return safeName.slice(0, budget) + return safeName.slice(0, budget - extension.length) + extension +} + +/** + * Builds the last component of a storage key from a caller-supplied file name. + * + * A name shares its path component with a uniquifier prefix, so the *effective* + * limit is `NAME_MAX − prefix` rather than the 255 the file contracts + * advertise. Local storage writes the key straight into the upload directory, + * so a component past that throws `ENAMETOOLONG` out of `writeFile` — an + * unclassifiable 500 on a name the contract already accepted, and on the + * upload-session path a session whose every later request fails. + * + * Reserving the budget here rather than shrinking the declared `maxLength` + * keeps each caller's limit off its own key prefix and keeps working the names + * that store fine on S3 and GCS, which have no per-component limit. The name in + * a key is a debugging convenience — the row's `originalName` is the identity — + * so truncating it costs nothing. + * + * The budget is {@link MAX_STORAGE_KEY_NAME_BYTES}, not `NAME_MAX` itself: a + * component that fills `NAME_MAX` exactly leaves its sidecar nowhere to go. + * + * @param prefix Must itself leave room for at least one character of the name. + */ +export function buildStorageKeySegment(prefix: string, fileName: string): string { + const budget = MAX_STORAGE_KEY_NAME_BYTES - prefix.length + if (budget < 1) { + throw new Error( + `Storage key prefix of ${prefix.length} bytes leaves no room for a file name within ${MAX_STORAGE_KEY_NAME_BYTES} bytes` + ) + } + return `${prefix}${fitStorageKeyName(sanitizeFileName(fileName), budget)}` +} diff --git a/apps/sim/lib/uploads/core/storage-service.ts b/apps/sim/lib/uploads/core/storage-service.ts index 499b603eec5..26e2a83d6f2 100644 --- a/apps/sim/lib/uploads/core/storage-service.ts +++ b/apps/sim/lib/uploads/core/storage-service.ts @@ -8,6 +8,7 @@ import { USE_GCS_STORAGE, USE_S3_STORAGE, } from '@/lib/uploads/config' +import { LOCAL_UPLOAD_METADATA_SUFFIX } from '@/lib/uploads/core/storage-key' import type { AzureMultipartPart, BlobConfig } from '@/lib/uploads/providers/blob/types' import type { GcsConfig, GcsMultipartPart } from '@/lib/uploads/providers/gcs/types' import type { S3Config, S3MultipartPart } from '@/lib/uploads/providers/s3/types' @@ -25,9 +26,6 @@ import { sanitizeFileKey } from '@/lib/uploads/utils/file-utils' const logger = createLogger('StorageService') -/** Sidecar attached to local objects promoted through the upload-session transport. */ -export const LOCAL_UPLOAD_METADATA_SUFFIX = '.upload-metadata.json' - /** * Create a Blob config from StorageConfig * @throws Error if required properties are missing diff --git a/apps/sim/lib/uploads/shared/types.ts b/apps/sim/lib/uploads/shared/types.ts index af9625d9b7e..37ff23c1bd1 100644 --- a/apps/sim/lib/uploads/shared/types.ts +++ b/apps/sim/lib/uploads/shared/types.ts @@ -25,6 +25,18 @@ export const MAX_WORKSPACE_FORMDATA_FILE_SIZE = 100 * 1024 * 1024 /** Maximum size accepted by the knowledge-document parsing pipeline. */ export const MAX_KNOWLEDGE_DOCUMENT_FILE_SIZE = 100 * 1024 * 1024 +/** + * Rejection wording shared by every surface that admits a knowledge document. + * + * The size guards were upper-bound only, so a zero-byte file passed admission + * and was stored and registered — but the parsing pipeline refuses an empty + * buffer outright (`parseBuffer` throws before dispatching to a parser), so the + * document could never reach anything but `failed`. A file the pipeline is + * guaranteed to reject is a bad request, and admission is the only place a + * caller can be told so. + */ +export const EMPTY_KNOWLEDGE_DOCUMENT_MESSAGE = 'Knowledge document cannot be empty' + export type StorageContext = | 'knowledge-base' | 'chat' diff --git a/apps/sim/lib/uploads/upload-session/cleanup.test.ts b/apps/sim/lib/uploads/upload-session/cleanup.test.ts index 586b76ea702..823e299e58c 100644 --- a/apps/sim/lib/uploads/upload-session/cleanup.test.ts +++ b/apps/sim/lib/uploads/upload-session/cleanup.test.ts @@ -38,6 +38,21 @@ describe('local upload artifact cleanup', () => { await expect(stat(`${testUploadDirectory}/.multipart/fresh`)).resolves.toBeDefined() }) + // A PUT or multipart assembly that dies mid-write leaves a staged object + // behind, so staging lives under a sweep root rather than beside its + // destination. + it('reclaims abandoned staged objects', async () => { + const now = Date.UTC(2026, 7, 4, 12) + await createStagedObject('abandoned.tmp', now - LOCAL_UPLOAD_ARTIFACT_TTL_MS - 1) + await createStagedObject('in-flight.tmp', now) + + await expect(sweepLocalUploadArtifacts({ now })).resolves.toEqual({ scanned: 2, removed: 1 }) + await expect(stat(`${testUploadDirectory}/.staging/abandoned.tmp`)).rejects.toMatchObject({ + code: 'ENOENT', + }) + await expect(stat(`${testUploadDirectory}/.staging/in-flight.tmp`)).resolves.toBeDefined() + }) + it('bounds each sweep by the requested entry count', async () => { const now = Date.UTC(2026, 7, 4, 12) await createArtifact('.multipart/one', now - LOCAL_UPLOAD_ARTIFACT_TTL_MS - 1) @@ -83,6 +98,16 @@ describe('local upload artifact cleanup', () => { }) }) +/** Staged objects are files, not the per-upload directories multipart leaves. */ +async function createStagedObject(name: string, modifiedAt: number): Promise { + const directory = `${testUploadDirectory}/.staging` + await mkdir(directory, { recursive: true }) + const path = `${directory}/${name}` + await writeFile(path, 'test') + const time = new Date(modifiedAt) + await utimes(path, time, time) +} + async function createArtifact(relativePath: string, modifiedAt: number): Promise { const path = `${testUploadDirectory}/${relativePath}` await mkdir(path, { recursive: true }) diff --git a/apps/sim/lib/uploads/upload-session/cleanup.ts b/apps/sim/lib/uploads/upload-session/cleanup.ts index b31cc9558e2..1b5b5d29aa7 100644 --- a/apps/sim/lib/uploads/upload-session/cleanup.ts +++ b/apps/sim/lib/uploads/upload-session/cleanup.ts @@ -2,6 +2,7 @@ import type { Dirent } from 'node:fs' import { opendir, rm, stat } from 'node:fs/promises' import { join } from 'node:path' import { UPLOAD_DIR_SERVER } from '@/lib/uploads/core/setup.server' +import { LOCAL_MULTIPART_ROOT, LOCAL_STAGING_ROOT } from '@/lib/uploads/core/storage-key' export const LOCAL_UPLOAD_CLEANUP_INTERVAL_MS = 15 * 60 * 1000 export const LOCAL_UPLOAD_ARTIFACT_TTL_MS = 25 * 60 * 60 * 1000 @@ -15,7 +16,7 @@ export interface LocalUploadCleanupResult { let activeCleanup: Promise | null = null let lastCleanupAt = 0 -const CLEANUP_ROOTS = ['.multipart'] as const +const CLEANUP_ROOTS = [LOCAL_MULTIPART_ROOT, LOCAL_STAGING_ROOT] as const interface CleanupRootState { directory: Awaited> | null diff --git a/apps/sim/lib/uploads/upload-session/provider.test.ts b/apps/sim/lib/uploads/upload-session/provider.test.ts index b8426003135..b0de0a03ac7 100644 --- a/apps/sim/lib/uploads/upload-session/provider.test.ts +++ b/apps/sim/lib/uploads/upload-session/provider.test.ts @@ -1,9 +1,16 @@ /** * @vitest-environment node */ -import { mkdir, readdir, readFile, rm, stat } from 'node:fs/promises' +import { link, mkdir, readdir, readFile, rm, stat } from 'node:fs/promises' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +/** + * Spied rather than replaced: every assertion in this file reads the real + * filesystem, and the only behaviour worth faking is a single `link` answering + * `EXDEV`, which no temporary directory can be made to produce on its own. + */ +vi.mock('node:fs/promises', { spy: true }) + const { testUploadDirectory, mockS3Presign, mockS3PartUrls } = vi.hoisted(() => ({ testUploadDirectory: `/tmp/sim-upload-session-provider-${process.pid}`, mockS3Presign: vi.fn(), @@ -26,6 +33,7 @@ vi.mock('@/lib/uploads/providers/s3/client', () => ({ getS3MultipartPartUrls: mockS3PartUrls, })) +import { buildStorageKeySegment } from '@/lib/uploads/core/storage-key' import { completeMultipartProviderUpload, createPutProviderTransfer, @@ -39,6 +47,14 @@ import { } from '@/lib/uploads/upload-session/provider' const CONTEXT = 'workspace' as const + +/** Longest name the file contracts admit, in the key shape workspace files use. */ +const MAX_LENGTH_KEY = `workspace/workspace-1/${buildStorageKeySegment( + '1700000000000-0123456789abcdef-', + `${'a'.repeat(251)}.txt` +)}` + +const NAME_MAX = 255 const METADATA = { uploadId: 'upload-1', userId: 'user-1', @@ -108,6 +124,60 @@ describe('local upload-session provider', () => { }) }) + /** + * Staging moved out of the destination's own directory into one shared + * `.staging` root, which is what makes this reachable: a volume mounted under + * part of the uploads tree puts the staged object and its destination on + * different devices, and a hard link cannot span them. Publication has to + * survive that without giving up the create-or-fail the link provides. + */ + it('publishes across a filesystem boundary a hard link cannot span', async () => { + vi.mocked(link).mockRejectedValueOnce( + Object.assign(new Error('EXDEV: cross-device link'), { code: 'EXDEV' }) + ) + + await writeLocalPutObject({ + uploadId: 'upload-1', + key: 'workspace/workspace-1/file.bin', + body: byteStream('ab', 'cd'), + expectedSize: 4, + contentType: 'application/octet-stream', + metadata: METADATA, + }) + + await expect(readFile(localPath('workspace/workspace-1/file.bin'), 'utf8')).resolves.toBe( + 'abcd' + ) + await expect( + headProviderObject({ + provider: 'local', + key: 'workspace/workspace-1/file.bin', + context: CONTEXT, + }) + ).resolves.toMatchObject({ size: 4, uploadId: 'upload-1' }) + expect(await temporaryFiles('workspace/workspace-1')).toEqual([]) + expect(await allEntries('.staging')).toEqual([]) + }) + + it('still refuses to overwrite an existing object when the link cannot span devices', async () => { + const params = { + uploadId: 'upload-1', + key: 'workspace/workspace-1/file.bin', + expectedSize: 3, + contentType: 'application/octet-stream', + metadata: METADATA, + } + await writeLocalPutObject({ ...params, body: byteStream('one') }) + vi.mocked(link).mockRejectedValueOnce( + Object.assign(new Error('EXDEV: cross-device link'), { code: 'EXDEV' }) + ) + + await expect(writeLocalPutObject({ ...params, body: byteStream('two') })).rejects.toThrow() + + await expect(readFile(localPath(params.key), 'utf8')).resolves.toBe('one') + expect(await temporaryFiles('workspace/workspace-1')).toEqual([]) + }) + it('does not let a replayed PUT overwrite the final object', async () => { const params = { uploadId: 'upload-1', @@ -212,6 +282,70 @@ describe('local upload-session provider', () => { ) await expect(stat(localPath('.multipart/upload-1'))).rejects.toMatchObject({ code: 'ENOENT' }) }) + + // A staged object named after its destination overflows `NAME_MAX` at the + // contract's longest name, and the whole session becomes unusable: the + // transfer URL is issued, the PUT against it 500s, and `complete` then reports + // the object missing. + it('stores a PUT under the longest key the name contract can produce', async () => { + await writeLocalPutObject({ + uploadId: '11111111-1111-4111-8111-111111111111', + key: MAX_LENGTH_KEY, + body: byteStream('abc'), + expectedSize: 3, + contentType: 'text/plain', + metadata: METADATA, + }) + + await expect(readFile(localPath(MAX_LENGTH_KEY), 'utf8')).resolves.toBe('abc') + await expect( + headProviderObject({ provider: 'local', key: MAX_LENGTH_KEY, context: CONTEXT }) + ).resolves.toMatchObject({ size: 3, contentType: 'text/plain' }) + expect(await temporaryFiles('workspace/workspace-1')).toEqual([]) + expect(await allEntries('.staging')).toEqual([]) + }) + + it('assembles multipart parts under the longest key the name contract can produce', async () => { + await writeLocalMultipartPart({ + uploadId: 'upload-1', + partNumber: 1, + body: byteStream('abc'), + expectedSize: 3, + }) + + await completeMultipartProviderUpload({ + provider: 'local', + providerUploadId: null, + uploadId: 'upload-1', + key: MAX_LENGTH_KEY, + contentType: 'text/plain', + context: CONTEXT, + parts: [{ partNumber: 1, size: 3 }], + metadata: METADATA, + }) + + await expect(readFile(localPath(MAX_LENGTH_KEY), 'utf8')).resolves.toBe('abc') + expect(await allEntries('.staging')).toEqual([]) + }) + + // The reservation only holds while every local path stays inside one + // component's budget, staged names included. + it('keeps every path component it writes within NAME_MAX', async () => { + await writeLocalPutObject({ + uploadId: '11111111-1111-4111-8111-111111111111', + key: MAX_LENGTH_KEY, + body: byteStream('abc'), + expectedSize: 3, + contentType: 'text/plain', + metadata: METADATA, + }) + + for (const path of await walk(testUploadDirectory)) { + for (const component of path.split('/')) { + expect(Buffer.byteLength(component, 'utf-8')).toBeLessThanOrEqual(NAME_MAX) + } + } + }) }) /** Mirrors `UPLOAD_SESSION_TTL_MS`, imported here as a literal so this suite @@ -388,6 +522,25 @@ function localPath(key: string): string { return `${testUploadDirectory}/${key}` } +async function allEntries(relativeDirectory: string): Promise { + return readdir(localPath(relativeDirectory)).catch((error: NodeJS.ErrnoException) => { + if (error.code === 'ENOENT') return [] + throw error + }) +} + +/** Every path under `directory`, relative to it, files and directories alike. */ +async function walk(directory: string, prefix = ''): Promise { + const entries = await readdir(directory, { withFileTypes: true }) + const paths: string[] = [] + for (const entry of entries) { + const relative = prefix ? `${prefix}/${entry.name}` : entry.name + paths.push(relative) + if (entry.isDirectory()) paths.push(...(await walk(`${directory}/${entry.name}`, relative))) + } + return paths +} + async function temporaryFiles(relativeDirectory: string): Promise { const entries = await readdir(localPath(relativeDirectory)).catch( (error: NodeJS.ErrnoException) => { diff --git a/apps/sim/lib/uploads/upload-session/provider.ts b/apps/sim/lib/uploads/upload-session/provider.ts index 478f6780c40..279da0eac2d 100644 --- a/apps/sim/lib/uploads/upload-session/provider.ts +++ b/apps/sim/lib/uploads/upload-session/provider.ts @@ -1,5 +1,6 @@ import { createReadStream, createWriteStream } from 'node:fs' import { + copyFile, link, mkdir, readdir, @@ -22,11 +23,15 @@ import { USE_S3_STORAGE, } from '@/lib/uploads/config' import { UPLOAD_DIR_SERVER } from '@/lib/uploads/core/setup.server' +import { + LOCAL_MULTIPART_ROOT, + LOCAL_STAGING_ROOT, + LOCAL_UPLOAD_METADATA_SUFFIX, +} from '@/lib/uploads/core/storage-key' import { createBlobConfig, createGcsConfig, createS3Config, - LOCAL_UPLOAD_METADATA_SUFFIX, } from '@/lib/uploads/core/storage-service' import type { StorageContext } from '@/lib/uploads/shared/types' import type { UploadStorageProvider } from '@/lib/uploads/upload-session/types' @@ -512,9 +517,11 @@ export async function writeLocalPutObject(params: { }): Promise { const { Readable, Transform } = await import('node:stream') const destination = localObjectPath(params.key) - const temporary = `${destination}.${params.uploadId}-${generateId()}.tmp` - const temporaryMetadata = `${temporary}${LOCAL_UPLOAD_METADATA_SUFFIX}` - await mkdir(dirname(destination), { recursive: true }) + const { object: temporary, metadata: temporaryMetadata } = localStagedPaths(params.uploadId) + await Promise.all([ + mkdir(dirname(destination), { recursive: true }), + mkdir(dirname(temporary), { recursive: true }), + ]) let bytes = 0 const counter = new Transform({ transform(chunk: Buffer, _encoding, callback) { @@ -603,7 +610,30 @@ export async function writeLocalMultipartPart(params: { } function localPartsDirectory(uploadId: string): string { - return join(UPLOAD_DIR_SERVER, '.multipart', uploadId) + return join(UPLOAD_DIR_SERVER, LOCAL_MULTIPART_ROOT, uploadId) +} + +/** + * Paths for an object being staged before it is published at its final key. + * + * Staged names are derived from the upload id alone, never from the + * destination. A temporary built as `destination + suffix` inherits the + * destination's length and then adds to it, so a key that fits `NAME_MAX` + * exactly still failed with `ENAMETOOLONG`: that is the 500 the upload-session + * PUT returned for any file name past roughly 125 characters, and the identical + * failure multipart `complete` returned while assembling one. Deriving the + * staged name from a fixed-width id removes the arithmetic rather than + * re-budgeting it — no suffix added here can depend on the caller's file name, + * so no future suffix can reintroduce the overflow. + * + * The staging root sits inside `UPLOAD_DIR_SERVER`, which keeps publication a + * same-filesystem `link` and lets the cleanup sweep reclaim what a crashed + * request left behind — artifacts written next to the destination were never + * swept at all. + */ +function localStagedPaths(uploadId: string): { object: string; metadata: string } { + const object = join(UPLOAD_DIR_SERVER, LOCAL_STAGING_ROOT, `${uploadId}-${generateId()}.tmp`) + return { object, metadata: `${object}${LOCAL_UPLOAD_METADATA_SUFFIX}` } } function localPartPath(uploadId: string, partNumber: number): string { @@ -626,9 +656,11 @@ async function assembleLocalParts( metadata: Record ): Promise { const destination = localObjectPath(key) - const temporary = `${destination}.${uploadId}-${generateId()}.tmp` - const temporaryMetadata = `${temporary}${LOCAL_UPLOAD_METADATA_SUFFIX}` - await mkdir(dirname(destination), { recursive: true }) + const { object: temporary, metadata: temporaryMetadata } = localStagedPaths(uploadId) + await Promise.all([ + mkdir(dirname(destination), { recursive: true }), + mkdir(dirname(temporary), { recursive: true }), + ]) try { for (const part of parts) { await pipeline( @@ -674,15 +706,48 @@ async function listLocalMultipartParts(uploadId: string): Promise { + try { + await link(source, destination) + return + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'EXDEV') throw error + } + const sameDeviceCopy = join(dirname(destination), `.${generateId()}.publish`) + try { + await copyFile(source, sameDeviceCopy) + await link(sameDeviceCopy, destination) + } finally { + await rm(sameDeviceCopy, { force: true }) + } +} + async function publishLocalObject( temporary: string, temporaryMetadata: string, destination: string, destinationMetadata: string ): Promise { - await link(temporary, destination) + await linkLocalArtifact(temporary, destination) try { - await link(temporaryMetadata, destinationMetadata) + await linkLocalArtifact(temporaryMetadata, destinationMetadata) } catch (error) { await rm(destination, { force: true }) throw error diff --git a/apps/sim/lib/uploads/upload-session/service.test.ts b/apps/sim/lib/uploads/upload-session/service.test.ts index 899a7b889ab..96b9413780d 100644 --- a/apps/sim/lib/uploads/upload-session/service.test.ts +++ b/apps/sim/lib/uploads/upload-session/service.test.ts @@ -34,11 +34,21 @@ vi.mock('@/lib/billing/storage', () => ({ resolveStorageBillingContext: mockResolveBillingContext, })) -vi.mock('@/lib/uploads/contexts/workspace', () => ({ - generateWorkspaceFileKey: vi.fn( - (workspaceId: string, fileName: string) => `workspace/${workspaceId}/final-${fileName}` - ), -})) +/** + * Stands in for the workspace-files barrel, which pulls the whole file manager. + * The real `generateWorkspaceFileKey` and its name budget are measured in + * `contexts/workspace/workspace-file-manager.test.ts`, so the purposes that key + * through it are deliberately absent from the sidecar-bounds sweep below. + */ +vi.mock('@/lib/uploads/contexts/workspace', async () => { + const { buildStorageKeySegment } = await import('@/lib/uploads/core/storage-key') + return { + generateWorkspaceFileKey: vi.fn( + (workspaceId: string, fileName: string) => + `workspace/${workspaceId}/${buildStorageKeySegment('final-', fileName)}` + ), + } +}) vi.mock('@/lib/uploads/upload-session/cleanup', () => ({ maybeCleanupLocalUploadArtifacts: vi.fn().mockResolvedValue({ scanned: 0, removed: 0 }), @@ -56,6 +66,7 @@ vi.mock('@/lib/uploads/upload-session/provider', () => ({ uploadStorageProvider: vi.fn(() => 's3'), })) +import { LOCAL_UPLOAD_METADATA_SUFFIX } from '@/lib/uploads/core/storage-key' import { abortUploadSession, assertUploadSessionAuthBinding, @@ -137,6 +148,77 @@ describe('upload sessions', () => { }) }) + // Local storage stores an object's metadata sidecar beside it, under the + // object's own name, so the whole key + suffix must fit one path component. + // Three purposes built their key by hand and admitted a 255-character name + // straight into it: the session was created, its transfer URL issued, and + // every request against it then failed with an unclassifiable 500. + it.each([ + ['knowledge_document', { knowledgeBaseId: 'kb-1' }], + ['table_import', {}], + ['profile_picture', {}], + ['workspace_logo', {}], + ['execution_attachment', { workflowId: 'workflow-1', executionId: 'execution-1' }], + ])('bounds the %s key so its local sidecar still fits', async (purpose, extra) => { + dbChainMockFns.returning.mockResolvedValue([uploadRow({ purpose })]) + + await createUploadSession({ + id: 'upload-1', + workspaceId: WORKSPACE_ID, + userId: 'user-1', + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + purpose: purpose as Parameters[0]['purpose'], + fileName: `${'a'.repeat(251)}.txt`, + contentType: 'text/plain', + fileSize: 4, + localOrigin: 'http://localhost:3000', + ...extra, + } as Parameters[0]) + + const { finalKey } = dbChainMockFns.values.mock.calls[0][0] + const lastComponent = finalKey.slice(finalKey.lastIndexOf('/') + 1) + expect( + Buffer.byteLength(`${lastComponent}${LOCAL_UPLOAD_METADATA_SUFFIX}`, 'utf-8') + ).toBeLessThanOrEqual(255) + }) + + /** + * A knowledge document the pipeline provably refuses is rejected on admission + * whichever route carries it: the direct upload use case rejects a zero-byte + * buffer, and the session path refuses the same file before it hands out a + * transfer URL for it. `workspace_file` is the deliberate exception — an empty + * file is a legitimate thing to keep in a workspace — so pinning both keeps + * the split a decision rather than an omission. + */ + it.each([ + ['knowledge_document', { knowledgeBaseId: 'kb-1' }, true], + ['workspace_file', {}, false], + ])( + 'admits a zero-byte %s only where an empty file is legitimate', + async (purpose, extra, refused) => { + dbChainMockFns.returning.mockResolvedValue([uploadRow({ purpose })]) + + const create = createUploadSession({ + id: 'upload-1', + workspaceId: WORKSPACE_ID, + userId: 'user-1', + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + purpose: purpose as Parameters[0]['purpose'], + fileName: 'empty.txt', + contentType: 'text/plain', + fileSize: 0, + localOrigin: 'http://localhost:3000', + ...(extra as object), + } as Parameters[0]) + + if (refused) { + await expect(create).rejects.toThrow('fileSize must be a positive integer') + } else { + await expect(create).resolves.toBeDefined() + } + } + ) + it('allocates distinct keys for same-named execution attachments', async () => { dbChainMockFns.returning .mockResolvedValueOnce([ diff --git a/apps/sim/lib/uploads/upload-session/service.ts b/apps/sim/lib/uploads/upload-session/service.ts index a4386de8753..3f80269150a 100644 --- a/apps/sim/lib/uploads/upload-session/service.ts +++ b/apps/sim/lib/uploads/upload-session/service.ts @@ -15,6 +15,7 @@ import { OrchestrationError } from '@/lib/core/orchestration/types' import { generateUniqueExecutionFileKey } from '@/lib/uploads/contexts/execution/utils' import { generateKnowledgeBaseFileKey } from '@/lib/uploads/contexts/knowledge-base/knowledge-base-file-manager' import { generateWorkspaceFileKey } from '@/lib/uploads/contexts/workspace' +import { buildStorageKeySegment } from '@/lib/uploads/core/storage-key' import { MAX_KNOWLEDGE_DOCUMENT_FILE_SIZE, MAX_WORKSPACE_FILE_SIZE, @@ -41,7 +42,6 @@ import type { UploadStorageProvider, UploadTransferMethod, } from '@/lib/uploads/upload-session/types' -import { sanitizeFileName } from '@/executor/constants' export const UPLOAD_SESSION_PUT_MAX_BYTES = 50 * 1024 * 1024 export const UPLOAD_SESSION_PART_SIZE = 8 * 1024 * 1024 @@ -1200,7 +1200,7 @@ function resolveUploadStorage( case 'table_import': return { storageContext: 'table-import', - finalKey: `table-import/${params.workspaceId}/${id}/${sanitizeFileName(params.fileName)}`, + finalKey: `table-import/${params.workspaceId}/${id}/${buildStorageKeySegment('', params.fileName)}`, } case 'knowledge_document': return { @@ -1210,12 +1210,12 @@ function resolveUploadStorage( case 'profile_picture': return { storageContext: 'profile-pictures', - finalKey: `profile-pictures/${id}-${sanitizeFileName(params.fileName)}`, + finalKey: `profile-pictures/${buildStorageKeySegment(`${id}-`, params.fileName)}`, } case 'workspace_logo': return { storageContext: 'workspace-logos', - finalKey: `workspace-logos/${params.workspaceId}/${id}-${sanitizeFileName(params.fileName)}`, + finalKey: `workspace-logos/${params.workspaceId}/${buildStorageKeySegment(`${id}-`, params.fileName)}`, } case 'mothership_attachment': return { diff --git a/apps/sim/lib/workflows/application/list-workflows.ts b/apps/sim/lib/workflows/application/list-workflows.ts index cf92b3863f2..9257735ec80 100644 --- a/apps/sim/lib/workflows/application/list-workflows.ts +++ b/apps/sim/lib/workflows/application/list-workflows.ts @@ -1,8 +1,7 @@ import { createLogger } from '@sim/logger' import type { CursorKey } from '@/lib/api/list-query' -import { OrchestrationError } from '@/lib/core/orchestration/types' import { MAX_FOLDERS_PER_WORKSPACE } from '@/lib/folders/constants' -import { loadActiveFolderPathIndex } from '@/lib/folders/queries' +import { loadActiveFolderPathIndex, resolveFolderPathFilter } from '@/lib/folders/queries' import { defineAuthorizedWorkflowUseCase } from '@/lib/workflows/application/authorized-workflow-use-case' import { resolveActiveWorkspaceApplicationContext } from '@/lib/workflows/application/context' import { workflowOperations } from '@/lib/workflows/application/operations' @@ -37,19 +36,19 @@ export const listWorkflows = defineAuthorizedWorkflowUseCase({ undefined, { maxRows: MAX_FOLDERS_PER_WORKSPACE } ) - const folderId = - input.folderPath === undefined - ? undefined - : input.folderPath === '/' - ? null - : folderIndex.idByPath.get(input.folderPath) - if (input.folderPath !== undefined && folderId === undefined) { - throw new OrchestrationError('not_found', 'Folder not found') + const folderFilter = resolveFolderPathFilter(folderIndex, input.folderPath) + if (folderFilter.kind === 'noMatch') { + return { + workflows: [], + nextCursorKeys: null, + sortBy: input.sortBy, + sortOrder: input.sortOrder, + } } const page = await listWorkspaceWorkflows({ workspaceId: context.workspaceId, - folderId, + folderId: folderFilter.kind === 'folder' ? folderFilter.folderId : undefined, deployedOnly: input.deployedOnly, search: input.search, sortBy: input.sortBy, diff --git a/apps/sim/lib/workflows/executor/execution-core.test.ts b/apps/sim/lib/workflows/executor/execution-core.test.ts index 753f632109c..91aec41b9ec 100644 --- a/apps/sim/lib/workflows/executor/execution-core.test.ts +++ b/apps/sim/lib/workflows/executor/execution-core.test.ts @@ -1153,6 +1153,33 @@ describe('executeWorkflowCore terminal finalization sequencing', () => { expect(clearExecutionCancellationMock).toHaveBeenCalledWith('execution-1') }) + /** + * The population `runCount` actually counts. Cancelled and paused runs are + * already pinned above; a plain failure is the case a caller is most likely to + * assume is included, and the workflow contract's `runCount` description is + * written against this. + */ + it('leaves runCount untouched when the run fails', async () => { + executorExecuteMock.mockResolvedValue({ + success: false, + status: 'failed', + output: {}, + logs: [], + error: 'block threw', + metadata: { duration: 123, startTime: 'start', endTime: 'end' }, + }) + + await executeWorkflowCore({ + snapshot: createSnapshot() as any, + callbacks: {}, + loggingSession: loggingSession as any, + }) + + await loggingSession.setPostExecutionPromise.mock.calls[0][0] + + expect(updateWorkflowRunCountsMock).not.toHaveBeenCalled() + }) + it('routes paused executions through safeCompleteWithPause', async () => { const executionState = { blockStates: { 'function-1': { output: { result: 'raw-secret-value' } } }, diff --git a/apps/sim/lib/workflows/executor/human-in-the-loop-manager.test.ts b/apps/sim/lib/workflows/executor/human-in-the-loop-manager.test.ts index 60c6cccbe4e..30a1706697d 100644 --- a/apps/sim/lib/workflows/executor/human-in-the-loop-manager.test.ts +++ b/apps/sim/lib/workflows/executor/human-in-the-loop-manager.test.ts @@ -1696,3 +1696,108 @@ describe('PauseResumeManager resume log claims', () => { }) }) }) + +/** + * Every refusal here is an ordinary client outcome — a stale `contextId`, a run + * someone else already resumed, a pause of the wrong kind for the endpoint. The + * resume surfaces classify a failure by its `statusCode`, so an untyped throw + * for any of these reaches the caller as a `500` and tells them nothing about + * what to fix. + */ +describe('PauseResumeManager.enqueueOrStartResume admission refusals', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + }) + + function pausedRow(overrides: Record = {}) { + return { + id: 'paused-exec-1', + workflowId: 'workflow-1', + executionId: 'execution-1', + status: 'paused', + pausePoints: { + 'ctx-1': { contextId: 'ctx-1', resumeStatus: 'paused', snapshotReady: true }, + }, + ...overrides, + } + } + + function enqueue(allowedPauseKinds?: ('human' | 'time')[]) { + return PauseResumeManager.enqueueOrStartResume({ + executionId: 'execution-1', + workflowId: 'workflow-1', + contextId: 'ctx-1', + resumeInput: {}, + userId: 'user-1', + allowedPauseKinds, + }) + } + + it.each([ + ['a run with no paused row', undefined, 404, 'Paused execution not found or already resumed'], + [ + 'a paused row in a terminal state', + pausedRow({ status: 'cancelled' }), + 409, + 'Paused execution is not resumable', + ], + [ + 'an unknown pause point', + pausedRow({ pausePoints: {} }), + 404, + 'Pause point not found for execution', + ], + [ + 'a pause point already being resumed', + pausedRow({ + pausePoints: { 'ctx-1': { resumeStatus: 'resuming', snapshotReady: true } }, + }), + 409, + 'Pause point already resumed or in progress', + ], + [ + 'a pause still finalizing its snapshot', + pausedRow({ pausePoints: { 'ctx-1': { resumeStatus: 'paused', snapshotReady: false } } }), + 409, + 'Snapshot not ready; execution still finalizing pause', + ], + ])('reports %s with its own status', async (_case, row, statusCode, message) => { + dbChainMockFns.limit.mockResolvedValueOnce(row ? [row] : []) + + await expect(enqueue()).rejects.toMatchObject({ + name: 'ResumeAdmissionError', + message, + statusCode, + }) + }) + + it('reports a pause of the wrong kind for the endpoint as a bad request', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([ + pausedRow({ + pausePoints: { + 'ctx-1': { resumeStatus: 'paused', snapshotReady: true, pauseKind: 'time' }, + }, + }), + ]) + + await expect(enqueue(['human'])).rejects.toMatchObject({ + name: 'ResumeAdmissionError', + statusCode: 400, + }) + }) + + /** + * A snapshot that has not finished persisting is the one refusal that a later + * automatic attempt can clear; the rest read identically on every retry. + */ + it('marks only the still-finalizing snapshot as worth retrying', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([ + pausedRow({ pausePoints: { 'ctx-1': { resumeStatus: 'paused', snapshotReady: false } } }), + ]) + await expect(enqueue()).rejects.toMatchObject({ retryable: true }) + + dbChainMockFns.limit.mockResolvedValueOnce([]) + await expect(enqueue()).rejects.toMatchObject({ retryable: false }) + }) +}) diff --git a/apps/sim/lib/workflows/executor/human-in-the-loop-manager.ts b/apps/sim/lib/workflows/executor/human-in-the-loop-manager.ts index 155b385351d..75951205a45 100644 --- a/apps/sim/lib/workflows/executor/human-in-the-loop-manager.ts +++ b/apps/sim/lib/workflows/executor/human-in-the-loop-manager.ts @@ -105,6 +105,20 @@ async function releaseCancelledResumeReservations( ) } +/** + * A resume attempt that was not admitted, carrying the status the caller should + * see. Every admission refusal must be raised through this rather than a bare + * `Error`: the resume surfaces classify a failure by its `statusCode`, so an + * untyped throw for an ordinary client mistake — a stale `contextId`, an + * already-resumed pause — reaches the caller as a `500`. + * + * `retryable` says whether an automatic resume should try the attempt again. + * Only a pause still finalizing its snapshot is; a pause that is absent, in the + * wrong state, or of the wrong kind will read the same on every retry. + * + * Messages must stay free of identifiers, snapshot contents, and ORM detail — + * they are forwarded verbatim to API callers. + */ class ResumeAdmissionError extends Error { constructor( message: string, @@ -654,29 +668,35 @@ export class PauseResumeManager { .then((rows) => rows[0]) if (!pausedExecution) { - throw new Error('Paused execution not found or already resumed') + throw new ResumeAdmissionError('Paused execution not found or already resumed', 404, false) } if (!isResumablePausedStatus(pausedExecution.status)) { - throw new Error('Paused execution is not resumable') + throw new ResumeAdmissionError('Paused execution is not resumable', 409, false) } const pausePoints = pausedExecution.pausePoints as Record const pausePoint = pausePoints?.[contextId] if (!pausePoint) { - throw new Error('Pause point not found for execution') + throw new ResumeAdmissionError('Pause point not found for execution', 404, false) } if (pausePoint.resumeStatus !== 'paused') { - throw new Error('Pause point already resumed or in progress') + throw new ResumeAdmissionError('Pause point already resumed or in progress', 409, false) } if (!pausePoint.snapshotReady) { - throw new Error('Snapshot not ready; execution still finalizing pause') + throw new ResumeAdmissionError( + 'Snapshot not ready; execution still finalizing pause', + 409, + true + ) } const pauseKind: PauseKind = pausePoint.pauseKind ?? 'human' if (allowedPauseKinds && !allowedPauseKinds.includes(pauseKind)) { - throw new Error( - `Pause kind '${pauseKind}' is not allowed for this resume endpoint (allowed: ${allowedPauseKinds.join(', ')})` + throw new ResumeAdmissionError( + `Pause kind '${pauseKind}' is not allowed for this resume endpoint (allowed: ${allowedPauseKinds.join(', ')})`, + 400, + false ) } diff --git a/apps/sim/lib/workflows/orchestration/workflow-lifecycle.test.ts b/apps/sim/lib/workflows/orchestration/workflow-lifecycle.test.ts new file mode 100644 index 00000000000..e4acadb6f3e --- /dev/null +++ b/apps/sim/lib/workflows/orchestration/workflow-lifecycle.test.ts @@ -0,0 +1,90 @@ +/** + * @vitest-environment node + */ +import { + auditMock, + dbChainMockFns, + posthogServerMock, + resetDbChainMock, + workflowAuthzMockFns, + workflowsPersistenceUtilsMock, + workflowsPersistenceUtilsMockFns, + workflowsUtilsMock, +} from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@sim/audit', () => auditMock) +vi.mock('@/lib/posthog/server', () => posthogServerMock) +vi.mock('@/lib/workflows/persistence/utils', () => workflowsPersistenceUtilsMock) +vi.mock('@/lib/workflows/utils', () => workflowsUtilsMock) +vi.mock('@/lib/workflows/lifecycle', () => ({ + archiveWorkflow: vi.fn(), + restoreWorkflow: vi.fn(), +})) +vi.mock('@/lib/workflows/defaults', () => ({ + buildDefaultWorkflowArtifacts: () => ({ + workflowState: { blocks: {}, edges: [], loops: {}, parallels: {} }, + subBlockValues: {}, + startBlockId: 'start-1', + }), +})) + +import { performCreateWorkflowTransition } from '@/lib/workflows/orchestration/workflow-lifecycle' + +/** Shape the `postgres` driver throws for a unique violation. */ +const uniqueViolation = (constraintName: string) => + Object.assign(new Error('duplicate key value violates unique constraint'), { + code: '23505', + constraint_name: constraintName, + }) + +const createParams = { + userId: 'user-1', + workspaceId: 'workspace-1', + name: 'My Workflow', +} + +describe('performCreateWorkflowTransition unique-violation handling', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + workflowAuthzMockFns.mockIsFolderInWorkspace.mockResolvedValue(true) + workflowsPersistenceUtilsMockFns.mockSaveWorkflowToNormalizedTables.mockResolvedValue({ + success: true, + }) + }) + + it('reports a lost name race as a conflict', async () => { + dbChainMockFns.transaction.mockRejectedValueOnce( + uniqueViolation('workflow_workspace_folder_name_active_unique') + ) + + const result = await performCreateWorkflowTransition(createParams) + + expect(result).toEqual({ + success: false, + error: 'A workflow named "My Workflow" already exists in this folder', + errorCode: 'conflict', + }) + }) + + it('does not report a block-id collision as a name conflict', async () => { + /** + * `workflow_blocks.id` is a global primary key and the same transaction runs + * `saveWorkflowToNormalizedTables`, so a colliding block id raises `23505` + * from a constraint that has nothing to do with the workflow name. Relabelling + * it hides an integrity fault behind a message about a duplicate name. + */ + const collision = uniqueViolation('workflow_blocks_pkey') + dbChainMockFns.transaction.mockRejectedValueOnce(collision) + + await expect(performCreateWorkflowTransition(createParams)).rejects.toBe(collision) + }) + + it('propagates a unique violation that carries no constraint name', async () => { + const opaque = Object.assign(new Error('duplicate key value'), { code: '23505' }) + dbChainMockFns.transaction.mockRejectedValueOnce(opaque) + + await expect(performCreateWorkflowTransition(createParams)).rejects.toBe(opaque) + }) +}) diff --git a/apps/sim/lib/workflows/orchestration/workflow-lifecycle.ts b/apps/sim/lib/workflows/orchestration/workflow-lifecycle.ts index 18210c81e8a..0bb8ab307f1 100644 --- a/apps/sim/lib/workflows/orchestration/workflow-lifecycle.ts +++ b/apps/sim/lib/workflows/orchestration/workflow-lifecycle.ts @@ -3,7 +3,7 @@ import { db } from '@sim/db' import { folder as folderTable, workflow } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { isFolderInWorkspace } from '@sim/platform-authz/workflow' -import { toError } from '@sim/utils/errors' +import { getPostgresConstraintName, getPostgresErrorCode, toError } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { and, eq, isNull, min, ne } from 'drizzle-orm' import type { OrchestrationErrorCode } from '@/lib/core/orchestration/types' @@ -17,6 +17,9 @@ import { deduplicateWorkflowName } from '@/lib/workflows/utils' const logger = createLogger('WorkflowLifecycle') +/** Partial unique index on `(workspace_id, coalesce(folder_id, ''), name) WHERE archived_at IS NULL`. */ +const WORKFLOW_NAME_UNIQUE_INDEX = 'workflow_workspace_folder_name_active_unique' + export interface PerformCreateWorkflowParams { userId: string workspaceId: string @@ -258,25 +261,52 @@ export async function performCreateWorkflowTransition( const now = new Date() const { workflowState, subBlockValues, startBlockId } = buildDefaultWorkflowArtifacts() - await db.transaction(async (tx) => { - await tx.insert(workflow).values({ - id: workflowId, - userId: params.userId, - workspaceId: params.workspaceId, - folderId, - sortOrder, - name, - description: params.description, - lastSynced: now, - createdAt: now, - updatedAt: now, - isDeployed: false, - runCount: 0, - variables: {}, - }) + try { + await db.transaction(async (tx) => { + await tx.insert(workflow).values({ + id: workflowId, + userId: params.userId, + workspaceId: params.workspaceId, + folderId, + sortOrder, + name, + description: params.description, + lastSynced: now, + createdAt: now, + updatedAt: now, + isDeployed: false, + runCount: 0, + variables: {}, + }) - await saveWorkflowToNormalizedTables(workflowId, workflowState, tx) - }) + await saveWorkflowToNormalizedTables(workflowId, workflowState, tx) + }) + } catch (error) { + /** + * The name pre-check above is a `SELECT`, so two concurrent creates of the same + * name both pass it and the loser is rejected by + * {@link WORKFLOW_NAME_UNIQUE_INDEX} as a raw Postgres `23505`. Reported as the + * conflict the pre-check already raises, so a caller sees one answer whether it + * lost the race or simply arrived second. + * + * Matched on the constraint name, not on the code alone. This transaction also + * runs `saveWorkflowToNormalizedTables`, whose inserts can raise `23505` from + * `workflow_blocks_pkey` — a globally unique block id colliding across + * workflows, an integrity fault this repository has already hit in production. + * A code-only match reported that as a name conflict and hid it. + */ + if ( + getPostgresErrorCode(error) === '23505' && + getPostgresConstraintName(error) === WORKFLOW_NAME_UNIQUE_INDEX + ) { + return { + success: false, + error: `A workflow named "${name}" already exists in this folder`, + errorCode: 'conflict', + } + } + throw error + } logger.info(`[${requestId}] Successfully created workflow ${workflowId}`) diff --git a/apps/sim/lib/workflows/persistence/save-normalized-state.test.ts b/apps/sim/lib/workflows/persistence/save-normalized-state.test.ts index 9240f827fc9..decd033725a 100644 --- a/apps/sim/lib/workflows/persistence/save-normalized-state.test.ts +++ b/apps/sim/lib/workflows/persistence/save-normalized-state.test.ts @@ -40,9 +40,13 @@ describe('parseWorkflowStateForPersistence', () => { const deployedAt = new Date('2026-01-02T03:04:05.678Z') const fromDate = parseWorkflowStateForPersistence(checkpointState({ deployedAt })) - const overTheWire = parseWorkflowStateForPersistence( - JSON.parse(JSON.stringify(checkpointState({ deployedAt }))) - ) + /** + * Serialized and parsed as two steps, not `structuredClone`: the point is the + * lossy JSON round trip that turns the `Date` into a string, which a + * structured clone would preserve and so would not exercise the wire form. + */ + const serialized = JSON.stringify(checkpointState({ deployedAt })) + const overTheWire = parseWorkflowStateForPersistence(JSON.parse(serialized)) expect(fromDate.success).toBe(true) expect(overTheWire.success).toBe(true) diff --git a/apps/sim/lib/workspace-files/application/compiled-check-workspace-file.test.ts b/apps/sim/lib/workspace-files/application/compiled-check-workspace-file.test.ts index 04172b19904..27967b13cee 100644 --- a/apps/sim/lib/workspace-files/application/compiled-check-workspace-file.test.ts +++ b/apps/sim/lib/workspace-files/application/compiled-check-workspace-file.test.ts @@ -102,6 +102,16 @@ describe('compiledCheckWorkspaceFile', () => { }, ] + /** + * A workspace key is refused with the code naming *why* — the operation + * denies workspace keys, so the remedy is a personal key — while any other + * disallowed kind gets the generic kind refusal. + */ + const expectedDetailCode = { + personal_api_key: 'PRINCIPAL_KIND_NOT_PERMITTED', + workspace_api_key: 'WORKSPACE_KEY_OPERATION_NOT_PERMITTED', + } as const + for (const principal of unsupportedPrincipals) { await expect( compiledCheckWorkspaceFile.execute({ @@ -110,7 +120,7 @@ describe('compiledCheckWorkspaceFile', () => { }) ).rejects.toMatchObject({ code: 'forbidden', - message: `Principal kind ${principal.kind} cannot perform operation files.compiled_check`, + detailCode: expectedDetailCode[principal.kind], }) } diff --git a/apps/sim/lib/workspace-files/application/list-workspace-files.ts b/apps/sim/lib/workspace-files/application/list-workspace-files.ts index 92238a897c4..ac855b1ed57 100644 --- a/apps/sim/lib/workspace-files/application/list-workspace-files.ts +++ b/apps/sim/lib/workspace-files/application/list-workspace-files.ts @@ -1,7 +1,7 @@ import type { CursorKey } from '@/lib/api/list-query' import { OrchestrationError } from '@/lib/core/orchestration/types' -import { ROOT_FOLDER_PATH } from '@/lib/folders/paths' -import { loadActiveFolderPathIndex } from '@/lib/folders/queries' +import { MAX_FOLDERS_PER_WORKSPACE } from '@/lib/folders/constants' +import { loadActiveFolderPathIndex, resolveFolderPathFilter } from '@/lib/folders/queries' import { getWorkspaceShares } from '@/lib/public-shares/share-manager' import { listWorkspaceFiles, @@ -26,7 +26,6 @@ export interface QueryWorkspaceFilePageInput { sortOrder: 'asc' | 'desc' limit: number after?: CursorKey[] - cursorSort: string } async function resolveListWorkspaceFileContext(workspaceId: string) { @@ -53,26 +52,29 @@ export const queryWorkspaceFilePage = defineAuthorizedWorkspaceFileUseCase({ resolveContext: ({ input }: { input: QueryWorkspaceFilePageInput }) => resolveListWorkspaceFileContext(input.workspaceId), async execute({ input, context }) { - const folderIndex = await loadActiveFolderPathIndex(context.workspaceId, 'file') - const folderId = - input.folderPath === undefined - ? undefined - : input.folderPath === ROOT_FOLDER_PATH - ? null - : folderIndex.idByPath.get(input.folderPath) - if (input.folderPath !== undefined && folderId === undefined) { - throw new OrchestrationError('not_found', 'Folder not found') - } + /** + * Capped the way the workflow, table, and knowledge lists cap theirs. A + * truncated index does not fail — it silently loses paths, and the only + * consumer here is the `folderPath` filter, so a real folder outside the + * read rows would resolve to nothing and the caller would get an empty page + * for a folder that has files in it. The cap turns that into the same 413 + * the sibling lists answer. + */ + const folderIndex = await loadActiveFolderPathIndex(context.workspaceId, 'file', undefined, { + maxRows: MAX_FOLDERS_PER_WORKSPACE, + }) + const folderFilter = resolveFolderPathFilter(folderIndex, input.folderPath) + if (folderFilter.kind === 'noMatch') return { files: [], nextKeys: null } const { files, nextKeys } = await queryWorkspaceFiles(context.workspaceId, { scope: input.scope, - folderId, + folderId: folderFilter.kind === 'folder' ? folderFilter.folderId : undefined, search: input.search, sortBy: input.sortBy, sortOrder: input.sortOrder, limit: input.limit, after: input.after, }) - return { files, nextKeys, cursorSort: input.cursorSort } + return { files, nextKeys } }, }) diff --git a/apps/sim/lib/workspace-files/application/share-workspace-file.ts b/apps/sim/lib/workspace-files/application/share-workspace-file.ts index 051957f390c..e28789a8846 100644 --- a/apps/sim/lib/workspace-files/application/share-workspace-file.ts +++ b/apps/sim/lib/workspace-files/application/share-workspace-file.ts @@ -2,6 +2,7 @@ import { AuditAction, AuditResourceType } from '@sim/audit' import { requirePrincipalSubjectUserId } from '@sim/auth/principal' import { createLogger } from '@sim/logger' import type { ShareAuthType, ShareRecord } from '@/lib/api/contracts/public-shares' +import { ForbiddenOperationError } from '@/lib/core/application/forbidden' import { OrchestrationError } from '@/lib/core/orchestration/types' import { getShareForResource, @@ -84,7 +85,7 @@ export const updateWorkspaceFileShare = defineAuthorizedWorkspaceFileUseCase({ await validatePublicFileSharing(subjectUserId, context.workspaceId, effectiveAuthType) } catch (error) { if (error instanceof PublicFileSharingNotAllowedError) - throw new OrchestrationError('forbidden', error.message) + throw new ForbiddenOperationError('PUBLIC_SHARING_NOT_ALLOWED', error.message) throw error } } diff --git a/package.json b/package.json index 7d4605fe321..5ecfb3ab44f 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ "check:icon-paths": "bun run scripts/check-icon-paths.ts", "check:migrations": "bun run scripts/check-migrations-safety.ts", "check:native-typecheck": "bun run scripts/check-native-typecheck.ts", + "check:source-text": "bun run scripts/check-source-text.ts", "check:audits": "bun run scripts/run-audits.ts", "check:desktop-bridge": "bun run scripts/check-desktop-bridge-contract.ts --check", "check:desktop-ipc": "bun run scripts/check-desktop-ipc-contract.ts", diff --git a/packages/db/db.ts b/packages/db/db.ts index 8cdc4d477e3..c1f033a1728 100644 --- a/packages/db/db.ts +++ b/packages/db/db.ts @@ -3,6 +3,7 @@ import { drizzle } from 'drizzle-orm/postgres-js' import postgres from 'postgres' import { resolveDbUrl } from './connection-url' import * as schema from './schema' +import { withUtcTimestamps } from './timestamps' import { instrumentPoolClient } from './tx-tripwire' const logger = createLogger('Db') @@ -71,14 +72,14 @@ if (!connectionString) { * Pinned by apps/sim/lib/execution/payloads/prune-metadata-sql.test.ts, which renders * the real statements and asserts no bind parameter is an array. */ -const poolOptions = { +const poolOptions = withUtcTimestamps({ prepare: false, fetch_types: false, idle_timeout: 20, connect_timeout: 30, onnotice: () => {}, connection: { application_name: process.env.DB_APP_NAME ?? profile.appName }, -} +}) const postgresClient = instrumentPoolClient( postgres(connectionString, { ...poolOptions, max: profile.primaryMax }), @@ -154,11 +155,14 @@ export function dbFor(role: SubProcessDbRole): typeof db { const subProfile = DB_POOL_PROFILES[role] const client = drizzle( instrumentPoolClient( - postgres(url, { - ...poolOptions, - max: subProfile.primaryMax, - connection: { application_name: subProfile.appName }, - }), + postgres( + url, + withUtcTimestamps({ + ...poolOptions, + max: subProfile.primaryMax, + connection: { application_name: subProfile.appName }, + }) + ), role ), { schema } diff --git a/packages/db/package.json b/packages/db/package.json index c30bb96a4bd..bc3eedfd895 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -16,6 +16,10 @@ "./schema": { "types": "./schema.ts", "default": "./schema.ts" + }, + "./timestamps": { + "types": "./timestamps.ts", + "default": "./timestamps.ts" } }, "scripts": { diff --git a/packages/db/timestamps.test.ts b/packages/db/timestamps.test.ts new file mode 100644 index 00000000000..c186bef130e --- /dev/null +++ b/packages/db/timestamps.test.ts @@ -0,0 +1,146 @@ +/** + * @vitest-environment node + * + * These assertions are only meaningful when the process is NOT running in UTC: + * a local-time defect is invisible when local time *is* UTC. `TZ` is therefore + * pinned to a non-UTC zone, and {@link isProcessInUtc} fails the suite outright + * if the runtime ignored it, rather than letting the file pass vacuously. + * + * The zone is set and restored around this file rather than assigned at module + * scope. `TZ` is process state, not module state, and a worker that runs test + * files back to back in one process carries the assignment into every file that + * follows — an unrelated suite would then read local time as Tokyo, and only + * when the file ordering put it after this one. Every `Date` here is built + * inside a test body, so a hook is early enough to pin the zone for all of them. + */ + +import { + UTC_CONNECTION_PARAMETERS, + UTC_TIMESTAMP_TYPES, + withUtcTimestamps, +} from '@sim/db/timestamps' +import { pgTable, timestamp } from 'drizzle-orm/pg-core' +import { drizzle } from 'drizzle-orm/postgres-js' +import postgres from 'postgres' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' + +const TEST_TIME_ZONE = 'Asia/Tokyo' + +/** Postgres oid of `timestamp without time zone`. */ +const TIMESTAMP_OID = 1114 + +/** A naive `timestamp` value exactly as Postgres renders it on the wire. */ +const NAIVE_WIRE_VALUE = '2026-08-13 02:44:03.42' +const NAIVE_WIRE_INSTANT = '2026-08-13T02:44:03.420Z' + +/** Stands in for any `timestamp without time zone` column in `schema.ts`. */ +const naiveColumn = pgTable('probe', { at: timestamp('at') }).at + +function isProcessInUtc(): boolean { + return new Date().getTimezoneOffset() === 0 +} + +type TimestampParser = (value: string) => unknown + +/** + * Builds a postgres.js client the way `db.ts` does and returns the parser it + * resolves for oid 1114. Constructing a client does not open a connection, so + * this reads the real resolved configuration without touching a database. + * + * `wrapInDrizzle` selects whether that is the parser the driver starts with or + * the one it ends up with after `drizzle()` has registered its own. Production + * is always the latter: every client in this repo is handed straight to + * `drizzle()`. + */ +function resolveTimestampParser(wrapInDrizzle: boolean): TimestampParser { + const client = postgres( + 'postgres://user@localhost:5432/db', + withUtcTimestamps({ connection: { application_name: 'test' } }) + ) + if (wrapInDrizzle) drizzle(client, {}) + return (client.options as { parsers: Record }).parsers[TIMESTAMP_OID] +} + +describe('naive timestamp UTC pinning', () => { + const ambientTimeZone = process.env.TZ + + beforeAll(() => { + process.env.TZ = TEST_TIME_ZONE + }) + + afterAll(() => { + /** + * Removed rather than assigned `undefined`: assigning it would leave the + * literal string `"undefined"` in the environment, which is a zone name no + * runtime resolves. + */ + if (ambientTimeZone === undefined) Reflect.deleteProperty(process.env, 'TZ') + else process.env.TZ = ambientTimeZone + }) + + it('runs outside UTC, so a local-time defect is observable', () => { + expect(isProcessInUtc()).toBe(false) + }) + + it('pins the session TimeZone so every writer stores the same wall clock', () => { + expect(UTC_CONNECTION_PARAMETERS.TimeZone).toBe('UTC') + }) + + it('keeps the session TimeZone when a caller sets its own connection params', () => { + const merged = withUtcTimestamps({ connection: { application_name: 'sub-pool' } }) + expect(merged.connection).toEqual({ application_name: 'sub-pool', TimeZone: 'UTC' }) + }) + + it('reads a naive timestamp as UTC rather than the process zone', () => { + const parsed = UTC_TIMESTAMP_TYPES.utcTimestamp.parse(NAIVE_WIRE_VALUE) + expect(parsed.toISOString()).toBe(NAIVE_WIRE_INSTANT) + }) + + it('round-trips an instant through the naive wire form unchanged', () => { + const instant = new Date('2026-08-13T02:44:03.420Z') + const serialized = UTC_TIMESTAMP_TYPES.utcTimestamp.serialize(instant) + /** Postgres discards the offset designator when parsing into a naive column. */ + const storedWallClock = serialized.replace('T', ' ').replace('Z', '') + expect(UTC_TIMESTAMP_TYPES.utcTimestamp.parse(storedWallClock).getTime()).toBe( + instant.getTime() + ) + }) + + it('registers the UTC parser on a bare postgres.js client', () => { + const parse = resolveTimestampParser(false) + expect(parse(NAIVE_WIRE_VALUE)).toEqual(new Date(NAIVE_WIRE_INSTANT)) + }) + + /** + * `drizzle()` installs its own transparent parser over the oids it maps, + * including 1114, so the entry `withUtcTimestamps` registered is replaced the + * moment a client is wrapped. Every client in this repo is wrapped, which + * makes the registration above true but not load-bearing — asserting only the + * registration passes whether or not the parser has any effect. This pins the + * fact the next case depends on, so a drizzle version that stops clobbering + * turns the file red instead of silently changing which layer decides the + * instant. + */ + it('has that parser overwritten by drizzle, so registration alone proves nothing', () => { + const parse = resolveTimestampParser(true) + expect(parse(NAIVE_WIRE_VALUE)).toBe(NAIVE_WIRE_VALUE) + }) + + /** + * What actually carries the read-side guarantee for a drizzle client: + * `PgTimestamp.mapFromDriverValue` appends `+0000` to a naive string, so the + * recovered instant is UTC regardless of which parser won the oid. Both + * branches are asserted together because the composition is the contract — + * the read must not depend on which of the two layers got there first. + */ + it('recovers the same UTC instant through either parser once drizzle maps it', () => { + const instant = new Date(NAIVE_WIRE_INSTANT) + + expect(naiveColumn.mapFromDriverValue(resolveTimestampParser(true)(NAIVE_WIRE_VALUE))).toEqual( + instant + ) + expect(naiveColumn.mapFromDriverValue(resolveTimestampParser(false)(NAIVE_WIRE_VALUE))).toEqual( + instant + ) + }) +}) diff --git a/packages/db/timestamps.ts b/packages/db/timestamps.ts new file mode 100644 index 00000000000..8d32419faee --- /dev/null +++ b/packages/db/timestamps.ts @@ -0,0 +1,76 @@ +/** + * UTC pinning for `timestamp without time zone` columns. + * + * Every timestamp column in `schema.ts` is bare `timestamp(...)`, which is + * Postgres `timestamp without time zone`: the column stores a naive wall-clock + * reading with no offset, so the instant it denotes is decided entirely by + * whoever writes it and whoever reads it. Writers and readers disagreed — + * `now()` and a raw `Date` bind render in the **session's** `TimeZone` while + * drizzle's `toISOString()` always stores UTC, and postgres.js parses oid 1114 + * in the **Node process's** local zone while drizzle parses it as UTC. The + * result is a local wall clock serialized with `toISOString()`: a `Z`-labelled + * string naming the wrong instant, which passes every `date-time` format check + * and silently corrupts sorts and range predicates. + * + * Pinned at the driver boundary rather than at the call sites, so no future + * writer can reintroduce it: {@link UTC_CONNECTION_PARAMETERS} forces every + * session's `TimeZone`, and {@link UTC_TIMESTAMP_TYPES} pins the read. + * + * `timestamptz` columns (oid 1184) are deliberately untouched: they already + * carry an offset on the wire and round-trip correctly on their own. + */ + +/** Postgres oid of `timestamp without time zone`. */ +const TIMESTAMP_OID = 1114 + +/** + * postgres.js startup parameters that pin the session's `TimeZone`, so `now()` + * and any `timestamptz → timestamp` cast render the UTC wall clock drizzle's + * `toISOString()` write already stores. + */ +export const UTC_CONNECTION_PARAMETERS = { TimeZone: 'UTC' } as const + +/** + * postgres.js `types` entry that reads and writes oid 1114 as UTC. + * + * `parse` appends the explicit `Z` that the naive wire form omits, making the + * recovered instant independent of the process's local zone. `to` is never + * selected by postgres.js's type inference (a `Date` infers as 1184), so the + * serializer only keeps the entry self-consistent for an explicit `sql.typed` + * bind. + * + * `drizzle()` registers its own oid-1114 parser when it wraps a client, + * replacing this entry, and every client here is wrapped — drizzle's own mapper + * (`new Date(value + '+0000')`) then supplies the same UTC reading, so the + * clobbering is harmless. The entry is kept because it is the only thing pinning + * the read for a client used as raw postgres.js. + */ +export const UTC_TIMESTAMP_TYPES = { + utcTimestamp: { + to: TIMESTAMP_OID, + from: [TIMESTAMP_OID], + serialize: (value: Date | string): string => + (value instanceof Date ? value : new Date(value)).toISOString(), + parse: (value: string): Date => new Date(`${value}Z`), + }, +} + +interface PostgresConnectionOptions { + connection?: Record +} + +/** + * Applies the UTC pinning to a postgres.js options object. + * + * Every client is built through this rather than spreading the two constants by + * hand, because `connection` is a nested object: a client that sets its own + * `application_name` replaces the whole sub-object and would silently drop the + * session `TimeZone`. + */ +export function withUtcTimestamps(options: T) { + return { + ...options, + connection: { ...options.connection, ...UTC_CONNECTION_PARAMETERS }, + types: UTC_TIMESTAMP_TYPES, + } +} diff --git a/packages/utils/src/string.ts b/packages/utils/src/string.ts index 3feb0427aff..38a7388cfbd 100644 --- a/packages/utils/src/string.ts +++ b/packages/utils/src/string.ts @@ -1,3 +1,24 @@ +/** + * `U+0000` is the one code point a Postgres `text`/`jsonb` value cannot carry: + * the wire protocol terminates strings on it, so the driver throws before the + * statement is planned, and the throw carries no SQLSTATE a route layer can + * classify — it reaches the caller as a 500, on reads as readily as on writes. + * Every boundary that admits caller-supplied text rejects it through + * {@link containsNulCharacter}: the JSON request scan, the multipart field + * scan, and the canonical folder-path decoder. + * + * Deliberately only NUL. `\n`, `\t`, and `\r` are ordinary content Postgres + * stores verbatim, and a lone surrogate is substituted with `U+FFFD` by the + * driver's encoder rather than throwing — a fidelity question, not an + * availability one. + */ +const NUL_CHARACTER = '\u0000' + +/** Reports whether `value` carries a `U+0000`. See {@link NUL_CHARACTER}. */ +export function containsNulCharacter(value: string): boolean { + return value.includes(NUL_CHARACTER) +} + /** * Truncates `str` if it exceeds `sliceLength` characters, appending `suffix`. * The total output length when truncated is `sliceLength + suffix.length`. diff --git a/scripts/check-openapi-specs.ts b/scripts/check-openapi-specs.ts index d13fff64786..4dbae563db4 100644 --- a/scripts/check-openapi-specs.ts +++ b/scripts/check-openapi-specs.ts @@ -59,12 +59,17 @@ const SPEC_FILES = OPENAPI_SPEC_FILES * A stale entry — one whose contract no longer exists, or which has since * been documented — also fails, so the list cannot rot into a blanket * exemption. + * + * Being unpublished is about *addressability*, not about behaviour: both + * entries below answer in the canonical `{ error: { code, message } }` envelope + * like every documented route, and what a caller needs in order to perform the + * transfer is published on `transfer.url` in `contracts/v2/uploads.ts`. */ const UNDOCUMENTED_V2_ROUTES: Readonly> = { 'PUT /api/v2/uploads/{uploadId}': - 'Local-storage data plane for a signed whole-object upload. Authenticated by the short-lived upload-token minted by the documented session-create operation, not by an API key; carries no v2 feature gate and returns bare error bodies rather than the canonical v2 envelope. The URL is handed to the client by the session response and is never constructed from docs.', + 'Local-storage data plane for a signed whole-object upload. Authenticated by the short-lived upload-token minted by the documented session-create operation, not by an API key, so it carries neither the v2 API-key security scheme nor the rate-limit and feature-gate responses `checkV2Conventions` requires of a published operation. On a cloud deployment the same field points at object storage instead, so the endpoint is described by `transfer.url` — which publishes its method, headers, success status, and error codes — rather than by an operation of its own.', 'PUT /api/v2/uploads/{uploadId}/parts/{partNumber}': - 'Local-storage data plane for a signed multipart part upload. Authenticated by a per-part signed `token` query param minted by the documented part-URL operation, not by an API key; same non-canonical envelope and self-describing URL as the whole-object PUT above.', + 'Local-storage data plane for a signed multipart part upload. Authenticated by a per-part signed `token` query param minted by the documented part-URL operation, not by an API key; same reasoning and same published `transfer.url` contract as the whole-object PUT above.', } /** diff --git a/scripts/check-source-text.ts b/scripts/check-source-text.ts new file mode 100644 index 00000000000..1052604eb32 --- /dev/null +++ b/scripts/check-source-text.ts @@ -0,0 +1,73 @@ +#!/usr/bin/env bun +/** + * Asserts that no tracked source file contains a raw `U+0000`. + * + * Git classifies a file as binary the moment its contents hold a NUL byte, so a + * single stray `U+0000` written as a literal turns the whole file into + * `Bin 0 -> 4102 bytes` in every diff — a reviewer sees not one line of it, and + * `git grep`, formatters, and editors treat it as opaque or silently normalize + * the byte away. `apps/sim/lib/api/server/nul-byte-boundary.test.ts` shipped + * exactly that way, and two older files had done the same unnoticed. + * + * The escape `'\u0000'` produces an identical string at runtime, so this costs + * nothing to satisfy. `.gitattributes` forces source files to diff as text as a + * second layer, which makes a violation visible; this audit is what keeps one + * from landing in the first place. + */ +import { spawnSync } from 'node:child_process' +import path from 'node:path' + +const ROOT = path.resolve(import.meta.dir, '..') + +/** Extensions whose contents are source text a human reads in review. */ +const SOURCE_EXTENSIONS = [ + '*.ts', + '*.tsx', + '*.js', + '*.jsx', + '*.mjs', + '*.cjs', + '*.json', + '*.md', + '*.mdx', + '*.css', + '*.yml', + '*.yaml', + '*.toml', + '*.sql', + '*.sh', +] + +const listed = spawnSync('git', ['ls-files', '-z', '--', ...SOURCE_EXTENSIONS], { + cwd: ROOT, + encoding: 'buffer', + maxBuffer: 256 * 1024 * 1024, +}) + +if (listed.status !== 0) { + console.error(`Source-text audit failed: \`git ls-files\` exited ${listed.status}.`) + process.exit(1) +} + +const files = listed.stdout + .toString('utf8') + .split('\0') + .filter((entry) => entry.length > 0) + +const offenders: string[] = [] +for (const file of files) { + const bytes = await Bun.file(path.join(ROOT, file)).bytes() + if (bytes.includes(0)) offenders.push(file) +} + +if (offenders.length > 0) { + console.error( + `Source-text audit failed: ${offenders.length} tracked source file(s) contain a raw NUL byte,\n` + + 'which makes git treat them as binary and hides their contents from review.\n\n' + + offenders.map((file) => ` ${file}`).join('\n') + + "\n\n Write the character as the escape '\\u0000' instead — the runtime string is identical." + ) + process.exit(1) +} + +console.log(`Source-text audit passed (${files.length} files, no raw NUL bytes).`) diff --git a/scripts/openapi/documents.test.ts b/scripts/openapi/documents.test.ts index 862b138819c..21ebac6ba87 100644 --- a/scripts/openapi/documents.test.ts +++ b/scripts/openapi/documents.test.ts @@ -6,6 +6,12 @@ import { filesAuditOpenApiDocument } from '../../apps/sim/lib/api/contracts/v2/o import { knowledgeOpenApiDocument } from '../../apps/sim/lib/api/contracts/v2/openapi/knowledge' import { logsOpenApiDocument } from '../../apps/sim/lib/api/contracts/v2/openapi/logs' import { resourcesOpenApiDocument } from '../../apps/sim/lib/api/contracts/v2/openapi/resources' +import { + FOLDER_TREE_TOO_LARGE, + RUN_RETENTION, + WORKSPACE_API_KEY_DENIED, + WORKSPACE_API_KEY_DENIED_AS_NOT_FOUND, +} from '../../apps/sim/lib/api/contracts/v2/openapi/shared' import { tablesOpenApiDocument } from '../../apps/sim/lib/api/contracts/v2/openapi/tables' import { workflowsOpenApiDocument } from '../../apps/sim/lib/api/contracts/v2/openapi/workflows' import { generateOpenApiDocument, serializeOpenApiDocument } from './generator' @@ -324,3 +330,133 @@ describe('generated OpenAPI documents', () => { } }) }) + +/** + * Documented error sets. + * + * The 413 sweep runs over all seven documents rather than the two families it + * first audited: the gaps the narrower scope was written around are closed, and + * leaving it narrow would let a new body-carrying operation in any other family + * ship without publishing the 413 its body read raises. + */ +describe('documented error sets', () => { + /** + * A v2 JSON route whose contract declares a body reads that body through + * `parseJsonBody` under `DEFAULT_MAX_JSON_BODY_BYTES` *before* schema + * validation, with the builders supplying `V2_PARSE_DEFAULTS`. So an + * oversized body is a real 413 on every one of them, and an operation that + * does not publish it is documenting a response its callers can hit. The + * converse does not hold — several bodyless folder reads publish 413 because + * materializing an oversized folder tree raises one — so this is one + * directional. + */ + it.each( + DOCUMENTS.flatMap((document) => + document.routes + .filter((route) => route.contract.body !== undefined) + .map((route) => [route.operation.operationId, route.operation.errors] as const) + ) + )('%s publishes the 413 its body read can raise', (_operationId, errors) => { + expect(errors).toContain('PayloadTooLarge') + }) + + /** + * The file list resolves its `folderPath` filter through the capped folder + * path index, so an oversized workspace tree is a 413 here exactly as it is on + * the knowledge, workflow, and table lists. + */ + it('publishes the folder-tree 413 the file list can raise', () => { + const listFiles = filesAuditOpenApiDocument.routes.find( + (route) => route.operation.operationId === 'listFiles' + )?.operation + + expect(listFiles?.errors).toContain('PayloadTooLarge') + expect(listFiles?.description).toContain(FOLDER_TREE_TOO_LARGE) + }) + + /** + * `listAuditLogs` has no not-found path to publish. It throws only + * `validation` (a bad cursor, a workspaceId outside the organization), + * `resolveEnterpriseAuditAccess` returns 403 shapes only, and an empty + * selection is an empty page. `getAuditLog` does 404 and keeps it. + */ + it('does not publish a 404 the audit-log list cannot emit', () => { + const spec = generateOpenApiDocument(filesAuditOpenApiDocument) + expect( + Object.keys(getOperation(spec, '/api/v2/audit-logs', 'get').responses as JsonObject) + ).not.toContain('404') + expect( + Object.keys(getOperation(spec, '/api/v2/audit-logs/{id}', 'get').responses as JsonObject) + ).toContain('404') + }) + + /** + * `files.share.update` denies the workspace key through its principal-kind + * list, which raises `PrincipalKindAuthorizationError` — not one of the + * cross-tenant errors the concealment policy rewrites — so the caller sees + * 403. The description claimed 404. + */ + it('describes the file-share workspace-key refusal as the 403 it renders', () => { + const description = filesAuditOpenApiDocument.routes.find( + (route) => route.operation.operationId === 'upsertFileShare' + )?.operation.description + + expect(description).toContain(WORKSPACE_API_KEY_DENIED) + expect(description).not.toContain(WORKSPACE_API_KEY_DENIED_AS_NOT_FOUND) + }) +}) + +/** + * Shared parameter vocabulary. + * + * `cursor` and `sortOrder` appear on dozens of operations across the seven + * documents, and each is sourced from one schema in `contracts/v2/shared.ts`. A + * caller reading two families back to back cannot tell a reworded copy from a + * different contract, so a divergence is a defect rather than a style choice. + * This pins each to one string; a list that hand-rolls its own `cursor` fails + * here. + * + * `startDate`/`endDate` are deliberately excluded: the run-window pair and the + * billing usage window share a name but filter different sequences. + */ +describe('shared parameter descriptions do not fork', () => { + const SINGLE_VOICE_PARAMETERS = ['cursor', 'sortOrder'] as const + + const descriptionsByParameter = new Map>() + for (const document of DOCUMENTS) { + const spec = generateOpenApiDocument(document) + for (const operation of operations(spec)) { + for (const parameter of (operation.parameters ?? []) as JsonObject[]) { + const name = parameter.name as string + if (!SINGLE_VOICE_PARAMETERS.includes(name as (typeof SINGLE_VOICE_PARAMETERS)[number])) { + continue + } + const seen = descriptionsByParameter.get(name) ?? new Set() + seen.add(parameter.description as string) + descriptionsByParameter.set(name, seen) + } + } + } + + it.each(SINGLE_VOICE_PARAMETERS)('publishes one description for %s', (name) => { + expect([...(descriptionsByParameter.get(name) ?? [])]).toHaveLength(1) + }) +}) + +/** + * The run-retention window is the one fact that explains an empty run list on a + * workflow reporting a non-zero `runCount`, and it is published on both reads + * over `workflow_execution_logs` from one constant. Pinning both keeps a future + * trim from silently dropping it off one of them. + */ +describe('run retention is published on both run reads', () => { + it.each([ + [logsOpenApiDocument, 'listLogs'], + [workflowsOpenApiDocument, 'listWorkflowRunsV2'], + ] as const)('%#: names the retention window', (document, operationId) => { + const description = document.routes.find((route) => route.operation.operationId === operationId) + ?.operation.description + + expect(description).toContain(RUN_RETENTION) + }) +}) diff --git a/scripts/openapi/generator.ts b/scripts/openapi/generator.ts index 5c5843d34a1..16968910b54 100644 --- a/scripts/openapi/generator.ts +++ b/scripts/openapi/generator.ts @@ -241,6 +241,20 @@ function objectProperties( } } +/** + * Whether a request slice declares no keys at all, i.e. `z.object({}).strict()`. + * + * A v2 contract states that an endpoint takes no query params by declaring + * `query: noInputSchema` rather than by omitting `query`, because an omitted + * slice is the one `parseRequest` skips validating entirely. That distinction is + * load-bearing at runtime and invisible to the spec: either way the operation + * publishes zero parameters. + */ +function declaresNoKeys(schema: ApiSchema): boolean { + const def = (schema as { def?: { type?: string; shape?: Record } }).def + return def?.type === 'object' && Object.keys(def.shape ?? {}).length === 0 +} + function parametersFor( schema: ApiSchema | undefined, location: 'path' | 'query' | 'header', @@ -248,6 +262,12 @@ function parametersFor( label: string ): JsonObject[] { if (!schema) return [] + /** + * Short-circuited ahead of `objectProperties`, which would otherwise demand + * the `.meta({ id })` and the non-empty `properties` an empty schema has + * nothing to supply, and would register a component no operation references. + */ + if (declaresNoKeys(schema)) return [] const { properties, required } = objectProperties(schema, components, label) return Object.entries(properties).map(([name, property]) => { invariant(property && typeof property === 'object', `${label}.${name} is not a schema`) diff --git a/scripts/openapi/vitest.config.ts b/scripts/openapi/vitest.config.ts index be5f25b5d47..2cfe8039d62 100644 --- a/scripts/openapi/vitest.config.ts +++ b/scripts/openapi/vitest.config.ts @@ -12,5 +12,12 @@ export default defineConfig({ test: { environment: 'node', include: ['scripts/openapi/**/*.test.ts'], + /** + * The determinism check serializes all seven published documents twice — + * roughly 2MB of JSON — so it was running against vitest's 5s default + * rather than a budget anyone chose. It already sat just under that on + * staging, and this branch's richer descriptions tip it over. + */ + testTimeout: 30_000, }, })