feat(v2): complete and align the v2 API surface (#6643)

* fix(v2): close four validation holes in the logs and billing surfaces

Each of these answered a caller-supplied value with a 500 or a silently
wrong result instead of a 400.

- `GET /api/v2/logs` accepted any string as `startDate`/`endDate`. The
  route constructs a `Date` from it, so `?startDate=abc` reached the
  driver's timestamp mapper as an `Invalid Date` and 500'd. Both bounds
  now carry `.datetime()`, matching the sibling run list so one timestamp
  works on both collections. This narrows the accepted set: a date without
  a time and an offset-bearing timestamp are now rejected, and the field
  descriptions say "UTC ISO 8601" rather than overpromising "ISO 8601".

- `v2BillingStatusQuerySchema` was the only non-strict query schema in its
  family, so a mis-cased `workspaceID` was stripped and the caller got
  account-scope billing in place of the workspace scope it asked for — a
  wrong answer about money, served as a 200.

- An unresolvable `cursor` on `/api/v2/billing/logs` applied no cursor
  condition and restarted the sequence at page 1 while still reporting
  `hasMore`, so a pager holding a cursor across a deploy loops over the
  first page and counts the same credits on every lap. It is now a 400.
  The message does not reuse `INVALID_CURSOR_MESSAGE`, which names
  `sortBy`/`sortOrder` params this collection does not accept.

- The logs `status` field disagrees with the run resources for the same
  run: the run projection overlays `paused` from `paused_executions`,
  so an ordinary human-in-the-loop pause reads `paused` there and
  `pending` here. Reconciling would mean joining `paused_executions` in
  this read and silently moving live runs between two buckets of a
  shipped field, so the divergence is documented on the contract instead.

* feat(v2): expose the MCP tool plane and page the MCP server list

Registering an MCP server through v2 dead-ended: nothing on the public
surface ever ran tool discovery, so connectionStatus, toolCount, lastError,
and lastToolsRefresh stayed at their registration defaults and there was no
way to read a server's tools without opening the UI.

Adds GET /api/v2/mcp-servers/{id}/tools over a thin use case composed from
the existing mcp_servers.tools.discover operation, resolveServerContext, and
mcpService.discoverServerTools. It is personal-API-key-only — discovery
resolves the acting user's own OAuth credentials, which a workspace key
cannot supply — and the contract says so rather than letting callers meet an
unexplained 403. Discovery failures are classified instead of collapsing
into a 500: an unreachable or cooling-down server is a retryable 503, a
stale OAuth grant is a 401.

Also pages GET /api/v2/mcp-servers. It was the one unbounded list on the v2
surface, classified full-set on a bounded-by-construction rationale that
only holds for folder lists; nothing caps how many servers a workspace
registers.

* feat(v2/tables): strict row bodies, a filtered row count, and round-trippable required columns

Three tables gaps from the v2 capability evaluation.

Strictness. Every v2 tables request body is now `.strict()`. The row family
was the whole hole: `POST /query` sent v1's `filter` key answered 200 with a
fully unfiltered page, because Zod strips unknown keys unless told not to. The
same laxity covered the row create/update/delete/upsert/find bodies, the
run and cancel-runs bodies, the enrichment body, and — outside the row family
but the same class — the column delete, view create/update, and export bodies.
A contract sweep now walks every body-bearing tables contract and fails if one
of them stops rejecting an unrecognized key.

Filtered row count. `POST /api/v2/tables/{tableId}/query/count` answers the
question v1's `includeTotal`/`totalCount` answered and the `{data, nextCursor}`
envelope has nowhere to put: how many rows a predicate matches. It binds the
existing `queryTableRows` use case with `includeTotal: true, limit: 1` — no new
domain logic and the same `tables.rows.query` read policy. The use case types
`totalCount` as nullable because paged callers can decline it; this route always
asks for it, so a null is treated as a broken invariant rather than presented as
a fabricated zero.

Required columns. `required` is accepted on create-table, add-column, and
update-column, matching v1. v2 emitted the flag on every read while stripping it
from every write, so a column could not round-trip. Enforcement was already
complete: turning it on over rows with null, missing, or empty cells is rejected
by the domain.

* test(skills): pin the workspace-API-key split as structural, not accidental

A workspace API key can create a skill it can then never update or delete,
which no sibling resource does — so the asymmetry reads like an oversight
worth widening. It is not. Skill edits are authorized by the per-skill
editor row belonging to the acting user, which is why update/upsert/delete
declare a 'read' floor rather than 'write': workspace role is not the
authority. A workspace key carries no user subject, so allowing one replaces
a 403 with an unclassified PrincipalSubjectUserRequiredError that the v2
surface renders as a caller-reachable 500.

Records the reason on the registry and pins it, so the next reader finds the
argument instead of flipping the flag.

* feat(v2): read deployment state, and undo a file delete

Two v2 reads that existed only as a side effect of a mutation.

`GET /api/v2/workflows/{id}/deployment` publishes the state the deploy,
undeploy, and rollback responses carry, plus `needsRedeployment` — which
those responses structurally cannot carry, because they answer at the
moment the draft and the live version are equal. A caller that lost the
mutation response, or that polls from another process, had no way to ask.
Reuses `readWorkflowDeploymentStatus` behind `workflows.read`, the same
use case the internal status and deploy GETs already adapt.

`DELETE /api/v2/files/{fileId}` was a soft delete with no way to see what
it archived and no way to reverse it. `GET /api/v2/files?scope=archived`
pages the archived set and `deletedAt` on the file resource dates each
one; `POST /api/v2/files/{fileId}/restore` reverses the delete through
the existing `files.restore` operation. Restore is not a pure undo — it
returns the file to the root and renames it on a collision — so the use
case now reads the file back and both the response and the OpenAPI
description say what actually came back rather than what was deleted.

`scope=all` is rejected on the list for the reason the internal contract
already gives: it drops the `deleted_at` predicate and cannot use the
partial index. `scope=archived` combined with `folderPath` 404s when the
containing folder was archived too, which the contract documents.

* fix(v2): keep the unresolvable-cursor rejection a 400 on every surface

The cursor rejection lived in shared billing core but was an OrchestrationError
only, which the session-only GET /api/users/me/usage-logs cannot project: that
route is raw withRouteHandler and readTypedError matches instanceof HttpError,
so any signed-in caller typing ?cursor=x got a 500. UnknownUsageCursorError is
an HttpError carrying the OrchestrationError as its cause, so the v2 route still
renders BAD_REQUEST off the cause chain and the internal route answers 400.

Also closes the other half of the run-list parity: an inverted window on
GET /api/v2/logs is now a 400 instead of a silently empty page.

* fix(v2/tables): sweep union bodies per member and name the shapes on a rows 400

Review follow-ups on the strictness work.

The sweep was vacuous on the one union body it covers. Parsing
`{ notAContractField: true }` against `v2CreateTableRowsBodySchema` and looking
for `unrecognized_keys` anywhere in the issue tree is satisfied by either member
alone, so dropping `.strict()` from the single-row branch shipped green —
reproduced, 36/36 passing with the regression in place. The sweep now flattens a
union body into its members and asserts each one separately; removing `.strict()`
from either branch now fails a case that names it.

`POST /rows` answered an unknown key with `Invalid input`, the exact message the
v2 conventions name as failing the actionable-error rule, because a union
surfaces `invalid_union` first. The union now carries a message naming both
accepted shapes; the per-member failures still ride along in `details`.

Two TSDoc corrections. The `required` docstring claimed the domain rejects
turning the flag on over rows with empty cells — true of the update path, false
of add-column, which applies the flag as given (the same shape `unique` already
had here). And `.strict()` binds the top level only, so the view `config` object
and the shared sort-spec elements still strip unknown keys; both docstrings now
say so instead of implying full coverage.

* fix(v2): classify MCP discovery failures by type, not by substring

The tool-discovery error policy consumed categorizeError's status, whose
fallback is a substring match on the upstream message. Three consequences,
all caller-visible:

- A ZodError from the builder's own response `.parse` contains `invalid_type`,
  so a Sim-side response-schema defect answered 400 "Invalid request
  parameters" and suppressed the builder's 500 and its unhandled-error log.
- An upstream `Invalid params` or `not found` became the caller's 400/404 on a
  request the contract had already validated.
- A stale OAuth grant to the third-party server answered 401, the status this
  surface reserves for a missing or invalid Sim API key, so a client would
  rotate a credential that was never the problem.

The policy now dispatches on the MCP error families and returns null for
anything else. Reauthorization is a 409 carrying
`details.code: MCP_SERVER_REAUTHORIZATION_REQUIRED`; an unreachable, slow, or
cooling-down server is a 503 with a constant message.

Also: widen the shared server path-param description now that it covers tool
listing, map the list query explicitly so no undeclared `cursor` reaches the
use-case input, and document the endpoint's write side effects.

* merge: bring in the MCP tool plane workstream

* feat(v2): make knowledge tags usable and let documents be updated

v2 accepted tag slots on upload and filtered search by tag display name,
but no response ever returned a tag value and nothing listed the
vocabulary, so a shipped feature dead-ended in the public API. A document
that failed processing could only be deleted and re-uploaded, and
retiring 500 documents cost 500 requests.

- GET /api/v2/knowledge/{id}/tags returns the vocabulary (display name,
  slot, field type) as a full-set list.
- Document list and detail responses carry `tags`, keyed by display name
  exactly as search keys its result metadata. Writes stay slot-keyed; the
  tags endpoint is the mapping and the contract documents the split.
- PATCH /api/v2/knowledge/{id}/documents/{documentId} renames, enables,
  disables, retags, or requeues processing. Derived indexing state is not
  writable: asserting `processingStatus` on an unindexed document would
  corrupt search. A retry may not ride along with field updates.
- PATCH /api/v2/knowledge/{id}/documents bulk-enables or bulk-disables.
  Bulk delete is deliberately absent — that operation records no semantic
  audit, and a public bulk delete would empty a knowledge base leaving no
  DOCUMENT_DELETED entries.
- The document list accepts the same name-based `tagFilters` as search;
  the name-to-slot resolver moves out of search into a shared helper, and
  the filters are stamped into the offset cursor scope so a replayed
  cursor cannot cross a filter change.
- Search accepts `rerankerEnabled`, `rerankerModel`, `rerankerInputCount`
  and returns `rerankerScore`; `rerankerApiKey` and `skipUsageBilling`
  stay unexposed. Every result now names its `knowledgeBaseId`.

knowledge.tags.list flips from workspaceApiKey 'deny' to 'allow' (and
gains the workspace_api_key principal kind) so it matches the sibling
reads knowledge.documents.list / read / search. The vocabulary is
required input for two operations a workspace key can already perform.
Every tag write stays human-delegated.

* fix(v2): name every 403 cause, unfork boolean params, close nested strictness holes

Four cross-cutting consistency gaps on the v2 public surface.

**403s now carry a machine-readable cause.** The conventions skill mandated
`error.details.code` on 403 and nothing emitted one, so a client had to
string-match prose to tell "raise this member's role" from "this workspace
refuses personal keys" from "buy an enterprise plan" — four different
remedies behind one status, and every message reword a silent break. The
vocabulary is a closed set, `FORBIDDEN_DETAIL_CODES`, with a `Record` of
descriptions beside it that the generated OpenAPI 403 description is built
from, so a code cannot reach the wire unpublished. Refusals throw
`ForbiddenOperationError` in the domain and `v2CaughtOrchestrationError` —
the function every v2 error policy falls through to — attaches the code, so a
route cannot forget it. The audit-log resolver distinguished four causes and
collapsed them into one; it now names each.

Cross-tenant refusals deliberately get no code: they are concealed as 404 and
naming their cause would hand back the existence signal the concealment
withholds.

**Two boolean query params rejoin the majority.** `?includeDeparted` and
`?includeOutput` were `'true'`/`'false'` string enums inherited from the
internal shapes they reused, while four sibling params were real booleans.
Both move to `booleanQueryFlagSchema`, which still coerces both strings — a
strict widening, so an existing caller is unaffected, and the spec stops
telling callers to send a string.

**Two nested strictness holes close.** `.strict()` binds the top level only,
so `sort: [{ field, direction, nulls: 'last' }]` was answered 200 with the
null-ordering request dropped, and an unknown key inside a saved view's
`config` was accepted and discarded — the headline `filter` bug one level
down. `sortSpecSchema`'s element and both view-config schemas are now strict.
Safe on the read side because `normalizeStoredViewConfig` projects the
schemaless stored blob onto the declared keys first, so a legacy row cannot
turn into a 500.

The two sort dialects stay as they are. `/logs` and `/workflows/{id}/runs`
have one sortable column, so there is no `sortBy` to pair with; renaming
`order` breaks every caller and an alias is a second spelling of one thing
with undefined precedence. Both contracts and the skill now state the rule.

* style: format the files the workspace-scoped lint gate does not reach

`turbo run lint:check` runs `biome check .` per workspace, so `scripts/` at the
repo root is outside the graph and four changed files were unformatted — one of
them a merge artifact from reconciling the route baseline across branches.

* fix(v2): collapse the four knowledge document projections onto one null-tolerant summary

Extracts toV2DocumentSummary in app/api/v2/knowledge/utils.ts and composes the
list, upload-acknowledgement and detail presenters from it. toV2TaggedDocument
serialized uploadedAt with a bare .toISOString(), so a document with no upload
timestamp threw where every sibling returned null and the contract declares the
field nullable.

Also consolidates the two Zod strictness walkers onto one shared introspection
helper that unwraps wrappers and expands unions, closing the hole where a
union-shaped schema answered null and was skipped by the pagination sweep.

* fix(v2): stop HEAD driving MCP discovery, and unbreak the updatedAt keyset page

B1: Next aliases HEAD onto GET, which RFC 9110 permits only because GET is safe.
The MCP tool-discovery GET is not: it opens a live connection to the registered
endpoint and writes the outcome onto the server row. The v2 JSON builder gains a
headSafe option, default true, and the discovery route declares itself unsafe —
a HEAD is authenticated and rate-limited, then answered bodiless.

B2: a discovery status write stamped updatedAt, which this branch added as a
keyset sort, so any concurrent discovery duplicated and skipped servers across a
caller's pages. Discovery liveness already has lastConnected, lastToolsRefresh,
lastError and statusConfig.

B4: a public refresh now skips the positive cache but keeps the failure cooldown,
so it cannot be used to drive a connection attempt per request at a failing
endpoint. An explicit user action on their own server keeps the full bypass.

B6: the consecutive-failure counter is incremented SQL-side rather than read,
incremented and written back, and the success branch carries the same workspace,
liveness and staleness guard the failure branch already had.

* fix(v2): bound the bulk update echo, close the search leak, and make the docs true

B3: a selectAll bulk document update echoed every changed identifier, which the
request does not bound — a 100k-document knowledge base produced a multi-megabyte
array, materialized and then element-wise validated. The use case now reports
whether the selection was unbounded and the presenter omits the echo.

A1: the knowledge search presenter spread the whole use-case result, which also
carries userId, workspaceId, a cost breakdown and a live secret-trace registry.
Only Zod's default key-stripping kept them off the wire. Projected explicitly.

P1-a: GET /knowledge/{id}/tags advertised all 17 slots while the document PATCH
accepted only the seven text ones. The writer already coerces every slot type,
so the PATCH now takes all 17 in their declared types, with a 400 where a
malformed value used to silently clear the tag.

P1-b: both new PATCHes deny workspace API keys and now say so.
P1-c: the two table query reads declare maxBodyBytes and now document the 413.
P1-d: getWorkflowDeploymentV2 loses its legacy suffix.

C3: deletes two orchestration error mappers with no callers that mapped
'forbidden' with no details.
D2: a stored null in table_views.config survived the pick and failed the
response schema.

Also folds the six 'bounded set' paraphrases onto one FULL_SET_LIST constant,
shares the run-window date bound between the logs and runs lists so their
documented parity is enforced rather than asserted, adds the missing barrel
export for FORBIDDEN_DETAIL_CODE_DESCRIPTIONS, and strictens two response
schemas whose peers were already strict.

Migrates 40 v2 route tests onto the shared @sim/testing harness: 26 asserted a
rateLimitSubjectIds shape v2 auth never returns, 26 asserted the wrong
refillRate, 33 could not exercise their 401 path at all, and 6 hard-wired the
rollout gate to null.

* fix(mcp): bound the connect handshake, and stop the 403 description over-claiming

B5: the connect clamp was getMaxExecutionTimeout(), the workflow ceiling of
seven days, so the real bound became the server row's own timeout — which the
registration contract permits up to 300s — times the connect retries. A slow
server could hold a Node request for roughly twenty minutes. Connecting is not a
workflow run, so the handshake now shares the one-minute ceiling tools/list
already applies to itself.

C2: the generated 403 description asserted that error.details.code names the
cause on every 403. Nine domain refusals still throw a bare forbidden
OrchestrationError and reach the wire codeless, so the wording now says 'where
the cause is one a caller can act on'. Reparenting those throws is left as a
deliberate change: one of them is a cross-tenant refusal that belongs in the
codeless class and would change its status.

* chore: reconcile the route ratchet with staging

* style: sort imports and format the three files biome flagged

* fix(openapi): import the forbidden-code constants from their module, not the application barrel

The barrel also re-exports the authorized use-case layer, which loads
@sim/db at import time. That pulled a database connection into the
OpenAPI spec check, so check:audits failed wherever DATABASE_URL is
absent, including CI.
This commit is contained in:
Waleed
2026-08-12 15:04:24 -07:00
committed by GitHub
parent 128054ea51
commit 1fa40b8118
142 changed files with 8486 additions and 1847 deletions
+18 -4
View File
@@ -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.
+18 -4
View File
@@ -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.
+18 -4
View File
@@ -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.
@@ -9,6 +9,7 @@
"getWorkflowVersionV2",
"exportWorkflow",
"importWorkflow",
"getWorkflowDeployment",
"deployWorkflow",
"undeployWorkflow",
"rollbackWorkflow",
+1 -1
View File
@@ -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": {
+180 -9
View File
@@ -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",
+751 -31
View File
@@ -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,12 +2800,308 @@
"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."
},
"V2KnowledgeTag": {
"type": "object",
"properties": {
"displayName": {
"type": "string",
"description": "Display name used by tag filters and by tag values on document reads.",
"examples": ["category"]
},
"tagSlot": {
"type": "string",
"description": "Storage slot the tag occupies. Document writes set tag values by slot (`tag1`..`tag7`).",
"examples": ["tag1"]
},
"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"]
},
"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"]
},
"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": [
"id",
"knowledgeBaseId",
"filename",
"fileSize",
"mimeType",
"processingStatus",
"chunkCount",
"tokenCount",
"characterCount",
"enabled",
"createdAt",
"tags"
],
"additionalProperties": false,
"title": "Knowledge document list item",
"description": "Document summary with the document tag values keyed by display name."
},
"V2KnowledgeDocumentListResponse": {
"type": "object",
"properties": {
"data": {
"type": "array",
"items": {
"$ref": "#/components/schemas/V2KnowledgeTaggedDocument"
},
"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 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": {
@@ -2632,33 +3187,6 @@
"title": "Knowledge document summary",
"description": "Summary returned by document lists and upload acknowledgements."
},
"V2KnowledgeDocumentListResponse": {
"type": "object",
"properties": {
"data": {
"type": "array",
"items": {
"$ref": "#/components/schemas/V2KnowledgeDocumentSummary"
},
"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 document list response",
"description": "A cursor-paginated page of knowledge documents."
},
"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": {
+13 -9
View File
@@ -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",
+233 -8
View File
@@ -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": {
+183 -10
View File
@@ -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": [
+191 -9
View File
@@ -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": {
+1 -1
View File
@@ -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)
@@ -196,7 +196,7 @@ export const POST = withRouteHandler(
userId,
serverId,
workspaceId,
true
'force'
)
logger.info(
`[${requestId}] Discovered ${discoveredTools.length} tools from server ${serverId}`
+12 -3
View File
@@ -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<string, number> = {}
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 }
}
)
+1 -1
View File
@@ -157,7 +157,7 @@ export const POST = withRouteHandler(
userId,
serverId,
workspaceId,
false,
'cache-aside',
recordProvenance
)
tool = tools.find((t) => t.name === toolName) ?? null
@@ -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)
})
})
+2 -2
View File
@@ -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.
@@ -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')
@@ -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')
@@ -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()
)
@@ -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,
},
})
@@ -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<string, string>, 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')
})
})
@@ -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) }),
})
@@ -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<string, unknown> = {}) {
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' },
},
})
})
@@ -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 })
@@ -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)
+25 -45
View File
@@ -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,
}
})
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', () => ({
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,
}),
}))
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()
})
})
+39 -36
View File
@@ -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')
+63 -34
View File
@@ -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()
})
+1
View File
@@ -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,
@@ -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' },
},
})
})
})
+34 -33
View File
@@ -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<string, unknown>) {
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 })
)
+1
View File
@@ -30,6 +30,7 @@ function serializeV2File(record: WorkspaceFileRecord, uploadedByEmail: string):
uploadedByEmail,
uploadedAt: record.uploadedAt.toISOString(),
updatedAt: record.updatedAt.toISOString(),
deletedAt: record.deletedAt?.toISOString() ?? null,
}
}
@@ -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()
})
})
@@ -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<V2UpdateKnowledgeDocumentBody, 'workspaceId' | 'retryProcessing'>
type UpdateKnowledgeDocumentUpdates = NonNullable<UpdateKnowledgeDocumentInput['updates']>
/**
* 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<string, string> = {}
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,
@@ -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()
})
})
@@ -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,
@@ -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,
@@ -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,
}
}
@@ -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
)
})
})
@@ -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,
}),
})
@@ -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(
+19 -1
View File
@@ -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,
},
}),
})
@@ -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',
})
})
})
+108 -1
View File
@@ -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<Record<AllTagSlot, unknown>>,
tagDefinitions: readonly DocumentTagDefinition[]
): Record<string, string | number | boolean | null> {
const displayNameBySlot = new Map(
tagDefinitions.map((definition) => [definition.tagSlot, definition.displayName])
)
const tags: Record<string, string | number | boolean | null> = {}
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<Record<AllTagSlot, unknown>> {}
/** 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
+27 -1
View File
@@ -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<string, string> {
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<T>(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)
)
}
+61
View File
@@ -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'))
@@ -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,
}
})
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', () => ({
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,
}),
}))
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()
})
+2 -11
View File
@@ -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,
@@ -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()
})
})
@@ -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-<hash>` 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 }),
})
+107 -46
View File
@@ -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,
}
})
const mocks = vi.hoisted(() => ({
list: vi.fn(),
create: vi.fn(),
capture: vi.fn(),
}))
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,
}),
}))
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()
})
})
+15 -2
View File
@@ -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. */
+83 -18
View File
@@ -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
+1 -26
View File
@@ -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')
}
}
@@ -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')
})
})
@@ -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')
})
})
@@ -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')
})
})
@@ -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')
})
})
@@ -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,
@@ -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')
})
})
@@ -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 } }
},
})
@@ -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')
})
})
@@ -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' }),
@@ -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
@@ -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)
@@ -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')
})
})
@@ -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')
@@ -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',
@@ -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')
})
})
@@ -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')
})
})
@@ -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')
})
})
@@ -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')
})
})
@@ -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')
})
})
+54 -31
View File
@@ -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()
})
@@ -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')
})
})
@@ -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,
},
}),
})
@@ -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}`
@@ -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')
})
})
@@ -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,
const mocks = vi.hoisted(() => ({
cancel: vi.fn(),
capture: vi.fn(),
readRun: vi.fn(),
}))
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/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',
@@ -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')
})
})
@@ -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')
})
})
@@ -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')
})
})
+30 -33
View File
@@ -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')
})
})
+39 -18
View File
@@ -551,13 +551,23 @@ export const predicateInputSchema = predicateBoundarySchema
'Recursive predicate condition or group, normalized to a grouped predicate after validation.'
) as z.ZodType<TablePredicate, PredicateNode>
/** 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<SortSpec> = 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<TableViewConfig>
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<TableViewConfig>
export const tableViewSchema = z.object({
id: z.string(),
@@ -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<string, unknown> | 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<string, unknown> | undefined)?.[field]).toBe(true)
const omitted = schema?.safeParse(
field === 'includeDeparted' ? { organizationId: 'org-1' } : {}
)
expect((omitted?.data as Record<string, unknown> | 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)
})
})
@@ -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)
}
})
})
@@ -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<string, unknown> }).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.
*
@@ -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')
})
})
@@ -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<string, unknown>
safeParse: (value: unknown) => { success: boolean; error?: { issues: readonly unknown[] } }
}
function schemaDef(schema: unknown): Record<string, unknown> | undefined {
return (schema as { def?: Record<string, unknown> } | 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[]))
: []),
]
})
}
@@ -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<string, unknown>
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,
+12 -4
View File
@@ -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.'
+32 -11
View File
@@ -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.'),
+41
View File
@@ -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<typeof v2FileScopeSchema>
/**
* 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<typeof v2RestoreFileBodySchema>
export type V2RenameFileBody = z.input<typeof v2RenameFileBodySchema>
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',
+465 -5
View File
@@ -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<typeof v2KnowledgeDocumentSummarySchema>
/**
* 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<typeof v2KnowledgeTaggedDocumentSchema>
/**
* 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<typeof v2KnowledgeDocumentSchema>
*/
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<typeof v2KnowledgeSearchBodySchema>
@@ -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<typeof v2KnowledgeDocumentTagFiltersSchema>
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<typeof v2ListKnowledgeDocumentsQuerySchema>
@@ -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<typeof v2KnowledgeTagSchema>
/**
* 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<keyof typeof v2UpdateKnowledgeDocumentTagSlotFields>
/**
* 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<typeof v2UpdateKnowledgeDocumentBodySchema>
/** 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<typeof v2BulkKnowledgeDocumentsBodySchema>
/** 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]',
+45 -10
View File
@@ -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',
+88 -5
View File
@@ -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<typeof v2McpServerDeleteDataSchema>
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<typeof v2McpServerParamsSchema>
@@ -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<typeof v2UpdateMcpServerBodySchema>
/**
* 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<typeof v2McpToolSchema>
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<typeof v2ListMcpServerToolsQuerySchema>
/**
* 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),
},
})
@@ -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.' },
}),
@@ -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.' },
}),
@@ -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', {

Some files were not shown because too many files have changed in this diff Show More