diff --git a/.agents/skills/v2-api-conventions/SKILL.md b/.agents/skills/v2-api-conventions/SKILL.md index 276bbd14436..3431b558056 100644 --- a/.agents/skills/v2-api-conventions/SKILL.md +++ b/.agents/skills/v2-api-conventions/SKILL.md @@ -52,7 +52,7 @@ 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. Carry a machine-readable `details.code` (e.g. `WORKFLOW_NOT_DEPLOYED`). | +| 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`). | | 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`. | @@ -74,6 +74,12 @@ 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. + +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. + +The cross-tenant refusals (`NoWorkspaceAccessError`, `WorkspaceApiKeyScopeAuthorizationError`, `DelegatedWorkspaceAuthorizationError`) deliberately carry **no** code. They are concealed as 404, and naming their cause would hand back the resource-existence signal the concealment exists to withhold. + Use the shared sets in `contracts/v2/openapi/shared.ts` — `RESOURCE_ERRORS`, `RESOURCE_CONFLICT_ERRORS`, `RESOURCE_MUTATION_ERRORS` — rather than assembling a per-operation list; all three already include `Forbidden`, and hand-assembled lists are how three knowledge reads and three upload operations quietly lost it. **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. @@ -99,9 +105,15 @@ Two cursor schemes exist, both opaque base64-JSON from `response.ts`. Which one Return `nextCursor: null` on the last page and only then. Never construct a cursor client-side. +**Ordering is `sortBy` + `sortOrder`, except where there is nothing to sort by.** Fourteen lists take the pair. Two — `GET /logs` and `GET /workflows/{id}/runs` — have exactly one sortable column (start time), so there is no `sortBy` to pair with and the direction rides on a single `order` param; `sortBy`/`sortOrder` are not accepted there. That split is documented in both contracts and is the *only* sanctioned deviation. A new list picks the pair. Do not "fix" the two by accepting `sortOrder` as an alias: an alias is a second spelling of one thing with undefined precedence when both arrive, which is its own inconsistency, and renaming `order` would break every shipped caller. + +**A boolean query param is a real boolean**, declared with `booleanQueryFlagSchema` from `contracts/primitives.ts`. It coerces `'true'`/`'1'` and `'false'`/`'0'`/`''`, so it is a strict widening of a `z.enum(['true','false'])` — which is what two v2 params used to be, purely by inheritance from the internal shapes they reused. Reusing an internal `.shape.x` inherits the internal spelling; re-declare instead when the internal one is not the v2 convention. + ## Rule 4 — reject what you do not implement -Query and body schemas are **`.strict()`**. 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. +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. Error messages name the field and, where there is one, the escape hatch: @@ -194,7 +206,9 @@ Run this against any new or changed v2 endpoint. - [ ] 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. - [ ] A retryable failure says when: 429 and 503 carry `Retry-After`. No other status invents one. -- [ ] 403s carry a machine-readable `details.code`. +- [ ] 403s carry a machine-readable `details.code` from `FORBIDDEN_DETAIL_CODES`, thrown as `ForbiddenOperationError` in the domain rather than attached at the route. +- [ ] Nested objects inside a `.strict()` body are strict too — `.strict()` does not recurse. +- [ ] Ordering uses `sortBy` + `sortOrder`; boolean query params use `booleanQueryFlagSchema`. - [ ] Validation messages name the field and echo the valid set. - [ ] Response schema matches every field the route actually emits. - [ ] OpenAPI description regenerated and truthful about pagination. @@ -202,4 +216,4 @@ Run this against any new or changed v2 endpoint. ## Known gap -A 405 on a path that *does* have a route file but does not export that verb is generated by Next.js before any Sim code runs: zero-byte body, no `content-type`, and no `Allow` header, which RFC 9110 §15.5.6 requires. Fixing it means either exporting explicit rejecting handlers from all 77 v2 route files or intercepting in `apps/sim/proxy.ts` with a static path→methods table. Neither is done. Unknown *paths* are handled — the catch-all covers those. +A 405 on a path that *does* have a route file but does not export that verb is generated by Next.js before any Sim code runs: zero-byte body, no `content-type`, and no `Allow` header, which RFC 9110 §15.5.6 requires. Fixing it means either exporting explicit rejecting handlers from every v2 route file or intercepting in `apps/sim/proxy.ts` with a static path→methods table. Neither is done. Unknown *paths* are handled — the catch-all covers those. diff --git a/.claude/commands/v2-api-conventions.md b/.claude/commands/v2-api-conventions.md index f2ce200b413..89095067c66 100644 --- a/.claude/commands/v2-api-conventions.md +++ b/.claude/commands/v2-api-conventions.md @@ -51,7 +51,7 @@ 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. Carry a machine-readable `details.code` (e.g. `WORKFLOW_NOT_DEPLOYED`). | +| 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`). | | 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`. | @@ -73,6 +73,12 @@ 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. + +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. + +The cross-tenant refusals (`NoWorkspaceAccessError`, `WorkspaceApiKeyScopeAuthorizationError`, `DelegatedWorkspaceAuthorizationError`) deliberately carry **no** code. They are concealed as 404, and naming their cause would hand back the resource-existence signal the concealment exists to withhold. + Use the shared sets in `contracts/v2/openapi/shared.ts` — `RESOURCE_ERRORS`, `RESOURCE_CONFLICT_ERRORS`, `RESOURCE_MUTATION_ERRORS` — rather than assembling a per-operation list; all three already include `Forbidden`, and hand-assembled lists are how three knowledge reads and three upload operations quietly lost it. **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. @@ -98,9 +104,15 @@ Two cursor schemes exist, both opaque base64-JSON from `response.ts`. Which one Return `nextCursor: null` on the last page and only then. Never construct a cursor client-side. +**Ordering is `sortBy` + `sortOrder`, except where there is nothing to sort by.** Fourteen lists take the pair. Two — `GET /logs` and `GET /workflows/{id}/runs` — have exactly one sortable column (start time), so there is no `sortBy` to pair with and the direction rides on a single `order` param; `sortBy`/`sortOrder` are not accepted there. That split is documented in both contracts and is the *only* sanctioned deviation. A new list picks the pair. Do not "fix" the two by accepting `sortOrder` as an alias: an alias is a second spelling of one thing with undefined precedence when both arrive, which is its own inconsistency, and renaming `order` would break every shipped caller. + +**A boolean query param is a real boolean**, declared with `booleanQueryFlagSchema` from `contracts/primitives.ts`. It coerces `'true'`/`'1'` and `'false'`/`'0'`/`''`, so it is a strict widening of a `z.enum(['true','false'])` — which is what two v2 params used to be, purely by inheritance from the internal shapes they reused. Reusing an internal `.shape.x` inherits the internal spelling; re-declare instead when the internal one is not the v2 convention. + ## Rule 4 — reject what you do not implement -Query and body schemas are **`.strict()`**. 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. +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. Error messages name the field and, where there is one, the escape hatch: @@ -193,7 +205,9 @@ Run this against any new or changed v2 endpoint. - [ ] 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. - [ ] A retryable failure says when: 429 and 503 carry `Retry-After`. No other status invents one. -- [ ] 403s carry a machine-readable `details.code`. +- [ ] 403s carry a machine-readable `details.code` from `FORBIDDEN_DETAIL_CODES`, thrown as `ForbiddenOperationError` in the domain rather than attached at the route. +- [ ] Nested objects inside a `.strict()` body are strict too — `.strict()` does not recurse. +- [ ] Ordering uses `sortBy` + `sortOrder`; boolean query params use `booleanQueryFlagSchema`. - [ ] Validation messages name the field and echo the valid set. - [ ] Response schema matches every field the route actually emits. - [ ] OpenAPI description regenerated and truthful about pagination. @@ -201,4 +215,4 @@ Run this against any new or changed v2 endpoint. ## Known gap -A 405 on a path that *does* have a route file but does not export that verb is generated by Next.js before any Sim code runs: zero-byte body, no `content-type`, and no `Allow` header, which RFC 9110 §15.5.6 requires. Fixing it means either exporting explicit rejecting handlers from all 77 v2 route files or intercepting in `apps/sim/proxy.ts` with a static path→methods table. Neither is done. Unknown *paths* are handled — the catch-all covers those. +A 405 on a path that *does* have a route file but does not export that verb is generated by Next.js before any Sim code runs: zero-byte body, no `content-type`, and no `Allow` header, which RFC 9110 §15.5.6 requires. Fixing it means either exporting explicit rejecting handlers from every v2 route file or intercepting in `apps/sim/proxy.ts` with a static path→methods table. Neither is done. Unknown *paths* are handled — the catch-all covers those. diff --git a/.cursor/commands/v2-api-conventions.md b/.cursor/commands/v2-api-conventions.md index d114966e6f5..7fa3e1a18ae 100644 --- a/.cursor/commands/v2-api-conventions.md +++ b/.cursor/commands/v2-api-conventions.md @@ -46,7 +46,7 @@ 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. Carry a machine-readable `details.code` (e.g. `WORKFLOW_NOT_DEPLOYED`). | +| 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`). | | 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`. | @@ -68,6 +68,12 @@ 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. + +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. + +The cross-tenant refusals (`NoWorkspaceAccessError`, `WorkspaceApiKeyScopeAuthorizationError`, `DelegatedWorkspaceAuthorizationError`) deliberately carry **no** code. They are concealed as 404, and naming their cause would hand back the resource-existence signal the concealment exists to withhold. + Use the shared sets in `contracts/v2/openapi/shared.ts` — `RESOURCE_ERRORS`, `RESOURCE_CONFLICT_ERRORS`, `RESOURCE_MUTATION_ERRORS` — rather than assembling a per-operation list; all three already include `Forbidden`, and hand-assembled lists are how three knowledge reads and three upload operations quietly lost it. **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. @@ -93,9 +99,15 @@ Two cursor schemes exist, both opaque base64-JSON from `response.ts`. Which one Return `nextCursor: null` on the last page and only then. Never construct a cursor client-side. +**Ordering is `sortBy` + `sortOrder`, except where there is nothing to sort by.** Fourteen lists take the pair. Two — `GET /logs` and `GET /workflows/{id}/runs` — have exactly one sortable column (start time), so there is no `sortBy` to pair with and the direction rides on a single `order` param; `sortBy`/`sortOrder` are not accepted there. That split is documented in both contracts and is the *only* sanctioned deviation. A new list picks the pair. Do not "fix" the two by accepting `sortOrder` as an alias: an alias is a second spelling of one thing with undefined precedence when both arrive, which is its own inconsistency, and renaming `order` would break every shipped caller. + +**A boolean query param is a real boolean**, declared with `booleanQueryFlagSchema` from `contracts/primitives.ts`. It coerces `'true'`/`'1'` and `'false'`/`'0'`/`''`, so it is a strict widening of a `z.enum(['true','false'])` — which is what two v2 params used to be, purely by inheritance from the internal shapes they reused. Reusing an internal `.shape.x` inherits the internal spelling; re-declare instead when the internal one is not the v2 convention. + ## Rule 4 — reject what you do not implement -Query and body schemas are **`.strict()`**. 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. +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. Error messages name the field and, where there is one, the escape hatch: @@ -188,7 +200,9 @@ Run this against any new or changed v2 endpoint. - [ ] 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. - [ ] A retryable failure says when: 429 and 503 carry `Retry-After`. No other status invents one. -- [ ] 403s carry a machine-readable `details.code`. +- [ ] 403s carry a machine-readable `details.code` from `FORBIDDEN_DETAIL_CODES`, thrown as `ForbiddenOperationError` in the domain rather than attached at the route. +- [ ] Nested objects inside a `.strict()` body are strict too — `.strict()` does not recurse. +- [ ] Ordering uses `sortBy` + `sortOrder`; boolean query params use `booleanQueryFlagSchema`. - [ ] Validation messages name the field and echo the valid set. - [ ] Response schema matches every field the route actually emits. - [ ] OpenAPI description regenerated and truthful about pagination. @@ -196,4 +210,4 @@ Run this against any new or changed v2 endpoint. ## Known gap -A 405 on a path that *does* have a route file but does not export that verb is generated by Next.js before any Sim code runs: zero-byte body, no `content-type`, and no `Allow` header, which RFC 9110 §15.5.6 requires. Fixing it means either exporting explicit rejecting handlers from all 77 v2 route files or intercepting in `apps/sim/proxy.ts` with a static path→methods table. Neither is done. Unknown *paths* are handled — the catch-all covers those. +A 405 on a path that *does* have a route file but does not export that verb is generated by Next.js before any Sim code runs: zero-byte body, no `content-type`, and no `Allow` header, which RFC 9110 §15.5.6 requires. Fixing it means either exporting explicit rejecting handlers from every v2 route file or intercepting in `apps/sim/proxy.ts` with a static path→methods table. Neither is done. Unknown *paths* are handled — the catch-all covers those. diff --git a/apps/docs/content/docs/en/api-reference/(generated)/workflows/meta.json b/apps/docs/content/docs/en/api-reference/(generated)/workflows/meta.json index fb32a86e414..1017e55e280 100644 --- a/apps/docs/content/docs/en/api-reference/(generated)/workflows/meta.json +++ b/apps/docs/content/docs/en/api-reference/(generated)/workflows/meta.json @@ -9,6 +9,7 @@ "getWorkflowVersionV2", "exportWorkflow", "importWorkflow", + "getWorkflowDeployment", "deployWorkflow", "undeployWorkflow", "rollbackWorkflow", diff --git a/apps/docs/openapi-v2-billing.json b/apps/docs/openapi-v2-billing.json index 13ef0c04962..ecf30cabc96 100644 --- a/apps/docs/openapi-v2-billing.json +++ b/apps/docs/openapi-v2-billing.json @@ -334,7 +334,7 @@ } }, "Forbidden": { - "description": "The caller lacks access to the resource.", + "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.", "content": { "application/json": { "schema": { diff --git a/apps/docs/openapi-v2-files-audit.json b/apps/docs/openapi-v2-files-audit.json index 94b5b888643..54432ad9883 100644 --- a/apps/docs/openapi-v2-files-audit.json +++ b/apps/docs/openapi-v2-files-audit.json @@ -40,7 +40,7 @@ "get": { "operationId": "listFiles", "summary": "List Files", - "description": "List workspace files with search, sorting, folder filtering, and opaque cursor pagination.", + "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.", "tags": ["Files"], "parameters": [ { @@ -64,6 +64,18 @@ "type": "string" } }, + { + "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.", + "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.", + "type": "string", + "enum": ["active", "archived"] + } + }, { "name": "search", "in": "query", @@ -678,7 +690,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 listings and is no longer readable through the API, and its stored bytes are never removed. An archived file can be restored from the workspace Recently Deleted settings; the v2 API exposes no restore operation.", + "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`.", "tags": ["Files"], "parameters": [ { @@ -834,6 +846,87 @@ } } }, + "/api/v2/files/{fileId}/restore": { + "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.", + "tags": ["Files"], + "parameters": [ + { + "name": "fileId", + "in": "path", + "required": true, + "description": "File identifier.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[A-Za-z0-9_-]+$", + "description": "File identifier." + } + } + ], + "requestBody": { + "required": true, + "description": "Workspace scope for the archived file.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RestoreFileRequest" + } + } + } + }, + "responses": { + "200": { + "description": "The file as it exists after the restore.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2RestoreFileResponse" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + } + }, "/api/v2/files/{fileId}/metadata": { "get": { "operationId": "getFile", @@ -986,9 +1079,7 @@ "description": "Include actions by users who have left the organization.", "schema": { "description": "Include actions by users who have left the organization.", - "default": "false", - "type": "string", - "enum": ["true", "false"] + "type": "boolean" } }, { @@ -1972,7 +2063,7 @@ } }, "Forbidden": { - "description": "The caller lacks access to the resource.", + "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.", "content": { "application/json": { "schema": { @@ -2195,6 +2286,19 @@ "description": "ISO 8601 timestamp of the last content or metadata write.", "format": "date-time", "examples": ["2026-01-15T10:30:00Z"] + }, + "deletedAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "ISO 8601 timestamp when the file was archived by `DELETE /files/{fileId}`, or null while the file is active. Only `GET /files?scope=archived` returns files with a non-null value.", + "format": "date-time", + "examples": ["2026-01-16T09:00:00Z"] } }, "required": [ @@ -2206,7 +2310,8 @@ "folderPath", "uploadedByEmail", "uploadedAt", - "updatedAt" + "updatedAt", + "deletedAt" ], "additionalProperties": false, "title": "Workspace file", @@ -2250,7 +2355,8 @@ "folderPath": "/Engineering", "uploadedByEmail": "jane@example.com", "uploadedAt": "2026-01-15T10:30:00Z", - "updatedAt": "2026-01-15T10:30:00Z" + "updatedAt": "2026-01-15T10:30:00Z", + "deletedAt": null } ], "nextCursor": null @@ -2280,7 +2386,8 @@ "folderPath": "/Engineering", "uploadedByEmail": "jane@example.com", "uploadedAt": "2026-01-15T10:30:00Z", - "updatedAt": "2026-01-15T10:30:00Z" + "updatedAt": "2026-01-15T10:30:00Z", + "deletedAt": null } } ] @@ -2696,6 +2803,54 @@ } ] }, + "V2RestoreFileResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2File" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Restore file response", + "description": "The restored workspace file, at the root and under its post-restore name.", + "examples": [ + { + "data": { + "id": "wf_V1StGXR8z5jdHi6BmyT91", + "name": "data_restored.csv", + "size": 1024, + "type": "text/csv", + "key": "workspace/example/data.csv", + "folderPath": "/", + "uploadedByEmail": "jane@example.com", + "uploadedAt": "2026-01-15T10:30:00Z", + "updatedAt": "2026-01-15T10:30:00Z", + "deletedAt": null + } + } + ] + }, + "RestoreFileRequest": { + "type": "object", + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "Workspace that owns the archived file." + } + }, + "required": ["workspaceId"], + "additionalProperties": false, + "title": "Restore file request", + "description": "Workspace scope for the archived file.", + "examples": [ + { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64" + } + ] + }, "V2FileShare": { "type": "object", "properties": { @@ -2812,6 +2967,19 @@ "format": "date-time", "examples": ["2026-01-15T10:30:00Z"] }, + "deletedAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "ISO 8601 timestamp when the file was archived by `DELETE /files/{fileId}`, or null while the file is active. Only `GET /files?scope=archived` returns files with a non-null value.", + "format": "date-time", + "examples": ["2026-01-16T09:00:00Z"] + }, "share": { "anyOf": [ { @@ -2834,6 +3002,7 @@ "uploadedByEmail", "uploadedAt", "updatedAt", + "deletedAt", "share" ], "additionalProperties": false, @@ -2864,6 +3033,7 @@ "uploadedByEmail": "jane@example.com", "uploadedAt": "2026-01-15T10:30:00Z", "updatedAt": "2026-01-15T10:30:00Z", + "deletedAt": null, "share": null } }, @@ -2878,6 +3048,7 @@ "uploadedByEmail": "jane@example.com", "uploadedAt": "2026-01-15T10:30:00Z", "updatedAt": "2026-01-15T10:30:00Z", + "deletedAt": null, "share": { "id": "shr_8Hf3kL9wQ2mNpXr6Tz1Vb", "token": "share-token-example", diff --git a/apps/docs/openapi-v2-knowledge.json b/apps/docs/openapi-v2-knowledge.json index d7b9af2f61e..754dc3ade5c 100644 --- a/apps/docs/openapi-v2-knowledge.json +++ b/apps/docs/openapi-v2-knowledge.json @@ -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. 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. 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.", "tags": ["Knowledge Bases"], "requestBody": { "required": true, @@ -539,11 +539,87 @@ } } }, + "/api/v2/knowledge/{id}/tags": { + "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.", + "tags": ["Knowledge Bases"], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "Unique knowledge base identifier.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Unique knowledge base identifier." + } + }, + { + "name": "workspaceId", + "in": "query", + "required": true, + "description": "Workspace that owns the knowledge base.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Workspace that owns the knowledge base." + } + } + ], + "responses": { + "200": { + "description": "The knowledge base tag vocabulary.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2KnowledgeTagListResponse" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + } + }, "/api/v2/knowledge/{id}/documents": { "get": { "operationId": "listKnowledgeDocuments", "summary": "List Documents", - "description": "List documents in a knowledge base with filename search, state filtering, sorting, and opaque cursor pagination.", + "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`.", "tags": ["Knowledge Bases"], "parameters": [ { @@ -645,6 +721,19 @@ "type": "string", "minLength": 1 } + }, + { + "name": "tagFilters", + "in": "query", + "required": false, + "description": "A JSON-encoded array of at most 10 tag filters, using the same display-name shape as knowledge search: `[{\"tagName\":\"category\",\"operator\":\"eq\",\"value\":\"billing\"}]`. A name that is not defined in this knowledge base is rejected, never ignored.", + "schema": { + "description": "A JSON-encoded array of at most 10 tag filters, using the same display-name shape as knowledge search: `[{\"tagName\":\"category\",\"operator\":\"eq\",\"value\":\"billing\"}]`. A name that is not defined in this knowledge base is rejected, never ignored.", + "examples": [ + "[{\"tagName\":\"category\",\"operator\":\"eq\",\"value\":\"billing\"}]" + ], + "type": "string" + } } ], "responses": { @@ -692,6 +781,80 @@ } } }, + "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.", + "tags": ["Knowledge Bases"], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "Unique knowledge base identifier.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Unique knowledge base identifier." + } + } + ], + "requestBody": { + "required": true, + "description": "Operation and the documents it applies to.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkUpdateKnowledgeDocumentsRequest" + } + } + } + }, + "responses": { + "200": { + "description": "The number and identifiers of the documents that changed.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2BulkKnowledgeDocumentsResponse" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + }, "post": { "operationId": "uploadKnowledgeDocument", "summary": "Upload Document", @@ -1275,6 +1438,91 @@ } } }, + "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.", + "tags": ["Knowledge Bases"], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "Unique knowledge base identifier.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Unique knowledge base identifier." + } + }, + { + "name": "documentId", + "in": "path", + "required": true, + "description": "Unique knowledge document identifier.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Unique knowledge document identifier." + } + } + ], + "requestBody": { + "required": true, + "description": "Filename, search state, tag slot values, or a processing retry.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateKnowledgeDocumentRequest" + } + } + } + }, + "responses": { + "200": { + "description": "The updated document, or the requeue acknowledgement.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2UpdateKnowledgeDocumentResponse" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + }, "delete": { "operationId": "deleteKnowledgeDocument", "summary": "Delete Document", @@ -1792,7 +2040,7 @@ } }, "Forbidden": { - "description": "The caller lacks access to the resource.", + "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.", "content": { "application/json": { "schema": { @@ -2302,6 +2550,11 @@ "V2KnowledgeSearchResult": { "type": "object", "properties": { + "knowledgeBaseId": { + "type": "string", + "description": "Knowledge base the matching chunk came from; a search may span up to 20.", + "examples": ["7c9e6679-7425-40de-944b-e07fc1f90ae7"] + }, "documentId": { "type": "string", "description": "Identifier of the document containing the matching chunk.", @@ -2362,9 +2615,15 @@ "type": "number", "description": "Similarity score for vector search; tag-only matches use 1.", "examples": [0.8423] + }, + "rerankerScore": { + "description": "Relevance score assigned by the reranker, present only on results a reranker ordered. Results are ordered by this score when it is present, which is why it can disagree with `similarity`.", + "examples": [0.9312], + "type": "number" } }, "required": [ + "knowledgeBaseId", "documentId", "documentName", "sourceUrl", @@ -2523,7 +2782,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. With a single knowledge base, an unknown tag name is simply ignored.", + "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.", "type": "array", "items": { "$ref": "#/components/schemas/V2KnowledgeSearchTagFilter" @@ -2541,36 +2800,102 @@ "type": "null" } ] + }, + "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.", + "type": "boolean" + }, + "rerankerModel": { + "description": "Reranking model to use; required for reranking to run.", + "type": "string", + "enum": ["rerank-v4.0-pro", "rerank-v4.0-fast", "rerank-v3.5"] + }, + "rerankerInputCount": { + "description": "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.", + "type": "integer", + "minimum": 1, + "maximum": 100 } }, "required": ["workspaceId", "knowledgeBaseIds"], "title": "Search knowledge request", "description": "Knowledge bases, query, result limit, retrieval mode, and optional tag filters." }, - "V2KnowledgeDocumentSummary": { + "V2KnowledgeTag": { "type": "object", "properties": { - "id": { - "type": "string", - "description": "Unique document identifier.", - "examples": ["b2d4f8a0-1c3e-4a5b-9d7c-2e6f0a8b4c12"] - }, - "knowledgeBaseId": { + "displayName": { "type": "string", - "description": "Knowledge base to which the document belongs.", - "examples": ["7c9e6679-7425-40de-944b-e07fc1f90ae7"] + "description": "Display name used by tag filters and by tag values on document reads.", + "examples": ["category"] }, - "filename": { + "tagSlot": { "type": "string", - "description": "Original filename of the uploaded document.", - "examples": ["getting-started.pdf"] - }, - "fileSize": { - "type": "number", - "description": "File size in bytes.", - "examples": [248913] + "description": "Storage slot the tag occupies. Document writes set tag values by slot (`tag1`..`tag7`).", + "examples": ["tag1"] }, - "mimeType": { + "fieldType": { + "type": "string", + "description": "Value type stored in the slot; it determines the valid filter operators.", + "examples": ["text"] + } + }, + "required": ["displayName", "tagSlot", "fieldType"], + "additionalProperties": false, + "title": "Knowledge tag", + "description": "A tag defined on a knowledge base, and the slot it is stored in." + }, + "V2KnowledgeTagListResponse": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/V2KnowledgeTag" + }, + "description": "Items in the current page." + }, + "nextCursor": { + "anyOf": [ + { + "type": "string" + }, + { + "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." + } + }, + "required": ["data", "nextCursor"], + "additionalProperties": false, + "title": "Knowledge tag list response", + "description": "The full tag vocabulary of one knowledge base." + }, + "V2KnowledgeTaggedDocument": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique document identifier.", + "examples": ["b2d4f8a0-1c3e-4a5b-9d7c-2e6f0a8b4c12"] + }, + "knowledgeBaseId": { + "type": "string", + "description": "Knowledge base to which the document belongs.", + "examples": ["7c9e6679-7425-40de-944b-e07fc1f90ae7"] + }, + "filename": { + "type": "string", + "description": "Original filename of the uploaded document.", + "examples": ["getting-started.pdf"] + }, + "fileSize": { + "type": "number", + "description": "File size in bytes.", + "examples": [248913] + }, + "mimeType": { "type": "string", "description": "MIME type of the document file.", "examples": ["application/pdf"] @@ -2613,6 +2938,36 @@ "description": "ISO 8601 timestamp when the document was uploaded, or null.", "format": "date-time", "examples": ["2025-06-18T16:45:00Z"] + }, + "tags": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "description": "Tag value; dates are ISO 8601 strings and an unset tag is null." + }, + "description": "Document tag values keyed by tag display name. Writes address the same tags by slot (`tag1`..`tag7`); resolve names to slots with GET /api/v2/knowledge/{id}/tags.", + "examples": [ + { + "category": "billing", + "priority": 2 + } + ] } }, "required": [ @@ -2626,11 +2981,12 @@ "tokenCount", "characterCount", "enabled", - "createdAt" + "createdAt", + "tags" ], "additionalProperties": false, - "title": "Knowledge document summary", - "description": "Summary returned by document lists and upload acknowledgements." + "title": "Knowledge document list item", + "description": "Document summary with the document tag values keyed by display name." }, "V2KnowledgeDocumentListResponse": { "type": "object", @@ -2638,7 +2994,7 @@ "data": { "type": "array", "items": { - "$ref": "#/components/schemas/V2KnowledgeDocumentSummary" + "$ref": "#/components/schemas/V2KnowledgeTaggedDocument" }, "description": "Items in the current page." }, @@ -2659,6 +3015,178 @@ "title": "Knowledge document list response", "description": "A cursor-paginated page of knowledge documents." }, + "V2BulkKnowledgeDocumentsData": { + "type": "object", + "properties": { + "operation": { + "type": "string", + "enum": ["enable", "disable"], + "description": "Operation that was applied." + }, + "updatedCount": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "description": "Number of documents the operation changed.", + "examples": [42] + }, + "documentIds": { + "description": "Identifiers of the documents the operation changed. Present only for an explicit `documentIds` request, which is bounded to 100 documents; a `selectAll` request omits it because the selection is unbounded, and reports `updatedCount` instead.", + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": ["operation", "updatedCount"], + "additionalProperties": false, + "title": "Bulk knowledge document update data", + "description": "Outcome of a bulk enable or disable across knowledge documents." + }, + "V2BulkKnowledgeDocumentsResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2BulkKnowledgeDocumentsData" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Bulk knowledge document response", + "description": "Outcome of a bulk enable or disable." + }, + "BulkUpdateKnowledgeDocumentsRequest": { + "type": "object", + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "Workspace that owns the knowledge base." + }, + "operation": { + "type": "string", + "enum": ["enable", "disable"], + "description": "Whether the selected documents become enabled or disabled for search." + }, + "documentIds": { + "description": "Documents to update, by identifier.", + "minItems": 1, + "maxItems": 100, + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, + "selectAll": { + "description": "Update every document in the knowledge base instead of an explicit list, narrowed by `enabledFilter`.", + "type": "boolean", + "const": true + }, + "enabledFilter": { + "description": "With `selectAll`, restrict the update to documents in this state.", + "type": "string", + "enum": ["all", "enabled", "disabled"] + } + }, + "required": ["workspaceId", "operation"], + "additionalProperties": false, + "title": "Bulk knowledge document request", + "description": "Operation and the documents it applies to.", + "examples": [ + { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "operation": "disable", + "documentIds": ["b2d4f8a0-1c3e-4a5b-9d7c-2e6f0a8b4c12"] + } + ] + }, + "V2KnowledgeDocumentSummary": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique document identifier.", + "examples": ["b2d4f8a0-1c3e-4a5b-9d7c-2e6f0a8b4c12"] + }, + "knowledgeBaseId": { + "type": "string", + "description": "Knowledge base to which the document belongs.", + "examples": ["7c9e6679-7425-40de-944b-e07fc1f90ae7"] + }, + "filename": { + "type": "string", + "description": "Original filename of the uploaded document.", + "examples": ["getting-started.pdf"] + }, + "fileSize": { + "type": "number", + "description": "File size in bytes.", + "examples": [248913] + }, + "mimeType": { + "type": "string", + "description": "MIME type of the document file.", + "examples": ["application/pdf"] + }, + "processingStatus": { + "type": "string", + "enum": ["pending", "processing", "completed", "failed"], + "description": "Current document processing state.", + "examples": ["completed"] + }, + "chunkCount": { + "type": "number", + "description": "Number of indexed chunks; zero until processing completes.", + "examples": [24] + }, + "tokenCount": { + "type": "number", + "description": "Total tokens extracted from the document.", + "examples": [8123] + }, + "characterCount": { + "type": "number", + "description": "Total characters extracted from the document.", + "examples": [41205] + }, + "enabled": { + "type": "boolean", + "description": "Whether the document is enabled for search.", + "examples": [true] + }, + "createdAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "ISO 8601 timestamp when the document was uploaded, or null.", + "format": "date-time", + "examples": ["2025-06-18T16:45:00Z"] + } + }, + "required": [ + "id", + "knowledgeBaseId", + "filename", + "fileSize", + "mimeType", + "processingStatus", + "chunkCount", + "tokenCount", + "characterCount", + "enabled", + "createdAt" + ], + "additionalProperties": false, + "title": "Knowledge document summary", + "description": "Summary returned by document lists and upload acknowledgements." + }, "V2KnowledgeDocumentSummaryResponse": { "type": "object", "properties": { @@ -3145,6 +3673,36 @@ "format": "date-time", "examples": ["2025-06-18T16:45:00Z"] }, + "tags": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "description": "Tag value; dates are ISO 8601 strings and an unset tag is null." + }, + "description": "Document tag values keyed by tag display name. Writes address the same tags by slot (`tag1`..`tag7`); resolve names to slots with GET /api/v2/knowledge/{id}/tags.", + "examples": [ + { + "category": "billing", + "priority": 2 + } + ] + }, "processingError": { "anyOf": [ { @@ -3228,6 +3786,7 @@ "characterCount", "enabled", "createdAt", + "tags", "processingError", "processingStartedAt", "processingCompletedAt", @@ -3252,6 +3811,167 @@ "title": "Knowledge document response", "description": "Full knowledge document detail." }, + "V2KnowledgeDocumentProcessing": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Identifier of the requeued document." + }, + "queued": { + "type": "boolean", + "const": true, + "description": "Confirms that processing was requeued." + }, + "processingStatus": { + "type": "string", + "description": "Processing state the document was moved to.", + "examples": ["pending"] + }, + "message": { + "type": "string", + "description": "Human-readable outcome of the requeue." + } + }, + "required": ["id", "queued", "processingStatus", "message"], + "additionalProperties": false, + "title": "Knowledge document processing acknowledgement", + "description": "Acknowledgement returned when a document is requeued for processing." + }, + "V2UpdateKnowledgeDocumentResponse": { + "type": "object", + "properties": { + "data": { + "anyOf": [ + { + "$ref": "#/components/schemas/V2KnowledgeTaggedDocument" + }, + { + "$ref": "#/components/schemas/V2KnowledgeDocumentProcessing" + } + ], + "description": "Response data." + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Update knowledge document response", + "description": "The updated document, or the processing requeue acknowledgement." + }, + "UpdateKnowledgeDocumentRequest": { + "type": "object", + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "Workspace that owns the knowledge base." + }, + "filename": { + "description": "New filename for the document.", + "examples": ["getting-started-v2.pdf"], + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + "enabled": { + "description": "Whether the document participates in search. Disabling keeps it indexed.", + "type": "boolean" + }, + "tag1": { + "description": "New value for tag slot 1.", + "type": "string", + "maxLength": 1000 + }, + "tag2": { + "description": "New value for tag slot 2.", + "type": "string", + "maxLength": 1000 + }, + "tag3": { + "description": "New value for tag slot 3.", + "type": "string", + "maxLength": 1000 + }, + "tag4": { + "description": "New value for tag slot 4.", + "type": "string", + "maxLength": 1000 + }, + "tag5": { + "description": "New value for tag slot 5.", + "type": "string", + "maxLength": 1000 + }, + "tag6": { + "description": "New value for tag slot 6.", + "type": "string", + "maxLength": 1000 + }, + "tag7": { + "description": "New value for tag slot 7.", + "type": "string", + "maxLength": 1000 + }, + "number1": { + "description": "New value for number tag slot 1.", + "type": "number" + }, + "number2": { + "description": "New value for number tag slot 2.", + "type": "number" + }, + "number3": { + "description": "New value for number tag slot 3.", + "type": "number" + }, + "number4": { + "description": "New value for number tag slot 4.", + "type": "number" + }, + "number5": { + "description": "New value for number tag slot 5.", + "type": "number" + }, + "date1": { + "description": "New value for date tag slot 1, formatted YYYY-MM-DD.", + "type": "string", + "pattern": "^\\d{4}-\\d{2}-\\d{2}$" + }, + "date2": { + "description": "New value for date tag slot 2, formatted YYYY-MM-DD.", + "type": "string", + "pattern": "^\\d{4}-\\d{2}-\\d{2}$" + }, + "boolean1": { + "description": "New value for boolean tag slot 1.", + "type": "boolean" + }, + "boolean2": { + "description": "New value for boolean tag slot 2.", + "type": "boolean" + }, + "boolean3": { + "description": "New value for boolean tag slot 3.", + "type": "boolean" + }, + "retryProcessing": { + "description": "Requeue the document for processing. Send it alone: no other field may accompany it.", + "type": "boolean", + "const": true + } + }, + "required": ["workspaceId"], + "additionalProperties": false, + "title": "Update knowledge document request", + "description": "Filename, search state, tag slot values, or a processing retry.", + "examples": [ + { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "enabled": false, + "tag1": "billing" + } + ] + }, "V2Folder": { "type": "object", "properties": { diff --git a/apps/docs/openapi-v2-logs.json b/apps/docs/openapi-v2-logs.json index 7907c60e5ea..f25b32e5dbb 100644 --- a/apps/docs/openapi-v2-logs.json +++ b/apps/docs/openapi-v2-logs.json @@ -85,20 +85,24 @@ "name": "startDate", "in": "query", "required": false, - "description": "Only include runs started at or after this ISO 8601 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.", "schema": { "type": "string", - "description": "Only include runs started at or after this ISO 8601 timestamp." + "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." } }, { "name": "endDate", "in": "query", "required": false, - "description": "Only include runs started at or before this ISO 8601 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.", "schema": { "type": "string", - "description": "Only include runs started at or before this ISO 8601 timestamp." + "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." } }, { @@ -211,12 +215,12 @@ "name": "order", "in": "query", "required": false, - "description": "Sort order by execution start time.", + "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.", "schema": { "default": "desc", "type": "string", "enum": ["desc", "asc"], - "description": "Sort order by execution start time." + "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." } }, { @@ -445,7 +449,7 @@ } }, "Forbidden": { - "description": "The caller lacks access to the resource.", + "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.", "content": { "application/json": { "schema": { @@ -657,7 +661,7 @@ "failed", "cancelled" ], - "description": "Current execution status. `redacting` is transient while run output is scrubbed. `paused` is reported when a resume attempt did not run to completion and the run is waiting to be resumed again." + "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." }, "level": { "type": "string", @@ -1053,7 +1057,7 @@ "failed", "cancelled" ], - "description": "Current execution status. `redacting` is transient while run output is scrubbed. `paused` is reported when a resume attempt did not run to completion and the run is waiting to be resumed again." + "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." }, "level": { "type": "string", diff --git a/apps/docs/openapi-v2-resources.json b/apps/docs/openapi-v2-resources.json index a9aa61b7580..a1186dc1054 100644 --- a/apps/docs/openapi-v2-resources.json +++ b/apps/docs/openapi-v2-resources.json @@ -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. The bounded workspace set uses the standard cursor envelope with `nextCursor` always null; there is no second page to fetch.", + "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.", "tags": ["MCP Servers"], "parameters": [ { @@ -259,6 +259,30 @@ "type": "string", "enum": ["asc", "desc"] } + }, + { + "name": "limit", + "in": "query", + "required": false, + "description": "Maximum MCP servers to return per page. Must be a whole number from 1 to 100. Defaults to 50.", + "schema": { + "default": 50, + "description": "Maximum MCP servers to return per page. Must be a whole number from 1 to 100. Defaults to 50.", + "type": "integer", + "minimum": 1, + "maximum": 100 + } + }, + { + "name": "cursor", + "in": "query", + "required": false, + "description": "Opaque cursor returned by the previous page.", + "schema": { + "description": "Opaque cursor returned by the previous page.", + "type": "string", + "minLength": 1 + } } ], "responses": { @@ -382,11 +406,11 @@ "name": "id", "in": "path", "required": true, - "description": "MCP server to retrieve, update, or delete.", + "description": "MCP server the operation acts on.", "schema": { "type": "string", "minLength": 1, - "description": "MCP server to retrieve, update, or delete." + "description": "MCP server the operation acts on." } }, { @@ -456,11 +480,11 @@ "name": "id", "in": "path", "required": true, - "description": "MCP server to retrieve, update, or delete.", + "description": "MCP server the operation acts on.", "schema": { "type": "string", "minLength": 1, - "description": "MCP server to retrieve, update, or delete." + "description": "MCP server the operation acts on." } } ], @@ -530,11 +554,11 @@ "name": "id", "in": "path", "required": true, - "description": "MCP server to retrieve, update, or delete.", + "description": "MCP server the operation acts on.", "schema": { "type": "string", "minLength": 1, - "description": "MCP server to retrieve, update, or delete." + "description": "MCP server the operation acts on." } }, { @@ -595,6 +619,95 @@ } } }, + "/api/v2/mcp-servers/{id}/tools": { + "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.", + "tags": ["MCP Servers"], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "MCP server the operation acts on.", + "schema": { + "type": "string", + "minLength": 1, + "description": "MCP server the operation acts on." + } + }, + { + "name": "workspaceId", + "in": "query", + "required": true, + "description": "Workspace that owns the MCP server.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Workspace that owns the MCP server." + } + }, + { + "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.", + "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.", + "type": "boolean" + } + } + ], + "responses": { + "200": { + "description": "Tools exposed by the MCP server.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListMcpServerToolsResponse" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + } + }, "/api/v2/skills": { "get": { "operationId": "listSkills", @@ -1989,7 +2102,7 @@ } }, "Forbidden": { - "description": "The caller lacks access to the resource.", + "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.", "content": { "application/json": { "schema": { @@ -2870,6 +2983,118 @@ } ] }, + "V2McpTool": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Tool name, as the MCP server reports it." + }, + "description": { + "description": "Tool description reported by the server.", + "type": "string" + }, + "inputSchema": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "object", + "description": "JSON Schema type of the argument object. MCP requires `object`." + }, + "properties": { + "description": "Argument schemas keyed by argument name.", + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "description": "Server-defined JSON Schema for one tool argument." + } + }, + "required": { + "description": "Names of the arguments the tool requires.", + "type": "array", + "items": { + "type": "string", + "description": "Name of a required argument." + } + }, + "description": { + "description": "Description of the argument object.", + "type": "string" + } + }, + "required": ["type"], + "additionalProperties": { + "description": "Additional JSON Schema keyword reported by the server." + }, + "description": "JSON Schema for the tool's arguments, as reported by the server." + }, + "serverId": { + "type": "string", + "description": "Identifier of the MCP server exposing the tool." + }, + "serverName": { + "type": "string", + "description": "Display name of the MCP server exposing the tool." + } + }, + "required": ["name", "inputSchema", "serverId", "serverName"], + "additionalProperties": false, + "title": "MCP tool", + "description": "A tool exposed by a registered MCP server." + }, + "ListMcpServerToolsResponse": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/V2McpTool" + }, + "description": "Items in the current page." + }, + "nextCursor": { + "anyOf": [ + { + "type": "string" + }, + { + "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." + } + }, + "required": ["data", "nextCursor"], + "additionalProperties": false, + "title": "List MCP server tools response", + "description": "Tools exposed by the MCP server.", + "examples": [ + { + "data": [ + { + "name": "search_docs", + "description": "Search the internal documentation", + "inputSchema": { + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "Search terms" + } + }, + "required": ["query"] + }, + "serverId": "mcp-3f7a9c21", + "serverName": "Docs server" + } + ], + "nextCursor": null + } + ] + }, "V2SkillSummary": { "type": "object", "properties": { diff --git a/apps/docs/openapi-v2-tables.json b/apps/docs/openapi-v2-tables.json index 66ef47c1c0c..809446dc7b9 100644 --- a/apps/docs/openapi-v2-tables.json +++ b/apps/docs/openapi-v2-tables.json @@ -1455,6 +1455,85 @@ } } }, + "/api/v2/tables/{tableId}/query/count": { + "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`.", + "tags": ["Tables"], + "parameters": [ + { + "name": "tableId", + "in": "path", + "required": true, + "description": "Unique table identifier.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Unique table identifier." + } + } + ], + "requestBody": { + "required": true, + "description": "Workspace scope and the optional predicate whose matches are counted.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CountTableRowsRequest" + } + } + } + }, + "responses": { + "200": { + "description": "The number of matching table rows.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2CountTableRowsResponse" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + } + }, "/api/v2/tables/{tableId}/views": { "get": { "operationId": "listTableViews", @@ -3673,7 +3752,7 @@ } }, "Forbidden": { - "description": "The caller lacks access to the resource.", + "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.", "content": { "application/json": { "schema": { @@ -4172,6 +4251,10 @@ "enum": ["string", "number", "currency", "boolean", "date", "json", "select"], "description": "Column data type." }, + "required": { + "description": "Whether inserts must supply a value for this column.", + "type": "boolean" + }, "unique": { "description": "Whether values in the column must be unique.", "type": "boolean" @@ -4450,6 +4533,10 @@ "enum": ["string", "number", "currency", "boolean", "date", "json", "select"], "description": "Column data type." }, + "required": { + "description": "Whether inserts must supply a value for this column.", + "type": "boolean" + }, "unique": { "description": "Whether values in the column must be unique.", "type": "boolean" @@ -4541,6 +4628,10 @@ "type": "string", "enum": ["string", "number", "currency", "boolean", "date", "json", "select"] }, + "required": { + "description": "Whether inserts must supply a value for this column.", + "type": "boolean" + }, "unique": { "description": "Whether values in the column must be unique.", "type": "boolean" @@ -4612,6 +4703,7 @@ } }, "required": ["workspaceId", "columnName"], + "additionalProperties": false, "title": "Delete table column request", "description": "Workspace scope and column name to delete.", "examples": [ @@ -4773,7 +4865,8 @@ "description": "Rows to insert, with cells keyed by column name." } }, - "required": ["workspaceId", "rows"] + "required": ["workspaceId", "rows"], + "additionalProperties": false }, { "type": "object", @@ -4798,7 +4891,8 @@ "minLength": 1 } }, - "required": ["workspaceId", "data"] + "required": ["workspaceId", "data"], + "additionalProperties": false } ], "title": "Create table rows request", @@ -4869,6 +4963,7 @@ } }, "required": ["workspaceId", "filter", "data"], + "additionalProperties": false, "title": "Update table rows request", "description": "Workspace scope, typed predicate, and row-data patch." }, @@ -4945,6 +5040,7 @@ } }, "required": ["workspaceId"], + "additionalProperties": false, "title": "Delete table rows request", "description": "Workspace scope and exactly one of a predicate or row identifier list.", "examples": [ @@ -4981,6 +5077,7 @@ } }, "required": ["workspaceId", "data"], + "additionalProperties": false, "title": "Update table row request", "description": "Workspace scope and row-data patch keyed by column name.", "examples": [ @@ -5073,6 +5170,7 @@ } }, "required": ["workspaceId", "data"], + "additionalProperties": false, "title": "Upsert table row request", "description": "Workspace scope, row data, and optional unique-column conflict target.", "examples": [ @@ -5143,7 +5241,8 @@ "description": "Sort direction for this column." } }, - "required": ["field", "direction"] + "required": ["field", "direction"], + "additionalProperties": false } }, "limit": { @@ -5159,6 +5258,7 @@ } }, "required": ["workspaceId"], + "additionalProperties": false, "title": "Query table rows request", "description": "Workspace scope, optional predicate and sort, and cursor pagination controls.", "examples": [ @@ -5183,6 +5283,65 @@ } ] }, + "V2QueryRowsCountData": { + "type": "object", + "properties": { + "totalCount": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "description": "Number of rows matching the predicate across the entire table." + } + }, + "required": ["totalCount"], + "additionalProperties": false, + "title": "Query rows count data", + "description": "Total number of table rows matching a predicate." + }, + "V2CountTableRowsResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2QueryRowsCountData" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Count table rows response", + "description": "The total number of table rows matching the predicate." + }, + "CountTableRowsRequest": { + "type": "object", + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "Unique workspace identifier." + }, + "predicate": { + "description": "Recursive predicate tree with exactly one non-empty `all` or `any` group at each group node." + } + }, + "required": ["workspaceId"], + "additionalProperties": false, + "title": "Count table rows request", + "description": "Workspace scope and the optional predicate whose matches are counted.", + "examples": [ + { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "predicate": { + "all": [ + { + "field": "status", + "op": "eq", + "value": "active" + } + ] + } + } + ] + }, "V2ApiTableView": { "type": "object", "properties": { @@ -5447,7 +5606,8 @@ "description": "Sort direction for this column." } }, - "required": ["field", "direction"] + "required": ["field", "direction"], + "additionalProperties": false } }, { @@ -5456,10 +5616,12 @@ ] } }, + "additionalProperties": false, "description": "Saved filter, sort, and column-layout configuration." } }, "required": ["workspaceId", "name", "config"], + "additionalProperties": false, "title": "Create table view request", "description": "Workspace scope, name, and saved filter, sort, and layout configuration." }, @@ -5557,7 +5719,8 @@ "description": "Sort direction for this column." } }, - "required": ["field", "direction"] + "required": ["field", "direction"], + "additionalProperties": false } }, { @@ -5565,7 +5728,8 @@ } ] } - } + }, + "additionalProperties": false }, "configPatch": { "description": "Saved-view configuration fields to shallow-merge.", @@ -5635,7 +5799,8 @@ "description": "Sort direction for this column." } }, - "required": ["field", "direction"] + "required": ["field", "direction"], + "additionalProperties": false } }, { @@ -5643,7 +5808,8 @@ } ] } - } + }, + "additionalProperties": false }, "isDefault": { "description": "Whether to promote this view to the table default.", @@ -5651,6 +5817,7 @@ } }, "required": ["workspaceId"], + "additionalProperties": false, "title": "Update table view request", "description": "Workspace scope and one or more saved-view changes." }, @@ -6488,6 +6655,7 @@ } }, "required": ["workspaceId", "groupIds"], + "additionalProperties": false, "title": "Run table columns request", "description": "Workspace scope, producer groups, execution mode, and optional row scope.", "examples": [ @@ -6520,6 +6688,7 @@ } }, "required": ["workspaceId"], + "additionalProperties": false, "title": "Run row enrichment request", "description": "Workspace scope for the row enrichment.", "examples": [ @@ -6617,11 +6786,13 @@ "description": "Sort direction for this column." } }, - "required": ["field", "direction"] + "required": ["field", "direction"], + "additionalProperties": false } } }, "required": ["workspaceId", "q"], + "additionalProperties": false, "title": "Find table rows request", "description": "Workspace scope, substring query, and optional predicate and sort.", "examples": [ @@ -7624,6 +7795,7 @@ } }, "required": ["workspaceId"], + "additionalProperties": false, "title": "Create table export request", "description": "Workspace scope and export format.", "examples": [ @@ -7754,6 +7926,7 @@ } }, "required": ["workspaceId", "scope"], + "additionalProperties": false, "title": "Cancel table runs request", "description": "Workspace scope, cancellation scope, and optional predicate or producer groups.", "examples": [ diff --git a/apps/docs/openapi-v2-workflows.json b/apps/docs/openapi-v2-workflows.json index 65348031bc5..e71582276f2 100644 --- a/apps/docs/openapi-v2-workflows.json +++ b/apps/docs/openapi-v2-workflows.json @@ -642,6 +642,72 @@ } } }, + "/api/v2/workflows/{id}/deployment": { + "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.", + "tags": ["Workflows"], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "Unique workflow identifier.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Unique workflow identifier.", + "examples": ["3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36"] + } + } + ], + "responses": { + "200": { + "description": "The current deployment state.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkflowDeploymentResponse" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + } + }, "/api/v2/workflows/{id}/deploy": { "post": { "operationId": "deployWorkflow", @@ -1215,24 +1281,24 @@ "name": "startDate", "in": "query", "required": false, - "description": "Include runs started at or after this ISO 8601 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.", "schema": { - "description": "Include runs started at or after this ISO 8601 timestamp.", "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))$" + "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." } }, { "name": "endDate", "in": "query", "required": false, - "description": "Include runs started at or before this ISO 8601 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.", "schema": { - "description": "Include runs started at or before this ISO 8601 timestamp.", "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))$" + "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." } }, { @@ -1357,8 +1423,7 @@ "description": "Include final and block outputs when true.", "schema": { "description": "Include final and block outputs when true.", - "type": "string", - "enum": ["true", "false"] + "type": "boolean" } }, { @@ -2069,7 +2134,7 @@ } }, "Forbidden": { - "description": "The caller lacks access to the resource.", + "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.", "content": { "application/json": { "schema": { @@ -3068,6 +3133,123 @@ "title": "Deployment operation error", "description": "Failure details for a deployment lifecycle operation." }, + "WorkflowDeployment": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique workflow identifier.", + "examples": ["3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36"] + }, + "isDeployed": { + "type": "boolean", + "description": "Whether a workflow version is currently live and available for API execution." + }, + "deployedAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "ISO 8601 timestamp associated with the deployment, or null when unavailable.", + "format": "date-time", + "examples": ["2026-06-12T10:30:00.000Z"] + }, + "warnings": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Non-fatal synchronization warnings. Empty when there is nothing to report." + }, + "activeDeployment": { + "anyOf": [ + { + "$ref": "#/components/schemas/ActiveDeploymentSummary" + }, + { + "type": "null" + } + ], + "description": "Currently live deployment version, or null while no version is active." + }, + "latestDeploymentAttempt": { + "anyOf": [ + { + "$ref": "#/components/schemas/DeploymentOperationSummary" + }, + { + "type": "null" + } + ], + "description": "Most recent deployment lifecycle attempt, or null when none is available." + }, + "needsRedeployment": { + "type": "boolean", + "description": "Whether the editable draft has diverged from the live deployment version. False while a deployment attempt is still preparing or activating, and false when nothing is deployed." + } + }, + "required": [ + "id", + "isDeployed", + "deployedAt", + "warnings", + "activeDeployment", + "latestDeploymentAttempt", + "needsRedeployment" + ], + "additionalProperties": false, + "title": "Workflow deployment", + "description": "Current deployment state of a workflow, including draft-versus-live drift and the most recent deployment attempt." + }, + "WorkflowDeploymentResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/WorkflowDeployment" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Workflow deployment response", + "description": "Current deployment state, including draft-versus-live drift.", + "examples": [ + { + "data": { + "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "isDeployed": true, + "needsRedeployment": true, + "deployedAt": "2026-06-12T10:30:00.000Z", + "warnings": [], + "activeDeployment": { + "deploymentVersionId": "depver_01J8ZK3QW4M6X2R9T7B5C0V2", + "version": 3, + "deployedAt": "2026-06-12T10:30:00.000Z" + }, + "latestDeploymentAttempt": { + "id": "depop_01J8ZK3QW4M6X2R9T7B5C0V1", + "deploymentVersionId": "depver_01J8ZK3QW4M6X2R9T7B5C0V2", + "version": 3, + "action": "deploy", + "status": "active", + "isCurrent": true, + "readiness": { + "webhooks": "ready", + "schedules": "ready", + "mcp": "not_applicable" + }, + "requestedAt": "2026-06-12T10:29:58.000Z", + "activatedAt": "2026-06-12T10:30:00.000Z", + "error": null + } + } + } + ] + }, "DeployResult": { "type": "object", "properties": { diff --git a/apps/sim/app/api/mcp/oauth/callback/route.ts b/apps/sim/app/api/mcp/oauth/callback/route.ts index 9d63191fb32..ff76af5b2a3 100644 --- a/apps/sim/app/api/mcp/oauth/callback/route.ts +++ b/apps/sim/app/api/mcp/oauth/callback/route.ts @@ -208,7 +208,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { try { // forceRefresh: skip any stale cache from before re-auth. await timedStep('discoverServerTools', 60_000, () => - mcpService.discoverServerTools(session.user.id, server.id, server.workspaceId, true) + mcpService.discoverServerTools(session.user.id, server.id, server.workspaceId, 'force') ) } catch (e) { logger.warn('Post-auth tools refresh failed', toError(e).message) 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 b1ceda9d016..90a91aeae7d 100644 --- a/apps/sim/app/api/mcp/servers/[id]/refresh/route.ts +++ b/apps/sim/app/api/mcp/servers/[id]/refresh/route.ts @@ -196,7 +196,7 @@ export const POST = withRouteHandler( userId, serverId, workspaceId, - true + 'force' ) logger.info( `[${requestId}] Discovered ${discoveredTools.length} tools from server ${serverId}` diff --git a/apps/sim/app/api/mcp/tools/discover/route.ts b/apps/sim/app/api/mcp/tools/discover/route.ts index 84acdad0b3d..a592dc116bb 100644 --- a/apps/sim/app/api/mcp/tools/discover/route.ts +++ b/apps/sim/app/api/mcp/tools/discover/route.ts @@ -65,8 +65,17 @@ export const GET = withRouteHandler( logger.info(`[${requestId}] Discovering MCP tools`, { serverId, workspaceId, forceRefresh }) const tools = serverId - ? await mcpService.discoverServerTools(userId, serverId, workspaceId, forceRefresh) - : await mcpService.discoverTools(userId, workspaceId, forceRefresh) + ? await mcpService.discoverServerTools( + userId, + serverId, + workspaceId, + forceRefresh ? 'force' : 'cache-aside' + ) + : await mcpService.discoverTools( + userId, + workspaceId, + forceRefresh ? 'force' : 'cache-aside' + ) const byServer: Record = {} for (const tool of tools) { @@ -115,7 +124,7 @@ export const POST = withRouteHandler( serverIds, MCP_REFRESH_DISCOVERY_CONCURRENCY, async (serverId: string) => { - const tools = await mcpService.discoverServerTools(userId, serverId, workspaceId, true) + const tools = await mcpService.discoverServerTools(userId, serverId, workspaceId, 'force') return { serverId, toolCount: tools.length } } ) diff --git a/apps/sim/app/api/mcp/tools/execute/route.ts b/apps/sim/app/api/mcp/tools/execute/route.ts index d045b407a54..cd371580e42 100644 --- a/apps/sim/app/api/mcp/tools/execute/route.ts +++ b/apps/sim/app/api/mcp/tools/execute/route.ts @@ -157,7 +157,7 @@ export const POST = withRouteHandler( userId, serverId, workspaceId, - false, + 'cache-aside', recordProvenance ) tool = tools.find((t) => t.name === toolName) ?? null diff --git a/apps/sim/app/api/users/me/usage-logs/cursor-route.test.ts b/apps/sim/app/api/users/me/usage-logs/cursor-route.test.ts new file mode 100644 index 00000000000..5d747a37d0c --- /dev/null +++ b/apps/sim/app/api/users/me/usage-logs/cursor-route.test.ts @@ -0,0 +1,50 @@ +/** + * @vitest-environment node + * + * The unresolvable-cursor rejection, end to end on the session-only ledger route. + * + * Deliberately a separate file from `route.test.ts`: that suite replaces + * `@/lib/billing/core/usage-log` with mocks, which is exactly the seam this case + * has to cross. Here the real query runs against the shared `@sim/db` chain mock, + * so the assertion covers the throw in billing core, `withRouteHandler`'s typed-error + * projection, and the message the caller reads — the path that answered 500 while the + * rejection was an `OrchestrationError` alone. + */ +import { authMockFns, createMockRequest, dbChainMockFns, resetDbChainMock } from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { UNKNOWN_CURSOR_MESSAGE } from '@/lib/billing/core/usage-log' +import { GET } from '@/app/api/users/me/usage-logs/route' + +afterAll(() => { + resetDbChainMock() +}) + +describe('GET /api/users/me/usage-logs cursor rejection', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-1' } }) + }) + + it('answers 400 when the cursor names no usage event', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([]) + + const response = await GET( + createMockRequest( + 'GET', + undefined, + {}, + 'http://localhost:3000/api/users/me/usage-logs?cursor=log-from-another-ledger' + ) + ) + + expect(response.status).toBe(400) + await expect(response.json()).resolves.toMatchObject({ error: UNKNOWN_CURSOR_MESSAGE }) + }) + + it('answers 200 for a request carrying no cursor', async () => { + const response = await GET(createMockRequest('GET')) + + expect(response.status).toBe(200) + }) +}) diff --git a/apps/sim/app/api/v2/[[...segments]]/route.ts b/apps/sim/app/api/v2/[[...segments]]/route.ts index e02fa71a017..893f455a7bc 100644 --- a/apps/sim/app/api/v2/[[...segments]]/route.ts +++ b/apps/sim/app/api/v2/[[...segments]]/route.ts @@ -23,8 +23,8 @@ export const revalidate = 0 * holds a key — requiring auth first would turn the 404 into a 401 and confirm * that the path is special. * - * Next.js only routes a request here when no literal segment matches, so the 77 - * real v2 routes are unaffected. The optional form (`[[...segments]]`) also + * Next.js only routes a request here when no literal segment matches, so every + * real v2 route file is unaffected, however many there are. The optional form (`[[...segments]]`) also * covers bare `/api/v2`. It cannot fix a 405 on a path that *does* have a route * file but does not export that verb — Next generates that response itself, * before any handler runs. 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 0c5e5f79387..b323e834a83 100644 --- a/apps/sim/app/api/v2/billing/logs/route.test.ts +++ b/apps/sim/app/api/v2/billing/logs/route.test.ts @@ -24,6 +24,8 @@ vi.mock('@/lib/billing/application/list-billing-logs', () => ({ listBillingLogs: { operation: { id: 'billing.logs.list' }, execute: mocks.execute }, })) +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' const auth = { @@ -95,6 +97,21 @@ describe('GET /api/v2/billing/logs', () => { }) }) + it('projects an unresolvable cursor as a 400 rather than an unpositioned first page', async () => { + mocks.execute.mockRejectedValueOnce( + new OrchestrationError('validation', UNKNOWN_CURSOR_MESSAGE) + ) + + const response = await GET( + new NextRequest('http://localhost:3000/api/v2/billing/logs?cursor=log-from-another-ledger') + ) + + expect(response.status).toBe(400) + expect(await response.json()).toMatchObject({ + error: { code: 'BAD_REQUEST', message: UNKNOWN_CURSOR_MESSAGE }, + }) + }) + it('authenticates before rejecting invalid custom ranges', async () => { const response = await GET( new NextRequest('http://localhost:3000/api/v2/billing/logs?period=custom') 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 e664f896b36..d7ccd3d07e4 100644 --- a/apps/sim/app/api/v2/billing/status/route.test.ts +++ b/apps/sim/app/api/v2/billing/status/route.test.ts @@ -80,6 +80,19 @@ describe('GET /api/v2/billing/status', () => { expect(await response.json()).toEqual({ data: { ...result, credits: null, storage: null } }) }) + it.each(['workspaceID', 'workspace_id', 'workspace'])( + 'rejects %s rather than silently answering for the account payer', + async (key) => { + const response = await GET( + new NextRequest(`http://localhost:3000/api/v2/billing/status?${key}=workspace-1`) + ) + + expect(response.status).toBe(400) + expect(await response.json()).toMatchObject({ error: { code: 'BAD_REQUEST' } }) + expect(mocks.execute).not.toHaveBeenCalled() + } + ) + it('projects typed workspace-policy errors', async () => { mocks.execute.mockRejectedValueOnce( new OrchestrationError('forbidden', 'API key is not authorized for this workspace') diff --git a/apps/sim/app/api/v2/files/[fileId]/content/route.test.ts b/apps/sim/app/api/v2/files/[fileId]/content/route.test.ts index acef3a88a5f..db308de20b6 100644 --- a/apps/sim/app/api/v2/files/[fileId]/content/route.test.ts +++ b/apps/sim/app/api/v2/files/[fileId]/content/route.test.ts @@ -1,15 +1,21 @@ /** * @vitest-environment node */ +import { + MockV2ApiKeyUnauthenticatedError, + V2_OPERATION_RATE_LIMIT_ALLOWED, + V2_PREAUTH_RATE_LIMIT_ALLOWED, + v2ApiKeyAuthModuleMock, + v2GateModuleMock, + v2RateLimiterModuleMock, + v2RouteMocks, +} from '@sim/testing' import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ admit: vi.fn(), updateContent: vi.fn(), - authenticateV2ApiKey: vi.fn(), - checkRateLimitDirect: vi.fn(), - checkRateLimitDirectOrThrow: vi.fn(), getUserEmailsByIds: vi.fn(), })) @@ -25,22 +31,9 @@ vi.mock('@/lib/workspace-files/application/update-workspace-file-content', () => }, })) -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: vi.fn().mockResolvedValue(null), -})) +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) vi.mock('@/lib/users/queries', () => ({ getUserEmailsByIds: mocks.getUserEmailsByIds, @@ -94,17 +87,10 @@ const callPut = (body: unknown, contentLength?: number) => describe('PUT /api/v2/files/[fileId]/content', () => { beforeEach(() => { vi.clearAllMocks() - mocks.authenticateV2ApiKey.mockResolvedValue(auth) - mocks.checkRateLimitDirect.mockResolvedValue({ - allowed: true, - remaining: 599, - resetAt: new Date('2024-01-01T01:00:00Z'), - }) - mocks.checkRateLimitDirectOrThrow.mockResolvedValue({ - allowed: true, - remaining: 99, - resetAt: new Date('2024-01-01T01:00:00Z'), - }) + v2RouteMocks.authenticate.mockResolvedValue(auth) + v2RouteMocks.gate.mockResolvedValue(null) + v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) + v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) mocks.admit.mockResolvedValue(undefined) mocks.updateContent.mockResolvedValue({ file: record }) mocks.getUserEmailsByIds.mockResolvedValue(new Map([['user-1', 'ada@example.com']])) @@ -120,6 +106,15 @@ describe('PUT /api/v2/files/[fileId]/content', () => { expect(mocks.updateContent).not.toHaveBeenCalled() }) + it('rejects an unauthenticated request', async () => { + v2RouteMocks.authenticate.mockRejectedValueOnce(new MockV2ApiKeyUnauthenticatedError()) + + const response = await callPut({ workspaceId: WORKSPACE_ID, content: 'id,name\n' }) + + expect(response.status).toBe(401) + expect((await response.json()).error.code).toBe('UNAUTHORIZED') + }) + it('validates body fields after admission', async () => { const response = await callPut({ workspaceId: WORKSPACE_ID }) @@ -159,6 +154,7 @@ describe('PUT /api/v2/files/[fileId]/content', () => { uploadedByEmail: 'ada@example.com', uploadedAt: '2024-01-01T00:00:00.000Z', updatedAt: '2024-01-03T00:00:00.000Z', + deletedAt: null, }, }) expect(mocks.updateContent).toHaveBeenCalledWith({ @@ -171,7 +167,7 @@ describe('PUT /api/v2/files/[fileId]/content', () => { }, request, }) - expect(mocks.checkRateLimitDirectOrThrow).toHaveBeenCalledWith( + expect(v2RouteMocks.operationRate).toHaveBeenCalledWith( 'v2:files.update_content:api-key:key-1', expect.anything() ) diff --git a/apps/sim/app/api/v2/files/[fileId]/metadata/route.test.ts b/apps/sim/app/api/v2/files/[fileId]/metadata/route.test.ts index 3c26fadfaad..c947dff15a9 100644 --- a/apps/sim/app/api/v2/files/[fileId]/metadata/route.test.ts +++ b/apps/sim/app/api/v2/files/[fileId]/metadata/route.test.ts @@ -1,14 +1,20 @@ /** * @vitest-environment node */ +import { + MockV2ApiKeyUnauthenticatedError, + V2_OPERATION_RATE_LIMIT_ALLOWED, + V2_PREAUTH_RATE_LIMIT_ALLOWED, + v2ApiKeyAuthModuleMock, + v2GateModuleMock, + v2RateLimiterModuleMock, + v2RouteMocks, +} from '@sim/testing' import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ readMetadata: vi.fn(), - authenticateV2ApiKey: vi.fn(), - checkRateLimitDirect: vi.fn(), - checkRateLimitDirectOrThrow: vi.fn(), getUserEmailsByIds: vi.fn(), })) @@ -19,22 +25,9 @@ vi.mock('@/lib/workspace-files/application/read-workspace-file-metadata', () => }, })) -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: vi.fn().mockResolvedValue(null), -})) +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) vi.mock('@/lib/users/queries', () => ({ getUserEmailsByIds: mocks.getUserEmailsByIds, @@ -89,17 +82,10 @@ const callGet = (query: string) => describe('GET /api/v2/files/[fileId]/metadata', () => { beforeEach(() => { vi.clearAllMocks() - mocks.authenticateV2ApiKey.mockResolvedValue(auth) - mocks.checkRateLimitDirect.mockResolvedValue({ - allowed: true, - remaining: 599, - resetAt: new Date('2024-01-01T01:00:00Z'), - }) - mocks.checkRateLimitDirectOrThrow.mockResolvedValue({ - allowed: true, - remaining: 99, - resetAt: new Date('2024-01-01T01:00:00Z'), - }) + v2RouteMocks.authenticate.mockResolvedValue(auth) + v2RouteMocks.gate.mockResolvedValue(null) + v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) + v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) mocks.readMetadata.mockResolvedValue({ file: buildRecord(), share: SHARE }) mocks.getUserEmailsByIds.mockResolvedValue(new Map([['user-1', 'ada@example.com']])) }) @@ -108,11 +94,20 @@ describe('GET /api/v2/files/[fileId]/metadata', () => { const response = await callGet('') expect(response.status).toBe(400) - expect(mocks.authenticateV2ApiKey).toHaveBeenCalled() - expect(mocks.checkRateLimitDirectOrThrow).toHaveBeenCalledTimes(2) + expect(v2RouteMocks.authenticate).toHaveBeenCalled() + expect(v2RouteMocks.operationRate).toHaveBeenCalledTimes(2) expect(mocks.readMetadata).not.toHaveBeenCalled() }) + it('rejects an unauthenticated request', async () => { + v2RouteMocks.authenticate.mockRejectedValueOnce(new MockV2ApiKeyUnauthenticatedError()) + + const response = await callGet(`workspaceId=${WORKSPACE_ID}`) + + expect(response.status).toBe(401) + expect((await response.json()).error.code).toBe('UNAUTHORIZED') + }) + it('conceals cross-workspace authorization as not found', async () => { mocks.readMetadata.mockRejectedValue(new NoWorkspaceAccessError()) @@ -137,6 +132,7 @@ describe('GET /api/v2/files/[fileId]/metadata', () => { uploadedByEmail: 'ada@example.com', uploadedAt: '2024-01-01T00:00:00.000Z', updatedAt: '2024-01-02T00:00:00.000Z', + deletedAt: null, share: SHARE, }, }) diff --git a/apps/sim/app/api/v2/files/[fileId]/restore/route.test.ts b/apps/sim/app/api/v2/files/[fileId]/restore/route.test.ts new file mode 100644 index 00000000000..926e0166a28 --- /dev/null +++ b/apps/sim/app/api/v2/files/[fileId]/restore/route.test.ts @@ -0,0 +1,156 @@ +/** + * @vitest-environment node + */ +import { + MockV2ApiKeyUnauthenticatedError, + V2_OPERATION_RATE_LIMIT_ALLOWED, + V2_PREAUTH_RATE_LIMIT_ALLOWED, + v2ApiKeyAuthModuleMock, + v2GateModuleMock, + v2RateLimiterModuleMock, + v2RouteMocks, +} from '@sim/testing' +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + restoreFile: vi.fn(), + getUserEmailsByIds: vi.fn(), +})) + +vi.mock('@/lib/workspace-files/application/restore-workspace-file', () => ({ + restoreWorkspaceFileOperation: { + operation: { id: 'files.restore', minimumRole: 'write', workspaceApiKey: 'allow' }, + execute: mocks.restoreFile, + }, +})) + +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) + +vi.mock('@/lib/users/queries', () => ({ + getUserEmailsByIds: mocks.getUserEmailsByIds, + requireResolvedUserEmail: (emails: Map, userId: string) => emails.get(userId)!, +})) + +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { POST } from '@/app/api/v2/files/[fileId]/restore/route' + +const WORKSPACE_ID = 'workspace-1' +const FILE_ID = 'wf_1' + +const auth = { + principal: { + kind: 'workspace_api_key' as const, + workspaceId: WORKSPACE_ID, + keyId: 'key-1', + }, + rolloutUserId: 'billing-owner-1', + rateLimitSubjectIds: ['api-key:key-1', `workspace:${WORKSPACE_ID}`] as const, + rateLimitSubscription: null, + keyType: 'workspace' as const, +} + +/** The post-restore record: renamed away from the taken name, back at the root. */ +const RESTORED_FILE = { + id: FILE_ID, + workspaceId: WORKSPACE_ID, + name: 'notes_restored.md', + key: `workspace/${WORKSPACE_ID}/notes.md`, + path: '/api/files/serve/notes.md?context=workspace', + size: 12, + type: 'text/markdown', + uploadedBy: 'user-1', + folderId: null, + folderPath: null, + deletedAt: null, + uploadedAt: new Date('2026-08-04T00:00:00.000Z'), + updatedAt: new Date('2026-08-07T00:00:00.000Z'), +} + +function restoreRequest(body: unknown): NextRequest { + return new NextRequest(`http://localhost:3000/api/v2/files/${FILE_ID}/restore`, { + method: 'POST', + headers: { 'content-type': 'application/json', 'x-api-key': 'secret' }, + body: JSON.stringify(body), + }) +} + +function post(body: unknown) { + return POST(restoreRequest(body), { params: Promise.resolve({ fileId: FILE_ID }) }) +} + +describe('POST /api/v2/files/[fileId]/restore', () => { + beforeEach(() => { + vi.clearAllMocks() + v2RouteMocks.authenticate.mockResolvedValue(auth) + v2RouteMocks.gate.mockResolvedValue(null) + v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) + v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) + mocks.restoreFile.mockResolvedValue({ restored: true, file: RESTORED_FILE }) + mocks.getUserEmailsByIds.mockResolvedValue(new Map([['user-1', 'ada@example.com']])) + }) + + it('returns the post-restore record so the caller sees the new name and root placement', async () => { + const response = await post({ workspaceId: WORKSPACE_ID }) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + data: { + id: FILE_ID, + name: 'notes_restored.md', + size: 12, + type: 'text/markdown', + key: RESTORED_FILE.key, + folderPath: '/', + uploadedByEmail: 'ada@example.com', + uploadedAt: '2026-08-04T00:00:00.000Z', + updatedAt: '2026-08-07T00:00:00.000Z', + deletedAt: null, + }, + }) + expect(mocks.restoreFile).toHaveBeenCalledWith({ + principal: auth.principal, + input: { fileId: FILE_ID, assertedWorkspaceId: WORKSPACE_ID }, + request: expect.anything(), + }) + }) + + it('conceals a file in another workspace as 404 rather than confirming it exists', async () => { + mocks.restoreFile.mockRejectedValueOnce(new OrchestrationError('not_found', 'File not found')) + + const response = await post({ workspaceId: WORKSPACE_ID }) + + expect(response.status).toBe(404) + expect((await response.json()).error).toMatchObject({ + code: 'NOT_FOUND', + message: 'File not found', + }) + }) + + it('rejects an unknown body key instead of ignoring it', async () => { + const response = await post({ workspaceId: WORKSPACE_ID, folderPath: '/Engineering' }) + + expect(response.status).toBe(400) + expect(mocks.restoreFile).not.toHaveBeenCalled() + }) + + it('authenticates and charges before validating the body', async () => { + const response = await post({}) + + expect(response.status).toBe(400) + expect(v2RouteMocks.authenticate).toHaveBeenCalled() + expect(v2RouteMocks.operationRate).toHaveBeenCalledTimes(2) + expect(mocks.restoreFile).not.toHaveBeenCalled() + }) + + it('rejects an unauthenticated request', async () => { + v2RouteMocks.authenticate.mockRejectedValueOnce(new MockV2ApiKeyUnauthenticatedError()) + + const response = await post({ workspaceId: WORKSPACE_ID }) + + expect(response.status).toBe(401) + expect((await response.json()).error.code).toBe('UNAUTHORIZED') + }) +}) diff --git a/apps/sim/app/api/v2/files/[fileId]/restore/route.ts b/apps/sim/app/api/v2/files/[fileId]/restore/route.ts new file mode 100644 index 00000000000..050a49e0ccf --- /dev/null +++ b/apps/sim/app/api/v2/files/[fileId]/restore/route.ts @@ -0,0 +1,35 @@ +import { v2RestoreFileContract } from '@/lib/api/contracts/v2/files' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2FileErrorPolicies } from '@/lib/workspace-files/api' +import { fileOperations } from '@/lib/workspace-files/application/operations' +import { restoreWorkspaceFileOperation } from '@/lib/workspace-files/application/restore-workspace-file' +import { toV2File } from '@/app/api/v2/files/utils' + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +/** + * POST /api/v2/files/[fileId]/restore — Bring an archived file back. + * + * `DELETE /api/v2/files/[fileId]` is a soft delete; this reverses it. Find the + * ids to pass here with `GET /api/v2/files?scope=archived`. + * + * Restore is not a pure undo: the file returns to the workspace root regardless + * of the folder it was deleted from, and it is renamed when its original name + * is no longer free. The response is therefore the post-restore record, not the + * one the caller deleted. Restoring an already-active file is a no-op that + * returns that file, so a retried request is safe. + */ +export const POST = defineV2JsonRoute({ + contract: v2RestoreFileContract, + auth: v2ApiKeyAuth, + operation: fileOperations.restore, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2FileErrorPolicies.concealResourceAuthorization, + mapInput: ({ params, body }) => ({ + fileId: params.fileId, + assertedWorkspaceId: body.workspaceId, + }), + useCase: restoreWorkspaceFileOperation, + present: async ({ file }) => ({ data: await toV2File(file) }), +}) 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 a63dbb5c50a..d4405e2afd0 100644 --- a/apps/sim/app/api/v2/files/[fileId]/route.test.ts +++ b/apps/sim/app/api/v2/files/[fileId]/route.test.ts @@ -1,6 +1,15 @@ /** * @vitest-environment node */ +import { + MockV2ApiKeyUnauthenticatedError, + V2_OPERATION_RATE_LIMIT_ALLOWED, + V2_PREAUTH_RATE_LIMIT_ALLOWED, + v2ApiKeyAuthModuleMock, + v2GateModuleMock, + v2RateLimiterModuleMock, + v2RouteMocks, +} from '@sim/testing' import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -8,9 +17,6 @@ const mocks = vi.hoisted(() => ({ download: vi.fn(), rename: vi.fn(), deleteFile: vi.fn(), - authenticateV2ApiKey: vi.fn(), - checkRateLimitDirect: vi.fn(), - checkRateLimitDirectOrThrow: vi.fn(), getUserEmailsByIds: vi.fn(), })) @@ -35,20 +41,9 @@ vi.mock('@/lib/workspace-files/application/delete-workspace-file', () => ({ }, })) -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: vi.fn().mockResolvedValue(null) })) +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) vi.mock('@/lib/users/queries', () => ({ getUserEmailsByIds: mocks.getUserEmailsByIds, @@ -97,17 +92,10 @@ function fileRecord(overrides: Record = {}) { describe('v2 single-file routes', () => { beforeEach(() => { vi.clearAllMocks() - mocks.authenticateV2ApiKey.mockResolvedValue(auth) - mocks.checkRateLimitDirect.mockResolvedValue({ - allowed: true, - remaining: 599, - resetAt: new Date('2024-01-01T01:00:00Z'), - }) - mocks.checkRateLimitDirectOrThrow.mockResolvedValue({ - allowed: true, - remaining: 99, - resetAt: new Date('2024-01-01T01:00:00Z'), - }) + v2RouteMocks.authenticate.mockResolvedValue(auth) + v2RouteMocks.gate.mockResolvedValue(null) + v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) + v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) mocks.download.mockResolvedValue({ file: fileRecord(), stream: new Blob(['id,name\n']).stream(), @@ -141,6 +129,18 @@ describe('v2 single-file routes', () => { }) }) + it('rejects an unauthenticated request', async () => { + v2RouteMocks.authenticate.mockRejectedValueOnce(new MockV2ApiKeyUnauthenticatedError()) + + const response = await GET( + new NextRequest(`http://localhost:3000/api/v2/files/${FILE_ID}?workspaceId=${WORKSPACE_ID}`), + context + ) + + expect(response.status).toBe(401) + expect((await response.json()).error.code).toBe('UNAUTHORIZED') + }) + it('encodes special characters in the extended download filename', async () => { mocks.download.mockResolvedValueOnce({ file: fileRecord({ name: "it's (final)* café.pdf" }), @@ -226,7 +226,11 @@ describe('v2 single-file routes', () => { expect(response.status).toBe(403) expect(await response.json()).toEqual({ - error: { code: 'FORBIDDEN', message: 'Insufficient workspace permissions' }, + error: { + code: 'FORBIDDEN', + message: 'Insufficient workspace permissions', + details: { code: 'INSUFFICIENT_WORKSPACE_ROLE' }, + }, }) }) diff --git a/apps/sim/app/api/v2/files/[fileId]/share/route.test.ts b/apps/sim/app/api/v2/files/[fileId]/share/route.test.ts index 42d6c30a8a2..7d33f8c91f9 100644 --- a/apps/sim/app/api/v2/files/[fileId]/share/route.test.ts +++ b/apps/sim/app/api/v2/files/[fileId]/share/route.test.ts @@ -1,46 +1,26 @@ /** * @vitest-environment node */ +import { + MockV2ApiKeyUnauthenticatedError, + V2_OPERATION_RATE_LIMIT_ALLOWED, + V2_PREAUTH_RATE_LIMIT_ALLOWED, + v2ApiKeyAuthModuleMock, + v2GateModuleMock, + v2RateLimiterModuleMock, + v2RouteMocks, +} from '@sim/testing' import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mocks, MockV2ApiKeyUnauthenticatedError } = vi.hoisted(() => { - class MockV2ApiKeyUnauthenticatedError extends Error { - constructor(message = 'Invalid API key') { - super(message) - this.name = 'V2ApiKeyUnauthenticatedError' - } - } - - return { - mocks: { - authenticate: vi.fn(), - preauthRate: vi.fn(), - operationRate: vi.fn(), - gate: vi.fn(), - getShare: vi.fn(), - updateShare: vi.fn(), - }, - MockV2ApiKeyUnauthenticatedError, - } -}) - -vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => ({ - authenticateV2ApiKey: mocks.authenticate, - V2ApiKeyUnauthenticatedError: MockV2ApiKeyUnauthenticatedError, +const mocks = vi.hoisted(() => ({ + getShare: vi.fn(), + updateShare: vi.fn(), })) -vi.mock('@/lib/core/rate-limiter', () => ({ - RateLimiter: class { - checkRateLimitDirect = mocks.preauthRate - checkRateLimitDirectOrThrow = mocks.operationRate - }, - getRateLimit: vi.fn().mockReturnValue({ - maxTokens: 100, - refillRate: 100, - refillIntervalMs: 60_000, - }), -})) +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) vi.mock('@/lib/api/server/rate-limit-context', () => ({ recordRateLimitSnapshot: vi.fn(), @@ -52,8 +32,6 @@ vi.mock('@/lib/core/utils/request', () => ({ getClientIp: vi.fn().mockReturnValue('127.0.0.1'), })) -vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mocks.gate })) - vi.mock('@/lib/workspace-files/application/share-workspace-file', () => ({ getWorkspaceFileShare: { operation: { id: 'files.share.read', minimumRole: 'read', workspaceApiKey: 'allow' }, @@ -67,6 +45,7 @@ vi.mock('@/lib/workspace-files/application/share-workspace-file', () => ({ import { OrchestrationError } from '@/lib/core/orchestration/types' import { GET, PATCH } from '@/app/api/v2/files/[fileId]/share/route' +import { v2Error } from '@/app/api/v2/lib/response' const WORKSPACE_ID = 'workspace-1' const FILE_ID = 'wf_1' @@ -74,16 +53,16 @@ const PRINCIPAL = { kind: 'workspace_api_key' as const, workspaceId: WORKSPACE_I const AUTH = { principal: PRINCIPAL, rolloutUserId: 'owner-1', - rateLimitSubjectIds: ['workspace:workspace-1'] as const, + rateLimitSubjectIds: ['api-key:key-1', `workspace:${WORKSPACE_ID}`] as const, rateLimitSubscription: null, keyType: 'workspace' as const, } -const RATE_LIMIT_OK = { - allowed: true, +const RATE_LIMIT_DENIED = { + allowed: false, limit: 100, - remaining: 99, + remaining: 0, resetAt: new Date('2024-01-01T01:00:00Z'), - retryAfterMs: 0, + retryAfterMs: 1000, } const SHARE = { id: 'shr_1', @@ -121,15 +100,15 @@ function callPatch(body: unknown) { describe('GET /api/v2/files/[fileId]/share', () => { beforeEach(() => { vi.clearAllMocks() - mocks.authenticate.mockResolvedValue(AUTH) - mocks.preauthRate.mockResolvedValue(RATE_LIMIT_OK) - mocks.operationRate.mockResolvedValue(RATE_LIMIT_OK) - mocks.gate.mockResolvedValue(null) + v2RouteMocks.authenticate.mockResolvedValue(AUTH) + v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) + v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) + v2RouteMocks.gate.mockResolvedValue(null) mocks.getShare.mockResolvedValue({ share: SHARE }) }) it('authenticates and rate-limits before parsing or executing', async () => { - mocks.authenticate.mockRejectedValueOnce( + v2RouteMocks.authenticate.mockRejectedValueOnce( new MockV2ApiKeyUnauthenticatedError('API key required') ) @@ -137,12 +116,11 @@ describe('GET /api/v2/files/[fileId]/share', () => { expect(response.status).toBe(401) expect(mocks.getShare).not.toHaveBeenCalled() - expect(mocks.operationRate).not.toHaveBeenCalled() + expect(v2RouteMocks.operationRate).not.toHaveBeenCalled() }) it('returns 404 when the v2 API surface flag is off', async () => { - const { v2Error } = await import('@/app/api/v2/lib/response') - mocks.gate.mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) + v2RouteMocks.gate.mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) const response = await callGet() @@ -180,7 +158,7 @@ describe('GET /api/v2/files/[fileId]/share', () => { }) it('returns the rate-limit response when denied', async () => { - mocks.operationRate.mockResolvedValueOnce({ ...RATE_LIMIT_OK, allowed: false, remaining: 0 }) + v2RouteMocks.operationRate.mockResolvedValueOnce(RATE_LIMIT_DENIED) const response = await callGet() @@ -193,10 +171,10 @@ describe('GET /api/v2/files/[fileId]/share', () => { describe('PATCH /api/v2/files/[fileId]/share', () => { beforeEach(() => { vi.clearAllMocks() - mocks.authenticate.mockResolvedValue(AUTH) - mocks.preauthRate.mockResolvedValue(RATE_LIMIT_OK) - mocks.operationRate.mockResolvedValue(RATE_LIMIT_OK) - mocks.gate.mockResolvedValue(null) + v2RouteMocks.authenticate.mockResolvedValue(AUTH) + v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) + v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) + v2RouteMocks.gate.mockResolvedValue(null) mocks.updateShare.mockResolvedValue({ share: SHARE }) }) @@ -261,7 +239,7 @@ describe('PATCH /api/v2/files/[fileId]/share', () => { }) it('returns the rate-limit response when denied', async () => { - mocks.operationRate.mockResolvedValueOnce({ ...RATE_LIMIT_OK, allowed: false, remaining: 0 }) + v2RouteMocks.operationRate.mockResolvedValueOnce(RATE_LIMIT_DENIED) const response = await callPatch({ workspaceId: WORKSPACE_ID, isActive: true }) diff --git a/apps/sim/app/api/v2/files/bulk-delete/route.test.ts b/apps/sim/app/api/v2/files/bulk-delete/route.test.ts index a66f490f6c2..01206253399 100644 --- a/apps/sim/app/api/v2/files/bulk-delete/route.test.ts +++ b/apps/sim/app/api/v2/files/bulk-delete/route.test.ts @@ -1,35 +1,25 @@ /** * @vitest-environment node */ +import { + MockV2ApiKeyUnauthenticatedError, + V2_OPERATION_RATE_LIMIT_ALLOWED, + V2_PREAUTH_RATE_LIMIT_ALLOWED, + v2ApiKeyAuthModuleMock, + v2GateModuleMock, + v2RateLimiterModuleMock, + v2RouteMocks, +} from '@sim/testing' import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockPreauth, mockOperationRate, mockGate, mockExecute } = vi.hoisted(() => ({ - mockPreauth: vi.fn(), - mockOperationRate: vi.fn(), - mockGate: vi.fn(), +const { mockExecute } = vi.hoisted(() => ({ mockExecute: vi.fn(), })) -vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => ({ - authenticateV2ApiKey: vi.fn().mockResolvedValue({ - principal: { kind: 'workspace_api_key', workspaceId: 'workspace-1', keyId: 'key-1' }, - rolloutUserId: 'owner-1', - rateLimitSubjectIds: ['workspace:workspace-1'], - rateLimitSubscription: null, - keyType: 'workspace', - }), - V2ApiKeyUnauthenticatedError: class V2ApiKeyUnauthenticatedError extends Error {}, -})) -vi.mock('@/lib/core/rate-limiter', () => ({ - RateLimiter: class { - checkRateLimitDirect = mockPreauth - checkRateLimitDirectOrThrow = mockOperationRate - }, - getRateLimit: vi - .fn() - .mockReturnValue({ maxTokens: 100, refillRate: 100, refillIntervalMs: 60_000 }), -})) +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) vi.mock('@/lib/api/server/rate-limit-context', () => ({ recordRateLimitSnapshot: vi.fn(), getRateLimitHeaders: vi.fn().mockReturnValue(null), @@ -38,7 +28,6 @@ vi.mock('@/lib/core/utils/request', () => ({ generateRequestId: vi.fn().mockReturnValue('request-1'), getClientIp: vi.fn().mockReturnValue('127.0.0.1'), })) -vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mockGate })) vi.mock('@/lib/workspace-files/application/archive-workspace-file-items', () => ({ archiveWorkspaceFileItemsOperation: { operation: { id: 'files.delete', minimumRole: 'write', workspaceApiKey: 'allow' }, @@ -46,13 +35,22 @@ vi.mock('@/lib/workspace-files/application/archive-workspace-file-items', () => }, })) +import { OrchestrationError } from '@/lib/core/orchestration/types' import { POST } from '@/app/api/v2/files/bulk-delete/route' +import { v2Error } from '@/app/api/v2/lib/response' const WS = 'workspace-1' -const RATE_LIMIT_OK = { - allowed: true, +const AUTH = { + principal: { kind: 'workspace_api_key' as const, workspaceId: WS, keyId: 'key-1' }, + rolloutUserId: 'owner-1', + rateLimitSubjectIds: ['api-key:key-1', `workspace:${WS}`] as const, + rateLimitSubscription: null, + keyType: 'workspace' as const, +} +const RATE_LIMIT_DENIED = { + allowed: false, limit: 100, - remaining: 99, + remaining: 0, resetAt: new Date('2024-01-01T01:00:00Z'), retryAfterMs: 0, } @@ -69,15 +67,15 @@ const callDelete = (body: unknown) => describe('POST /api/v2/files/bulk-delete', () => { beforeEach(() => { vi.clearAllMocks() - mockPreauth.mockResolvedValue(RATE_LIMIT_OK) - mockOperationRate.mockResolvedValue(RATE_LIMIT_OK) - mockGate.mockResolvedValue(null) + v2RouteMocks.authenticate.mockResolvedValue(AUTH) + v2RouteMocks.gate.mockResolvedValue(null) + v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) + v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) mockExecute.mockResolvedValue({ deletedItems: { files: 3, folders: 0 } }) }) it('returns 404 when the v2 API surface flag is off', async () => { - const { v2Error } = await import('@/app/api/v2/lib/response') - mockGate.mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) + v2RouteMocks.gate.mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) const res = await callDelete({ workspaceId: WS, fileIds: ['wf_1'] }) expect(res.status).toBe(404) expect(mockExecute).not.toHaveBeenCalled() @@ -91,19 +89,26 @@ describe('POST /api/v2/files/bulk-delete', () => { }) it('surfaces a forbidden collection operation', async () => { - const { OrchestrationError } = await import('@/lib/core/orchestration/types') mockExecute.mockRejectedValue(new OrchestrationError('forbidden', 'Access denied')) const res = await callDelete({ workspaceId: WS, fileIds: ['wf_1'] }) expect(res.status).toBe(403) }) it('returns the rate-limit response when denied', async () => { - mockPreauth.mockResolvedValue({ ...RATE_LIMIT_OK, allowed: false, remaining: 0 }) + v2RouteMocks.preauthRate.mockResolvedValue(RATE_LIMIT_DENIED) const res = await callDelete({ workspaceId: WS, fileIds: ['wf_1'] }) expect(res.status).toBe(429) expect((await res.json()).error.code).toBe('RATE_LIMITED') }) + it('rejects an unauthenticated request', async () => { + v2RouteMocks.authenticate.mockRejectedValueOnce(new MockV2ApiKeyUnauthenticatedError()) + const res = await callDelete({ workspaceId: WS, fileIds: ['wf_1'] }) + expect(res.status).toBe(401) + expect((await res.json()).error.code).toBe('UNAUTHORIZED') + expect(mockExecute).not.toHaveBeenCalled() + }) + it('deletes the selection and reports the file count', async () => { const res = await callDelete({ workspaceId: WS, fileIds: ['wf_1'] }) expect(res.status).toBe(200) @@ -114,7 +119,6 @@ describe('POST /api/v2/files/bulk-delete', () => { }) it('maps a not-found failure to 404', async () => { - const { OrchestrationError } = await import('@/lib/core/orchestration/types') mockExecute.mockRejectedValue(new OrchestrationError('not_found', 'File not found')) const res = await callDelete({ workspaceId: WS, fileIds: ['wf_missing'] }) expect(res.status).toBe(404) 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 50dd9b0c98c..bbdf3ffdf82 100644 --- a/apps/sim/app/api/v2/files/folders/route.test.ts +++ b/apps/sim/app/api/v2/files/folders/route.test.ts @@ -1,41 +1,28 @@ /** * @vitest-environment node */ +import { + MockV2ApiKeyUnauthenticatedError, + V2_OPERATION_RATE_LIMIT_ALLOWED, + V2_PREAUTH_RATE_LIMIT_ALLOWED, + v2ApiKeyAuthModuleMock, + v2GateModuleMock, + v2RateLimiterModuleMock, + v2RouteMocks, +} from '@sim/testing' import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mocks, MockV2ApiKeyUnauthenticatedError } = vi.hoisted(() => { - class MockV2ApiKeyUnauthenticatedError extends Error {} - return { - mocks: { - authenticate: vi.fn(), - preauthRate: vi.fn(), - operationRate: vi.fn(), - gate: vi.fn(), - listFolders: vi.fn(), - createFolder: vi.fn(), - updateFolder: vi.fn(), - deleteFolder: vi.fn(), - }, - MockV2ApiKeyUnauthenticatedError, - } -}) - -vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => ({ - authenticateV2ApiKey: mocks.authenticate, - V2ApiKeyUnauthenticatedError: MockV2ApiKeyUnauthenticatedError, -})) -vi.mock('@/lib/core/rate-limiter', () => ({ - RateLimiter: class { - checkRateLimitDirect = mocks.preauthRate - checkRateLimitDirectOrThrow = mocks.operationRate - }, - getRateLimit: vi.fn().mockReturnValue({ - maxTokens: 100, - refillRate: 100, - refillIntervalMs: 60_000, - }), +const mocks = vi.hoisted(() => ({ + listFolders: vi.fn(), + createFolder: vi.fn(), + updateFolder: vi.fn(), + deleteFolder: vi.fn(), })) + +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) vi.mock('@/lib/api/server/rate-limit-context', () => ({ recordRateLimitSnapshot: vi.fn(), getRateLimitHeaders: vi.fn().mockReturnValue(null), @@ -44,7 +31,6 @@ vi.mock('@/lib/core/utils/request', () => ({ generateRequestId: vi.fn().mockReturnValue('request-1'), getClientIp: vi.fn().mockReturnValue('127.0.0.1'), })) -vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mocks.gate })) vi.mock('@/lib/workspace-files/application/workspace-file-folders', () => ({ listWorkspaceFileFoldersOperation: { operation: { id: 'files.folders.list', minimumRole: 'read', workspaceApiKey: 'allow' }, @@ -75,17 +61,10 @@ const PRINCIPAL = { kind: 'workspace_api_key' as const, workspaceId: WORKSPACE_I const AUTH = { principal: PRINCIPAL, rolloutUserId: 'owner-1', - rateLimitSubjectIds: ['workspace:workspace-1'] as const, + rateLimitSubjectIds: ['api-key:key-1', `workspace:${WORKSPACE_ID}`] as const, rateLimitSubscription: null, keyType: 'workspace' as const, } -const RATE_LIMIT_OK = { - allowed: true, - limit: 100, - remaining: 99, - resetAt: new Date('2024-01-01T01:00:00Z'), - retryAfterMs: 0, -} const folder = { id: 'folder-1', workspaceId: WORKSPACE_ID, @@ -114,10 +93,10 @@ function request(method: 'GET' | 'POST' | 'PATCH' | 'DELETE', url: string, body? describe('/api/v2/files/folders', () => { beforeEach(() => { vi.clearAllMocks() - mocks.authenticate.mockResolvedValue(AUTH) - mocks.preauthRate.mockResolvedValue(RATE_LIMIT_OK) - mocks.operationRate.mockResolvedValue(RATE_LIMIT_OK) - mocks.gate.mockResolvedValue(null) + v2RouteMocks.authenticate.mockResolvedValue(AUTH) + v2RouteMocks.gate.mockResolvedValue(null) + v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) + v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) mocks.listFolders.mockResolvedValue({ folders: [folder] }) mocks.createFolder.mockResolvedValue({ folder }) mocks.updateFolder.mockResolvedValue({ folder }) @@ -294,11 +273,12 @@ describe('/api/v2/files/folders', () => { }) it('authenticates before parsing folder input', async () => { - mocks.authenticate.mockRejectedValueOnce(new MockV2ApiKeyUnauthenticatedError()) + v2RouteMocks.authenticate.mockRejectedValueOnce(new MockV2ApiKeyUnauthenticatedError()) const response = await POST(request('POST', '/api/v2/files/folders', {}), context) expect(response.status).toBe(401) + expect((await response.json()).error.code).toBe('UNAUTHORIZED') expect(mocks.createFolder).not.toHaveBeenCalled() }) }) diff --git a/apps/sim/app/api/v2/files/move/route.test.ts b/apps/sim/app/api/v2/files/move/route.test.ts index a311ffcb614..9f8d95138f4 100644 --- a/apps/sim/app/api/v2/files/move/route.test.ts +++ b/apps/sim/app/api/v2/files/move/route.test.ts @@ -1,35 +1,25 @@ /** * @vitest-environment node */ +import { + MockV2ApiKeyUnauthenticatedError, + V2_OPERATION_RATE_LIMIT_ALLOWED, + V2_PREAUTH_RATE_LIMIT_ALLOWED, + v2ApiKeyAuthModuleMock, + v2GateModuleMock, + v2RateLimiterModuleMock, + v2RouteMocks, +} from '@sim/testing' import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockPreauth, mockOperationRate, mockGate, mockExecute } = vi.hoisted(() => ({ - mockPreauth: vi.fn(), - mockOperationRate: vi.fn(), - mockGate: vi.fn(), +const { mockExecute } = vi.hoisted(() => ({ mockExecute: vi.fn(), })) -vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => ({ - authenticateV2ApiKey: vi.fn().mockResolvedValue({ - principal: { kind: 'workspace_api_key', workspaceId: 'workspace-1', keyId: 'key-1' }, - rolloutUserId: 'owner-1', - rateLimitSubjectIds: ['workspace:workspace-1'], - rateLimitSubscription: null, - keyType: 'workspace', - }), - V2ApiKeyUnauthenticatedError: class V2ApiKeyUnauthenticatedError extends Error {}, -})) -vi.mock('@/lib/core/rate-limiter', () => ({ - RateLimiter: class { - checkRateLimitDirect = mockPreauth - checkRateLimitDirectOrThrow = mockOperationRate - }, - getRateLimit: vi - .fn() - .mockReturnValue({ maxTokens: 100, refillRate: 100, refillIntervalMs: 60_000 }), -})) +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) vi.mock('@/lib/api/server/rate-limit-context', () => ({ recordRateLimitSnapshot: vi.fn(), getRateLimitHeaders: vi.fn().mockReturnValue(null), @@ -38,7 +28,6 @@ vi.mock('@/lib/core/utils/request', () => ({ generateRequestId: vi.fn().mockReturnValue('request-1'), getClientIp: vi.fn().mockReturnValue('127.0.0.1'), })) -vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mockGate })) vi.mock('@/lib/workspace-files/application/move-workspace-file-items', () => ({ moveWorkspaceFileItemsOperation: { operation: { id: 'files.move', minimumRole: 'write', workspaceApiKey: 'allow' }, @@ -46,18 +35,26 @@ vi.mock('@/lib/workspace-files/application/move-workspace-file-items', () => ({ }, })) +import { OrchestrationError } from '@/lib/core/orchestration/types' import { WorkspaceFileMoveConflictError } from '@/lib/uploads/contexts/workspace/workspace-file-folder-manager' import { POST } from '@/app/api/v2/files/move/route' +import { v2Error } from '@/app/api/v2/lib/response' const WS = 'workspace-1' -const RATE_LIMIT_OK = { - allowed: true, +const auth = { + principal: { kind: 'workspace_api_key' as const, workspaceId: WS, keyId: 'key-1' }, + rolloutUserId: 'owner-1', + rateLimitSubjectIds: ['api-key:key-1', `workspace:${WS}`] as const, + rateLimitSubscription: null, + keyType: 'workspace' as const, +} +const RATE_LIMIT_DENIED = { + allowed: false, limit: 100, - remaining: 99, + remaining: 0, resetAt: new Date('2024-01-01T01:00:00Z'), - retryAfterMs: 0, + retryAfterMs: 1000, } -const RATE_LIMIT_DENIED = { ...RATE_LIMIT_OK, allowed: false, remaining: 0, retryAfterMs: 1000 } const callMove = (body: unknown) => POST( @@ -71,15 +68,22 @@ const callMove = (body: unknown) => describe('POST /api/v2/files/move', () => { beforeEach(() => { vi.clearAllMocks() - mockPreauth.mockResolvedValue(RATE_LIMIT_OK) - mockOperationRate.mockResolvedValue(RATE_LIMIT_OK) - mockGate.mockResolvedValue(null) + v2RouteMocks.authenticate.mockResolvedValue(auth) + v2RouteMocks.gate.mockResolvedValue(null) + v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) + v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) mockExecute.mockResolvedValue({ movedItems: { files: 2, folders: 0 } }) }) + it('rejects an unauthenticated request', async () => { + v2RouteMocks.authenticate.mockRejectedValueOnce(new MockV2ApiKeyUnauthenticatedError()) + const res = await callMove({ workspaceId: WS, fileIds: ['wf_1'] }) + expect(res.status).toBe(401) + expect((await res.json()).error.code).toBe('UNAUTHORIZED') + }) + it('returns 404 when the v2 API surface flag is off', async () => { - const { v2Error } = await import('@/app/api/v2/lib/response') - mockGate.mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) + v2RouteMocks.gate.mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) const res = await callMove({ workspaceId: WS, fileIds: ['wf_1'] }) expect(res.status).toBe(404) expect(mockExecute).not.toHaveBeenCalled() @@ -93,7 +97,6 @@ describe('POST /api/v2/files/move', () => { }) it('surfaces a forbidden collection operation', async () => { - const { OrchestrationError } = await import('@/lib/core/orchestration/types') mockExecute.mockRejectedValue(new OrchestrationError('forbidden', 'Access denied')) const res = await callMove({ workspaceId: WS, fileIds: ['wf_1'] }) expect(res.status).toBe(403) @@ -101,7 +104,7 @@ describe('POST /api/v2/files/move', () => { }) it('returns the rate-limit response when denied', async () => { - mockPreauth.mockResolvedValue(RATE_LIMIT_DENIED) + v2RouteMocks.preauthRate.mockResolvedValue(RATE_LIMIT_DENIED) const res = await callMove({ workspaceId: WS, fileIds: ['wf_1'] }) expect(res.status).toBe(429) expect((await res.json()).error.code).toBe('RATE_LIMITED') diff --git a/apps/sim/app/api/v2/files/route.test.ts b/apps/sim/app/api/v2/files/route.test.ts index 8d62db0876c..242bfbf5aa4 100644 --- a/apps/sim/app/api/v2/files/route.test.ts +++ b/apps/sim/app/api/v2/files/route.test.ts @@ -1,17 +1,22 @@ /** * @vitest-environment node */ +import { + MockV2ApiKeyUnauthenticatedError, + V2_OPERATION_RATE_LIMIT_ALLOWED, + V2_PREAUTH_RATE_LIMIT_ALLOWED, + v2ApiKeyAuthModuleMock, + v2GateModuleMock, + v2RateLimiterModuleMock, + v2RouteMocks, +} from '@sim/testing' import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ - authenticateV2ApiKey: vi.fn(), - checkRateLimitDirect: vi.fn(), - checkRateLimitDirectOrThrow: vi.fn(), createFile: vi.fn(), queryFiles: vi.fn(), getUserEmailsByIds: vi.fn(), - gate: vi.fn(), })) vi.mock('@/lib/workspace-files/application/create-workspace-file', () => ({ @@ -28,20 +33,9 @@ vi.mock('@/lib/workspace-files/application/list-workspace-files', () => ({ }, })) -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/api/server/routes/v2-api-key-auth', () => v2ApiKeyAuthModuleMock) +vi.mock('@/lib/core/rate-limiter', () => v2RateLimiterModuleMock) +vi.mock('@/app/api/v2/lib/gate', () => v2GateModuleMock) vi.mock('@/lib/users/queries', () => ({ getUserEmailsByIds: mocks.getUserEmailsByIds, @@ -89,18 +83,10 @@ function createRequest(body: unknown): NextRequest { describe('/api/v2/files', () => { beforeEach(() => { vi.clearAllMocks() - mocks.authenticateV2ApiKey.mockResolvedValue(auth) - mocks.gate.mockResolvedValue(null) - mocks.checkRateLimitDirect.mockResolvedValue({ - allowed: true, - remaining: 599, - resetAt: new Date('2026-08-04T01:00:00.000Z'), - }) - mocks.checkRateLimitDirectOrThrow.mockResolvedValue({ - allowed: true, - remaining: 99, - resetAt: new Date('2026-08-04T01:00:00.000Z'), - }) + v2RouteMocks.authenticate.mockResolvedValue(auth) + v2RouteMocks.gate.mockResolvedValue(null) + v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) + v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) mocks.queryFiles.mockResolvedValue({ files: [FILE], nextKeys: undefined, @@ -114,11 +100,22 @@ describe('/api/v2/files', () => { const response = await GET(new NextRequest('http://localhost:3000/api/v2/files')) expect(response.status).toBe(400) - expect(mocks.authenticateV2ApiKey).toHaveBeenCalled() - expect(mocks.checkRateLimitDirectOrThrow).toHaveBeenCalledTimes(2) + expect(v2RouteMocks.authenticate).toHaveBeenCalled() + expect(v2RouteMocks.operationRate).toHaveBeenCalledTimes(2) expect(mocks.queryFiles).not.toHaveBeenCalled() }) + it('rejects an unauthenticated request', async () => { + v2RouteMocks.authenticate.mockRejectedValueOnce(new MockV2ApiKeyUnauthenticatedError()) + + const response = await GET( + new NextRequest(`http://localhost:3000/api/v2/files?workspaceId=${WORKSPACE_ID}`) + ) + + expect(response.status).toBe(401) + expect((await response.json()).error.code).toBe('UNAUTHORIZED') + }) + it('lists through the shared use case and v2 presenter', async () => { const request = new NextRequest( `http://localhost:3000/api/v2/files?workspaceId=${WORKSPACE_ID}&sortBy=name` @@ -138,6 +135,7 @@ describe('/api/v2/files', () => { uploadedByEmail: 'ada@example.com', uploadedAt: '2026-08-04T00:00:00.000Z', updatedAt: '2026-08-05T00:00:00.000Z', + deletedAt: null, }, ], nextCursor: null, @@ -146,6 +144,7 @@ describe('/api/v2/files', () => { principal: auth.principal, input: expect.objectContaining({ workspaceId: WORKSPACE_ID, + scope: 'active', sortBy: 'name', sortOrder: 'asc', limit: 100, @@ -154,6 +153,36 @@ describe('/api/v2/files', () => { }) }) + it('pages the archived set and dates each soft delete when asked for it', async () => { + 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( + `http://localhost:3000/api/v2/files?workspaceId=${WORKSPACE_ID}&scope=archived` + ) + ) + + expect(response.status).toBe(200) + expect((await response.json()).data[0].deletedAt).toBe('2026-08-06T00:00:00.000Z') + expect(mocks.queryFiles).toHaveBeenCalledWith({ + principal: auth.principal, + input: expect.objectContaining({ scope: 'archived' }), + request: expect.anything(), + }) + }) + + it('rejects an unimplemented scope instead of silently listing the active set', async () => { + const response = await GET( + new NextRequest(`http://localhost:3000/api/v2/files?workspaceId=${WORKSPACE_ID}&scope=all`) + ) + + expect(response.status).toBe(400) + expect(mocks.queryFiles).not.toHaveBeenCalled() + }) + it('preserves escaped slashes in the containing folder path', async () => { mocks.queryFiles.mockResolvedValueOnce({ files: [{ ...FILE, folderId: 'folder-1', folderPath: 'Finance\\/Legal' }], @@ -216,8 +245,8 @@ describe('/api/v2/files', () => { ) expect(response.status).toBe(400) - expect(mocks.authenticateV2ApiKey).toHaveBeenCalled() - expect(mocks.checkRateLimitDirectOrThrow).toHaveBeenCalledTimes(2) + expect(v2RouteMocks.authenticate).toHaveBeenCalled() + expect(v2RouteMocks.operationRate).toHaveBeenCalledTimes(2) expect(mocks.createFile).not.toHaveBeenCalled() }) diff --git a/apps/sim/app/api/v2/files/route.ts b/apps/sim/app/api/v2/files/route.ts index bf3c3b4a291..8a5a20ceee7 100644 --- a/apps/sim/app/api/v2/files/route.ts +++ b/apps/sim/app/api/v2/files/route.ts @@ -25,6 +25,7 @@ export const GET = defineV2JsonRoute({ errorPolicy: v2FileErrorPolicies.default, mapInput: ({ query }) => ({ workspaceId: query.workspaceId, + scope: query.scope, folderPath: query.folderPath, search: query.search, sortBy: query.sortBy, diff --git a/apps/sim/app/api/v2/files/uploads/[uploadId]/route.test.ts b/apps/sim/app/api/v2/files/uploads/[uploadId]/route.test.ts index 8df882b0064..193aeee9c65 100644 --- a/apps/sim/app/api/v2/files/uploads/[uploadId]/route.test.ts +++ b/apps/sim/app/api/v2/files/uploads/[uploadId]/route.test.ts @@ -1,14 +1,20 @@ /** * @vitest-environment node */ +import { + MockV2ApiKeyUnauthenticatedError, + V2_OPERATION_RATE_LIMIT_ALLOWED, + V2_PREAUTH_RATE_LIMIT_ALLOWED, + v2ApiKeyAuthModuleMock, + v2GateModuleMock, + v2RateLimiterModuleMock, + v2RouteMocks, +} from '@sim/testing' import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ abort: vi.fn(), - authenticateV2ApiKey: vi.fn(), - checkRateLimitDirect: vi.fn(), - checkRateLimitDirectOrThrow: vi.fn(), })) vi.mock('@/lib/uploads/upload-session/application', () => ({ @@ -18,20 +24,9 @@ vi.mock('@/lib/uploads/upload-session/application', () => ({ }, })) -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: vi.fn().mockResolvedValue(null) })) +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) vi.mock('@/app/api/v2/files/uploads/utils', () => ({ toV2FileUpload: vi.fn(async () => ({ @@ -78,17 +73,10 @@ function abortRequest() { describe('DELETE /api/v2/files/uploads/[uploadId]', () => { beforeEach(() => { vi.clearAllMocks() - mocks.authenticateV2ApiKey.mockResolvedValue(AUTH) - mocks.checkRateLimitDirect.mockResolvedValue({ - allowed: true, - remaining: 599, - resetAt: new Date('2026-08-04T21:00:00.000Z'), - }) - mocks.checkRateLimitDirectOrThrow.mockResolvedValue({ - allowed: true, - remaining: 99, - resetAt: new Date('2026-08-04T21:00:00.000Z'), - }) + v2RouteMocks.authenticate.mockResolvedValue(AUTH) + v2RouteMocks.gate.mockResolvedValue(null) + v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) + v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) mocks.abort.mockResolvedValue({ id: UPLOAD_ID }) }) @@ -99,6 +87,16 @@ describe('DELETE /api/v2/files/uploads/[uploadId]', () => { expect(await response.json()).toMatchObject({ data: { id: UPLOAD_ID, status: 'aborted' } }) }) + it('rejects an unauthenticated request', async () => { + v2RouteMocks.authenticate.mockRejectedValueOnce(new MockV2ApiKeyUnauthenticatedError()) + + const response = await DELETE(abortRequest(), context) + + expect(response.status).toBe(401) + expect((await response.json()).error.code).toBe('UNAUTHORIZED') + expect(mocks.abort).not.toHaveBeenCalled() + }) + it('conceals a cross-tenant reach as a missing upload session', async () => { mocks.abort.mockRejectedValueOnce(new NoWorkspaceAccessError()) @@ -130,7 +128,11 @@ describe('DELETE /api/v2/files/uploads/[uploadId]', () => { expect(response.status).toBe(403) expect(await response.json()).toEqual({ - error: { code: 'FORBIDDEN', message: 'Insufficient workspace permissions' }, + error: { + code: 'FORBIDDEN', + message: 'Insufficient workspace permissions', + details: { code: 'INSUFFICIENT_WORKSPACE_ROLE' }, + }, }) }) }) diff --git a/apps/sim/app/api/v2/files/uploads/route.test.ts b/apps/sim/app/api/v2/files/uploads/route.test.ts index cfc19a29766..f1b6df19ad9 100644 --- a/apps/sim/app/api/v2/files/uploads/route.test.ts +++ b/apps/sim/app/api/v2/files/uploads/route.test.ts @@ -1,15 +1,20 @@ /** * @vitest-environment node */ +import { + MockV2ApiKeyUnauthenticatedError, + V2_OPERATION_RATE_LIMIT_ALLOWED, + V2_PREAUTH_RATE_LIMIT_ALLOWED, + v2ApiKeyAuthModuleMock, + v2GateModuleMock, + v2RateLimiterModuleMock, + v2RouteMocks, +} from '@sim/testing' import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ - authenticateV2ApiKey: vi.fn(), - checkRateLimitDirect: vi.fn(), - checkRateLimitDirectOrThrow: vi.fn(), createUpload: vi.fn(), - gate: vi.fn(), })) vi.mock('@/lib/uploads/upload-session/application', () => ({ @@ -19,20 +24,9 @@ vi.mock('@/lib/uploads/upload-session/application', () => ({ }, })) -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/api/server/routes/v2-api-key-auth', () => v2ApiKeyAuthModuleMock) +vi.mock('@/lib/core/rate-limiter', () => v2RateLimiterModuleMock) +vi.mock('@/app/api/v2/lib/gate', () => v2GateModuleMock) vi.mock('@/app/api/v2/files/uploads/utils', () => ({ toV2FileUpload: vi.fn(async () => ({ @@ -86,18 +80,10 @@ function request(body: Record) { describe('POST /api/v2/files/uploads', () => { beforeEach(() => { vi.clearAllMocks() - mocks.authenticateV2ApiKey.mockResolvedValue(AUTH) - mocks.gate.mockResolvedValue(null) - mocks.checkRateLimitDirect.mockResolvedValue({ - allowed: true, - remaining: 599, - resetAt: new Date('2026-08-04T21:00:00.000Z'), - }) - mocks.checkRateLimitDirectOrThrow.mockResolvedValue({ - allowed: true, - remaining: 99, - resetAt: new Date('2026-08-04T21:00:00.000Z'), - }) + v2RouteMocks.authenticate.mockResolvedValue(AUTH) + v2RouteMocks.gate.mockResolvedValue(null) + v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) + v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) mocks.createUpload.mockResolvedValue(UPLOAD_SESSION) }) @@ -139,8 +125,23 @@ describe('POST /api/v2/files/uploads', () => { const response = await request({ workspaceId: WORKSPACE_ID }).response expect(response.status).toBe(400) - expect(mocks.authenticateV2ApiKey).toHaveBeenCalledTimes(1) - expect(mocks.checkRateLimitDirectOrThrow).toHaveBeenCalledTimes(2) + expect(v2RouteMocks.authenticate).toHaveBeenCalledTimes(1) + expect(v2RouteMocks.operationRate).toHaveBeenCalledTimes(2) + expect(mocks.createUpload).not.toHaveBeenCalled() + }) + + it('rejects an unauthenticated request', async () => { + v2RouteMocks.authenticate.mockRejectedValueOnce(new MockV2ApiKeyUnauthenticatedError()) + + const response = await request({ + workspaceId: WORKSPACE_ID, + name: 'file.csv', + contentType: 'text/csv', + size: 10, + }).response + + expect(response.status).toBe(401) + expect((await response.json()).error.code).toBe('UNAUTHORIZED') expect(mocks.createUpload).not.toHaveBeenCalled() }) @@ -152,7 +153,7 @@ describe('POST /api/v2/files/uploads', () => { size: 0, }).response - expect(mocks.authenticateV2ApiKey).toHaveBeenCalledTimes(1) + expect(v2RouteMocks.authenticate).toHaveBeenCalledTimes(1) expect(mocks.createUpload).toHaveBeenCalledWith( expect.objectContaining({ principal: PRINCIPAL }) ) diff --git a/apps/sim/app/api/v2/files/utils.ts b/apps/sim/app/api/v2/files/utils.ts index d21514036e4..bff1477af38 100644 --- a/apps/sim/app/api/v2/files/utils.ts +++ b/apps/sim/app/api/v2/files/utils.ts @@ -30,6 +30,7 @@ function serializeV2File(record: WorkspaceFileRecord, uploadedByEmail: string): uploadedByEmail, uploadedAt: record.uploadedAt.toISOString(), updatedAt: record.updatedAt.toISOString(), + deletedAt: record.deletedAt?.toISOString() ?? null, } } diff --git a/apps/sim/app/api/v2/knowledge/[id]/documents/[documentId]/route.test.ts b/apps/sim/app/api/v2/knowledge/[id]/documents/[documentId]/route.test.ts new file mode 100644 index 00000000000..272d20c5f52 --- /dev/null +++ b/apps/sim/app/api/v2/knowledge/[id]/documents/[documentId]/route.test.ts @@ -0,0 +1,252 @@ +/** + * @vitest-environment node + */ +import { + V2_OPERATION_RATE_LIMIT_ALLOWED, + V2_PREAUTH_RATE_LIMIT_ALLOWED, + v2ApiKeyAuthModuleMock, + v2GateModuleMock, + v2RateLimiterModuleMock, + v2RouteMocks, +} from '@sim/testing' +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockReadDocument, mockUpdateDocument, mockDeleteDocument, mockCapture } = vi.hoisted( + () => ({ + mockReadDocument: vi.fn(), + mockUpdateDocument: vi.fn(), + mockDeleteDocument: vi.fn(), + mockCapture: vi.fn(), + }) +) + +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) + +vi.mock('@/lib/knowledge/application/documents', () => ({ + readKnowledgeDocument: { + operation: { id: 'knowledge.documents.read' }, + execute: mockReadDocument, + }, + updateKnowledgeDocument: { + operation: { id: 'knowledge.documents.update' }, + execute: mockUpdateDocument, + }, + deleteKnowledgeDocument: { + operation: { id: 'knowledge.documents.delete' }, + execute: mockDeleteDocument, + }, +})) + +vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: mockCapture })) + +import { GET, PATCH } from '@/app/api/v2/knowledge/[id]/documents/[documentId]/route' + +const WORKSPACE_ID = 'workspace-1' +const PRINCIPAL = { kind: 'personal_api_key', userId: 'user-1', keyId: 'key-1' } as const +const UPLOADED_AT = new Date('2025-06-18T16:45:00Z') + +const TAG_DEFINITIONS = [ + { + id: 'tag-def-1', + knowledgeBaseId: 'kb-1', + tagSlot: 'tag1', + displayName: 'category', + fieldType: 'text', + createdAt: UPLOADED_AT, + updatedAt: UPLOADED_AT, + }, + { + id: 'tag-def-2', + knowledgeBaseId: 'kb-1', + tagSlot: 'number1', + displayName: 'priority', + fieldType: 'number', + createdAt: UPLOADED_AT, + updatedAt: UPLOADED_AT, + }, +] + +const DOCUMENT_ROW = { + id: 'doc-1', + knowledgeBaseId: 'kb-1', + filename: 'support.txt', + fileSize: 5, + mimeType: 'text/plain', + processingStatus: 'completed' as const, + processingError: null, + processingStartedAt: UPLOADED_AT, + processingCompletedAt: UPLOADED_AT, + chunkCount: 2, + tokenCount: 10, + characterCount: 40, + enabled: true, + connectorId: null, + connectorType: null, + sourceUrl: null, + uploadedAt: UPLOADED_AT, + tag1: 'billing', + tag2: null, + number1: 2, + date1: null, + boolean1: null, + tag6: 'orphaned-slot-value', +} + +const context = { params: Promise.resolve({ id: 'kb-1', documentId: 'doc-1' }) } + +function buildGetRequest() { + return new NextRequest( + `http://localhost/api/v2/knowledge/kb-1/documents/doc-1?workspaceId=${WORKSPACE_ID}`, + { headers: { 'x-api-key': 'secret' } } + ) +} + +function buildPatchRequest(body: unknown) { + return new NextRequest('http://localhost/api/v2/knowledge/kb-1/documents/doc-1', { + method: 'PATCH', + headers: { 'content-type': 'application/json', 'x-api-key': 'secret' }, + body: JSON.stringify(body), + }) +} + +describe('/api/v2/knowledge/[id]/documents/[documentId]', () => { + 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', + }) + mockReadDocument.mockResolvedValue({ + document: DOCUMENT_ROW, + tagDefinitions: TAG_DEFINITIONS, + workspaceId: WORKSPACE_ID, + }) + mockUpdateDocument.mockResolvedValue({ + kind: 'updated', + document: { ...DOCUMENT_ROW, filename: 'renamed.txt', enabled: false }, + tagDefinitions: TAG_DEFINITIONS, + updatedFields: ['filename', 'enabled'], + }) + }) + + it('keys document tag values by display name, falling back to the raw slot', async () => { + const response = await GET(buildGetRequest(), context) + + expect(response.status).toBe(200) + const body = await response.json() + expect(body.data.tags).toEqual({ + category: 'billing', + priority: 2, + tag6: 'orphaned-slot-value', + }) + }) + + it('updates the whitelisted fields and returns the updated document with its tags', async () => { + const response = await PATCH( + buildPatchRequest({ + workspaceId: WORKSPACE_ID, + filename: 'renamed.txt', + enabled: false, + tag1: 'support', + }), + context + ) + + expect(response.status).toBe(200) + expect(mockUpdateDocument).toHaveBeenCalledWith( + expect.objectContaining({ + input: { + knowledgeBaseId: 'kb-1', + documentId: 'doc-1', + assertedWorkspaceId: WORKSPACE_ID, + updates: { filename: 'renamed.txt', enabled: false, tag1: 'support' }, + source: 'api', + }, + }) + ) + const body = await response.json() + expect(body.data).toEqual( + expect.objectContaining({ + id: 'doc-1', + filename: 'renamed.txt', + enabled: false, + tags: { category: 'billing', priority: 2, tag6: 'orphaned-slot-value' }, + }) + ) + }) + + it('acknowledges a processing retry without claiming settled indexing state', async () => { + mockUpdateDocument.mockResolvedValueOnce({ + kind: 'processing', + documentId: 'doc-1', + status: 'pending', + message: 'Document processing restarted', + }) + + const response = await PATCH( + buildPatchRequest({ workspaceId: WORKSPACE_ID, retryProcessing: true }), + context + ) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + data: { + id: 'doc-1', + queued: true, + processingStatus: 'pending', + message: 'Document processing restarted', + }, + }) + expect(mockUpdateDocument).toHaveBeenCalledWith( + expect.objectContaining({ + input: expect.objectContaining({ retryProcessing: true }), + }) + ) + expect(mockUpdateDocument.mock.calls[0][0].input).not.toHaveProperty('updates') + }) + + it('refuses to let a caller assert derived indexing state', async () => { + for (const body of [ + { workspaceId: WORKSPACE_ID, processingStatus: 'completed' }, + { workspaceId: WORKSPACE_ID, chunkCount: 99 }, + { workspaceId: WORKSPACE_ID, tokenCount: 99 }, + { workspaceId: WORKSPACE_ID, processingError: null }, + { workspaceId: WORKSPACE_ID, markFailedDueToTimeout: true }, + ]) { + const response = await PATCH(buildPatchRequest(body), context) + expect(response.status).toBe(400) + } + expect(mockUpdateDocument).not.toHaveBeenCalled() + }) + + it('rejects a retry combined with field updates instead of silently dropping them', async () => { + const response = await PATCH( + buildPatchRequest({ workspaceId: WORKSPACE_ID, retryProcessing: true, enabled: false }), + context + ) + + expect(response.status).toBe(400) + expect(await response.json()).toEqual({ + error: expect.objectContaining({ + message: expect.stringContaining('retryProcessing cannot be combined with enabled'), + }), + }) + expect(mockUpdateDocument).not.toHaveBeenCalled() + }) + + it('rejects an update that changes nothing', async () => { + const response = await PATCH(buildPatchRequest({ workspaceId: WORKSPACE_ID }), context) + + expect(response.status).toBe(400) + expect(mockUpdateDocument).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/v2/knowledge/[id]/documents/[documentId]/route.ts b/apps/sim/app/api/v2/knowledge/[id]/documents/[documentId]/route.ts index 2695d8e3984..3d9d8543e08 100644 --- a/apps/sim/app/api/v2/knowledge/[id]/documents/[documentId]/route.ts +++ b/apps/sim/app/api/v2/knowledge/[id]/documents/[documentId]/route.ts @@ -1,29 +1,56 @@ import { + V2_WRITABLE_TAG_SLOTS, + type V2UpdateKnowledgeDocumentBody, v2DeleteKnowledgeDocumentContract, v2GetKnowledgeDocumentContract, + v2UpdateKnowledgeDocumentContract, } from '@/lib/api/contracts/v2/knowledge' import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' import { v2KnowledgeErrorPolicies } from '@/lib/knowledge/api/route-policies' import { deleteKnowledgeDocument, readKnowledgeDocument, + type UpdateKnowledgeDocumentInput, + updateKnowledgeDocument, } from '@/lib/knowledge/application/documents' import { knowledgeOperations } from '@/lib/knowledge/application/operations' import { captureServerEvent } from '@/lib/posthog/server' import { serializeDate } from '@/app/api/v1/knowledge/utils' +import { + toV2DocumentSummary, + toV2DocumentTags, + toV2TaggedDocument, +} from '@/app/api/v2/knowledge/utils' export const dynamic = 'force-dynamic' export const revalidate = 0 -function toProcessingStatus(status: string): 'pending' | 'processing' | 'completed' | 'failed' { - switch (status) { - case 'pending': - case 'processing': - case 'completed': - case 'failed': - return status - default: - throw new Error(`Unexpected knowledge document processing status: ${status}`) +type V2DocumentUpdates = Omit + +type UpdateKnowledgeDocumentUpdates = NonNullable + +/** + * Serializes the typed tag slots for the document writer. + * + * The wire takes each slot in its natural JSON type — a number for a number + * slot, `true`/`false` for a boolean one — because that is how a document read + * projects them. The writer's `convertTagValue` takes strings and parses back to + * the storage column's type, so the boundary hands it the canonical spelling. + * The contract has already rejected anything those parsers would answer `null` + * for, so nothing reaches storage silently cleared. + */ +function toTagSlotUpdates(updates: V2DocumentUpdates): UpdateKnowledgeDocumentUpdates { + const { filename, enabled, ...slots } = updates + const serialized: Record = {} + for (const slot of V2_WRITABLE_TAG_SLOTS) { + const value = slots[slot] + if (value === undefined) continue + serialized[slot] = typeof value === 'string' ? value : String(value) + } + return { + ...(filename === undefined ? {} : { filename }), + ...(enabled === undefined ? {} : { enabled }), + ...serialized, } } @@ -40,29 +67,60 @@ export const GET = defineV2JsonRoute({ assertedWorkspaceId: query.workspaceId, }), useCase: readKnowledgeDocument, - present: ({ document }) => ({ + present: ({ document, tagDefinitions }) => ({ data: { - id: document.id, - knowledgeBaseId: document.knowledgeBaseId, - filename: document.filename, - fileSize: document.fileSize, - mimeType: document.mimeType, - processingStatus: toProcessingStatus(document.processingStatus), + ...toV2DocumentSummary(document), + tags: toV2DocumentTags(document, tagDefinitions), processingError: document.processingError, processingStartedAt: serializeDate(document.processingStartedAt), processingCompletedAt: serializeDate(document.processingCompletedAt), - chunkCount: document.chunkCount, - tokenCount: document.tokenCount, - characterCount: document.characterCount, - enabled: document.enabled, connectorId: document.connectorId, connectorType: document.connectorType, sourceUrl: document.sourceUrl, - createdAt: serializeDate(document.uploadedAt), }, }), }) +/** + * PATCH /api/v2/knowledge/[id]/documents/[documentId] — Update a document. + * + * Renames, enables or disables, retags, or requeues processing. Derived + * indexing state is not writable; the contract records why. + * + * The updated document is returned without connector provenance because the + * update writes and returns the document row alone. A caller that needs the full + * detail re-reads it with GET. + */ +export const PATCH = defineV2JsonRoute({ + contract: v2UpdateKnowledgeDocumentContract, + auth: v2ApiKeyAuth, + operation: knowledgeOperations.updateDocument, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2KnowledgeErrorPolicies.concealKnowledgeBaseAuthorization, + mapInput: ({ params, body }) => { + const { workspaceId, retryProcessing, ...updates } = body + return { + knowledgeBaseId: params.id, + documentId: params.documentId, + assertedWorkspaceId: workspaceId, + ...(retryProcessing ? { retryProcessing } : { updates: toTagSlotUpdates(updates) }), + source: 'api', + } + }, + useCase: updateKnowledgeDocument, + present: (result) => + result.kind === 'processing' + ? { + data: { + id: result.documentId, + queued: true as const, + processingStatus: result.status, + message: result.message, + }, + } + : { data: toV2TaggedDocument(result.document, result.tagDefinitions) }, +}) + /** DELETE /api/v2/knowledge/[id]/documents/[documentId] — Delete a document. */ export const DELETE = defineV2JsonRoute({ contract: v2DeleteKnowledgeDocumentContract, 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 new file mode 100644 index 00000000000..b199cbc898e --- /dev/null +++ b/apps/sim/app/api/v2/knowledge/[id]/documents/collection.test.ts @@ -0,0 +1,310 @@ +/** + * @vitest-environment node + * + * Covers the JSON halves of the documents collection route (list and bulk + * update). The multipart upload half is covered in `route.test.ts`, which mocks + * the stream-limit helpers the JSON body parser also uses. + */ +import { + V2_OPERATION_RATE_LIMIT_ALLOWED, + V2_PREAUTH_RATE_LIMIT_ALLOWED, + v2ApiKeyAuthModuleMock, + v2GateModuleMock, + v2RateLimiterModuleMock, + v2RouteMocks, +} from '@sim/testing' +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockListDocuments, mockBulkUpdate } = vi.hoisted(() => ({ + mockListDocuments: vi.fn(), + mockBulkUpdate: vi.fn(), +})) + +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) + +vi.mock('@/lib/knowledge/application/documents', () => ({ + listKnowledgeDocuments: { + operation: { id: 'knowledge.documents.list' }, + execute: mockListDocuments, + }, + bulkUpdateKnowledgeDocuments: { + operation: { id: 'knowledge.documents.bulk' }, + execute: mockBulkUpdate, + }, + admitKnowledgeDocumentUpload: { + operation: { id: 'knowledge.documents.upload' }, + execute: vi.fn(), + }, + uploadKnowledgeDocument: { + operation: { id: 'knowledge.documents.upload' }, + execute: vi.fn(), + }, +})) + +vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: vi.fn() })) + +import { GET, PATCH } 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 +const UPLOADED_AT = new Date('2025-06-18T16:45:00Z') + +const TAG_DEFINITIONS = [ + { + id: 'tag-def-1', + knowledgeBaseId: 'kb-1', + tagSlot: 'tag1', + displayName: 'category', + fieldType: 'text', + createdAt: UPLOADED_AT, + updatedAt: UPLOADED_AT, + }, +] + +const DOCUMENT = { + id: 'doc-1', + knowledgeBaseId: 'kb-1', + filename: 'support.txt', + fileSize: 5, + mimeType: 'text/plain', + processingStatus: 'completed' as const, + chunkCount: 2, + tokenCount: 10, + characterCount: 40, + enabled: true, + uploadedAt: UPLOADED_AT, + tag1: 'billing', + tag2: null, +} + +const context = { params: Promise.resolve({ id: 'kb-1' }) } + +function buildListRequest(query: string) { + return new NextRequest(`http://localhost/api/v2/knowledge/kb-1/documents${query}`, { + headers: { 'x-api-key': 'secret' }, + }) +} + +function buildPatchRequest(body: unknown) { + return new NextRequest('http://localhost/api/v2/knowledge/kb-1/documents', { + method: 'PATCH', + headers: { 'content-type': 'application/json', 'x-api-key': 'secret' }, + body: JSON.stringify(body), + }) +} + +function authenticateAsPersonalKey() { + 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', + }) +} + +describe('GET /api/v2/knowledge/[id]/documents', () => { + beforeEach(() => { + vi.clearAllMocks() + authenticateAsPersonalKey() + mockListDocuments.mockResolvedValue({ + documents: [DOCUMENT], + tagDefinitions: TAG_DEFINITIONS, + pagination: { total: 1, limit: 50, offset: 0, hasMore: false }, + cursorScope: 'scope', + workspaceId: WORKSPACE_ID, + }) + }) + + it('returns each document with its tag values keyed by display name', async () => { + const response = await GET(buildListRequest(`?workspaceId=${WORKSPACE_ID}`), context) + + expect(response.status).toBe(200) + const body = await response.json() + expect(body.data[0]).toEqual( + expect.objectContaining({ id: 'doc-1', tags: { category: 'billing' } }) + ) + expect(body.nextCursor).toBeNull() + }) + + it('forwards display-named tag filters to the application use case', async () => { + const tagFilters = JSON.stringify([{ tagName: 'category', operator: 'eq', value: 'billing' }]) + + const response = await GET( + buildListRequest(`?workspaceId=${WORKSPACE_ID}&tagFilters=${encodeURIComponent(tagFilters)}`), + context + ) + + expect(response.status).toBe(200) + expect(mockListDocuments).toHaveBeenCalledWith( + expect.objectContaining({ + input: expect.objectContaining({ + tagNameFilters: [{ tagName: 'category', operator: 'eq', value: 'billing' }], + }), + }) + ) + }) + + it('stamps the tag filters into the cursor scope so a replayed cursor cannot cross filters', async () => { + const tagFilters = JSON.stringify([{ tagName: 'category', operator: 'eq', value: 'billing' }]) + + await GET(buildListRequest(`?workspaceId=${WORKSPACE_ID}`), context) + await GET( + buildListRequest(`?workspaceId=${WORKSPACE_ID}&tagFilters=${encodeURIComponent(tagFilters)}`), + context + ) + + const [unfiltered, filtered] = mockListDocuments.mock.calls.map( + ([call]) => call.input.cursorScope + ) + expect(unfiltered).not.toEqual(filtered) + }) + + it('rejects malformed and wrongly shaped tag filters with a 400', async () => { + const malformed = await GET( + buildListRequest(`?workspaceId=${WORKSPACE_ID}&tagFilters=not-json`), + context + ) + const wrongShape = await GET( + buildListRequest( + `?workspaceId=${WORKSPACE_ID}&tagFilters=${encodeURIComponent(JSON.stringify([{ tagSlot: 'tag1' }]))}` + ), + context + ) + + expect(malformed.status).toBe(400) + expect(await malformed.json()).toEqual({ + error: expect.objectContaining({ + message: 'tagFilters must be a JSON-encoded array of tag filters', + }), + }) + expect(wrongShape.status).toBe(400) + expect(mockListDocuments).not.toHaveBeenCalled() + }) +}) + +describe('PATCH /api/v2/knowledge/[id]/documents', () => { + beforeEach(() => { + vi.clearAllMocks() + authenticateAsPersonalKey() + mockBulkUpdate.mockResolvedValue({ + operation: 'disable', + successCount: 2, + updatedDocuments: [ + { id: 'doc-1', enabled: false }, + { id: 'doc-2', enabled: false }, + ], + selectAll: false, + }) + }) + + /** + * `documentIds` is bounded by the request; `selectAll` is bounded by nothing. + * Echoing the identifiers for a knowledge base of 100k documents is a + * multi-megabyte array the caller never asked for, materialized and then + * element-wise validated by the response schema. + */ + it('omits the identifier echo for an unbounded selectAll update', async () => { + mockBulkUpdate.mockResolvedValueOnce({ + operation: 'disable', + successCount: 100_000, + updatedDocuments: Array.from({ length: 100_000 }, (_, index) => ({ + id: `doc-${index}`, + enabled: false, + })), + selectAll: true, + }) + + const response = await PATCH( + buildPatchRequest({ workspaceId: WORKSPACE_ID, operation: 'disable', selectAll: true }), + context + ) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + data: { operation: 'disable', updatedCount: 100_000 }, + }) + }) + + it('disables the named documents and answers with one object, not a page', async () => { + const response = await PATCH( + buildPatchRequest({ + workspaceId: WORKSPACE_ID, + operation: 'disable', + documentIds: ['doc-1', 'doc-2'], + }), + context + ) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + data: { operation: 'disable', updatedCount: 2, documentIds: ['doc-1', 'doc-2'] }, + }) + expect(mockBulkUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + input: { + knowledgeBaseId: 'kb-1', + assertedWorkspaceId: WORKSPACE_ID, + operation: 'disable', + documentIds: ['doc-1', 'doc-2'], + selectAll: undefined, + enabledFilter: undefined, + }, + }) + ) + }) + + it('does not expose an unaudited bulk delete', async () => { + const response = await PATCH( + buildPatchRequest({ + workspaceId: WORKSPACE_ID, + operation: 'delete', + documentIds: ['doc-1'], + }), + context + ) + + expect(response.status).toBe(400) + expect(await response.json()).toEqual({ + error: expect.objectContaining({ + message: expect.stringContaining('operation: expected one of "enable" | "disable"'), + }), + }) + expect(mockBulkUpdate).not.toHaveBeenCalled() + }) + + it('requires exactly one selection and bounds an explicit list', async () => { + const neither = await PATCH( + buildPatchRequest({ workspaceId: WORKSPACE_ID, operation: 'enable' }), + context + ) + const both = await PATCH( + buildPatchRequest({ + workspaceId: WORKSPACE_ID, + operation: 'enable', + documentIds: ['doc-1'], + selectAll: true, + }), + context + ) + const tooMany = await PATCH( + buildPatchRequest({ + workspaceId: WORKSPACE_ID, + operation: 'enable', + documentIds: Array.from({ length: 101 }, (_, index) => `doc-${index}`), + }), + context + ) + + expect(neither.status).toBe(400) + expect(both.status).toBe(400) + expect(tooMany.status).toBe(400) + expect(mockBulkUpdate).not.toHaveBeenCalled() + }) +}) 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 1b03a060bbe..203817873d4 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 @@ -39,6 +39,10 @@ vi.mock('@/lib/knowledge/application/documents', () => ({ operation: { id: 'knowledge.documents.list' }, execute: vi.fn(), }, + bulkUpdateKnowledgeDocuments: { + operation: { id: 'knowledge.documents.bulk' }, + execute: vi.fn(), + }, admitKnowledgeDocumentUpload: { operation: { id: 'knowledge.documents.upload' }, execute: mockAdmitUpload, 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 dfd8dd1255c..1f11166b8f8 100644 --- a/apps/sim/app/api/v2/knowledge/[id]/documents/route.ts +++ b/apps/sim/app/api/v2/knowledge/[id]/documents/route.ts @@ -1,5 +1,6 @@ import { - type V2KnowledgeDocumentSummary, + parseV2KnowledgeTagFiltersParam, + v2BulkUpdateKnowledgeDocumentsContract, v2ListKnowledgeDocumentsContract, v2UploadKnowledgeDocumentContract, } from '@/lib/api/contracts/v2/knowledge' @@ -20,6 +21,7 @@ import { import { v2KnowledgeErrorPolicies } from '@/lib/knowledge/api/route-policies' import { admitKnowledgeDocumentUpload, + bulkUpdateKnowledgeDocuments, listKnowledgeDocuments, uploadKnowledgeDocument, } from '@/lib/knowledge/application/documents' @@ -28,7 +30,7 @@ import { KnowledgeDocumentUnsupportedMediaTypeError } from '@/lib/knowledge/appl 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 { serializeDate } from '@/app/api/v1/knowledge/utils' +import { toV2DocumentSummary, toV2TaggedDocument } from '@/app/api/v2/knowledge/utils' import { decodeOffsetCursor, encodeOffsetCursor, @@ -40,34 +42,6 @@ export const revalidate = 0 const MAX_FILE_SIZE = MAX_KNOWLEDGE_DOCUMENT_FILE_SIZE -function toV2DocumentSummary(document: { - id: string - knowledgeBaseId: string - filename: string - fileSize: number - mimeType: string - processingStatus?: 'pending' | 'processing' | 'completed' | 'failed' - chunkCount: number - tokenCount: number - characterCount: number - enabled: boolean - uploadedAt: Date -}): V2KnowledgeDocumentSummary { - return { - id: document.id, - knowledgeBaseId: document.knowledgeBaseId, - filename: document.filename, - fileSize: document.fileSize, - mimeType: document.mimeType, - processingStatus: document.processingStatus ?? 'pending', - chunkCount: document.chunkCount, - tokenCount: document.tokenCount, - characterCount: document.characterCount, - enabled: document.enabled, - createdAt: serializeDate(document.uploadedAt), - } -} - /** GET /api/v2/knowledge/[id]/documents — List documents in a knowledge base. */ export const GET = defineV2JsonRoute({ contract: v2ListKnowledgeDocumentsContract, @@ -76,6 +50,10 @@ export const GET = defineV2JsonRoute({ rateLimit: v2RateLimits.publicApi, errorPolicy: v2KnowledgeErrorPolicies.concealKnowledgeBaseAuthorization, mapInput: ({ params, query }) => { + const tagFilters = parseV2KnowledgeTagFiltersParam(query.tagFilters) + 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 @@ -88,6 +66,7 @@ export const GET = defineV2JsonRoute({ search: query.search, sortBy: query.sortBy, sortOrder: query.sortOrder, + tagFilters: query.tagFilters, }) return { knowledgeBaseId: params.id, @@ -98,18 +77,65 @@ export const GET = defineV2JsonRoute({ offset: decodeOffsetCursor(query.cursor, cursorScope), sortBy: query.sortBy, sortOrder: query.sortOrder, + tagNameFilters: tagFilters.filters, cursorScope, } }, useCase: listKnowledgeDocuments, - present: ({ documents, pagination, cursorScope }) => ({ - data: documents.map(toV2DocumentSummary), + present: ({ documents, tagDefinitions, pagination, cursorScope }) => ({ + data: documents.map((document) => toV2TaggedDocument(document, tagDefinitions)), nextCursor: pagination.hasMore ? encodeOffsetCursor(cursorScope ?? '', pagination.offset + pagination.limit) : null, }), }) +/** + * PATCH /api/v2/knowledge/[id]/documents — Enable or disable many documents. + * + * Enable and disable only. Bulk delete is deliberately not offered: the bulk + * operation records no semantic audit, so a public bulk delete would empty a + * knowledge base leaving no `DOCUMENT_DELETED` entries, while the per-document + * DELETE audits every one. + */ +export const PATCH = defineV2JsonRoute({ + contract: v2BulkUpdateKnowledgeDocumentsContract, + auth: v2ApiKeyAuth, + operation: knowledgeOperations.bulkDocuments, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2KnowledgeErrorPolicies.concealKnowledgeBaseAuthorization, + mapInput: ({ params, body }) => ({ + knowledgeBaseId: params.id, + assertedWorkspaceId: body.workspaceId, + operation: body.operation, + documentIds: body.documentIds, + selectAll: body.selectAll, + enabledFilter: body.enabledFilter, + }), + useCase: bulkUpdateKnowledgeDocuments, + present: (result) => { + if (result.operation === 'delete') { + throw new Error('Bulk knowledge document delete is not exposed on the public API') + } + /** + * `documentIds` is echoed only for an explicit-list request, which the body + * bounds. A `selectAll` request has no such bound: a knowledge base with + * 100k documents would otherwise materialize and element-wise validate a + * multi-megabyte identifier array nobody asked for. That caller reads + * `updatedCount` and re-lists if it needs the identifiers. + */ + return { + data: { + operation: result.operation, + updatedCount: result.successCount, + documentIds: result.selectAll + ? undefined + : result.updatedDocuments.map((document) => document.id), + }, + } + }, +}) + /** POST /api/v2/knowledge/[id]/documents — Upload a document to a knowledge base. */ export const POST = defineV2BodyLifecycleRoute({ contract: v2UploadKnowledgeDocumentContract, 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 fbf8e030418..0e48f8489e7 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,13 +1,10 @@ import type { NextResponse } from 'next/server' -import type { - V2KnowledgeDocumentSummary, - V2KnowledgeDocumentUpload, -} from '@/lib/api/contracts/v2/knowledge' +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 { serializeDate } from '@/app/api/v1/knowledge/utils' +import { toV2DocumentSummary } from '@/app/api/v2/knowledge/utils' import { v2CaughtOrchestrationError, v2Error } from '@/app/api/v2/lib/response' export function v2KnowledgeDocumentUploadError(error: unknown): NextResponse | null { @@ -20,24 +17,6 @@ export function v2KnowledgeDocumentUploadError(error: unknown): NextResponse | n return v2CaughtOrchestrationError(error) } -export function toV2KnowledgeDocumentSummary( - document: CreatedKnowledgeDocument -): V2KnowledgeDocumentSummary { - return { - id: document.id, - knowledgeBaseId: document.knowledgeBaseId, - filename: document.filename, - fileSize: document.fileSize, - mimeType: document.mimeType, - processingStatus: document.processingStatus ?? 'pending', - chunkCount: document.chunkCount, - tokenCount: document.tokenCount, - characterCount: document.characterCount, - enabled: document.enabled, - createdAt: serializeDate(document.uploadedAt), - } -} - export function toV2KnowledgeDocumentUpload( session: UploadSessionRecord, document: CreatedKnowledgeDocument | null @@ -54,6 +33,6 @@ export function toV2KnowledgeDocumentUpload( size: session.fileSize, expiresAt: session.expiresAt.toISOString(), error: session.error, - document: document ? toV2KnowledgeDocumentSummary(document) : null, + document: document ? toV2DocumentSummary(document) : null, } } diff --git a/apps/sim/app/api/v2/knowledge/[id]/tags/route.test.ts b/apps/sim/app/api/v2/knowledge/[id]/tags/route.test.ts new file mode 100644 index 00000000000..d6ab7343cc9 --- /dev/null +++ b/apps/sim/app/api/v2/knowledge/[id]/tags/route.test.ts @@ -0,0 +1,107 @@ +/** + * @vitest-environment node + */ +import { + V2_OPERATION_RATE_LIMIT_ALLOWED, + V2_PREAUTH_RATE_LIMIT_ALLOWED, + v2ApiKeyAuthModuleMock, + v2GateModuleMock, + v2RateLimiterModuleMock, + v2RouteMocks, +} from '@sim/testing' +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockListTags } = vi.hoisted(() => ({ + mockListTags: vi.fn(), +})) + +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) + +vi.mock('@/lib/knowledge/application/tags', () => ({ + listKnowledgeTags: { operation: { id: 'knowledge.tags.list' }, execute: mockListTags }, +})) + +import { knowledgeOperations } from '@/lib/knowledge/application/operations' +import { GET } from '@/app/api/v2/knowledge/[id]/tags/route' + +const WORKSPACE_ID = 'workspace-1' +const PRINCIPAL = { kind: 'workspace_api_key', workspaceId: WORKSPACE_ID, keyId: 'key-1' } as const + +function buildRequest(query = `?workspaceId=${WORKSPACE_ID}`) { + return new NextRequest(`http://localhost/api/v2/knowledge/kb-1/tags${query}`, { + headers: { 'x-api-key': 'secret' }, + }) +} + +const context = { params: Promise.resolve({ id: 'kb-1' }) } + +describe('GET /api/v2/knowledge/[id]/tags', () => { + 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: 'billing-owner', + rateLimitSubjectIds: ['api-key:key-1', `workspace:${WORKSPACE_ID}`], + rateLimitSubscription: null, + keyType: 'workspace', + }) + mockListTags.mockResolvedValue({ + tagDefinitions: [ + { + id: 'tag-def-1', + knowledgeBaseId: 'kb-1', + tagSlot: 'tag1', + displayName: 'category', + fieldType: 'text', + createdAt: new Date('2025-01-10T09:00:00Z'), + updatedAt: new Date('2025-01-10T09:00:00Z'), + }, + ], + }) + }) + + it('returns the tag vocabulary as a full-set list', async () => { + const response = await GET(buildRequest(), context) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + data: [{ displayName: 'category', tagSlot: 'tag1', fieldType: 'text' }], + nextCursor: null, + }) + expect(mockListTags).toHaveBeenCalledWith( + expect.objectContaining({ + principal: PRINCIPAL, + input: { knowledgeBaseId: 'kb-1', assertedWorkspaceId: WORKSPACE_ID }, + }) + ) + expect(response.headers.get('cache-control')).toBe('private, no-store') + }) + + it('does not publish the tag definition identifier or its timestamps', async () => { + const response = await GET(buildRequest(), context) + + const [tag] = (await response.json()).data + expect(Object.keys(tag).sort()).toEqual(['displayName', 'fieldType', 'tagSlot']) + }) + + it('requires the workspace scope', async () => { + const response = await GET(buildRequest(''), context) + + expect(response.status).toBe(400) + expect(mockListTags).not.toHaveBeenCalled() + }) + + it('is reachable by a workspace API key, like its sibling knowledge reads', () => { + expect(knowledgeOperations.listTags.workspaceApiKey).toBe('allow') + expect(knowledgeOperations.listTags.principalKinds).toContain('workspace_api_key') + expect(knowledgeOperations.listTags.workspaceApiKey).toBe( + knowledgeOperations.listDocuments.workspaceApiKey + ) + }) +}) diff --git a/apps/sim/app/api/v2/knowledge/[id]/tags/route.ts b/apps/sim/app/api/v2/knowledge/[id]/tags/route.ts new file mode 100644 index 00000000000..fadf77fe83a --- /dev/null +++ b/apps/sim/app/api/v2/knowledge/[id]/tags/route.ts @@ -0,0 +1,35 @@ +import { v2ListKnowledgeTagsContract } 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 { listKnowledgeTags } from '@/lib/knowledge/application/tags' + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +/** + * GET /api/v2/knowledge/[id]/tags — List the knowledge base's tag vocabulary. + * + * Full-set list: a knowledge base has a fixed number of tag slots, so the whole + * vocabulary is one page and `nextCursor` is always null. + */ +export const GET = defineV2JsonRoute({ + contract: v2ListKnowledgeTagsContract, + auth: v2ApiKeyAuth, + operation: knowledgeOperations.listTags, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2KnowledgeErrorPolicies.concealKnowledgeBaseAuthorization, + mapInput: ({ params, query }) => ({ + knowledgeBaseId: params.id, + assertedWorkspaceId: query.workspaceId, + }), + useCase: listKnowledgeTags, + present: ({ tagDefinitions }) => ({ + data: tagDefinitions.map((definition) => ({ + displayName: definition.displayName, + tagSlot: definition.tagSlot, + fieldType: definition.fieldType, + })), + nextCursor: null, + }), +}) 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 cb7816ec799..e705128e562 100644 --- a/apps/sim/app/api/v2/knowledge/search/route.test.ts +++ b/apps/sim/app/api/v2/knowledge/search/route.test.ts @@ -53,13 +53,16 @@ describe('POST /api/v2/knowledge/search', () => { mockSearch.mockResolvedValue({ results: [ { + embeddingId: 'embedding-1', + knowledgeBaseId: 'kb-1', documentId: 'doc-1', documentName: 'support.txt', sourceUrl: null, content: 'hello', chunkIndex: 0, - metadata: {}, + metadata: { category: 'billing' }, similarity: 0.9, + rerankerScore: 0.42, }, ], query: 'hello', @@ -92,6 +95,9 @@ describe('POST /api/v2/knowledge/search', () => { topK: 10, tagFilters: undefined, searchMode: 'hybrid', + rerankerEnabled: undefined, + rerankerModel: undefined, + rerankerInputCount: undefined, }, request, }) @@ -102,6 +108,121 @@ describe('POST /api/v2/knowledge/search', () => { expect(response.headers.get('x-ratelimit-limit')).toBe('100') }) + it('names the source knowledge base and the reranker score on every result', async () => { + const response = await POST( + buildRequest( + JSON.stringify({ + workspaceId: WORKSPACE_ID, + knowledgeBaseIds: ['kb-1', 'kb-2'], + query: 'hello', + topK: 10, + }) + ) + ) + + expect(response.status).toBe(200) + const body = await response.json() + expect(body.data.results[0]).toEqual({ + knowledgeBaseId: 'kb-1', + documentId: 'doc-1', + documentName: 'support.txt', + sourceUrl: null, + content: 'hello', + chunkIndex: 0, + metadata: { category: 'billing' }, + similarity: 0.9, + rerankerScore: 0.42, + }) + }) + + it('forwards reranker options and never a caller-supplied reranker key', async () => { + const response = await POST( + buildRequest( + JSON.stringify({ + workspaceId: WORKSPACE_ID, + knowledgeBaseIds: ['kb-1'], + query: 'hello', + topK: 5, + rerankerEnabled: true, + rerankerModel: 'rerank-v4.0-fast', + rerankerInputCount: 40, + }) + ) + ) + + expect(response.status).toBe(200) + expect(mockSearch).toHaveBeenCalledWith( + expect.objectContaining({ + input: expect.objectContaining({ + rerankerEnabled: true, + rerankerModel: 'rerank-v4.0-fast', + rerankerInputCount: 40, + }), + }) + ) + const [{ input }] = mockSearch.mock.calls[0] + expect(input).not.toHaveProperty('rerankerApiKey') + expect(input).not.toHaveProperty('skipUsageBilling') + }) + + it('rejects an unsupported reranker model and an out-of-range candidate pool', async () => { + const unsupportedModel = await POST( + buildRequest( + JSON.stringify({ + workspaceId: WORKSPACE_ID, + knowledgeBaseIds: ['kb-1'], + query: 'hello', + topK: 5, + rerankerEnabled: true, + rerankerModel: 'rerank-does-not-exist', + }) + ) + ) + const oversizedPool = await POST( + buildRequest( + JSON.stringify({ + workspaceId: WORKSPACE_ID, + knowledgeBaseIds: ['kb-1'], + query: 'hello', + topK: 5, + rerankerEnabled: true, + rerankerModel: 'rerank-v4.0-fast', + rerankerInputCount: 101, + }) + ) + ) + + expect(unsupportedModel.status).toBe(400) + expect(oversizedPool.status).toBe(400) + expect(await oversizedPool.json()).toEqual({ + error: expect.objectContaining({ + code: 'BAD_REQUEST', + message: expect.stringContaining('rerankerInputCount cannot exceed 100'), + }), + }) + expect(mockSearch).not.toHaveBeenCalled() + }) + + it('drops a caller-supplied reranker key instead of forwarding it', async () => { + const response = await POST( + buildRequest( + JSON.stringify({ + workspaceId: WORKSPACE_ID, + knowledgeBaseIds: ['kb-1'], + query: 'hello', + topK: 5, + rerankerEnabled: true, + rerankerModel: 'rerank-v4.0-fast', + rerankerApiKey: 'secret-byok-key', + }) + ) + ) + + expect(response.status).toBe(200) + const [{ input }] = mockSearch.mock.calls[0] + expect(input).not.toHaveProperty('rerankerApiKey') + }) + it('forwards an opted-in hybrid search mode to the application use case', async () => { const response = await POST( buildRequest( diff --git a/apps/sim/app/api/v2/knowledge/search/route.ts b/apps/sim/app/api/v2/knowledge/search/route.ts index 95bef90bd1b..d22a4be4f8c 100644 --- a/apps/sim/app/api/v2/knowledge/search/route.ts +++ b/apps/sim/app/api/v2/knowledge/search/route.ts @@ -33,7 +33,25 @@ export const POST = defineV2JsonRoute({ topK: body.topK, tagFilters: body.tagFilters, searchMode: body.searchMode, + rerankerEnabled: body.rerankerEnabled, + rerankerModel: body.rerankerModel, + rerankerInputCount: body.rerankerInputCount, }), useCase: searchKnowledge, - present: (result) => ({ data: result }), + /** + * Projected field by field rather than spread. The use-case result also + * carries `userId`, `workspaceId`, a `cost` breakdown with pricing internals, + * and a live resolved-secret trace registry; only Zod's default key-stripping + * keeps them off the wire today, so a single loosened or opaque field in the + * response schema would ship them. + */ + present: (result) => ({ + data: { + results: result.results, + query: result.query, + knowledgeBaseIds: result.knowledgeBaseIds, + topK: result.topK, + totalResults: result.totalResults, + }, + }), }) diff --git a/apps/sim/app/api/v2/knowledge/utils.test.ts b/apps/sim/app/api/v2/knowledge/utils.test.ts new file mode 100644 index 00000000000..9ccf8818312 --- /dev/null +++ b/apps/sim/app/api/v2/knowledge/utils.test.ts @@ -0,0 +1,92 @@ +/** + * @vitest-environment node + */ + +import { describe, expect, it } from 'vitest' +import { v2KnowledgeTaggedDocumentSchema } from '@/lib/api/contracts/v2/knowledge' +import type { DocumentTagDefinition } from '@/lib/knowledge/tags/types' +import { + toV2DocumentSummary, + toV2DocumentTags, + toV2TaggedDocument, +} from '@/app/api/v2/knowledge/utils' + +const uploadedAt = new Date('2026-08-01T00:00:00.000Z') + +const documentRow = { + id: 'doc-1', + knowledgeBaseId: 'kb-1', + filename: 'invoice.pdf', + fileSize: 1024, + mimeType: 'application/pdf', + processingStatus: 'completed', + chunkCount: 4, + tokenCount: 512, + characterCount: 2048, + enabled: true, + uploadedAt, + tag1: 'billing', + number1: 7, +} + +const tagDefinitions: DocumentTagDefinition[] = [ + { + id: 'def-1', + knowledgeBaseId: 'kb-1', + tagSlot: 'tag1', + displayName: 'category', + fieldType: 'text', + createdAt: uploadedAt, + updatedAt: uploadedAt, + } as DocumentTagDefinition, +] + +describe('toV2DocumentSummary', () => { + it('serializes the shared document fields', () => { + expect(toV2DocumentSummary(documentRow)).toEqual({ + id: 'doc-1', + knowledgeBaseId: 'kb-1', + filename: 'invoice.pdf', + fileSize: 1024, + mimeType: 'application/pdf', + processingStatus: 'completed', + chunkCount: 4, + tokenCount: 512, + characterCount: 2048, + enabled: true, + createdAt: '2026-08-01T00:00:00.000Z', + }) + }) + + it('returns a null createdAt for a document with no upload timestamp', () => { + expect(toV2DocumentSummary({ ...documentRow, uploadedAt: null }).createdAt).toBeNull() + }) + + it('reads an absent processing status as pending', () => { + expect(toV2DocumentSummary({ ...documentRow, processingStatus: null }).processingStatus).toBe( + 'pending' + ) + }) +}) + +describe('toV2TaggedDocument', () => { + it('produces a contract-valid list item with tags keyed by display name', () => { + const projected = toV2TaggedDocument(documentRow, tagDefinitions) + expect(v2KnowledgeTaggedDocumentSchema.parse(projected)).toEqual(projected) + expect(projected.tags).toEqual({ category: 'billing', number1: 7 }) + }) + + it('does not throw when the document has no upload timestamp', () => { + const projected = toV2TaggedDocument({ ...documentRow, uploadedAt: null }, tagDefinitions) + expect(projected.createdAt).toBeNull() + expect(v2KnowledgeTaggedDocumentSchema.parse(projected)).toEqual(projected) + }) +}) + +describe('toV2DocumentTags', () => { + it('serializes a date-valued slot as an ISO string', () => { + expect(toV2DocumentTags({ date1: new Date('2026-08-02T00:00:00.000Z') }, [])).toEqual({ + date1: '2026-08-02T00:00:00.000Z', + }) + }) +}) diff --git a/apps/sim/app/api/v2/knowledge/utils.ts b/apps/sim/app/api/v2/knowledge/utils.ts index 373f7b2dbd4..1751b673ee3 100644 --- a/apps/sim/app/api/v2/knowledge/utils.ts +++ b/apps/sim/app/api/v2/knowledge/utils.ts @@ -1,6 +1,113 @@ -import type { V2KnowledgeBase } from '@/lib/api/contracts/v2/knowledge' +import type { + V2KnowledgeBase, + V2KnowledgeDocumentSummary, + V2KnowledgeTaggedDocument, +} from '@/lib/api/contracts/v2/knowledge' +import { ALL_TAG_SLOTS, type AllTagSlot } from '@/lib/knowledge/constants' +import type { DocumentTagDefinition } from '@/lib/knowledge/tags/types' import type { KnowledgeBaseWithCounts } from '@/lib/knowledge/types' import { getUserEmailsByIds, requireResolvedUserEmail } from '@/lib/users/queries' +import { serializeDate } from '@/app/api/v1/knowledge/utils' + +/** + * Projects a document's tag slots onto a map keyed by tag display name, the same + * projection knowledge search applies to its result `metadata`. A slot holding a + * value with no definition keeps its raw slot name rather than disappearing. + */ +export function toV2DocumentTags( + document: Partial>, + tagDefinitions: readonly DocumentTagDefinition[] +): Record { + const displayNameBySlot = new Map( + tagDefinitions.map((definition) => [definition.tagSlot, definition.displayName]) + ) + const tags: Record = {} + for (const slot of ALL_TAG_SLOTS) { + const value = document[slot] + if (value === null || value === undefined) continue + const key = displayNameBySlot.get(slot) ?? slot + if (value instanceof Date) { + tags[key] = value.toISOString() + } else if ( + typeof value === 'string' || + typeof value === 'number' || + typeof value === 'boolean' + ) { + tags[key] = value + } + } + return tags +} + +const PROCESSING_STATUSES = ['pending', 'processing', 'completed', 'failed'] as const + +type V2DocumentProcessingStatus = (typeof PROCESSING_STATUSES)[number] + +/** + * Narrows a stored processing status onto the published enum. An absent value + * 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 { + 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}`) + return known +} + +/** + * The document columns every v2 document projection reads. `uploadedAt` is + * accepted as nullable because the column is nullable in storage. + */ +export interface V2DocumentSummarySource { + id: string + knowledgeBaseId: string + filename: string + fileSize: number + mimeType: string + processingStatus?: string | null + chunkCount: number + tokenCount: number + characterCount: number + enabled: boolean + uploadedAt: Date | string | null | undefined +} + +/** + * The single v2 document summary projection. Every v2 document response — list + * item, upload acknowledgement, detail — is this shape plus its own extras, so + * the shared field set is serialized in exactly one place. + */ +export function toV2DocumentSummary(document: V2DocumentSummarySource): V2KnowledgeDocumentSummary { + return { + id: document.id, + knowledgeBaseId: document.knowledgeBaseId, + filename: document.filename, + fileSize: document.fileSize, + mimeType: document.mimeType, + processingStatus: toProcessingStatus(document.processingStatus), + chunkCount: document.chunkCount, + tokenCount: document.tokenCount, + characterCount: document.characterCount, + enabled: document.enabled, + createdAt: serializeDate(document.uploadedAt), + } +} + +interface V2TaggedDocumentSource + extends V2DocumentSummarySource, + Partial> {} + +/** Serializes a document summary with its tag values keyed by display name. */ +export function toV2TaggedDocument( + document: V2TaggedDocumentSource, + tagDefinitions: readonly DocumentTagDefinition[] +): V2KnowledgeTaggedDocument { + return { + ...toV2DocumentSummary(document), + tags: toV2DocumentTags(document, tagDefinitions), + } +} interface KnowledgeBaseWithFolder { knowledgeBase: KnowledgeBaseWithCounts diff --git a/apps/sim/app/api/v2/lib/response.ts b/apps/sim/app/api/v2/lib/response.ts index d619a3c2138..8228a8ee3d5 100644 --- a/apps/sim/app/api/v2/lib/response.ts +++ b/apps/sim/app/api/v2/lib/response.ts @@ -3,6 +3,7 @@ import type { ZodError } from 'zod' 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' +import { forbiddenErrorDetails } from '@/lib/core/application' import { asOrchestrationError, OrchestrationError, @@ -119,6 +120,21 @@ function successHeaders(options: V2SuccessOptions): Record { return { ...PRIVATE_NO_STORE, ...rateLimitHeaders(options.rateLimit), ...options.headers } } +/** + * The bodiless 200 a `HEAD` receives from a route whose `GET` is not safe. + * + * 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. + */ +export function v2HeadNoEffect(options: V2SuccessOptions = {}): NextResponse { + return new NextResponse(null, { status: options.status ?? 200, headers: successHeaders(options) }) +} + /** `{ data }` (+ rate-limit headers). */ export function v2Data(data: T, options: V2SuccessOptions = {}): NextResponse { return NextResponse.json( @@ -392,9 +408,19 @@ export function v2ErrorForOrchestration( * Renders a thrown domain failure in the v2 envelope, or `null` when the error * carries no classification and the caller should log it and return its own * generic 500. The v2 counterpart of `orchestrationErrorResponse`. + * + * A refusal that names its cause carries it through as `error.details.code`. + * That projection lives here, on the one function every v2 error policy + * ultimately falls through to, rather than at each throw site — a route cannot + * then forget it, and the code cannot be attached to a status other than the + * one its failure class maps to. */ export function v2CaughtOrchestrationError(error: unknown): NextResponse | null { const classified = asOrchestrationError(error) if (!classified) return null - return v2ErrorForOrchestration(classified.code, classified.message) + return v2ErrorForOrchestration( + classified.code, + classified.message, + forbiddenErrorDetails(classified) + ) } diff --git a/apps/sim/app/api/v2/logs/route.test.ts b/apps/sim/app/api/v2/logs/route.test.ts index 74b5755c412..2b6bb177f72 100644 --- a/apps/sim/app/api/v2/logs/route.test.ts +++ b/apps/sim/app/api/v2/logs/route.test.ts @@ -128,6 +128,67 @@ describe('GET /api/v2/logs', () => { expect(mocks.execute).not.toHaveBeenCalled() }) + it.each([ + ['abc', 'startDate'], + ['2026-08-06', 'startDate'], + ['2026-08-06T00:00:00+02:00', 'startDate'], + ])('rejects %s as a window bound before it can reach the query', async (value, field) => { + 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('startDate') }, + }) + expect(mocks.execute).not.toHaveBeenCalled() + }) + + it('rejects an unparseable endDate', async () => { + const response = await GET( + new NextRequest(`http://localhost:3000/api/v2/logs?workspaceId=${WORKSPACE_ID}&endDate=abc`) + ) + + expect(response.status).toBe(400) + expect(mocks.execute).not.toHaveBeenCalled() + }) + + it('forwards a UTC window bound as a Date', async () => { + const response = await GET( + new NextRequest( + `http://localhost:3000/api/v2/logs?workspaceId=${WORKSPACE_ID}&startDate=2026-08-06T00:00:00Z` + ) + ) + + expect(response.status).toBe(200) + expect(mocks.execute).toHaveBeenCalledWith( + expect.objectContaining({ + input: expect.objectContaining({ + filters: expect.objectContaining({ startDate: new Date('2026-08-06T00:00:00Z') }), + }), + }) + ) + }) + + it('rejects an inverted window instead of answering with an empty page', async () => { + const response = await GET( + new NextRequest( + `http://localhost:3000/api/v2/logs?workspaceId=${WORKSPACE_ID}&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('projects typed folder errors', async () => { mocks.execute.mockRejectedValueOnce(new OrchestrationError('not_found', 'Folder not found')) 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 01b9dffcb7e..d1c0836afdb 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 @@ -2,6 +2,15 @@ * @vitest-environment node */ import type { mcpServers } from '@sim/db/schema' +import { + MockV2ApiKeyUnauthenticatedError, + V2_OPERATION_RATE_LIMIT_ALLOWED, + V2_PREAUTH_RATE_LIMIT_ALLOWED, + v2ApiKeyAuthModuleMock, + v2GateModuleMock, + v2RateLimiterModuleMock, + v2RouteMocks, +} from '@sim/testing' import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' import { @@ -9,38 +18,16 @@ import { NoWorkspaceAccessError, } from '@/lib/core/application' -const { mocks, MockV2ApiKeyUnauthenticatedError } = vi.hoisted(() => { - class MockV2ApiKeyUnauthenticatedError extends Error {} - return { - mocks: { - authenticate: vi.fn(), - preauthRate: vi.fn(), - operationRate: vi.fn(), - gate: vi.fn(), - get: vi.fn(), - update: vi.fn(), - remove: vi.fn(), - capture: vi.fn(), - }, - MockV2ApiKeyUnauthenticatedError, - } -}) - -vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => ({ - authenticateV2ApiKey: mocks.authenticate, - V2ApiKeyUnauthenticatedError: MockV2ApiKeyUnauthenticatedError, -})) -vi.mock('@/lib/core/rate-limiter', () => ({ - RateLimiter: class { - checkRateLimitDirect = mocks.preauthRate - checkRateLimitDirectOrThrow = mocks.operationRate - }, - getRateLimit: vi.fn().mockReturnValue({ - maxTokens: 100, - refillRate: 100, - refillIntervalMs: 60_000, - }), +const mocks = vi.hoisted(() => ({ + get: vi.fn(), + update: vi.fn(), + remove: vi.fn(), + capture: vi.fn(), })) + +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) vi.mock('@/lib/api/server/rate-limit-context', () => ({ recordRateLimitSnapshot: vi.fn(), getRateLimitHeaders: vi.fn().mockReturnValue(null), @@ -49,7 +36,6 @@ vi.mock('@/lib/core/utils/request', () => ({ generateRequestId: vi.fn().mockReturnValue('request-1'), getClientIp: vi.fn().mockReturnValue('127.0.0.1'), })) -vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mocks.gate })) vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: mocks.capture })) vi.mock('@/lib/mcp/application/use-cases', () => ({ getMcpServerUseCase: { operation: { id: 'mcp_servers.read' }, execute: mocks.get }, @@ -65,17 +51,10 @@ const PRINCIPAL = { kind: 'workspace_api_key' as const, workspaceId: WORKSPACE_I const AUTH = { principal: PRINCIPAL, rolloutUserId: 'owner-1', - rateLimitSubjectIds: ['workspace:workspace-1'] as const, + rateLimitSubjectIds: ['api-key:key-1', `workspace:${WORKSPACE_ID}`] as const, rateLimitSubscription: null, keyType: 'workspace' as const, } -const RATE_LIMIT_OK = { - allowed: true, - limit: 100, - remaining: 99, - resetAt: new Date('2026-01-01T00:00:00Z'), - retryAfterMs: 0, -} const server = { id: 'mcp-server-1', workspaceId: WORKSPACE_ID, @@ -122,10 +101,10 @@ function request(method: 'GET' | 'PATCH' | 'DELETE', body?: unknown) { describe('/api/v2/mcp-servers/[id]', () => { beforeEach(() => { vi.clearAllMocks() - mocks.authenticate.mockResolvedValue(AUTH) - mocks.preauthRate.mockResolvedValue(RATE_LIMIT_OK) - mocks.operationRate.mockResolvedValue(RATE_LIMIT_OK) - mocks.gate.mockResolvedValue(null) + v2RouteMocks.authenticate.mockResolvedValue(AUTH) + v2RouteMocks.gate.mockResolvedValue(null) + v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) + v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) mocks.get.mockResolvedValue({ server }) mocks.update.mockResolvedValue({ server }) mocks.remove.mockResolvedValue({ server }) @@ -175,11 +154,12 @@ describe('/api/v2/mcp-servers/[id]', () => { }) it('authenticates before parsing an invalid update body', async () => { - mocks.authenticate.mockRejectedValueOnce(new MockV2ApiKeyUnauthenticatedError()) + v2RouteMocks.authenticate.mockRejectedValueOnce(new MockV2ApiKeyUnauthenticatedError()) const response = await PATCH(request('PATCH', {}), context) expect(response.status).toBe(401) + expect((await response.json()).error.code).toBe('UNAUTHORIZED') expect(mocks.update).not.toHaveBeenCalled() }) diff --git a/apps/sim/app/api/v2/mcp-servers/[id]/route.ts b/apps/sim/app/api/v2/mcp-servers/[id]/route.ts index 20027c2f474..1612b2269b0 100644 --- a/apps/sim/app/api/v2/mcp-servers/[id]/route.ts +++ b/apps/sim/app/api/v2/mcp-servers/[id]/route.ts @@ -3,12 +3,7 @@ import { v2GetMcpServerContract, v2UpdateMcpServerContract, } from '@/lib/api/contracts/v2/mcp-servers' -import { - createV2ResourceConcealmentPolicy, - defineV2JsonRoute, - v2ApiKeyAuth, - v2RateLimits, -} from '@/lib/api/server/routes' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' import { mcpServerOperations } from '@/lib/mcp/application/operations' import { deleteMcpServerUseCase, @@ -16,15 +11,11 @@ import { updateMcpServerUseCase, } from '@/lib/mcp/application/use-cases' import { captureServerEvent } from '@/lib/posthog/server' -import { toV2McpServer } from '@/app/api/v2/mcp-servers/utils' +import { mcpServerResourceErrorPolicy, toV2McpServer } from '@/app/api/v2/mcp-servers/utils' export const dynamic = 'force-dynamic' export const revalidate = 0 -const mcpServerResourceErrorPolicy = createV2ResourceConcealmentPolicy({ - notFoundMessage: 'MCP server not found', -}) - /** GET /api/v2/mcp-servers/[id] — Fetch a single MCP server. */ export const GET = defineV2JsonRoute({ contract: v2GetMcpServerContract, 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 new file mode 100644 index 00000000000..cf39d34b6a8 --- /dev/null +++ b/apps/sim/app/api/v2/mcp-servers/[id]/tools/route.test.ts @@ -0,0 +1,195 @@ +/** + * @vitest-environment node + */ +import { + MockV2ApiKeyUnauthenticatedError, + V2_OPERATION_RATE_LIMIT_ALLOWED, + V2_PREAUTH_RATE_LIMIT_ALLOWED, + v2ApiKeyAuthModuleMock, + v2GateModuleMock, + v2RateLimiterModuleMock, + v2RouteMocks, +} from '@sim/testing' +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + discover: vi.fn(), +})) + +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) +vi.mock('@/lib/api/server/rate-limit-context', () => ({ + recordRateLimitSnapshot: vi.fn(), + getRateLimitHeaders: vi.fn().mockReturnValue(null), +})) +vi.mock('@/lib/core/utils/request', () => ({ + generateRequestId: vi.fn().mockReturnValue('request-1'), + getClientIp: vi.fn().mockReturnValue('127.0.0.1'), +})) +vi.mock('@/lib/mcp/application/use-cases', () => ({ + discoverMcpServerToolsUseCase: { + operation: { id: 'mcp_servers.tools.discover' }, + execute: mocks.discover, + }, +})) + +import { WorkspaceApiKeyAuthorizationError } from '@/lib/core/application' +import { McpConnectionError, McpOauthAuthorizationRequiredError } from '@/lib/mcp/types' +import { GET } from '@/app/api/v2/mcp-servers/[id]/tools/route' + +const WORKSPACE_ID = 'workspace-1' +const SERVER_ID = 'mcp-3f7a9c21' +const PRINCIPAL = { kind: 'personal_api_key' as const, userId: 'user-1', keyId: 'key-1' } +const AUTH = { + principal: PRINCIPAL, + rolloutUserId: 'user-1', + rateLimitSubjectIds: ['api-key:key-1', 'user:user-1'] as const, + rateLimitSubscription: null, + keyType: 'personal' as const, +} +const TOOL = { + name: 'search_docs', + description: 'Search the internal documentation', + inputSchema: { + type: 'object' as const, + properties: { query: { type: 'string' } }, + required: ['query'], + }, + serverId: SERVER_ID, + serverName: 'Docs server', +} + +function request(query: string, method = 'GET') { + return new NextRequest(`http://localhost:3000/api/v2/mcp-servers/${SERVER_ID}/tools?${query}`, { + method, + headers: { 'x-api-key': 'key' }, + }) +} + +const context = { params: Promise.resolve({ id: SERVER_ID }) } + +describe('/api/v2/mcp-servers/[id]/tools', () => { + beforeEach(() => { + vi.clearAllMocks() + v2RouteMocks.authenticate.mockResolvedValue(AUTH) + v2RouteMocks.gate.mockResolvedValue(null) + v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) + v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) + mocks.discover.mockResolvedValue({ tools: [TOOL] }) + }) + + it('returns a server tool inventory as a single page', async () => { + const response = await GET(request(`workspaceId=${WORKSPACE_ID}`), context) + const body = await response.json() + + expect(response.status).toBe(200) + expect(body).toEqual({ data: [TOOL], nextCursor: null }) + expect(mocks.discover).toHaveBeenCalledWith({ + principal: PRINCIPAL, + input: { workspaceId: WORKSPACE_ID, serverId: SERVER_ID, refresh: false }, + request: expect.anything(), + }) + }) + + it('forwards an explicit refresh so a caller can bypass the tool cache', async () => { + await GET(request(`workspaceId=${WORKSPACE_ID}&refresh=true`), { ...context }) + + expect(mocks.discover).toHaveBeenCalledWith( + expect.objectContaining({ input: expect.objectContaining({ refresh: true }) }) + ) + }) + + /** + * Next aliases a missing `HEAD` export onto `GET`, and RFC 9110 §9.2.1 defines + * `HEAD` as safe. Discovery is not: it opens a live connection to a + * third-party endpoint and writes the outcome onto the server row. An uptime + * monitor or link checker walking the documented URL list would otherwise + * drive both on every probe, invisibly. + */ + it('answers HEAD without connecting to the server or writing its status', async () => { + const response = await GET(request(`workspaceId=${WORKSPACE_ID}&refresh=true`, 'HEAD'), { + ...context, + }) + + expect(response.status).toBe(200) + expect(await response.text()).toBe('') + expect(mocks.discover).not.toHaveBeenCalled() + }) + + it('rejects a query param it does not implement', async () => { + const response = await GET(request(`workspaceId=${WORKSPACE_ID}&limit=10`), { ...context }) + + expect(response.status).toBe(400) + expect(mocks.discover).not.toHaveBeenCalled() + }) + + it('reports an unreachable server as a retryable 503, not a server fault', async () => { + mocks.discover.mockRejectedValueOnce(new McpConnectionError('ECONNREFUSED', 'Docs server')) + + const response = await GET(request(`workspaceId=${WORKSPACE_ID}`), { ...context }) + const body = await response.json() + + expect(response.status).toBe(503) + expect(body.error.code).toBe('SERVICE_UNAVAILABLE') + expect(response.headers.get('Retry-After')).not.toBeNull() + expect(JSON.stringify(body)).not.toContain('ECONNREFUSED') + }) + + it('reports a stale OAuth grant as a 409 a client can branch on, never as a Sim credential failure', async () => { + mocks.discover.mockRejectedValueOnce( + new McpOauthAuthorizationRequiredError(SERVER_ID, 'Docs server') + ) + + const response = await GET(request(`workspaceId=${WORKSPACE_ID}`), { ...context }) + const body = await response.json() + + expect(response.status).toBe(409) + expect(body.error.code).toBe('CONFLICT') + expect(body.error.details).toEqual({ code: 'MCP_SERVER_REAUTHORIZATION_REQUIRED' }) + }) + + it('does not blame the caller for an upstream protocol fault', async () => { + mocks.discover.mockRejectedValueOnce(new Error('MCP error -32602: Invalid params')) + + const response = await GET(request(`workspaceId=${WORKSPACE_ID}`), { ...context }) + const body = await response.json() + + expect(response.status).toBe(500) + expect(body.error.code).toBe('INTERNAL_ERROR') + expect(JSON.stringify(body)).not.toContain('Invalid params') + }) + + it('does not report a Sim-side response-schema defect as the caller`s bad request', async () => { + mocks.discover.mockResolvedValueOnce({ + tools: [{ ...TOOL, inputSchema: { ...TOOL.inputSchema, type: 'string' } }], + }) + + const response = await GET(request(`workspaceId=${WORKSPACE_ID}`), { ...context }) + const body = await response.json() + + expect(response.status).toBe(500) + expect(body.error.code).toBe('INTERNAL_ERROR') + }) + + it('rejects a workspace API key, which cannot supply the caller`s OAuth grant', async () => { + mocks.discover.mockRejectedValueOnce(new WorkspaceApiKeyAuthorizationError()) + + const response = await GET(request(`workspaceId=${WORKSPACE_ID}`), { ...context }) + const body = await response.json() + + expect(response.status).toBe(403) + expect(body.error.code).toBe('FORBIDDEN') + }) + + it('authenticates before parsing', async () => { + v2RouteMocks.authenticate.mockRejectedValueOnce(new MockV2ApiKeyUnauthenticatedError()) + + const response = await GET(request(''), { ...context }) + + expect(response.status).toBe(401) + expect((await response.json()).error.code).toBe('UNAUTHORIZED') + expect(mocks.discover).not.toHaveBeenCalled() + }) +}) 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 new file mode 100644 index 00000000000..137ef0fab8e --- /dev/null +++ b/apps/sim/app/api/v2/mcp-servers/[id]/tools/route.ts @@ -0,0 +1,39 @@ +import { v2ListMcpServerToolsContract } from '@/lib/api/contracts/v2/mcp-servers' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { mcpServerOperations } from '@/lib/mcp/application/operations' +import { discoverMcpServerToolsUseCase } from '@/lib/mcp/application/use-cases' +import { v2McpToolDiscoveryErrorPolicy } from '@/app/api/v2/mcp-servers/utils' + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +/** + * GET /api/v2/mcp-servers/[id]/tools — List the tools a registered MCP server exposes. + * + * The path segment is static, so it can never shadow a server id: ids are minted + * 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. + */ +export const GET = defineV2JsonRoute({ + contract: v2ListMcpServerToolsContract, + operation: mcpServerOperations.discoverTools, + auth: v2ApiKeyAuth, + headSafe: false, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2McpToolDiscoveryErrorPolicy, + mapInput: ({ params, query }) => ({ + workspaceId: query.workspaceId, + serverId: params.id, + refresh: query.refresh, + }), + useCase: discoverMcpServerToolsUseCase, + present: ({ tools }) => ({ data: tools, nextCursor: null }), +}) 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 cf158e4cec2..027ae7272ed 100644 --- a/apps/sim/app/api/v2/mcp-servers/route.test.ts +++ b/apps/sim/app/api/v2/mcp-servers/route.test.ts @@ -2,40 +2,27 @@ * @vitest-environment node */ import type { mcpServers } from '@sim/db/schema' +import { + MockV2ApiKeyUnauthenticatedError, + V2_OPERATION_RATE_LIMIT_ALLOWED, + V2_PREAUTH_RATE_LIMIT_ALLOWED, + v2ApiKeyAuthModuleMock, + v2GateModuleMock, + v2RateLimiterModuleMock, + v2RouteMocks, +} from '@sim/testing' import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mocks, MockV2ApiKeyUnauthenticatedError } = vi.hoisted(() => { - class MockV2ApiKeyUnauthenticatedError extends Error {} - return { - mocks: { - authenticate: vi.fn(), - preauthRate: vi.fn(), - operationRate: vi.fn(), - gate: vi.fn(), - list: vi.fn(), - create: vi.fn(), - capture: vi.fn(), - }, - MockV2ApiKeyUnauthenticatedError, - } -}) - -vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => ({ - authenticateV2ApiKey: mocks.authenticate, - V2ApiKeyUnauthenticatedError: MockV2ApiKeyUnauthenticatedError, -})) -vi.mock('@/lib/core/rate-limiter', () => ({ - RateLimiter: class { - checkRateLimitDirect = mocks.preauthRate - checkRateLimitDirectOrThrow = mocks.operationRate - }, - getRateLimit: vi.fn().mockReturnValue({ - maxTokens: 100, - refillRate: 100, - refillIntervalMs: 60_000, - }), +const mocks = vi.hoisted(() => ({ + list: vi.fn(), + create: vi.fn(), + capture: vi.fn(), })) + +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) vi.mock('@/lib/api/server/rate-limit-context', () => ({ recordRateLimitSnapshot: vi.fn(), getRateLimitHeaders: vi.fn().mockReturnValue(null), @@ -44,7 +31,6 @@ vi.mock('@/lib/core/utils/request', () => ({ generateRequestId: vi.fn().mockReturnValue('request-1'), getClientIp: vi.fn().mockReturnValue('127.0.0.1'), })) -vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mocks.gate })) vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: mocks.capture })) vi.mock('@/lib/mcp/application/use-cases', () => ({ listMcpServersUseCase: { operation: { id: 'mcp_servers.list' }, execute: mocks.list }, @@ -59,17 +45,10 @@ const PRINCIPAL = { kind: 'workspace_api_key' as const, workspaceId: WORKSPACE_I const AUTH = { principal: PRINCIPAL, rolloutUserId: 'owner-1', - rateLimitSubjectIds: ['workspace:workspace-1'] as const, + rateLimitSubjectIds: ['api-key:key-1', `workspace:${WORKSPACE_ID}`] as const, rateLimitSubscription: null, keyType: 'workspace' as const, } -const RATE_LIMIT_OK = { - allowed: true, - limit: 100, - remaining: 99, - resetAt: new Date('2026-01-01T00:00:00Z'), - retryAfterMs: 0, -} const server = { id: 'mcp-server-1', workspaceId: WORKSPACE_ID, @@ -112,11 +91,16 @@ function request(method: 'GET' | 'POST', url: string, body?: unknown) { describe('/api/v2/mcp-servers', () => { beforeEach(() => { vi.clearAllMocks() - mocks.authenticate.mockResolvedValue(AUTH) - mocks.preauthRate.mockResolvedValue(RATE_LIMIT_OK) - mocks.operationRate.mockResolvedValue(RATE_LIMIT_OK) - mocks.gate.mockResolvedValue(null) - mocks.list.mockResolvedValue({ servers: [server] }) + v2RouteMocks.authenticate.mockResolvedValue(AUTH) + v2RouteMocks.gate.mockResolvedValue(null) + v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) + v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) + mocks.list.mockResolvedValue({ + servers: [server], + nextCursorKeys: null, + sortBy: 'createdAt', + sortOrder: 'desc', + }) mocks.create.mockResolvedValue({ server, updated: false }) }) @@ -134,11 +118,86 @@ describe('/api/v2/mcp-servers', () => { search: undefined, sortBy: 'createdAt', sortOrder: 'desc', + limit: 50, + cursor: undefined, + cursorKeys: undefined, }, request: expect.anything(), }) }) + it('bounds the server list by the requested limit', async () => { + await GET(request('GET', `/api/v2/mcp-servers?workspaceId=${WORKSPACE_ID}&limit=2`)) + + expect(mocks.list).toHaveBeenCalledWith( + expect.objectContaining({ input: expect.objectContaining({ limit: 2 }) }) + ) + }) + + it('mints a resumable cursor and replays it against the same sort', async () => { + mocks.list.mockResolvedValueOnce({ + servers: [server], + nextCursorKeys: [server.createdAt.toISOString(), server.id], + sortBy: 'createdAt', + sortOrder: 'desc', + }) + + const first = await GET( + request('GET', `/api/v2/mcp-servers?workspaceId=${WORKSPACE_ID}&limit=1`) + ) + const { nextCursor } = await first.json() + + expect(nextCursor).toEqual(expect.any(String)) + + const second = await GET( + request( + 'GET', + `/api/v2/mcp-servers?workspaceId=${WORKSPACE_ID}&limit=1&cursor=${encodeURIComponent(nextCursor)}` + ) + ) + + expect(second.status).toBe(200) + expect(mocks.list).toHaveBeenLastCalledWith( + expect.objectContaining({ + input: expect.objectContaining({ + cursorKeys: [server.createdAt.toISOString(), server.id], + }), + }) + ) + }) + + it('rejects a cursor minted under a different sort', async () => { + mocks.list.mockResolvedValueOnce({ + servers: [server], + nextCursorKeys: [server.createdAt.toISOString(), server.id], + sortBy: 'createdAt', + sortOrder: 'desc', + }) + + const first = await GET( + request('GET', `/api/v2/mcp-servers?workspaceId=${WORKSPACE_ID}&limit=1`) + ) + const { nextCursor } = await first.json() + + const response = await GET( + request( + 'GET', + `/api/v2/mcp-servers?workspaceId=${WORKSPACE_ID}&limit=1&sortBy=name&cursor=${encodeURIComponent(nextCursor)}` + ) + ) + + expect(response.status).toBe(400) + }) + + 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`) + ) + + expect(response.status).toBe(400) + expect(mocks.list).not.toHaveBeenCalled() + }) + it('strictly creates an MCP server with the v2 source and status', async () => { const response = await POST( request('POST', '/api/v2/mcp-servers', { @@ -163,9 +222,10 @@ describe('/api/v2/mcp-servers', () => { }) it('keeps product analytics surface-specific for personal API keys', async () => { - mocks.authenticate.mockResolvedValueOnce({ + v2RouteMocks.authenticate.mockResolvedValueOnce({ ...AUTH, principal: { kind: 'personal_api_key', userId: 'user-1', keyId: 'key-personal' }, + rateLimitSubjectIds: ['api-key:key-personal', 'user:user-1'], keyType: 'personal', }) @@ -187,11 +247,12 @@ describe('/api/v2/mcp-servers', () => { }) it('authenticates before parsing create input', async () => { - mocks.authenticate.mockRejectedValueOnce(new MockV2ApiKeyUnauthenticatedError()) + v2RouteMocks.authenticate.mockRejectedValueOnce(new MockV2ApiKeyUnauthenticatedError()) const response = await POST(request('POST', '/api/v2/mcp-servers', {})) expect(response.status).toBe(401) + expect((await response.json()).error.code).toBe('UNAUTHORIZED') expect(mocks.create).not.toHaveBeenCalled() }) }) diff --git a/apps/sim/app/api/v2/mcp-servers/route.ts b/apps/sim/app/api/v2/mcp-servers/route.ts index 5d31b41f9b9..53949d93738 100644 --- a/apps/sim/app/api/v2/mcp-servers/route.ts +++ b/apps/sim/app/api/v2/mcp-servers/route.ts @@ -11,6 +11,7 @@ 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 { toV2McpServer } from '@/app/api/v2/mcp-servers/utils' export const dynamic = 'force-dynamic' @@ -23,9 +24,21 @@ export const GET = defineV2JsonRoute({ auth: v2ApiKeyAuth, rateLimit: v2RateLimits.publicApi, errorPolicy: v2OrchestrationErrorPolicy, - mapInput: ({ query }) => query, + mapInput: ({ query }) => ({ + workspaceId: query.workspaceId, + search: query.search, + sortBy: query.sortBy, + sortOrder: query.sortOrder, + limit: query.limit, + cursorKeys: readSortedCursor(query.cursor, query.sortBy, query.sortOrder), + }), useCase: listMcpServersUseCase, - present: ({ servers }) => ({ data: servers.map(toV2McpServer), nextCursor: null }), + present: ({ servers, nextCursorKeys, sortBy, sortOrder }) => ({ + data: servers.map(toV2McpServer), + nextCursor: nextCursorKeys + ? encodeSortedCursor(cursorSortKey(sortBy, sortOrder), nextCursorKeys) + : null, + }), }) /** POST /api/v2/mcp-servers — Register a new MCP server. */ diff --git a/apps/sim/app/api/v2/mcp-servers/utils.ts b/apps/sim/app/api/v2/mcp-servers/utils.ts index 7d186ff8770..a46f344ea57 100644 --- a/apps/sim/app/api/v2/mcp-servers/utils.ts +++ b/apps/sim/app/api/v2/mcp-servers/utils.ts @@ -1,7 +1,12 @@ -import type { NextResponse } from 'next/server' +import { UnauthorizedError } from '@modelcontextprotocol/sdk/client/auth.js' +import { StreamableHTTPError } from '@modelcontextprotocol/sdk/client/streamableHttp.js' +import { McpError as McpSdkError } from '@modelcontextprotocol/sdk/types.js' import { type V2McpServer, v2McpServerSchema } from '@/lib/api/contracts/v2/mcp-servers' +import { createV2ResourceConcealmentPolicy, type V2ErrorPolicy } from '@/lib/api/server/routes' +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 { v2Error } from '@/app/api/v2/lib/response' /** @@ -26,25 +31,85 @@ export function toV2McpServer(row: McpServerRow): V2McpServer { }) } +export const mcpServerResourceErrorPolicy = createV2ResourceConcealmentPolicy({ + notFoundMessage: 'MCP server not found', +}) + /** - * Renders an MCP orchestration failure in the v2 error envelope. + * `error.details.code` on the 409 a stale MCP OAuth grant produces. * - * `forbidden` is the domain-allowlist / SSRF rejection and keeps its 403. - * `bad_gateway` is a DNS failure on the caller-supplied hostname — the caller's - * input is at fault, so it surfaces as a 400 rather than implying a Sim outage. + * 409 carries more than one cause across the v2 surface, so the discriminator is + * what lets a client branch without matching on prose. It is published in the + * operation description. */ -export function v2McpOrchestrationError( - errorCode: string | undefined, - message: string -): NextResponse { - switch (errorCode) { - case 'not_found': - return v2Error('NOT_FOUND', 'MCP server not found') - case 'forbidden': - return v2Error('FORBIDDEN', message) - case 'bad_gateway': - return v2Error('BAD_REQUEST', message) - default: - return v2Error('INTERNAL_ERROR', 'Internal server error') +export const MCP_SERVER_REAUTHORIZATION_REQUIRED = 'MCP_SERVER_REAUTHORIZATION_REQUIRED' + +/** + * Caller-safe wording for a third-party server that did not answer usefully. + * + * Every branch returns a constant, so an upstream message — which may quote a + * hostname, a token endpoint, or a stack — never reaches the caller. + */ +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')) { + return 'The MCP server recently failed and is in cooldown' } + return 'The MCP server could not be reached' } + +/** + * Renders a tool-discovery failure. + * + * Discovery talks to a server the caller registered, so its failures are + * ordinary operating conditions rather than Sim faults: an unreachable, slow, or + * cooling-down server is a retryable 503 (`v2Error` stamps it with + * `Retry-After`), and a server whose stored OAuth grant no longer works is a 409 + * — the registration exists but its grant no longer does, which is a state + * conflict a human resolves by reauthorizing. Answering all of those with a bare + * 500 would make the endpoint that completes MCP onboarding indistinguishable + * from a Sim outage. + * + * The reauthorization case deliberately does **not** reuse 401. On this surface + * 401 means exactly one thing — the Sim API key is missing or invalid — and the + * published response description says so; a client that reacted to it by + * rotating or refreshing its Sim key would loop forever without touching the + * actual problem. It is also not a 403: the caller's rights on the Sim resource + * are fine. + * + * Classification is a typed dispatch over the MCP error families rather than + * `categorizeError`'s substring fallback. That fallback reaches 400 on any + * message containing `invalid`, which misattributed two different faults to the + * caller: an upstream JSON-RPC `Invalid params`, and — because the builder + * `.parse`s the response on the way out — a Sim-side response-schema defect, + * whose `ZodError` message carries `invalid_type`. The second is the worse of + * the two: answering it here suppressed the builder's 500 and its + * unhandled-error logging on the one v2 endpoint whose payload shape is authored + * by a third party. Anything unrecognised now returns `null` and keeps that + * generic 500. + */ +export const v2McpToolDiscoveryErrorPolicy = { + render(error) { + const orchestrated = mcpServerResourceErrorPolicy.render(error) + if (orchestrated) return orchestrated + + if (error instanceof McpOauthAuthorizationRequiredError || error instanceof UnauthorizedError) { + return v2Error( + 'CONFLICT', + 'The MCP server must be reauthorized in Sim before its tools can be listed', + { details: { code: MCP_SERVER_REAUTHORIZATION_REQUIRED } } + ) + } + + if ( + isTimeoutError(error) || + error instanceof McpConnectionError || + error instanceof McpSdkError || + error instanceof StreamableHTTPError + ) { + return v2Error('SERVICE_UNAVAILABLE', unreachableServerMessage(error)) + } + + return null + }, +} satisfies V2ErrorPolicy diff --git a/apps/sim/app/api/v2/skills/utils.ts b/apps/sim/app/api/v2/skills/utils.ts index 34ffbc18002..eb07440b91e 100644 --- a/apps/sim/app/api/v2/skills/utils.ts +++ b/apps/sim/app/api/v2/skills/utils.ts @@ -1,15 +1,9 @@ import type { skill } from '@sim/db/schema' -import type { NextResponse } from 'next/server' import type { V2Skill, V2SkillSummary } from '@/lib/api/contracts/v2/skills' -import type { SkillOrchestrationErrorCode } from '@/lib/skills/orchestration' import { isBuiltinSkillId } from '@/lib/workflows/skills/builtin-skills' import type { SkillSummaryRow } from '@/lib/workflows/skills/operations' -import { v2Error } from '@/app/api/v2/lib/response' - -/** - * Shared serialization + error mapping for the v2 skills surface. - */ +/** Shared serialization for the v2 skills surface. */ type SkillRow = typeof skill.$inferSelect /** @@ -31,22 +25,3 @@ export function toV2SkillSummary(row: SkillSummaryRow): V2SkillSummary { export function toV2Skill(row: SkillRow): V2Skill { return { ...toV2SkillSummary(row), content: row.content } } - -/** Renders a skill orchestration failure in the v2 error envelope. */ -export function v2SkillOrchestrationError( - errorCode: SkillOrchestrationErrorCode | undefined, - message: string -): NextResponse { - switch (errorCode) { - case 'validation': - return v2Error('BAD_REQUEST', message) - case 'forbidden': - return v2Error('FORBIDDEN', message) - case 'not_found': - return v2Error('NOT_FOUND', 'Skill not found') - case 'conflict': - return v2Error('CONFLICT', message) - default: - return v2Error('INTERNAL_ERROR', 'Internal server error') - } -} diff --git a/apps/sim/app/api/v2/tables/[tableId]/cancel-runs/route.test.ts b/apps/sim/app/api/v2/tables/[tableId]/cancel-runs/route.test.ts index 17d013606d6..4ed78156035 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/cancel-runs/route.test.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/cancel-runs/route.test.ts @@ -2,6 +2,15 @@ * @vitest-environment node */ +import { + MockV2ApiKeyUnauthenticatedError, + V2_OPERATION_RATE_LIMIT_ALLOWED, + V2_PREAUTH_RATE_LIMIT_ALLOWED, + v2ApiKeyAuthModuleMock, + v2GateModuleMock, + v2RateLimiterModuleMock, + v2RouteMocks, +} from '@sim/testing' import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -9,28 +18,15 @@ const { mocks, MockTableRowsValidationError } = vi.hoisted(() => { class MockTableRowsValidationError extends Error {} return { mocks: { - authenticate: vi.fn(), - preauthRate: vi.fn(), - operationRate: vi.fn(), - gate: vi.fn(), cancelRuns: vi.fn(), }, MockTableRowsValidationError, } }) -vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => ({ - authenticateV2ApiKey: mocks.authenticate, - V2ApiKeyUnauthenticatedError: class V2ApiKeyUnauthenticatedError extends Error {}, -})) -vi.mock('@/lib/core/rate-limiter', () => ({ - RateLimiter: class { - checkRateLimitDirect = mocks.preauthRate - checkRateLimitDirectOrThrow = mocks.operationRate - }, - getRateLimit: () => ({ maxTokens: 100, refillRate: 100, refillIntervalMs: 60_000 }), -})) -vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mocks.gate })) +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) vi.mock('@/lib/table/application/rows', () => ({ TableRowsValidationError: MockTableRowsValidationError, })) @@ -49,16 +45,10 @@ const PRINCIPAL = { const AUTH = { principal: PRINCIPAL, rolloutUserId: 'owner-1', - rateLimitSubjectIds: [`workspace:${WORKSPACE_ID}`], + rateLimitSubjectIds: ['api-key:key-1', `workspace:${WORKSPACE_ID}`], rateLimitSubscription: null, keyType: 'workspace' as const, } -const RATE = { - allowed: true, - remaining: 99, - resetAt: new Date('2026-01-01T01:00:00Z'), - retryAfterMs: 0, -} function call(body: unknown) { const request = new NextRequest('http://localhost/api/v2/tables/table-1/cancel-runs', { @@ -75,10 +65,10 @@ function call(body: unknown) { describe('POST /api/v2/tables/[tableId]/cancel-runs', () => { beforeEach(() => { vi.clearAllMocks() - mocks.authenticate.mockResolvedValue(AUTH) - mocks.preauthRate.mockResolvedValue(RATE) - mocks.operationRate.mockResolvedValue(RATE) - mocks.gate.mockResolvedValue(null) + v2RouteMocks.authenticate.mockResolvedValue(AUTH) + v2RouteMocks.gate.mockResolvedValue(null) + v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) + v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) mocks.cancelRuns.mockResolvedValue({ table: { id: 'table-1' }, cancelled: 4 }) }) @@ -146,4 +136,13 @@ describe('POST /api/v2/tables/[tableId]/cancel-runs', () => { expect(contradictory.status).toBe(400) expect(mocks.cancelRuns).not.toHaveBeenCalled() }) + + it('rejects an unauthenticated request', async () => { + v2RouteMocks.authenticate.mockRejectedValueOnce(new MockV2ApiKeyUnauthenticatedError()) + + const response = await call({ workspaceId: WORKSPACE_ID, scope: 'all' }).response + + expect(response.status).toBe(401) + expect((await response.json()).error.code).toBe('UNAUTHORIZED') + }) }) 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 4ca1f73bf67..5fedb51afac 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 @@ -2,31 +2,27 @@ * @vitest-environment node */ +import { + MockV2ApiKeyUnauthenticatedError, + V2_OPERATION_RATE_LIMIT_ALLOWED, + V2_PREAUTH_RATE_LIMIT_ALLOWED, + v2ApiKeyAuthModuleMock, + v2GateModuleMock, + v2RateLimiterModuleMock, + v2RouteMocks, +} from '@sim/testing' import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ - authenticate: vi.fn(), - preauthRate: vi.fn(), - operationRate: vi.fn(), - gate: vi.fn(), add: vi.fn(), update: vi.fn(), remove: vi.fn(), })) -vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => ({ - authenticateV2ApiKey: mocks.authenticate, - V2ApiKeyUnauthenticatedError: class V2ApiKeyUnauthenticatedError extends Error {}, -})) -vi.mock('@/lib/core/rate-limiter', () => ({ - RateLimiter: class { - checkRateLimitDirect = mocks.preauthRate - checkRateLimitDirectOrThrow = mocks.operationRate - }, - getRateLimit: () => ({ maxTokens: 100, refillRate: 100, refillIntervalMs: 60_000 }), -})) -vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mocks.gate })) +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) vi.mock('@/lib/table/application/columns', () => ({ addTableColumnUseCase: { operation: { id: 'tables.columns.add' }, execute: mocks.add }, updateTableColumnUseCase: { operation: { id: 'tables.columns.update' }, execute: mocks.update }, @@ -45,16 +41,10 @@ const principal = { const auth = { principal, rolloutUserId: 'owner-1', - rateLimitSubjectIds: [`workspace:${WORKSPACE_ID}`], + rateLimitSubjectIds: ['api-key:key-1', `workspace:${WORKSPACE_ID}`], rateLimitSubscription: null, keyType: 'workspace' as const, } -const rate = { - allowed: true, - remaining: 99, - resetAt: new Date('2026-01-01T01:00:00.000Z'), - retryAfterMs: 0, -} const table = { id: 'table-1', name: 'Contacts', @@ -77,10 +67,10 @@ function request(method: 'POST' | 'PATCH' | 'DELETE', body: unknown) { describe('/api/v2/tables/[tableId]/columns', () => { beforeEach(() => { vi.clearAllMocks() - mocks.authenticate.mockResolvedValue(auth) - mocks.preauthRate.mockResolvedValue(rate) - mocks.operationRate.mockResolvedValue(rate) - mocks.gate.mockResolvedValue(null) + v2RouteMocks.authenticate.mockResolvedValue(auth) + v2RouteMocks.gate.mockResolvedValue(null) + v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) + v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) mocks.add.mockResolvedValue({ table }) mocks.update.mockResolvedValue({ table, changed: false }) mocks.remove.mockResolvedValue({ table }) @@ -108,6 +98,49 @@ describe('/api/v2/tables/[tableId]/columns', () => { }) }) + it('forwards required on both the add and the update column write', async () => { + await POST( + request('POST', { + workspaceId: WORKSPACE_ID, + column: { name: 'Name', type: 'string', required: true }, + }), + context + ) + await PATCH( + request('PATCH', { + workspaceId: WORKSPACE_ID, + columnName: 'Name', + updates: { required: false }, + }), + context + ) + + expect(mocks.add).toHaveBeenCalledWith( + expect.objectContaining({ + input: expect.objectContaining({ + column: { name: 'Name', type: 'string', required: true }, + }), + }) + ) + expect(mocks.update).toHaveBeenCalledWith( + expect.objectContaining({ input: expect.objectContaining({ updates: { required: false } }) }) + ) + }) + + it('rejects an unrecognized key on the column delete body', async () => { + const response = await DELETE( + request('DELETE', { + workspaceId: WORKSPACE_ID, + columnName: 'Other', + columnNames: ['Other'], + }), + context + ) + + expect(response.status).toBe(400) + expect(mocks.remove).not.toHaveBeenCalled() + }) + it('maps typed application validation failures without inspecting messages', async () => { mocks.update.mockRejectedValueOnce(new OrchestrationError('validation', 'Invalid column')) @@ -133,4 +166,16 @@ describe('/api/v2/tables/[tableId]/columns', () => { expect(response.status).toBe(200) expect(mocks.remove).toHaveBeenCalledOnce() }) + + it('rejects an unauthenticated request', async () => { + v2RouteMocks.authenticate.mockRejectedValueOnce(new MockV2ApiKeyUnauthenticatedError()) + + const response = await POST( + request('POST', { workspaceId: WORKSPACE_ID, column: { name: 'Name', type: 'string' } }), + context + ) + + expect(response.status).toBe(401) + expect((await response.json()).error.code).toBe('UNAUTHORIZED') + }) }) diff --git a/apps/sim/app/api/v2/tables/[tableId]/columns/run/route.test.ts b/apps/sim/app/api/v2/tables/[tableId]/columns/run/route.test.ts index 83b77f98923..e9257648ab5 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/columns/run/route.test.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/columns/run/route.test.ts @@ -2,6 +2,15 @@ * @vitest-environment node */ +import { + MockV2ApiKeyUnauthenticatedError, + V2_OPERATION_RATE_LIMIT_ALLOWED, + V2_PREAUTH_RATE_LIMIT_ALLOWED, + v2ApiKeyAuthModuleMock, + v2GateModuleMock, + v2RateLimiterModuleMock, + v2RouteMocks, +} from '@sim/testing' import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -9,28 +18,15 @@ const { mocks, MockTableRowsValidationError } = vi.hoisted(() => { class MockTableRowsValidationError extends Error {} return { mocks: { - authenticate: vi.fn(), - preauthRate: vi.fn(), - operationRate: vi.fn(), - gate: vi.fn(), startRun: vi.fn(), }, MockTableRowsValidationError, } }) -vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => ({ - authenticateV2ApiKey: mocks.authenticate, - V2ApiKeyUnauthenticatedError: class V2ApiKeyUnauthenticatedError extends Error {}, -})) -vi.mock('@/lib/core/rate-limiter', () => ({ - RateLimiter: class { - checkRateLimitDirect = mocks.preauthRate - checkRateLimitDirectOrThrow = mocks.operationRate - }, - getRateLimit: () => ({ maxTokens: 100, refillRate: 100, refillIntervalMs: 60_000 }), -})) -vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mocks.gate })) +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) vi.mock('@/lib/table/application/rows', () => ({ TableRowsValidationError: MockTableRowsValidationError, })) @@ -49,16 +45,10 @@ const PRINCIPAL = { const AUTH = { principal: PRINCIPAL, rolloutUserId: 'owner-1', - rateLimitSubjectIds: [`workspace:${WORKSPACE_ID}`], + rateLimitSubjectIds: ['api-key:key-1', `workspace:${WORKSPACE_ID}`], rateLimitSubscription: null, keyType: 'workspace' as const, } -const RATE = { - allowed: true, - remaining: 99, - resetAt: new Date('2026-01-01T01:00:00Z'), - retryAfterMs: 0, -} function call(body: unknown) { const request = new NextRequest('http://localhost/api/v2/tables/table-1/columns/run', { @@ -75,10 +65,10 @@ function call(body: unknown) { describe('POST /api/v2/tables/[tableId]/columns/run', () => { beforeEach(() => { vi.clearAllMocks() - mocks.authenticate.mockResolvedValue(AUTH) - mocks.preauthRate.mockResolvedValue(RATE) - mocks.operationRate.mockResolvedValue(RATE) - mocks.gate.mockResolvedValue(null) + v2RouteMocks.authenticate.mockResolvedValue(AUTH) + v2RouteMocks.gate.mockResolvedValue(null) + v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) + v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) mocks.startRun.mockResolvedValue({ table: { id: 'table-1' }, dispatchId: 'dispatch-1' }) }) @@ -143,4 +133,13 @@ describe('POST /api/v2/tables/[tableId]/columns/run', () => { expect(response.status).toBe(400) expect(mocks.startRun).not.toHaveBeenCalled() }) + + it('rejects an unauthenticated request', async () => { + v2RouteMocks.authenticate.mockRejectedValueOnce(new MockV2ApiKeyUnauthenticatedError()) + + const response = await call({ workspaceId: WORKSPACE_ID, groupIds: ['group-1'] }).response + + expect(response.status).toBe(401) + expect((await response.json()).error.code).toBe('UNAUTHORIZED') + }) }) diff --git a/apps/sim/app/api/v2/tables/[tableId]/exports/route.test.ts b/apps/sim/app/api/v2/tables/[tableId]/exports/route.test.ts index ad7a2d1bc2f..b2c700871c9 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/exports/route.test.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/exports/route.test.ts @@ -2,31 +2,27 @@ * @vitest-environment node */ +import { + MockV2ApiKeyUnauthenticatedError, + V2_OPERATION_RATE_LIMIT_ALLOWED, + V2_PREAUTH_RATE_LIMIT_ALLOWED, + v2ApiKeyAuthModuleMock, + v2GateModuleMock, + v2RateLimiterModuleMock, + v2RouteMocks, +} from '@sim/testing' import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ - authenticate: vi.fn(), - preauthRate: vi.fn(), - operationRate: vi.fn(), - gate: vi.fn(), create: vi.fn(), read: vi.fn(), download: vi.fn(), })) -vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => ({ - authenticateV2ApiKey: mocks.authenticate, - V2ApiKeyUnauthenticatedError: class V2ApiKeyUnauthenticatedError extends Error {}, -})) -vi.mock('@/lib/core/rate-limiter', () => ({ - RateLimiter: class { - checkRateLimitDirect = mocks.preauthRate - checkRateLimitDirectOrThrow = mocks.operationRate - }, - getRateLimit: () => ({ maxTokens: 100, refillRate: 100, refillIntervalMs: 60_000 }), -})) -vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mocks.gate })) +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) vi.mock('@/app/api/v2/tables/presenters', () => ({ presentV2TableExport: (tableExport: unknown) => ({ data: tableExport }), })) @@ -53,16 +49,10 @@ const principal = { const auth = { principal, rolloutUserId: 'owner-1', - rateLimitSubjectIds: [`workspace:${WORKSPACE_ID}`], + rateLimitSubjectIds: ['api-key:key-1', `workspace:${WORKSPACE_ID}`], rateLimitSubscription: null, keyType: 'workspace' as const, } -const rate = { - allowed: true, - remaining: 99, - resetAt: new Date('2026-01-01T01:00:00.000Z'), - retryAfterMs: 0, -} const tableExport = { id: 'export-1', tableId: 'table-1', @@ -79,10 +69,10 @@ const tableExport = { describe('v2 table exports', () => { beforeEach(() => { vi.clearAllMocks() - mocks.authenticate.mockResolvedValue(auth) - mocks.preauthRate.mockResolvedValue(rate) - mocks.operationRate.mockResolvedValue(rate) - mocks.gate.mockResolvedValue(null) + v2RouteMocks.authenticate.mockResolvedValue(auth) + v2RouteMocks.gate.mockResolvedValue(null) + v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) + v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) }) it('creates an export through the authorized use case', async () => { @@ -140,4 +130,18 @@ describe('v2 table exports', () => { request: downloadRequest, }) }) + + it('rejects an unauthenticated request', async () => { + v2RouteMocks.authenticate.mockRejectedValueOnce(new MockV2ApiKeyUnauthenticatedError()) + const request = new NextRequest('http://localhost:3000/api/v2/tables/table-1/exports', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ workspaceId: WORKSPACE_ID, format: 'csv' }), + }) + + const response = await POST(request, { params: Promise.resolve({ tableId: 'table-1' }) }) + + expect(response.status).toBe(401) + expect((await response.json()).error.code).toBe('UNAUTHORIZED') + }) }) 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 35acd0b7739..94bfb5816c6 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 @@ -2,32 +2,28 @@ * @vitest-environment node */ +import { + MockV2ApiKeyUnauthenticatedError, + V2_OPERATION_RATE_LIMIT_ALLOWED, + V2_PREAUTH_RATE_LIMIT_ALLOWED, + v2ApiKeyAuthModuleMock, + v2GateModuleMock, + v2RateLimiterModuleMock, + v2RouteMocks, +} from '@sim/testing' import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ - authenticate: vi.fn(), - preauthRate: vi.fn(), - operationRate: vi.fn(), - gate: vi.fn(), list: vi.fn(), create: vi.fn(), update: vi.fn(), remove: vi.fn(), })) -vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => ({ - authenticateV2ApiKey: mocks.authenticate, - V2ApiKeyUnauthenticatedError: class V2ApiKeyUnauthenticatedError extends Error {}, -})) -vi.mock('@/lib/core/rate-limiter', () => ({ - RateLimiter: class { - checkRateLimitDirect = mocks.preauthRate - checkRateLimitDirectOrThrow = mocks.operationRate - }, - getRateLimit: () => ({ maxTokens: 100, refillRate: 100, refillIntervalMs: 60_000 }), -})) -vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mocks.gate })) +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) vi.mock('@/lib/table/application/groups', () => ({ listTableGroupsUseCase: { operation: { id: 'tables.groups.list' }, execute: mocks.list }, createTableGroupUseCase: { operation: { id: 'tables.groups.create' }, execute: mocks.create }, @@ -47,16 +43,10 @@ const principal = { const auth = { principal, rolloutUserId: 'owner-1', - rateLimitSubjectIds: [`workspace:${WORKSPACE_ID}`], + rateLimitSubjectIds: ['api-key:key-1', `workspace:${WORKSPACE_ID}`], rateLimitSubscription: null, keyType: 'workspace' as const, } -const rate = { - allowed: true, - remaining: 99, - resetAt: new Date('2026-01-01T01:00:00.000Z'), - retryAfterMs: 0, -} const group = { id: 'group-1', workflowId: 'workflow-1', @@ -93,10 +83,10 @@ function writeRequest(method: 'POST' | 'PATCH' | 'DELETE', body: unknown) { describe('/api/v2/tables/[tableId]/groups', () => { beforeEach(() => { vi.clearAllMocks() - mocks.authenticate.mockResolvedValue(auth) - mocks.preauthRate.mockResolvedValue(rate) - mocks.operationRate.mockResolvedValue(rate) - mocks.gate.mockResolvedValue(null) + v2RouteMocks.authenticate.mockResolvedValue(auth) + 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.create.mockResolvedValue({ table, group }) mocks.update.mockResolvedValue({ table, group, changed: true, startAutoRun: false }) @@ -118,6 +108,20 @@ describe('/api/v2/tables/[tableId]/groups', () => { }) }) + it('rejects an unauthenticated request', async () => { + v2RouteMocks.authenticate.mockRejectedValueOnce(new MockV2ApiKeyUnauthenticatedError()) + + const response = await GET( + new NextRequest( + `http://localhost:3000/api/v2/tables/table-1/groups?workspaceId=${WORKSPACE_ID}` + ), + context + ) + + expect(response.status).toBe(401) + expect((await response.json()).error.code).toBe('UNAUTHORIZED') + }) + it('defaults create autoRun off and delegates all execution initiation to the application layer', async () => { const req = writeRequest('POST', { workspaceId: WORKSPACE_ID, diff --git a/apps/sim/app/api/v2/tables/[tableId]/query/count/route.test.ts b/apps/sim/app/api/v2/tables/[tableId]/query/count/route.test.ts new file mode 100644 index 00000000000..576b3925505 --- /dev/null +++ b/apps/sim/app/api/v2/tables/[tableId]/query/count/route.test.ts @@ -0,0 +1,181 @@ +/** + * @vitest-environment node + */ + +import { + MockV2ApiKeyUnauthenticatedError, + V2_OPERATION_RATE_LIMIT_ALLOWED, + V2_PREAUTH_RATE_LIMIT_ALLOWED, + v2ApiKeyAuthModuleMock, + v2GateModuleMock, + v2RateLimiterModuleMock, + v2RouteMocks, +} from '@sim/testing' +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mocks, MockTableRowsValidationError } = vi.hoisted(() => { + class MockTableRowsValidationError extends Error { + constructor( + message: string, + readonly details?: unknown + ) { + super(message) + } + } + return { + mocks: { + queryRows: vi.fn(), + }, + MockTableRowsValidationError, + } +}) + +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) +vi.mock('@/lib/table/application/rows', () => ({ + TableRowsValidationError: MockTableRowsValidationError, + queryTableRows: { operation: { id: 'tables.rows.query' }, execute: mocks.queryRows }, +})) + +import { POST } from '@/app/api/v2/tables/[tableId]/query/count/route' + +const WORKSPACE_ID = 'workspace-1' +const PRINCIPAL = { + kind: 'workspace_api_key' as const, + workspaceId: WORKSPACE_ID, + keyId: 'key-1', +} +const AUTH = { + principal: PRINCIPAL, + rolloutUserId: 'owner-1', + rateLimitSubjectIds: ['api-key:key-1', `workspace:${WORKSPACE_ID}`], + rateLimitSubscription: null, + keyType: 'workspace' as const, +} +const TABLE = { + id: 'table-1', + workspaceId: WORKSPACE_ID, + schema: { columns: [{ id: 'column-name', name: 'name', type: 'string' as const }] }, +} +const ROW = { + id: 'row-1', + data: { 'column-name': 'Ada' }, + createdAt: new Date('2026-01-01T00:00:00Z'), + updatedAt: new Date('2026-01-02T00:00:00Z'), +} + +function call(body: unknown) { + const request = new NextRequest('http://localhost/api/v2/tables/table-1/query/count', { + method: 'POST', + headers: { 'content-type': 'application/json', 'x-api-key': 'secret' }, + body: JSON.stringify(body), + }) + return { + request, + response: POST(request, { params: Promise.resolve({ tableId: 'table-1' }) }), + } +} + +describe('POST /api/v2/tables/[tableId]/query/count', () => { + beforeEach(() => { + vi.clearAllMocks() + v2RouteMocks.authenticate.mockResolvedValue(AUTH) + v2RouteMocks.gate.mockResolvedValue(null) + v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) + v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) + mocks.queryRows.mockResolvedValue({ + table: TABLE, + rows: [ROW], + rowCount: 1, + totalCount: 4321, + nextCursor: 'cursor-1', + }) + }) + + it('counts the predicate matches across the whole table, not the page', async () => { + const predicate = { all: [{ field: 'name', op: 'eq', value: 'Ada' }] } + const invocation = call({ workspaceId: WORKSPACE_ID, predicate }) + const response = await invocation.response + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ data: { totalCount: 4321 } }) + expect(mocks.queryRows).toHaveBeenCalledWith({ + principal: PRINCIPAL, + input: { + tableId: 'table-1', + assertedWorkspaceId: WORKSPACE_ID, + predicate, + limit: 1, + includeTotal: true, + }, + request: invocation.request, + }) + }) + + it('counts the whole table when no predicate is sent', async () => { + mocks.queryRows.mockResolvedValue({ + table: TABLE, + rows: [], + rowCount: 0, + totalCount: 0, + nextCursor: null, + }) + + const response = await call({ workspaceId: WORKSPACE_ID }).response + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ data: { totalCount: 0 } }) + expect(mocks.queryRows).toHaveBeenCalledWith( + expect.objectContaining({ input: expect.objectContaining({ predicate: undefined }) }) + ) + }) + + it('rejects the paging controls a count has no use for', async () => { + const response = await call({ workspaceId: WORKSPACE_ID, limit: 10, cursor: 'x' }).response + + expect(response.status).toBe(400) + expect(mocks.queryRows).not.toHaveBeenCalled() + }) + + it('keeps a malformed predicate as a structured 400', async () => { + mocks.queryRows.mockRejectedValue( + new MockTableRowsValidationError('Unknown column "nope"', { code: 'INVALID_PREDICATE' }) + ) + + const response = await call({ + workspaceId: WORKSPACE_ID, + predicate: { all: [{ field: 'nope', op: 'eq', value: 1 }] }, + }).response + + expect(response.status).toBe(400) + expect((await response.json()).error.details).toEqual({ code: 'INVALID_PREDICATE' }) + }) + + it('never presents a fabricated zero when no total was computed', async () => { + mocks.queryRows.mockResolvedValue({ + table: TABLE, + rows: [ROW], + rowCount: 1, + totalCount: null, + nextCursor: null, + }) + + const response = await call({ workspaceId: WORKSPACE_ID }).response + + expect(response.status).toBe(500) + expect(await response.json()).toEqual({ + error: { code: 'INTERNAL_ERROR', message: 'Internal server error' }, + }) + }) + + it('rejects an unauthenticated request', async () => { + v2RouteMocks.authenticate.mockRejectedValueOnce(new MockV2ApiKeyUnauthenticatedError()) + + const response = await call({ workspaceId: WORKSPACE_ID }).response + + expect(response.status).toBe(401) + expect((await response.json()).error.code).toBe('UNAUTHORIZED') + }) +}) diff --git a/apps/sim/app/api/v2/tables/[tableId]/query/count/route.ts b/apps/sim/app/api/v2/tables/[tableId]/query/count/route.ts new file mode 100644 index 00000000000..f71712224dc --- /dev/null +++ b/apps/sim/app/api/v2/tables/[tableId]/query/count/route.ts @@ -0,0 +1,45 @@ +import { TABLE_QUERY_MAX_BODY_BYTES } from '@/lib/api/contracts/tables' +import { v2QueryRowsCountContract } from '@/lib/api/contracts/v2/tables' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2TableRowsErrorPolicy } from '@/lib/table/api/row-route-policies' +import { tableOperations } from '@/lib/table/application/operations' +import { queryTableRows } from '@/lib/table/application/rows' + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +/** + * Counts the rows a predicate matches. + * + * The same `queryTableRows` read the paged endpoints use, asked for its total + * instead of its page: `includeTotal` runs a COUNT over the full predicate view + * (not the page's keyset window), and `limit: 1` keeps the row drain that runs + * alongside it to a single row rather than a full default page. + * + * `totalCount` is `number | null` on the use-case result because callers may ask + * for a page without a total. This route always asks for one, so a null here is + * a broken invariant rather than a reachable outcome — it fails loudly instead + * of being coerced into a plausible-looking zero. + */ +export const POST = defineV2JsonRoute({ + contract: v2QueryRowsCountContract, + operation: tableOperations.queryRows, + auth: v2ApiKeyAuth, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2TableRowsErrorPolicy, + parseOptions: { maxBodyBytes: TABLE_QUERY_MAX_BODY_BYTES }, + mapInput: ({ params, body }) => ({ + tableId: params.tableId, + assertedWorkspaceId: body.workspaceId, + predicate: body.predicate, + limit: 1, + includeTotal: true, + }), + useCase: queryTableRows, + present: ({ totalCount }) => { + if (totalCount === null) { + throw new Error('Table row count requested with includeTotal but no total was computed') + } + return { data: { totalCount } } + }, +}) diff --git a/apps/sim/app/api/v2/tables/[tableId]/query/route.test.ts b/apps/sim/app/api/v2/tables/[tableId]/query/route.test.ts index 88ab015bf3c..d32782c9bb2 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/query/route.test.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/query/route.test.ts @@ -2,6 +2,15 @@ * @vitest-environment node */ +import { + MockV2ApiKeyUnauthenticatedError, + V2_OPERATION_RATE_LIMIT_ALLOWED, + V2_PREAUTH_RATE_LIMIT_ALLOWED, + v2ApiKeyAuthModuleMock, + v2GateModuleMock, + v2RateLimiterModuleMock, + v2RouteMocks, +} from '@sim/testing' import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -16,28 +25,15 @@ const { mocks, MockTableRowsValidationError } = vi.hoisted(() => { } return { mocks: { - authenticate: vi.fn(), - preauthRate: vi.fn(), - operationRate: vi.fn(), - gate: vi.fn(), queryRows: vi.fn(), }, MockTableRowsValidationError, } }) -vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => ({ - authenticateV2ApiKey: mocks.authenticate, - V2ApiKeyUnauthenticatedError: class V2ApiKeyUnauthenticatedError extends Error {}, -})) -vi.mock('@/lib/core/rate-limiter', () => ({ - RateLimiter: class { - checkRateLimitDirect = mocks.preauthRate - checkRateLimitDirectOrThrow = mocks.operationRate - }, - getRateLimit: () => ({ maxTokens: 100, refillRate: 100, refillIntervalMs: 60_000 }), -})) -vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mocks.gate })) +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) vi.mock('@/lib/table/application/rows', () => ({ TableRowsValidationError: MockTableRowsValidationError, queryTableRows: { operation: { id: 'tables.rows.query' }, execute: mocks.queryRows }, @@ -54,16 +50,10 @@ const PRINCIPAL = { const AUTH = { principal: PRINCIPAL, rolloutUserId: 'owner-1', - rateLimitSubjectIds: [`workspace:${WORKSPACE_ID}`], + rateLimitSubjectIds: ['api-key:key-1', `workspace:${WORKSPACE_ID}`], rateLimitSubscription: null, keyType: 'workspace' as const, } -const RATE = { - allowed: true, - remaining: 99, - resetAt: new Date('2026-01-01T01:00:00Z'), - retryAfterMs: 0, -} const TABLE = { id: 'table-1', workspaceId: WORKSPACE_ID, @@ -91,10 +81,10 @@ function call(body: unknown) { describe('POST /api/v2/tables/[tableId]/query', () => { beforeEach(() => { vi.clearAllMocks() - mocks.authenticate.mockResolvedValue(AUTH) - mocks.preauthRate.mockResolvedValue(RATE) - mocks.operationRate.mockResolvedValue(RATE) - mocks.gate.mockResolvedValue(null) + v2RouteMocks.authenticate.mockResolvedValue(AUTH) + v2RouteMocks.gate.mockResolvedValue(null) + v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) + v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) mocks.queryRows.mockResolvedValue({ table: TABLE, rows: [ROW], nextCursor: null }) }) @@ -138,12 +128,22 @@ describe('POST /api/v2/tables/[tableId]/query', () => { ) }) + it('rejects a v1-shaped filter key instead of answering with an unfiltered page', async () => { + const response = await call({ + workspaceId: WORKSPACE_ID, + filter: { name: { $eq: 'Ada' } }, + }).response + + expect(response.status).toBe(400) + expect(mocks.queryRows).not.toHaveBeenCalled() + }) + it('rejects an invalid page limit after admission and before delegation', async () => { const response = await call({ workspaceId: WORKSPACE_ID, limit: 5000 }).response expect(response.status).toBe(400) - expect(mocks.authenticate).toHaveBeenCalledOnce() - expect(mocks.operationRate).toHaveBeenCalledOnce() + expect(v2RouteMocks.authenticate).toHaveBeenCalledOnce() + expect(v2RouteMocks.operationRate).toHaveBeenCalledTimes(AUTH.rateLimitSubjectIds.length) expect(mocks.queryRows).not.toHaveBeenCalled() }) @@ -165,4 +165,13 @@ describe('POST /api/v2/tables/[tableId]/query', () => { expect(response.status).toBe(413) expect(mocks.queryRows).not.toHaveBeenCalled() }) + + it('rejects an unauthenticated request', async () => { + v2RouteMocks.authenticate.mockRejectedValueOnce(new MockV2ApiKeyUnauthenticatedError()) + + const response = await call({ workspaceId: WORKSPACE_ID }).response + + expect(response.status).toBe(401) + expect((await response.json()).error.code).toBe('UNAUTHORIZED') + }) }) diff --git a/apps/sim/app/api/v2/tables/[tableId]/route.test.ts b/apps/sim/app/api/v2/tables/[tableId]/route.test.ts index da221f15235..5060a47a762 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/route.test.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/route.test.ts @@ -2,14 +2,19 @@ * @vitest-environment node */ +import { + MockV2ApiKeyUnauthenticatedError, + V2_OPERATION_RATE_LIMIT_ALLOWED, + V2_PREAUTH_RATE_LIMIT_ALLOWED, + v2ApiKeyAuthModuleMock, + v2GateModuleMock, + v2RateLimiterModuleMock, + v2RouteMocks, +} from '@sim/testing' import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ - authenticate: vi.fn(), - preauthRate: vi.fn(), - operationRate: vi.fn(), - gate: vi.fn(), read: vi.fn(), update: vi.fn(), remove: vi.fn(), @@ -18,18 +23,9 @@ const mocks = vi.hoisted(() => ({ getMaxRowsPerTable: vi.fn(), })) -vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => ({ - authenticateV2ApiKey: mocks.authenticate, - V2ApiKeyUnauthenticatedError: class V2ApiKeyUnauthenticatedError extends Error {}, -})) -vi.mock('@/lib/core/rate-limiter', () => ({ - RateLimiter: class { - checkRateLimitDirect = mocks.preauthRate - checkRateLimitDirectOrThrow = mocks.operationRate - }, - getRateLimit: () => ({ maxTokens: 100, refillRate: 100, refillIntervalMs: 60_000 }), -})) -vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mocks.gate })) +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) vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: mocks.capture })) vi.mock('@/lib/table/application/tables', () => ({ readTableUseCase: { operation: { id: 'tables.read' }, execute: mocks.read }, @@ -57,16 +53,10 @@ const principal = { const auth = { principal, rolloutUserId: 'owner-1', - rateLimitSubjectIds: [`workspace:${WORKSPACE_ID}`], + rateLimitSubjectIds: ['api-key:key-1', `workspace:${WORKSPACE_ID}`], rateLimitSubscription: null, keyType: 'workspace' as const, } -const rate = { - allowed: true, - remaining: 99, - resetAt: new Date('2026-01-01T01:00:00.000Z'), - retryAfterMs: 0, -} const table = { id: 'table-1', workspaceId: WORKSPACE_ID, @@ -107,10 +97,10 @@ function request(method: 'GET' | 'PATCH' | 'DELETE', body?: unknown) { describe('/api/v2/tables/[tableId]', () => { beforeEach(() => { vi.clearAllMocks() - mocks.authenticate.mockResolvedValue(auth) - mocks.preauthRate.mockResolvedValue(rate) - mocks.operationRate.mockResolvedValue(rate) - mocks.gate.mockResolvedValue(null) + v2RouteMocks.authenticate.mockResolvedValue(auth) + v2RouteMocks.gate.mockResolvedValue(null) + v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) + v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) mocks.getUserEmailsByIds.mockResolvedValue(new Map([['owner-1', 'owner@example.com']])) mocks.getMaxRowsPerTable.mockResolvedValue(5000) mocks.read.mockResolvedValue({ table, folderPath: '/' }) @@ -147,6 +137,15 @@ describe('/api/v2/tables/[tableId]', () => { }) }) + it('rejects an unauthenticated request', async () => { + v2RouteMocks.authenticate.mockRejectedValueOnce(new MockV2ApiKeyUnauthenticatedError()) + + const response = await GET(request('GET'), context) + + expect(response.status).toBe(401) + expect((await response.json()).error.code).toBe('UNAUTHORIZED') + }) + it('preserves a successful no-op PATCH response', async () => { const response = await PATCH( request('PATCH', { workspaceId: WORKSPACE_ID, name: 'Contacts' }), diff --git a/apps/sim/app/api/v2/tables/[tableId]/rows/[rowId]/enrichment/[groupId]/route.test.ts b/apps/sim/app/api/v2/tables/[tableId]/rows/[rowId]/enrichment/[groupId]/route.test.ts index c0e1306fe6a..31671eb4fa4 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/rows/[rowId]/enrichment/[groupId]/route.test.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/rows/[rowId]/enrichment/[groupId]/route.test.ts @@ -2,6 +2,15 @@ * @vitest-environment node */ +import { + MockV2ApiKeyUnauthenticatedError, + V2_OPERATION_RATE_LIMIT_ALLOWED, + V2_PREAUTH_RATE_LIMIT_ALLOWED, + v2ApiKeyAuthModuleMock, + v2GateModuleMock, + v2RateLimiterModuleMock, + v2RouteMocks, +} from '@sim/testing' import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -9,28 +18,15 @@ const { mocks, MockTableRowsValidationError } = vi.hoisted(() => { class MockTableRowsValidationError extends Error {} return { mocks: { - authenticate: vi.fn(), - preauthRate: vi.fn(), - operationRate: vi.fn(), - gate: vi.fn(), startRun: vi.fn(), }, MockTableRowsValidationError, } }) -vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => ({ - authenticateV2ApiKey: mocks.authenticate, - V2ApiKeyUnauthenticatedError: class V2ApiKeyUnauthenticatedError extends Error {}, -})) -vi.mock('@/lib/core/rate-limiter', () => ({ - RateLimiter: class { - checkRateLimitDirect = mocks.preauthRate - checkRateLimitDirectOrThrow = mocks.operationRate - }, - getRateLimit: () => ({ maxTokens: 100, refillRate: 100, refillIntervalMs: 60_000 }), -})) -vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mocks.gate })) +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) vi.mock('@/lib/table/application/rows', () => ({ TableRowsValidationError: MockTableRowsValidationError, })) @@ -50,16 +46,10 @@ const PRINCIPAL = { const AUTH = { principal: PRINCIPAL, rolloutUserId: 'owner-1', - rateLimitSubjectIds: [`workspace:${WORKSPACE_ID}`], + rateLimitSubjectIds: ['api-key:key-1', `workspace:${WORKSPACE_ID}`], rateLimitSubscription: null, keyType: 'workspace' as const, } -const RATE = { - allowed: true, - remaining: 99, - resetAt: new Date('2026-01-01T01:00:00Z'), - retryAfterMs: 0, -} function call(body: unknown) { const request = new NextRequest( @@ -81,10 +71,10 @@ function call(body: unknown) { describe('POST /api/v2/tables/[tableId]/rows/[rowId]/enrichment/[groupId]', () => { beforeEach(() => { vi.clearAllMocks() - mocks.authenticate.mockResolvedValue(AUTH) - mocks.preauthRate.mockResolvedValue(RATE) - mocks.operationRate.mockResolvedValue(RATE) - mocks.gate.mockResolvedValue(null) + v2RouteMocks.authenticate.mockResolvedValue(AUTH) + v2RouteMocks.gate.mockResolvedValue(null) + v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) + v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) mocks.startRun.mockResolvedValue({ table: { id: 'table-1' }, dispatchId: 'dispatch-1' }) }) @@ -116,6 +106,15 @@ describe('POST /api/v2/tables/[tableId]/rows/[rowId]/enrichment/[groupId]', () = expect(await response.json()).toEqual({ data: { dispatchId: null } }) }) + it('rejects an unauthenticated request', async () => { + v2RouteMocks.authenticate.mockRejectedValueOnce(new MockV2ApiKeyUnauthenticatedError()) + + const response = await call({ workspaceId: WORKSPACE_ID }).response + + expect(response.status).toBe(401) + expect((await response.json()).error.code).toBe('UNAUTHORIZED') + }) + it('rejects a missing workspace before delegation', async () => { const response = await call({}).response 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 8a2395c073a..276751f0723 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 @@ -2,6 +2,15 @@ * @vitest-environment node */ +import { + MockV2ApiKeyUnauthenticatedError, + V2_OPERATION_RATE_LIMIT_ALLOWED, + V2_PREAUTH_RATE_LIMIT_ALLOWED, + v2ApiKeyAuthModuleMock, + v2GateModuleMock, + v2RateLimiterModuleMock, + v2RouteMocks, +} from '@sim/testing' import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -9,10 +18,6 @@ const { mocks, MockTableRowsValidationError } = vi.hoisted(() => { class MockTableRowsValidationError extends Error {} return { mocks: { - authenticate: vi.fn(), - preauthRate: vi.fn(), - operationRate: vi.fn(), - gate: vi.fn(), readRow: vi.fn(), updateRow: vi.fn(), deleteRow: vi.fn(), @@ -21,18 +26,9 @@ const { mocks, MockTableRowsValidationError } = vi.hoisted(() => { } }) -vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => ({ - authenticateV2ApiKey: mocks.authenticate, - V2ApiKeyUnauthenticatedError: class V2ApiKeyUnauthenticatedError extends Error {}, -})) -vi.mock('@/lib/core/rate-limiter', () => ({ - RateLimiter: class { - checkRateLimitDirect = mocks.preauthRate - checkRateLimitDirectOrThrow = mocks.operationRate - }, - getRateLimit: () => ({ maxTokens: 100, refillRate: 100, refillIntervalMs: 60_000 }), -})) -vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mocks.gate })) +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) vi.mock('@/lib/table/application/rows', () => ({ TableRowsValidationError: MockTableRowsValidationError, readTableRow: { operation: { id: 'tables.rows.read' }, execute: mocks.readRow }, @@ -53,16 +49,10 @@ const PRINCIPAL = { const AUTH = { principal: PRINCIPAL, rolloutUserId: 'owner-1', - rateLimitSubjectIds: [`workspace:${WORKSPACE_ID}`], + rateLimitSubjectIds: ['api-key:key-1', `workspace:${WORKSPACE_ID}`], rateLimitSubscription: null, keyType: 'workspace' as const, } -const RATE = { - allowed: true, - remaining: 99, - resetAt: new Date('2026-01-01T01:00:00Z'), - retryAfterMs: 0, -} const TABLE = { id: 'table-1', workspaceId: WORKSPACE_ID, @@ -93,10 +83,10 @@ function request(method: 'GET' | 'PATCH' | 'DELETE', body?: unknown) { describe('/api/v2/tables/[tableId]/rows/[rowId]', () => { beforeEach(() => { vi.clearAllMocks() - mocks.authenticate.mockResolvedValue(AUTH) - mocks.preauthRate.mockResolvedValue(RATE) - mocks.operationRate.mockResolvedValue(RATE) - mocks.gate.mockResolvedValue(null) + v2RouteMocks.authenticate.mockResolvedValue(AUTH) + v2RouteMocks.gate.mockResolvedValue(null) + v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) + v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) mocks.readRow.mockResolvedValue({ table: TABLE, row: ROW }) mocks.updateRow.mockResolvedValue({ table: TABLE, row: ROW, changed: true }) mocks.deleteRow.mockResolvedValue({ table: TABLE, deletedRowId: ROW.id }) @@ -120,6 +110,15 @@ describe('/api/v2/tables/[tableId]/rows/[rowId]', () => { }) }) + it('rejects an unauthenticated request', async () => { + v2RouteMocks.authenticate.mockRejectedValueOnce(new MockV2ApiKeyUnauthenticatedError()) + + const response = await GET(request('GET'), CONTEXT) + + expect(response.status).toBe(401) + expect((await response.json()).error.code).toBe('UNAUTHORIZED') + }) + it('updates through the shared use case with the exact patch', async () => { const req = request('PATCH', { workspaceId: WORKSPACE_ID, data: { name: 'Ada' } }) const response = await PATCH(req, CONTEXT) diff --git a/apps/sim/app/api/v2/tables/[tableId]/rows/find/route.test.ts b/apps/sim/app/api/v2/tables/[tableId]/rows/find/route.test.ts index e86f657c272..293935428a1 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/rows/find/route.test.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/rows/find/route.test.ts @@ -2,6 +2,15 @@ * @vitest-environment node */ +import { + MockV2ApiKeyUnauthenticatedError, + V2_OPERATION_RATE_LIMIT_ALLOWED, + V2_PREAUTH_RATE_LIMIT_ALLOWED, + v2ApiKeyAuthModuleMock, + v2GateModuleMock, + v2RateLimiterModuleMock, + v2RouteMocks, +} from '@sim/testing' import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -9,33 +18,21 @@ const { mocks, MockTableRowsValidationError } = vi.hoisted(() => { class MockTableRowsValidationError extends Error {} return { mocks: { - authenticate: vi.fn(), - preauthRate: vi.fn(), - operationRate: vi.fn(), - gate: vi.fn(), findRows: vi.fn(), }, MockTableRowsValidationError, } }) -vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => ({ - authenticateV2ApiKey: mocks.authenticate, - V2ApiKeyUnauthenticatedError: class V2ApiKeyUnauthenticatedError extends Error {}, -})) -vi.mock('@/lib/core/rate-limiter', () => ({ - RateLimiter: class { - checkRateLimitDirect = mocks.preauthRate - checkRateLimitDirectOrThrow = mocks.operationRate - }, - getRateLimit: () => ({ maxTokens: 100, refillRate: 100, refillIntervalMs: 60_000 }), -})) -vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mocks.gate })) +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) vi.mock('@/lib/table/application/rows', () => ({ TableRowsValidationError: MockTableRowsValidationError, findTableRows: { operation: { id: 'tables.rows.find' }, execute: mocks.findRows }, })) +import { v2Error } from '@/app/api/v2/lib/response' import { POST } from '@/app/api/v2/tables/[tableId]/rows/find/route' const WORKSPACE_ID = 'workspace-1' @@ -47,16 +44,10 @@ const PRINCIPAL = { const AUTH = { principal: PRINCIPAL, rolloutUserId: 'owner-1', - rateLimitSubjectIds: [`workspace:${WORKSPACE_ID}`], + rateLimitSubjectIds: ['api-key:key-1', `workspace:${WORKSPACE_ID}`], rateLimitSubscription: null, keyType: 'workspace' as const, } -const RATE = { - allowed: true, - remaining: 99, - resetAt: new Date('2026-01-01T01:00:00Z'), - retryAfterMs: 0, -} const TABLE = { id: 'table-1', workspaceId: WORKSPACE_ID, @@ -78,10 +69,10 @@ function call(body: unknown) { describe('POST /api/v2/tables/[tableId]/rows/find', () => { beforeEach(() => { vi.clearAllMocks() - mocks.authenticate.mockResolvedValue(AUTH) - mocks.preauthRate.mockResolvedValue(RATE) - mocks.operationRate.mockResolvedValue(RATE) - mocks.gate.mockResolvedValue(null) + v2RouteMocks.authenticate.mockResolvedValue(AUTH) + v2RouteMocks.gate.mockResolvedValue(null) + v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) + v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) mocks.findRows.mockResolvedValue({ table: TABLE, matches: [{ ordinal: 3, rowId: 'row-1', column: 'column-name' }], @@ -119,17 +110,25 @@ describe('POST /api/v2/tables/[tableId]/rows/find', () => { const response = await call({ workspaceId: WORKSPACE_ID, q: '' }).response expect(response.status).toBe(400) - expect(mocks.authenticate).toHaveBeenCalledOnce() + expect(v2RouteMocks.authenticate).toHaveBeenCalledOnce() expect(mocks.findRows).not.toHaveBeenCalled() }) it('stops at the rollout gate before the shared use case', async () => { - const { v2Error } = await import('@/app/api/v2/lib/response') - mocks.gate.mockResolvedValue(v2Error('NOT_FOUND', 'Not found')) + v2RouteMocks.gate.mockResolvedValue(v2Error('NOT_FOUND', 'Not found')) const response = await call({ workspaceId: WORKSPACE_ID, q: 'ada' }).response expect(response.status).toBe(404) expect(mocks.findRows).not.toHaveBeenCalled() }) + + it('rejects an unauthenticated request', async () => { + v2RouteMocks.authenticate.mockRejectedValueOnce(new MockV2ApiKeyUnauthenticatedError()) + + const response = await call({ workspaceId: WORKSPACE_ID, q: 'ada' }).response + + expect(response.status).toBe(401) + expect((await response.json()).error.code).toBe('UNAUTHORIZED') + }) }) 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 9c4eaa74426..3d7ef8d5271 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 @@ -2,6 +2,15 @@ * @vitest-environment node */ +import { + MockV2ApiKeyUnauthenticatedError, + V2_OPERATION_RATE_LIMIT_ALLOWED, + V2_PREAUTH_RATE_LIMIT_ALLOWED, + v2ApiKeyAuthModuleMock, + v2GateModuleMock, + v2RateLimiterModuleMock, + v2RouteMocks, +} from '@sim/testing' import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -9,10 +18,6 @@ const { mocks, MockTableRowsValidationError } = vi.hoisted(() => { class MockTableRowsValidationError extends Error {} return { mocks: { - authenticate: vi.fn(), - preauthRate: vi.fn(), - operationRate: vi.fn(), - gate: vi.fn(), listRows: vi.fn(), createRows: vi.fn(), updateRows: vi.fn(), @@ -22,18 +27,9 @@ const { mocks, MockTableRowsValidationError } = vi.hoisted(() => { } }) -vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => ({ - authenticateV2ApiKey: mocks.authenticate, - V2ApiKeyUnauthenticatedError: class V2ApiKeyUnauthenticatedError extends Error {}, -})) -vi.mock('@/lib/core/rate-limiter', () => ({ - RateLimiter: class { - checkRateLimitDirect = mocks.preauthRate - checkRateLimitDirectOrThrow = mocks.operationRate - }, - getRateLimit: () => ({ maxTokens: 100, refillRate: 100, refillIntervalMs: 60_000 }), -})) -vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mocks.gate })) +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) vi.mock('@/lib/table/application/rows', () => ({ TableRowsValidationError: MockTableRowsValidationError, listTableRows: { operation: { id: 'tables.rows.list' }, execute: mocks.listRows }, @@ -53,16 +49,10 @@ const PRINCIPAL = { const AUTH = { principal: PRINCIPAL, rolloutUserId: 'owner-1', - rateLimitSubjectIds: [`workspace:${WORKSPACE_ID}`], + rateLimitSubjectIds: ['api-key:key-1', `workspace:${WORKSPACE_ID}`], rateLimitSubscription: null, keyType: 'workspace' as const, } -const RATE = { - allowed: true, - remaining: 99, - resetAt: new Date('2026-01-01T01:00:00Z'), - retryAfterMs: 0, -} const TABLE = { id: 'table-1', workspaceId: WORKSPACE_ID, @@ -90,10 +80,10 @@ function request(method: 'GET' | 'POST' | 'PATCH' | 'DELETE', body?: unknown, qu describe('/api/v2/tables/[tableId]/rows', () => { beforeEach(() => { vi.clearAllMocks() - mocks.authenticate.mockResolvedValue(AUTH) - mocks.preauthRate.mockResolvedValue(RATE) - mocks.operationRate.mockResolvedValue(RATE) - mocks.gate.mockResolvedValue(null) + v2RouteMocks.authenticate.mockResolvedValue(AUTH) + v2RouteMocks.gate.mockResolvedValue(null) + v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) + v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) mocks.listRows.mockResolvedValue({ table: TABLE, rows: [ROW], nextCursor: null }) mocks.createRows.mockResolvedValue({ kind: 'single', table: TABLE, row: ROW }) mocks.updateRows.mockResolvedValue({ @@ -139,6 +129,18 @@ describe('/api/v2/tables/[tableId]/rows', () => { expect((await response.json()).nextCursor).toBe('next-native-cursor') }) + it('rejects an unauthenticated request', async () => { + v2RouteMocks.authenticate.mockRejectedValueOnce(new MockV2ApiKeyUnauthenticatedError()) + + const response = await GET( + request('GET', undefined, `?workspaceId=${WORKSPACE_ID}&limit=25`), + CONTEXT + ) + + expect(response.status).toBe(401) + expect((await response.json()).error.code).toBe('UNAUTHORIZED') + }) + 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') 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 ddcd5106649..96e633d0834 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 @@ -2,6 +2,15 @@ * @vitest-environment node */ +import { + MockV2ApiKeyUnauthenticatedError, + V2_OPERATION_RATE_LIMIT_ALLOWED, + V2_PREAUTH_RATE_LIMIT_ALLOWED, + v2ApiKeyAuthModuleMock, + v2GateModuleMock, + v2RateLimiterModuleMock, + v2RouteMocks, +} from '@sim/testing' import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -9,28 +18,15 @@ const { mocks, MockTableRowsValidationError } = vi.hoisted(() => { class MockTableRowsValidationError extends Error {} return { mocks: { - authenticate: vi.fn(), - preauthRate: vi.fn(), - operationRate: vi.fn(), - gate: vi.fn(), upsertRow: vi.fn(), }, MockTableRowsValidationError, } }) -vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => ({ - authenticateV2ApiKey: mocks.authenticate, - V2ApiKeyUnauthenticatedError: class V2ApiKeyUnauthenticatedError extends Error {}, -})) -vi.mock('@/lib/core/rate-limiter', () => ({ - RateLimiter: class { - checkRateLimitDirect = mocks.preauthRate - checkRateLimitDirectOrThrow = mocks.operationRate - }, - getRateLimit: () => ({ maxTokens: 100, refillRate: 100, refillIntervalMs: 60_000 }), -})) -vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mocks.gate })) +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) vi.mock('@/lib/table/application/rows', () => ({ TableRowsValidationError: MockTableRowsValidationError, upsertTableRow: { operation: { id: 'tables.rows.upsert' }, execute: mocks.upsertRow }, @@ -47,16 +43,10 @@ const PRINCIPAL = { const AUTH = { principal: PRINCIPAL, rolloutUserId: 'owner-1', - rateLimitSubjectIds: [`workspace:${WORKSPACE_ID}`], + rateLimitSubjectIds: ['api-key:key-1', `workspace:${WORKSPACE_ID}`], rateLimitSubscription: null, keyType: 'workspace' as const, } -const RATE = { - allowed: true, - remaining: 99, - resetAt: new Date('2026-01-01T01:00:00Z'), - retryAfterMs: 0, -} const TABLE = { id: 'table-1', workspaceId: WORKSPACE_ID, @@ -72,10 +62,10 @@ const ROW = { describe('POST /api/v2/tables/[tableId]/rows/upsert', () => { beforeEach(() => { vi.clearAllMocks() - mocks.authenticate.mockResolvedValue(AUTH) - mocks.preauthRate.mockResolvedValue(RATE) - mocks.operationRate.mockResolvedValue(RATE) - mocks.gate.mockResolvedValue(null) + v2RouteMocks.authenticate.mockResolvedValue(AUTH) + v2RouteMocks.gate.mockResolvedValue(null) + v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) + v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) mocks.upsertRow.mockResolvedValue({ table: TABLE, row: ROW, operation: 'update' }) }) @@ -117,6 +107,26 @@ describe('POST /api/v2/tables/[tableId]/rows/upsert', () => { }) }) + it('rejects an unauthenticated request', async () => { + v2RouteMocks.authenticate.mockRejectedValueOnce(new MockV2ApiKeyUnauthenticatedError()) + + const request = new NextRequest('http://localhost/api/v2/tables/table-1/rows/upsert', { + method: 'POST', + headers: { 'content-type': 'application/json', 'x-api-key': 'secret' }, + body: JSON.stringify({ + workspaceId: WORKSPACE_ID, + data: { email: 'ada@example.com' }, + conflictTarget: 'email', + }), + }) + const response = await POST(request, { + params: Promise.resolve({ tableId: 'table-1' }), + }) + + expect(response.status).toBe(401) + expect((await response.json()).error.code).toBe('UNAUTHORIZED') + }) + it('rejects an empty conflict target before delegation', async () => { const request = new NextRequest('http://localhost/api/v2/tables/table-1/rows/upsert', { method: 'POST', 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 fa253c093f4..9fdecfd9985 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 @@ -2,32 +2,28 @@ * @vitest-environment node */ +import { + MockV2ApiKeyUnauthenticatedError, + V2_OPERATION_RATE_LIMIT_ALLOWED, + V2_PREAUTH_RATE_LIMIT_ALLOWED, + v2ApiKeyAuthModuleMock, + v2GateModuleMock, + v2RateLimiterModuleMock, + v2RouteMocks, +} from '@sim/testing' import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ - authenticate: vi.fn(), - preauthRate: vi.fn(), - operationRate: vi.fn(), - gate: vi.fn(), read: vi.fn(), update: vi.fn(), remove: vi.fn(), email: vi.fn(), })) -vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => ({ - authenticateV2ApiKey: mocks.authenticate, - V2ApiKeyUnauthenticatedError: class V2ApiKeyUnauthenticatedError extends Error {}, -})) -vi.mock('@/lib/core/rate-limiter', () => ({ - RateLimiter: class { - checkRateLimitDirect = mocks.preauthRate - checkRateLimitDirectOrThrow = mocks.operationRate - }, - getRateLimit: () => ({ maxTokens: 100, refillRate: 100, refillIntervalMs: 60_000 }), -})) -vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mocks.gate })) +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) vi.mock('@/lib/table/application/views', () => ({ readTableViewUseCase: { operation: { id: 'tables.views.read' }, execute: mocks.read }, updateTableViewUseCase: { operation: { id: 'tables.views.update' }, execute: mocks.update }, @@ -46,16 +42,10 @@ const principal = { const auth = { principal, rolloutUserId: 'owner-1', - rateLimitSubjectIds: [`workspace:${WORKSPACE_ID}`], + rateLimitSubjectIds: ['api-key:key-1', `workspace:${WORKSPACE_ID}`], rateLimitSubscription: null, keyType: 'workspace' as const, } -const rate = { - allowed: true, - remaining: 99, - resetAt: new Date('2026-01-01T01:00:00.000Z'), - retryAfterMs: 0, -} const view = { id: 'view-1', tableId: 'table-1', @@ -82,10 +72,10 @@ function request(method: 'GET' | 'PATCH' | 'DELETE', body?: unknown) { describe('/api/v2/tables/[tableId]/views/[viewId]', () => { beforeEach(() => { vi.clearAllMocks() - mocks.authenticate.mockResolvedValue(auth) - mocks.preauthRate.mockResolvedValue(rate) - mocks.operationRate.mockResolvedValue(rate) - mocks.gate.mockResolvedValue(null) + v2RouteMocks.authenticate.mockResolvedValue(auth) + 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.remove.mockResolvedValue({ viewId: 'view-1' }) @@ -122,4 +112,13 @@ describe('/api/v2/tables/[tableId]/views/[viewId]', () => { expect(await response.json()).toEqual({ data: { id: 'view-1', deleted: true } }) expect(mocks.remove).toHaveBeenCalledOnce() }) + + it('rejects an unauthenticated request', async () => { + v2RouteMocks.authenticate.mockRejectedValueOnce(new MockV2ApiKeyUnauthenticatedError()) + + const response = await GET(request('GET'), context) + + expect(response.status).toBe(401) + expect((await response.json()).error.code).toBe('UNAUTHORIZED') + }) }) 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 3f2e6a20288..e89fe4a4fa2 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 @@ -2,32 +2,28 @@ * @vitest-environment node */ +import { + MockV2ApiKeyUnauthenticatedError, + V2_OPERATION_RATE_LIMIT_ALLOWED, + V2_PREAUTH_RATE_LIMIT_ALLOWED, + v2ApiKeyAuthModuleMock, + v2GateModuleMock, + v2RateLimiterModuleMock, + v2RouteMocks, +} from '@sim/testing' import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ - authenticate: vi.fn(), - preauthRate: vi.fn(), - operationRate: vi.fn(), - gate: vi.fn(), list: vi.fn(), create: vi.fn(), emails: vi.fn(), email: vi.fn(), })) -vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => ({ - authenticateV2ApiKey: mocks.authenticate, - V2ApiKeyUnauthenticatedError: class V2ApiKeyUnauthenticatedError extends Error {}, -})) -vi.mock('@/lib/core/rate-limiter', () => ({ - RateLimiter: class { - checkRateLimitDirect = mocks.preauthRate - checkRateLimitDirectOrThrow = mocks.operationRate - }, - getRateLimit: () => ({ maxTokens: 100, refillRate: 100, refillIntervalMs: 60_000 }), -})) -vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mocks.gate })) +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) vi.mock('@/lib/table/application/views', () => ({ listTableViewsUseCase: { operation: { id: 'tables.views.list' }, execute: mocks.list }, createTableViewUseCase: { operation: { id: 'tables.views.create' }, execute: mocks.create }, @@ -49,16 +45,10 @@ const principal = { const auth = { principal, rolloutUserId: 'owner-1', - rateLimitSubjectIds: [`workspace:${WORKSPACE_ID}`], + rateLimitSubjectIds: ['api-key:key-1', `workspace:${WORKSPACE_ID}`], rateLimitSubscription: null, keyType: 'workspace' as const, } -const rate = { - allowed: true, - remaining: 99, - resetAt: new Date('2026-01-01T01:00:00.000Z'), - retryAfterMs: 0, -} const view = { id: 'view-1', tableId: 'table-1', @@ -74,10 +64,10 @@ const context = { params: Promise.resolve({ tableId: 'table-1' }) } describe('/api/v2/tables/[tableId]/views', () => { beforeEach(() => { vi.clearAllMocks() - mocks.authenticate.mockResolvedValue(auth) - mocks.preauthRate.mockResolvedValue(rate) - mocks.operationRate.mockResolvedValue(rate) - mocks.gate.mockResolvedValue(null) + v2RouteMocks.authenticate.mockResolvedValue(auth) + 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.emails.mockResolvedValue(new Map([['user-1', 'user@example.com']])) @@ -129,4 +119,18 @@ describe('/api/v2/tables/[tableId]/views', () => { request: req, }) }) + + it('rejects an unauthenticated request', async () => { + v2RouteMocks.authenticate.mockRejectedValueOnce(new MockV2ApiKeyUnauthenticatedError()) + + const response = await GET( + new NextRequest( + `http://localhost:3000/api/v2/tables/table-1/views?workspaceId=${WORKSPACE_ID}` + ), + context + ) + + expect(response.status).toBe(401) + expect((await response.json()).error.code).toBe('UNAUTHORIZED') + }) }) diff --git a/apps/sim/app/api/v2/tables/folders/route.test.ts b/apps/sim/app/api/v2/tables/folders/route.test.ts index 866438a058c..c0f2f244068 100644 --- a/apps/sim/app/api/v2/tables/folders/route.test.ts +++ b/apps/sim/app/api/v2/tables/folders/route.test.ts @@ -2,32 +2,28 @@ * @vitest-environment node */ +import { + MockV2ApiKeyUnauthenticatedError, + V2_OPERATION_RATE_LIMIT_ALLOWED, + V2_PREAUTH_RATE_LIMIT_ALLOWED, + v2ApiKeyAuthModuleMock, + v2GateModuleMock, + v2RateLimiterModuleMock, + v2RouteMocks, +} from '@sim/testing' import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ - authenticate: vi.fn(), - preauthRate: vi.fn(), - operationRate: vi.fn(), - gate: vi.fn(), list: vi.fn(), create: vi.fn(), update: vi.fn(), remove: vi.fn(), })) -vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => ({ - authenticateV2ApiKey: mocks.authenticate, - V2ApiKeyUnauthenticatedError: class V2ApiKeyUnauthenticatedError extends Error {}, -})) -vi.mock('@/lib/core/rate-limiter', () => ({ - RateLimiter: class { - checkRateLimitDirect = mocks.preauthRate - checkRateLimitDirectOrThrow = mocks.operationRate - }, - getRateLimit: () => ({ maxTokens: 100, refillRate: 100, refillIntervalMs: 60_000 }), -})) -vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mocks.gate })) +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) vi.mock('@/lib/table/application/folders', () => ({ listTableFoldersUseCase: { operation: { id: 'tables.folders.list' }, execute: mocks.list }, createTableFolderUseCase: { operation: { id: 'tables.folders.create' }, execute: mocks.create }, @@ -46,16 +42,10 @@ const principal = { const auth = { principal, rolloutUserId: 'owner-1', - rateLimitSubjectIds: [`workspace:${WORKSPACE_ID}`], + rateLimitSubjectIds: ['api-key:key-1', `workspace:${WORKSPACE_ID}`], rateLimitSubscription: null, keyType: 'workspace' as const, } -const rate = { - allowed: true, - remaining: 99, - resetAt: new Date('2026-01-01T01:00:00.000Z'), - retryAfterMs: 0, -} const folder = { id: 'folder-1', workspaceId: WORKSPACE_ID, @@ -85,10 +75,10 @@ function request(method: 'GET' | 'POST' | 'PATCH' | 'DELETE', url: string, body? describe('/api/v2/tables/folders', () => { beforeEach(() => { vi.clearAllMocks() - mocks.authenticate.mockResolvedValue(auth) - mocks.preauthRate.mockResolvedValue(rate) - mocks.operationRate.mockResolvedValue(rate) - mocks.gate.mockResolvedValue(null) + v2RouteMocks.authenticate.mockResolvedValue(auth) + v2RouteMocks.gate.mockResolvedValue(null) + v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) + v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) mocks.list.mockResolvedValue({ folders: [folder], index }) mocks.create.mockResolvedValue({ folder, index, path: '/Reports' }) mocks.update.mockResolvedValue({ @@ -155,4 +145,13 @@ describe('/api/v2/tables/folders', () => { }, }) }) + + it('rejects an unauthenticated request', async () => { + v2RouteMocks.authenticate.mockRejectedValueOnce(new MockV2ApiKeyUnauthenticatedError()) + + const response = await GET(request('GET', `/api/v2/tables/folders?workspaceId=${WORKSPACE_ID}`)) + + expect(response.status).toBe(401) + expect((await response.json()).error.code).toBe('UNAUTHORIZED') + }) }) diff --git a/apps/sim/app/api/v2/tables/imports/[importId]/complete/route.test.ts b/apps/sim/app/api/v2/tables/imports/[importId]/complete/route.test.ts index 6964d818d6b..e9e12f184a1 100644 --- a/apps/sim/app/api/v2/tables/imports/[importId]/complete/route.test.ts +++ b/apps/sim/app/api/v2/tables/imports/[importId]/complete/route.test.ts @@ -2,29 +2,25 @@ * @vitest-environment node */ +import { + MockV2ApiKeyUnauthenticatedError, + V2_OPERATION_RATE_LIMIT_ALLOWED, + V2_PREAUTH_RATE_LIMIT_ALLOWED, + v2ApiKeyAuthModuleMock, + v2GateModuleMock, + v2RateLimiterModuleMock, + v2RouteMocks, +} from '@sim/testing' import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ - authenticate: vi.fn(), - preauthRate: vi.fn(), - operationRate: vi.fn(), - gate: vi.fn(), complete: vi.fn(), })) -vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => ({ - authenticateV2ApiKey: mocks.authenticate, - V2ApiKeyUnauthenticatedError: class V2ApiKeyUnauthenticatedError extends Error {}, -})) -vi.mock('@/lib/core/rate-limiter', () => ({ - RateLimiter: class { - checkRateLimitDirect = mocks.preauthRate - checkRateLimitDirectOrThrow = mocks.operationRate - }, - getRateLimit: () => ({ maxTokens: 100, refillRate: 100, refillIntervalMs: 60_000 }), -})) -vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mocks.gate })) +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) vi.mock('@/app/api/v2/tables/presenters', () => ({ presentV2TableImport: (tableImport: unknown) => ({ data: tableImport }), })) @@ -46,24 +42,18 @@ const principal = { const auth = { principal, rolloutUserId: 'owner-1', - rateLimitSubjectIds: [`workspace:${WORKSPACE_ID}`], + rateLimitSubjectIds: ['api-key:key-1', `workspace:${WORKSPACE_ID}`], rateLimitSubscription: null, keyType: 'workspace' as const, } -const rate = { - allowed: true, - remaining: 99, - resetAt: new Date('2026-01-01T01:00:00.000Z'), - retryAfterMs: 0, -} describe('POST /api/v2/tables/imports/[importId]/complete', () => { beforeEach(() => { vi.clearAllMocks() - mocks.authenticate.mockResolvedValue(auth) - mocks.preauthRate.mockResolvedValue(rate) - mocks.operationRate.mockResolvedValue(rate) - mocks.gate.mockResolvedValue(null) + v2RouteMocks.authenticate.mockResolvedValue(auth) + v2RouteMocks.gate.mockResolvedValue(null) + v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) + v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) }) it('delegates idempotent completion to the authorized import use case', async () => { @@ -101,4 +91,19 @@ describe('POST /api/v2/tables/imports/[importId]/complete', () => { request, }) }) + + it('rejects an unauthenticated request', async () => { + v2RouteMocks.authenticate.mockRejectedValueOnce(new MockV2ApiKeyUnauthenticatedError()) + + const response = await POST( + new NextRequest( + `http://localhost:3000/api/v2/tables/imports/import-1/complete?workspaceId=${WORKSPACE_ID}`, + { method: 'POST', headers: { 'upload-token': 'signed-upload-token' } } + ), + { params: Promise.resolve({ importId: 'import-1' }) } + ) + + expect(response.status).toBe(401) + expect((await response.json()).error.code).toBe('UNAUTHORIZED') + }) }) 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 fd1f7672193..44fe854fa3a 100644 --- a/apps/sim/app/api/v2/tables/imports/route.test.ts +++ b/apps/sim/app/api/v2/tables/imports/route.test.ts @@ -2,29 +2,25 @@ * @vitest-environment node */ +import { + MockV2ApiKeyUnauthenticatedError, + V2_OPERATION_RATE_LIMIT_ALLOWED, + V2_PREAUTH_RATE_LIMIT_ALLOWED, + v2ApiKeyAuthModuleMock, + v2GateModuleMock, + v2RateLimiterModuleMock, + v2RouteMocks, +} from '@sim/testing' import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ - authenticate: vi.fn(), - preauthRate: vi.fn(), - operationRate: vi.fn(), - gate: vi.fn(), create: vi.fn(), })) -vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => ({ - authenticateV2ApiKey: mocks.authenticate, - V2ApiKeyUnauthenticatedError: class V2ApiKeyUnauthenticatedError extends Error {}, -})) -vi.mock('@/lib/core/rate-limiter', () => ({ - RateLimiter: class { - checkRateLimitDirect = mocks.preauthRate - checkRateLimitDirectOrThrow = mocks.operationRate - }, - getRateLimit: () => ({ maxTokens: 100, refillRate: 100, refillIntervalMs: 60_000 }), -})) -vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mocks.gate })) +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) vi.mock('@/app/api/v2/tables/presenters', () => ({ presentV2CreateTableImport: (tableImport: unknown) => ({ data: tableImport }), })) @@ -43,25 +39,19 @@ const principal = { const auth = { principal, rolloutUserId: 'owner-1', - rateLimitSubjectIds: [`workspace:${WORKSPACE_ID}`], + rateLimitSubjectIds: ['api-key:key-1', `workspace:${WORKSPACE_ID}`], rateLimitSubscription: null, keyType: 'workspace' as const, } -const rate = { - allowed: true, - remaining: 99, - resetAt: new Date('2026-01-01T01:00:00.000Z'), - retryAfterMs: 0, -} const timestamp = '2026-01-01T00:00:00.000Z' describe('POST /api/v2/tables/imports', () => { beforeEach(() => { vi.clearAllMocks() - mocks.authenticate.mockResolvedValue(auth) - mocks.preauthRate.mockResolvedValue(rate) - mocks.operationRate.mockResolvedValue(rate) - mocks.gate.mockResolvedValue(null) + v2RouteMocks.authenticate.mockResolvedValue(auth) + v2RouteMocks.gate.mockResolvedValue(null) + v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) + v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) }) it.each([ @@ -145,8 +135,27 @@ describe('POST /api/v2/tables/imports', () => { ) expect(response.status).toBe(400) - expect(mocks.authenticate).toHaveBeenCalled() - expect(mocks.operationRate).toHaveBeenCalled() + expect(v2RouteMocks.authenticate).toHaveBeenCalled() + expect(v2RouteMocks.operationRate).toHaveBeenCalled() expect(mocks.create).not.toHaveBeenCalled() }) + + it('rejects an unauthenticated request', async () => { + v2RouteMocks.authenticate.mockRejectedValueOnce(new MockV2ApiKeyUnauthenticatedError()) + + const response = await POST( + new NextRequest('http://localhost:3000/api/v2/tables/imports', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + workspaceId: WORKSPACE_ID, + source: { type: 'workspace_file', fileId: 'file-1' }, + target: { type: 'new', name: 'imported_data' }, + }), + }) + ) + + expect(response.status).toBe(401) + expect((await response.json()).error.code).toBe('UNAUTHORIZED') + }) }) diff --git a/apps/sim/app/api/v2/tables/route.test.ts b/apps/sim/app/api/v2/tables/route.test.ts index 831a3c69d9c..e27af471956 100644 --- a/apps/sim/app/api/v2/tables/route.test.ts +++ b/apps/sim/app/api/v2/tables/route.test.ts @@ -2,32 +2,28 @@ * @vitest-environment node */ +import { + MockV2ApiKeyUnauthenticatedError, + V2_OPERATION_RATE_LIMIT_ALLOWED, + V2_PREAUTH_RATE_LIMIT_ALLOWED, + v2ApiKeyAuthModuleMock, + v2GateModuleMock, + v2RateLimiterModuleMock, + v2RouteMocks, +} from '@sim/testing' import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ - authenticate: vi.fn(), - preauthRate: vi.fn(), - operationRate: vi.fn(), - gate: vi.fn(), list: vi.fn(), create: vi.fn(), getUserEmailsByIds: vi.fn(), getMaxRowsPerTable: vi.fn(), })) -vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => ({ - authenticateV2ApiKey: mocks.authenticate, - V2ApiKeyUnauthenticatedError: class V2ApiKeyUnauthenticatedError extends Error {}, -})) -vi.mock('@/lib/core/rate-limiter', () => ({ - RateLimiter: class { - checkRateLimitDirect = mocks.preauthRate - checkRateLimitDirectOrThrow = mocks.operationRate - }, - getRateLimit: () => ({ maxTokens: 100, refillRate: 100, refillIntervalMs: 60_000 }), -})) -vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mocks.gate })) +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) vi.mock('@/lib/table/application/tables', () => ({ listTablesUseCase: { operation: { id: 'tables.list' }, execute: mocks.list }, createTableUseCase: { operation: { id: 'tables.create' }, execute: mocks.create }, @@ -51,16 +47,10 @@ const principal = { const auth = { principal, rolloutUserId: 'owner-1', - rateLimitSubjectIds: [`workspace:${WORKSPACE_ID}`], + rateLimitSubjectIds: ['api-key:key-1', `workspace:${WORKSPACE_ID}`], rateLimitSubscription: null, keyType: 'workspace' as const, } -const rate = { - allowed: true, - remaining: 99, - resetAt: new Date('2026-01-01T01:00:00.000Z'), - retryAfterMs: 0, -} const table = { id: 'table-1', workspaceId: WORKSPACE_ID, @@ -89,10 +79,10 @@ const table = { describe('/api/v2/tables', () => { beforeEach(() => { vi.clearAllMocks() - mocks.authenticate.mockResolvedValue(auth) - mocks.preauthRate.mockResolvedValue(rate) - mocks.operationRate.mockResolvedValue(rate) - mocks.gate.mockResolvedValue(null) + v2RouteMocks.authenticate.mockResolvedValue(auth) + v2RouteMocks.gate.mockResolvedValue(null) + v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) + v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) mocks.getUserEmailsByIds.mockResolvedValue(new Map([['owner-1', 'owner@example.com']])) mocks.getMaxRowsPerTable.mockResolvedValue(5000) mocks.list.mockResolvedValue({ @@ -134,13 +124,24 @@ describe('/api/v2/tables', () => { const response = await GET(new NextRequest('http://localhost:3000/api/v2/tables')) expect(response.status).toBe(400) - expect(mocks.authenticate).toHaveBeenCalled() - expect(mocks.operationRate).toHaveBeenCalled() + expect(v2RouteMocks.authenticate).toHaveBeenCalled() + expect(v2RouteMocks.operationRate).toHaveBeenCalled() expect(mocks.list).not.toHaveBeenCalled() }) + it('rejects an unauthenticated request', async () => { + v2RouteMocks.authenticate.mockRejectedValueOnce(new MockV2ApiKeyUnauthenticatedError()) + + const response = await GET( + new NextRequest(`http://localhost:3000/api/v2/tables?workspaceId=${WORKSPACE_ID}&limit=25`) + ) + + expect(response.status).toBe(401) + expect((await response.json()).error.code).toBe('UNAUTHORIZED') + }) + it('maps operation rate-limit infrastructure failures to service unavailable', async () => { - mocks.operationRate.mockRejectedValueOnce(new Error('rate store unavailable')) + v2RouteMocks.operationRate.mockRejectedValueOnce(new Error('rate store unavailable')) const response = await GET( new NextRequest(`http://localhost:3000/api/v2/tables?workspaceId=${WORKSPACE_ID}&limit=25`) @@ -181,7 +182,7 @@ describe('/api/v2/tables', () => { }) }) - it('rejects required in a table column before calling the use case', async () => { + it('forwards required on a table column to the use case', async () => { const request = new NextRequest('http://localhost:3000/api/v2/tables', { method: 'POST', headers: { 'content-type': 'application/json', 'x-api-key': 'secret' }, @@ -193,6 +194,28 @@ describe('/api/v2/tables', () => { }) const response = await POST(request) + expect(response.status).toBe(201) + expect(mocks.create).toHaveBeenCalledWith( + expect.objectContaining({ + input: expect.objectContaining({ + schema: { columns: [{ name: 'Name', type: 'string', required: true }] }, + }), + }) + ) + }) + + 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', + headers: { 'content-type': 'application/json', 'x-api-key': 'secret' }, + body: JSON.stringify({ + workspaceId: WORKSPACE_ID, + name: 'Contacts', + schema: { columns: [{ name: 'Name', type: 'string', requried: true }] }, + }), + }) + const response = await POST(request) + expect(response.status).toBe(400) expect(mocks.create).not.toHaveBeenCalled() }) diff --git a/apps/sim/app/api/v2/workflows/[id]/deployment/route.test.ts b/apps/sim/app/api/v2/workflows/[id]/deployment/route.test.ts new file mode 100644 index 00000000000..54f7bc7664d --- /dev/null +++ b/apps/sim/app/api/v2/workflows/[id]/deployment/route.test.ts @@ -0,0 +1,207 @@ +/** + * @vitest-environment node + */ +import { + MockV2ApiKeyUnauthenticatedError, + V2_OPERATION_RATE_LIMIT_ALLOWED, + V2_PREAUTH_RATE_LIMIT_ALLOWED, + v2ApiKeyAuthModuleMock, + v2GateModuleMock, + v2RateLimiterModuleMock, + v2RouteMocks, +} from '@sim/testing' +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + resolvePermission: vi.fn(), + resolveWorkflowContext: vi.fn(), + getWorkflowDeploymentSummary: vi.fn(), + checkNeedsRedeployment: vi.fn(), +})) + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (actual: string | null, required: string) => { + const rank = { read: 1, write: 2, admin: 3 } as const + return ( + actual !== null && rank[actual as keyof typeof rank] >= rank[required as keyof typeof rank] + ) + }, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) +vi.mock('@/lib/workflows/application/context', () => ({ + resolveActiveWorkflowApplicationContext: mocks.resolveWorkflowContext, +})) +vi.mock('@/lib/workflows/orchestration/deploy', () => ({ + getWorkflowDeploymentSummary: mocks.getWorkflowDeploymentSummary, + performActivateVersion: vi.fn(), + performFullDeploy: vi.fn(), + performFullUndeploy: vi.fn(), + performRevertToVersion: vi.fn(), +})) +vi.mock('@/lib/workflows/deployment-status', () => ({ + checkNeedsRedeployment: mocks.checkNeedsRedeployment, +})) +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 { GET } from '@/app/api/v2/workflows/[id]/deployment/route' + +const auth = { + principal: { + kind: 'personal_api_key' as const, + userId: 'user-1', + keyId: 'personal-key-1', + }, + rolloutUserId: 'user-1', + rateLimitSubjectIds: ['api-key:personal-key-1', 'user:user-1'] as const, + rateLimitSubscription: null, + keyType: 'personal' as const, +} + +const activeDeployment = { + deploymentVersionId: 'depver-2', + version: 2, + deployedAt: '2026-08-01T00:00:00.000Z', +} + +const latestDeploymentAttempt = { + id: 'op-2', + deploymentVersionId: 'depver-2', + version: 2, + action: 'deploy' as const, + status: 'active' as const, + isCurrent: true, + readiness: { + webhooks: 'not_applicable' as const, + schedules: 'not_applicable' as const, + mcp: 'not_applicable' as const, + }, + requestedAt: '2026-08-01T00:00:00.000Z', + activatedAt: '2026-08-01T00:00:01.000Z', + error: null, +} + +/** + * `workflow.deployedAt` carries a stale timestamp from a deployment that was + * later undeployed — the presenter must never fall back to it. + */ +const workflowContext = { + workspaceId: 'workspace-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', + workflowId: 'workflow-1', + workflow: { + id: 'workflow-1', + workspaceId: 'workspace-1', + deployedAt: new Date('2025-01-01T00:00:00.000Z'), + }, +} + +async function get() { + const request = new NextRequest('http://localhost/api/v2/workflows/workflow-1/deployment') + return GET(request, { params: Promise.resolve({ id: 'workflow-1' }) }) +} + +describe('GET /api/v2/workflows/[id]/deployment', () => { + beforeEach(() => { + vi.clearAllMocks() + v2RouteMocks.authenticate.mockResolvedValue(auth) + v2RouteMocks.gate.mockResolvedValue(null) + v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) + v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) + mocks.resolvePermission.mockResolvedValue('read') + mocks.resolveWorkflowContext.mockResolvedValue(workflowContext) + mocks.getWorkflowDeploymentSummary.mockResolvedValue({ + activeDeployment, + latestDeploymentAttempt, + warnings: undefined, + }) + mocks.checkNeedsRedeployment.mockResolvedValue(true) + }) + + it('publishes draft-versus-live drift and the latest attempt after canonical authorization', async () => { + const response = await get() + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + data: { + id: 'workflow-1', + isDeployed: true, + needsRedeployment: true, + deployedAt: '2026-08-01T00:00:00.000Z', + warnings: [], + activeDeployment, + latestDeploymentAttempt, + }, + }) + expect(mocks.resolveWorkflowContext).toHaveBeenCalledBefore(mocks.getWorkflowDeploymentSummary) + }) + + it('carries the failed attempt error payload when nothing is live', async () => { + mocks.getWorkflowDeploymentSummary.mockResolvedValue({ + activeDeployment: null, + latestDeploymentAttempt: { + ...latestDeploymentAttempt, + status: 'failed' as const, + activatedAt: null, + error: { + code: 'webhook_conflict', + message: 'Webhook path already in use', + retryable: false, + }, + }, + warnings: ['Deployment attempt failed'], + }) + + const response = await get() + const body = await response.json() + + expect(response.status).toBe(200) + expect(body.data.isDeployed).toBe(false) + expect(body.data.needsRedeployment).toBe(false) + expect(body.data.deployedAt).toBeNull() + expect(body.data.warnings).toEqual(['Deployment attempt failed']) + expect(body.data.latestDeploymentAttempt.error).toEqual({ + code: 'webhook_conflict', + message: 'Webhook path already in use', + retryable: false, + }) + expect(mocks.checkNeedsRedeployment).not.toHaveBeenCalled() + }) + + it('never reports a deploy time from the stale workflow column once nothing is live', async () => { + mocks.getWorkflowDeploymentSummary.mockResolvedValue({ + activeDeployment: null, + latestDeploymentAttempt: null, + warnings: undefined, + }) + + const response = await get() + const body = await response.json() + + expect(response.status).toBe(200) + expect(body.data.deployedAt).toBeNull() + }) + + it('conceals a workflow the caller cannot reach as 404', async () => { + mocks.resolvePermission.mockResolvedValue(null) + + const response = await get() + + expect(response.status).toBe(404) + expect((await response.json()).error.code).toBe('NOT_FOUND') + expect(mocks.getWorkflowDeploymentSummary).not.toHaveBeenCalled() + }) + + it('rejects an unauthenticated request', async () => { + v2RouteMocks.authenticate.mockRejectedValueOnce(new MockV2ApiKeyUnauthenticatedError()) + + const response = await get() + + expect(response.status).toBe(401) + expect((await response.json()).error.code).toBe('UNAUTHORIZED') + }) +}) diff --git a/apps/sim/app/api/v2/workflows/[id]/deployment/route.ts b/apps/sim/app/api/v2/workflows/[id]/deployment/route.ts new file mode 100644 index 00000000000..52224636031 --- /dev/null +++ b/apps/sim/app/api/v2/workflows/[id]/deployment/route.ts @@ -0,0 +1,44 @@ +import { v2GetWorkflowDeploymentContract } from '@/lib/api/contracts/v2/workflows' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2WorkflowErrorPolicies } from '@/lib/workflows/api' +import { readWorkflowDeploymentStatus } from '@/lib/workflows/application/deployments' +import { workflowOperations } from '@/lib/workflows/application/operations' + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +/** + * GET /api/v2/workflows/[id]/deployment — Read current deployment state. + * + * The deploy, undeploy, and rollback responses are the only other place this + * state is published, so a caller that lost one — or that polls from a + * different process — had no way to ask. `needsRedeployment` is exposed here + * only: it compares the draft against the live version, so it is meaningless on + * the response of the mutation that just made them equal. + * + * `deployedAt` comes from the active deployment version, which always carries + * one. The workflow's own `deployed_at` column is deliberately not used as a + * fallback: it retains the timestamp of a deployment that has since been + * undeployed, so reading it would report a deploy time alongside + * `isDeployed: false`. + */ +export const GET = defineV2JsonRoute({ + contract: v2GetWorkflowDeploymentContract, + auth: v2ApiKeyAuth, + operation: workflowOperations.read, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2WorkflowErrorPolicies.concealWorkflowAuthorization, + mapInput: ({ params }) => ({ workflowId: params.id }), + useCase: readWorkflowDeploymentStatus, + present: (result) => ({ + data: { + id: result.workflow.id, + isDeployed: result.isDeployed, + needsRedeployment: result.needsRedeployment, + deployedAt: result.activeDeployment?.deployedAt ?? null, + warnings: result.warnings ?? [], + activeDeployment: result.activeDeployment ?? null, + latestDeploymentAttempt: result.latestDeploymentAttempt ?? null, + }, + }), +}) 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 ae363a04dee..8e44e5f35f5 100644 --- a/apps/sim/app/api/v2/workflows/[id]/execute/route.ts +++ b/apps/sim/app/api/v2/workflows/[id]/execute/route.ts @@ -20,6 +20,7 @@ import { import type { V2ApiKeyPrincipal } from '@/lib/api/server/routes/v2-api-key-auth' import { tryAdmit } from '@/lib/core/admission/gate' import { ADMISSION_ERROR_DESCRIPTOR } from '@/lib/core/admission/transient-failure' +import type { ForbiddenDetailCode } from '@/lib/core/application' import { generateRequestId } from '@/lib/core/utils/request' import { getBaseUrl } from '@/lib/core/utils/urls' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -274,7 +275,9 @@ export const POST = withRouteHandler( return v2Error('NOT_FOUND', 'Workflow not found') } if (workflowAuthorization.status === 403) { - return v2Error('FORBIDDEN', 'Insufficient workspace permissions') + return v2Error('FORBIDDEN', 'Insufficient workspace permissions', { + details: { code: 'INSUFFICIENT_WORKSPACE_ROLE' satisfies ForbiddenDetailCode }, + }) } throw new Error( `Unexpected workflow authorization status: ${workflowAuthorization.status}` diff --git a/apps/sim/app/api/v2/workflows/[id]/route.test.ts b/apps/sim/app/api/v2/workflows/[id]/route.test.ts index d16cabfc429..72003b5574f 100644 --- a/apps/sim/app/api/v2/workflows/[id]/route.test.ts +++ b/apps/sim/app/api/v2/workflows/[id]/route.test.ts @@ -1,17 +1,22 @@ /** * @vitest-environment node */ +import { + MockV2ApiKeyUnauthenticatedError, + V2_OPERATION_RATE_LIMIT_ALLOWED, + V2_PREAUTH_RATE_LIMIT_ALLOWED, + v2ApiKeyAuthModuleMock, + v2GateModuleMock, + v2RateLimiterModuleMock, + v2RouteMocks, +} from '@sim/testing' import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ - authenticateV2ApiKey: vi.fn(), - checkRateLimitDirect: vi.fn(), - checkRateLimitDirectOrThrow: vi.fn(), readWorkflow: vi.fn(), updateWorkflow: vi.fn(), deleteWorkflow: vi.fn(), - gate: vi.fn(), })) vi.mock('@/lib/workflows/application/read-workflow', () => ({ @@ -23,18 +28,9 @@ vi.mock('@/lib/workflows/application/update-workflow', () => ({ vi.mock('@/lib/workflows/application/delete-workflow', () => ({ deleteWorkflow: { operation: { id: 'workflows.delete' }, execute: mocks.deleteWorkflow }, })) -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/api/server/routes/v2-api-key-auth', () => v2ApiKeyAuthModuleMock) +vi.mock('@/lib/core/rate-limiter', () => v2RateLimiterModuleMock) +vi.mock('@/app/api/v2/lib/gate', () => v2GateModuleMock) import { NoWorkspaceAccessError, PersonalApiKeysDisabledError } from '@/lib/core/application' import { DELETE, GET, PATCH } from '@/app/api/v2/workflows/[id]/route' @@ -71,18 +67,10 @@ const routeContext = { params: Promise.resolve({ id: WORKFLOW_ID }) } describe('/api/v2/workflows/[id]', () => { beforeEach(() => { vi.clearAllMocks() - mocks.authenticateV2ApiKey.mockResolvedValue(auth) - mocks.gate.mockResolvedValue(null) - mocks.checkRateLimitDirect.mockResolvedValue({ - allowed: true, - remaining: 599, - resetAt: new Date('2026-08-01T01:00:00.000Z'), - }) - mocks.checkRateLimitDirectOrThrow.mockResolvedValue({ - allowed: true, - remaining: 99, - resetAt: new Date('2026-08-01T01:00:00.000Z'), - }) + v2RouteMocks.authenticate.mockResolvedValue(auth) + v2RouteMocks.gate.mockResolvedValue(null) + v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) + v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) mocks.readWorkflow.mockResolvedValue({ workflow, workspaceId: WORKSPACE_ID, @@ -176,4 +164,16 @@ describe('/api/v2/workflows/[id]', () => { request, }) }) + + it('rejects an unauthenticated request', async () => { + v2RouteMocks.authenticate.mockRejectedValueOnce(new MockV2ApiKeyUnauthenticatedError()) + + const response = await GET( + new NextRequest(`http://localhost/api/v2/workflows/${WORKFLOW_ID}`), + routeContext + ) + + expect(response.status).toBe(401) + expect((await response.json()).error.code).toBe('UNAUTHORIZED') + }) }) diff --git a/apps/sim/app/api/v2/workflows/[id]/runs/[runId]/route.test.ts b/apps/sim/app/api/v2/workflows/[id]/runs/[runId]/route.test.ts index 14ca04fbc5d..e67b9ddf739 100644 --- a/apps/sim/app/api/v2/workflows/[id]/runs/[runId]/route.test.ts +++ b/apps/sim/app/api/v2/workflows/[id]/runs/[runId]/route.test.ts @@ -1,40 +1,27 @@ /** * @vitest-environment node */ -import { createMockRequest } from '@sim/testing' +import { + createMockRequest, + MockV2ApiKeyUnauthenticatedError, + V2_OPERATION_RATE_LIMIT_ALLOWED, + V2_PREAUTH_RATE_LIMIT_ALLOWED, + v2ApiKeyAuthModuleMock, + v2GateModuleMock, + v2RateLimiterModuleMock, + v2RouteMocks, +} from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mocks, MockV2ApiKeyUnauthenticatedError } = vi.hoisted(() => { - class MockV2ApiKeyUnauthenticatedError extends Error {} - return { - MockV2ApiKeyUnauthenticatedError, - mocks: { - authenticate: vi.fn(), - cancel: vi.fn(), - capture: vi.fn(), - checkOperationRate: vi.fn(), - checkPreAuthRate: vi.fn(), - readRun: vi.fn(), - }, - } -}) - -vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => ({ - authenticateV2ApiKey: mocks.authenticate, - V2ApiKeyUnauthenticatedError: MockV2ApiKeyUnauthenticatedError, -})) - -vi.mock('@/lib/core/rate-limiter', () => ({ - getRateLimit: () => ({ maxTokens: 100, refillRate: 50, refillIntervalMs: 60_000 }), - RateLimiter: class RateLimiter { - checkRateLimitDirect = mocks.checkPreAuthRate - checkRateLimitDirectOrThrow = mocks.checkOperationRate - }, +const mocks = vi.hoisted(() => ({ + cancel: vi.fn(), + capture: vi.fn(), + readRun: vi.fn(), })) -vi.mock('@/app/api/v2/lib/gate', () => ({ - v2ApiGateError: vi.fn().mockResolvedValue(null), -})) +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) vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: mocks.capture })) @@ -98,6 +85,17 @@ const baseStatus = { blockOutputs: null, } +/** + * Local denial fixture — the harness only publishes the allowed shapes, and the + * cancel adapter must surface `retryAfterMs` as a `Retry-After` header. + */ +const OPERATION_RATE_LIMIT_DENIED = { + allowed: false, + remaining: 0, + resetAt: new Date('2026-08-05T01:00:00Z'), + retryAfterMs: 5_000, +} as const + const successfulCancellation = { success: true, executionId: 'run-1', @@ -113,17 +111,10 @@ const successfulCancellation = { describe('v2 run detail and cancel adapters', () => { beforeEach(() => { vi.clearAllMocks() - mocks.authenticate.mockResolvedValue(auth) - mocks.checkPreAuthRate.mockResolvedValue({ - allowed: true, - remaining: 599, - resetAt: new Date('2026-08-05T01:00:00Z'), - }) - mocks.checkOperationRate.mockResolvedValue({ - allowed: true, - remaining: 99, - resetAt: new Date('2026-08-05T01:00:00Z'), - }) + v2RouteMocks.authenticate.mockResolvedValue(auth) + v2RouteMocks.gate.mockResolvedValue(null) + v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) + v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) mocks.readRun.mockResolvedValue(baseStatus) mocks.cancel.mockResolvedValue(successfulCancellation) }) @@ -223,13 +214,14 @@ describe('v2 run detail and cancel adapters', () => { }) it('rejects missing API keys before reading the run', async () => { - mocks.authenticate.mockRejectedValueOnce( + v2RouteMocks.authenticate.mockRejectedValueOnce( new MockV2ApiKeyUnauthenticatedError('API key required') ) const response = await callStatus() expect(response.status).toBe(401) + expect((await response.json()).error.code).toBe('UNAUTHORIZED') expect(mocks.readRun).not.toHaveBeenCalled() }) @@ -249,8 +241,8 @@ describe('v2 run detail and cancel adapters', () => { input: { workflowId: 'workflow-1', runId: 'run-1' }, request: expect.anything(), }) - expect(mocks.checkOperationRate).toHaveBeenCalledTimes(2) - expect(mocks.checkOperationRate).toHaveBeenCalledWith( + expect(v2RouteMocks.operationRate).toHaveBeenCalledTimes(2) + expect(v2RouteMocks.operationRate).toHaveBeenCalledWith( 'v2:workflows.runs.cancel:api-key:key-1', expect.anything() ) @@ -258,18 +250,9 @@ describe('v2 run detail and cancel adapters', () => { }) it('keeps cancellation request-rate admission separate from run control', async () => { - mocks.checkOperationRate - .mockResolvedValueOnce({ - allowed: false, - remaining: 0, - resetAt: new Date('2026-08-05T01:00:00Z'), - retryAfterMs: 5_000, - }) - .mockResolvedValueOnce({ - allowed: true, - remaining: 99, - resetAt: new Date('2026-08-05T01:00:00Z'), - }) + v2RouteMocks.operationRate + .mockResolvedValueOnce(OPERATION_RATE_LIMIT_DENIED) + .mockResolvedValueOnce(V2_OPERATION_RATE_LIMIT_ALLOWED) const response = await cancelPost(createMockRequest('POST', undefined, {}), { params: Promise.resolve({ id: 'workflow-1', runId: 'run-1' }), @@ -296,7 +279,7 @@ describe('v2 run detail and cancel adapters', () => { }) it('projects cancellation analytics only after a successful personal-key result', async () => { - mocks.authenticate.mockResolvedValueOnce({ + v2RouteMocks.authenticate.mockResolvedValueOnce({ ...auth, principal: { kind: 'personal_api_key', userId: 'key-user', keyId: 'personal-key' }, rolloutUserId: 'key-user', 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 f3714d3f563..de409d6a69b 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 @@ -1,32 +1,25 @@ /** * @vitest-environment node */ +import { + MockV2ApiKeyUnauthenticatedError, + V2_OPERATION_RATE_LIMIT_ALLOWED, + V2_PREAUTH_RATE_LIMIT_ALLOWED, + v2ApiKeyAuthModuleMock, + v2GateModuleMock, + v2RateLimiterModuleMock, + v2RouteMocks, +} from '@sim/testing' import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ - authenticate: vi.fn(), - checkPreAuthRate: vi.fn(), - checkOperationRate: vi.fn(), listRuns: vi.fn(), })) -vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => ({ - authenticateV2ApiKey: mocks.authenticate, - V2ApiKeyUnauthenticatedError: class V2ApiKeyUnauthenticatedError extends Error {}, -})) - -vi.mock('@/lib/core/rate-limiter', () => ({ - getRateLimit: () => ({ maxTokens: 100, refillRate: 50, refillIntervalMs: 60_000 }), - RateLimiter: class RateLimiter { - checkRateLimitDirect = mocks.checkPreAuthRate - checkRateLimitDirectOrThrow = mocks.checkOperationRate - }, -})) - -vi.mock('@/app/api/v2/lib/gate', () => ({ - v2ApiGateError: vi.fn().mockResolvedValue(null), -})) +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) vi.mock('@/lib/workflows/application/list-workflow-runs', () => ({ listWorkflowRuns: { @@ -85,17 +78,10 @@ const EXECUTIONS = [ describe('GET /api/v2/workflows/[id]/runs', () => { beforeEach(() => { vi.clearAllMocks() - mocks.authenticate.mockResolvedValue(auth) - mocks.checkPreAuthRate.mockResolvedValue({ - allowed: true, - remaining: 599, - resetAt: new Date('2026-08-05T01:00:00Z'), - }) - mocks.checkOperationRate.mockResolvedValue({ - allowed: true, - remaining: 99, - resetAt: new Date('2026-08-05T01:00:00Z'), - }) + v2RouteMocks.authenticate.mockResolvedValue(auth) + v2RouteMocks.gate.mockResolvedValue(null) + v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) + v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) mocks.listRuns.mockResolvedValue({ data: EXECUTIONS, nextCursor: null, @@ -160,8 +146,8 @@ describe('GET /api/v2/workflows/[id]/runs', () => { const response = await callGet('?cursor=not-a-cursor') expect(response.status).toBe(400) - expect(mocks.authenticate).toHaveBeenCalledOnce() - expect(mocks.checkOperationRate).toHaveBeenCalledTimes(2) + expect(v2RouteMocks.authenticate).toHaveBeenCalledOnce() + expect(v2RouteMocks.operationRate).toHaveBeenCalledTimes(2) expect(mocks.listRuns).not.toHaveBeenCalled() }) @@ -242,4 +228,13 @@ describe('GET /api/v2/workflows/[id]/runs', () => { error: { code: 'INTERNAL_ERROR', message: 'Internal server error' }, }) }) + + it('rejects an unauthenticated request', async () => { + v2RouteMocks.authenticate.mockRejectedValueOnce(new MockV2ApiKeyUnauthenticatedError()) + + const response = await callGet() + + expect(response.status).toBe(401) + expect((await response.json()).error.code).toBe('UNAUTHORIZED') + }) }) diff --git a/apps/sim/app/api/v2/workflows/[id]/versions/[version]/route.test.ts b/apps/sim/app/api/v2/workflows/[id]/versions/[version]/route.test.ts index 909c823e864..fd19cec176f 100644 --- a/apps/sim/app/api/v2/workflows/[id]/versions/[version]/route.test.ts +++ b/apps/sim/app/api/v2/workflows/[id]/versions/[version]/route.test.ts @@ -1,17 +1,22 @@ /** * @vitest-environment node */ +import { + MockV2ApiKeyUnauthenticatedError, + V2_OPERATION_RATE_LIMIT_ALLOWED, + V2_PREAUTH_RATE_LIMIT_ALLOWED, + v2ApiKeyAuthModuleMock, + v2GateModuleMock, + v2RateLimiterModuleMock, + v2RouteMocks, +} from '@sim/testing' import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ - authenticateV2ApiKey: vi.fn(), - checkRateLimitDirect: vi.fn(), - checkRateLimitDirectOrThrow: vi.fn(), resolvePermission: vi.fn(), resolveWorkflowContext: vi.fn(), readVersion: vi.fn(), - gate: vi.fn(), })) vi.mock('@sim/platform-authz/workspace', () => ({ @@ -60,18 +65,9 @@ vi.mock('@/blocks/registry', () => ({ outputs: {}, }), })) -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/api/server/routes/v2-api-key-auth', () => v2ApiKeyAuthModuleMock) +vi.mock('@/lib/core/rate-limiter', () => v2RateLimiterModuleMock) +vi.mock('@/app/api/v2/lib/gate', () => v2GateModuleMock) import { GET } from '@/app/api/v2/workflows/[id]/versions/[version]/route' @@ -136,20 +132,12 @@ function versionState() { describe('GET /api/v2/workflows/[id]/versions/[version]', () => { beforeEach(() => { vi.clearAllMocks() - mocks.authenticateV2ApiKey.mockResolvedValue(auth) - mocks.gate.mockResolvedValue(null) + v2RouteMocks.authenticate.mockResolvedValue(auth) + v2RouteMocks.gate.mockResolvedValue(null) + v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) + v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) mocks.resolvePermission.mockResolvedValue('admin') mocks.resolveWorkflowContext.mockResolvedValue(workflowContext) - mocks.checkRateLimitDirect.mockResolvedValue({ - allowed: true, - remaining: 599, - resetAt: new Date('2026-08-01T01:00:00.000Z'), - }) - mocks.checkRateLimitDirectOrThrow.mockResolvedValue({ - allowed: true, - remaining: 99, - resetAt: new Date('2026-08-01T01:00:00.000Z'), - }) mocks.readVersion.mockResolvedValue({ id: 'version-2', version: 2, @@ -194,4 +182,13 @@ describe('GET /api/v2/workflows/[id]/versions/[version]', () => { expect(JSON.stringify(subBlocks)).not.toContain('sk-tool-plaintext-secret') expect(JSON.stringify(subBlocks)).not.toContain('table-plaintext-secret') }) + + it('rejects an unauthenticated request', async () => { + v2RouteMocks.authenticate.mockRejectedValueOnce(new MockV2ApiKeyUnauthenticatedError()) + + const response = await get() + + expect(response.status).toBe(401) + expect((await response.json()).error.code).toBe('UNAUTHORIZED') + }) }) 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 aca18ee8ede..f039ed8ba39 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 @@ -1,15 +1,20 @@ /** * @vitest-environment node */ +import { + MockV2ApiKeyUnauthenticatedError, + V2_OPERATION_RATE_LIMIT_ALLOWED, + V2_PREAUTH_RATE_LIMIT_ALLOWED, + v2ApiKeyAuthModuleMock, + v2GateModuleMock, + v2RateLimiterModuleMock, + v2RouteMocks, +} from '@sim/testing' import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ - authenticateV2ApiKey: vi.fn(), - checkRateLimitDirect: vi.fn(), - checkRateLimitDirectOrThrow: vi.fn(), listVersions: vi.fn(), - gate: vi.fn(), })) vi.mock('@/lib/workflows/application/list-workflow-versions', () => ({ @@ -18,18 +23,9 @@ vi.mock('@/lib/workflows/application/list-workflow-versions', () => ({ execute: mocks.listVersions, }, })) -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/api/server/routes/v2-api-key-auth', () => v2ApiKeyAuthModuleMock) +vi.mock('@/lib/core/rate-limiter', () => v2RateLimiterModuleMock) +vi.mock('@/app/api/v2/lib/gate', () => v2GateModuleMock) import { GET } from '@/app/api/v2/workflows/[id]/versions/route' @@ -49,18 +45,10 @@ const context = { params: Promise.resolve({ id: 'workflow-1' }) } describe('GET /api/v2/workflows/[id]/versions', () => { beforeEach(() => { vi.clearAllMocks() - mocks.authenticateV2ApiKey.mockResolvedValue(auth) - mocks.gate.mockResolvedValue(null) - mocks.checkRateLimitDirect.mockResolvedValue({ - allowed: true, - remaining: 599, - resetAt: new Date('2026-08-01T01:00:00.000Z'), - }) - mocks.checkRateLimitDirectOrThrow.mockResolvedValue({ - allowed: true, - remaining: 99, - resetAt: new Date('2026-08-01T01:00:00.000Z'), - }) + v2RouteMocks.authenticate.mockResolvedValue(auth) + v2RouteMocks.gate.mockResolvedValue(null) + v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) + v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) mocks.listVersions.mockResolvedValue({ versions: [ { @@ -116,4 +104,16 @@ describe('GET /api/v2/workflows/[id]/versions', () => { expect(response.status).toBe(400) expect(mocks.listVersions).not.toHaveBeenCalled() }) + + it('rejects an unauthenticated request', async () => { + v2RouteMocks.authenticate.mockRejectedValueOnce(new MockV2ApiKeyUnauthenticatedError()) + + const response = await GET( + new NextRequest('http://localhost/api/v2/workflows/workflow-1/versions?limit=10'), + context + ) + + expect(response.status).toBe(401) + expect((await response.json()).error.code).toBe('UNAUTHORIZED') + }) }) diff --git a/apps/sim/app/api/v2/workflows/route.test.ts b/apps/sim/app/api/v2/workflows/route.test.ts index 6a3e094b23c..c93ae286fa8 100644 --- a/apps/sim/app/api/v2/workflows/route.test.ts +++ b/apps/sim/app/api/v2/workflows/route.test.ts @@ -1,16 +1,21 @@ /** * @vitest-environment node */ +import { + MockV2ApiKeyUnauthenticatedError, + V2_OPERATION_RATE_LIMIT_ALLOWED, + V2_PREAUTH_RATE_LIMIT_ALLOWED, + v2ApiKeyAuthModuleMock, + v2GateModuleMock, + v2RateLimiterModuleMock, + v2RouteMocks, +} from '@sim/testing' import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ - authenticateV2ApiKey: vi.fn(), - checkRateLimitDirect: vi.fn(), - checkRateLimitDirectOrThrow: vi.fn(), createWorkflow: vi.fn(), listWorkflows: vi.fn(), - gate: vi.fn(), })) vi.mock('@/lib/workflows/application/create-workflow', () => ({ @@ -21,20 +26,9 @@ vi.mock('@/lib/workflows/application/list-workflows', () => ({ listWorkflows: { operation: { id: 'workflows.list' }, execute: mocks.listWorkflows }, })) -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/api/server/routes/v2-api-key-auth', () => v2ApiKeyAuthModuleMock) +vi.mock('@/lib/core/rate-limiter', () => v2RateLimiterModuleMock) +vi.mock('@/app/api/v2/lib/gate', () => v2GateModuleMock) import { GET, POST } from '@/app/api/v2/workflows/route' @@ -82,18 +76,10 @@ const personalAuth = { describe('/api/v2/workflows', () => { beforeEach(() => { vi.clearAllMocks() - mocks.authenticateV2ApiKey.mockResolvedValue(workspaceAuth) - mocks.gate.mockResolvedValue(null) - mocks.checkRateLimitDirect.mockResolvedValue({ - allowed: true, - remaining: 599, - resetAt: new Date('2026-08-01T01:00:00.000Z'), - }) - mocks.checkRateLimitDirectOrThrow.mockResolvedValue({ - allowed: true, - remaining: 99, - resetAt: new Date('2026-08-01T01:00:00.000Z'), - }) + v2RouteMocks.authenticate.mockResolvedValue(workspaceAuth) + v2RouteMocks.gate.mockResolvedValue(null) + v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) + v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) mocks.listWorkflows.mockResolvedValue({ workflows: [WORKFLOW], nextCursorKeys: null, @@ -107,8 +93,8 @@ describe('/api/v2/workflows', () => { const response = await GET(new NextRequest('http://localhost/api/v2/workflows')) expect(response.status).toBe(400) - expect(mocks.authenticateV2ApiKey).toHaveBeenCalledOnce() - expect(mocks.checkRateLimitDirectOrThrow).toHaveBeenCalledTimes(2) + expect(v2RouteMocks.authenticate).toHaveBeenCalledOnce() + expect(v2RouteMocks.operationRate).toHaveBeenCalledTimes(2) expect(mocks.listWorkflows).not.toHaveBeenCalled() }) @@ -148,7 +134,7 @@ describe('/api/v2/workflows', () => { }) it('creates through a personal-key principal with the exact 201 contract', async () => { - mocks.authenticateV2ApiKey.mockResolvedValue(personalAuth) + v2RouteMocks.authenticate.mockResolvedValue(personalAuth) const request = new NextRequest('http://localhost/api/v2/workflows', { method: 'POST', headers: { 'content-type': 'application/json', 'x-api-key': 'secret' }, @@ -176,4 +162,15 @@ describe('/api/v2/workflows', () => { error: { code: 'INTERNAL_ERROR', message: 'Internal server error' }, }) }) + + it('rejects an unauthenticated request', async () => { + v2RouteMocks.authenticate.mockRejectedValueOnce(new MockV2ApiKeyUnauthenticatedError()) + + const response = await GET( + new NextRequest(`http://localhost/api/v2/workflows?workspaceId=${WORKSPACE_ID}`) + ) + + expect(response.status).toBe(401) + expect((await response.json()).error.code).toBe('UNAUTHORIZED') + }) }) diff --git a/apps/sim/lib/api/contracts/tables.ts b/apps/sim/lib/api/contracts/tables.ts index 8813cd97d2c..e668a3a1d5a 100644 --- a/apps/sim/lib/api/contracts/tables.ts +++ b/apps/sim/lib/api/contracts/tables.ts @@ -551,13 +551,23 @@ export const predicateInputSchema = predicateBoundarySchema 'Recursive predicate condition or group, normalized to a grouped predicate after validation.' ) as z.ZodType -/** v2 sort wire format: an ordered list of `{ field, direction }`. */ +/** + * v2 sort wire format: an ordered list of `{ field, direction }`. + * + * The element is `.strict()` because a body's own `.strict()` binds its top + * level only. Left open, `sort: [{ field, direction, nulls: 'last' }]` answered + * 200 with the `nulls` request silently dropped — a caller asking for + * null-ordering got default ordering and no signal. That is the same failure as + * the v1-shaped `filter` key returning an unfiltered page, one level down. + */ export const sortSpecSchema: z.ZodType = z .array( - z.object({ - field: z.string().min(1, 'field is required').max(128).describe('Column name to sort by.'), - direction: z.enum(SORT_DIRECTIONS).describe('Sort direction for this column.'), - }) + z + .object({ + field: z.string().min(1, 'field is required').max(128).describe('Column name to sort by.'), + direction: z.enum(SORT_DIRECTIONS).describe('Sort direction for this column.'), + }) + .strict() ) .max(MAX_SORT_KEYS) @@ -1870,19 +1880,30 @@ export const tableEventStreamContract = defineRouteContract({ * predicate and sort. Every column reference is a stable column id, so a rename * never invalidates a view. */ -export const tableViewConfigSchema = tableMetadataSchema.extend({ - // 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. - filter: predicateInputSchema - .nullable() - .optional() - .describe('Saved row predicate, or null when the view is unfiltered.'), - sort: sortSpecSchema - .nullable() - .optional() - .describe('Saved ordered sort specification, or null for default ordering.'), -}) satisfies z.ZodType +export const tableViewConfigSchema = tableMetadataSchema + .extend({ + // 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. + filter: predicateInputSchema + .nullable() + .optional() + .describe('Saved row predicate, or null when the view is unfiltered.'), + sort: sortSpecSchema + .nullable() + .optional() + .describe('Saved ordered sort specification, or null for default ordering.'), + }) + /** + * `tableMetadataSchema` is not strict — it is also the body and the response + * of the column-layout endpoint, which has its own compatibility story — so + * extending it inherited the laxness and let a misspelled layout key be + * accepted and dropped. Strictness is applied here, where the schema is a + * saved-view config. Safe in both directions because + * `normalizeStoredViewConfig` projects a stored blob onto exactly these keys + * before a read is validated against this schema. + */ + .strict() satisfies z.ZodType export const tableViewSchema = z.object({ id: z.string(), 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 new file mode 100644 index 00000000000..56be75963fc --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/__tests__/cross-cutting.test.ts @@ -0,0 +1,133 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { sortSpecSchema, tableViewConfigSchema } from '@/lib/api/contracts/tables' +import { v2ListAuditLogsContract } from '@/lib/api/contracts/v2/audit-logs' +import { ERROR_RESPONSES } from '@/lib/api/contracts/v2/openapi/shared' +import { v2CreateTableViewContract, v2QueryRowsBodySchema } from '@/lib/api/contracts/v2/tables' +import { v2GetWorkflowRunContract } from '@/lib/api/contracts/v2/workflows' +import { + FORBIDDEN_DETAIL_CODE_DESCRIPTIONS, + FORBIDDEN_DETAIL_CODES, +} from '@/lib/core/application/forbidden' + +/** + * The cross-cutting promises that no single resource family owns, and that + * therefore have nowhere else to be asserted. + */ +describe('v2 403 cause codes', () => { + it('publishes every code in the generated OpenAPI 403 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] + ) + } + }) + + it('tells a client the codes live on error.details.code', () => { + expect(ERROR_RESPONSES.Forbidden.description).toContain('error.details.code') + }) +}) + +/** + * Two v2 boolean query params were spelled as a `'true'`/`'false'` string enum + * while four others were real booleans. Normalising them onto the shared flag + * must not change what an existing caller can send, so both spellings are + * pinned rather than just the new one. + */ +describe('v2 boolean query params', () => { + const cases = [ + ['includeOutput', v2GetWorkflowRunContract.query], + ['includeDeparted', v2ListAuditLogsContract.query], + ] as const + + it.each(cases)('%s accepts the string spellings unchanged', (field, schema) => { + expect(schema).toBeDefined() + const parseField = (value: string) => { + const parsed = schema?.safeParse( + field === 'includeDeparted' + ? { organizationId: 'org-1', [field]: value } + : { [field]: value } + ) + expect(parsed?.success).toBe(true) + return (parsed?.data as Record | undefined)?.[field] + } + expect(parseField('true')).toBe(true) + expect(parseField('false')).toBe(false) + }) + + it.each(cases)('%s accepts a real boolean and defaults to false', (field, schema) => { + const withBoolean = schema?.safeParse( + field === 'includeDeparted' ? { organizationId: 'org-1', [field]: true } : { [field]: true } + ) + expect((withBoolean?.data as Record | undefined)?.[field]).toBe(true) + + const omitted = schema?.safeParse( + field === 'includeDeparted' ? { organizationId: 'org-1' } : {} + ) + expect((omitted?.data as Record | undefined)?.[field]).toBe(false) + }) + + it.each(cases)('%s still rejects a non-boolean word', (field, schema) => { + const parsed = schema?.safeParse( + field === 'includeDeparted' ? { organizationId: 'org-1', [field]: 'yes' } : { [field]: 'yes' } + ) + expect(parsed?.success).toBe(false) + }) +}) + +/** + * `.strict()` binds the top level only. These are the two places on the tables + * surface where that mattered: an unknown key one level down was accepted and + * dropped, so the caller got a 200 for a request the server did not honour — + * the same failure class as the v1-shaped `filter` key returning an unfiltered + * page. + */ +describe('tables nested strictness', () => { + it('rejects an unsupported per-sort option instead of dropping it', () => { + const parsed = sortSpecSchema.safeParse([{ field: 'name', direction: 'asc', nulls: 'last' }]) + expect(parsed.success).toBe(false) + }) + + it('rejects it on the row query body too', () => { + const parsed = v2QueryRowsBodySchema.safeParse({ + workspaceId: 'ws-1', + sort: [{ field: 'name', direction: 'asc', nulls: 'last' }], + }) + expect(parsed.success).toBe(false) + }) + + it('still accepts a well-formed sort spec', () => { + expect(sortSpecSchema.safeParse([{ field: 'name', direction: 'asc' }]).success).toBe(true) + }) + + it('rejects an unknown key inside a saved-view config', () => { + const parsed = tableViewConfigSchema.safeParse({ + columnOrder: ['col-1'], + groupBy: 'col-1', + }) + expect(parsed.success).toBe(false) + }) + + it('rejects it through the v2 create-view body', () => { + const parsed = v2CreateTableViewContract.body?.safeParse({ + workspaceId: 'ws-1', + name: 'My view', + config: { columnOrder: ['col-1'], groupBy: 'col-1' }, + }) + expect(parsed?.success).toBe(false) + }) + + it('still accepts a well-formed saved-view config', () => { + expect( + tableViewConfigSchema.safeParse({ + columnOrder: ['col-1'], + hiddenColumns: [], + sort: [{ field: 'name', direction: 'desc' }], + filter: null, + }).success + ).toBe(true) + }) +}) diff --git a/apps/sim/lib/api/contracts/v2/__tests__/document-tag-slots.test.ts b/apps/sim/lib/api/contracts/v2/__tests__/document-tag-slots.test.ts new file mode 100644 index 00000000000..4d0a621e63e --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/__tests__/document-tag-slots.test.ts @@ -0,0 +1,38 @@ +/** @vitest-environment node */ +import { describe, expect, it } from 'vitest' +import { v2UpdateKnowledgeDocumentBodySchema } from '@/lib/api/contracts/v2/knowledge' + +const WORKSPACE_ID = '6fc7631d-88cd-46f8-9f0a-d4764daef7f8' + +describe('v2 document update tag slots', () => { + it('accepts every slot GET /knowledge/{id}/tags can advertise', () => { + for (const [field, value] of [ + ['tag1', 'billing'], + ['number1', 7], + ['number5', -1.5], + ['date1', '2026-08-06'], + ['boolean3', true], + ] as const) { + const result = v2UpdateKnowledgeDocumentBodySchema.safeParse({ + workspaceId: WORKSPACE_ID, + [field]: value, + }) + expect(result.success, `${field} was rejected`).toBe(true) + } + }) + + it('rejects a malformed typed slot rather than silently clearing the tag', () => { + for (const [field, value] of [ + ['number1', 'abc'], + ['date1', '2026-02-31'], + ['date2', '06-08-2026'], + ['boolean1', 'yes'], + ] as const) { + const result = v2UpdateKnowledgeDocumentBodySchema.safeParse({ + workspaceId: WORKSPACE_ID, + [field]: value, + }) + expect(result.success, `${field} was accepted`).toBe(false) + } + }) +}) 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 cbaf49cc562..9a5c2fcb5bb 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 @@ -5,6 +5,10 @@ import { readdirSync } from 'node:fs' import path from 'node:path' import { describe, expect, it } from 'vitest' import { z } from 'zod' +import { + MAX_SCHEMA_DEPTH, + rejectsUnknownKeys, +} from '@/lib/api/contracts/v2/__tests__/schema-introspection' /** * Pins which v2 lists are paged. @@ -52,6 +56,7 @@ const PAGED_LISTS = [ 'GET /api/v2/knowledge', 'GET /api/v2/knowledge/[id]/documents', 'GET /api/v2/logs', + 'GET /api/v2/mcp-servers', 'GET /api/v2/secrets', 'GET /api/v2/skills', 'GET /api/v2/tables', @@ -65,17 +70,25 @@ const PAGED_LISTS = [ /** * Lists that accept neither param and always return `nextCursor: null`, because - * the set is small and bounded per workspace or per table. + * the set is small and bounded per workspace, per table, or per server. * - * Every remaining entry but the MCP server list is a *folder* list, and a folder - * tree is already capped where it is loaded + * 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`. + * 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. */ const FULL_SET_LISTS = [ 'GET /api/v2/files/folders', + 'GET /api/v2/knowledge/[id]/tags', 'GET /api/v2/knowledge/folders', - 'GET /api/v2/mcp-servers', + 'GET /api/v2/mcp-servers/[id]/tools', 'GET /api/v2/tables/[tableId]/groups', 'GET /api/v2/tables/[tableId]/views', 'GET /api/v2/tables/folders', @@ -113,7 +126,6 @@ function isContract(value: unknown): value is ContractLike { ) } -const MAX_SCHEMA_DEPTH = 12 const PAGINATION_PARAMS = ['limit', 'cursor'] as const /** @@ -219,26 +231,6 @@ function paginationParams(variants: string[][]): { any: string[]; all: string[] } } -/** - * Whether a schema rejects keys it does not declare, i.e. is `.strict()`. - * - * This is what separates "this list does not page" from "this list quietly - * throws your `limit` away". Zod strips unknown keys by default, so a full-set - * list that is not strict answers `?limit=1` with 200 and the entire set — the - * caller believes it bounded the response and it did not. Only the outermost - * object is inspected; that is where a query's unknown key is caught. - */ -function rejectsUnknownKeys(schema: unknown, depth: number = MAX_SCHEMA_DEPTH): boolean | null { - if (!schema || depth <= 0) return null - const def = (schema as { def?: Record }).def - if (!def) return null - if (def.type === 'object') { - return (def.catchall as { def?: { type?: string } } | undefined)?.def?.type === 'never' - } - const inner = def.innerType ?? def.in ?? def.schema - return inner ? rejectsUnknownKeys(inner, depth - 1) : null -} - /** * Whether a fractional `limit` is rejected by whichever slot carries it. * diff --git a/apps/sim/lib/api/contracts/v2/__tests__/schema-introspection.test.ts b/apps/sim/lib/api/contracts/v2/__tests__/schema-introspection.test.ts new file mode 100644 index 00000000000..738f230ba99 --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/__tests__/schema-introspection.test.ts @@ -0,0 +1,75 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { z } from 'zod' +import { + issueCodes, + rejectsUnknownKeys, + type SchemaLike, + strictnessTargets, +} from '@/lib/api/contracts/v2/__tests__/schema-introspection' + +const strictObject = z.object({ a: z.string() }).strict() +const looseObject = z.object({ a: z.string() }) + +describe('strictnessTargets', () => { + it('returns a plain object schema unchanged', () => { + expect(strictnessTargets(strictObject as unknown as SchemaLike)).toHaveLength(1) + }) + + it('expands a union into its members', () => { + expect( + strictnessTargets(z.union([strictObject, looseObject]) as unknown as SchemaLike) + ).toHaveLength(2) + }) + + it('unwraps a wrapper around a union, which the tables walker could not', () => { + expect( + strictnessTargets(z.union([strictObject, looseObject]).optional() as unknown as SchemaLike) + ).toHaveLength(2) + }) +}) + +describe('rejectsUnknownKeys', () => { + it('reads a strict object as strict', () => { + expect(rejectsUnknownKeys(strictObject)).toBe(true) + }) + + it('reads a Zod 4 refined strict object as strict, because refine is a check and not a wrapper', () => { + expect(rejectsUnknownKeys(strictObject.refine(() => true))).toBe(true) + }) + + it('reads a non-strict object as non-strict', () => { + expect(rejectsUnknownKeys(looseObject)).toBe(false) + }) + + it('unwraps wrappers', () => { + expect(rejectsUnknownKeys(strictObject.optional())).toBe(true) + expect(rejectsUnknownKeys(looseObject.optional())).toBe(false) + }) + + /** + * The hole the pagination sweep used to carry: a union answered `null`, and a + * `null` verdict was skipped, so a union-shaped query opted out of the + * strictness sweep entirely. A union is only as strict as its weakest member. + */ + it('rejects a union whose members are not all strict, rather than answering null', () => { + expect(rejectsUnknownKeys(z.union([strictObject, looseObject]))).toBe(false) + expect(rejectsUnknownKeys(z.union([strictObject, strictObject]))).toBe(true) + expect(rejectsUnknownKeys(z.union([strictObject, looseObject]).optional())).toBe(false) + }) + + it('answers null for a schema it cannot introspect, so the sweep fails loudly', () => { + expect(rejectsUnknownKeys(z.string())).toBeNull() + expect(rejectsUnknownKeys(undefined)).toBeNull() + }) +}) + +describe('issueCodes', () => { + it('flattens the member failures nested under a union issue', () => { + const result = z.union([strictObject, strictObject]).safeParse({ a: 'x', unknown: 1 }) + expect(result.success).toBe(false) + expect(issueCodes(result.error?.issues ?? [])).toContain('unrecognized_keys') + }) +}) diff --git a/apps/sim/lib/api/contracts/v2/__tests__/schema-introspection.ts b/apps/sim/lib/api/contracts/v2/__tests__/schema-introspection.ts new file mode 100644 index 00000000000..708ac1b6610 --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/__tests__/schema-introspection.ts @@ -0,0 +1,127 @@ +/** + * Shared Zod introspection for the v2 contract sweeps. + * + * Two sweeps ask the same question — "does this schema reject a key it does not + * declare?" — and each used to carry its own walker with the hole the other + * filled: the pagination sweep unwrapped wrappers but returned `null` for a + * union (which it then skipped), and the tables sweep expanded unions but not + * wrappers. A schema that is both wrapped and union-shaped was reachable by + * neither. One walker handles both so a strictness claim cannot be vacuous by + * virtue of which sweep happens to look at it. + */ + +/** Depth cap so a self-referential `lazy` schema cannot spin the walk. */ +export const MAX_SCHEMA_DEPTH = 12 + +export interface SchemaLike { + def?: Record + safeParse: (value: unknown) => { success: boolean; error?: { issues: readonly unknown[] } } +} + +function schemaDef(schema: unknown): Record | undefined { + return (schema as { def?: Record } | undefined)?.def +} + +/** + * Flattens a schema onto the object schemas that actually carry strictness. + * + * Wrappers (`.optional()`, `.default()`, pipes) are unwrapped, and a union + * expands to its members, because a union is only as strict as its weakest + * member: asserting against the union itself is satisfied by any one strict + * member, so a sibling that stopped being strict would still sweep green. + * + * A schema the walk cannot resolve falls back to the schema itself, so callers + * that only `safeParse` the result keep working; use `rejectsUnknownKeys` when + * an unresolvable schema must be distinguishable from a non-strict one. + * + * Note that a Zod 4 `.refine()` is a check on the schema rather than a wrapper, + * so a refined object still reports `def.type === 'object'` and needs no + * unwrapping here. + */ +export function strictnessTargets( + schema: SchemaLike, + depth: number = MAX_SCHEMA_DEPTH +): SchemaLike[] { + const resolved = resolveStrictnessTargets(schema, depth) + return resolved ?? [schema] +} + +function resolveStrictnessTargets(schema: unknown, depth: number): SchemaLike[] | null { + if (!schema || depth <= 0) return null + const def = schemaDef(schema) + if (!def) return null + + switch (def.type) { + case 'object': + return [schema as SchemaLike] + case 'union': { + const options = def.options as unknown[] | undefined + if (!options?.length) return null + const targets: SchemaLike[] = [] + for (const option of options) { + const resolved = resolveStrictnessTargets(option, depth - 1) + if (!resolved) return null + targets.push(...resolved) + } + return targets + } + case 'lazy': { + const getter = def.getter + if (typeof getter !== 'function') return null + try { + return resolveStrictnessTargets(getter(), depth - 1) + } catch { + return null + } + } + default: { + const inner = def.innerType ?? def.in ?? def.schema + return inner ? resolveStrictnessTargets(inner, depth - 1) : null + } + } +} + +/** Whether an object schema declares `catchall(never)`, i.e. is `.strict()`. */ +function isStrictObject(schema: SchemaLike): boolean { + const def = schemaDef(schema) + if (def?.type !== 'object') return false + return (def.catchall as { def?: { type?: string } } | undefined)?.def?.type === 'never' +} + +/** + * Whether a schema rejects keys it does not declare, i.e. is `.strict()`. + * + * This is what separates "this list does not page" from "this list quietly + * throws your `limit` away". Zod strips unknown keys by default, so a full-set + * list that is not strict answers `?limit=1` with 200 and the entire set — the + * caller believes it bounded the response and it did not. + * + * Returns `null` when the walk cannot reach an object schema, so an + * un-introspectable schema fails loudly rather than passing as strict. A union + * is strict only when **every** member is. + */ +export function rejectsUnknownKeys( + schema: unknown, + depth: number = MAX_SCHEMA_DEPTH +): boolean | null { + const targets = resolveStrictnessTargets(schema, depth) + if (!targets?.length) return null + return targets.every(isStrictObject) +} + +/** + * Zod reports a union's member failures nested under the union issue, so a + * union-bodied contract needs the whole tree walked before "did any member + * reject the unknown key" can be answered. + */ +export function issueCodes(issues: readonly unknown[]): string[] { + return issues.flatMap((issue) => { + const entry = issue as { code?: string; errors?: unknown } + return [ + ...(entry.code ? [entry.code] : []), + ...(Array.isArray(entry.errors) + ? entry.errors.flatMap((nested) => issueCodes(nested as readonly unknown[])) + : []), + ] + }) +} 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 77b2b6f6405..8aa700675ad 100644 --- a/apps/sim/lib/api/contracts/v2/__tests__/tables.test.ts +++ b/apps/sim/lib/api/contracts/v2/__tests__/tables.test.ts @@ -1,42 +1,55 @@ import { describe, expect, it } from 'vitest' +import type { z } from 'zod' +import { + issueCodes, + type SchemaLike, + strictnessTargets, +} from '@/lib/api/contracts/v2/__tests__/schema-introspection' +import * as tableContracts from '@/lib/api/contracts/v2/tables' import { V2_TABLE_IMPORT_OPTIONS_MAX_BYTES, v2ApiTableSchema, v2CreateTableBodySchema, v2CreateTableColumnBodySchema, v2CreateTableImportBodySchema, + v2CreateTableRowsBodySchema, v2CsvImportCreateColumnsSchema, v2CsvImportMappingSchema, + v2QueryRowsBodySchema, v2TableUploadImportSourceSchema, v2UpdateTableColumnBodySchema, } from '@/lib/api/contracts/v2/tables' +import { getValidationErrorMessage } from '@/lib/api/server/validation' import { 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' describe('v2 table column contracts', () => { - it('rejects required on every public column write', () => { + it('accepts required on every public column write so a column round-trips', () => { expect( v2CreateTableBodySchema.safeParse({ workspaceId: WORKSPACE_ID, name: 'contacts', schema: { columns: [{ name: 'email', type: 'string', required: true }] }, - }).success - ).toBe(false) + }) + ).toMatchObject({ + success: true, + data: { schema: { columns: [{ required: true }] } }, + }) expect( v2CreateTableColumnBodySchema.safeParse({ workspaceId: WORKSPACE_ID, column: { name: 'email', type: 'string', required: true }, - }).success - ).toBe(false) + }) + ).toMatchObject({ success: true, data: { column: { required: true } } }) expect( v2UpdateTableColumnBodySchema.safeParse({ workspaceId: WORKSPACE_ID, columnName: 'email', updates: { required: true }, - }).success - ).toBe(false) + }) + ).toMatchObject({ success: true, data: { updates: { required: true } } }) }) it('keeps required in table responses for existing stored schemas', () => { @@ -64,6 +77,92 @@ describe('v2 table column contracts', () => { }) }) +interface BodyBearingContract { + method: string + path: string + body: SchemaLike +} + +function isBodyBearingContract(value: unknown): value is BodyBearingContract { + if (typeof value !== 'object' || value === null) return false + const candidate = value as Record + if (typeof candidate.method !== 'string' || typeof candidate.path !== 'string') return false + const body = candidate.body + return ( + typeof body === 'object' && + body !== null && + typeof (body as { safeParse?: unknown }).safeParse === 'function' + ) +} + +describe('v2 table request bodies', () => { + const contracts = Object.entries(tableContracts) + .filter((entry): entry is [string, BodyBearingContract] => isBodyBearingContract(entry[1])) + .map(([name, contract]) => [`${contract.method} ${contract.path} (${name})`, contract] as const) + + const bodySchemas = contracts.flatMap(([label, contract]) => { + const targets = strictnessTargets(contract.body) + return targets.length === 1 + ? [[label, targets[0]] as const] + : targets.map((target, index) => [`${label} union member ${index}`, target] as const) + }) + + it('covers every table contract that accepts a body', () => { + expect(contracts.length).toBeGreaterThan(20) + }) + + /** + * Guards the sweep itself: if the rows body stopped expanding into its two + * members, every case below would collapse back to the vacuous union + * assertion without any test turning red. + */ + it('sweeps each member of the union-bodied rows contract separately', () => { + expect(strictnessTargets(v2CreateTableRowsBodySchema)).toHaveLength(2) + expect(bodySchemas.length).toBeGreaterThan(contracts.length) + }) + + it.each(bodySchemas)('rejects an unrecognized key on %s', (_label, schema) => { + const result = schema.safeParse({ notAContractField: true }) + + expect(result.success).toBe(false) + expect(issueCodes(result.error?.issues ?? [])).toContain('unrecognized_keys') + }) + + /** + * A union's first issue is `invalid_union`, and its default message — + * `Invalid input` — is what the 400 body surfaces. The v2 conventions name + * that exact string as failing the "errors must be actionable" rule. + */ + it('names both accepted shapes when the rows body matches neither', () => { + const result = v2CreateTableRowsBodySchema.safeParse({ + workspaceId: WORKSPACE_ID, + data: { name: 'ada' }, + bogus: 1, + }) + + expect(result.success).toBe(false) + expect(getValidationErrorMessage(result.error as z.ZodError)).toBe( + 'Row insert body must be either { rows: [...] } for a batch insert or { data: {...} } for a single row' + ) + expect(issueCodes(result.error?.issues ?? [])).toContain('unrecognized_keys') + }) + + /** + * The regression this class of bug actually produced: v1 named its row filter + * `filter`, and a non-strict query body answered that request with 200 and an + * unfiltered page. + */ + it('rejects the v1-shaped filter key on the rows query body', () => { + const result = v2QueryRowsBodySchema.safeParse({ + workspaceId: WORKSPACE_ID, + filter: { status: { $eq: 'active' } }, + }) + + expect(result.success).toBe(false) + expect(issueCodes(result.error?.issues ?? [])).toContain('unrecognized_keys') + }) +}) + function uploadSource(size: number) { return { type: 'upload' as const, diff --git a/apps/sim/lib/api/contracts/v2/audit-logs.ts b/apps/sim/lib/api/contracts/v2/audit-logs.ts index 8df2a91061e..05da3ec2d0b 100644 --- a/apps/sim/lib/api/contracts/v2/audit-logs.ts +++ b/apps/sim/lib/api/contracts/v2/audit-logs.ts @@ -1,5 +1,5 @@ import { z } from 'zod' -import { organizationIdSchema } from '@/lib/api/contracts/primitives' +import { booleanQueryFlagSchema, organizationIdSchema } from '@/lib/api/contracts/primitives' import { defineRouteContract } from '@/lib/api/contracts/types' import { v1AuditLogParamsSchema, @@ -85,9 +85,17 @@ export const v2ListAuditLogsQuerySchema = v1ListAuditLogsQuerySchema 'Inclusive ISO 8601 start timestamp.' ), endDate: v1ListAuditLogsQuerySchema.shape.endDate.describe('Inclusive ISO 8601 end timestamp.'), - includeDeparted: v1ListAuditLogsQuerySchema.shape.includeDeparted.describe( - 'Include actions by users who have left the organization.' - ), + /** + * 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 + * v2 boolean query param is a real boolean. The shared schema still accepts + * both strings, so `?includeDeparted=true` is unchanged for existing + * callers — it only widens what parses and fixes what the spec advertises. + */ + includeDeparted: booleanQueryFlagSchema + .describe('Include actions by users who have left the organization.') + .optional() + .default(false), ...v2PaginationFields({ description: 'Maximum audit entries to return per page.' }), organizationId: organizationIdSchema.describe( 'Organization whose audit trail should be queried.' diff --git a/apps/sim/lib/api/contracts/v2/billing.ts b/apps/sim/lib/api/contracts/v2/billing.ts index 78eb5759bdb..c86d1224ce1 100644 --- a/apps/sim/lib/api/contracts/v2/billing.ts +++ b/apps/sim/lib/api/contracts/v2/billing.ts @@ -24,17 +24,30 @@ const parseableDateSchema = z .min(1) .refine((value) => !Number.isNaN(Date.parse(value)), { error: 'Invalid date' }) -export const v2BillingStatusQuerySchema = z.object({ - /** - * Resolve status against one workspace's payer. A workspace-scoped API key - * is always pinned to its own workspace; passing a different id returns 403. - */ - workspaceId: workspaceIdSchema - .optional() - .describe( - 'Workspace whose payer should be resolved. Workspace API keys are pinned to their own workspace.' - ), -}) +/** + * `.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 — + * a mis-cased `workspaceID`, or a param copied from a sibling contract — silently + * demotes a workspace-scoped question to account scope and answers 200 about a + * different payer than the caller asked about. It is a wrong answer, not a cross-tenant + * read: `resolveBillingReadScope` still pins a workspace API key to its own workspace + * whatever the query says, so the reachable case is a personal key being told about its + * own account when it asked about a workspace. Rejecting the unknown key turns that + * wrong answer about money into a 400. + */ +export const v2BillingStatusQuerySchema = z + .object({ + /** + * Resolve status against one workspace's payer. A workspace-scoped API key + * is always pinned to its own workspace; passing a different id returns 403. + */ + workspaceId: workspaceIdSchema + .optional() + .describe( + 'Workspace whose payer should be resolved. Workspace API keys are pinned to their own workspace.' + ), + }) + .strict() /** * Current billing standing, credit allowance, and storage quota. Ledger rows @@ -126,6 +139,14 @@ export const v2GetBillingStatusContract = defineRouteContract({ }, }) +/** + * Unlike the keyset lists, this ledger's `cursor` is a usage-event id resolved by + * lookup rather than a self-describing opaque cursor, so it cannot be re-validated + * from its own contents. A cursor that names no usage event is a 400 + * (`UNKNOWN_CURSOR_MESSAGE`) rather than an unpositioned first page, so a pager + * holding a cursor from another environment or a wiped ledger fails loudly instead + * of looping over page 1 and counting the same credits on every lap. + */ export const v2BillingLogsQuerySchema = z .object({ source: usageLogSourceSchema.optional().describe('Restrict results to one usage source.'), diff --git a/apps/sim/lib/api/contracts/v2/files.ts b/apps/sim/lib/api/contracts/v2/files.ts index 3e6d2b6e2a7..8c8b5b7231d 100644 --- a/apps/sim/lib/api/contracts/v2/files.ts +++ b/apps/sim/lib/api/contracts/v2/files.ts @@ -90,6 +90,14 @@ export const v2FileSchema = z .string() .describe('ISO 8601 timestamp of the last content or metadata write.') .meta({ format: 'date-time', examples: ['2026-01-15T10:30:00Z'] }), + /** Non-null only for a file `DELETE` archived; see `scope` on the list. */ + deletedAt: z + .string() + .nullable() + .describe( + 'ISO 8601 timestamp when the file was archived by `DELETE /files/{fileId}`, or null while the file is active. Only `GET /files?scope=archived` returns files with a non-null value.' + ) + .meta({ format: 'date-time', examples: ['2026-01-16T09:00:00Z'] }), }) .meta({ id: 'V2File', @@ -270,6 +278,15 @@ export const v2FileSortFields = ['name', 'size', 'uploadedAt', 'updatedAt'] as c export type V2FileSortBy = (typeof v2FileSortFields)[number] +/** + * Listing scopes, matching the internal surface. `all` is deliberately absent + * on both: it drops the `deleted_at` predicate, so it cannot use the partial + * index that serves the other two and degrades to a full workspace scan. + */ +export const v2FileScopeSchema = z.enum(['active', 'archived']) + +export type V2FileScope = z.output + /** * List query: workspace scope, the v2 search/sort convention, an optional * folder filter, and opaque keyset cursor pagination. `limit` clamps to @@ -286,6 +303,11 @@ export const v2ListFilesQuerySchema = z folderPath: v2FolderPathInputSchema .optional() .describe('Restrict results to files directly inside this folder.'), + 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.' + ), search: v2SearchSchema.describe('Case-insensitive substring match against the file name.'), ...v2SortFields(v2FileSortFields, { sortBy: 'uploadedAt', sortOrder: 'asc' }), ...v2PaginationFields({ @@ -313,6 +335,14 @@ export const v2RenameFileBodySchema = z }) .strict() +export const v2RestoreFileBodySchema = z + .object({ + workspaceId: workspaceIdSchema.describe('Workspace that owns the archived file.'), + }) + .strict() + +export type V2RestoreFileBody = z.input + export type V2RenameFileBody = z.input const fileSelectionSchema = { @@ -587,6 +617,17 @@ export const v2DeleteFileContract = defineRouteContract({ }, }) +export const v2RestoreFileContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/files/[fileId]/restore', + params: v2FileParamsSchema, + body: v2RestoreFileBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2FileSchema), + }, +}) + export const v2MoveFileItemsContract = defineRouteContract({ method: 'POST', path: '/api/v2/files/move', diff --git a/apps/sim/lib/api/contracts/v2/knowledge.ts b/apps/sim/lib/api/contracts/v2/knowledge.ts index 1f8e4d4a6c2..6e3078d2c37 100644 --- a/apps/sim/lib/api/contracts/v2/knowledge.ts +++ b/apps/sim/lib/api/contracts/v2/knowledge.ts @@ -39,6 +39,7 @@ import { v2UploadTransferSchema, } from '@/lib/api/contracts/v2/uploads' import { DEFAULT_CHUNKING_CONFIG } from '@/lib/knowledge/constants' +import { rerankerModelSchema } from '@/lib/knowledge/reranker-models' import { knowledgeDocumentUploadMetadataSchema } from '@/lib/knowledge/upload-metadata' import { MAX_KNOWLEDGE_DOCUMENT_FILE_SIZE } from '@/lib/uploads/shared/types' @@ -225,11 +226,57 @@ export const v2KnowledgeDocumentSummarySchema = v2KnowledgeDocumentCoreSchema export type V2KnowledgeDocumentSummary = z.output /** - * Document detail — the summary plus processing state and connector provenance. - * Every field is always present (nullable), mirroring the v1 detail projection. + * Tag values carried on a document read, keyed by tag **display name**. + * + * The read and write surfaces address tags differently, deliberately: + * + * - **Reads are name-keyed.** This map, the `metadata` map on a search result, + * and the `tagName` in a search or document-list tag filter all speak display + * names, so everything a caller reads or filters by uses one vocabulary. + * - **Writes are slot-keyed** (`tag1`..`tag7` on upload and on document update), + * because a slot is the addressable column and a display name is only unique + * per knowledge base and may be renamed. + * + * `GET /api/v2/knowledge/{id}/tags` is the mapping between the two. A slot that + * holds a value but has no definition in the knowledge base appears under its + * raw slot name, matching how knowledge search projects the same columns. + */ +export const v2KnowledgeDocumentTagsSchema = z + .record( + z.string(), + z + .union([z.string(), z.number(), z.boolean(), z.null()]) + .describe('Tag value; dates are ISO 8601 strings and an unset tag is null.') + ) + .describe( + 'Document tag values keyed by tag display name. Writes address the same tags by slot (`tag1`..`tag7`); resolve names to slots with GET /api/v2/knowledge/{id}/tags.' + ) + .meta({ examples: [{ category: 'billing', priority: 2 }] }) + +/** + * Document list item — the summary plus its tag values. Upload acknowledgements + * keep the plain summary: they echo a document the caller has just described, + * so there is no vocabulary to resolve for them. + */ +export const v2KnowledgeTaggedDocumentSchema = v2KnowledgeDocumentSummarySchema + .extend({ + tags: v2KnowledgeDocumentTagsSchema, + }) + .meta({ + id: 'V2KnowledgeTaggedDocument', + title: 'Knowledge document list item', + description: 'Document summary with the document tag values keyed by display name.', + }) +export type V2KnowledgeTaggedDocument = z.output + +/** + * Document detail — the summary plus tag values, processing state and connector + * provenance. Every field is always present (nullable), mirroring the v1 detail + * projection. */ export const v2KnowledgeDocumentSchema = v2KnowledgeDocumentSummarySchema .extend({ + tags: v2KnowledgeDocumentTagsSchema, processingError: z .string() .nullable() @@ -267,6 +314,10 @@ export type V2KnowledgeDocument = z.output */ export const v2KnowledgeSearchResultSchema = z .object({ + knowledgeBaseId: z + .string() + .describe('Knowledge base the matching chunk came from; a search may span up to 20.') + .meta({ examples: ['7c9e6679-7425-40de-944b-e07fc1f90ae7'] }), documentId: z .string() .describe('Identifier of the document containing the matching chunk.') @@ -301,6 +352,13 @@ export const v2KnowledgeSearchResultSchema = z .number() .describe('Similarity score for vector search; tag-only matches use 1.') .meta({ examples: [0.8423] }), + rerankerScore: z + .number() + .optional() + .describe( + 'Relevance score assigned by the reranker, present only on results a reranker ordered. Results are ordered by this score when it is present, which is why it can disagree with `similarity`.' + ) + .meta({ examples: [0.9312] }), }) .meta({ id: 'V2KnowledgeSearchResult', @@ -702,7 +760,7 @@ export const v2DeleteKnowledgeFolderContract = defineRouteContract({ response: { mode: 'json', schema: v2DataResponse(v2DeleteKnowledgeFolderDataSchema) }, }) -const v2KnowledgeSearchTagFilterSchema = v1SearchTagFilterSchema +export const v2KnowledgeSearchTagFilterSchema = v1SearchTagFilterSchema .extend({ tagName: v1SearchTagFilterSchema.shape.tagName .describe('Display name of the tag to filter.') @@ -741,11 +799,29 @@ export const v2KnowledgeSearchBodySchema = v1KnowledgeSearchBodySchema.safeExten .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. With a single knowledge base, an unknown tag name is simply ignored.' + '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.' ), 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.' + ), + 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.' + ), }) export type V2KnowledgeSearchBody = z.input @@ -759,6 +835,53 @@ export const v2SearchKnowledgeContract = defineRouteContract({ }, }) +/** Maximum tag filters accepted on one document-list request. */ +export const MAX_V2_KNOWLEDGE_DOCUMENT_TAG_FILTERS = 10 + +const v2KnowledgeDocumentTagFiltersSchema = z + .array(v2KnowledgeSearchTagFilterSchema) + .max( + MAX_V2_KNOWLEDGE_DOCUMENT_TAG_FILTERS, + `tagFilters cannot contain more than ${MAX_V2_KNOWLEDGE_DOCUMENT_TAG_FILTERS} filters` + ) + +export type V2KnowledgeDocumentTagFilters = z.output + +export type ParsedV2KnowledgeTagFilters = + | { success: true; filters: V2KnowledgeDocumentTagFilters | undefined } + | { success: false; message: string } + +/** + * Decodes the JSON-encoded `tagFilters` query param. + * + * A query param cannot carry a structured array, so the filters travel as JSON + * text and are validated here rather than by the query schema. The result is a + * discriminated union so the caller renders a 400 — no input can escape as an + * unhandled parse failure. + */ +export function parseV2KnowledgeTagFiltersParam( + value: string | undefined +): ParsedV2KnowledgeTagFilters { + if (value === undefined) return { success: true, filters: undefined } + let decoded: unknown + try { + decoded = JSON.parse(value) + } catch { + return { success: false, message: 'tagFilters must be a JSON-encoded array of tag filters' } + } + const parsed = v2KnowledgeDocumentTagFiltersSchema.safeParse(decoded) + if (!parsed.success) { + const issue = parsed.error.issues[0] + return { + success: false, + message: `tagFilters is not a valid tag filter array${ + issue ? `: ${[...issue.path, issue.message].join(' ')}` : '' + }`, + } + } + return { success: true, filters: parsed.data } +} + /** * 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 @@ -784,6 +907,13 @@ export const v2ListKnowledgeDocumentsQuerySchema = v1ListKnowledgeDocumentsQuery ), 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() + .describe( + `A JSON-encoded array of at most ${MAX_V2_KNOWLEDGE_DOCUMENT_TAG_FILTERS} tag filters, using the same display-name shape as knowledge search: \`[{"tagName":"category","operator":"eq","value":"billing"}]\`. A name that is not defined in this knowledge base is rejected, never ignored.` + ) + .meta({ examples: ['[{"tagName":"category","operator":"eq","value":"billing"}]'] }), }) .strict() export type V2ListKnowledgeDocumentsQuery = z.output @@ -795,7 +925,7 @@ export const v2ListKnowledgeDocumentsContract = defineRouteContract({ query: v2ListKnowledgeDocumentsQuerySchema, response: { mode: 'json', - schema: v2CursorListResponse(v2KnowledgeDocumentSummarySchema), + schema: v2CursorListResponse(v2KnowledgeTaggedDocumentSchema), }, }) @@ -866,6 +996,336 @@ export const v2GetKnowledgeDocumentContract = defineRouteContract({ }, }) +/** + * A tag definition — the mapping between the display name reads and filters use + * and the slot writes address. + */ +export const v2KnowledgeTagSchema = z + .object({ + displayName: z + .string() + .describe('Display name used by tag filters and by tag values on document reads.') + .meta({ examples: ['category'] }), + tagSlot: z + .string() + .describe( + 'Storage slot the tag occupies. Document writes set tag values by slot (`tag1`..`tag7`).' + ) + .meta({ examples: ['tag1'] }), + fieldType: z + .string() + .describe('Value type stored in the slot; it determines the valid filter operators.') + .meta({ examples: ['text'] }), + }) + .strict() + .meta({ + id: 'V2KnowledgeTag', + title: 'Knowledge tag', + description: 'A tag defined on a knowledge base, and the slot it is stored in.', + }) +export type V2KnowledgeTag = z.output + +/** + * Tag vocabulary for one knowledge base. A full-set list: the number of tags is + * bounded by the fixed slot table, so the whole set is always one page and + * `nextCursor` is always null. + */ +export const v2ListKnowledgeTagsContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/knowledge/[id]/tags', + params: v2KnowledgeBaseParamsSchema, + query: v1KnowledgeWorkspaceQuerySchema + .extend({ + workspaceId: v1KnowledgeWorkspaceQuerySchema.shape.workspaceId.describe( + 'Workspace that owns the knowledge base.' + ), + }) + .strict(), + response: { + mode: 'json', + schema: v2CursorListResponse(v2KnowledgeTagSchema), + }, +}) + +const v2UpdateKnowledgeDocumentTagSlotSchema = z + .string() + .max(1000, 'Tag values cannot exceed 1000 characters') + +/** + * Number, date and boolean tag slots take their natural JSON type, mirroring + * how a document read projects them. The storage columns are typed + * (`double precision`, `timestamp`, `boolean`), so accepting a loose string + * here would push a malformed value onto a parser that answers `null` — the + * caller would get 200 and a silently cleared tag instead of a 400 naming the + * field. + */ +const v2UpdateKnowledgeDocumentNumberSlotSchema = z + .number() + .finite('Number tag values must be a finite number') + +const v2UpdateKnowledgeDocumentDateSlotSchema = z + .string() + .regex(/^\d{4}-\d{2}-\d{2}$/, 'Date tag values must be formatted YYYY-MM-DD') + .refine((value) => { + const [year, month, day] = value.split('-').map(Number) + const date = new Date(year, month - 1, day) + return date.getFullYear() === year && date.getMonth() === month - 1 && date.getDate() === day + }, 'Date tag values must be a real calendar date') + +const v2UpdateKnowledgeDocumentBooleanSlotSchema = z.boolean() + +/** The typed tag slots, declared once so the body and its mutex refine agree. */ +const v2UpdateKnowledgeDocumentTagSlotFields = { + tag1: v2UpdateKnowledgeDocumentTagSlotSchema.optional().describe('New value for tag slot 1.'), + tag2: v2UpdateKnowledgeDocumentTagSlotSchema.optional().describe('New value for tag slot 2.'), + tag3: v2UpdateKnowledgeDocumentTagSlotSchema.optional().describe('New value for tag slot 3.'), + tag4: v2UpdateKnowledgeDocumentTagSlotSchema.optional().describe('New value for tag slot 4.'), + tag5: v2UpdateKnowledgeDocumentTagSlotSchema.optional().describe('New value for tag slot 5.'), + tag6: v2UpdateKnowledgeDocumentTagSlotSchema.optional().describe('New value for tag slot 6.'), + tag7: v2UpdateKnowledgeDocumentTagSlotSchema.optional().describe('New value for tag slot 7.'), + number1: v2UpdateKnowledgeDocumentNumberSlotSchema + .optional() + .describe('New value for number tag slot 1.'), + number2: v2UpdateKnowledgeDocumentNumberSlotSchema + .optional() + .describe('New value for number tag slot 2.'), + number3: v2UpdateKnowledgeDocumentNumberSlotSchema + .optional() + .describe('New value for number tag slot 3.'), + number4: v2UpdateKnowledgeDocumentNumberSlotSchema + .optional() + .describe('New value for number tag slot 4.'), + number5: v2UpdateKnowledgeDocumentNumberSlotSchema + .optional() + .describe('New value for number tag slot 5.'), + date1: v2UpdateKnowledgeDocumentDateSlotSchema + .optional() + .describe('New value for date tag slot 1, formatted YYYY-MM-DD.'), + date2: v2UpdateKnowledgeDocumentDateSlotSchema + .optional() + .describe('New value for date tag slot 2, formatted YYYY-MM-DD.'), + boolean1: v2UpdateKnowledgeDocumentBooleanSlotSchema + .optional() + .describe('New value for boolean tag slot 1.'), + boolean2: v2UpdateKnowledgeDocumentBooleanSlotSchema + .optional() + .describe('New value for boolean tag slot 2.'), + boolean3: v2UpdateKnowledgeDocumentBooleanSlotSchema + .optional() + .describe('New value for boolean tag slot 3.'), +} as const + +/** Every writable tag slot, in the order `TAG_SLOT_CONFIG` declares them. */ +export const V2_WRITABLE_TAG_SLOTS = Object.keys( + v2UpdateKnowledgeDocumentTagSlotFields +) as ReadonlyArray + +/** + * Document update body. + * + * Only the fields a caller owns are accepted. Derived indexing state + * (`chunkCount`, `tokenCount`, `characterCount`, `processingStatus`, + * `processingError`) is deliberately absent: it is written by the processing + * pipeline, and letting a caller assert `processingStatus: "completed"` on a + * document that was never indexed would silently corrupt search results. + * + * `retryProcessing` requeues a failed or stuck document and is mutually + * exclusive with the field updates — the retry runs instead of them, so + * accepting both would silently drop half the request. + */ +export const v2UpdateKnowledgeDocumentBodySchema = z + .object({ + workspaceId: workspaceIdSchema.describe('Workspace that owns the knowledge base.'), + filename: z + .string() + .trim() + .min(1, 'filename cannot be empty') + .max(255, 'filename is too long') + .optional() + .describe('New filename for the document.') + .meta({ examples: ['getting-started-v2.pdf'] }), + enabled: z + .boolean() + .optional() + .describe('Whether the document participates in search. Disabling keeps it indexed.'), + ...v2UpdateKnowledgeDocumentTagSlotFields, + retryProcessing: z + .literal(true) + .optional() + .describe( + 'Requeue the document for processing. Send it alone: no other field may accompany it.' + ), + }) + .strict() + .superRefine((body, ctx) => { + const mutatedFields = (['filename', 'enabled', ...V2_WRITABLE_TAG_SLOTS] as const).filter( + (field) => body[field] !== undefined + ) + if (body.retryProcessing && mutatedFields.length > 0) { + ctx.addIssue({ + code: 'custom', + path: ['retryProcessing'], + message: `retryProcessing cannot be combined with ${mutatedFields.join(', ')}; send it on its own request`, + }) + return + } + if (!body.retryProcessing && mutatedFields.length === 0) { + ctx.addIssue({ + code: 'custom', + path: ['filename'], + message: 'At least one of filename, enabled, tag1-tag7, or retryProcessing is required', + }) + } + }) +export type V2UpdateKnowledgeDocumentBody = z.input + +/** Acknowledgement for a document requeued by `retryProcessing`. */ +export const v2KnowledgeDocumentProcessingSchema = z + .object({ + id: z.string().describe('Identifier of the requeued document.'), + queued: z.literal(true).describe('Confirms that processing was requeued.'), + processingStatus: z + .string() + .describe('Processing state the document was moved to.') + .meta({ examples: ['pending'] }), + message: z.string().describe('Human-readable outcome of the requeue.'), + }) + .strict() + .meta({ + id: 'V2KnowledgeDocumentProcessing', + title: 'Knowledge document processing acknowledgement', + description: 'Acknowledgement returned when a document is requeued for processing.', + }) + +/** + * The update response is the updated document with its tag values, except for a + * `retryProcessing` request, which returns the requeue acknowledgement — the + * document's indexing state is not yet settled at that point, so returning it + * would be a snapshot of work in flight. The acknowledgement carries + * `queued: true`, which the document never does. + * + * The updated document omits the connector provenance the detail read carries: + * the update writes and returns the document row alone. Re-read with GET for the + * full detail. + */ +const v2UpdateKnowledgeDocumentDataSchema = z.union([ + v2KnowledgeTaggedDocumentSchema, + v2KnowledgeDocumentProcessingSchema, +]) + +export const v2UpdateKnowledgeDocumentContract = defineRouteContract({ + method: 'PATCH', + path: '/api/v2/knowledge/[id]/documents/[documentId]', + params: v2KnowledgeDocumentParamsSchema, + body: v2UpdateKnowledgeDocumentBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2UpdateKnowledgeDocumentDataSchema), + }, +}) + +/** Maximum documents addressable by identifier in one bulk request. */ +export const MAX_V2_BULK_KNOWLEDGE_DOCUMENTS = 100 + +/** + * Bulk document body. + * + * `enable` and `disable` only. A bulk `delete` is deliberately absent: the + * underlying bulk operation records no semantic audit, so a public bulk delete + * would remove a knowledge base's documents leaving no `DOCUMENT_DELETED` + * entries, while `DELETE /api/v2/knowledge/{id}/documents/{documentId}` audits + * every single deletion. Delete documents one request at a time. + */ +export const v2BulkKnowledgeDocumentsBodySchema = z + .object({ + workspaceId: workspaceIdSchema.describe('Workspace that owns the knowledge base.'), + operation: z + .enum(['enable', 'disable'], { + error: 'operation: expected one of "enable" | "disable"', + }) + .describe('Whether the selected documents become enabled or disabled for search.'), + documentIds: z + .array(z.string().min(1, 'documentIds entries cannot be empty')) + .min(1, 'documentIds cannot be empty') + .max( + MAX_V2_BULK_KNOWLEDGE_DOCUMENTS, + `documentIds cannot contain more than ${MAX_V2_BULK_KNOWLEDGE_DOCUMENTS} documents` + ) + .optional() + .describe('Documents to update, by identifier.'), + selectAll: z + .literal(true) + .optional() + .describe( + 'Update every document in the knowledge base instead of an explicit list, narrowed by `enabledFilter`.' + ), + enabledFilter: z + .enum(['all', 'enabled', 'disabled']) + .optional() + .describe('With `selectAll`, restrict the update to documents in this state.'), + }) + .strict() + .superRefine((body, ctx) => { + if (body.selectAll && body.documentIds) { + ctx.addIssue({ + code: 'custom', + path: ['documentIds'], + message: 'documentIds cannot be combined with selectAll', + }) + } + if (!body.selectAll && !body.documentIds) { + ctx.addIssue({ + code: 'custom', + path: ['documentIds'], + message: 'Either documentIds or selectAll is required', + }) + } + if (body.enabledFilter && !body.selectAll) { + ctx.addIssue({ + code: 'custom', + path: ['enabledFilter'], + message: 'enabledFilter applies only with selectAll', + }) + } + }) +export type V2BulkKnowledgeDocumentsBody = z.input + +/** Bulk update outcome — one object, not a page. */ +export const v2BulkKnowledgeDocumentsDataSchema = z + .object({ + operation: z.enum(['enable', 'disable']).describe('Operation that was applied.'), + updatedCount: z + .number() + .int() + .nonnegative() + .describe('Number of documents the operation changed.') + .meta({ examples: [42] }), + documentIds: z + .array(z.string()) + .optional() + .describe( + 'Identifiers of the documents the operation changed. Present only for an explicit `documentIds` request, which is bounded to ' + + `${MAX_V2_BULK_KNOWLEDGE_DOCUMENTS} documents; a \`selectAll\` request omits it because the selection is unbounded, and reports \`updatedCount\` instead.` + ), + }) + .strict() + .meta({ + id: 'V2BulkKnowledgeDocumentsData', + title: 'Bulk knowledge document update data', + description: 'Outcome of a bulk enable or disable across knowledge documents.', + }) + +export const v2BulkUpdateKnowledgeDocumentsContract = defineRouteContract({ + method: 'PATCH', + path: '/api/v2/knowledge/[id]/documents', + params: v2KnowledgeBaseParamsSchema, + body: v2BulkKnowledgeDocumentsBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2BulkKnowledgeDocumentsDataSchema), + }, +}) + export const v2DeleteKnowledgeDocumentContract = defineRouteContract({ method: 'DELETE', path: '/api/v2/knowledge/[id]/documents/[documentId]', diff --git a/apps/sim/lib/api/contracts/v2/logs.ts b/apps/sim/lib/api/contracts/v2/logs.ts index a8f7c1af30e..97ab3e76e0d 100644 --- a/apps/sim/lib/api/contracts/v2/logs.ts +++ b/apps/sim/lib/api/contracts/v2/logs.ts @@ -9,6 +9,7 @@ import { v2FolderPathInputSchema, v2FolderPathSchema, v2PaginationFields, + v2RunWindowBoundSchema, v2TimestampSchema, } from '@/lib/api/contracts/v2/shared' import { PERSISTED_WORKFLOW_EXECUTION_STATUSES } from '@/lib/logs/types' @@ -28,11 +29,23 @@ const v2LogCostSchema = z * reported set is exactly the persisted set — a value missing here fails the response * parse, and because list validation is whole-page one such row turns an entire page * into a 500. + * + * That pass-through is also why this field disagrees with the run resources for the + * same run, and the disagreement is documented rather than reconciled. The run list + * projects `paused` over the persisted value whenever the run holds a `paused` or + * `partially_resumed` row in `paused_executions` (`executionStatus` in + * `lib/workflows/executor/execution-queries.ts`), so an ordinary human-in-the-loop + * pause reads `paused` there and `pending` here. Adopting the overlay would need this + * read to join `paused_executions`, and would silently move live runs between the + * `pending` and `paused` buckets of a shipped field that internal log consumers read + * from the same query — a breaking change, not a correction. Callers that need the + * pause distinction read the run resources, which also carry the `paused` object that + * separates "waiting on a human" from "a resume attempt failed". */ export const v2LogStatusSchema = z .enum(PERSISTED_WORKFLOW_EXECUTION_STATUSES) .describe( - 'Current execution status. `redacting` is transient while run output is scrubbed. `paused` is reported when a resume attempt did not run to completion and the run is waiting to be resumed again.' + '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.' ) /** Execution `files` is a per-run jsonb array of attachment metadata. */ @@ -189,14 +202,8 @@ export const v2ListLogsQuerySchema = v1ListLogsQuerySchema workflowIds: z.string().describe('Comma-separated workflow identifiers to include.').optional(), triggers: z.string().describe('Comma-separated trigger types to include.').optional(), level: z.enum(['info', 'error']).describe('Severity level to include.').optional(), - startDate: z - .string() - .describe('Only include runs started at or after this ISO 8601 timestamp.') - .optional(), - endDate: z - .string() - .describe('Only include runs started at or before this ISO 8601 timestamp.') - .optional(), + startDate: v2RunWindowBoundSchema('startDate').optional(), + endDate: v2RunWindowBoundSchema('endDate').optional(), runId: z .string() .min(1, 'runId cannot be empty') @@ -232,9 +239,20 @@ export const v2ListLogsQuerySchema = v1ListLogsQuerySchema outOfRange: 'clamp', description: 'Maximum log entries per page.', }), + /** + * Deliberate deviation from the v2 `sortBy` + `sortOrder` convention, and + * the same one `GET /workflows/{id}/runs` makes for the same reason: logs + * have exactly one sortable column (execution start time), so there is no + * `sortBy` to pair with. `order` is the published name and renaming it + * 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. + */ order: z .enum(['desc', 'asc']) - .describe('Sort order by execution start time.') + .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'), folderPaths: z @@ -262,6 +280,23 @@ export const v2ListLogsQuerySchema = v1ListLogsQuerySchema }), }) .strict() + /** + * The other half of the parity with the sibling run list + * (`v2ListWorkflowRunsQuerySchema`): agreeing on the timestamp *format* while + * still disagreeing on window *validity* would leave an inverted window a 400 on + * `/runs` and a silently empty page here — the same wrong-answer-instead-of-error + * shape the format check was added to remove. + */ + .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'], + } + ) export const v2ListLogsContract = defineRouteContract({ method: 'GET', diff --git a/apps/sim/lib/api/contracts/v2/mcp-servers.ts b/apps/sim/lib/api/contracts/v2/mcp-servers.ts index 4ceae20d290..46fdd9b03b5 100644 --- a/apps/sim/lib/api/contracts/v2/mcp-servers.ts +++ b/apps/sim/lib/api/contracts/v2/mcp-servers.ts @@ -1,10 +1,15 @@ import { z } from 'zod' import { mcpAuthTypeSchema, mcpServerSchema, mcpTransportSchema } from '@/lib/api/contracts/mcp' -import { nonEmptyIdSchema, workspaceIdSchema } from '@/lib/api/contracts/primitives' +import { + booleanQueryFlagSchema, + nonEmptyIdSchema, + workspaceIdSchema, +} from '@/lib/api/contracts/primitives' import { defineRouteContract } from '@/lib/api/contracts/types' import { v2CursorListResponse, v2DataResponse, + v2PaginationFields, v2SearchSchema, v2SortFields, } from '@/lib/api/contracts/v2/shared' @@ -163,7 +168,7 @@ export const v2McpServerDeleteDataSchema = z export type V2McpServerDeleteData = z.output export const v2McpServerParamsSchema = z.object({ - id: nonEmptyIdSchema.describe('MCP server to retrieve, update, or delete.'), + id: nonEmptyIdSchema.describe('MCP server the operation acts on.'), }) export type V2McpServerParams = z.output @@ -180,6 +185,7 @@ export const v2ListMcpServersQuerySchema = v2McpServerWorkspaceQuerySchema .extend({ search: v2SearchSchema.describe('Case-insensitive substring match against the server name.'), ...v2SortFields(v2McpServerSortFields, { sortBy: 'createdAt', sortOrder: 'desc' }), + ...v2PaginationFields({ description: 'Maximum MCP servers to return per page.' }), }) .strict() @@ -274,9 +280,69 @@ export const v2UpdateMcpServerBodySchema = v2CreateMcpServerBodySchema export type V2UpdateMcpServerBody = z.input /** - * MCP server list. The per-workspace set is small and bounded, so the full set - * is returned as a single page (`nextCursor` is always `null`); the canonical - * cursor envelope keeps the v2 list surface uniform. + * A tool's argument schema, as the MCP server reports it. + * + * Everything below the `object` wrapper is authored by the third-party server, + * so it is published open (`catchall`) and passed through rather than + * re-validated keyword by keyword — the same treatment the v2 custom-tool + * 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. + */ +const v2McpToolInputSchema = z + .object({ + type: z + .literal('object') + .describe('JSON Schema type of the argument object. MCP requires `object`.'), + properties: z + .record(z.string(), z.unknown().describe('Server-defined JSON Schema for one tool argument.')) + .optional() + .describe('Argument schemas keyed by argument name.'), + required: 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.") + +/** One tool exposed by a registered MCP server. */ +export const v2McpToolSchema = z + .object({ + name: z.string().describe('Tool name, as the MCP server reports it.'), + description: z.string().optional().describe('Tool description reported by the server.'), + inputSchema: v2McpToolInputSchema, + serverId: z.string().describe('Identifier of the MCP server exposing the tool.'), + serverName: z.string().describe('Display name of the MCP server exposing the tool.'), + }) + .strict() + .meta({ + id: 'V2McpTool', + title: 'MCP tool', + description: 'A tool exposed by a registered MCP server.', + }) +export type V2McpTool = z.output + +export const v2ListMcpServerToolsQuerySchema = v2McpServerWorkspaceQuerySchema + .extend({ + refresh: booleanQueryFlagSchema + .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.' + ), + }) + .strict() +export type V2ListMcpServerToolsQuery = z.output + +/** + * MCP server list, keyset-paginated over the active sort. + * + * Nothing caps how many servers a workspace may register, so the original + * single-page shape was the one unbounded list on the v2 surface. Callers that + * relied on reading every server from one response must now follow `nextCursor`. */ export const v2ListMcpServersContract = defineRouteContract({ method: 'GET', @@ -331,3 +397,20 @@ export const v2DeleteMcpServerContract = defineRouteContract({ schema: v2DataResponse(v2McpServerDeleteDataSchema), }, }) + +/** + * One server's tool inventory, returned as a single page (`nextCursor` is always + * `null`). Unlike the server list, this set is bounded by construction: tool + * discovery stops at 1,000 tools and 5 MB of tool payload per server no matter + * what the upstream server reports, so there is no page for a cursor to name. + */ +export const v2ListMcpServerToolsContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/mcp-servers/[id]/tools', + params: v2McpServerParamsSchema, + query: v2ListMcpServerToolsQuerySchema, + response: { + mode: 'json', + schema: v2CursorListResponse(v2McpToolSchema), + }, +}) 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 b94bc2d4ce4..5a875a657f8 100644 --- a/apps/sim/lib/api/contracts/v2/openapi/files-audit.ts +++ b/apps/sim/lib/api/contracts/v2/openapi/files-audit.ts @@ -18,6 +18,7 @@ import { v2MoveFileItemsContract, v2RelocateFileFolderContract, v2RenameFileContract, + v2RestoreFileContract, v2UpdateFileContentContract, v2UpsertFileShareContract, } from '@/lib/api/contracts/v2/files' @@ -25,6 +26,7 @@ import { documentedSchema, ERROR_RESPONSES, type ErrorResponseId, + FULL_SET_LIST, RATE_LIMIT_HEADERS, RESOURCE_CONFLICT_ERRORS, RESOURCE_ERRORS, @@ -52,6 +54,7 @@ const FILE_EXAMPLE = { uploadedByEmail: 'jane@example.com', uploadedAt: '2026-01-15T10:30:00Z', updatedAt: '2026-01-15T10:30:00Z', + deletedAt: null, } as const const SHARE_EXAMPLE = { @@ -119,7 +122,7 @@ const routes = [ operationId: 'listFiles', summary: 'List Files', description: - 'List workspace files with search, sorting, folder filtering, and opaque cursor pagination.', + '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, success: { description: 'A page of workspace files.' }, }), @@ -356,7 +359,7 @@ 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 listings and is no longer readable through the API, and its stored bytes are never removed. An archived file can be restored from the workspace Recently Deleted settings; the v2 API exposes no restore operation.', + '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, success: { description: 'Deletion confirmation.' }, }), @@ -418,6 +421,39 @@ const routes = [ ), } ), + defineOpenApiRoute( + v2RestoreFileContract, + filesOperation({ + 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, + success: { description: 'The file as it exists after the restore.' }, + }), + { + params: documentedSchema( + v2RestoreFileContract.params, + 'RestoreFileParams', + 'Restore file path parameters', + 'Archived file selected for restore.' + ), + body: documentedSchema( + v2RestoreFileContract.body, + 'RestoreFileRequest', + 'Restore file request', + 'Workspace scope for the archived file.', + [{ workspaceId: 'a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64' }] + ), + response: documentedSchema( + v2RestoreFileContract.response.schema, + 'V2RestoreFileResponse', + 'Restore file response', + 'The restored workspace file, at the root and under its post-restore name.', + [{ data: { ...FILE_EXAMPLE, name: 'data_restored.csv', folderPath: '/' } }] + ), + } + ), defineOpenApiRoute( v2GetFileContract, filesOperation({ @@ -687,8 +723,7 @@ const routes = [ filesOperation({ 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. ${FULL_SET_LIST}`, errors: RESOURCE_ERRORS, success: { description: 'Workspace file folders.' }, }), diff --git a/apps/sim/lib/api/contracts/v2/openapi/knowledge.ts b/apps/sim/lib/api/contracts/v2/openapi/knowledge.ts index dd4ecf3f022..9ab7f2aa4d3 100644 --- a/apps/sim/lib/api/contracts/v2/openapi/knowledge.ts +++ b/apps/sim/lib/api/contracts/v2/openapi/knowledge.ts @@ -1,5 +1,6 @@ import { v2AbortKnowledgeDocumentUploadContract, + v2BulkUpdateKnowledgeDocumentsContract, v2CompleteKnowledgeDocumentUploadContract, v2CreateKnowledgeBaseContract, v2CreateKnowledgeDocumentUploadContract, @@ -13,9 +14,11 @@ import { v2ListKnowledgeBasesContract, v2ListKnowledgeDocumentsContract, v2ListKnowledgeFoldersContract, + v2ListKnowledgeTagsContract, v2RelocateKnowledgeFolderContract, v2SearchKnowledgeContract, v2UpdateKnowledgeBaseContract, + v2UpdateKnowledgeDocumentContract, v2UploadKnowledgeDocumentContract, v2UploadKnowledgeDocumentFormSchema, } from '@/lib/api/contracts/v2/knowledge' @@ -24,6 +27,7 @@ import { ERROR_RESPONSES, type ErrorResponseId, FOLDER_TREE_TOO_LARGE, + FULL_SET_LIST, RATE_LIMIT_HEADERS, RESOURCE_CONFLICT_ERRORS, RESOURCE_ERRORS, @@ -31,6 +35,7 @@ import { V2_API_KEY_SECURITY_SCHEMES, V2_COMMON_HEADERS, V2_ERROR_SCHEMA, + WORKSPACE_API_KEY_DENIED, WORKSPACE_ERRORS, } from '@/lib/api/contracts/v2/openapi/shared' import { @@ -206,7 +211,7 @@ 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. 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. 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.', errors: [...WORKSPACE_ERRORS, 'UsageLimitExceeded', 'NotFound', 'PayloadTooLarge'], success: { description: 'Matching document chunks ordered by relevance.' }, }), @@ -233,13 +238,43 @@ const routes = [ ), } ), + defineOpenApiRoute( + v2ListKnowledgeTagsContract, + 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}`, + errors: RESOURCE_ERRORS, + success: { description: 'The knowledge base tag vocabulary.' }, + }), + { + params: documentedSchema( + v2ListKnowledgeTagsContract.params, + 'ListKnowledgeTagsParams', + 'List knowledge tags path parameters', + 'Knowledge base whose tags should be listed.' + ), + query: documentedSchema( + v2ListKnowledgeTagsContract.query, + 'ListKnowledgeTagsQuery', + 'List knowledge tags query', + 'Workspace scope for the knowledge base.' + ), + response: documentedSchema( + v2ListKnowledgeTagsContract.response.schema, + 'V2KnowledgeTagListResponse', + 'Knowledge tag list response', + 'The full tag vocabulary of one knowledge base.' + ), + } + ), defineOpenApiRoute( v2ListKnowledgeDocumentsContract, knowledgeOperation({ operationId: 'listKnowledgeDocuments', summary: 'List Documents', description: - 'List documents in a knowledge base with filename search, state filtering, sorting, and opaque cursor pagination.', + '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`.', errors: RESOURCE_ERRORS, success: { description: 'A page of knowledge documents.' }, }), @@ -254,7 +289,7 @@ const routes = [ v2ListKnowledgeDocumentsContract.query, 'ListKnowledgeDocumentsQuery', 'List knowledge documents query', - 'Workspace, pagination, filtering, search, and sorting options.' + 'Workspace, pagination, filtering, tag filtering, search, and sorting options.' ), response: documentedSchema( v2ListKnowledgeDocumentsContract.response.schema, @@ -264,6 +299,43 @@ const routes = [ ), } ), + defineOpenApiRoute( + v2BulkUpdateKnowledgeDocumentsContract, + 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, + success: { description: 'The number and identifiers of the documents that changed.' }, + }), + { + params: documentedSchema( + v2BulkUpdateKnowledgeDocumentsContract.params, + 'BulkUpdateKnowledgeDocumentsParams', + 'Bulk knowledge document path parameters', + 'Knowledge base whose documents should be updated.' + ), + body: documentedSchema( + v2BulkUpdateKnowledgeDocumentsContract.body, + 'BulkUpdateKnowledgeDocumentsRequest', + 'Bulk knowledge document request', + 'Operation and the documents it applies to.', + [ + { + workspaceId: WORKSPACE_ID, + operation: 'disable', + documentIds: ['b2d4f8a0-1c3e-4a5b-9d7c-2e6f0a8b4c12'], + }, + ] + ), + response: documentedSchema( + v2BulkUpdateKnowledgeDocumentsContract.response.schema, + 'V2BulkKnowledgeDocumentsResponse', + 'Bulk knowledge document response', + 'Outcome of a bulk enable or disable.' + ), + } + ), defineOpenApiRoute( v2UploadKnowledgeDocumentContract, knowledgeOperation({ @@ -501,6 +573,37 @@ const routes = [ ), } ), + defineOpenApiRoute( + v2UpdateKnowledgeDocumentContract, + 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, + success: { description: 'The updated document, or the requeue acknowledgement.' }, + }), + { + params: documentedSchema( + v2UpdateKnowledgeDocumentContract.params, + 'UpdateKnowledgeDocumentParams', + 'Update knowledge document path parameters', + 'Knowledge base and document selected for update.' + ), + body: documentedSchema( + v2UpdateKnowledgeDocumentContract.body, + 'UpdateKnowledgeDocumentRequest', + 'Update knowledge document request', + 'Filename, search state, tag slot values, or a processing retry.', + [{ workspaceId: WORKSPACE_ID, enabled: false, tag1: 'billing' }] + ), + response: documentedSchema( + v2UpdateKnowledgeDocumentContract.response.schema, + 'V2UpdateKnowledgeDocumentResponse', + 'Update knowledge document response', + 'The updated document, or the processing requeue acknowledgement.' + ), + } + ), defineOpenApiRoute( v2DeleteKnowledgeDocumentContract, knowledgeOperation({ @@ -537,7 +640,7 @@ const routes = [ knowledgeOperation({ 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. ${FOLDER_TREE_TOO_LARGE}`, + description: `List folders in the knowledge-base folder tree with filtering and sorting. ${FULL_SET_LIST} ${FOLDER_TREE_TOO_LARGE}`, errors: [...RESOURCE_ERRORS, 'PayloadTooLarge'], success: { description: 'A page of knowledge-base folders.' }, }), diff --git a/apps/sim/lib/api/contracts/v2/openapi/resources.ts b/apps/sim/lib/api/contracts/v2/openapi/resources.ts index 66ebd341d63..9a527057188 100644 --- a/apps/sim/lib/api/contracts/v2/openapi/resources.ts +++ b/apps/sim/lib/api/contracts/v2/openapi/resources.ts @@ -11,13 +11,16 @@ import { v2DeleteMcpServerContract, v2GetMcpServerContract, v2ListMcpServersContract, + v2ListMcpServerToolsContract, v2UpdateMcpServerContract, } from '@/lib/api/contracts/v2/mcp-servers' import { documentedSchema, ERROR_RESPONSES, type ErrorResponseId, + FULL_SET_LIST, RATE_LIMIT_HEADERS, + RESOURCE_CONFLICT_ERRORS, V2_API_KEY_SECURITY, V2_API_KEY_SECURITY_SCHEMES, V2_COMMON_HEADERS, @@ -90,6 +93,18 @@ const MCP_SERVER_EXAMPLE = { hasOauthClientSecret: false, } as const +const MCP_TOOL_EXAMPLE = { + name: 'search_docs', + description: 'Search the internal documentation', + inputSchema: { + type: 'object', + properties: { query: { type: 'string', description: 'Search terms' } }, + required: ['query'], + }, + serverId: 'mcp-3f7a9c21', + serverName: 'Docs server', +} as const + const SKILL_SUMMARY_EXAMPLE = { id: 'V1StGXR8Z5jdHi6BmyT', name: 'refund-policy', @@ -251,7 +266,7 @@ 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. The bounded workspace set uses the standard cursor envelope with `nextCursor` always null; there is no second page to fetch.', + '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'], success: { description: 'MCP servers registered in the workspace.' }, }), @@ -260,7 +275,7 @@ const routes = [ v2ListMcpServersContract.query, 'ListMcpServersQuery', 'List MCP servers query', - 'Workspace, search, and sorting controls for MCP servers.' + 'Workspace, search, sorting, and pagination controls for MCP servers.' ), response: documentedSchema( v2ListMcpServersContract.response.schema, @@ -403,6 +418,37 @@ const routes = [ ), } ), + defineOpenApiRoute( + v2ListMcpServerToolsContract, + 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.`, + errors: RESOURCE_CONFLICT_ERRORS, + success: { description: 'Tools exposed by the MCP server.' }, + }), + { + params: documentedSchema( + v2ListMcpServerToolsContract.params, + 'ListMcpServerToolsParams', + 'List MCP server tools path parameters', + 'MCP server whose tools should be listed.' + ), + query: documentedSchema( + v2ListMcpServerToolsContract.query, + 'ListMcpServerToolsQuery', + 'List MCP server tools query', + 'Workspace scope and cache control for tool discovery.' + ), + response: documentedSchema( + v2ListMcpServerToolsContract.response.schema, + 'ListMcpServerToolsResponse', + 'List MCP server tools response', + 'Tools exposed by the MCP server.', + [{ data: [MCP_TOOL_EXAMPLE], nextCursor: null }] + ), + } + ), defineOpenApiRoute( v2ListSkillsContract, resourceOperation('Skills', { diff --git a/apps/sim/lib/api/contracts/v2/openapi/shared.ts b/apps/sim/lib/api/contracts/v2/openapi/shared.ts index e2d5654ae30..a2de98da422 100644 --- a/apps/sim/lib/api/contracts/v2/openapi/shared.ts +++ b/apps/sim/lib/api/contracts/v2/openapi/shared.ts @@ -5,6 +5,10 @@ import type { OpenApiHeader, 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', @@ -21,6 +25,31 @@ export const WORKSPACE_ERRORS = [ 'ServiceUnavailable', ] as const +/** + * The 403 description, assembled from the closed cause set rather than written + * out, so a new code is published the moment it exists. + * + * Four remedies hide behind one status — raise a role, switch key kind, + * re-point a workspace key, change the plan — and prose is not branchable, so a + * 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. + */ +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') + export const ERROR_RESPONSES = { BadRequest: { status: 400, description: 'The request is invalid.' }, Unauthorized: { status: 401, description: 'The API key is missing or invalid.' }, @@ -28,7 +57,7 @@ export const ERROR_RESPONSES = { status: 402, description: 'The workspace has exceeded its usage or billing limits.', }, - Forbidden: { status: 403, description: 'The caller lacks access to the resource.' }, + Forbidden: { status: 403, description: FORBIDDEN_DESCRIPTION }, NotFound: { status: 404, description: 'The requested resource was not found.' }, Conflict: { status: 409, description: 'The request conflicts with current resource state.' }, RunIdConflict: { @@ -131,6 +160,17 @@ export const V2_API_KEY_SECURITY_SCHEMES = { 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.' +/** + * 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. + */ +export const FULL_SET_LIST = + 'The bounded set is returned in one page with `nextCursor` always null; there is no second page to fetch.' + /** * Appended to an operation whose semantic operation sets `workspaceApiKey: 'deny'`. * That policy is structural — an `admin` operation can never accept a workspace key — diff --git a/apps/sim/lib/api/contracts/v2/openapi/tables.ts b/apps/sim/lib/api/contracts/v2/openapi/tables.ts index 64a059c95ae..c23448e95ce 100644 --- a/apps/sim/lib/api/contracts/v2/openapi/tables.ts +++ b/apps/sim/lib/api/contracts/v2/openapi/tables.ts @@ -3,6 +3,7 @@ import { ERROR_RESPONSES, type ErrorResponseId, FOLDER_TREE_TOO_LARGE, + FULL_SET_LIST, RATE_LIMIT_HEADERS, RESOURCE_CONFLICT_ERRORS, RESOURCE_ERRORS, @@ -46,6 +47,7 @@ import { v2ListTableViewsContract, v2ListWorkflowGroupsContract, v2QueryRowsContract, + v2QueryRowsCountContract, v2RelocateTableFolderContract, v2RunRowEnrichmentContract, v2RunTableColumnContract, @@ -90,6 +92,17 @@ const TABLE_MUTATION_ERRORS = [ 'Locked', ] 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. + */ +const TABLE_QUERY_ERRORS = [ + ...RESOURCE_ERRORS, + 'PayloadTooLarge', +] as const satisfies readonly ErrorResponseId[] + function tableOperation( operation: Omit & { errors: readonly ErrorResponseId[] @@ -652,13 +665,49 @@ const routes = [ ), } ), + defineOpenApiRoute( + v2QueryRowsCountContract, + tableOperation({ + 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`.', + errors: TABLE_QUERY_ERRORS, + success: { description: 'The number of matching table rows.' }, + }), + { + params: documentedSchema( + v2QueryRowsCountContract.params, + 'CountTableRowsParams', + 'Count table rows path parameters', + 'Table whose matching rows should be counted.' + ), + body: documentedSchema( + v2QueryRowsCountContract.body, + 'CountTableRowsRequest', + 'Count table rows request', + 'Workspace scope and the optional predicate whose matches are counted.', + [ + { + workspaceId: WORKSPACE_ID, + predicate: { all: [{ field: 'status', op: 'eq', value: 'active' }] }, + }, + ] + ), + response: documentedSchema( + v2QueryRowsCountContract.response.schema, + 'V2CountTableRowsResponse', + 'Count table rows response', + 'The total number of table rows matching the predicate.' + ), + } + ), defineOpenApiRoute( v2ListTableViewsContract, tableOperation({ 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. ${FULL_SET_LIST}`, errors: RESOURCE_ERRORS, success: { description: 'The saved table views.' }, }), @@ -821,8 +870,7 @@ const routes = [ tableOperation({ 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. ${FULL_SET_LIST}`, errors: RESOURCE_ERRORS, success: { description: 'The table workflow groups.' }, }), @@ -1396,8 +1444,7 @@ const routes = [ tableOperation({ 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. ${FULL_SET_LIST}`, errors: [...WORKSPACE_ERRORS, 'NotFound', 'PayloadTooLarge'], success: { description: 'The table folders.' }, }), diff --git a/apps/sim/lib/api/contracts/v2/openapi/workflows.ts b/apps/sim/lib/api/contracts/v2/openapi/workflows.ts index 15bd3cb2fab..30536db8fec 100644 --- a/apps/sim/lib/api/contracts/v2/openapi/workflows.ts +++ b/apps/sim/lib/api/contracts/v2/openapi/workflows.ts @@ -3,6 +3,7 @@ import { ERROR_RESPONSES, type ErrorResponseId, FOLDER_TREE_TOO_LARGE, + FULL_SET_LIST, RATE_LIMIT_HEADERS, RESOURCE_CONFLICT_ERRORS, V2_API_KEY_SECURITY, @@ -24,6 +25,7 @@ import { v2ExecuteWorkflowSyncResponseSchema, v2ExportWorkflowContract, v2GetWorkflowContract, + v2GetWorkflowDeploymentContract, v2GetWorkflowRunContract, v2GetWorkflowVersionContract, v2ImportWorkflowContract, @@ -325,6 +327,54 @@ const routes = [ ), } ), + defineOpenApiRoute( + v2GetWorkflowDeploymentContract, + workflowOperation({ + 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.', + errors: RESOURCE_ERRORS, + success: jsonSuccess('The current deployment state.'), + }), + { + params: v2GetWorkflowDeploymentContract.params, + response: documentedSchema( + v2GetWorkflowDeploymentContract.response.schema, + 'WorkflowDeploymentResponse', + 'Workflow deployment response', + 'Current deployment state, including draft-versus-live drift.', + [ + { + data: { + id: WORKFLOW_ID, + isDeployed: true, + needsRedeployment: true, + deployedAt: '2026-06-12T10:30:00.000Z', + warnings: [], + activeDeployment: { + deploymentVersionId: 'depver_01J8ZK3QW4M6X2R9T7B5C0V2', + version: 3, + deployedAt: '2026-06-12T10:30:00.000Z', + }, + latestDeploymentAttempt: { + id: 'depop_01J8ZK3QW4M6X2R9T7B5C0V1', + deploymentVersionId: 'depver_01J8ZK3QW4M6X2R9T7B5C0V2', + version: 3, + action: 'deploy', + status: 'active', + isCurrent: true, + readiness: { webhooks: 'ready', schedules: 'ready', mcp: 'not_applicable' }, + requestedAt: '2026-06-12T10:29:58.000Z', + activatedAt: '2026-06-12T10:30:00.000Z', + error: null, + }, + }, + }, + ] + ), + } + ), defineOpenApiRoute( v2DeployWorkflowContract, workflowOperation({ @@ -695,8 +745,7 @@ const routes = [ workflowOperation({ 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. ${FULL_SET_LIST}`, errors: [...WORKSPACE_ERRORS, 'NotFound', 'PayloadTooLarge'], success: jsonSuccess('A list of workflow folders.'), }), diff --git a/apps/sim/lib/api/contracts/v2/shared.ts b/apps/sim/lib/api/contracts/v2/shared.ts index 62647fa6a74..17a9e83073e 100644 --- a/apps/sim/lib/api/contracts/v2/shared.ts +++ b/apps/sim/lib/api/contracts/v2/shared.ts @@ -278,6 +278,32 @@ export function v2PaginationFields(options: V2LimitOptions = {}) { * any of the name columns it is aimed at, and every one of these matches is an * unindexed scan. */ +/** + * A run-window bound, for the two collections that filter on run start time. + * + * Both bounds are constructed into `Date`s by their route and reach the query as + * bound timestamps, so an unparseable value would arrive as an `Invalid Date` + * and fail inside the driver's timestamp mapper — a caller-reachable 500. + * Validating the format here is what keeps that a 400. + * + * The form is `z.datetime()`, which is UTC-only: a date with no time + * (`2026-08-06`) and an offset-bearing timestamp (`2026-08-06T00:00:00+02:00`) + * are both rejected. `GET /logs` and `GET /workflows/{id}/runs` are sibling + * 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". + */ +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` }) + .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.` + ) + .meta({ format: 'date-time' }) +} + export const v2SearchSchema = z .string() .trim() diff --git a/apps/sim/lib/api/contracts/v2/tables.ts b/apps/sim/lib/api/contracts/v2/tables.ts index 37480e4275c..391e10b0758 100644 --- a/apps/sim/lib/api/contracts/v2/tables.ts +++ b/apps/sim/lib/api/contracts/v2/tables.ts @@ -364,13 +364,25 @@ const v2TableColumnInputShape = { id: z.string().optional().describe('Optional client-provided column identifier.'), name: columnNameSchema.describe('Column name.'), type: columnTypeSchema.describe('Column data type.'), + required: z.boolean().optional().describe('Whether inserts must supply a value for this column.'), unique: z.boolean().optional().describe('Whether values in the column must be unique.'), options: selectOptionsSchema.optional().describe('Select options for select-type columns.'), multiple: z.boolean().optional().describe('Whether a select column accepts multiple values.'), currencyCode: currencyCodeSchema.optional().describe('ISO 4217 code for currency columns.'), } -/** Public column input. `required` is response-only and rejected on every v2 write. */ +/** + * Public column input. + * + * `required` round-trips: it is emitted on every column read, accepted here, and + * accepted on the update body below. + * + * The two write paths enforce it differently, matching v1. Turning it ON via + * update is rejected with a 400 naming the count of rows that hold null, + * missing, or empty cells. Add-column applies the flag as given without + * inspecting existing rows — the same shape `unique` already had on this + * surface — so a column added as required only constrains later writes. + */ export const v2TableColumnInputSchema = z .object(v2TableColumnInputShape) .strict() @@ -582,6 +594,10 @@ export const v2UpdateTableColumnBodySchema = z .object({ name: columnNameSchema.optional().describe('Replacement column name.'), type: columnTypeSchema.optional().describe('Replacement column data type.'), + required: z + .boolean() + .optional() + .describe('Whether inserts must supply a value for this column.'), unique: z.boolean().optional().describe('Whether values in the column must be unique.'), options: selectOptionsSchema .optional() @@ -624,11 +640,20 @@ export const v2UpdateTableColumnContract = defineRouteContract({ }, }) +/** + * The first-party body, narrowed to `.strict()` for the public surface. The + * first-party schema stays permissive because the grid posts it; a public caller + * that misspells `columnName` must hear about it rather than get a 400 about a + * missing field it believes it sent. + */ +export const v2DeleteTableColumnBodySchema = deleteTableColumnBodySchema.strict() +export type V2DeleteTableColumnBody = z.input + export const v2DeleteTableColumnContract = defineRouteContract({ method: 'DELETE', path: '/api/v2/tables/[tableId]/columns', params: tableIdParamsSchema, - body: deleteTableColumnBodySchema, + body: v2DeleteTableColumnBodySchema, response: { mode: 'json', schema: v2DataResponse(v2TableColumnsDataSchema), @@ -685,29 +710,70 @@ export const v2ListTableRowsContract = defineRouteContract({ * field refs keyed by column NAME. `limit`: omitted → * {@link V2_DEFAULT_ROW_LIMIT}; `0` → unbounded (whole result or a 400 * `TABLE_QUERY_RESULT_TOO_LARGE`); `1..{@link V2_MAX_ROW_LIMIT}` → page cap. + * + * `.strict()` earns its place here more than anywhere else on this surface: v1 + * named its row filter `filter`, and while this body tolerated unknown keys that + * request was answered with 200 and a fully unfiltered page. + * + * It binds the top level only, so the shared `sortSpecSchema` element carries + * its own `.strict()` — otherwise `sort: [{ field, direction, nulls: 'last' }]` + * is answered 200 with the null-ordering request dropped. */ -export const v2QueryRowsBodySchema = z.object({ - workspaceId: workspaceIdSchema, - predicate: predicateSchema.optional(), - sort: sortSpecSchema.optional().describe('Ordered table-row sort specification.'), - limit: z - .number({ error: 'Limit must be a number' }) - .int('Limit must be an integer') - .min(0, 'Limit must be at least 0 (use 0 for an unbounded query)') - .max( - V2_MAX_ROW_LIMIT, - `Limit cannot exceed ${V2_MAX_ROW_LIMIT}; use limit=0 for a full result or create an export resource for large datasets` - ) - .optional() - .describe('Maximum rows to return; zero requests an unbounded result.'), - cursor: z - .string() - .min(1, 'cursor must be a non-empty token') - .optional() - .describe('Opaque cursor returned by the previous query page.'), -}) +export const v2QueryRowsBodySchema = z + .object({ + workspaceId: workspaceIdSchema, + predicate: predicateSchema.optional(), + sort: sortSpecSchema.optional().describe('Ordered table-row sort specification.'), + limit: z + .number({ error: 'Limit must be a number' }) + .int('Limit must be an integer') + .min(0, 'Limit must be at least 0 (use 0 for an unbounded query)') + .max( + V2_MAX_ROW_LIMIT, + `Limit cannot exceed ${V2_MAX_ROW_LIMIT}; use limit=0 for a full result or create an export resource for large datasets` + ) + .optional() + .describe('Maximum rows to return; zero requests an unbounded result.'), + cursor: z + .string() + .min(1, 'cursor must be a non-empty token') + .optional() + .describe('Opaque cursor returned by the previous query page.'), + }) + .strict() export type V2QueryRowsBody = z.input +/** + * Match count for a filtered read: the same `predicate` grammar as + * {@link v2QueryRowsBodySchema}, with the paging controls dropped because a + * count has no page. Omitting `predicate` counts the whole table, which is also + * what `rowCount` on the table resource reports. + */ +export const v2QueryRowsCountBodySchema = z + .object({ + workspaceId: workspaceIdSchema, + predicate: predicateSchema.optional(), + }) + .strict() +export type V2QueryRowsCountBody = z.input + +/** Number of rows matching a predicate across the whole table, not one page. */ +export const v2QueryRowsCountDataSchema = z + .object({ + totalCount: z + .number() + .int() + .nonnegative() + .describe('Number of rows matching the predicate across the entire table.'), + }) + .strict() + .meta({ + id: 'V2QueryRowsCountData', + title: 'Query rows count data', + description: 'Total number of table rows matching a predicate.', + }) +export type V2QueryRowsCountData = z.output + /** * Rich filtered/sorted row read with cursor pagination — the v2 read surface * for anything beyond a plain page. POST because the predicate tree is a @@ -724,6 +790,27 @@ export const v2QueryRowsContract = defineRouteContract({ }, }) +/** + * How many rows a predicate matches. The cursor-paged reads deliberately carry + * no total — `{ data, nextCursor }` has nowhere to put one and computing a COUNT + * on every page is a cost a paging caller has not asked for — so the count is + * its own single-purpose read. `rowCount` on the table resource answers the + * unfiltered question; this answers the filtered one. + * + * POST for the same reason `POST /query` is a POST: the predicate tree is a + * structured body, not a querystring dialect. + */ +export const v2QueryRowsCountContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/tables/[tableId]/query/count', + params: tableIdParamsSchema, + body: v2QueryRowsCountBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2QueryRowsCountDataSchema), + }, +}) + /** * Single contract for `POST /rows` — the body is the single|batch union so the * route can dispatch in one `parseRequest`, and the response is the matching @@ -735,23 +822,34 @@ export const v2InsertTableRowBodySchema = insertTableRowBodyBaseSchema .extend({ data: v2RowDataSchema.describe('Row cells keyed by column name.'), }) + .strict() .refine(...rowAnchorMutexRefine) -export const v2BatchInsertTableRowsBodySchema = v1BatchInsertTableRowsBodySchema.extend({ - rows: z - .array(v2RowDataSchema) - .min(1, 'At least one row is required') - .max( - TABLE_LIMITS.MAX_BATCH_INSERT_SIZE, - `Cannot insert more than ${TABLE_LIMITS.MAX_BATCH_INSERT_SIZE} rows per batch` - ) - .describe('Rows to insert, with cells keyed by column name.'), -}) +export const v2BatchInsertTableRowsBodySchema = v1BatchInsertTableRowsBodySchema + .extend({ + rows: z + .array(v2RowDataSchema) + .min(1, 'At least one row is required') + .max( + TABLE_LIMITS.MAX_BATCH_INSERT_SIZE, + `Cannot insert more than ${TABLE_LIMITS.MAX_BATCH_INSERT_SIZE} rows per batch` + ) + .describe('Rows to insert, with cells keyed by column name.'), + }) + .strict() -export const v2CreateTableRowsBodySchema = z.union([ - v2BatchInsertTableRowsBodySchema, - v2InsertTableRowBodySchema, -]) +/** + * A union surfaces `invalid_union` as its first issue, whose default message is + * the unactionable `Invalid input` — so the shapes are named here. The per-member + * failures still ride along in `details`. + */ +export const v2CreateTableRowsBodySchema = z.union( + [v2BatchInsertTableRowsBodySchema, v2InsertTableRowBodySchema], + { + error: + 'Row insert body must be either { rows: [...] } for a batch insert or { data: {...} } for a single row', + } +) export const v2CreateTableRowsContract = defineRouteContract({ method: 'POST', @@ -771,6 +869,7 @@ export const v2UpdateRowsByPredicateBodySchema = updateRowsByFilterBodySchema filter: predicateSchema, data: v2RowDataSchema.describe('Row-data patch applied to every matching row.'), }) + .strict() export type V2UpdateRowsByPredicateBody = z.input /** @@ -814,6 +913,7 @@ export const v2DeleteTableRowsBodySchema = z .optional() .describe('Explicit row identifiers to delete.'), }) + .strict() .refine((data) => Boolean(data.filter) !== Boolean(data.rowIds), { message: 'Provide either filter or rowIds, but not both', }) @@ -835,6 +935,7 @@ export const v2UpdateTableRowBodySchema = updateTableRowBodySchema .extend({ data: v2RowDataSchema.describe('Partial row-data patch keyed by column name.'), }) + .strict() /** * Upsert body. `data` is a WHOLE-ROW value, not a patch — on the update branch @@ -850,6 +951,7 @@ export const v2UpsertTableRowBodySchema = upsertTableRowBodySchema '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}.' ), }) + .strict() export const v2GetTableRowContract = defineRouteContract({ method: 'GET', @@ -900,7 +1002,7 @@ export const v2UpsertTableRowContract = defineRouteContract({ * belong to. Present so every v2 mutation carries the same scope check the rest * of the surface applies through `resolveWorkspaceScope`. */ -export const v2WorkspaceScopedBodySchema = z.object({ workspaceId: workspaceIdSchema }) +export const v2WorkspaceScopedBodySchema = z.object({ workspaceId: workspaceIdSchema }).strict() export type V2WorkspaceScopedBody = z.input const v2TableViewPredicateOutputSchema = z @@ -927,6 +1029,13 @@ export const v2TableViewConfigSchema = tableMetadataSchema .optional() .describe('Saved ordered sort specification, or null for default ordering.'), }) + /** + * `tableMetadataSchema` is not strict, so extending it inherited the laxness + * and a misspelled layout key inside `config` was accepted and dropped. Safe + * on the read side too: `normalizeStoredViewConfig` projects a stored blob + * onto exactly these keys before the response is validated. + */ + .strict() .meta({ id: 'V2TableViewConfig', title: 'Table view configuration', @@ -989,11 +1098,24 @@ export const v2ListTableViewsContract = defineRouteContract({ }, }) +/** + * First-party view bodies narrowed to `.strict()` for the public surface. + * + * `.strict()` binds the top level only. The nested `config` object is the + * first-party table-metadata shape and still strips unknown keys; tightening it + * belongs with that shared schema, not here. + */ +export const v2CreateTableViewBodySchema = createTableViewBodySchema.strict() +export type V2CreateTableViewBody = z.input + +export const v2UpdateTableViewBodySchema = updateTableViewBodySchema.strict() +export type V2UpdateTableViewBody = z.input + export const v2CreateTableViewContract = defineRouteContract({ method: 'POST', path: '/api/v2/tables/[tableId]/views', params: tableIdParamsSchema, - body: createTableViewBodySchema, + body: v2CreateTableViewBodySchema, response: { mode: 'json', schema: v2DataResponse(v2ApiViewSchema), @@ -1016,7 +1138,7 @@ export const v2UpdateTableViewContract = defineRouteContract({ method: 'PATCH', path: '/api/v2/tables/[tableId]/views/[viewId]', params: tableViewParamsSchema, - body: updateTableViewBodySchema, + body: v2UpdateTableViewBodySchema, response: { mode: 'json', schema: v2DataResponse(v2ApiViewSchema), @@ -1266,6 +1388,7 @@ export const v2DeleteWorkflowGroupContract = defineRouteContract({ */ export const v2RunColumnBodySchema = runColumnBodyBaseSchema .extend({ filter: predicateSchema.optional() }) + .strict() .refine(...runColumnScopeMutexRefine) .refine(...runColumnExcludeMutexRefine) export type V2RunColumnBody = z.input @@ -1331,15 +1454,17 @@ export const v2RunRowEnrichmentContract = defineRouteContract({ * by the same predicate/sort grammar as `POST /query`. POST because the * predicate tree is a structured body, not a querystring dialect. */ -export const v2FindRowsBodySchema = z.object({ - workspaceId: workspaceIdSchema, - q: z - .string() - .min(1, 'q must be a non-empty search string') - .describe('Case-insensitive cell substring to find.'), - predicate: predicateSchema.optional(), - sort: sortSpecSchema.optional().describe('Ordered table-row sort specification.'), -}) +export const v2FindRowsBodySchema = z + .object({ + workspaceId: workspaceIdSchema, + q: z + .string() + .min(1, 'q must be a non-empty search string') + .describe('Case-insensitive cell substring to find.'), + predicate: predicateSchema.optional(), + sort: sortSpecSchema.optional().describe('Ordered table-row sort specification.'), + }) + .strict() export type V2FindRowsBody = z.input /** @@ -1700,11 +1825,15 @@ export const v2TableExportSchema = z }) export type V2TableExport = z.output +/** First-party export body narrowed to `.strict()` for the public surface. */ +export const v2CreateTableExportBodySchema = exportTableAsyncBodySchema.strict() +export type V2CreateTableExportBody = z.input + export const v2CreateTableExportContract = defineRouteContract({ method: 'POST', path: '/api/v2/tables/[tableId]/exports', params: tableIdParamsSchema, - body: exportTableAsyncBodySchema, + body: v2CreateTableExportBodySchema, response: { mode: 'json', schema: v2DataResponse(v2TableExportSchema), status: 201 }, }) @@ -1750,6 +1879,7 @@ export const v2TableExportDownloadContract = defineRouteContract({ */ export const v2CancelTableRunsBodySchema = cancelTableRunsBodyBaseSchema .extend({ filter: predicateSchema.optional() }) + .strict() .superRefine((value, ctx) => { for (const issue of refineCancelTableRunsScope(value)) { ctx.addIssue({ code: 'custom', ...issue }) diff --git a/apps/sim/lib/api/contracts/v2/workflows.ts b/apps/sim/lib/api/contracts/v2/workflows.ts index f8dc403eb4d..167e8244e2a 100644 --- a/apps/sim/lib/api/contracts/v2/workflows.ts +++ b/apps/sim/lib/api/contracts/v2/workflows.ts @@ -27,6 +27,7 @@ import { v2ListFoldersQuerySchema, v2PaginationFields, v2RelocateFolderBodySchema, + v2RunWindowBoundSchema, v2SearchSchema, v2SortFields, } from '@/lib/api/contracts/v2/shared' @@ -285,6 +286,29 @@ export const v2DeploymentStateSchema = z description: 'Current workflow deployment state and lifecycle progress.', }) +/** + * Read-only deployment state. Extends the shared state with `needsRedeployment`, + * which the mutation responses cannot carry: it compares the live graph against + * the draft, and immediately after a deploy or rollback the two are equal by + * construction. + */ +export const v2WorkflowDeploymentSchema = v2DeploymentStateSchema + .extend({ + needsRedeployment: z + .boolean() + .describe( + 'Whether the editable draft has diverged from the live deployment version. False while a deployment attempt is still preparing or activating, and false when nothing is deployed.' + ), + }) + .meta({ + id: 'WorkflowDeployment', + title: 'Workflow deployment', + description: + 'Current deployment state of a workflow, including draft-versus-live drift and the most recent deployment attempt.', + }) + +export type V2WorkflowDeployment = z.output + export const v2DeployWorkflowDataSchema = v2DeploymentStateSchema .extend({ version: z @@ -627,6 +651,16 @@ export const v2GetWorkflowVersionContract = defineRouteContract({ }, }) +export const v2GetWorkflowDeploymentContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/workflows/[id]/deployment', + params: v2WorkflowIdParamsSchema, + response: { + mode: 'json', + schema: v2DataResponse(v2WorkflowDeploymentSchema), + }, +}) + export const v2DeployWorkflowContract = defineRouteContract({ method: 'POST', path: '/api/v2/workflows/[id]/deploy', @@ -1009,16 +1043,8 @@ export const v2ListWorkflowRunsQuerySchema = z .min(1, 'trigger cannot be empty') .optional() .describe('Filter by trigger type.'), - startDate: z - .string() - .datetime() - .optional() - .describe('Include runs started at or after this ISO 8601 timestamp.'), - endDate: z - .string() - .datetime() - .optional() - .describe('Include runs started at or before this ISO 8601 timestamp.'), + startDate: v2RunWindowBoundSchema('startDate').optional(), + endDate: v2RunWindowBoundSchema('endDate').optional(), ...v2PaginationFields({ description: 'Maximum workflow runs to return per page.' }), /** * Deliberate deviation from the v2 `sortBy` + `sortOrder` convention. Runs @@ -1160,9 +1186,17 @@ export const v2GetWorkflowRunContract = defineRouteContract({ params: v2WorkflowRunParamsSchema, query: workflowExecutionStatusQuerySchema .extend({ - includeOutput: workflowExecutionStatusQuerySchema.shape.includeOutput.describe( - 'Include final and block outputs when true.' - ), + /** + * Declared with the shared boolean flag rather than reused from the + * internal shape, which spells it as a `'true'`/`'false'` string enum + * while every other v2 boolean query param is a real boolean. The shared + * schema still accepts both strings, so `?includeOutput=true` keeps + * working identically; it only widens what parses. + */ + includeOutput: booleanQueryFlagSchema + .describe('Include final and block outputs when true.') + .optional() + .default(false), selectedOutputs: workflowExecutionStatusQuerySchema.shape.selectedOutputs.describe( 'Comma-separated block output references to include.' ), 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 b9abb7e2db5..54670f57b5c 100644 --- a/apps/sim/lib/api/server/routes/resource-concealment.test.ts +++ b/apps/sim/lib/api/server/routes/resource-concealment.test.ts @@ -97,6 +97,7 @@ describe.each(policies)('$domain resource concealment', ({ policy, notFoundMessa error: { code: 'FORBIDDEN', message: 'Personal API keys are not allowed for this workspace', + details: { code: 'PERSONAL_API_KEYS_DISABLED' }, }, }) }) @@ -105,17 +106,48 @@ describe.each(policies)('$domain resource concealment', ({ policy, notFoundMessa const response = policy.render(new InsufficientWorkspacePermissionsError()) expect(response?.status).toBe(403) await expect(response?.json()).resolves.toEqual({ - error: { code: 'FORBIDDEN', message: 'Insufficient workspace permissions' }, + error: { + code: 'FORBIDDEN', + message: 'Insufficient workspace permissions', + details: { code: 'INSUFFICIENT_WORKSPACE_ROLE' }, + }, }) }) + /** + * The two denials above share one status and, for the role case, the exact + * message a caller with no access at all would see. `details.code` is the + * only thing that separates "raise this member's role" from "this workspace + * refuses personal keys", so it is asserted rather than matched loosely. + */ it.each([ - new WorkspaceApiKeyAuthorizationError(), - new PrincipalKindAuthorizationError('workspace_api_key', 'resources.read'), - ])('preserves same-workspace principal policy denial as forbidden: %s', async (error) => { - const response = policy.render(error) + [new WorkspaceApiKeyAuthorizationError(), 'WORKSPACE_KEY_OPERATION_NOT_PERMITTED'], + [ + new PrincipalKindAuthorizationError('workspace_api_key', 'resources.read'), + 'PRINCIPAL_KIND_NOT_PERMITTED', + ], + ])( + 'preserves same-workspace principal policy denial as forbidden with its cause: %s', + async (error, detailCode) => { + const response = policy.render(error) + expect(response?.status).toBe(403) + await expect(response?.json()).resolves.toMatchObject({ + error: { code: 'FORBIDDEN', details: { code: detailCode } }, + }) + } + ) + + /** + * A cross-tenant refusal is concealed as 404, so it never reaches this + * branch — and it carries no `details.code` for the same reason, which is + * what stops the concealed case from being distinguishable if it ever did. + */ + it('leaves a forbidden failure with no named cause without a details code', async () => { + const response = policy.render(new OrchestrationError('forbidden', 'Nope')) expect(response?.status).toBe(403) - await expect(response?.json()).resolves.toMatchObject({ error: { code: 'FORBIDDEN' } }) + await expect(response?.json()).resolves.toEqual({ + error: { code: 'FORBIDDEN', message: 'Nope' }, + }) }) it('does not classify generic forbidden errors by message', async () => { 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 d186e17f796..41f1e89b0f1 100644 --- a/apps/sim/lib/api/server/routes/v2-json-route.ts +++ b/apps/sim/lib/api/server/routes/v2-json-route.ts @@ -25,6 +25,7 @@ import { v2ApiGateError } from '@/app/api/v2/lib/gate' import { v2CaughtOrchestrationError, v2Error, + v2HeadNoEffect, v2HttpError, v2RateLimitError, v2ValidationError, @@ -225,6 +226,15 @@ interface V2JsonRouteOptions beforeParse?(args: { request: NextRequest @@ -268,6 +278,10 @@ 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 { diff --git a/apps/sim/lib/audit-logs/application/audit-log-use-cases.test.ts b/apps/sim/lib/audit-logs/application/audit-log-use-cases.test.ts index 60c4def062d..96b61c1b1d1 100644 --- a/apps/sim/lib/audit-logs/application/audit-log-use-cases.test.ts +++ b/apps/sim/lib/audit-logs/application/audit-log-use-cases.test.ts @@ -129,6 +129,30 @@ describe('audit-log application use cases', () => { expect(mocks.buildOrgScopeCondition).toHaveBeenCalled() }) + /** + * The resolver distinguishes four refusals with four different remedies. They + * share a status and are indistinguishable to a client that cannot branch on + * prose, so each has to arrive with its own `detailCode`. + */ + it.each([ + ['ORGANIZATION_MEMBERSHIP_REQUIRED', 'Not a member of the requested organization'], + ['ORGANIZATION_ADMIN_REQUIRED', 'Organization admin or owner role required'], + ['ENTERPRISE_PLAN_REQUIRED', 'Active enterprise subscription required'], + ['AUDIT_LOGS_DISABLED', 'Audit logs are disabled.'], + ] as const)('carries the %s refusal cause through the use case', async (code, message) => { + mocks.resolveAccess.mockResolvedValueOnce({ success: false, status: 403, code, message }) + + await expect( + listAuditLogs.execute({ principal: sessionPrincipal, input: listInput }) + ).rejects.toMatchObject({ code: 'forbidden', detailCode: code, message }) + }) + + it('names the principal-kind refusal too', async () => { + await expect( + listAuditLogs.execute({ principal: workspacePrincipal, input: listInput }) + ).rejects.toMatchObject({ code: 'forbidden', detailCode: 'PRINCIPAL_KIND_NOT_PERMITTED' }) + }) + it('propagates organization-store failures', async () => { const failure = new Error('database unavailable') mocks.resolveAccess.mockRejectedValueOnce(failure) diff --git a/apps/sim/lib/audit-logs/application/authorized-audit-log-use-case.ts b/apps/sim/lib/audit-logs/application/authorized-audit-log-use-case.ts index 363816b7c83..781ab12cc8b 100644 --- a/apps/sim/lib/audit-logs/application/authorized-audit-log-use-case.ts +++ b/apps/sim/lib/audit-logs/application/authorized-audit-log-use-case.ts @@ -1,8 +1,7 @@ import type { Principal } from '@sim/auth/principal' import type { AuditLogOperation, AuditLogPrincipal } from '@/lib/audit-logs/application/operations' import { resolveEnterpriseAuditAccess } from '@/lib/audit-logs/authorization' -import type { OperationUseCase } from '@/lib/core/application' -import { OrchestrationError } from '@/lib/core/orchestration/types' +import { ForbiddenOperationError, type OperationUseCase } from '@/lib/core/application' export interface AuthorizedAuditLogContext { organizationId: string @@ -25,8 +24,8 @@ function requireAuditLogPrincipal( operation: AuditLogOperation ): asserts principal is AuditLogPrincipal { if (!operation.principalKinds.some((kind) => kind === principal.kind)) { - throw new OrchestrationError( - 'forbidden', + throw new ForbiddenOperationError( + 'PRINCIPAL_KIND_NOT_PERMITTED', `Principal kind ${principal.kind} cannot perform operation ${operation.id}` ) } @@ -48,7 +47,7 @@ export function defineAuthorizedAuditLogUseCase organizationMember.userId) diff --git a/apps/sim/lib/billing/core/usage-log.test.ts b/apps/sim/lib/billing/core/usage-log.test.ts index 9812f0777c3..86befaa5701 100644 --- a/apps/sim/lib/billing/core/usage-log.test.ts +++ b/apps/sim/lib/billing/core/usage-log.test.ts @@ -41,7 +41,11 @@ import { recordCumulativeUsage, recordUsage, resolveCumulativeTopUp, + UNKNOWN_CURSOR_MESSAGE, + UnknownUsageCursorError, } from '@/lib/billing/core/usage-log' +import { asOrchestrationError } from '@/lib/core/orchestration/types' +import { HttpError } from '@/lib/core/utils/http-error' /** * Re-wires the shared db mocks (`dbChainMockFns`, backing the single shared @@ -422,6 +426,67 @@ describe('usage-log query scopes', () => { expect(dbChainMockFns.limit).toHaveBeenCalledWith(26) }) + it('rejects a cursor that resolves to no usage event instead of restarting at page 1', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([]) + + const rejection = await getUserUsageLogs('user-1', { + cursor: 'log-from-another-ledger', + includeSummary: false, + }).catch((error: unknown) => error) + + expect(rejection).toBeInstanceOf(UnknownUsageCursorError) + expect((rejection as Error).message).toBe(UNKNOWN_CURSOR_MESSAGE) + }) + + /** + * Both projections of the same throw: the v2 route reads the classification off + * the `cause` chain, the session-only internal route reads `statusCode` off the + * `HttpError`. Asserting them here is what lets the route suites stay on the + * surface behaviour. + */ + it('classifies the unresolvable-cursor rejection for both surfaces', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([]) + + const rejection = await getUserUsageLogs('user-1', { + cursor: 'log-from-another-ledger', + includeSummary: false, + }).catch((error: unknown) => error) + + expect(rejection).toBeInstanceOf(HttpError) + expect((rejection as HttpError).statusCode).toBe(400) + expect(asOrchestrationError(rejection)).toMatchObject({ + code: 'validation', + message: UNKNOWN_CURSOR_MESSAGE, + }) + }) + + it('narrows the page to rows after a resolvable cursor', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([{ createdAt: new Date('2026-07-01T00:00:00Z') }]) + + await getUserUsageLogs('user-1', { cursor: 'log-1', limit: 25, includeSummary: false }) + + expect(latestWhereCondition()).toMatchObject({ + type: 'and', + conditions: [{ type: 'eq', left: 'userId', right: 'user-1' }, { type: 'or' }], + }) + }) + + it('trusts a caller-supplied cursor timestamp without a lookup', async () => { + await getUserUsageLogs('user-1', { + cursor: 'log-1', + cursorCreatedAt: new Date('2026-07-01T00:00:00Z'), + limit: 25, + includeSummary: false, + }) + + expect(latestWhereCondition()).toMatchObject({ + type: 'and', + conditions: [{ type: 'eq', left: 'userId', right: 'user-1' }, { type: 'or' }], + }) + expect(dbChainMockFns.limit).toHaveBeenCalledTimes(1) + expect(dbChainMockFns.limit).toHaveBeenCalledWith(26) + }) + it('keeps personal queries actor-scoped with an optional workspace filter', async () => { await getUserUsageLogs('user-1', { workspaceId: 'workspace-1', diff --git a/apps/sim/lib/billing/core/usage-log.ts b/apps/sim/lib/billing/core/usage-log.ts index 0fd588b922d..50c7a8a345a 100644 --- a/apps/sim/lib/billing/core/usage-log.ts +++ b/apps/sim/lib/billing/core/usage-log.ts @@ -10,6 +10,8 @@ import { getHighestPrioritySubscription } from '@/lib/billing/core/plan' import { apportionCredits } from '@/lib/billing/credits/conversion' import { isOrgScopedSubscription } from '@/lib/billing/subscriptions/utils' import type { InternalUsageLogSource } from '@/lib/billing/usage-sources' +import { asOrchestrationError, OrchestrationError } from '@/lib/core/orchestration/types' +import { HttpError } from '@/lib/core/utils/http-error' import type { DbClient, DbOrTx } from '@/lib/db/types' const logger = createLogger('UsageLog') @@ -657,6 +659,51 @@ export async function getUsageCreditsByLogId( ) } +/** + * Caller-facing message for a `cursor` that names no usage event. + * + * This ledger's cursor is a raw `usage_log.id` resolved by lookup rather than an + * opaque keyset cursor, so a value that resolves to no row carries no position at + * all. Applying no cursor condition in that case — the previous behaviour — restarts + * the sequence at page 1 while still reporting `hasMore`, so a pager that persisted a + * cursor across a deploy, or across environments, walks the first page forever and + * counts the same credits on every lap. Rejecting it makes the failure visible on the + * request that caused it. + * + * The wording deliberately does not reuse `INVALID_CURSOR_MESSAGE`: that message names + * `sortBy`/`sortOrder`, and this collection accepts neither param, so it would send the + * caller to look for a knob that does not exist. The actionable half — restart without + * a cursor — is the same. + */ +export const UNKNOWN_CURSOR_MESSAGE = + 'cursor does not identify a usage event. Restart pagination without a cursor; a cursor is only valid against the ledger it was issued from.' + +/** + * The rejection for an unresolvable `cursor`, classified for both kinds of caller + * this shared ledger has. + * + * The v2 route reads the classification off the `cause` chain + * (`asOrchestrationError` walks it) and renders the v2 `BAD_REQUEST` envelope. The + * session-only internal route (`GET /api/users/me/usage-logs`) is a raw + * `withRouteHandler` with no error policy, and its `readTypedError` matches + * `instanceof HttpError` only — so an `OrchestrationError` alone would have made a + * hand-typed `?cursor=` a 500 there. Being both at once is what keeps every surface + * on 400 without either one having to learn about the other. + * + * `message` is the caller-facing constant above, so forwarding it verbatim (which is + * what `withRouteHandler` does for an `HttpError`) exposes nothing internal. + */ +export class UnknownUsageCursorError extends HttpError { + readonly statusCode = 400 + + constructor() { + super(UNKNOWN_CURSOR_MESSAGE, { + cause: new OrchestrationError('validation', UNKNOWN_CURSOR_MESSAGE), + }) + this.name = 'UnknownUsageCursorError' + } +} + /** * Options for querying usage logs */ @@ -748,9 +795,11 @@ async function getUsageLogs( let resolvedCursorCreatedAt = cursorCreatedAt if (!resolvedCursorCreatedAt) { - // Cursor resolution stays on the primary: the page itself reads a - // load-balanced replica, and a laggier sibling replica missing the - // cursor row would silently restart pagination from page 1. + /** + * Cursor resolution stays on the primary: the page itself reads a + * load-balanced replica, and a laggier sibling replica missing the + * cursor row would reject a cursor that is in fact resumable. + */ const cursorLog = await db .select({ createdAt: usageLog.createdAt }) .from(usageLog) @@ -759,13 +808,13 @@ async function getUsageLogs( resolvedCursorCreatedAt = cursorLog[0]?.createdAt } - if (resolvedCursorCreatedAt) { - const cursorCondition = or( - lt(usageLog.createdAt, resolvedCursorCreatedAt), - and(eq(usageLog.createdAt, resolvedCursorCreatedAt), lt(usageLog.id, cursor)) - ) - if (cursorCondition) conditions.push(cursorCondition) - } + if (!resolvedCursorCreatedAt) throw new UnknownUsageCursorError() + + const cursorCondition = or( + lt(usageLog.createdAt, resolvedCursorCreatedAt), + and(eq(usageLog.createdAt, resolvedCursorCreatedAt), lt(usageLog.id, cursor)) + ) + if (cursorCondition) conditions.push(cursorCondition) } const logs = await dbReplica @@ -845,6 +894,15 @@ async function getUsageLogs( }, } } catch (error) { + /** + * A classified failure is caller-fixable and already carries the message the + * surface will render, so it is reported as a warning rather than joining the + * genuine faults this logger's error volume is watched for. + */ + if (asOrchestrationError(error)) { + logger.warn('Rejected a usage-log query', { error: toError(error).message, scope }) + throw error + } logger.error('Failed to get usage logs', { error: toError(error).message, scope, diff --git a/apps/sim/lib/core/application/forbidden.ts b/apps/sim/lib/core/application/forbidden.ts new file mode 100644 index 00000000000..9c99901591d --- /dev/null +++ b/apps/sim/lib/core/application/forbidden.ts @@ -0,0 +1,102 @@ +import { OrchestrationError } from '@/lib/core/orchestration/types' + +/** + * The closed set of machine-readable causes a `403` may carry in + * `error.details.code`. + * + * A 403 tells a caller only that it was refused; the cause decides what it can + * do about it. Raising a member's role, issuing a personal key instead of a + * workspace-scoped one, re-pointing a workspace key, and buying an enterprise + * plan are four different remedies that were previously distinguishable only by + * matching on prose — which makes every message reword a silent client break. + * + * The set is closed and exhaustive over the refusals a caller can act on, for + * two reasons. A union type makes an unlisted spelling a compile error rather + * than a new undocumented value on the wire, and the OpenAPI 403 description is + * generated from these same members, so a code cannot be emitted without being + * published. + * + * Deliberately absent: the cross-tenant refusals (`NoWorkspaceAccessError`, + * `WorkspaceApiKeyScopeAuthorizationError`, + * `DelegatedWorkspaceAuthorizationError`). Those are concealed as `404` by + * `createV2ResourceConcealmentPolicy` precisely so a caller cannot learn that + * the resource exists, and naming their cause would hand back the signal the + * concealment withholds. + */ +export const FORBIDDEN_DETAIL_CODES = [ + /** The caller's workspace role is below the operation's `minimumRole`. */ + 'INSUFFICIENT_WORKSPACE_ROLE', + /** The workspace's organization has disabled personal API keys. */ + 'PERSONAL_API_KEYS_DISABLED', + /** The operation is not delegable to a workspace-scoped API key. */ + 'WORKSPACE_KEY_OPERATION_NOT_PERMITTED', + /** The operation does not accept this kind of principal at all. */ + 'PRINCIPAL_KIND_NOT_PERMITTED', + /** The caller is not a member of the organization it named. */ + 'ORGANIZATION_MEMBERSHIP_REQUIRED', + /** The caller is a member of the organization but not an admin or owner. */ + 'ORGANIZATION_ADMIN_REQUIRED', + /** The organization has no usable enterprise subscription. */ + 'ENTERPRISE_PLAN_REQUIRED', + /** Audit logging is switched off for this deployment. */ + 'AUDIT_LOGS_DISABLED', + /** The caller holds workspace write but is not an editor of this skill. */ + 'SKILL_EDITOR_ACCESS_REQUIRED', + /** The MCP server URL is outside the allowed domains or resolves internally. */ + 'MCP_SERVER_URL_NOT_ALLOWED', +] as const + +export type ForbiddenDetailCode = (typeof FORBIDDEN_DETAIL_CODES)[number] + +/** + * What each code means to a caller, in the words the generated OpenAPI 403 + * description publishes. + * + * The `Record` is the completeness gate: adding a member to + * {@link FORBIDDEN_DETAIL_CODES} fails to compile until it is documented here, + * so a code cannot reach the wire undocumented. + */ +export const FORBIDDEN_DETAIL_CODE_DESCRIPTIONS: Record = { + INSUFFICIENT_WORKSPACE_ROLE: + 'The caller has access to the workspace but its role is below the one this operation requires.', + PERSONAL_API_KEYS_DISABLED: + "The workspace's organization does not allow personal API keys. Use a workspace API key.", + WORKSPACE_KEY_OPERATION_NOT_PERMITTED: + 'This operation is not available to a workspace-scoped API key. Use a personal API key.', + PRINCIPAL_KIND_NOT_PERMITTED: 'This operation does not accept the caller’s kind of API key.', + ORGANIZATION_MEMBERSHIP_REQUIRED: 'The caller is not a member of the organization it named.', + ORGANIZATION_ADMIN_REQUIRED: + 'The caller is a member of the organization but not an admin or owner.', + ENTERPRISE_PLAN_REQUIRED: 'The organization has no active enterprise subscription.', + AUDIT_LOGS_DISABLED: 'Audit logging is not enabled for this deployment.', + SKILL_EDITOR_ACCESS_REQUIRED: + 'The caller can write in the workspace but is not an editor of this skill.', + MCP_SERVER_URL_NOT_ALLOWED: + 'The supplied MCP server URL is outside the allowed domains or resolves to an internal address.', +} + +/** + * A `forbidden` orchestration failure that names its cause. + * + * Subclasses keep their own identity so `instanceof` checks that already + * classify a refusal — concealment, for one — keep working unchanged; the code + * rides alongside rather than replacing the message. + */ +export class ForbiddenOperationError extends OrchestrationError { + constructor( + readonly detailCode: ForbiddenDetailCode, + message: string + ) { + super('forbidden', message) + this.name = 'ForbiddenOperationError' + } +} + +/** + * The `error.details` a refusal should be rendered with, or `undefined` when the + * failure names no cause. Applied by the v2 error projection so a route never + * has to remember to attach it. + */ +export function forbiddenErrorDetails(error: unknown): { code: ForbiddenDetailCode } | undefined { + return error instanceof ForbiddenOperationError ? { code: error.detailCode } : undefined +} diff --git a/apps/sim/lib/core/application/index.ts b/apps/sim/lib/core/application/index.ts index 8617e151734..6ac885759ba 100644 --- a/apps/sim/lib/core/application/index.ts +++ b/apps/sim/lib/core/application/index.ts @@ -6,6 +6,13 @@ export { recordProjectedUseCaseAuditEntries, type WorkspaceUseCaseAuditEntry, } from '@/lib/core/application/authorized-workspace-use-case' +export { + FORBIDDEN_DETAIL_CODE_DESCRIPTIONS, + FORBIDDEN_DETAIL_CODES, + type ForbiddenDetailCode, + ForbiddenOperationError, + forbiddenErrorDetails, +} from '@/lib/core/application/forbidden' export type { ApplicationOperation, OperationUseCase, diff --git a/apps/sim/lib/core/application/workspace-authorization.ts b/apps/sim/lib/core/application/workspace-authorization.ts index d90c62c1682..bc3a61be9f8 100644 --- a/apps/sim/lib/core/application/workspace-authorization.ts +++ b/apps/sim/lib/core/application/workspace-authorization.ts @@ -5,6 +5,7 @@ import { permissionSatisfies, resolveEffectiveWorkspacePermission, } from '@sim/platform-authz/workspace' +import { ForbiddenOperationError } from '@/lib/core/application/forbidden' import type { PrincipalForOperation, WorkspaceOperation, @@ -28,13 +29,19 @@ export interface WorkspaceAuthorizationOptions } -export class InsufficientWorkspacePermissionsError extends OrchestrationError { +export class InsufficientWorkspacePermissionsError extends ForbiddenOperationError { constructor() { - super('forbidden', 'Insufficient workspace permissions') + super('INSUFFICIENT_WORKSPACE_ROLE', 'Insufficient workspace permissions') this.name = 'InsufficientWorkspacePermissionsError' } } +/** + * No reach into the workspace at all. Carries no `detailCode` on purpose: the v2 + * surface conceals this as a `404`, and a code would restate the resource's + * existence that the concealment withholds. The message stays identical to + * {@link InsufficientWorkspacePermissionsError} for the same reason. + */ export class NoWorkspaceAccessError extends OrchestrationError { constructor() { super('forbidden', 'Insufficient workspace permissions') @@ -42,20 +49,24 @@ export class NoWorkspaceAccessError extends OrchestrationError { } } -export class PersonalApiKeysDisabledError extends OrchestrationError { +export class PersonalApiKeysDisabledError extends ForbiddenOperationError { constructor() { - super('forbidden', 'Personal API keys are not allowed for this workspace') + super('PERSONAL_API_KEYS_DISABLED', 'Personal API keys are not allowed for this workspace') this.name = 'PersonalApiKeysDisabledError' } } -export class WorkspaceApiKeyAuthorizationError extends OrchestrationError { +export class WorkspaceApiKeyAuthorizationError extends ForbiddenOperationError { constructor() { - super('forbidden', 'Workspace API key cannot perform this operation') + super( + 'WORKSPACE_KEY_OPERATION_NOT_PERMITTED', + 'Workspace API key cannot perform this operation' + ) this.name = 'WorkspaceApiKeyAuthorizationError' } } +/** Concealed as a `404`; see {@link NoWorkspaceAccessError} for why it has no code. */ export class WorkspaceApiKeyScopeAuthorizationError extends OrchestrationError { constructor() { super('forbidden', 'Workspace API key cannot access this workspace') @@ -63,6 +74,7 @@ export class WorkspaceApiKeyScopeAuthorizationError extends OrchestrationError { } } +/** Concealed as a `404`; see {@link NoWorkspaceAccessError} for why it has no code. */ export class DelegatedWorkspaceAuthorizationError extends OrchestrationError { constructor() { super('forbidden', 'Delegated workspace access is no longer valid') @@ -70,13 +82,21 @@ export class DelegatedWorkspaceAuthorizationError extends OrchestrationError { } } -export class PrincipalKindAuthorizationError extends OrchestrationError { +export class PrincipalKindAuthorizationError extends ForbiddenOperationError { constructor(principalKind: Principal['kind'], operationId: string) { - super('forbidden', `Principal kind ${principalKind} cannot perform operation ${operationId}`) + super( + 'PRINCIPAL_KIND_NOT_PERMITTED', + `Principal kind ${principalKind} cannot perform operation ${operationId}` + ) this.name = 'PrincipalKindAuthorizationError' } } +/** + * Only a delegated principal can raise this, and no delegated principal reaches + * `/api/v2` — the surface authenticates API keys only — so it carries no v2 + * `detailCode`. + */ export class DelegatedServiceAuthorizationError extends OrchestrationError { constructor(serviceId: DelegatedPrincipal['serviceId'], operationId: string) { super('forbidden', `Delegated service ${serviceId} cannot perform operation ${operationId}`) diff --git a/apps/sim/lib/knowledge/api/route-policies.test.ts b/apps/sim/lib/knowledge/api/route-policies.test.ts index 5271af49dbd..25a4c789890 100644 --- a/apps/sim/lib/knowledge/api/route-policies.test.ts +++ b/apps/sim/lib/knowledge/api/route-policies.test.ts @@ -45,6 +45,7 @@ describe('v2 knowledge error policies', () => { error: { code: 'FORBIDDEN', message: 'Personal API keys are not allowed for this workspace', + details: { code: 'PERSONAL_API_KEYS_DISABLED' }, }, }) }) diff --git a/apps/sim/lib/knowledge/application/documents.ts b/apps/sim/lib/knowledge/application/documents.ts index 11b1add79b2..50cf0ce86c1 100644 --- a/apps/sim/lib/knowledge/application/documents.ts +++ b/apps/sim/lib/knowledge/application/documents.ts @@ -56,6 +56,12 @@ import { performUploadKnowledgeDocuments, } from '@/lib/knowledge/orchestration/documents' import type { KnowledgeDocumentWriteSecretProvenance } from '@/lib/knowledge/secret-provenance' +import { + type KnowledgeTagNameFilter, + resolveKnowledgeTagFilters, + toKnowledgeTagFilterConditions, +} from '@/lib/knowledge/tags/filter-resolution' +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' @@ -73,7 +79,14 @@ export interface ListKnowledgeDocumentsInput { offset?: number sortBy?: DocumentSortField sortOrder?: SortOrder + /** Slot-addressed filters, as first-party surfaces already build them. */ tagFilters?: TagFilterCondition[] + /** + * Display-name-addressed filters, resolved to slots here against the + * knowledge base's own tag definitions. Public surfaces send these so that + * 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 @@ -199,6 +212,12 @@ export interface UpsertKnowledgeDocumentInput extends UploadKnowledgeDocumentAdm }): KnowledgeDocumentWriteSecretProvenance[] | undefined } +/** + * Lists documents, resolving any display-named tag filters against the + * knowledge base's tag definitions. The definitions are returned with the page + * so a presenter can key each document's tag values by display name — the same + * projection knowledge search performs — without reading protected data itself. + */ export const listKnowledgeDocuments = defineAuthorizedKnowledgeUseCase({ operation: knowledgeOperations.listDocuments, resolveContext: ({ input }: { input: ListKnowledgeDocumentsInput }) => @@ -212,6 +231,15 @@ export const listKnowledgeDocuments = defineAuthorizedKnowledgeUseCase({ if (!Number.isInteger(offset) || offset < 0) { throw new OrchestrationError('validation', 'Document offset must be a non-negative integer') } + const resolvedNameFilters = input.tagNameFilters?.length + ? await resolveKnowledgeTagFilters(input.tagNameFilters, [context.knowledgeBaseId]) + : null + const tagFilters = [ + ...(input.tagFilters ?? []), + ...(resolvedNameFilters + ? toKnowledgeTagFilterConditions(resolvedNameFilters.structuredFilters) + : []), + ] const result = await getDocuments( context.knowledgeBaseId, { @@ -221,11 +249,18 @@ export const listKnowledgeDocuments = defineAuthorizedKnowledgeUseCase({ offset, sortBy: input.sortBy, sortOrder: input.sortOrder, - tagFilters: input.tagFilters, + tagFilters: tagFilters.length > 0 ? tagFilters : undefined, }, generateRequestId() ) - return { ...result, workspaceId: context.workspaceId, cursorScope: input.cursorScope } + return { + ...result, + tagDefinitions: + resolvedNameFilters?.definitionsByKnowledgeBase.get(context.knowledgeBaseId) ?? + (await getDocumentTagDefinitions(context.knowledgeBaseId)), + workspaceId: context.workspaceId, + cursorScope: input.cursorScope, + } }, }) @@ -234,7 +269,11 @@ export const readKnowledgeDocument = defineAuthorizedKnowledgeUseCase({ resolveContext: ({ input }: { input: ReadKnowledgeDocumentInput }) => resolveActiveKnowledgeDocumentContext(input), async execute({ context }: { context: ActiveKnowledgeDocumentContext }) { - return { document: context.document, workspaceId: context.workspaceId } + return { + document: context.document, + tagDefinitions: await getDocumentTagDefinitions(context.knowledgeBaseId), + workspaceId: context.workspaceId, + } }, }) @@ -800,6 +839,7 @@ export const updateKnowledgeDocument = defineAuthorizedKnowledgeUseCase({ return { kind: 'updated' as const, document: await updateDocument(context.documentId, updates, generateRequestId()), + tagDefinitions: await getDocumentTagDefinitions(context.knowledgeBaseId), updatedFields, } }, @@ -848,6 +888,13 @@ export const bulkUpdateKnowledgeDocuments = defineAuthorizedKnowledgeUseCase({ operation: input.operation, successCount: result.successCount, updatedDocuments: result.updatedDocuments, + /** + * Reported so a surface can tell a bounded selection from an unbounded + * one: `documentIds` is capped by the request, `selectAll` is capped by + * nothing, and a presenter that echoes the identifiers either way returns + * a multi-megabyte array on a large knowledge base. + */ + selectAll: input.selectAll === true, } }, }) diff --git a/apps/sim/lib/knowledge/application/operations.test.ts b/apps/sim/lib/knowledge/application/operations.test.ts index 32847a90a73..64a1c433876 100644 --- a/apps/sim/lib/knowledge/application/operations.test.ts +++ b/apps/sim/lib/knowledge/application/operations.test.ts @@ -74,12 +74,28 @@ describe('knowledge operation registry', () => { } }) + /** + * Reading the tag vocabulary is the one tag operation a workspace key may + * perform. It is required input for the tag-name filters on document listing + * and on search, both of which a workspace key can already run, so it carries + * the policy of those sibling reads. Every tag *write* stays human-delegated. + */ + it('lets a workspace key read the tag vocabulary, exactly like its sibling knowledge reads', () => { + expect(knowledgeOperations.listTags.workspaceApiKey).toBe('allow') + expect(knowledgeOperations.listTags.principalKinds).toContain('workspace_api_key') + expect(knowledgeOperations.listTags.minimumRole).toBe( + knowledgeOperations.listDocuments.minimumRole + ) + expect(knowledgeOperations.listTags.workspaceApiKey).toBe( + knowledgeOperations.listDocuments.workspaceApiKey + ) + }) + it('keeps human-delegated tag, connector, and composed document operations off workspace keys', () => { const operations = [ knowledgeOperations.updateDocument, knowledgeOperations.addWorkspaceFiles, knowledgeOperations.bulkDeleteDocuments, - knowledgeOperations.listTags, knowledgeOperations.createTag, knowledgeOperations.updateTag, knowledgeOperations.deleteTag, diff --git a/apps/sim/lib/knowledge/application/operations.ts b/apps/sim/lib/knowledge/application/operations.ts index c0181d2f8e9..40c58d8b40d 100644 --- a/apps/sim/lib/knowledge/application/operations.ts +++ b/apps/sim/lib/knowledge/application/operations.ts @@ -197,11 +197,17 @@ export const knowledgeOperations = { workspaceApiKey: 'deny', ...HUMAN_AND_COPILOT_PRINCIPAL_POLICY, }), + /** + * The tag vocabulary is required input for two operations a workspace API key + * may already perform — filtering documents and search by tag display name — + * so it carries the same policy as those sibling reads (`documents.list`, + * `read`, `search`) rather than the stricter one the tag *writes* keep. + */ listTags: defineWorkspaceOperation({ id: 'knowledge.tags.list', minimumRole: 'read', - workspaceApiKey: 'deny', - ...HUMAN_COPILOT_AND_EXECUTOR_PRINCIPAL_POLICY, + workspaceApiKey: 'allow', + ...ALL_PRINCIPAL_WITH_EXECUTOR_POLICY, }), createTag: defineWorkspaceOperation({ id: 'knowledge.tags.create', diff --git a/apps/sim/lib/knowledge/application/search.ts b/apps/sim/lib/knowledge/application/search.ts index 50f863087d2..131853d1c0a 100644 --- a/apps/sim/lib/knowledge/application/search.ts +++ b/apps/sim/lib/knowledge/application/search.ts @@ -38,8 +38,12 @@ import { } from '@/lib/knowledge/search/queries' import { importKnowledgeSearchResultSecretProvenance } from '@/lib/knowledge/secret-provenance' import { getKnowledgeBaseById } from '@/lib/knowledge/service' +import { + type KnowledgeTagNameFilter, + resolveKnowledgeTagFilters, +} from '@/lib/knowledge/tags/filter-resolution' import { getDocumentTagDefinitions } from '@/lib/knowledge/tags/service' -import { buildUndefinedTagsError, validateTagValue } from '@/lib/knowledge/tags/utils' +import type { DocumentTagDefinition } from '@/lib/knowledge/tags/types' import type { KnowledgeBaseWithCounts, StructuredFilter } from '@/lib/knowledge/types' import { estimateTokenCount } from '@/lib/tokenization/estimators' import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' @@ -61,13 +65,11 @@ export class KnowledgeSearchProvenanceUnavailableError extends Error { } } -export interface KnowledgeSearchTagFilter { - tagName: string - fieldType?: 'text' | 'number' | 'date' | 'boolean' - operator: string - value: string | number | boolean - valueTo?: string | number -} +/** + * Search filters tags by display name. The resolution to storage slots is + * shared with the document list so both knowledge reads speak one vocabulary. + */ +export type KnowledgeSearchTagFilter = KnowledgeTagNameFilter export interface SearchKnowledgeInput { /** Optional assertion from a trusted adapter or public contract. */ @@ -99,6 +101,8 @@ type KnowledgeSearchContext = KnowledgeResourceContext & { export interface KnowledgeSearchItem { /** Trusted embedding identity for provenance import; HTTP presenters omit it. */ embeddingId: string + /** Knowledge base the matching chunk came from; a search spans up to 20. */ + knowledgeBaseId: string documentId: string documentName: string | null sourceUrl: string | null @@ -204,92 +208,6 @@ async function resolveKnowledgeSearchContext( } } -async function buildStructuredFilters( - filters: KnowledgeSearchTagFilter[], - knowledgeBaseIds: string[] -): Promise<{ - structuredFilters: StructuredFilter[] - definitionsByKnowledgeBase: Map>> -}> { - const definitionEntries = await Promise.all( - knowledgeBaseIds.map( - async (knowledgeBaseId) => - [knowledgeBaseId, await getDocumentTagDefinitions(knowledgeBaseId)] as const - ) - ) - const definitionsByKnowledgeBase = new Map(definitionEntries) - const sharedDefinitions = new Map() - for (const [, definitions] of definitionEntries) { - const currentByName = new Map( - definitions.map((definition) => [ - definition.displayName, - { tagSlot: definition.tagSlot, fieldType: definition.fieldType }, - ]) - ) - for (const filter of filters) { - const current = currentByName.get(filter.tagName) - if (!current) { - if (knowledgeBaseIds.length > 1) { - throw new OrchestrationError( - 'validation', - `Tag "${filter.tagName}" does not exist in all selected knowledge bases. Search those knowledge bases separately.` - ) - } - continue - } - const existing = sharedDefinitions.get(filter.tagName) - if ( - existing && - (existing.tagSlot !== current.tagSlot || existing.fieldType !== current.fieldType) - ) { - throw new OrchestrationError( - 'validation', - `Tag "${filter.tagName}" is not mapped consistently across the selected knowledge bases. Search those knowledge bases separately.` - ) - } - sharedDefinitions.set(filter.tagName, current) - } - } - const undefinedTags: string[] = [] - const typeErrors: string[] = [] - for (const filter of filters) { - const definition = sharedDefinitions.get(filter.tagName) - if (!definition) { - undefinedTags.push(filter.tagName) - continue - } - const validationError = validateTagValue( - filter.tagName, - String(filter.value), - definition.fieldType - ) - if (validationError) typeErrors.push(validationError) - } - if (undefinedTags.length > 0 || typeErrors.length > 0) { - throw new OrchestrationError( - 'validation', - [ - ...(undefinedTags.length > 0 ? [buildUndefinedTagsError(undefinedTags)] : []), - ...typeErrors, - ].join('\n') - ) - } - return { - structuredFilters: filters.map((filter) => { - const definition = sharedDefinitions.get(filter.tagName) - if (!definition) throw new Error('Validated knowledge tag definition disappeared') - return { - tagSlot: definition.tagSlot, - fieldType: definition.fieldType, - operator: filter.operator, - value: filter.value, - valueTo: filter.valueTo, - } - }), - definitionsByKnowledgeBase, - } -} - export const searchKnowledge = defineAuthorizedKnowledgeUseCase({ operation: knowledgeOperations.search, resolveContext: ({ input }: { input: SearchKnowledgeInput }) => @@ -329,12 +247,9 @@ export const searchKnowledge = defineAuthorizedKnowledgeUseCase({ const knowledgeBaseIds = context.knowledgeBases.map((knowledgeBase) => knowledgeBase.id) let structuredFilters: StructuredFilter[] = [] - let definitionsByKnowledgeBase = new Map< - string, - Awaited> - >() + let definitionsByKnowledgeBase = new Map() if (filters.length > 0) { - const built = await buildStructuredFilters(filters, knowledgeBaseIds) + const built = await resolveKnowledgeTagFilters(filters, knowledgeBaseIds) structuredFilters = built.structuredFilters definitionsByKnowledgeBase = built.definitionsByKnowledgeBase } @@ -529,6 +444,7 @@ export const searchKnowledge = defineAuthorizedKnowledgeUseCase({ const rerankerScore = rerankerScores.get(row.id) return { embeddingId: row.id, + knowledgeBaseId: row.knowledgeBaseId, documentId: row.documentId, documentName: document?.filename ?? null, sourceUrl: document?.sourceUrl ?? null, diff --git a/apps/sim/lib/knowledge/tags/filter-resolution.test.ts b/apps/sim/lib/knowledge/tags/filter-resolution.test.ts new file mode 100644 index 00000000000..d0cb6f5c8e2 --- /dev/null +++ b/apps/sim/lib/knowledge/tags/filter-resolution.test.ts @@ -0,0 +1,113 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockGetDocumentTagDefinitions } = vi.hoisted(() => ({ + mockGetDocumentTagDefinitions: vi.fn(), +})) + +vi.mock('@/lib/knowledge/tags/service', () => ({ + getDocumentTagDefinitions: mockGetDocumentTagDefinitions, +})) + +import { + resolveKnowledgeTagFilters, + toKnowledgeTagFilterConditions, +} from '@/lib/knowledge/tags/filter-resolution' + +const CREATED_AT = new Date('2025-01-10T09:00:00Z') + +function definition( + knowledgeBaseId: string, + tagSlot: string, + displayName: string, + fieldType = 'text' +) { + return { + id: `${knowledgeBaseId}-${tagSlot}`, + knowledgeBaseId, + tagSlot, + displayName, + fieldType, + createdAt: CREATED_AT, + updatedAt: CREATED_AT, + } +} + +describe('resolveKnowledgeTagFilters', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('resolves a display name to the slot it is stored in', async () => { + mockGetDocumentTagDefinitions.mockResolvedValue([definition('kb-1', 'tag1', 'category')]) + + const resolved = await resolveKnowledgeTagFilters( + [{ tagName: 'category', operator: 'eq', value: 'billing' }], + ['kb-1'] + ) + + expect(resolved.structuredFilters).toEqual([ + { tagSlot: 'tag1', fieldType: 'text', operator: 'eq', value: 'billing', valueTo: undefined }, + ]) + expect(resolved.definitionsByKnowledgeBase.get('kb-1')).toHaveLength(1) + }) + + it('rejects a tag name the knowledge base does not define instead of ignoring it', async () => { + mockGetDocumentTagDefinitions.mockResolvedValue([definition('kb-1', 'tag1', 'category')]) + + await expect( + resolveKnowledgeTagFilters( + [{ tagName: 'not-a-tag', operator: 'eq', value: 'billing' }], + ['kb-1'] + ) + ).rejects.toThrow('not defined in this knowledge base') + }) + + it('rejects a tag missing from one of several knowledge bases', async () => { + mockGetDocumentTagDefinitions + .mockResolvedValueOnce([definition('kb-1', 'tag1', 'category')]) + .mockResolvedValueOnce([definition('kb-2', 'tag1', 'other')]) + + await expect( + resolveKnowledgeTagFilters( + [{ tagName: 'category', operator: 'eq', value: 'billing' }], + ['kb-1', 'kb-2'] + ) + ).rejects.toThrow('does not exist in all selected knowledge bases') + }) + + it('rejects a tag mapped to different slots across knowledge bases', async () => { + mockGetDocumentTagDefinitions + .mockResolvedValueOnce([definition('kb-1', 'tag1', 'category')]) + .mockResolvedValueOnce([definition('kb-2', 'tag2', 'category')]) + + await expect( + resolveKnowledgeTagFilters( + [{ tagName: 'category', operator: 'eq', value: 'billing' }], + ['kb-1', 'kb-2'] + ) + ).rejects.toThrow('is not mapped consistently') + }) +}) + +describe('toKnowledgeTagFilterConditions', () => { + it('narrows resolved filters onto the document-list filter shape', () => { + expect( + toKnowledgeTagFilterConditions([ + { tagSlot: 'number1', fieldType: 'number', operator: 'gte', value: 2 }, + ]) + ).toEqual([ + { tagSlot: 'number1', fieldType: 'number', operator: 'gte', value: 2, valueTo: undefined }, + ]) + }) + + it('rejects a definition stored with an unsupported field type rather than dropping the predicate', () => { + expect(() => + toKnowledgeTagFilterConditions([ + { tagSlot: 'tag1', fieldType: 'nonsense', operator: 'eq', value: 'x' }, + ]) + ).toThrow('unsupported field type') + }) +}) diff --git a/apps/sim/lib/knowledge/tags/filter-resolution.ts b/apps/sim/lib/knowledge/tags/filter-resolution.ts new file mode 100644 index 00000000000..5c230600c76 --- /dev/null +++ b/apps/sim/lib/knowledge/tags/filter-resolution.ts @@ -0,0 +1,149 @@ +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { SUPPORTED_FIELD_TYPES } from '@/lib/knowledge/constants' +import type { TagFilterCondition } from '@/lib/knowledge/documents/tag-filter' +import { getDocumentTagDefinitions } from '@/lib/knowledge/tags/service' +import type { DocumentTagDefinition } from '@/lib/knowledge/tags/types' +import { buildUndefinedTagsError, validateTagValue } from '@/lib/knowledge/tags/utils' +import type { StructuredFilter } from '@/lib/knowledge/types' + +/** + * A tag filter addressed by the tag's display name rather than by its storage + * slot. Display names are the vocabulary every public read speaks: search + * filters name tags, and search results and document reads key their tag values + * by name. The slot is an implementation detail of the `document` row that only + * the write surface (`tag1`..`tag7`) still exposes. + */ +export interface KnowledgeTagNameFilter { + tagName: string + fieldType?: 'text' | 'number' | 'date' | 'boolean' + operator: string + value: string | number | boolean + valueTo?: string | number +} + +export interface ResolvedKnowledgeTagFilters { + structuredFilters: StructuredFilter[] + definitionsByKnowledgeBase: Map +} + +/** + * Resolves display-named tag filters to the storage slots they address. + * + * A tag name must map to the same slot and field type in every selected + * knowledge base; a name missing from one of several, or mapped inconsistently + * across them, is a validation failure telling the caller to search those + * knowledge bases separately. With one knowledge base a name that resolves to no + * definition is reported as an undefined tag rather than dropped, so a filter is + * never silently ignored. + * + * The loaded definitions are returned alongside the filters so a caller that + * also needs the slot-to-name map (to project tag values back out) does not read + * them a second time. + */ +export async function resolveKnowledgeTagFilters( + filters: KnowledgeTagNameFilter[], + knowledgeBaseIds: string[] +): Promise { + const definitionEntries = await Promise.all( + knowledgeBaseIds.map( + async (knowledgeBaseId) => + [knowledgeBaseId, await getDocumentTagDefinitions(knowledgeBaseId)] as const + ) + ) + const definitionsByKnowledgeBase = new Map(definitionEntries) + const sharedDefinitions = new Map() + for (const [, definitions] of definitionEntries) { + const currentByName = new Map( + definitions.map((definition) => [ + definition.displayName, + { tagSlot: definition.tagSlot, fieldType: definition.fieldType }, + ]) + ) + for (const filter of filters) { + const current = currentByName.get(filter.tagName) + if (!current) { + if (knowledgeBaseIds.length > 1) { + throw new OrchestrationError( + 'validation', + `Tag "${filter.tagName}" does not exist in all selected knowledge bases. Search those knowledge bases separately.` + ) + } + continue + } + const existing = sharedDefinitions.get(filter.tagName) + if ( + existing && + (existing.tagSlot !== current.tagSlot || existing.fieldType !== current.fieldType) + ) { + throw new OrchestrationError( + 'validation', + `Tag "${filter.tagName}" is not mapped consistently across the selected knowledge bases. Search those knowledge bases separately.` + ) + } + sharedDefinitions.set(filter.tagName, current) + } + } + const undefinedTags: string[] = [] + const typeErrors: string[] = [] + for (const filter of filters) { + const definition = sharedDefinitions.get(filter.tagName) + if (!definition) { + undefinedTags.push(filter.tagName) + continue + } + const validationError = validateTagValue( + filter.tagName, + String(filter.value), + definition.fieldType + ) + if (validationError) typeErrors.push(validationError) + } + if (undefinedTags.length > 0 || typeErrors.length > 0) { + throw new OrchestrationError( + 'validation', + [ + ...(undefinedTags.length > 0 ? [buildUndefinedTagsError(undefinedTags)] : []), + ...typeErrors, + ].join('\n') + ) + } + return { + structuredFilters: filters.map((filter) => { + const definition = sharedDefinitions.get(filter.tagName) + if (!definition) throw new Error('Validated knowledge tag definition disappeared') + return { + tagSlot: definition.tagSlot, + fieldType: definition.fieldType, + operator: filter.operator, + value: filter.value, + valueTo: filter.valueTo, + } + }), + definitionsByKnowledgeBase, + } +} + +/** + * Narrows resolved filters onto the document-list filter shape. The field type + * is stored as free text, so a definition carrying an unsupported one is a + * validation failure rather than a silently dropped predicate. + */ +export function toKnowledgeTagFilterConditions( + structuredFilters: StructuredFilter[] +): TagFilterCondition[] { + return structuredFilters.map((filter) => { + if (!(SUPPORTED_FIELD_TYPES as readonly string[]).includes(filter.fieldType)) { + throw new OrchestrationError( + 'validation', + `Tag slot "${filter.tagSlot}" is defined with unsupported field type "${filter.fieldType}"` + ) + } + return { + tagSlot: filter.tagSlot, + fieldType: filter.fieldType as TagFilterCondition['fieldType'], + operator: filter.operator, + value: filter.value, + valueTo: filter.valueTo, + } + }) +} diff --git a/apps/sim/lib/mcp/application/use-cases.test.ts b/apps/sim/lib/mcp/application/use-cases.test.ts index 46e84e343c0..3c99924a008 100644 --- a/apps/sim/lib/mcp/application/use-cases.test.ts +++ b/apps/sim/lib/mcp/application/use-cases.test.ts @@ -13,6 +13,9 @@ const { events, mocks } = vi.hoisted(() => ({ create: vi.fn(), effects: vi.fn(), audit: vi.fn(), + getServer: vi.fn(), + listServers: vi.fn(), + discoverServerTools: vi.fn(), }, })) @@ -41,11 +44,19 @@ vi.mock('@/lib/mcp/orchestration', () => ({ })) vi.mock('@/lib/mcp/queries', () => ({ getMcpServerIdState: mocks.idState, - getWorkspaceMcpServer: vi.fn(), - listWorkspaceMcpServers: vi.fn(), + getWorkspaceMcpServer: mocks.getServer, + listWorkspaceMcpServers: mocks.listServers, +})) +vi.mock('@/lib/mcp/service', () => ({ + mcpService: { discoverTools: vi.fn(), discoverServerTools: mocks.discoverServerTools }, })) -import { createMcpServerUseCase, discoverMcpToolsUseCase } from '@/lib/mcp/application/use-cases' +import { + createMcpServerUseCase, + discoverMcpServerToolsUseCase, + discoverMcpToolsUseCase, + listMcpServersUseCase, +} from '@/lib/mcp/application/use-cases' type McpServerRow = typeof mcpServers.$inferSelect const workspace = { @@ -97,6 +108,9 @@ describe('MCP server application use cases', () => { }) mocks.audit.mockImplementation(() => events.push('audit')) mocks.effects.mockImplementation(async () => events.push('effects')) + mocks.getServer.mockResolvedValue(server) + mocks.listServers.mockResolvedValue({ data: [server], nextCursorKeys: null }) + mocks.discoverServerTools.mockResolvedValue([]) }) it('keeps strict creation, compatibility attribution, audit, and effects in order', async () => { @@ -163,6 +177,88 @@ describe('MCP server application use cases', () => { expect(mocks.loadContext).not.toHaveBeenCalled() }) + it('rejects workspace-key per-server tool discovery before protected loading', async () => { + await expect( + discoverMcpServerToolsUseCase.execute({ + principal: { + kind: 'workspace_api_key', + workspaceId: workspace.workspaceId, + keyId: 'workspace-key-1', + }, + input: { workspaceId: workspace.workspaceId, serverId: server.id }, + }) + ).rejects.toMatchObject({ code: 'forbidden' }) + + expect(mocks.loadContext).not.toHaveBeenCalled() + expect(mocks.discoverServerTools).not.toHaveBeenCalled() + }) + + it('resolves the server in the caller workspace before reaching the network', async () => { + mocks.getServer.mockResolvedValueOnce(null) + + await expect( + discoverMcpServerToolsUseCase.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { workspaceId: workspace.workspaceId, serverId: 'mcp-from-another-workspace' }, + }) + ).rejects.toMatchObject({ code: 'not_found' }) + + expect(mocks.discoverServerTools).not.toHaveBeenCalled() + }) + + it('discovers one server tools for the acting subject, honouring refresh', async () => { + const tools = [ + { + name: 'search_docs', + inputSchema: { type: 'object' }, + serverId: server.id, + serverName: server.name, + }, + ] + mocks.discoverServerTools.mockResolvedValueOnce(tools) + + const result = await discoverMcpServerToolsUseCase.execute({ + principal: { kind: 'personal_api_key', userId: 'user-1', keyId: 'key-1' }, + input: { workspaceId: workspace.workspaceId, serverId: server.id, refresh: true }, + }) + + expect(result.tools).toBe(tools) + /** + * A public `refresh` skips the positive cache but must keep the failure + * cooldown: `force` would let one API key drive a connection attempt per + * request at an endpoint already known to be failing, from Sim's egress + * addresses. + */ + expect(mocks.discoverServerTools).toHaveBeenCalledWith( + 'user-1', + server.id, + workspace.workspaceId, + 'skip-cache' + ) + }) + + it('bounds the server list by the caller limit and reports the resuming keys', async () => { + mocks.listServers.mockResolvedValueOnce({ + data: [server], + nextCursorKeys: ['2026-01-01T00:00:00.000Z', server.id], + }) + + const result = await listMcpServersUseCase.execute({ + principal: { kind: 'personal_api_key', userId: 'user-1', keyId: 'key-1' }, + input: { workspaceId: workspace.workspaceId, limit: 1 }, + }) + + expect(mocks.listServers).toHaveBeenCalledWith( + expect.objectContaining({ workspaceId: workspace.workspaceId, limit: 1 }) + ) + expect(result).toMatchObject({ + servers: [server], + nextCursorKeys: ['2026-01-01T00:00:00.000Z', server.id], + sortBy: 'createdAt', + sortOrder: 'desc', + }) + }) + it('fails fast when a post-audit domain effect fails', async () => { mocks.effects.mockRejectedValueOnce(new Error('cache unavailable')) diff --git a/apps/sim/lib/mcp/application/use-cases.ts b/apps/sim/lib/mcp/application/use-cases.ts index 41bb504affe..3189a7ef481 100644 --- a/apps/sim/lib/mcp/application/use-cases.ts +++ b/apps/sim/lib/mcp/application/use-cases.ts @@ -1,8 +1,8 @@ import { AuditAction, AuditResourceType } from '@sim/audit' import { requirePrincipalSubjectUserId, resolvePrincipalAttribution } from '@sim/auth/principal' import { getPostgresErrorCode } from '@sim/utils/errors' -import type { ListSortOrder } from '@/lib/api/list-query' -import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' +import type { CursorKey, ListSortOrder } from '@/lib/api/list-query' +import { defineAuthorizedWorkspaceUseCase, ForbiddenOperationError } from '@/lib/core/application' import { OrchestrationError } from '@/lib/core/orchestration/types' import { sanitizeUrlForLog } from '@/lib/core/utils/logging' import { mcpServerDelegationPolicy } from '@/lib/mcp/application/authorization' @@ -66,7 +66,8 @@ function requireSuccessfulResult( case 'not_found': throw new OrchestrationError('not_found', 'MCP server not found') case 'forbidden': - throw new OrchestrationError('forbidden', result.error ?? fallback) + /** The single MCP refusal: the URL is off the domain allowlist or resolves internally. */ + throw new ForbiddenOperationError('MCP_SERVER_URL_NOT_ALLOWED', result.error ?? fallback) case 'bad_gateway': throw new OrchestrationError('validation', result.error ?? fallback) case 'conflict': @@ -83,16 +84,28 @@ export interface ListMcpServersInput { search?: string sortBy?: McpServerSortBy sortOrder?: ListSortOrder + /** Absent reads the whole set — only the copilot adapter does that. */ + limit?: number + cursorKeys?: CursorKey[] } +/** + * One page of a workspace's MCP servers, plus the sort the presenter needs to + * stamp the next cursor with. The surface presenter sees only this result. + */ export const listMcpServersUseCase = defineAuthorizedWorkspaceUseCase({ operation: mcpServerOperations.list, resolveContext: ({ input }: { input: ListMcpServersInput }) => resolveWorkspaceContext(input.workspaceId), authorizationOptions, async execute({ input, context }) { - const servers = await listWorkspaceMcpServers({ ...input, workspaceId: context.workspaceId }) - return { servers } + const page = await listWorkspaceMcpServers({ ...input, workspaceId: context.workspaceId }) + return { + servers: page.data, + nextCursorKeys: page.nextCursorKeys, + sortBy: input.sortBy ?? 'createdAt', + sortOrder: input.sortOrder ?? 'desc', + } }, }) @@ -110,7 +123,52 @@ export const discoverMcpToolsUseCase = defineAuthorizedWorkspaceUseCase({ const tools = await mcpService.discoverTools( requirePrincipalSubjectUserId(principal), context.workspaceId, - input.refresh ?? false + /** + * A public `refresh` skips the positive cache but keeps the failure + * cooldown; only an explicit user action on their own server may bypass + * both. See {@link McpDiscoveryRefresh}. + */ + input.refresh ? 'skip-cache' : 'cache-aside' + ) + return { tools } + }, +}) + +export interface DiscoverMcpServerToolsInput { + workspaceId: string + serverId: string + refresh?: boolean +} + +/** + * The tool inventory of one registered MCP server. + * + * Shares `mcp_servers.tools.discover` with the workspace-wide discovery: both + * resolve the acting user's own OAuth credentials against a third-party server, + * which is why that operation denies workspace API keys. Resolving the server + * through {@link resolveServerContext} first is what makes an id from another + * workspace a not-found rather than an upstream connection attempt. + * + * The pass is also the only thing that writes the server row's + * `connectionStatus`, `toolCount`, `lastError`, and `lastToolsRefresh`, so + * calling this is what makes those fields meaningful on a subsequent read. + */ +export const discoverMcpServerToolsUseCase = defineAuthorizedWorkspaceUseCase({ + operation: mcpServerOperations.discoverTools, + resolveContext: ({ input }: { input: DiscoverMcpServerToolsInput }) => + resolveServerContext(input.workspaceId, input.serverId), + authorizationOptions, + async execute({ principal, input, context }) { + const tools = await mcpService.discoverServerTools( + requirePrincipalSubjectUserId(principal), + context.server.id, + context.workspaceId, + /** + * A public `refresh` skips the positive cache but keeps the failure + * cooldown; only an explicit user action on their own server may bypass + * both. See {@link McpDiscoveryRefresh}. + */ + input.refresh ? 'skip-cache' : 'cache-aside' ) return { tools } }, diff --git a/apps/sim/lib/mcp/client.ts b/apps/sim/lib/mcp/client.ts index 85269355106..e8030595c13 100644 --- a/apps/sim/lib/mcp/client.ts +++ b/apps/sim/lib/mcp/client.ts @@ -149,10 +149,12 @@ export class McpClient { async connect(options: McpClientConnectOptions = {}): Promise { const startedAt = Date.now() const configuredTimeout = this.config.timeout - const timeoutMs = + const timeoutMs = Math.min( configuredTimeout !== undefined && Number.isFinite(configuredTimeout) && configuredTimeout > 0 - ? Math.min(Math.floor(configuredTimeout), getMaxExecutionTimeout()) - : MCP_CLIENT_CONSTANTS.CLIENT_TIMEOUT + ? Math.floor(configuredTimeout) + : MCP_CLIENT_CONSTANTS.CLIENT_TIMEOUT, + MCP_CLIENT_CONSTANTS.CONNECT_MAX_TIMEOUT_MS + ) const headerNames = Object.keys(this.config.headers ?? {}).sort() const hasUnresolvedEnvRefs = [ this.config.url ?? '', diff --git a/apps/sim/lib/mcp/queries.ts b/apps/sim/lib/mcp/queries.ts index f7fdf40af1c..637ac9540f0 100644 --- a/apps/sim/lib/mcp/queries.ts +++ b/apps/sim/lib/mcp/queries.ts @@ -1,7 +1,19 @@ import { db } from '@sim/db' import { mcpServers } from '@sim/db/schema' -import { and, type Column, eq, isNull } from 'drizzle-orm' -import { type ListSortOrder, listOrderBy, searchFilter } from '@/lib/api/list-query' +import { and, eq, isNull } from 'drizzle-orm' +import { + type CursorKey, + type KeysetKey, + type KeysetPage, + keysetColumns, + keysetPage, + type ListSortOrder, + listOrderBy, + resumeKeyset, + searchFilter, + textKey, + timestampKey, +} from '@/lib/api/list-query' /** * Workspace-scoped MCP server reads. The lifecycle functions in @@ -12,37 +24,65 @@ import { type ListSortOrder, listOrderBy, searchFilter } from '@/lib/api/list-qu export type McpServerRow = typeof mcpServers.$inferSelect export type McpServerSortBy = 'name' | 'createdAt' | 'updatedAt' -/** Live (non-soft-deleted) MCP servers in a workspace, newest first. */ +const mcpServerId = textKey(mcpServers.id, (row) => row.id) + /** - * Orderings for the public list's sortable fields, made total over the contract - * enum by `satisfies`. Each ends in `id` so servers sharing a name or a - * timestamp still come back in a stable order. + * Keyset orderings for the public list's sortable fields, made total over the + * contract enum by `satisfies`. Each ends in `id` so servers sharing a name or a + * timestamp still come back in a stable order — which is also what makes the + * cursor resumable, since a non-unique final key can repeat or skip a row at a + * page boundary. */ const MCP_SERVER_SORTS = { - name: [mcpServers.name, mcpServers.id], - createdAt: [mcpServers.createdAt, mcpServers.id], - updatedAt: [mcpServers.updatedAt, mcpServers.id], -} satisfies Record + name: [textKey(mcpServers.name, (row) => row.name), mcpServerId], + createdAt: [ + timestampKey(mcpServers.createdAt, (row) => row.createdAt), + mcpServerId, + ], + updatedAt: [ + timestampKey(mcpServers.updatedAt, (row) => row.updatedAt), + mcpServerId, + ], +} satisfies Record[]> +/** + * One keyset page of live (non-soft-deleted) MCP servers in a workspace. + * + * Nothing caps how many servers a workspace may register, so this read shipped + * as the one unbounded v2 list; the public page is now cut by the caller's + * `limit` like every other collection. `limit` stays optional because the + * copilot adapter reads the whole set — an absent `limit` applies no `LIMIT` + * clause and, per {@link keysetPage}, can never yield a cursor. + */ export async function listWorkspaceMcpServers(params: { workspaceId: string /** Case-insensitive substring match on the server name. */ search?: string sortBy?: McpServerSortBy sortOrder?: ListSortOrder -}): Promise { - const { sortBy = 'createdAt', sortOrder = 'desc' } = params - return db + limit?: number + cursorKeys?: CursorKey[] +}): Promise> { + const { sortBy = 'createdAt', sortOrder = 'desc', limit } = params + const keys = MCP_SERVER_SORTS[sortBy] + const resumeAfter = resumeKeyset(keys, params.cursorKeys, sortOrder) + + const ordered = db .select() .from(mcpServers) .where( and( eq(mcpServers.workspaceId, params.workspaceId), isNull(mcpServers.deletedAt), - searchFilter(mcpServers.name, params.search) + searchFilter(mcpServers.name, params.search), + resumeAfter ) ) - .orderBy(...listOrderBy(MCP_SERVER_SORTS[sortBy], sortOrder)) + .orderBy(...listOrderBy(keysetColumns(keys), sortOrder)) + + const rows = await (limit === undefined ? ordered : ordered.limit(limit + 1)) + + return keysetPage(keys, rows, limit) } /** A single live MCP server, or null when it does not exist in this workspace. */ diff --git a/apps/sim/lib/mcp/service.test.ts b/apps/sim/lib/mcp/service.test.ts index d1cbb4bb538..5f89545e2ab 100644 --- a/apps/sim/lib/mcp/service.test.ts +++ b/apps/sim/lib/mcp/service.test.ts @@ -122,6 +122,7 @@ vi.mock('@/lib/mcp/storage', () => ({ import { mcpService } from '@/lib/mcp/service' import { McpOauthAuthorizationRequiredError } from '@/lib/mcp/types' +import { MCP_CONSTANTS } from '@/lib/mcp/utils' const mockLogger = vi.mocked(loggerMock.createLogger).mock.results.at(-1)?.value @@ -158,6 +159,51 @@ function tool(name: string, serverId: string) { } } +/** + * Renders a mocked drizzle `sql` fragment, recursing into nested fragments. + * + * The failure status write computes the consecutive-failure counter in SQL + * rather than reading it, adding one and writing it back, so its + * `connectionStatus` and `statusConfig` arrive as expressions. Rendering them is + * the only way to assert the increment and the error threshold without a live + * database — and asserting on a literal object would be asserting the old + * read-modify-write back into existence. + */ +function renderSql(fragment: unknown): string { + const node = fragment as { strings?: readonly string[]; values?: readonly unknown[] } + if (!node?.strings) return String(fragment) + return node.strings.reduce( + (rendered, chunk, index) => + index === 0 ? chunk : `${rendered}${renderSql(node.values?.[index - 1])}${chunk}`, + '' + ) +} + +/** The values written by the failure branch of the discovery status write. */ +function failureStatusWrite(lastError: string): Record { + const call = dbChainMockFns.set.mock.calls.find( + ([values]) => (values as Record | undefined)?.lastError === lastError + ) + expect(call, `no status write carried lastError ${lastError}`).toBeDefined() + return (call as unknown[])[0] as Record +} + +/** + * Pins the failure write's SQL: the counter is incremented from the stored blob + * in the same statement, and the row flips to `error` at the threshold. + */ +function expectSqlSideFailureIncrement(values: Record): void { + const statusConfig = renderSql(values.statusConfig) + expect(statusConfig).toContain("'consecutiveFailures'") + expect(statusConfig).toContain("->> 'consecutiveFailures')::int, 0) + 1") + expect(statusConfig).toContain("-> 'lastSuccessfulDiscovery'") + + const connectionStatus = renderSql(values.connectionStatus) + expect(connectionStatus).toContain(') + 1 >= ') + expect(connectionStatus).toContain(String(MCP_CONSTANTS.MAX_CONSECUTIVE_FAILURES)) + expect(connectionStatus).toContain("THEN 'error' ELSE 'disconnected' END") +} + describe('McpService.discoverTools per-server caching', () => { beforeEach(async () => { vi.clearAllMocks() @@ -342,13 +388,7 @@ describe('McpService.discoverTools per-server caching', () => { expect(first).toEqual([]) await vi.waitFor(() => { - expect(dbChainMockFns.set).toHaveBeenCalledWith( - expect.objectContaining({ - connectionStatus: 'disconnected', - lastError: 'Authentication failed', - statusConfig: { consecutiveFailures: 1, lastSuccessfulDiscovery: null }, - }) - ) + expectSqlSideFailureIncrement(failureStatusWrite('Authentication failed')) expect(mockCacheAdapter.set).toHaveBeenCalledWith( `workspace:${WORKSPACE_ID}:server:mcp-a:failure`, [], @@ -411,10 +451,17 @@ describe('McpService.discoverTools per-server caching', () => { ) expect(mockListTools).not.toHaveBeenCalled() + // A public `refresh` skips the positive cache but still honours the + // cooldown, so it cannot be used to hammer a failing endpoint. + await expect( + mcpService.discoverServerTools(USER_ID, 'mcp-a', WORKSPACE_ID, 'skip-cache') + ).rejects.toThrow('cooldown') + expect(mockListTools).not.toHaveBeenCalled() + // Reconnecting via the explicit-refresh path (refresh button / OAuth // callback) bypasses both caches and brings the server back to live. mockListTools.mockResolvedValueOnce([tool('a1', 'mcp-a')]) - const tools = await mcpService.discoverServerTools(USER_ID, 'mcp-a', WORKSPACE_ID, true) + const tools = await mcpService.discoverServerTools(USER_ID, 'mcp-a', WORKSPACE_ID, 'force') expect(tools.map((t) => t.name)).toEqual(['a1']) // discoverTools now sees the cleared negative cache + primed positive cache. @@ -455,13 +502,9 @@ describe('McpService.discoverTools per-server caching', () => { 'Request timed out' ) - expect(dbChainMockFns.set).toHaveBeenCalledWith( - expect.objectContaining({ - connectionStatus: 'disconnected', - // Raw SDK timeout text is mapped to a user-facing message before persisting. - lastError: 'The MCP server took too long to respond and timed out', - statusConfig: { consecutiveFailures: 1, lastSuccessfulDiscovery: null }, - }) + // Raw SDK timeout text is mapped to a user-facing message before persisting. + expectSqlSideFailureIncrement( + failureStatusWrite('The MCP server took too long to respond and timed out') ) }) @@ -492,13 +535,7 @@ describe('McpService.discoverTools per-server caching', () => { reflectedCredential ) - expect(dbChainMockFns.set).toHaveBeenCalledWith( - expect.objectContaining({ - connectionStatus: 'disconnected', - lastError: 'Authentication failed', - statusConfig: { consecutiveFailures: 1, lastSuccessfulDiscovery: null }, - }) - ) + expectSqlSideFailureIncrement(failureStatusWrite('Authentication failed')) expect(JSON.stringify(dbChainMockFns.set.mock.calls)).not.toContain(reflectedCredential) expect(JSON.stringify(mockCacheAdapter.set.mock.calls)).not.toContain(reflectedCredential) expect(JSON.stringify(mockLogger?.warn.mock.calls)).not.toContain(reflectedCredential) @@ -550,7 +587,14 @@ describe('McpService.discoverTools per-server caching', () => { expect(mockResolveEnvVars).toHaveBeenCalledTimes(1) }) - it('promotes the persisted server status to error on the third consecutive failure', async () => { + /** + * The counter used to be read, incremented in JS, and written back. Two + * concurrent failures both read N and wrote N+1, losing a count, so a flapping + * server could sit below the threshold forever and never flip to `error`. The + * increment and the threshold comparison now happen in the one statement that + * writes them. + */ + it('promotes to error by incrementing the failure counter in the write itself', async () => { mockGetWorkspaceServersRows.mockResolvedValue([ dbRow('mcp-a', 'A', { statusConfig: { consecutiveFailures: 2, lastSuccessfulDiscovery: null }, @@ -562,12 +606,49 @@ describe('McpService.discoverTools per-server caching', () => { 'Connection refused' ) - expect(dbChainMockFns.set).toHaveBeenCalledWith( - expect.objectContaining({ - connectionStatus: 'error', - statusConfig: { consecutiveFailures: 3, lastSuccessfulDiscovery: null }, - }) - ) + expectSqlSideFailureIncrement(failureStatusWrite('Connection refused')) + }) + + /** + * `updatedAt` is one of the public list's keyset sorts. A background discovery + * stamping it moves rows to the head of `sortBy=updatedAt` mid-walk, so a + * caller paginating while any discovery runs sees servers duplicated across + * pages and others skipped entirely. + */ + it('never stamps updatedAt from a discovery status write', async () => { + mockGetWorkspaceServersRows.mockResolvedValue([dbRow('mcp-a', 'A'), dbRow('mcp-b', 'B')]) + mockListTools + .mockResolvedValueOnce([tool('a1', 'mcp-a')]) + .mockRejectedValueOnce(new Error('Connection refused')) + + await mcpService.discoverTools(USER_ID, WORKSPACE_ID) + + await vi.waitFor(() => { + expect(dbChainMockFns.set.mock.calls.length).toBeGreaterThanOrEqual(2) + }) + for (const [values] of dbChainMockFns.set.mock.calls) { + expect( + (values as Record)?.updatedAt, + 'a discovery status write stamped updatedAt, corrupting the updatedAt keyset page' + ).toBeUndefined() + } + }) + + /** + * A discovery that started before a newer attempt landed must not overwrite + * it, and neither outcome may write onto a foreign or soft-deleted row. The + * success branch used to guard on the id alone. + */ + it('guards the success status write with workspace, liveness and staleness', async () => { + mockGetWorkspaceServersRows.mockResolvedValue([dbRow('mcp-a', 'A')]) + mockListTools.mockResolvedValueOnce([tool('a1', 'mcp-a')]) + + await mcpService.discoverServerTools(USER_ID, 'mcp-a', WORKSPACE_ID) + + const guard = JSON.stringify(dbChainMockFns.where.mock.calls) + expect(guard).toContain('deletedAt') + expect(guard).toContain('lastConnected') + expect(guard).toContain(WORKSPACE_ID) }) it('persists OAuth-required discovery as disconnected without a failure error', async () => { diff --git a/apps/sim/lib/mcp/service.ts b/apps/sim/lib/mcp/service.ts index 4a867b35382..46276ca6d01 100644 --- a/apps/sim/lib/mcp/service.ts +++ b/apps/sim/lib/mcp/service.ts @@ -7,7 +7,7 @@ import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { sleep } from '@sim/utils/helpers' import { backoffWithJitter } from '@sim/utils/retry' -import { and, eq, isNull, lte, or } from 'drizzle-orm' +import { and, eq, isNull, lte, or, sql } from 'drizzle-orm' import { isTest } from '@/lib/core/config/env-flags' import { generateRequestId } from '@/lib/core/utils/request' import { McpClient } from '@/lib/mcp/client' @@ -112,6 +112,23 @@ function createInvocationProvenanceReporter( } } +/** + * How far a discovery may bypass the caches. + * + * - `cache-aside` — serve the 5-minute positive cache, and honour the failure + * cooldown. The default for every incidental read. + * - `skip-cache` — re-fetch even on a cache hit, but still honour the failure + * cooldown. This is what a public `refresh=true` gets: a caller asking for + * fresh tools should not also be able to drive a connection attempt per + * request at an endpoint already known to be failing, from Sim's egress + * addresses. + * - `force` — bypass both. Reserved for an explicit user action on their own + * server (the refresh button, the OAuth callback), where the whole point is + * that the credential or endpoint has just been fixed and the cooldown would + * only delay the recovery the user is watching for. + */ +export type McpDiscoveryRefresh = 'cache-aside' | 'skip-cache' | 'force' + type DiscoveryOutcome = | { kind: 'cached'; tools: McpTool[] } | { kind: 'fetched'; tools: McpTool[] } @@ -121,9 +138,16 @@ type DiscoveryOutcome = // exemption survives the getErrorMessage call. | { kind: 'error'; message: string; originalError: unknown } -type ServerStatusUpdate = +/** + * `discoveryStartedAt` is what makes a status write conditional: a discovery + * that started before a newer attempt already landed must not overwrite it. + * Both outcomes carry it, because a slow success can clobber a recent failure + * exactly as a slow failure can clobber a recent success. + */ +type ServerStatusUpdate = { discoveryStartedAt?: Date } & ( | { outcome: 'connected'; toolCount: number } - | { outcome: 'failed'; error: string; discoveryStartedAt?: Date } + | { outcome: 'failed'; error: string } +) function isOauthAuthorizationError(error: unknown, authType: McpServerConfig['authType']): boolean { return ( @@ -619,6 +643,16 @@ class McpService { return false } + /** + * Records the outcome of a discovery attempt on the server row. + * + * Deliberately leaves `updatedAt` alone. `updatedAt` means "when the server's + * configuration last changed" and is one of the public list's keyset sorts, so + * stamping it from a background discovery would move rows to the head of + * `sortBy=updatedAt` mid-walk and duplicate or skip servers across a caller's + * pages. Discovery liveness is already published through `lastConnected`, + * `lastToolsRefresh`, `lastError` and `statusConfig`. + */ private async updateServerStatus( serverId: string, workspaceId: string, @@ -626,9 +660,27 @@ class McpService { ): Promise { try { const now = new Date() + /** + * Both outcomes carry the same guard: a discovery that started before a + * newer attempt already landed must not overwrite it, and neither branch + * may write onto a foreign or soft-deleted row. Without it on the success + * branch a slow connect could revive a server a later failure had just + * marked down, with a stale `toolCount` and a cleared `lastError`. + */ + const liveServerScope = and( + eq(mcpServers.id, serverId), + eq(mcpServers.workspaceId, workspaceId), + isNull(mcpServers.deletedAt), + update.discoveryStartedAt + ? or( + isNull(mcpServers.lastConnected), + lte(mcpServers.lastConnected, update.discoveryStartedAt) + ) + : undefined + ) if (update.outcome === 'connected') { - await db + const updatedServers = await db .update(mcpServers) .set({ connectionStatus: 'connected', @@ -640,64 +692,36 @@ class McpService { consecutiveFailures: 0, lastSuccessfulDiscovery: now.toISOString(), }, - updatedAt: now, }) - .where(eq(mcpServers.id, serverId)) - return true + .where(liveServerScope) + .returning({ id: mcpServers.id }) + return updatedServers.length > 0 } - const [currentServer] = await db - .select({ statusConfig: mcpServers.statusConfig }) - .from(mcpServers) - .where( - and( - eq(mcpServers.id, serverId), - eq(mcpServers.workspaceId, workspaceId), - isNull(mcpServers.deletedAt) - ) - ) - .limit(1) - - const storedConfig = currentServer?.statusConfig as Partial | null - const currentConfig: McpServerStatusConfig = { - consecutiveFailures: - typeof storedConfig?.consecutiveFailures === 'number' - ? storedConfig.consecutiveFailures - : 0, - lastSuccessfulDiscovery: storedConfig?.lastSuccessfulDiscovery ?? null, - } - - const newFailures = currentConfig.consecutiveFailures + 1 - const isErrorState = newFailures >= MCP_CONSTANTS.MAX_CONSECUTIVE_FAILURES + /** + * The failure counter is incremented SQL-side rather than read, added to, + * and written back. Two concurrent failures both reading N and writing N+1 + * lose a count, so a flapping server could sit below + * {@link MCP_CONSTANTS.MAX_CONSECUTIVE_FAILURES} indefinitely and never + * flip to `error`. `lastSuccessfulDiscovery` is carried through from the + * stored blob in the same statement. + */ + const nextFailures = sql`COALESCE((${mcpServers.statusConfig} ->> 'consecutiveFailures')::int, 0) + 1` const updatedServers = await db .update(mcpServers) .set({ - connectionStatus: isErrorState ? 'error' : 'disconnected', + connectionStatus: sql`CASE WHEN ${nextFailures} >= ${MCP_CONSTANTS.MAX_CONSECUTIVE_FAILURES} THEN 'error' ELSE 'disconnected' END`, lastError: update.error || 'Unknown error', - statusConfig: { - consecutiveFailures: newFailures, - lastSuccessfulDiscovery: currentConfig.lastSuccessfulDiscovery, - }, - updatedAt: now, + statusConfig: sql`jsonb_build_object('consecutiveFailures', ${nextFailures}, 'lastSuccessfulDiscovery', ${mcpServers.statusConfig} -> 'lastSuccessfulDiscovery')`, }) - .where( - and( - eq(mcpServers.id, serverId), - eq(mcpServers.workspaceId, workspaceId), - isNull(mcpServers.deletedAt), - update.discoveryStartedAt - ? or( - isNull(mcpServers.lastConnected), - lte(mcpServers.lastConnected, update.discoveryStartedAt) - ) - : undefined - ) - ) - .returning({ id: mcpServers.id }) + .where(liveServerScope) + .returning({ id: mcpServers.id, statusConfig: mcpServers.statusConfig }) - if (isErrorState && updatedServers.length > 0) { - logger.warn(`Server ${serverId} marked as error after ${newFailures} consecutive failures`) + const failures = (updatedServers[0]?.statusConfig as Partial | null) + ?.consecutiveFailures + if (typeof failures === 'number' && failures >= MCP_CONSTANTS.MAX_CONSECUTIVE_FAILURES) { + logger.warn(`Server ${serverId} marked as error after ${failures} consecutive failures`) } return updatedServers.length > 0 } catch (err) { @@ -741,7 +765,6 @@ class McpService { .set({ connectionStatus: 'disconnected', lastError: null, - updatedAt: new Date(), }) .where( and( @@ -781,10 +804,15 @@ class McpService { } } + /** + * Discover tools across every server in a workspace. See + * {@link McpDiscoveryRefresh} for what each mode is allowed to bypass — the + * fan-out makes the cooldown matter more here, not less. + */ async discoverTools( userId: string, workspaceId: string, - forceRefresh = false + refresh: McpDiscoveryRefresh = 'cache-aside' ): Promise { const requestId = generateRequestId() const discoveryStartedAt = new Date() @@ -803,7 +831,7 @@ class McpService { servers.map(async (config): Promise => { const cacheKey = serverCacheKey(workspaceId, config.id) - if (!forceRefresh) { + if (refresh === 'cache-aside') { try { const cached = await this.cacheAdapter.get(cacheKey) if (cached) return { kind: 'cached', tools: cached.tools } @@ -813,12 +841,13 @@ class McpService { error ) } - if (await this.isServerUnhealthy(workspaceId, config.id)) { - logger.info( - `[${requestId}] Skipping recently-failed server ${config.name} (negative-cache hit)` - ) - return { kind: 'unhealthy' } - } + } + + if (refresh !== 'force' && (await this.isServerUnhealthy(workspaceId, config.id))) { + logger.info( + `[${requestId}] Skipping recently-failed server ${config.name} (negative-cache hit)` + ) + return { kind: 'unhealthy' } } try { @@ -862,6 +891,7 @@ class McpService { this.updateServerStatus(server.id, workspaceId, { outcome: 'connected', toolCount: outcome.tools.length, + discoveryStartedAt, }) ) cacheWrites.push( @@ -966,17 +996,16 @@ class McpService { } /** - * Discover tools from one server. Cache-aside by default; pass - * `forceRefresh: true` from explicit-refresh paths (refresh button, OAuth - * callback) to bypass both positive and negative caches. Concurrent callers - * for the same `(workspaceId, serverId, userId, forceRefresh)` share one - * upstream request. + * Discover tools from one server. Cache-aside by default; see + * {@link McpDiscoveryRefresh} for what each mode is allowed to bypass. + * Concurrent callers for the same `(workspaceId, serverId, userId, refresh)` + * share one upstream request. */ async discoverServerTools( userId: string, serverId: string, workspaceId: string, - forceRefresh = false, + refresh: McpDiscoveryRefresh = 'cache-aside', onResolvedSecretTraceProvenance?: ResolvedSecretTraceProvenanceCallback ): Promise { if (onResolvedSecretTraceProvenance) { @@ -984,12 +1013,12 @@ class McpService { userId, serverId, workspaceId, - forceRefresh, + refresh, createInvocationProvenanceReporter(onResolvedSecretTraceProvenance) ) } - const inflightKey = `${workspaceId}:${serverId}:${userId}:${forceRefresh ? 'force' : 'cache'}` + const inflightKey = `${workspaceId}:${serverId}:${userId}:${refresh}` const existing = this.inflightServerDiscovery.get(inflightKey) if (existing) return existing @@ -997,7 +1026,7 @@ class McpService { userId, serverId, workspaceId, - forceRefresh, + refresh, undefined ).finally(() => { this.inflightServerDiscovery.delete(inflightKey) @@ -1010,14 +1039,14 @@ class McpService { userId: string, serverId: string, workspaceId: string, - forceRefresh: boolean, + refresh: McpDiscoveryRefresh, onResolvedSecretTraceProvenance?: ResolvedSecretTraceProvenanceCallback ): Promise { const requestId = generateRequestId() const discoveryStartedAt = new Date() const maxRetries = 2 - if (!forceRefresh) { + if (refresh === 'cache-aside') { try { const cached = await this.cacheAdapter.get(serverCacheKey(workspaceId, serverId)) if (cached) { @@ -1027,13 +1056,14 @@ class McpService { } catch (error) { logger.warn(`[${requestId}] Cache read failed for server ${serverId}:`, error) } - if (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 - ) - } + } + + 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 + ) } for (let attempt = 0; attempt < maxRetries; attempt++) { @@ -1066,6 +1096,7 @@ class McpService { this.updateServerStatus(serverId, workspaceId, { outcome: 'connected', toolCount: tools.length, + discoveryStartedAt, }), ]) return tools diff --git a/apps/sim/lib/mcp/utils.ts b/apps/sim/lib/mcp/utils.ts index 8e64ee476c0..e1fd0eb801b 100644 --- a/apps/sim/lib/mcp/utils.ts +++ b/apps/sim/lib/mcp/utils.ts @@ -51,6 +51,18 @@ export function sanitizeHeaders( export const MCP_CLIENT_CONSTANTS = { CLIENT_TIMEOUT: DEFAULT_EXECUTION_TIMEOUT_MS, AUTO_REFRESH_INTERVAL: 5 * 60 * 1000, + /** + * Hard ceiling for the connect handshake, regardless of the server row's + * configured `timeout`. + * + * The clamp used to be `getMaxExecutionTimeout()`, the *workflow* ceiling of + * seven days, so the real bound became the row's own `timeout` — which the + * registration contract permits up to 300s — multiplied by the connect + * retries. A hostile-but-slow server could therefore hold a Node request for + * roughly twenty minutes. Connecting is not a workflow run, and `tools/list` + * already bounds itself at a minute; the handshake gets the same budget. + */ + CONNECT_MAX_TIMEOUT_MS: 60_000, /** Idle timeout for tools/list (gap between progress events); raised from 10s toward the SDK's 60s default. */ LIST_TOOLS_TIMEOUT_MS: 30_000, /** Hard ceiling for tools/list regardless of progress (SDK maxTotalTimeout safeguard). */ diff --git a/apps/sim/lib/skills/application/operations.test.ts b/apps/sim/lib/skills/application/operations.test.ts new file mode 100644 index 00000000000..7c03a3c3ed8 --- /dev/null +++ b/apps/sim/lib/skills/application/operations.test.ts @@ -0,0 +1,60 @@ +/** + * @vitest-environment node + */ +import { requirePrincipalSubjectUserId } from '@sim/auth/principal' +import { describe, expect, it } from 'vitest' +import { skillOperations } from '@/lib/skills/application/operations' + +/** + * Pins the workspace-API-key split on skills. + * + * A workspace key can create a skill it can then never update or delete, which + * no sibling resource does, so the asymmetry reads like an oversight. It is not: + * a skill write is authorized by the per-skill editor row belonging to the + * acting user, not by workspace role, and a workspace key has no user to check. + * 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', () => { + expect(skillOperations.create).toMatchObject({ + minimumRole: 'write', + workspaceApiKey: 'allow', + }) + }) + + it('gates every edit path on a human subject rather than workspace role', () => { + for (const operation of [ + skillOperations.update, + skillOperations.upsert, + skillOperations.delete, + ]) { + expect(operation).toMatchObject({ + /** `read`, not `write` — the editor row is the authority, not the role. */ + minimumRole: 'read', + workspaceApiKey: 'deny', + principalKinds: ['session', 'personal_api_key', 'delegated'], + }) + } + }) + + /** + * Proves the deny is load-bearing rather than conservative: every edit use + * case resolves the acting subject before writing, and a workspace key cannot + * produce one. Allowing the key would replace a `403` with an unclassified + * throw, which the v2 surface renders as a caller-reachable `500`. + */ + it('cannot resolve an acting subject for a workspace key', () => { + expect(() => + requirePrincipalSubjectUserId({ + kind: 'workspace_api_key', + workspaceId: 'workspace-1', + keyId: 'workspace-key-1', + }) + ).toThrow(/does not represent a human subject/) + }) + + it('uses unique stable operation IDs', () => { + const ids = Object.values(skillOperations).map((operation) => operation.id) + expect(new Set(ids).size).toBe(ids.length) + }) +}) diff --git a/apps/sim/lib/skills/application/operations.ts b/apps/sim/lib/skills/application/operations.ts index 79c652eb395..5540ce2f2e8 100644 --- a/apps/sim/lib/skills/application/operations.ts +++ b/apps/sim/lib/skills/application/operations.ts @@ -9,6 +9,22 @@ const HUMAN_PRINCIPAL_POLICY = { delegatedServices: ['copilot'], } as const +/** + * Skill operations split on workspace API keys, and the split is structural + * rather than an oversight. + * + * `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`. + * + * 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. + * Pinned in `operations.test.ts`. + */ export const skillOperations = { list: defineWorkspaceOperation({ id: 'skills.list', diff --git a/apps/sim/lib/skills/orchestration/skill-lifecycle.ts b/apps/sim/lib/skills/orchestration/skill-lifecycle.ts index b7930b1a6fa..07203c9ddf4 100644 --- a/apps/sim/lib/skills/orchestration/skill-lifecycle.ts +++ b/apps/sim/lib/skills/orchestration/skill-lifecycle.ts @@ -7,6 +7,7 @@ import { skillDescriptionSchema, skillNameSchema, } from '@/lib/api/contracts/skills' +import { ForbiddenOperationError } from '@/lib/core/application' import { OrchestrationError, type OrchestrationErrorCode } from '@/lib/core/orchestration/types' import { getSkillActorContext } from '@/lib/skills/access' import { getBuiltinSkillByName, isBuiltinSkillId } from '@/lib/workflows/skills/builtin-skills' @@ -144,7 +145,15 @@ function classifyUpsertError(error: unknown): SkillFailure { return { error: 'Failed to save skill', errorCode: 'internal' } } +/** + * Editor access is the one skill refusal a workspace role cannot express, so it + * is also the one that needs naming on the wire: a caller holding workspace + * write sees the same `403` it would get for a role that is too low. + */ function throwSkillFailure(result: SkillFailure): never { + if (result.errorCode === 'forbidden') { + throw new ForbiddenOperationError('SKILL_EDITOR_ACCESS_REQUIRED', result.error) + } throw new OrchestrationError(result.errorCode, result.error) } diff --git a/apps/sim/lib/table/views/service.test.ts b/apps/sim/lib/table/views/service.test.ts index 29e88f403bd..f33affcef5d 100644 --- a/apps/sim/lib/table/views/service.test.ts +++ b/apps/sim/lib/table/views/service.test.ts @@ -100,6 +100,24 @@ describe('normalizeStoredViewConfig', () => { const out = normalizeStoredViewConfig({ filter: { $bogus: [{ nested: true }] } }) expect(out.filter).toBeNull() }) + + /** + * `config` is a schemaless JSONB blob and the config schemas are `.strict()`, + * so anything the stored row carries beyond the declared shape would fail the + * response parse and turn a legacy row into a 500. The read projects onto the + * canonical keys instead. + */ + it('drops a stored key the current config shape does not declare', () => { + const out = normalizeStoredViewConfig({ columnOrder: ['col_a'], groupBy: 'col_a' }) + expect(out).toEqual({ columnOrder: ['col_a'] }) + }) + + it('drops a stored per-sort option the sort spec does not declare', () => { + const out = normalizeStoredViewConfig({ + sort: [{ field: 'col_a', direction: 'asc', nulls: 'last' }], + }) + expect(out.sort).toEqual([{ field: 'col_a', direction: 'asc' }]) + }) }) describe('table-view mutations signal collaborators', () => { diff --git a/apps/sim/lib/table/views/service.ts b/apps/sim/lib/table/views/service.ts index de0c8c24118..6f0a6c9b690 100644 --- a/apps/sim/lib/table/views/service.ts +++ b/apps/sim/lib/table/views/service.ts @@ -100,8 +100,37 @@ function predicateFieldsAreValid(node: PredicateNode): boolean { return NAME_PATTERN.test((node as Predicate).field) } +/** + * The keys a stored config is allowed to contribute to a read. + * + * `table_views.config` is a schemaless JSONB blob, so a row written before a + * key was retired — or by any writer that bypassed the contract — can carry + * members the current shape does not declare. Spreading the blob wholesale + * published them, and now that the config schemas are `.strict()` it would fail + * the response parse and turn a legacy row into a 500. Projecting onto this + * list makes the read canonical by construction. + */ +const STORED_VIEW_CONFIG_KEYS = [ + 'columnWidths', + 'columnOrder', + 'pinnedColumns', + 'hiddenColumns', + 'filter', + 'sort', +] as const satisfies readonly (keyof TableViewConfig)[] + export function normalizeStoredViewConfig(raw: Record): TableViewConfig { - const config = { ...raw } as TableViewConfig + const picked: Record = {} + for (const key of STORED_VIEW_CONFIG_KEYS) { + /** + * `!= null`, not `!== undefined`: `table_views.config` is schemaless JSONB, + * so a legacy row storing `{"columnOrder": null}` would otherwise survive + * the pick and fail the declared response schema — turning a read into a + * 500. An absent key and an explicitly null one mean the same thing here. + */ + if (raw[key] != null) picked[key] = raw[key] + } + const config = picked as TableViewConfig const filter = raw.filter as Record | null | undefined if (filter && !('all' in filter) && !('any' in filter)) { try { @@ -117,6 +146,9 @@ export function normalizeStoredViewConfig(raw: Record): TableVi const sort = raw.sort as Record | unknown[] | null | undefined if (sort && !Array.isArray(sort)) { config.sort = Object.entries(sort).map(([field, direction]) => ({ field, direction })) + } else if (Array.isArray(config.sort)) { + /** Same reason as the key projection: a stored entry may carry more than the spec declares. */ + config.sort = config.sort.map(({ field, direction }) => ({ field, direction })) } return config } diff --git a/apps/sim/lib/workflows/api/route-policies.test.ts b/apps/sim/lib/workflows/api/route-policies.test.ts index e313c01ada5..6d107b5ccb5 100644 --- a/apps/sim/lib/workflows/api/route-policies.test.ts +++ b/apps/sim/lib/workflows/api/route-policies.test.ts @@ -59,6 +59,7 @@ describe('v2 workflow error policies', () => { error: { code: 'FORBIDDEN', message: 'Personal API keys are not allowed for this workspace', + details: { code: 'PERSONAL_API_KEYS_DISABLED' }, }, }) }) 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 bbf9924e47c..92238a897c4 100644 --- a/apps/sim/lib/workspace-files/application/list-workspace-files.ts +++ b/apps/sim/lib/workspace-files/application/list-workspace-files.ts @@ -18,6 +18,8 @@ export interface ListAllWorkspaceFilesInput { export interface QueryWorkspaceFilePageInput { workspaceId: string + /** Lifecycle set to page over. Omission preserves the active-only default. */ + scope?: 'active' | 'archived' folderPath?: string search?: string sortBy: 'name' | 'size' | 'uploadedAt' | 'updatedAt' @@ -63,6 +65,7 @@ export const queryWorkspaceFilePage = defineAuthorizedWorkspaceFileUseCase({ } const { files, nextKeys } = await queryWorkspaceFiles(context.workspaceId, { + scope: input.scope, folderId, search: input.search, sortBy: input.sortBy, diff --git a/apps/sim/lib/workspace-files/application/restore-workspace-file.test.ts b/apps/sim/lib/workspace-files/application/restore-workspace-file.test.ts index 99389ff56b8..2a64ac318fc 100644 --- a/apps/sim/lib/workspace-files/application/restore-workspace-file.test.ts +++ b/apps/sim/lib/workspace-files/application/restore-workspace-file.test.ts @@ -6,6 +6,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ loadLifecycle: vi.fn(), restoreStored: vi.fn(), + getFile: vi.fn(), recordAudit: vi.fn(), notify: vi.fn(), resolvePermission: vi.fn(), @@ -25,6 +26,7 @@ vi.mock('@/lib/realtime/notify', () => ({ notifyWorkspaceFilesChanged: mocks.not vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ loadWorkspaceFileLifecycleContext: mocks.loadLifecycle, restoreWorkspaceFile: mocks.restoreStored, + getWorkspaceFile: mocks.getFile, })) import { restoreWorkspaceFileOperation } from '@/lib/workspace-files/application/restore-workspace-file' @@ -38,12 +40,33 @@ const context = { deletedAt: new Date('2026-01-01T00:00:00Z'), } +/** + * `restoreWorkspaceFile` renames on a collision and clears the folder, so the + * post-restore record never matches the one the caller deleted. + */ +const restoredFile = { + id: 'file-1', + workspaceId: 'workspace-1', + name: 'notes_restored.md', + key: 'workspace/workspace-1/notes.md', + path: '/api/files/serve/notes.md?context=workspace', + size: 12, + type: 'text/markdown', + uploadedBy: 'user-1', + folderId: null, + folderPath: null, + deletedAt: null, + uploadedAt: new Date('2026-01-01T00:00:00Z'), + updatedAt: new Date('2026-01-02T00:00:00Z'), +} + describe('restoreWorkspaceFileOperation', () => { beforeEach(() => { vi.clearAllMocks() mocks.loadLifecycle.mockResolvedValue(context) mocks.resolvePermission.mockResolvedValue('admin') mocks.restoreStored.mockResolvedValue(undefined) + mocks.getFile.mockResolvedValue(restoredFile) mocks.notify.mockResolvedValue(undefined) }) @@ -53,7 +76,11 @@ describe('restoreWorkspaceFileOperation', () => { input: { fileId: 'file-1', assertedWorkspaceId: 'workspace-1' }, }) - expect(result).toEqual({ restored: true }) + expect(result).toEqual({ restored: true, file: restoredFile }) + expect(mocks.getFile).toHaveBeenCalledWith('workspace-1', 'file-1', { throwOnError: true }) + expect(mocks.restoreStored.mock.invocationCallOrder[0]).toBeLessThan( + mocks.getFile.mock.invocationCallOrder[0] + ) expect(mocks.resolvePermission).toHaveBeenCalled() expect(mocks.restoreStored).toHaveBeenCalledWith('workspace-1', 'file-1') expect(mocks.recordAudit).toHaveBeenCalledWith( @@ -79,4 +106,15 @@ describe('restoreWorkspaceFileOperation', () => { expect(mocks.resolvePermission).not.toHaveBeenCalled() expect(mocks.restoreStored).not.toHaveBeenCalled() }) + + it('reports a file that vanished between the restore and the read-back as absent', async () => { + mocks.getFile.mockResolvedValueOnce(null) + + await expect( + restoreWorkspaceFileOperation.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { fileId: 'file-1', assertedWorkspaceId: 'workspace-1' }, + }) + ).rejects.toMatchObject({ code: 'not_found' }) + }) }) diff --git a/apps/sim/lib/workspace-files/application/restore-workspace-file.ts b/apps/sim/lib/workspace-files/application/restore-workspace-file.ts index 918fb2c746e..9457cac9523 100644 --- a/apps/sim/lib/workspace-files/application/restore-workspace-file.ts +++ b/apps/sim/lib/workspace-files/application/restore-workspace-file.ts @@ -1,10 +1,13 @@ import { AuditAction, AuditResourceType } from '@sim/audit' import { createLogger } from '@sim/logger' import type { AuthorizedWorkspaceUseCaseContext } from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' import { notifyWorkspaceFilesChanged } from '@/lib/realtime/notify' import { + getWorkspaceFile, restoreWorkspaceFile, type WorkspaceFileLifecycleContext, + type WorkspaceFileRecord, } from '@/lib/uploads/contexts/workspace/workspace-file-manager' import { defineAuthorizedWorkspaceFileUseCase } from '@/lib/workspace-files/application/authorized-workspace-file-use-case' import { fileOperations } from '@/lib/workspace-files/application/operations' @@ -19,6 +22,13 @@ export interface RestoreWorkspaceFileInput { export interface RestoreWorkspaceFileResult { restored: true + /** + * The file as it exists after the restore. Restore is not a pure undo — it + * returns the file to the workspace root and renames it to avoid colliding + * with whatever took its name — so the caller needs the post-restore record + * rather than the one it deleted. + */ + file: WorkspaceFileRecord } async function executeRestoreWorkspaceFile({ @@ -29,7 +39,9 @@ async function executeRestoreWorkspaceFile({ WorkspaceFileLifecycleContext >): Promise { await restoreWorkspaceFile(context.workspaceId, context.fileId) - return { restored: true } + const file = await getWorkspaceFile(context.workspaceId, context.fileId, { throwOnError: true }) + if (!file) throw new OrchestrationError('not_found', 'File not found') + return { restored: true, file } } export const restoreWorkspaceFileOperation = defineAuthorizedWorkspaceFileUseCase({ diff --git a/scripts/check-api-validation-contracts.ts b/scripts/check-api-validation-contracts.ts index 017e7e20d03..58393603987 100644 --- a/scripts/check-api-validation-contracts.ts +++ b/scripts/check-api-validation-contracts.ts @@ -9,8 +9,8 @@ const QUERY_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/queries') const SELECTOR_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/selectors') const BASELINE = { - totalRoutes: 1100, - zodRoutes: 1100, + totalRoutes: 1105, + zodRoutes: 1105, nonZodRoutes: 0, } as const diff --git a/scripts/openapi/documents.test.ts b/scripts/openapi/documents.test.ts index 0464b7f8eca..862b138819c 100644 --- a/scripts/openapi/documents.test.ts +++ b/scripts/openapi/documents.test.ts @@ -25,13 +25,13 @@ const DOCUMENTS = [ ] as const const EXPECTED_OPERATION_COUNTS = new Map([ - ['apps/docs/openapi-v2-workflows.json', 21], + ['apps/docs/openapi-v2-workflows.json', 22], ['apps/docs/openapi-v2-logs.json', 2], - ['apps/docs/openapi-v2-files-audit.json', 21], - ['apps/docs/openapi-v2-tables.json', 43], - ['apps/docs/openapi-v2-knowledge.json', 18], + ['apps/docs/openapi-v2-files-audit.json', 22], + ['apps/docs/openapi-v2-tables.json', 44], + ['apps/docs/openapi-v2-knowledge.json', 21], ['apps/docs/openapi-v2-billing.json', 2], - ['apps/docs/openapi-v2-resources.json', 21], + ['apps/docs/openapi-v2-resources.json', 22], ]) function getOperation(spec: JsonObject, path: string, method: string): JsonObject { @@ -163,8 +163,7 @@ describe('generated OpenAPI documents', () => { }) } } - - expect(totalOperations).toBe(128) + expect(totalOperations).toBe(135) }) it('documents mixed workflow execution and resume responses', () => { diff --git a/scripts/openapi/generator.test.ts b/scripts/openapi/generator.test.ts index 1162abaa8e1..252cdcce25c 100644 --- a/scripts/openapi/generator.test.ts +++ b/scripts/openapi/generator.test.ts @@ -3,6 +3,7 @@ import { z } from 'zod' import { defineRouteContract } from '../../apps/sim/lib/api/contracts/types' import { billingOpenApiDocument } from '../../apps/sim/lib/api/contracts/v2/openapi/billing' import { filesAuditOpenApiDocument } from '../../apps/sim/lib/api/contracts/v2/openapi/files-audit' +import { workflowsOpenApiDocument } from '../../apps/sim/lib/api/contracts/v2/openapi/workflows' import { defineOpenApiDocument, defineOpenApiRoute, @@ -581,19 +582,46 @@ describe('OpenAPI generator', () => { ]) }) - it('uses string wire values for transformed boolean defaults', () => { + /** + * `recursive` is declared with `z.stringbool()`, which accepts only strings + * (including `yes`/`no`/`on`/`off`), so `type: 'string'` is what the wire + * genuinely takes. It is the last v2 boolean query param not on + * `booleanQueryFlagSchema`; moving it would *narrow* the accepted set, which + * is why it stays and is pinned here instead. + */ + it('uses string wire values for a stringbool query param', () => { const spec = generateOpenApiDocument(filesAuditOpenApiDocument) const deleteFolder = getOperation(spec, '/api/v2/files/folders', 'delete') const deleteFolderParameters = deleteFolder.parameters as JsonObject[] const recursive = deleteFolderParameters.find((parameter) => parameter.name === 'recursive') - const listAuditLogs = getOperation(spec, '/api/v2/audit-logs', 'get') - const listAuditLogParameters = listAuditLogs.parameters as JsonObject[] + + expect(recursive?.schema).toMatchObject({ type: 'string', default: 'false' }) + }) + + /** + * Every other v2 boolean query param documents a real boolean. + * `includeDeparted` and `includeOutput` used to be `'true'`/`'false'` string + * enums inherited from the internal shapes they reused, so the spec told + * callers to send a string for what four sibling params took as a boolean. + */ + it('documents boolean query flags as booleans', () => { + const auditSpec = generateOpenApiDocument(filesAuditOpenApiDocument) + const listAuditLogParameters = getOperation(auditSpec, '/api/v2/audit-logs', 'get') + .parameters as JsonObject[] const includeDeparted = listAuditLogParameters.find( (parameter) => parameter.name === 'includeDeparted' ) - expect(recursive?.schema).toMatchObject({ type: 'string', default: 'false' }) - expect(includeDeparted?.schema).toMatchObject({ type: 'string', default: 'false' }) + const workflowSpec = generateOpenApiDocument(workflowsOpenApiDocument) + const getRunParameters = getOperation( + workflowSpec, + '/api/v2/workflows/{id}/runs/{runId}', + 'get' + ).parameters as JsonObject[] + const includeOutput = getRunParameters.find((parameter) => parameter.name === 'includeOutput') + + expect(includeDeparted?.schema).toMatchObject({ type: 'boolean' }) + expect(includeOutput?.schema).toMatchObject({ type: 'boolean' }) }) it('documents binary download response headers', () => {